From 9788f3111078f14d9aa64d4c70d520d5c4ddbfe3 Mon Sep 17 00:00:00 2001 From: probe Date: Tue, 29 Sep 2026 22:52:13 +0800 Subject: [PATCH 001/666] feat(sqlserver): add metadata and transaction base --- docs/architecture/backend/drivers.md | 3 + packages/drivers/sqlserver/src/lib.rs | 2 + packages/drivers/sqlserver/src/metadata.rs | 616 ++++++++++++++++++ packages/drivers/sqlserver/src/parameters.rs | 79 +++ packages/drivers/sqlserver/src/sqlserver.rs | 519 +++++++++++---- .../drivers/sqlserver/src/sync_adapter.rs | 8 +- .../sqlserver/tests/live_query_types.rs | 49 +- .../sqlserver/tests/live_schema_metadata.rs | 122 +++- .../sqlserver/tests/live_write_and_ddl.rs | 156 ++++- 9 files changed, 1424 insertions(+), 130 deletions(-) create mode 100644 packages/drivers/sqlserver/src/metadata.rs create mode 100644 packages/drivers/sqlserver/src/parameters.rs diff --git a/docs/architecture/backend/drivers.md b/docs/architecture/backend/drivers.md index 2aa0900a4..917285e35 100644 --- a/docs/architecture/backend/drivers.md +++ b/docs/architecture/backend/drivers.md @@ -86,6 +86,9 @@ Host **不拼任何方言分页子句**:每次读取都向驱动要 `paginatio | 批处理专用语句 | `CREATE`/`ALTER` `SCHEMA`·`VIEW`·`PROCEDURE`·`FUNCTION`·`TRIGGER`、会话级 `SET`(`IDENTITY_INSERT`、`SHOWPLAN_TEXT`)与 `BEGIN TRAN`/`COMMIT`/`ROLLBACK` 必须经 `Client::simple_query` 作为真实 batch 发出;走 `sp_executesql` RPC 会分别报 156 / 544 / 266 | | EXPLAIN | `SET SHOWPLAN_TEXT ON` + 语句 + `SET SHOWPLAN_TEXT OFF` 整批执行;经 RPC 传入会**实际执行**被分析的语句 | | 时间值 | `date` / `time` / `datetime2` 输出精确文本,`datetimeoffset` 输出带偏移的 RFC3339;不发 Rust Debug 形式(`Date(…)` / `Time { increments: … }`) | +| 参数 | `query_with_params` / `execute_with_params` 通过 Tiberius TDS 参数绑定传值,使用 `@P1` 到 `@P2100`;批处理专用 SQL 不接受参数并显式返回 Unsupported,SQL 文本不会拼入参数值 | +| 结构元数据 | 目录读取保留声明类型长度/精度、主键/索引/外键列顺序、外键动作、CHECK 表达式和列注释;无法由公共模型等价表达的计算/生成列、特殊索引及禁用或不可信约束会明确拒绝,避免静默返回不完整结构。Data Transfer 当前仍拒绝二级索引、外键和 CHECK,避免这些对象在传输计划中丢失 | +| 事务与读取快照 | `begin_transaction`、`commit`、`rollback` 在同一 TDS session 上执行;`begin_read_snapshot` 保存原隔离级别,在 `ALLOW_SNAPSHOT_ISOLATION` 可用时开始 SNAPSHOT 事务,并在提交/回滚后恢复隔离级别。失败的事务收尾会丢弃状态不确定的 session | | 语句切分 | 多语句按引号/注释感知的扫描器切分(`driver-api::sql_split`),不使用裸 `;` | | 服务端拒绝的写法 | `FETCH NEXT 0 ROWS ONLY`(10744)、缺少 `ORDER BY` 的 `OFFSET/FETCH`(102)。分页控件对 0 行页给出诊断而不是丢弃子句 | | 平台限制 | Azure SQL Database:`BACKUP`/`RESTORE` 40510、`msdb` 跨库名 40515、`CREATE LOGIN` 需 master(5001)、serverless 自动暂停首次登录 40613(需退避重试) | diff --git a/packages/drivers/sqlserver/src/lib.rs b/packages/drivers/sqlserver/src/lib.rs index 52cc58870..e715141bc 100644 --- a/packages/drivers/sqlserver/src/lib.rs +++ b/packages/drivers/sqlserver/src/lib.rs @@ -5,6 +5,8 @@ use std::sync::Arc; use datazen_driver_api::*; mod admin_commands; +mod metadata; +mod parameters; mod sql_target; mod sqlserver; mod structure; diff --git a/packages/drivers/sqlserver/src/metadata.rs b/packages/drivers/sqlserver/src/metadata.rs new file mode 100644 index 000000000..74a651db8 --- /dev/null +++ b/packages/drivers/sqlserver/src/metadata.rs @@ -0,0 +1,616 @@ +//! SQL Server catalog reads used by schema diff, sync and transfer. + +use datazen_driver_api::{ + CheckConstraint, ColumnSchema, DriverError, ForeignKeyDeferrability, ForeignKeyInfo, IndexInfo, + QueryResult, Value, +}; +use std::collections::HashMap; + +fn catalog_prefix(database: &str) -> String { + let database = database.trim(); + if database.is_empty() { + String::new() + } else { + format!("[{}].", database.replace(']', "]]")) + } +} + +fn selected_table(catalog: &str) -> String { + format!( + "JOIN {catalog}sys.tables t ON t.object_id = o.object_id \ + JOIN {catalog}sys.schemas s ON s.schema_id = t.schema_id \ + WHERE s.name = @P1 AND t.name = @P2" + ) +} + +pub(crate) fn columns_sql(database: &str) -> String { + let catalog = catalog_prefix(database); + format!( + "SELECT c.name AS column_name, \ + CASE \ + WHEN tp.is_user_defined = 1 THEN QUOTENAME(tp_schema.name) + N'.' + QUOTENAME(tp.name) \ + WHEN tp.name IN ('nvarchar', 'nchar') THEN CONCAT(tp.name, '(', CASE WHEN c.max_length = -1 THEN 'max' ELSE CONVERT(varchar(10), c.max_length / 2) END, ')') \ + WHEN tp.name IN ('varchar', 'char', 'varbinary', 'binary') THEN CONCAT(tp.name, '(', CASE WHEN c.max_length = -1 THEN 'max' ELSE CONVERT(varchar(10), c.max_length) END, ')') \ + WHEN tp.name IN ('decimal', 'numeric') THEN CONCAT(tp.name, '(', c.precision, ',', c.scale, ')') \ + WHEN tp.name IN ('time', 'datetime2', 'datetimeoffset') THEN CONCAT(tp.name, '(', c.scale, ')') \ + WHEN tp.name = 'float' THEN CONCAT(tp.name, '(', c.precision, ')') \ + ELSE tp.name \ + END AS data_type, \ + c.is_nullable, c.is_identity, dc.definition AS default_value, \ + CAST(ep.value AS nvarchar(max)) AS comment, \ + CAST(CASE WHEN pk.column_id IS NULL THEN 0 ELSE 1 END AS bit) AS is_pk, \ + ISNULL(pk.key_ordinal, 0) AS pk_ordinal, \ + CAST(CASE WHEN cc.object_id IS NULL THEN 0 ELSE 1 END AS bit) AS is_computed, \ + c.generated_always_type, c.is_filestream, c.is_sparse, c.is_column_set, \ + CAST(CASE WHEN c.default_object_id <> 0 AND dc.object_id IS NULL THEN 1 ELSE 0 END AS bit) AS has_legacy_default, \ + CAST(CASE WHEN tp.name IN ('timestamp', 'rowversion') THEN 1 ELSE 0 END AS bit) AS is_rowversion, \ + CAST(CASE WHEN ic.object_id IS NOT NULL AND \ + (TRY_CONVERT(decimal(38,0), ic.seed_value) <> 1 OR TRY_CONVERT(decimal(38,0), ic.increment_value) <> 1) \ + THEN 1 ELSE 0 END AS bit) AS has_nondefault_identity \ + FROM {catalog}sys.columns c \ + JOIN {catalog}sys.objects o ON o.object_id = c.object_id \ + JOIN {catalog}sys.types tp ON tp.user_type_id = c.user_type_id \ + LEFT JOIN {catalog}sys.schemas tp_schema ON tp_schema.schema_id = tp.schema_id \ + JOIN {catalog}sys.schemas s ON s.schema_id = o.schema_id \ + LEFT JOIN {catalog}sys.default_constraints dc ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id \ + LEFT JOIN {catalog}sys.extended_properties ep ON ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = 'MS_Description' \ + LEFT JOIN {catalog}sys.computed_columns cc ON cc.object_id = c.object_id AND cc.column_id = c.column_id \ + LEFT JOIN {catalog}sys.identity_columns ic ON ic.object_id = c.object_id AND ic.column_id = c.column_id \ + LEFT JOIN ( \ + SELECT ix.object_id, ix.column_id, ix.key_ordinal \ + FROM {catalog}sys.index_columns ix \ + JOIN {catalog}sys.indexes i ON i.object_id = ix.object_id AND i.index_id = ix.index_id \ + WHERE i.is_primary_key = 1 AND ix.key_ordinal > 0 \ + ) pk ON pk.object_id = c.object_id AND pk.column_id = c.column_id \ + WHERE s.name = @P1 AND o.name = @P2 AND o.type IN ('U', 'V') \ + ORDER BY c.column_id" + ) +} + +pub(crate) fn indexes_sql(database: &str) -> String { + let catalog = catalog_prefix(database); + format!( + "SELECT i.index_id, i.name, i.is_unique, i.is_primary_key, i.type_desc, \ + i.is_unique_constraint, i.has_filter, i.is_disabled, i.is_hypothetical, \ + ic.key_ordinal, c.name AS column_name, ic.is_included_column, ic.is_descending_key, \ + ds.type AS data_space_type \ + FROM {catalog}sys.indexes i \ + JOIN {catalog}sys.objects o ON o.object_id = i.object_id \ + JOIN {catalog}sys.tables t ON t.object_id = o.object_id \ + JOIN {catalog}sys.schemas s ON s.schema_id = t.schema_id \ + LEFT JOIN {catalog}sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id \ + LEFT JOIN {catalog}sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id \ + LEFT JOIN {catalog}sys.data_spaces ds ON ds.data_space_id = i.data_space_id \ + WHERE s.name = @P1 AND t.name = @P2 AND i.index_id > 0 \ + ORDER BY i.index_id, ic.key_ordinal, ic.index_column_id" + ) +} + +pub(crate) fn foreign_keys_sql(database: &str) -> String { + let catalog = catalog_prefix(database); + format!( + "SELECT fk.name, fkc.constraint_column_id, pc.name AS parent_column, \ + rs.name AS referenced_schema, rt.name AS referenced_table, rc.name AS referenced_column, \ + fk.update_referential_action_desc, fk.delete_referential_action_desc, \ + fk.is_disabled, fk.is_not_trusted, fk.is_not_for_replication \ + FROM {catalog}sys.foreign_keys fk \ + JOIN {catalog}sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id \ + JOIN {catalog}sys.tables t ON t.object_id = fk.parent_object_id \ + JOIN {catalog}sys.schemas s ON s.schema_id = t.schema_id \ + JOIN {catalog}sys.columns pc ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id \ + JOIN {catalog}sys.tables rt ON rt.object_id = fk.referenced_object_id \ + JOIN {catalog}sys.schemas rs ON rs.schema_id = rt.schema_id \ + JOIN {catalog}sys.columns rc ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id \ + WHERE s.name = @P1 AND t.name = @P2 \ + ORDER BY fk.object_id, fkc.constraint_column_id" + ) +} + +pub(crate) fn checks_sql(database: &str) -> String { + let catalog = catalog_prefix(database); + let selected = selected_table(&catalog); + format!( + "SELECT cc.name, cc.definition, cc.is_disabled, cc.is_not_trusted, cc.is_not_for_replication \ + FROM {catalog}sys.check_constraints cc \ + JOIN {catalog}sys.objects o ON o.object_id = cc.parent_object_id \ + {selected} \ + ORDER BY cc.name" + ) +} + +pub(crate) fn parse_columns( + result: QueryResult, +) -> Result<(Vec, Vec), DriverError> { + let mut columns = Vec::with_capacity(result.rows.len()); + let mut key_columns = Vec::new(); + for row in result.rows { + let name = required_text(&row, 0, "column name")?; + let data_type = required_text(&row, 1, "declared column type")?; + let is_primary_key = required_bool(&row, 6, "primary key flag")?; + let key_ordinal = required_integer(&row, 7, "primary key ordinal")?; + for (index, reason) in [ + (8, "computed columns"), + (9, "generated-always columns"), + (10, "FILESTREAM columns"), + (11, "sparse columns"), + (12, "column-set columns"), + (14, "rowversion columns"), + (15, "non-default IDENTITY seed/increment"), + ] { + if optional_bool(&row, index) == Some(true) { + return Err(unsupported(format!( + "SQL Server table contains {reason}; this metadata contract cannot represent it safely" + ))); + } + } + if optional_bool(&row, 13) == Some(true) { + return Err(unsupported( + "SQL Server table uses a legacy bound DEFAULT; its expression is not represented by sys.default_constraints", + )); + } + if is_primary_key { + if key_ordinal <= 0 { + return Err(incomplete("primary key column has no key ordinal")); + } + key_columns.push((key_ordinal, name.clone())); + } + columns.push(ColumnSchema { + name, + data_type, + nullable: required_bool(&row, 2, "nullability")?, + default_value: optional_text(&row, 4), + comment: optional_text(&row, 5), + is_primary_key, + is_auto_increment: required_bool(&row, 3, "identity flag")?, + }); + } + key_columns.sort_by_key(|(ordinal, _)| *ordinal); + Ok(( + columns, + key_columns.into_iter().map(|(_, column)| column).collect(), + )) +} + +pub(crate) fn parse_indexes(result: QueryResult) -> Result, DriverError> { + #[derive(Default)] + struct PendingIndex { + name: String, + is_unique: bool, + is_primary: bool, + index_type: String, + columns: Vec<(i64, String)>, + } + + let mut ordered = Vec::new(); + let mut indexes = HashMap::::new(); + for row in result.rows { + let id = required_integer(&row, 0, "index id")?; + let name = required_text(&row, 1, "index name")?; + let unique = required_bool(&row, 2, "index uniqueness")?; + let primary = required_bool(&row, 3, "primary index flag")?; + let type_desc = required_text(&row, 4, "index type")?; + let unique_constraint = required_bool(&row, 5, "unique constraint flag")?; + if required_bool(&row, 6, "filtered index flag")? + || required_bool(&row, 7, "disabled index flag")? + || required_bool(&row, 8, "hypothetical index flag")? + { + return Err(unsupported(format!( + "SQL Server index '{name}' is filtered, disabled, or hypothetical and cannot be represented safely" + ))); + } + let data_space = optional_text(&row, 13).unwrap_or_default(); + if data_space.eq_ignore_ascii_case("PS") { + return Err(unsupported(format!( + "SQL Server index '{name}' uses a partition scheme that is not represented by the migration model" + ))); + } + let supported_type = matches!(type_desc.as_str(), "CLUSTERED" | "NONCLUSTERED"); + if !supported_type { + return Err(unsupported(format!( + "SQL Server index '{name}' has unsupported type '{type_desc}'" + ))); + } + let key_ordinal = optional_integer(&row, 9).unwrap_or(0); + let column = optional_text(&row, 10); + let included = optional_bool(&row, 11).unwrap_or(false); + let descending = optional_bool(&row, 12).unwrap_or(false); + if included || descending { + return Err(unsupported(format!( + "SQL Server index '{name}' has INCLUDE or descending key columns that are not represented by IndexInfo" + ))); + } + let index = indexes.entry(id).or_insert_with(|| { + ordered.push(id); + PendingIndex { + name: name.clone(), + is_unique: unique, + is_primary: primary, + index_type: if unique_constraint { + format!("UNIQUE_CONSTRAINT:{type_desc}") + } else { + type_desc.clone() + }, + columns: Vec::new(), + } + }); + if index.name != name || index.is_unique != unique || index.is_primary != primary { + return Err(incomplete(format!( + "SQL Server index id {id} returned conflicting catalog rows" + ))); + } + if key_ordinal > 0 { + let column = column.ok_or_else(|| incomplete("index key column is missing"))?; + index.columns.push((key_ordinal, column)); + } + } + + let mut parsed = Vec::with_capacity(ordered.len()); + for id in ordered { + let mut index = indexes + .remove(&id) + .ok_or_else(|| incomplete(format!("index id {id} disappeared while parsing")))?; + index.columns.sort_by_key(|(ordinal, _)| *ordinal); + if index.columns.is_empty() { + return Err(incomplete(format!( + "SQL Server index '{}' has no representable key columns", + index.name + ))); + } + parsed.push(IndexInfo { + name: index.name, + columns: index.columns.into_iter().map(|(_, name)| name).collect(), + is_unique: index.is_unique, + is_primary: index.is_primary, + index_type: index.index_type, + }); + } + Ok(parsed) +} + +pub(crate) fn parse_foreign_keys(result: QueryResult) -> Result, DriverError> { + #[derive(Default)] + struct PendingForeignKey { + columns: Vec<(i64, String, String)>, + referenced_table: Option, + on_update: Option, + on_delete: Option, + } + + let mut ordered = Vec::new(); + let mut keys = HashMap::::new(); + for row in result.rows { + let name = required_text(&row, 0, "foreign key name")?; + if required_bool(&row, 8, "foreign key disabled state")? + || required_bool(&row, 9, "foreign key trust state")? + || required_bool(&row, 10, "foreign key replication state")? + { + return Err(unsupported(format!( + "SQL Server foreign key '{name}' is disabled, untrusted, or NOT FOR REPLICATION" + ))); + } + let ordinal = required_integer(&row, 1, "foreign key column ordinal")?; + let local_column = required_text(&row, 2, "foreign key local column")?; + let ref_schema = required_text(&row, 3, "foreign key referenced schema")?; + let ref_table = required_text(&row, 4, "foreign key referenced table")?; + let ref_column = required_text(&row, 5, "foreign key referenced column")?; + let referenced_table = format!( + "[{}].[{}]", + ref_schema.replace(']', "]]"), + ref_table.replace(']', "]]"), + ); + let on_update = normalize_action(&required_text(&row, 6, "foreign key update action")?); + let on_delete = normalize_action(&required_text(&row, 7, "foreign key delete action")?); + let key = keys.entry(name.clone()).or_insert_with(|| { + ordered.push(name.clone()); + PendingForeignKey { + referenced_table: Some(referenced_table.clone()), + on_update: Some(on_update.clone()), + on_delete: Some(on_delete.clone()), + ..PendingForeignKey::default() + } + }); + if key.referenced_table.as_deref() != Some(referenced_table.as_str()) + || key.on_update.as_deref() != Some(on_update.as_str()) + || key.on_delete.as_deref() != Some(on_delete.as_str()) + { + return Err(incomplete(format!( + "SQL Server foreign key '{name}' returned conflicting catalog rows" + ))); + } + key.columns.push((ordinal, local_column, ref_column)); + } + + let mut parsed = Vec::with_capacity(ordered.len()); + for name in ordered { + let mut key = keys + .remove(&name) + .ok_or_else(|| incomplete(format!("foreign key '{name}' disappeared while parsing")))?; + key.columns.sort_by_key(|(ordinal, _, _)| *ordinal); + if key.columns.is_empty() { + return Err(incomplete(format!( + "SQL Server foreign key '{name}' has no columns" + ))); + } + parsed.push(ForeignKeyInfo { + name, + columns: key + .columns + .iter() + .map(|(_, local, _)| local.clone()) + .collect(), + referenced_table: key + .referenced_table + .ok_or_else(|| incomplete("foreign key referenced table is missing"))?, + referenced_columns: key + .columns + .into_iter() + .map(|(_, _, remote)| remote) + .collect(), + on_update: key + .on_update + .ok_or_else(|| incomplete("foreign key update action is missing"))?, + on_delete: key + .on_delete + .ok_or_else(|| incomplete("foreign key delete action is missing"))?, + deferrability: ForeignKeyDeferrability::NotDeferrable, + }); + } + Ok(parsed) +} + +pub(crate) fn parse_checks(result: QueryResult) -> Result, DriverError> { + result + .rows + .into_iter() + .map(|row| { + let name = required_text(&row, 0, "CHECK constraint name")?; + if required_bool(&row, 2, "CHECK disabled state")? + || required_bool(&row, 3, "CHECK trust state")? + || required_bool(&row, 4, "CHECK replication state")? + { + return Err(unsupported(format!( + "SQL Server CHECK constraint '{name}' is disabled, untrusted, or NOT FOR REPLICATION" + ))); + } + Ok(CheckConstraint { + name, + expression: required_text(&row, 1, "CHECK expression")?, + }) + }) + .collect() +} + +fn normalize_action(value: &str) -> String { + value.replace('_', " ").to_ascii_uppercase() +} + +fn value_at<'a>(row: &'a [Option], index: usize) -> Option<&'a Value> { + row.get(index).and_then(Option::as_ref) +} + +fn display(value: &Value) -> String { + match value { + Value::String(value) | Value::Timestamp(value) => value.clone(), + Value::Integer(value) => value.to_string(), + Value::Float(value) => value.to_string(), + Value::Bool(value) => value.to_string(), + Value::Bytes(value) => String::from_utf8_lossy(value).into_owned(), + Value::Json(value) => value.to_string(), + Value::Null => String::new(), + } +} + +fn required_text(row: &[Option], index: usize, label: &str) -> Result { + value_at(row, index) + .map(display) + .filter(|value| !value.is_empty()) + .ok_or_else(|| incomplete(format!("catalog row omitted {label}"))) +} + +fn optional_text(row: &[Option], index: usize) -> Option { + value_at(row, index).map(display) +} + +fn optional_bool(row: &[Option], index: usize) -> Option { + match value_at(row, index) { + Some(Value::Bool(value)) => Some(*value), + Some(Value::Integer(value)) => Some(*value != 0), + Some(Value::String(value)) => match value.as_str() { + "1" | "true" | "TRUE" => Some(true), + "0" | "false" | "FALSE" => Some(false), + _ => None, + }, + _ => None, + } +} + +fn required_bool(row: &[Option], index: usize, label: &str) -> Result { + optional_bool(row, index).ok_or_else(|| incomplete(format!("catalog row omitted {label}"))) +} + +fn optional_integer(row: &[Option], index: usize) -> Option { + match value_at(row, index) { + Some(Value::Integer(value)) => Some(*value), + Some(Value::Float(value)) => Some(*value as i64), + Some(Value::String(value)) => value.parse().ok(), + _ => None, + } +} + +fn required_integer(row: &[Option], index: usize, label: &str) -> Result { + optional_integer(row, index).ok_or_else(|| incomplete(format!("catalog row omitted {label}"))) +} + +fn unsupported(message: impl Into) -> DriverError { + DriverError::Unsupported(message.into()) +} + +fn incomplete(message: impl Into) -> DriverError { + DriverError::QueryFailed(format!( + "SQL Server returned incomplete schema metadata: {}", + message.into() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn result(rows: Vec>>) -> QueryResult { + QueryResult { + columns: Vec::new(), + rows, + rows_affected: None, + execution_time_ms: 0, + } + } + + fn text(value: &str) -> Option { + Some(Value::String(value.into())) + } + + fn bit(value: bool) -> Option { + Some(Value::Bool(value)) + } + + #[test] + fn metadata_queries_quote_catalog_and_bind_object_names() { + let columns = columns_sql("db]"); + assert!(columns.contains("FROM [db]]].sys.columns")); + assert!(columns.contains("QUOTENAME(tp_schema.name)")); + assert!(columns.contains("[db]]].sys.schemas tp_schema")); + assert!(columns.contains("s.name = @P1 AND o.name = @P2")); + assert!(!columns.contains("@P1'")); + assert!(indexes_sql("db").contains("ic.key_ordinal")); + assert!(foreign_keys_sql("db").contains("fkc.constraint_column_id")); + assert!(checks_sql("db").contains("cc.is_not_trusted")); + } + + #[test] + fn composite_primary_key_keeps_catalog_key_order() { + let mut a = vec![ + text("first"), + text("int"), + bit(false), + bit(false), + None, + None, + bit(true), + Some(Value::Integer(2)), + bit(false), + Some(Value::Integer(0)), + bit(false), + bit(false), + bit(false), + bit(false), + bit(false), + bit(false), + ]; + let mut b = a.clone(); + a[0] = text("a"); + b[0] = text("z"); + a[7] = Some(Value::Integer(2)); + b[7] = Some(Value::Integer(1)); + let (columns, keys) = parse_columns(result(vec![a, b])).expect("parse columns"); + assert_eq!(columns.len(), 2); + assert_eq!(keys, vec!["z", "a"]); + } + + #[test] + fn filtered_and_untrusted_objects_fail_closed() { + let index_row = vec![ + Some(Value::Integer(2)), + text("idx_filtered"), + bit(false), + bit(false), + text("NONCLUSTERED"), + bit(false), + bit(true), + bit(false), + bit(false), + Some(Value::Integer(1)), + text("value"), + bit(false), + bit(false), + text("FG"), + ]; + assert!(matches!( + parse_indexes(result(vec![index_row])), + Err(DriverError::Unsupported(message)) if message.contains("filtered") + )); + + let check = vec![ + text("ck_positive"), + text("([value] > 0)"), + bit(false), + bit(true), + bit(false), + ]; + assert!(matches!( + parse_checks(result(vec![check])), + Err(DriverError::Unsupported(message)) if message.contains("untrusted") + )); + } + + #[test] + fn composite_index_order_and_fk_reference_schema_are_preserved() { + let index_rows = vec![ + vec![ + Some(Value::Integer(2)), + text("ix_pair"), + bit(false), + bit(false), + text("NONCLUSTERED"), + bit(false), + bit(false), + bit(false), + bit(false), + Some(Value::Integer(2)), + text("second"), + bit(false), + bit(false), + text("FG"), + ], + vec![ + Some(Value::Integer(2)), + text("ix_pair"), + bit(false), + bit(false), + text("NONCLUSTERED"), + bit(false), + bit(false), + bit(false), + bit(false), + Some(Value::Integer(1)), + text("first"), + bit(false), + bit(false), + text("FG"), + ], + ]; + let indexes = parse_indexes(result(index_rows)).expect("parse indexes"); + assert_eq!(indexes[0].columns, vec!["first", "second"]); + + let fk_rows = vec![vec![ + text("fk_pair"), + Some(Value::Integer(1)), + text("left_id"), + text("other.schema"), + text("parent]table"), + text("key_id"), + text("NO_ACTION"), + text("CASCADE"), + bit(false), + bit(false), + bit(false), + ]]; + let keys = parse_foreign_keys(result(fk_rows)).expect("parse foreign key"); + assert_eq!(keys[0].columns, vec!["left_id"]); + assert_eq!(keys[0].referenced_table, "[other.schema].[parent]]table]"); + assert_eq!(keys[0].on_delete, "CASCADE"); + assert_eq!( + keys[0].deferrability, + ForeignKeyDeferrability::NotDeferrable + ); + } +} diff --git a/packages/drivers/sqlserver/src/parameters.rs b/packages/drivers/sqlserver/src/parameters.rs new file mode 100644 index 000000000..fb75f1a9a --- /dev/null +++ b/packages/drivers/sqlserver/src/parameters.rs @@ -0,0 +1,79 @@ +//! Native TDS parameter conversion for the driver-api value model. + +use datazen_driver_api::Value; +use tiberius::ToSql; + +/// Owned values are kept beside the `ToSql` references for the duration of a +/// Tiberius request. SQL text never receives a serialized parameter value. +pub(crate) enum BoundParameter { + Null(Option), + Bool(bool), + Integer(i64), + Float(f64), + String(String), + Bytes(Vec), +} + +impl BoundParameter { + pub(crate) fn as_tosql(&self) -> &dyn ToSql { + match self { + Self::Null(value) => value, + Self::Bool(value) => value, + Self::Integer(value) => value, + Self::Float(value) => value, + Self::String(value) => value, + Self::Bytes(value) => value, + } + } +} + +pub(crate) fn bind_values(values: &[Value]) -> Vec { + values + .iter() + .map(|value| match value { + Value::Null => BoundParameter::Null(None), + Value::Bool(value) => BoundParameter::Bool(*value), + Value::Integer(value) => BoundParameter::Integer(*value), + Value::Float(value) => BoundParameter::Float(*value), + Value::String(value) | Value::Timestamp(value) => BoundParameter::String(value.clone()), + Value::Bytes(value) => BoundParameter::Bytes(value.clone()), + Value::Json(value) => BoundParameter::String(value.to_string()), + }) + .collect() +} + +pub(crate) fn to_sql_refs(values: &[BoundParameter]) -> Vec<&dyn ToSql> { + values.iter().map(BoundParameter::as_tosql).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tiberius::ColumnData; + + #[test] + fn values_convert_to_native_tds_parameter_families() { + let input = vec![ + Value::Null, + Value::Bool(true), + Value::Integer(7), + Value::Float(2.5), + Value::String("O'Brien".into()), + Value::Bytes(vec![0, 255]), + Value::Timestamp("2026-01-02T03:04:05Z".into()), + Value::Json(serde_json::json!({ "x": 1 })), + ]; + let bound = bind_values(&input); + let refs = to_sql_refs(&bound); + let tds = refs.iter().map(|value| value.to_sql()).collect::>(); + + assert!(matches!(tds[0], ColumnData::String(None))); + assert!(matches!(tds[1], ColumnData::Bit(Some(true)))); + assert!(matches!(tds[2], ColumnData::I64(Some(7)))); + assert!(matches!(tds[3], ColumnData::F64(Some(value)) if value == 2.5)); + assert!(matches!(tds[4], ColumnData::String(Some(_)))); + assert!(matches!(tds[5], ColumnData::Binary(Some(_)))); + assert!(matches!(tds[6], ColumnData::String(Some(_)))); + assert!(matches!(tds[7], ColumnData::String(Some(_)))); + } +} diff --git a/packages/drivers/sqlserver/src/sqlserver.rs b/packages/drivers/sqlserver/src/sqlserver.rs index f2071d0b5..d1eb9249a 100644 --- a/packages/drivers/sqlserver/src/sqlserver.rs +++ b/packages/drivers/sqlserver/src/sqlserver.rs @@ -7,19 +7,26 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tiberius::{AuthMethod, Client, ColumnData, Config, EncryptionLevel, QueryItem}; use tokio::net::TcpStream; -use tokio::sync::RwLock; +use tokio::sync::{Mutex, RwLock}; use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; type SqlClient = Client>; pub struct SqlServerDriver { clients: RwLock>, + transactions: Mutex>, +} + +struct ActiveTransaction { + id: String, + restore_isolation: Option<&'static str>, } impl SqlServerDriver { pub fn new() -> Self { Self { clients: RwLock::new(HashMap::new()), + transactions: Mutex::new(HashMap::new()), } } @@ -75,42 +82,12 @@ impl SqlServerDriver { ) } - /// Columns of one table or view, filtered by the explicit `(schema, table)`. - /// - /// `database` is inlined as a catalog prefix when non-empty, so reading - /// another database never needs a session `USE`. `INFORMATION_SCHEMA.COLUMNS` - /// is the column source; the catalog-qualified `sys.*` views add the - /// identity / default / primary-key / comment metadata it does not expose. - /// The object lookup is a derived table keyed on `(schema, name)` so a - /// same-named relation in another schema can never duplicate or steal rows. + /// Template used by catalog-builder unit tests; object names are bound by + /// the caller rather than interpolated into this SQL text. + #[cfg(test)] fn build_table_schema_sql(database: &str, schema: &str, table: &str) -> String { - let catalog = Self::catalog_prefix(database); - let schema = schema.replace('\'', "''"); - let table = table.replace('\'', "''"); - format!( - "SELECT c.COLUMN_NAME AS column_name, c.DATA_TYPE AS data_type, \ - CAST(CASE WHEN c.IS_NULLABLE = 'YES' THEN 1 ELSE 0 END AS bit) AS is_nullable, \ - CAST(CASE WHEN sc.is_identity = 1 THEN 1 ELSE 0 END AS bit) AS is_identity, \ - dc.definition AS default_value, CAST(ep.value AS nvarchar(max)) AS comment, \ - CAST(CASE WHEN pk.column_id IS NULL THEN 0 ELSE 1 END AS bit) AS is_pk \ - FROM {catalog}INFORMATION_SCHEMA.COLUMNS c \ - LEFT JOIN ( \ - SELECT o.object_id, o.name AS object_name, s.name AS schema_name \ - FROM {catalog}sys.objects o \ - JOIN {catalog}sys.schemas s ON s.schema_id = o.schema_id \ - ) obj ON obj.object_name = c.TABLE_NAME AND obj.schema_name = c.TABLE_SCHEMA \ - LEFT JOIN {catalog}sys.columns sc ON sc.object_id = obj.object_id AND sc.name = c.COLUMN_NAME \ - LEFT JOIN {catalog}sys.default_constraints dc ON dc.parent_object_id = obj.object_id AND dc.parent_column_id = sc.column_id \ - LEFT JOIN {catalog}sys.extended_properties ep ON ep.major_id = obj.object_id AND ep.minor_id = sc.column_id AND ep.name = 'MS_Description' \ - LEFT JOIN ( \ - SELECT ic.object_id, ic.column_id \ - FROM {catalog}sys.index_columns ic \ - INNER JOIN {catalog}sys.indexes i ON ic.object_id = i.object_id AND ic.index_id = i.index_id \ - WHERE i.is_primary_key = 1 \ - ) pk ON pk.object_id = obj.object_id AND pk.column_id = sc.column_id \ - WHERE c.TABLE_SCHEMA = '{schema}' AND c.TABLE_NAME = '{table}' \ - ORDER BY c.ORDINAL_POSITION" - ) + let _ = (schema, table); + crate::metadata::columns_sql(database) } /// Batch columns for every table/view in `database` (optionally narrowed to @@ -124,17 +101,27 @@ impl SqlServerDriver { }; format!( "SELECT s.name AS schema_name, o.name AS table_name, c.name AS column_name, \ - tp.name AS data_type, c.is_nullable, c.is_identity, dc.definition AS default_value, \ + CASE \ + WHEN tp.is_user_defined = 1 THEN QUOTENAME(tp_schema.name) + N'.' + QUOTENAME(tp.name) \ + WHEN tp.name IN ('nvarchar', 'nchar') THEN CONCAT(tp.name, '(', CASE WHEN c.max_length = -1 THEN 'max' ELSE CONVERT(varchar(10), c.max_length / 2) END, ')') \ + WHEN tp.name IN ('varchar', 'char', 'varbinary', 'binary') THEN CONCAT(tp.name, '(', CASE WHEN c.max_length = -1 THEN 'max' ELSE CONVERT(varchar(10), c.max_length) END, ')') \ + WHEN tp.name IN ('decimal', 'numeric') THEN CONCAT(tp.name, '(', c.precision, ',', c.scale, ')') \ + WHEN tp.name IN ('time', 'datetime2', 'datetimeoffset') THEN CONCAT(tp.name, '(', c.scale, ')') \ + WHEN tp.name = 'float' THEN CONCAT(tp.name, '(', c.precision, ')') \ + ELSE tp.name \ + END AS data_type, c.is_nullable, c.is_identity, dc.definition AS default_value, \ CAST(ep.value AS nvarchar(max)) AS comment, \ - CAST(CASE WHEN pk.column_id IS NULL THEN 0 ELSE 1 END AS bit) AS is_pk \ + CAST(CASE WHEN pk.column_id IS NULL THEN 0 ELSE 1 END AS bit) AS is_pk, \ + ISNULL(pk.key_ordinal, 0) AS pk_ordinal \ FROM {catalog}sys.columns c \ JOIN {catalog}sys.objects o ON c.object_id = o.object_id \ JOIN {catalog}sys.schemas s ON o.schema_id = s.schema_id \ JOIN {catalog}sys.types tp ON c.user_type_id = tp.user_type_id \ + LEFT JOIN {catalog}sys.schemas tp_schema ON tp_schema.schema_id = tp.schema_id \ LEFT JOIN {catalog}sys.default_constraints dc ON c.default_object_id = dc.object_id \ LEFT JOIN {catalog}sys.extended_properties ep ON ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = 'MS_Description' \ LEFT JOIN ( \ - SELECT ic.object_id, ic.column_id \ + SELECT ic.object_id, ic.column_id, ic.key_ordinal \ FROM {catalog}sys.index_columns ic \ INNER JOIN {catalog}sys.indexes i ON ic.object_id = i.object_id AND ic.index_id = i.index_id \ WHERE i.is_primary_key = 1 \ @@ -240,12 +227,7 @@ impl SqlServerDriver { ColumnData::Bit(v) => v.map(Value::Bool), ColumnData::String(v) => v.as_ref().map(|s| Value::String(s.to_string())), ColumnData::Guid(v) => v.map(|g| Value::String(g.to_string())), - ColumnData::Binary(v) => v.as_ref().map(|b| { - Value::String(format!( - "0x{}", - b.iter().map(|x| format!("{x:02x}")).collect::() - )) - }), + ColumnData::Binary(v) => v.as_ref().map(|b| Value::Bytes(b.to_vec())), ColumnData::Numeric(v) => v.map(|n| Value::String(n.to_string())), ColumnData::Xml(v) => v.as_ref().map(|x| Value::String(x.to_string())), ColumnData::DateTime(_) @@ -313,6 +295,16 @@ impl SqlServerDriver { Self::run_routed(client, sql, false).await } + async fn run_with_params( + client: &mut SqlClient, + sql: &str, + params: &[Value], + ) -> Result { + let bound = crate::parameters::bind_values(params); + let refs = crate::parameters::to_sql_refs(&bound); + Self::run_routed_with_params(client, sql, &refs, false).await + } + /// Read a result set from a statement that must go out as a real batch even /// though its text would normally be routed through `sp_executesql`. /// @@ -324,17 +316,98 @@ impl SqlServerDriver { Self::run_routed(client, sql, true).await } + async fn execute_batch(client: &mut SqlClient, sql: &str) -> Result<(), DriverError> { + use futures_util::TryStreamExt; + let mut stream = client + .simple_query(sql) + .await + .map_err(|e| DriverError::TransactionError(e.to_string()))?; + while stream + .try_next() + .await + .map_err(|e| DriverError::TransactionError(e.to_string()))? + .is_some() + {} + Ok(()) + } + + async fn current_isolation_level(client: &mut SqlClient) -> Result<&'static str, DriverError> { + let result = Self::run_batch(client, "DBCC USEROPTIONS WITH NO_INFOMSGS").await?; + let value = result.rows.iter().find_map(|row| { + let name = row + .first() + .cloned() + .flatten() + .map(|v| datazen_driver_http_support::value_display(&v))?; + if !name.eq_ignore_ascii_case("isolation level") { + return None; + } + row.get(1) + .cloned() + .flatten() + .map(|v| datazen_driver_http_support::value_display(&v)) + }); + let normalized = value.map(|value| value.to_ascii_lowercase()); + match normalized.as_deref() { + Some("read uncommitted") => Ok("READ UNCOMMITTED"), + Some("read committed" | "read committed snapshot") => Ok("READ COMMITTED"), + Some("repeatable read") => Ok("REPEATABLE READ"), + Some("serializable") => Ok("SERIALIZABLE"), + Some("snapshot") => Ok("SNAPSHOT"), + _ => Err(DriverError::TransactionError( + "DBCC USEROPTIONS did not return a recognized isolation level".into(), + )), + } + } + + async fn ensure_no_open_transaction(client: &mut SqlClient) -> Result<(), DriverError> { + let result = Self::run(client, "SELECT @@TRANCOUNT AS [transaction_count]").await?; + let count = result + .rows + .first() + .and_then(|row| row.first()) + .and_then(Option::as_ref) + .map(datazen_driver_http_support::value_display) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + DriverError::TransactionError( + "SQL Server did not return the active transaction count".into(), + ) + })?; + if count != 0 { + return Err(DriverError::TransactionError( + "SQL Server session already has an open transaction".into(), + )); + } + Ok(()) + } + async fn run_routed( client: &mut SqlClient, sql: &str, force_batch: bool, + ) -> Result { + Self::run_routed_with_params(client, sql, &[], force_batch).await + } + + async fn run_routed_with_params( + client: &mut SqlClient, + sql: &str, + params: &[&dyn tiberius::ToSql], + force_batch: bool, ) -> Result { use futures_util::TryStreamExt; let start = Instant::now(); let mut stream = if force_batch || needs_own_batch(sql) { + if !params.is_empty() { + return Err(DriverError::Unsupported( + "SQL Server batch-only statements cannot accept bound parameters; use a parameterized RPC-compatible statement" + .into(), + )); + } client.simple_query(sql).await } else { - client.query(sql, &[]).await + client.query(sql, params).await } .map_err(|e| DriverError::QueryFailed(format!("SQL Server query failed: {e}")))?; let mut columns: Vec = Vec::new(); @@ -737,6 +810,21 @@ impl DatabaseDriver for SqlServerDriver { } async fn disconnect(&self, handle: ConnectionHandle) -> Result<(), DriverError> { + let mut transactions = self.transactions.lock().await; + if let Some(transaction) = transactions.remove(&handle.id) { + if let Some(client) = self.clients.write().await.get_mut(&handle.pool_id) { + // Disconnect must not return a still-open SQL Server + // transaction to the live handle map. The client is removed + // below even if rollback fails. + let rollback = transaction.restore_isolation.map_or_else( + || "ROLLBACK TRANSACTION".to_string(), + |level| { + format!("ROLLBACK TRANSACTION; SET TRANSACTION ISOLATION LEVEL {level}") + }, + ); + let _ = Self::execute_batch(client, &rollback).await; + } + } self.clients.write().await.remove(&handle.pool_id); Ok(()) } @@ -823,51 +911,14 @@ impl DatabaseDriver for SqlServerDriver { let client = map .get_mut(&handle.pool_id) .ok_or_else(|| DriverError::ConnectionFailed("Connection pool not found".into()))?; - let sql = Self::build_table_schema_sql(database, schema, table); - let result = Self::run(client, &sql).await?; - let columns: Vec = result - .rows - .into_iter() - .filter_map(|r| { - let is_pk = Self::bit_true(&r.get(6).cloned().flatten()); - Some(ColumnSchema { - name: r - .get(0) - .cloned() - .flatten() - .map(|v| datazen_driver_http_support::value_display(&v))?, - data_type: r - .get(1) - .cloned() - .flatten() - .map(|v| datazen_driver_http_support::value_display(&v)) - .unwrap_or_default(), - nullable: r - .get(2) - .cloned() - .flatten() - .map(|v| Self::bit_true(&Some(v))) - .unwrap_or(true), - default_value: r - .get(4) - .cloned() - .flatten() - .map(|v| datazen_driver_http_support::value_display(&v)), - comment: r - .get(5) - .cloned() - .flatten() - .map(|v| datazen_driver_http_support::value_display(&v)), - is_primary_key: is_pk, - is_auto_increment: Self::bit_true(&r.get(3).cloned().flatten()), - }) - }) - .collect(); - let primary_keys: Vec = columns - .iter() - .filter(|c| c.is_primary_key) - .map(|c| c.name.clone()) - .collect(); + let parameters = [ + Value::String(schema.to_string()), + Value::String(table.to_string()), + ]; + let column_rows = + Self::run_with_params(client, &crate::metadata::columns_sql(database), ¶meters) + .await?; + let (columns, primary_keys) = crate::metadata::parse_columns(column_rows)?; // A relation always has at least one column, so "no columns" means the // table is absent (or not visible) rather than a column-less table. // Reporting `Ok` here would let callers cache a blank structure; this is @@ -877,13 +928,28 @@ impl DatabaseDriver for SqlServerDriver { "Table '{schema}.{table}' does not exist in database '{database}'" ))); } + let index_rows = + Self::run_with_params(client, &crate::metadata::indexes_sql(database), ¶meters) + .await?; + let indexes = crate::metadata::parse_indexes(index_rows)?; + let foreign_key_rows = Self::run_with_params( + client, + &crate::metadata::foreign_keys_sql(database), + ¶meters, + ) + .await?; + let foreign_keys = crate::metadata::parse_foreign_keys(foreign_key_rows)?; + let check_rows = + Self::run_with_params(client, &crate::metadata::checks_sql(database), ¶meters) + .await?; + let check_constraints = crate::metadata::parse_checks(check_rows)?; Ok(TableSchema { table_name: table.to_string(), columns, primary_keys, - indexes: Vec::new(), - foreign_keys: Vec::new(), - check_constraints: Vec::new(), + indexes, + foreign_keys, + check_constraints, table_options: TableOptions::default(), }) } @@ -901,13 +967,14 @@ impl DatabaseDriver for SqlServerDriver { .ok_or_else(|| DriverError::ConnectionFailed("Connection pool not found".into()))?; let result = Self::run(client, &Self::build_all_columns_sql(database, schema)).await?; - let mut all_columns: HashMap, Vec)> = HashMap::new(); + let mut all_columns: HashMap, Vec<(i64, String)>)> = + HashMap::new(); let mut owners: HashMap = HashMap::new(); for row in &result.rows { // SQL: schema_name(0), table_name(1), column_name(2), data_type(3), // is_nullable(4), is_identity(5), default_value(6), comment(7), - // is_pk(8) + // is_pk(8), pk_ordinal(9) let table_schema = row .get(0) .cloned() @@ -978,11 +1045,35 @@ impl DatabaseDriver for SqlServerDriver { let entry = all_columns.entry(table_name).or_default(); entry.0.push(column); if is_pk { - entry.1.push(col_name); + let key_ordinal = row + .get(9) + .cloned() + .flatten() + .map(|v| datazen_driver_http_support::value_display(&v)) + .and_then(|v| v.parse::().ok()) + .filter(|ordinal| *ordinal > 0) + .ok_or_else(|| { + DriverError::QueryFailed( + "SQL Server returned incomplete composite primary-key metadata".into(), + ) + })?; + entry.1.push((key_ordinal, col_name)); } } - Ok(all_columns) + Ok(all_columns + .into_iter() + .map(|(table, (columns, mut key_columns))| { + key_columns.sort_by_key(|(ordinal, _)| *ordinal); + ( + table, + ( + columns, + key_columns.into_iter().map(|(_, name)| name).collect(), + ), + ) + }) + .collect()) } async fn query( @@ -1070,9 +1161,68 @@ impl DatabaseDriver for SqlServerDriver { &self, handle: &ConnectionHandle, sql: &str, - _params: &[Value], + params: &[Value], ) -> Result { - self.query(handle, sql).await + let bound = crate::parameters::bind_values(params); + let refs = crate::parameters::to_sql_refs(&bound); + let mut map = self.clients.write().await; + let client = map + .get_mut(&handle.pool_id) + .ok_or_else(|| DriverError::ConnectionFailed("Connection pool not found".into()))?; + Self::run_routed_with_params(client, sql, &refs, false).await + } + + fn parameter_placeholder( + &self, + index: usize, + _data_type: Option<&str>, + ) -> Result { + if !(1..=2100).contains(&index) { + return Err(DriverError::InvalidConfig( + "SQL Server parameter indexes must be between 1 and 2100".into(), + )); + } + Ok(format!("@P{index}")) + } + + async fn execute_with_params( + &self, + handle: &ConnectionHandle, + sql: &str, + params: &[Value], + ) -> Result { + use futures_util::TryStreamExt; + + let bound = crate::parameters::bind_values(params); + let refs = crate::parameters::to_sql_refs(&bound); + let mut map = self.clients.write().await; + let client = map + .get_mut(&handle.pool_id) + .ok_or_else(|| DriverError::ConnectionFailed("Connection pool not found".into()))?; + if needs_own_batch(sql) { + if !refs.is_empty() { + return Err(DriverError::Unsupported( + "SQL Server batch-only statements cannot accept bound parameters; use a parameterized RPC-compatible statement" + .into(), + )); + } + let mut stream = client + .simple_query(sql) + .await + .map_err(|e| DriverError::QueryFailed(format!("SQL Server execute failed: {e}")))?; + while stream + .try_next() + .await + .map_err(|e| DriverError::QueryFailed(format!("SQL Server execute failed: {e}")))? + .is_some() + {} + return Ok(0); + } + client + .execute(sql, &refs) + .await + .map(|result| result.total()) + .map_err(|e| DriverError::QueryFailed(format!("SQL Server execute failed: {e}"))) } async fn execute(&self, handle: &ConnectionHandle, sql: &str) -> Result { @@ -1104,6 +1254,154 @@ impl DatabaseDriver for SqlServerDriver { .map_err(|e| DriverError::QueryFailed(format!("SQL Server execute failed: {e}"))) } + async fn begin_transaction( + &self, + handle: &ConnectionHandle, + ) -> Result { + let mut transactions = self.transactions.lock().await; + if transactions.contains_key(&handle.id) { + return Err(DriverError::TransactionError( + "A transaction is already open on this connection".into(), + )); + } + let mut clients = self.clients.write().await; + let client = clients + .get_mut(&handle.pool_id) + .ok_or_else(|| DriverError::ConnectionFailed("Connection pool not found".into()))?; + Self::ensure_no_open_transaction(client).await?; + if let Err(error) = Self::execute_batch(client, "BEGIN TRANSACTION").await { + clients.remove(&handle.pool_id); + return Err(error); + } + let id = format!("sqlserver_tx_{}", uuid::Uuid::new_v4()); + transactions.insert( + handle.id.clone(), + ActiveTransaction { + id: id.clone(), + restore_isolation: None, + }, + ); + Ok(TransactionHandle { + id, + connection_id: handle.id.clone(), + }) + } + + async fn begin_read_snapshot( + &self, + handle: &ConnectionHandle, + ) -> Result { + let mut transactions = self.transactions.lock().await; + if transactions.contains_key(&handle.id) { + return Err(DriverError::TransactionError( + "A transaction is already open on this connection".into(), + )); + } + let mut clients = self.clients.write().await; + let client = clients + .get_mut(&handle.pool_id) + .ok_or_else(|| DriverError::ConnectionFailed("Connection pool not found".into()))?; + Self::ensure_no_open_transaction(client).await?; + let restore_isolation = match Self::current_isolation_level(client).await { + Ok(level) => level, + Err(error) => return Err(error), + }; + if let Err(error) = Self::execute_batch( + client, + "SET TRANSACTION ISOLATION LEVEL SNAPSHOT; BEGIN TRANSACTION", + ) + .await + { + // SNAPSHOT may be disabled for the active database. The TDS + // connection is discarded so the session isolation setting cannot + // leak into a later operation after an uncertain partial batch. + clients.remove(&handle.pool_id); + return Err(DriverError::TransactionError(format!( + "could not begin a SQL Server SNAPSHOT transaction (enable ALLOW_SNAPSHOT_ISOLATION for this database): {error}" + ))); + } + let id = format!("sqlserver_snapshot_{}", uuid::Uuid::new_v4()); + transactions.insert( + handle.id.clone(), + ActiveTransaction { + id: id.clone(), + restore_isolation: Some(restore_isolation), + }, + ); + Ok(TransactionHandle { + id, + connection_id: handle.id.clone(), + }) + } + + async fn commit(&self, tx: TransactionHandle) -> Result<(), DriverError> { + let mut transactions = self.transactions.lock().await; + let restore_isolation = match transactions.get(&tx.connection_id) { + Some(active) if active.id == tx.id => active.restore_isolation, + Some(_) => { + return Err(DriverError::TransactionError( + "Transaction handle does not match the active SQL Server transaction".into(), + )); + } + None => { + return Err(DriverError::TransactionError( + "Transaction not found or already ended".into(), + )); + } + }; + let statement = restore_isolation.map_or_else( + || "COMMIT TRANSACTION".to_string(), + |level| format!("COMMIT TRANSACTION; SET TRANSACTION ISOLATION LEVEL {level}"), + ); + let mut clients = self.clients.write().await; + let result = match clients.get_mut(&tx.connection_id) { + Some(client) => Self::execute_batch(client, &statement).await, + None => Err(DriverError::ConnectionFailed( + "Connection pool not found".into(), + )), + }; + transactions.remove(&tx.connection_id); + if result.is_err() { + // After a failed COMMIT the server-side transaction state is + // uncertain. Drop the session rather than reuse it. + clients.remove(&tx.connection_id); + } + result + } + + async fn rollback(&self, tx: TransactionHandle) -> Result<(), DriverError> { + let mut transactions = self.transactions.lock().await; + let restore_isolation = match transactions.get(&tx.connection_id) { + Some(active) if active.id == tx.id => active.restore_isolation, + Some(_) => { + return Err(DriverError::TransactionError( + "Transaction handle does not match the active SQL Server transaction".into(), + )); + } + None => { + return Err(DriverError::TransactionError( + "Transaction not found or already ended".into(), + )); + } + }; + let statement = restore_isolation.map_or_else( + || "ROLLBACK TRANSACTION".to_string(), + |level| format!("ROLLBACK TRANSACTION; SET TRANSACTION ISOLATION LEVEL {level}"), + ); + let mut clients = self.clients.write().await; + let result = match clients.get_mut(&tx.connection_id) { + Some(client) => Self::execute_batch(client, &statement).await, + None => Err(DriverError::ConnectionFailed( + "Connection pool not found".into(), + )), + }; + transactions.remove(&tx.connection_id); + if result.is_err() { + clients.remove(&tx.connection_id); + } + result + } + async fn cancel_query(&self, _handle: &ConnectionHandle) -> Result<(), DriverError> { Ok(()) } @@ -1264,15 +1562,12 @@ mod tests { #[test] fn build_table_schema_sql_filters_schema_and_qualifies_catalog() { let sql = SqlServerDriver::build_table_schema_sql("sales", "dbo", "users"); - assert!(sql.contains("FROM [sales].INFORMATION_SCHEMA.COLUMNS")); - assert!(sql.contains("c.TABLE_SCHEMA = 'dbo'")); - assert!(sql.contains("c.TABLE_NAME = 'users'")); + assert!(sql.contains("FROM [sales].sys.columns c")); + assert!(sql.contains("s.name = @P1 AND o.name = @P2")); assert!(sql.contains("is_primary_key = 1")); assert!(sql.contains("is_pk")); - // The object lookup must be keyed on (schema, name): joining on the bare - // name would let a same-named table in another schema duplicate rows. - assert!(sql.contains("obj.schema_name = c.TABLE_SCHEMA")); - assert!(!sql.contains("ON o.name = c.TABLE_NAME")); + assert!(sql.contains("pk.key_ordinal")); + assert!(sql.contains("c.max_length")); // No session switch may be embedded in a read path. assert!(!sql.to_uppercase().contains("USE [")); } @@ -1280,16 +1575,18 @@ mod tests { #[test] fn build_table_schema_sql_stays_local_when_database_is_blank() { let sql = SqlServerDriver::build_table_schema_sql("", "dbo", "users"); - assert!(sql.contains("FROM INFORMATION_SCHEMA.COLUMNS")); + assert!(sql.contains("FROM sys.columns c")); assert!(!sql.contains("[].")); } #[test] fn build_table_schema_sql_escapes_quotes() { let sql = SqlServerDriver::build_table_schema_sql("db]", "d'bo", "us'ers"); - assert!(sql.contains("FROM [db]]].INFORMATION_SCHEMA.COLUMNS")); - assert!(sql.contains("c.TABLE_SCHEMA = 'd''bo'")); - assert!(sql.contains("c.TABLE_NAME = 'us''ers'")); + assert!(sql.contains("FROM [db]]].sys.columns c")); + assert!(sql.contains("@P1")); + assert!(sql.contains("@P2")); + assert!(!sql.contains("d'bo")); + assert!(!sql.contains("us'ers")); } #[test] diff --git a/packages/drivers/sqlserver/src/sync_adapter.rs b/packages/drivers/sqlserver/src/sync_adapter.rs index 47cc2606d..3826ad7e7 100644 --- a/packages/drivers/sqlserver/src/sync_adapter.rs +++ b/packages/drivers/sqlserver/src/sync_adapter.rs @@ -126,10 +126,10 @@ impl SyncSourceAdapter for SqlServerSyncAdapter { schema: Option<&str>, table: &str, ) -> Option { - // SQL Server's current schema reader only exposes columns and PKs. - // Refuse source objects that would otherwise disappear from the - // transfer plan. Keep catalog qualifiers and user names safely quoted - // as identifiers/literals, including `]` and apostrophes. + // The current transfer plan preserves columns and PKs only, so refuse + // table-level objects that would otherwise disappear from the export. + // Keep catalog qualifiers and user names safely quoted as identifiers + // or literals, including `]` and apostrophes. let catalog = if database.trim().is_empty() { String::new() } else { diff --git a/packages/drivers/sqlserver/tests/live_query_types.rs b/packages/drivers/sqlserver/tests/live_query_types.rs index 153a8c3bc..b1c7d0bbb 100644 --- a/packages/drivers/sqlserver/tests/live_query_types.rs +++ b/packages/drivers/sqlserver/tests/live_query_types.rs @@ -113,8 +113,8 @@ async fn scalar_types_round_trip_with_expected_variants() { ); } assert!( - matches!(by_name("t_bin"), Some(Value::String(s)) if s.eq_ignore_ascii_case("0x0102ff")), - "varbinary decodes to a 0x hex string, got {:?}", + matches!(by_name("t_bin"), Some(Value::Bytes(bytes)) if bytes == &[0x01, 0x02, 0xff]), + "varbinary decodes to its original bytes, got {:?}", by_name("t_bin") ); assert!( @@ -126,6 +126,51 @@ async fn scalar_types_round_trip_with_expected_variants() { let _ = driver.disconnect(handle).await; } +#[tokio::test] +async fn query_parameters_are_bound_as_values_without_sql_interpolation() { + let Some(cfg) = live_config() else { return }; + let (driver, handle) = connect(&cfg).await; + let untrusted = "O'Brien'; SELECT 99 AS injected --"; + let binary = vec![0, 0x27, 0xff]; + + let result = driver + .query_with_params( + &handle, + "SELECT CAST(@P1 AS nvarchar(200)) AS [text], @P2 AS [bytes], @P3 AS [null_value]", + &[ + Value::String(untrusted.into()), + Value::Bytes(binary.clone()), + Value::Null, + ], + ) + .await + .unwrap_or_else(|error| panic!("parameterized query failed: {error}")); + assert_eq!( + result.rows.len(), + 1, + "bound text must not inject another SELECT" + ); + assert!( + matches!(&result.rows[0][0], Some(Value::String(value)) if value == untrusted), + "quotes and SQL-looking text must survive as data" + ); + assert!( + matches!(&result.rows[0][1], Some(Value::Bytes(value)) if value == &binary), + "binary parameters must retain their bytes" + ); + assert!( + matches!(result.rows[0][2].as_ref(), None | Some(Value::Null)), + "NULL must remain a native NULL parameter" + ); + + assert_eq!(driver.parameter_placeholder(1, None).unwrap(), "@P1"); + assert_eq!(driver.parameter_placeholder(2100, None).unwrap(), "@P2100"); + assert!(driver.parameter_placeholder(0, None).is_err()); + assert!(driver.parameter_placeholder(2101, None).is_err()); + + let _ = driver.disconnect(handle).await; +} + /// Prints the concrete `Value` shape of every probe column. Run with /// `--nocapture` when the report needs the exact temporal/numeric encoding. #[tokio::test] diff --git a/packages/drivers/sqlserver/tests/live_schema_metadata.rs b/packages/drivers/sqlserver/tests/live_schema_metadata.rs index 206673e6a..146ba7df0 100644 --- a/packages/drivers/sqlserver/tests/live_schema_metadata.rs +++ b/packages/drivers/sqlserver/tests/live_schema_metadata.rs @@ -178,6 +178,109 @@ impl Fixture { } } +#[tokio::test] +async fn composite_key_and_secondary_constraints_preserve_catalog_state() { + let Some(cfg) = live_config() else { return }; + if !write_allowed(&cfg, "composite SQL Server schema metadata") { + return; + } + + let schema = cfg.scratch("meta_keys"); + let parent = qualified(&schema, "parent"); + let child = qualified(&schema, "child"); + let db = cfg.database.clone(); + let mut fixture = Fixture::start(&cfg).await; + fixture.track(format!("DROP TABLE IF EXISTS {child}")); + fixture.track(format!("DROP TABLE IF EXISTS {parent}")); + fixture.track(format!("DROP SCHEMA IF EXISTS {}", bracket(&schema))); + + fixture + .run(move |driver: Arc, handle: ConnectionHandle| async move { + exec( + &driver, + &handle, + "create metadata scratch schema", + &format!("CREATE SCHEMA {}", bracket(&schema)), + ) + .await; + exec( + &driver, + &handle, + "create composite-key parent", + &format!( + "CREATE TABLE {parent} (\ + [a] INT NOT NULL, [b] INT NOT NULL, \ + CONSTRAINT [pk_meta_parent] PRIMARY KEY ([b], [a]))" + ), + ) + .await; + exec( + &driver, + &handle, + "create composite-key child and metadata", + &format!( + "CREATE TABLE {child} (\ + [parent_a] INT NOT NULL, [parent_b] INT NOT NULL, \ + [local_a] INT NOT NULL, [local_b] INT NOT NULL, \ + CONSTRAINT [pk_meta_child] PRIMARY KEY ([local_b], [local_a]), \ + CONSTRAINT [fk_meta_child_parent] FOREIGN KEY ([parent_b], [parent_a]) \ + REFERENCES {parent} ([b], [a]) ON DELETE CASCADE ON UPDATE NO ACTION, \ + CONSTRAINT [ck_meta_child_positive] CHECK ([local_a] >= 0))" + ), + ) + .await; + exec( + &driver, + &handle, + "create ordered secondary index", + &format!( + "CREATE NONCLUSTERED INDEX [ix_meta_child_parent] ON {child} ([parent_b], [parent_a])" + ), + ) + .await; + + let table_schema = driver + .get_table_schema(&handle, "child", &db, Some(&schema)) + .await + .expect("composite metadata should be representable"); + assert_eq!(table_schema.primary_keys, vec!["local_b", "local_a"]); + assert_eq!( + columns_of(&table_schema), + vec!["parent_a", "parent_b", "local_a", "local_b"] + ); + + let all = driver + .get_all_columns(&handle, &db, Some(&schema)) + .await + .expect("get_all_columns should preserve the composite key order"); + assert_eq!(all["child"].1, table_schema.primary_keys); + + let index = table_schema + .indexes + .iter() + .find(|index| index.name == "ix_meta_child_parent") + .expect("secondary index should be present"); + assert_eq!(index.columns, vec!["parent_b", "parent_a"]); + assert!(!index.is_unique && !index.is_primary); + + assert_eq!(table_schema.foreign_keys.len(), 1); + let foreign_key = &table_schema.foreign_keys[0]; + assert_eq!(foreign_key.name, "fk_meta_child_parent"); + assert_eq!(foreign_key.columns, vec!["parent_b", "parent_a"]); + assert_eq!(foreign_key.referenced_table, format!("{}.{}", bracket(&schema), bracket("parent"))); + assert_eq!(foreign_key.referenced_columns, vec!["b", "a"]); + assert_eq!(foreign_key.on_update, "NO ACTION"); + assert_eq!(foreign_key.on_delete, "CASCADE"); + + assert_eq!(table_schema.check_constraints.len(), 1); + let check = &table_schema.check_constraints[0]; + assert_eq!(check.name, "ck_meta_child_positive"); + assert!(check.expression.contains("local_a")); + assert!(check.expression.contains('0')); + }) + .await; +} + // ═══════════════════════ 1. diverse scratch table ═══════════════════════ #[tokio::test] @@ -218,8 +321,7 @@ async fn scratch_table_metadata_round_trip() { [payload] VARBINARY(16) NULL,\n\ [token] UNIQUEIDENTIFIER NULL,\n\ [big] BIGINT NULL,\n\ - [ratio] FLOAT NULL,\n\ - [doubled] AS ([id] * 2)\n\ + [ratio] FLOAT NULL\n\ )" ), ) @@ -299,7 +401,7 @@ async fn scratch_table_metadata_round_trip() { assert_eq!( columns_of(&table_schema), vec![ - "id", "note", "amount", "created_at", "payload", "token", "big", "ratio", "doubled" + "id", "note", "amount", "created_at", "payload", "token", "big", "ratio" ], "column names must follow ORDINAL_POSITION" ); @@ -317,7 +419,7 @@ async fn scratch_table_metadata_round_trip() { assert!(id.default_value.is_none(), "an IDENTITY column has no DEFAULT"); let note = find_column(&table_schema, "note"); - assert_eq!(note.data_type, "nvarchar"); + assert_eq!(note.data_type, "nvarchar(100)"); assert!(!note.nullable); assert!( note.default_value @@ -328,11 +430,11 @@ async fn scratch_table_metadata_round_trip() { ); let amount = find_column(&table_schema, "amount"); - assert_eq!(amount.data_type, "decimal"); + assert_eq!(amount.data_type, "decimal(18,4)"); assert!(amount.nullable); let created = find_column(&table_schema, "created_at"); - assert_eq!(created.data_type, "datetime2"); + assert_eq!(created.data_type, "datetime2(3)"); assert!(!created.nullable); assert!( created @@ -343,17 +445,13 @@ async fn scratch_table_metadata_round_trip() { created.default_value ); - assert_eq!(find_column(&table_schema, "payload").data_type, "varbinary"); + assert_eq!(find_column(&table_schema, "payload").data_type, "varbinary(16)"); assert_eq!(find_column(&table_schema, "token").data_type, "uniqueidentifier"); assert_eq!(find_column(&table_schema, "big").data_type, "bigint"); - assert_eq!(find_column(&table_schema, "ratio").data_type, "float"); + assert_eq!(find_column(&table_schema, "ratio").data_type, "float(53)"); assert!(find_column(&table_schema, "payload").nullable); assert!(find_column(&table_schema, "token").nullable); - let doubled = find_column(&table_schema, "doubled"); - assert_eq!(doubled.data_type, "int"); - assert!(!doubled.is_primary_key && !doubled.is_auto_increment); - // ── get_columns (trait default) agrees ── let (columns, primary_keys) = driver .get_columns(&handle, table, &db, Some(&schema)) diff --git a/packages/drivers/sqlserver/tests/live_write_and_ddl.rs b/packages/drivers/sqlserver/tests/live_write_and_ddl.rs index 42f4d47a4..260fe840b 100644 --- a/packages/drivers/sqlserver/tests/live_write_and_ddl.rs +++ b/packages/drivers/sqlserver/tests/live_write_and_ddl.rs @@ -32,7 +32,7 @@ use common::{ cell, cell_i64, cell_string, connect, drop_quietly, live_config, scalar, with_resume_retry, write_allowed, LiveConfig, }; -use datazen_driver_api::{ConnectionHandle, DatabaseDriver as _, DriverError}; +use datazen_driver_api::{ConnectionHandle, DatabaseDriver as _, DriverError, Value}; use datazen_driver_sqlserver::SqlServerDriver; use futures_util::FutureExt as _; use serde_json::json; @@ -1359,6 +1359,160 @@ async fn transaction_control_statements_work_on_their_own() { .await; } +#[tokio::test] +async fn driver_transaction_api_keeps_parameterized_writes_on_the_same_session() { + let Some(cfg) = live_config() else { return }; + if !write_allowed(&cfg, "SQL Server driver transaction API") { + return; + } + let table = cfg.scratch("driver_tx"); + let t = dbo(&table); + let cleanup = vec![rollback_if_open(), format!("DROP TABLE IF EXISTS {t}")]; + let probes = vec![object_probe(&t)]; + + with_cleanup( + &cfg, + "driver transaction API", + cleanup, + probes, + move |driver, handle| async move { + driver + .execute( + &handle, + &format!("CREATE TABLE {t} ([id] INT NOT NULL PRIMARY KEY, [note] NVARCHAR(100) NOT NULL)"), + ) + .await + .expect("create transaction probe table"); + + let tx = driver + .begin_transaction(&handle) + .await + .expect("begin_transaction starts on this connection's TDS session"); + let affected = driver + .execute_with_params( + &handle, + &format!("INSERT INTO {t} ([id], [note]) VALUES (@P1, @P2)"), + &[Value::Integer(1), Value::String("O'Brien".into())], + ) + .await + .expect("parameterized insert runs inside the open transaction"); + assert_eq!(affected, 1); + assert_eq!( + count(&driver, &handle, &t).await, + 1, + "reads on the same handle see the uncommitted write" + ); + driver.rollback(tx).await.expect("rollback the first write"); + assert_eq!( + count(&driver, &handle, &t).await, + 0, + "rollback must clear the parameterized write" + ); + + let tx = driver + .begin_transaction(&handle) + .await + .expect("begin a second transaction on the same live session"); + driver + .execute_with_params( + &handle, + &format!("INSERT INTO {t} ([id], [note]) VALUES (@P1, @P2)"), + &[Value::Integer(2), Value::String("committed".into())], + ) + .await + .expect("insert before commit"); + driver.commit(tx).await.expect("commit the second write"); + assert_eq!( + count(&driver, &handle, &t).await, + 1, + "commit must leave the row visible after the transaction" + ); + }, + ) + .await; +} + +#[tokio::test] +async fn read_snapshot_keeps_a_stable_view_on_its_tds_session() { + let Some(cfg) = live_config() else { return }; + if !write_allowed(&cfg, "SQL Server snapshot isolation") { + return; + } + let table = cfg.scratch("snapshot"); + let t = dbo(&table); + let connect_config = cfg.default_config(); + let cleanup = vec![rollback_if_open(), format!("DROP TABLE IF EXISTS {t}")]; + let probes = vec![object_probe(&t)]; + + with_cleanup( + &cfg, + "snapshot isolation", + cleanup, + probes, + move |driver, first| async move { + let snapshot_state = scalar( + &driver, + &first, + "SELECT snapshot_isolation_state FROM sys.databases WHERE name = DB_NAME()", + ) + .await + .expect("read ALLOW_SNAPSHOT_ISOLATION state"); + if cell_i64(&snapshot_state) != Some(1) { + eprintln!("⏭ Skipping snapshot assertion: ALLOW_SNAPSHOT_ISOLATION is not ON"); + return; + } + + driver + .execute( + &first, + &format!("CREATE TABLE {t} ([id] INT NOT NULL PRIMARY KEY)"), + ) + .await + .expect("create snapshot probe table"); + driver + .execute(&first, &format!("INSERT INTO {t} ([id]) VALUES (1)")) + .await + .expect("seed snapshot probe table"); + let second = driver + .connect(&connect_config) + .await + .expect("open a second TDS session"); + + let snapshot = driver + .begin_read_snapshot(&first) + .await + .expect("begin SNAPSHOT transaction on the first TDS session"); + assert_eq!(count(&driver, &first, &t).await, 1); + assert_eq!( + driver + .execute(&second, &format!("INSERT INTO {t} ([id]) VALUES (2)")) + .await + .expect("commit a row from the second session"), + 1 + ); + assert_eq!( + count(&driver, &first, &t).await, + 1, + "the open snapshot keeps the view established by its first read" + ); + driver + .rollback(snapshot) + .await + .expect("end the snapshot transaction and restore isolation"); + assert_eq!( + count(&driver, &first, &t).await, + 2, + "after rollback, a new statement sees the second session's commit" + ); + driver + .disconnect(second) + .await + .expect("disconnect the second TDS session"); + }, + ) + .await; +} + /// The supported transaction form: one self-contained batch whose trancount is /// balanced when the `sp_executesql` module ends. #[tokio::test] From 5d750fe25b505148c0d14f5ac43efb1c13928d62 Mon Sep 17 00:00:00 2001 From: probe Date: Tue, 29 Sep 2026 23:10:37 +0800 Subject: [PATCH 002/666] test(sqlserver): cover metadata catalog edges --- e2e/tsconfig.json | 6 +- packages/drivers/sqlserver/e2e/README.md | 6 + .../sqlserver/e2e/sqlserver-metadata.ts | 280 ++++++++++++++++++ packages/drivers/sqlserver/src/metadata.rs | 87 ++++++ 4 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 packages/drivers/sqlserver/e2e/sqlserver-metadata.ts diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 8892fc6d6..0b6babec7 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -14,5 +14,9 @@ // entry — `pnpm typecheck` runs the root tsconfig, whose `include` does not // reach `e2e/` — which is how a spec can sit in a suite with a broken import // and still be reported as clean, by never being asked. - "include": ["**/*.ts", "../packages/pro-extensions/*/e2e/**/*.ts"] + "include": [ + "**/*.ts", + "../packages/pro-extensions/*/e2e/**/*.ts", + "../packages/drivers/sqlserver/e2e/**/*.ts" + ] } diff --git a/packages/drivers/sqlserver/e2e/README.md b/packages/drivers/sqlserver/e2e/README.md index 18580d8c3..c74aea669 100644 --- a/packages/drivers/sqlserver/e2e/README.md +++ b/packages/drivers/sqlserver/e2e/README.md @@ -7,6 +7,7 @@ Optional WebdriverIO specs for the SQL Server path driver. Not included in defau | `sqlserver-smoke.ts` | Reachability placeholder: skips unless a SQL Server instance answers on `host:port`. | | `sqlserver-live-e2e.ts` | Live journey through the app's own IPC (`save_connection` → `connect` → `execute_query` → `get_table_data`). Asserts dialect pagination (`OFFSET … FETCH NEXT`, never `LIMIT`), batch-only DDL such as `CREATE SCHEMA`/`CREATE VIEW`, temporal rendering and filtered/sorted paging. Creates only `dz_e2e_*` objects and drops them in `after`. | | `sqlserver-live-ui.ts` | Live journey through the **real window**: connects via the navigator card, expands ` → → Tables`, opens the scratch table in the data grid and asserts every column header, the temporal cell text (`2026-03-01`, never `Date(`/`increments`) and next-page navigation. Reuses the host harness helpers (`e2e/helpers.ts`) for tree expansion, so it also exercises the same navigator code path as the other drivers' specs. | +| `sqlserver-metadata.ts` | Live journey through `get_table_schema` IPC. Creates parent/child scratch tables with composite primary keys, a secondary index, a composite foreign key and a CHECK constraint; verifies catalog names, columns, actions and key ordering. It also checks that a non-default column collation is preserved or rejected explicitly, then drops the scratch objects. | ## Prerequisites @@ -51,6 +52,11 @@ E2E_SQLSERVER_DATABASE=master \ E2E_SQLSERVER_HOST=127.0.0.1 E2E_SQLSERVER_USER=sa E2E_SQLSERVER_PASSWORD='YourPassword' \ E2E_SQLSERVER_DATABASE=master \ pnpm e2e:skip-build -- --spec packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts + +# catalog metadata journey through get_table_schema IPC +E2E_SQLSERVER_HOST=127.0.0.1 E2E_SQLSERVER_USER=sa E2E_SQLSERVER_PASSWORD='YourPassword' \ +E2E_SQLSERVER_DATABASE=master \ + pnpm e2e:skip-build -- --spec packages/drivers/sqlserver/e2e/sqlserver-metadata.ts ``` Without credentials, the specs skip cleanly. The live specs make no assumption about diff --git a/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts new file mode 100644 index 000000000..8653764b9 --- /dev/null +++ b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts @@ -0,0 +1,280 @@ +/** + * Live SQL Server catalog journey through the product's public IPC command. + * + * This exercises `get_table_schema` against scratch tables that combine a + * composite primary key, secondary index, composite foreign key, and CHECK + * constraint. It skips unless the SQL Server E2E connection variables are set. + */ +import { expect } from '@wdio/globals'; + +const HOST = (process.env.E2E_SQLSERVER_HOST || '').trim(); +const PORT = Number(process.env.E2E_SQLSERVER_PORT || '1433'); +const USER = (process.env.E2E_SQLSERVER_USER || '').trim(); +const PASSWORD = process.env.E2E_SQLSERVER_PASSWORD || ''; +const DATABASE = (process.env.E2E_SQLSERVER_DATABASE || '').trim(); +const SCHEMA = (process.env.E2E_SQLSERVER_SCHEMA || 'dbo').trim(); +const SSL_MODE = (process.env.E2E_SQLSERVER_SSL_MODE || 'require').trim(); +const TRUST_CERT = process.env.E2E_SQLSERVER_TRUST_CERT !== '0'; + +const CONNECTION_ID = 'e2e-sqlserver-metadata'; +const scratchSchema = `dz_e2e_meta_${Date.now().toString(36)}`; +const parentTable = 'parent_pair'; +const childTable = 'child_pair'; +const collationTable = 'collation_probe'; +let nonDefaultCollation = ''; + +interface ColumnSchemaPayload { + name: string; + dataType: string; + nullable: boolean; + isPrimaryKey: boolean; +} + +interface IndexPayload { + name: string; + columns: string[]; + isUnique: boolean; + isPrimary: boolean; + indexType: string; +} + +interface ForeignKeyPayload { + name: string; + columns: string[]; + referencedTable: string; + referencedColumns: string[]; + onUpdate: string; + onDelete: string; +} + +interface CheckConstraintPayload { + name: string; + expression: string; +} + +interface TableSchemaPayload { + tableName: string; + columns: ColumnSchemaPayload[]; + primaryKeys: string[]; + indexes: IndexPayload[]; + foreignKeys: ForeignKeyPayload[]; + checkConstraints: CheckConstraintPayload[]; +} + +interface StatementPayload { + rows: Array>; +} + +interface MultiQueryPayload { + results: StatementPayload[]; +} + +async function invoke(cmd: string, args: Record = {}): Promise { + const result = await browser.executeAsync( + (command: string, serializedArgs: string, done: (value: unknown) => void) => { + const internals = ( + window as unknown as { + __TAURI_INTERNALS__?: { invoke: (name: string, input: unknown) => Promise }; + } + ).__TAURI_INTERNALS__; + if (!internals) { + done({ __error: '__TAURI_INTERNALS__ is unavailable (not a webdriver build?)' }); + return; + } + internals + .invoke(command, JSON.parse(serializedArgs)) + .then((value) => done(value)) + .catch((error: unknown) => done({ __error: String(error) })); + }, + cmd, + JSON.stringify(args), + ); + if (result && typeof result === 'object' && '__error' in (result as Record)) { + throw new Error(String((result as { __error: string }).__error)); + } + return result as T; +} + +function bracket(identifier: string): string { + return `[${identifier.replaceAll(']', ']]')}]`; +} + +describe('SQL Server schema metadata IPC (live)', () => { + let dbSessionId = ''; + let database = DATABASE; + let originalSafeMode: boolean | undefined; + + const run = async (sql: string) => + invoke('execute_query', { + dbSessionId, + sql, + ...(database ? { database } : {}), + }); + + const tableSchema = (table: string) => + invoke('get_table_schema', { + dbSessionId, + table, + database, + schema: scratchSchema, + }); + + const qualified = (table: string) => `${bracket(scratchSchema)}.${bracket(table)}`; + + const setSafeMode = async (enabled: boolean) => { + const settings = await invoke>('get_settings'); + if (originalSafeMode === undefined) originalSafeMode = settings.safeMode !== false; + await invoke('save_settings', { settings: { ...settings, safeMode: enabled } }); + }; + + const cleanup = async () => { + if (dbSessionId) { + try { + await setSafeMode(false); + for (const table of [childTable, parentTable, collationTable]) { + await run(`DROP TABLE IF EXISTS ${qualified(table)}`).catch((error: unknown) => { + console.warn(`SQL Server metadata cleanup could not drop ${table}: ${String(error)}`); + }); + } + await run(`DROP SCHEMA IF EXISTS ${bracket(scratchSchema)}`).catch((error: unknown) => { + console.warn(`SQL Server metadata cleanup could not drop scratch schema: ${String(error)}`); + }); + } finally { + await invoke('disconnect', { dbSessionId }).catch(() => undefined); + dbSessionId = ''; + } + } + await invoke('delete_connection', { id: CONNECTION_ID }).catch(() => undefined); + if (originalSafeMode !== undefined) { + const settings = await invoke>('get_settings').catch(() => null); + if (settings) { + await invoke('save_settings', { + settings: { ...settings, safeMode: originalSafeMode }, + }).catch(() => undefined); + } + } + }; + + before(async function () { + this.timeout(120_000); + if (process.env.E2E_SKIP_SQLSERVER === '1' || !HOST || !USER || !PASSWORD) { + console.warn( + '⏩ Skipping SQL Server metadata E2E: set E2E_SQLSERVER_HOST / E2E_SQLSERVER_USER / E2E_SQLSERVER_PASSWORD, or E2E_SKIP_SQLSERVER=1', + ); + this.skip(); + } + + const handles = await browser.getWindowHandles(); + if (handles[0]) await browser.switchToWindow(handles[0]); + + await invoke('save_connection', { + config: { + id: CONNECTION_ID, + name: 'E2E SQL Server metadata', + databaseType: 'sqlserver', + host: HOST, + port: PORT, + ...(DATABASE ? { database: DATABASE } : {}), + ...(SCHEMA ? { schema: SCHEMA } : {}), + username: USER, + password: PASSWORD, + sslMode: SSL_MODE, + connectionTimeout: 30, + maxPoolSize: 5, + options: { trustServerCertificate: TRUST_CERT }, + }, + }); + dbSessionId = await invoke('connect', { connectionId: CONNECTION_ID }); + + await setSafeMode(false); + if (!database) { + const current = await run('SELECT DB_NAME() AS database_name'); + database = String(current.results[0]?.rows[0]?.[0] ?? ''); + if (!database) throw new Error('SQL Server did not return the active database name'); + } + + await run(`CREATE SCHEMA ${bracket(scratchSchema)}`); + await run( + `CREATE TABLE ${qualified(parentTable)} (` + + '[part_b] INT NOT NULL, [part_a] INT NOT NULL, [label] NVARCHAR(40) NULL, ' + + 'CONSTRAINT [PK_parent_pair] PRIMARY KEY ([part_a], [part_b]))', + ); + await run( + `CREATE TABLE ${qualified(childTable)} (` + + '[part_b] INT NOT NULL, [part_a] INT NOT NULL, [rank] INT NOT NULL, ' + + 'CONSTRAINT [PK_child_pair] PRIMARY KEY ([part_b], [part_a]), ' + + 'CONSTRAINT [FK_child_pair_parent] FOREIGN KEY ([part_a], [part_b]) ' + + `REFERENCES ${qualified(parentTable)} ([part_a], [part_b]) ON DELETE CASCADE, ` + + 'CONSTRAINT [CK_child_pair_rank] CHECK ([rank] >= 0))', + ); + await run( + `CREATE NONCLUSTERED INDEX [IX_child_pair_rank] ON ${qualified(childTable)} ([rank], [part_a])`, + ); + + const alternateCollation = await run( + "SELECT TOP (1) name FROM sys.fn_helpcollations() WHERE name <> CAST(DATABASEPROPERTYEX(DB_NAME(), 'Collation') AS nvarchar(128)) ORDER BY name", + ); + nonDefaultCollation = String(alternateCollation.results[0]?.rows[0]?.[0] ?? ''); + if (!/^[A-Za-z0-9_]+$/.test(nonDefaultCollation)) { + throw new Error('SQL Server did not return a usable alternate collation name'); + } + await run( + `CREATE TABLE ${qualified(collationTable)} ([text_value] NVARCHAR(20) COLLATE ${nonDefaultCollation} NULL)`, + ); + }); + + after(async function () { + this.timeout(60_000); + await cleanup(); + }); + + it('reads composite keys and secondary constraints through get_table_schema IPC', async () => { + const parent = await tableSchema(parentTable); + expect(parent.columns.map((column) => column.name)).toEqual(['part_b', 'part_a', 'label']); + expect(parent.primaryKeys).toEqual(['part_a', 'part_b']); + expect(parent.columns.filter((column) => column.isPrimaryKey).map((column) => column.name)).toEqual([ + 'part_b', + 'part_a', + ]); + + const child = await tableSchema(childTable); + expect(child.primaryKeys).toEqual(['part_b', 'part_a']); + + const index = child.indexes.find((item) => item.name === 'IX_child_pair_rank'); + expect(index).toBeDefined(); + expect(index?.columns).toEqual(['rank', 'part_a']); + expect(index?.isPrimary).toBe(false); + expect(index?.indexType).toBe('NONCLUSTERED'); + + expect(child.foreignKeys).toHaveLength(1); + expect(child.foreignKeys[0]).toMatchObject({ + name: 'FK_child_pair_parent', + columns: ['part_a', 'part_b'], + referencedTable: `[${scratchSchema}].[${parentTable}]`, + referencedColumns: ['part_a', 'part_b'], + onDelete: 'CASCADE', + }); + + expect(child.checkConstraints).toHaveLength(1); + expect(child.checkConstraints[0]?.name).toBe('CK_child_pair_rank'); + expect(child.checkConstraints[0]?.expression.toLowerCase()).toContain('rank'); + expect(child.checkConstraints[0]?.expression).toContain('0'); + }); + + it('preserves a non-default column collation or refuses incomplete metadata', async () => { + let response: TableSchemaPayload | undefined; + let error = ''; + try { + response = await tableSchema(collationTable); + } catch (cause) { + error = String(cause); + } + + if (response) { + const dataType = response.columns.find((column) => column.name === 'text_value')?.dataType ?? ''; + expect(dataType.toLowerCase()).toContain(nonDefaultCollation.toLowerCase()); + } else { + expect(error.toLowerCase()).toMatch(/collation|unsupported|not represent/); + } + }); +}); diff --git a/packages/drivers/sqlserver/src/metadata.rs b/packages/drivers/sqlserver/src/metadata.rs index 74a651db8..b956ffe32 100644 --- a/packages/drivers/sqlserver/src/metadata.rs +++ b/packages/drivers/sqlserver/src/metadata.rs @@ -516,6 +516,93 @@ mod tests { assert_eq!(keys, vec!["z", "a"]); } + #[test] + fn duplicate_primary_key_ordinals_are_rejected() { + let mut first = vec![ + text("first"), + text("int"), + bit(false), + bit(false), + None, + None, + bit(true), + Some(Value::Integer(1)), + bit(false), + Some(Value::Integer(0)), + bit(false), + bit(false), + bit(false), + bit(false), + bit(false), + bit(false), + ]; + let mut second = first.clone(); + first[0] = text("first"); + second[0] = text("second"); + + let parsed = parse_columns(result(vec![first, second])); + assert!( + matches!(parsed, Err(DriverError::QueryFailed(ref message)) if message.contains("duplicate") || message.contains("ordinal")), + "duplicate primary-key ordinals should fail as incomplete metadata, got {parsed:?}" + ); + } + + #[test] + fn duplicate_secondary_index_ordinals_are_rejected() { + let index_row = |column: &str| { + vec![ + Some(Value::Integer(2)), + text("ix_pair"), + bit(false), + bit(false), + text("NONCLUSTERED"), + bit(false), + bit(false), + bit(false), + bit(false), + Some(Value::Integer(1)), + text(column), + bit(false), + bit(false), + text("FG"), + ] + }; + + let parsed = parse_indexes(result(vec![index_row("first"), index_row("second")])); + assert!( + matches!(parsed, Err(DriverError::QueryFailed(ref message)) if message.contains("duplicate") || message.contains("ordinal")), + "duplicate secondary-index ordinals should fail as incomplete metadata, got {parsed:?}" + ); + } + + #[test] + fn duplicate_foreign_key_ordinals_are_rejected() { + let foreign_key_row = |local: &str, remote: &str| { + vec![ + text("fk_pair"), + Some(Value::Integer(1)), + text(local), + text("dbo"), + text("parent"), + text(remote), + text("NO_ACTION"), + text("NO_ACTION"), + bit(false), + bit(false), + bit(false), + ] + }; + + let parsed = parse_foreign_keys(result(vec![ + foreign_key_row("local_a", "remote_a"), + foreign_key_row("local_b", "remote_b"), + ])); + assert!( + matches!(parsed, Err(DriverError::QueryFailed(ref message)) if message.contains("duplicate") || message.contains("ordinal")), + "duplicate foreign-key ordinals should fail as incomplete metadata, got {parsed:?}" + ); + } + #[test] fn filtered_and_untrusted_objects_fail_closed() { let index_row = vec![ From 092e6dbdb1fcdf1dbae162a3c4060c7eda869721 Mon Sep 17 00:00:00 2001 From: probe Date: Tue, 29 Sep 2026 23:25:16 +0800 Subject: [PATCH 003/666] fix(sqlserver): reject ambiguous metadata ordinals --- .../sqlserver/e2e/sqlserver-metadata.ts | 13 +--- packages/drivers/sqlserver/src/metadata.rs | 78 ++++++++++++++++++- packages/drivers/sqlserver/src/sqlserver.rs | 1 + 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts index 8653764b9..dc85708cb 100644 --- a/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts +++ b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts @@ -261,20 +261,13 @@ describe('SQL Server schema metadata IPC (live)', () => { expect(child.checkConstraints[0]?.expression).toContain('0'); }); - it('preserves a non-default column collation or refuses incomplete metadata', async () => { - let response: TableSchemaPayload | undefined; + it('refuses non-default collations that TableSchema cannot represent', async () => { let error = ''; try { - response = await tableSchema(collationTable); + await tableSchema(collationTable); } catch (cause) { error = String(cause); } - - if (response) { - const dataType = response.columns.find((column) => column.name === 'text_value')?.dataType ?? ''; - expect(dataType.toLowerCase()).toContain(nonDefaultCollation.toLowerCase()); - } else { - expect(error.toLowerCase()).toMatch(/collation|unsupported|not represent/); - } + expect(error.toLowerCase()).toMatch(/collation|unsupported|not represent/); }); }); diff --git a/packages/drivers/sqlserver/src/metadata.rs b/packages/drivers/sqlserver/src/metadata.rs index b956ffe32..ca2f3a2f3 100644 --- a/packages/drivers/sqlserver/src/metadata.rs +++ b/packages/drivers/sqlserver/src/metadata.rs @@ -4,7 +4,7 @@ use datazen_driver_api::{ CheckConstraint, ColumnSchema, DriverError, ForeignKeyDeferrability, ForeignKeyInfo, IndexInfo, QueryResult, Value, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; fn catalog_prefix(database: &str) -> String { let database = database.trim(); @@ -46,7 +46,9 @@ pub(crate) fn columns_sql(database: &str) -> String { CAST(CASE WHEN tp.name IN ('timestamp', 'rowversion') THEN 1 ELSE 0 END AS bit) AS is_rowversion, \ CAST(CASE WHEN ic.object_id IS NOT NULL AND \ (TRY_CONVERT(decimal(38,0), ic.seed_value) <> 1 OR TRY_CONVERT(decimal(38,0), ic.increment_value) <> 1) \ - THEN 1 ELSE 0 END AS bit) AS has_nondefault_identity \ + THEN 1 ELSE 0 END AS bit) AS has_nondefault_identity, \ + c.collation_name AS column_collation, \ + CAST(DATABASEPROPERTYEX(CASE WHEN @P3 = N'' THEN DB_NAME() ELSE @P3 END, 'Collation') AS nvarchar(128)) AS database_collation \ FROM {catalog}sys.columns c \ JOIN {catalog}sys.objects o ON o.object_id = c.object_id \ JOIN {catalog}sys.types tp ON tp.user_type_id = c.user_type_id \ @@ -124,10 +126,21 @@ pub(crate) fn parse_columns( let mut columns = Vec::with_capacity(result.rows.len()); let mut key_columns = Vec::new(); for row in result.rows { + if row.len() < 18 { + return Err(incomplete("catalog row omitted column collation metadata")); + } let name = required_text(&row, 0, "column name")?; let data_type = required_text(&row, 1, "declared column type")?; let is_primary_key = required_bool(&row, 6, "primary key flag")?; let key_ordinal = required_integer(&row, 7, "primary key ordinal")?; + let database_collation = required_text(&row, 17, "database collation")?; + if let Some(column_collation) = optional_text(&row, 16).filter(|value| !value.is_empty()) { + if !column_collation.eq_ignore_ascii_case(&database_collation) { + return Err(unsupported(format!( + "SQL Server column '{name}' uses non-default collation '{column_collation}', which ColumnSchema cannot represent safely" + ))); + } + } for (index, reason) in [ (8, "computed columns"), (9, "generated-always columns"), @@ -164,6 +177,10 @@ pub(crate) fn parse_columns( is_auto_increment: required_bool(&row, 3, "identity flag")?, }); } + ensure_unique_ordinals( + key_columns.iter().map(|(ordinal, _)| *ordinal), + "primary-key", + )?; key_columns.sort_by_key(|(ordinal, _)| *ordinal); Ok(( columns, @@ -256,6 +273,10 @@ pub(crate) fn parse_indexes(result: QueryResult) -> Result, Drive index.name ))); } + ensure_unique_ordinals( + index.columns.iter().map(|(ordinal, _)| *ordinal), + &format!("index '{}' key", index.name), + )?; parsed.push(IndexInfo { name: index.name, columns: index.columns.into_iter().map(|(_, name)| name).collect(), @@ -331,6 +352,10 @@ pub(crate) fn parse_foreign_keys(result: QueryResult) -> Result Result, + label: &str, +) -> Result<(), DriverError> { + let mut seen = HashSet::new(); + for ordinal in ordinals { + if !seen.insert(ordinal) { + return Err(incomplete(format!("duplicate {label} ordinal {ordinal}"))); + } + } + Ok(()) +} + pub(crate) fn parse_checks(result: QueryResult) -> Result, DriverError> { result .rows @@ -480,6 +518,9 @@ mod tests { assert!(columns.contains("QUOTENAME(tp_schema.name)")); assert!(columns.contains("[db]]].sys.schemas tp_schema")); assert!(columns.contains("s.name = @P1 AND o.name = @P2")); + assert!( + columns.contains("DATABASEPROPERTYEX(CASE WHEN @P3 = N'' THEN DB_NAME() ELSE @P3 END") + ); assert!(!columns.contains("@P1'")); assert!(indexes_sql("db").contains("ic.key_ordinal")); assert!(foreign_keys_sql("db").contains("fkc.constraint_column_id")); @@ -505,6 +546,8 @@ mod tests { bit(false), bit(false), bit(false), + None, + text("Latin1_General_100_CI_AS_SC_UTF8"), ]; let mut b = a.clone(); a[0] = text("a"); @@ -535,6 +578,8 @@ mod tests { bit(false), bit(false), bit(false), + None, + text("Latin1_General_100_CI_AS_SC_UTF8"), ]; let mut second = first.clone(); first[0] = text("first"); @@ -603,6 +648,35 @@ mod tests { ); } + #[test] + fn nondefault_column_collation_is_rejected() { + let row = vec![ + text("label"), + text("nvarchar(40)"), + bit(true), + bit(false), + None, + None, + bit(false), + Some(Value::Integer(0)), + bit(false), + Some(Value::Integer(0)), + bit(false), + bit(false), + bit(false), + bit(false), + bit(false), + bit(false), + text("Latin1_General_100_CI_AS_SC_UTF8"), + text("SQL_Latin1_General_CP1_CI_AS"), + ]; + + assert!(matches!( + parse_columns(result(vec![row])), + Err(DriverError::Unsupported(message)) if message.contains("non-default collation") + )); + } + #[test] fn filtered_and_untrusted_objects_fail_closed() { let index_row = vec![ diff --git a/packages/drivers/sqlserver/src/sqlserver.rs b/packages/drivers/sqlserver/src/sqlserver.rs index d1eb9249a..9bc7280e3 100644 --- a/packages/drivers/sqlserver/src/sqlserver.rs +++ b/packages/drivers/sqlserver/src/sqlserver.rs @@ -914,6 +914,7 @@ impl DatabaseDriver for SqlServerDriver { let parameters = [ Value::String(schema.to_string()), Value::String(table.to_string()), + Value::String(database.trim().to_string()), ]; let column_rows = Self::run_with_params(client, &crate::metadata::columns_sql(database), ¶meters) From de424d8651b448cf973063457447ba2b838e2f23 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 00:06:34 +0800 Subject: [PATCH 004/666] feat(sync): add SQL Server data sync support --- .../drivers/sqlserver/src/sync_adapter.rs | 78 +++++- src-tauri/src/commands/sync/apply.rs | 243 ++++++++++++++---- src-tauri/src/commands/sync/exec.rs | 15 +- src-tauri/src/commands/sync/inspect.rs | 44 +++- src-tauri/src/commands/sync/keyset_source.rs | 28 +- src-tauri/src/commands/sync/tests.rs | 10 +- src-tauri/src/data_sync/gate.rs | 49 ++++ src-tauri/src/data_sync/keyset.rs | 158 +++++++++--- src-tauri/src/data_sync/mod.rs | 5 +- src-tauri/src/data_sync/pairing.rs | 16 +- src-tauri/src/data_sync/sql.rs | 128 ++++++--- 11 files changed, 636 insertions(+), 138 deletions(-) diff --git a/packages/drivers/sqlserver/src/sync_adapter.rs b/packages/drivers/sqlserver/src/sync_adapter.rs index 3826ad7e7..f174e4bda 100644 --- a/packages/drivers/sqlserver/src/sync_adapter.rs +++ b/packages/drivers/sqlserver/src/sync_adapter.rs @@ -1,8 +1,9 @@ //! SQL Server sync adapter. +use datazen_driver_api::sync::contract_from_column; use datazen_driver_api::{ BoxedSyncAdapter, ColumnSchema, IRColumn, IRDefault, IRType, SyncAdapterFactory, - SyncSourceAdapter, SyncTargetAdapter, Value, + SyncKeyContract, SyncKeyKind, SyncSourceAdapter, SyncTargetAdapter, Value, }; pub struct SqlServerSyncAdapter; @@ -95,6 +96,43 @@ fn base_type(raw: &str) -> String { // ── SyncSourceAdapter ────────────────────────────────────────────── impl SyncSourceAdapter for SqlServerSyncAdapter { + fn sync_key_contract(&self, column: &ColumnSchema) -> Result { + let lower = column.data_type.trim().to_ascii_lowercase(); + let base = lower.split(['(', ' ', ',']).next().unwrap_or_default(); + match base { + // SQL Server string comparisons can ignore trailing spaces and + // depend on a database collation. The shared bytewise contract + // cannot represent those semantics, so refuse string keys until a + // driver-owned ordering/equality contract can prove parity. + "char" | "nchar" | "varchar" | "nvarchar" | "text" | "ntext" => Err(format!( + "SQL Server text key type '{}' has collation and trailing-space semantics that Data Sync cannot verify safely", + column.data_type + )), + // SQL Server's UNIQUEIDENTIFIER ordering differs from the generic + // lexical UUID order, so it must not enter the merge/keyset path. + "uniqueidentifier" => Err( + "SQL Server UNIQUEIDENTIFIER keys do not have a verified Data Sync ordering contract".into(), + ), + // SQL Server exposes rowversion through the legacy `timestamp` + // type name; it is a generated version token, never a row key. + "timestamp" | "rowversion" => Err( + "SQL Server rowversion columns cannot be used as Data Sync keys".into(), + ), + "datetime2" | "datetimeoffset" => { + let precision = lower + .split_once('(') + .and_then(|(_, rest)| rest.strip_suffix(')')) + .and_then(|value| value.parse::().ok()) + .unwrap_or(7); + Ok(SyncKeyContract::reject_nulls(SyncKeyKind::Timestamp { + with_timezone: base == "datetimeoffset", + precision, + })) + } + _ => contract_from_column(column), + } + } + /// Rebuild declared type dimensions that INFORMATION_SCHEMA omits, so a /// transfer preserves bounded strings and decimal precision/scale. fn full_column_types_query(&self, table: &str) -> Option { @@ -409,6 +447,44 @@ mod tests { ); } + #[test] + fn sqlserver_sync_key_contract_fails_closed_for_collated_and_guid_keys() { + let adapter = SqlServerSyncAdapter; + for ty in [ + "varchar(32)", + "nvarchar(64)", + "uniqueidentifier", + "rowversion", + ] { + let error = adapter + .sync_key_contract(&col("key", ty)) + .expect_err("key ordering must be verified before paging"); + assert!(!error.is_empty(), "{ty}"); + } + } + + #[test] + fn sqlserver_sync_key_contract_preserves_decimal_and_datetime_semantics() { + let adapter = SqlServerSyncAdapter; + assert!(matches!( + adapter.sync_key_contract(&col("key", "decimal(38, 18)")), + Ok(SyncKeyContract { + kind: SyncKeyKind::Decimal { scale: Some(18) }, + null_policy: datazen_driver_api::SyncKeyNullPolicy::Reject, + }) + )); + assert!(matches!( + adapter.sync_key_contract(&col("key", "datetimeoffset(7)")), + Ok(SyncKeyContract { + kind: SyncKeyKind::Timestamp { + with_timezone: true, + precision: 7, + }, + null_policy: datazen_driver_api::SyncKeyNullPolicy::Reject, + }) + )); + } + #[test] fn sqlserver_nvarchar_to_ir() { let ir = SqlServerSyncAdapter.column_to_ir(&col("name", "nvarchar(100)"), None); diff --git a/src-tauri/src/commands/sync/apply.rs b/src-tauri/src/commands/sync/apply.rs index 2abd34bbf..cdd2a6640 100644 --- a/src-tauri/src/commands/sync/apply.rs +++ b/src-tauri/src/commands/sync/apply.rs @@ -7,21 +7,13 @@ use super::inspect::inspect_data_sync_impl; use super::keyset_source::DriverKeysetSource; use super::plans; use crate::data_sync::{ - compare_table_pages_to_sink, generate_table_sql_with_preview_formatter_and_policy, - mysql_placeholder, postgres_typed_placeholder, quote_ident_sql, ChangeSet, ComparisonResult, - DataSyncError, SyncOptions, SyncSourceFilter, TableMapping, TableMappingStatus, TableResult, + compare_table_pages_to_sink, generate_table_sql_with_qualified_table_and_policy, + ChangeOperation, ChangeSet, ComparisonResult, ConflictPolicy, DataSyncError, SyncOptions, + SyncSourceFilter, TableChangeSet, TableMapping, TableMappingStatus, TableResult, }; use crate::services::metadata_schema; use std::collections::HashMap; -fn ident_quote(family: &str) -> char { - if family == "mysql" { - '`' - } else { - '"' - } -} - pub(crate) async fn compare_data_sync_impl( state: &AppState, source_db_session_id: String, @@ -36,6 +28,20 @@ pub(crate) async fn compare_data_sync_impl( mappings: &[TableMapping], source_filters: &HashMap, ) -> Result { + let source_config = state + .connection_manager + .get_session_config(&source_db_session_id) + .await + .cmd_err("compare_data_sync")?; + let target_config = state + .connection_manager + .get_session_config(&target_db_session_id) + .await + .cmd_err("compare_data_sync")?; + crate::data_sync::require_data_sync_family( + &source_config.database_type, + &target_config.database_type, + )?; let (src_driver, src_handle) = state .connection_manager .get_session(&source_db_session_id) @@ -141,7 +147,6 @@ async fn compare_data_sync_impl_inner( &src_config.database_type, &tgt_config.database_type, )?; - let quote = ident_quote(&family); let (src_driver, src_handle) = state .connection_manager .get_session(&source_db_session_id) @@ -152,6 +157,8 @@ async fn compare_data_sync_impl_inner( .get_session(&target_db_session_id) .await .cmd_err("compare_data_sync")?; + let source_quote = src_driver.quote_char(); + let target_quote = tgt_driver.quote_char(); let source_schema = metadata_schema( src_driver.as_ref(), source_schema.as_deref(), @@ -301,7 +308,7 @@ async fn compare_data_sync_impl_inner( source_schema.clone(), column_names.clone(), pk_columns.clone(), - quote, + source_quote, &family, src_key_adapter.clone(), src_contracts, @@ -317,7 +324,7 @@ async fn compare_data_sync_impl_inner( target_schema.clone(), column_names.clone(), pk_columns.clone(), - quote, + target_quote, &family, tgt_key_adapter.clone(), tgt_contracts, @@ -468,6 +475,72 @@ fn resolve_projection_types( .collect() } +fn sqlserver_write_preflight( + schema: &datazen_driver_api::TableSchema, + changes: &TableChangeSet, + conflict_policy: ConflictPolicy, + target_supports_explicit_identity: bool, +) -> Result<(), String> { + let has_insert = changes + .changes + .iter() + .any(|change| change.operation == ChangeOperation::Insert); + if has_insert && !target_supports_explicit_identity { + if let Some(column) = schema + .columns + .iter() + .find(|column| column.is_auto_increment) + { + return Err(format!( + "SQL Server Data Sync cannot insert selected rows into identity column '{}' because scoped IDENTITY_INSERT support is unavailable", + column.name + )); + } + } + + let needs_optimistic_condition = conflict_policy != ConflictPolicy::Force + && changes.changes.iter().any(|change| { + matches!( + change.operation, + ChangeOperation::Update | ChangeOperation::Delete + ) + }); + if needs_optimistic_condition { + let primary_keys = schema.effective_primary_keys(); + for column in &schema.columns { + if primary_keys.iter().any(|key| key == &column.name) { + continue; + } + let base_type = column + .data_type + .trim() + .split(['(', ' ', ',']) + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + if matches!( + base_type.as_str(), + "char" | "nchar" | "varchar" | "nvarchar" | "text" | "ntext" + ) { + return Err(format!( + "SQL Server optimistic write conditions cannot verify column '{}' exactly because collation and trailing-space comparison semantics are unavailable; choose an explicit conflict policy that does not compare the prior row or use a table without this column type", + column.name + )); + } + if matches!( + base_type.as_str(), + "xml" | "image" | "geography" | "geometry" | "hierarchyid" | "sql_variant" + ) { + return Err(format!( + "SQL Server optimistic write conditions do not support equality for column '{}' of type '{}'; refusing to generate UPDATE/DELETE SQL", + column.name, column.data_type + )); + } + } + } + Ok(()) +} + pub(crate) async fn generate_data_sync_sql_impl( state: &AppState, target_db_session_id: String, @@ -490,7 +563,6 @@ pub(crate) async fn generate_data_sync_sql_impl( &tgt_config.database_type, &tgt_config.database_type, )?; - let quote = ident_quote(&family); let (tgt_driver, tgt_handle) = state .connection_manager .get_session(&target_db_session_id) @@ -532,6 +604,15 @@ pub(crate) async fn generate_data_sync_sql_impl( ) .await .cmd_err("generate_data_sync_sql")?; + if family == "sqlserver" { + sqlserver_write_preflight( + &schema, + table, + options.conflict_policy, + preview_target.supports_explicit_identity_values(), + ) + .map_err(CommandError::Validation)?; + } let projection = comparison .tables .iter() @@ -561,31 +642,26 @@ pub(crate) async fn generate_data_sync_sql_impl( })?; Ok(preview_target.format_literal(value, ir_type)) }; - let stmts = if family == "mysql" { - generate_table_sql_with_preview_formatter_and_policy( - table, - Some(&target_database_name), - &pk, - &column_names, - &column_types, - |n| quote_ident_sql(n, quote), - |idx, _| mysql_placeholder(idx), - options.conflict_policy, - preview_literal, - ) - } else { - generate_table_sql_with_preview_formatter_and_policy( - table, - target_schema.as_deref(), - &pk, - &column_names, - &column_types, - |n| quote_ident_sql(n, quote), - postgres_typed_placeholder, - options.conflict_policy, - preview_literal, - ) - } + let qualified_table = preview_target.qualify_relation( + &target_database_name, + target_schema.as_deref(), + &table.target_table, + ); + let stmts = generate_table_sql_with_qualified_table_and_policy( + table, + &qualified_table, + pk, + column_names, + &column_types, + |name| preview_target.quote_ident(name), + |index, data_type| { + tgt_driver + .parameter_placeholder(index, data_type) + .map_err(|error| DataSyncError::validation(error.to_string())) + }, + options.conflict_policy, + preview_literal, + ) .map_err(CommandError::from)?; statements.extend(stmts); } @@ -667,13 +743,92 @@ pub(crate) async fn revalidate_data_sync_impl( #[cfg(test)] mod tests { - use super::ident_quote; + use super::sqlserver_write_preflight; use crate::commands::sync::types::{resolve_options, SyncOptionsInput}; + use crate::data_sync::{ChangeOperation, ConflictPolicy, RowChange, TableChangeSet}; + use datazen_driver_api::{ColumnSchema, TableSchema, Value}; + + #[test] + fn sqlserver_write_preflight_rejects_identity_inserts_without_session_support() { + let schema = test_sqlserver_schema(true); + let changes = TableChangeSet { + source_table: "source".into(), + target_table: "target".into(), + changes: vec![RowChange { + operation: ChangeOperation::Insert, + key: vec![Value::Integer(1)], + source_row: Some(vec![ + Some(Value::Integer(1)), + Some(Value::String("x".into())), + ]), + target_row: None, + changed_columns: Vec::new(), + selected: true, + }], + }; + let error = sqlserver_write_preflight(&schema, &changes, ConflictPolicy::Abort, false) + .expect_err("IDENTITY_INSERT must not be silently skipped"); + assert!(error.contains("IDENTITY_INSERT"), "{error}"); + } #[test] - fn mysql_uses_backticks_postgres_uses_double_quotes() { - assert_eq!(ident_quote("mysql"), '`'); - assert_eq!(ident_quote("postgresql"), '"'); + fn sqlserver_write_preflight_rejects_unverifiable_string_conditions() { + let schema = test_sqlserver_schema(false); + let changes = TableChangeSet { + source_table: "source".into(), + target_table: "target".into(), + changes: vec![RowChange { + operation: ChangeOperation::Update, + key: vec![Value::Integer(1)], + source_row: Some(vec![ + Some(Value::Integer(1)), + Some(Value::String("new".into())), + ]), + target_row: Some(vec![ + Some(Value::Integer(1)), + Some(Value::String("old".into())), + ]), + changed_columns: vec!["label".into()], + selected: true, + }], + }; + let error = sqlserver_write_preflight(&schema, &changes, ConflictPolicy::Abort, false) + .expect_err("case/trailing-space changes require exact SQL equality"); + assert!(error.contains("collation and trailing-space"), "{error}"); + assert!( + sqlserver_write_preflight(&schema, &changes, ConflictPolicy::Force, false,).is_ok() + ); + } + + fn test_sqlserver_schema(identity: bool) -> TableSchema { + TableSchema { + table_name: "target".into(), + columns: vec![ + ColumnSchema { + name: "id".into(), + data_type: "int".into(), + nullable: false, + default_value: None, + comment: None, + is_primary_key: true, + is_auto_increment: identity, + }, + ColumnSchema { + name: "label".into(), + data_type: "nvarchar(64)".into(), + nullable: true, + default_value: None, + comment: None, + is_primary_key: false, + is_auto_increment: false, + }, + ], + primary_keys: vec!["id".into()], + indexes: Vec::new(), + foreign_keys: Vec::new(), + check_constraints: Vec::new(), + table_options: Default::default(), + } } #[test] diff --git a/src-tauri/src/commands/sync/exec.rs b/src-tauri/src/commands/sync/exec.rs index b50f27437..c34e16b07 100644 --- a/src-tauri/src/commands/sync/exec.rs +++ b/src-tauri/src/commands/sync/exec.rs @@ -584,11 +584,16 @@ mod tests { use crate::data_sync::{ ChangeOperation, ComparisonResult, ConflictPolicy, RowChange, TableResult, }; - use crate::testing::app_state::TestAppState; + use crate::testing::app_state::{rich_mock_options, TestAppState}; + use crate::testing::mock_driver::MockDriverOptions; #[tokio::test] async fn execution_streams_a_plan_larger_than_64_mib_in_order_without_full_load() { - let test = TestAppState::with_tables().await; + let test = TestAppState::with_options(MockDriverOptions { + parameterized_writes: true, + ..rich_mock_options() + }) + .await; test.save_and_connect("sync-stream-target").await; let target_db_session_id = test.connect_config("sync-stream-target").await; @@ -767,7 +772,11 @@ mod tests { #[tokio::test] async fn test_tester_bounded_sql_preview_returns_selected_rows_in_order() { - let test = TestAppState::with_tables().await; + let test = TestAppState::with_options(MockDriverOptions { + parameterized_writes: true, + ..rich_mock_options() + }) + .await; test.save_and_connect("sync-preview-target").await; let target_db_session_id = test.connect_config("sync-preview-target").await; let options = SyncOptions::default(); diff --git a/src-tauri/src/commands/sync/inspect.rs b/src-tauri/src/commands/sync/inspect.rs index fad210849..a897d08b9 100644 --- a/src-tauri/src/commands/sync/inspect.rs +++ b/src-tauri/src/commands/sync/inspect.rs @@ -87,6 +87,7 @@ pub(crate) async fn inspect_data_sync_impl( ); let mut source_schemas = HashMap::new(); + let mut source_schema_errors = HashMap::new(); for table in src_tables .iter() .filter(|t| matches!(t.table_type, crate::db::TableType::Table)) @@ -97,14 +98,21 @@ pub(crate) async fn inspect_data_sync_impl( table.schema.as_deref(), None, ); - if let Ok(schema) = src_driver + match src_driver .get_table_schema(&src_handle, &table.name, &src_db, table_schema.as_deref()) .await { - source_schemas.insert(table.name.clone(), schema); + Ok(schema) => { + source_schemas.insert(table.name.clone(), schema); + } + Err(error) if src_config.database_type == "sqlserver" => { + source_schema_errors.insert(table.name.clone(), error.to_string()); + } + Err(_) => {} } } let mut target_schemas = HashMap::new(); + let mut target_schema_errors = HashMap::new(); for table in tgt_tables .iter() .filter(|t| matches!(t.table_type, crate::db::TableType::Table)) @@ -115,11 +123,17 @@ pub(crate) async fn inspect_data_sync_impl( table.schema.as_deref(), None, ); - if let Ok(schema) = tgt_driver + match tgt_driver .get_table_schema(&tgt_handle, &table.name, &tgt_db, table_schema.as_deref()) .await { - target_schemas.insert(table.name.clone(), schema); + Ok(schema) => { + target_schemas.insert(table.name.clone(), schema); + } + Err(error) if tgt_config.database_type == "sqlserver" => { + target_schema_errors.insert(table.name.clone(), error.to_string()); + } + Err(_) => {} } } @@ -136,6 +150,28 @@ pub(crate) async fn inspect_data_sync_impl( &target_schemas, ); for result in &mut results { + if result.status == crate::data_sync::TableMappingStatus::Incompatible { + let source_error = source_schema_errors.get(&result.source_table); + let target_error = target_schema_errors.get(&result.target_table); + match (source_error, target_error) { + (Some(source), Some(target)) => { + result.incompatible_reason = Some(format!( + "SQL Server schema preflight failed; source: {source}; target: {target}" + )); + } + (Some(error), None) => { + result.incompatible_reason = Some(format!( + "SQL Server source schema preflight failed: {error}" + )); + } + (None, Some(error)) => { + result.incompatible_reason = Some(format!( + "SQL Server target schema preflight failed: {error}" + )); + } + (None, None) => {} + } + } if result.status != crate::data_sync::TableMappingStatus::Matched { continue; } diff --git a/src-tauri/src/commands/sync/keyset_source.rs b/src-tauri/src/commands/sync/keyset_source.rs index 73f41ff29..a49e6ff0b 100644 --- a/src-tauri/src/commands/sync/keyset_source.rs +++ b/src-tauri/src/commands/sync/keyset_source.rs @@ -7,8 +7,8 @@ use async_trait::async_trait; use datazen_driver_api::{SyncKeyContract, SyncKeyValue, SyncSourceAdapter, Value}; use crate::data_sync::{ - build_keyset_select_sql_with_order_filter_and_pagination, quote_ident_sql, DataSyncError, Row, - RowPageSource, SyncSourceFilter, + build_keyset_select_sql_with_order_filter_and_pagination, keyset_seek_parameter_count, + quote_ident_sql, DataSyncError, Row, RowPageSource, SyncSourceFilter, }; use crate::db::{ConnectionHandle, DatabaseDriver, SqlTarget}; @@ -52,6 +52,15 @@ impl DriverKeysetSource { "normalized key contract count does not match primary key columns", )); } + let pagination = driver.pagination_syntax(1, 0); + if pagination.clause.trim().is_empty() { + return Err(DataSyncError::validation( + "driver does not provide a Data Sync keyset pagination clause", + )); + } + driver + .parameter_placeholder(1, None) + .map_err(|error| DataSyncError::validation(error.to_string()))?; let key_order_expressions = pk_columns .iter() .zip(&key_contracts) @@ -117,7 +126,7 @@ impl RowPageSource for DriverKeysetSource { Some(filter) => filter .build_where_typed_with_key_order( quote, - after_key.map_or(0, |key| key.len()) + 1, + keyset_seek_parameter_count(self.pk_columns.len(), after_key.is_some()) + 1, (self.pk_columns.len() == 1).then(|| self.pk_columns[0].as_str()), Some((&self.pk_columns, &self.key_order_expressions)), |column, value| { @@ -150,6 +159,11 @@ impl RowPageSource for DriverKeysetSource { .driver .pagination_syntax(u64::from(page_limit.max(1)), 0) .clause; + if pagination_clause.trim().is_empty() { + return Err(DataSyncError::validation( + "driver does not provide a Data Sync keyset pagination clause", + )); + } let (sql, params) = build_keyset_select_sql_with_order_filter_and_pagination( &self.table, self.database.as_deref(), @@ -164,13 +178,7 @@ impl RowPageSource for DriverKeysetSource { |i| { self.driver .parameter_placeholder(i, None) - .unwrap_or_else(|_| { - if family == "mysql" { - "?".into() - } else { - format!("${i}") - } - }) + .map_err(|error| DataSyncError::validation(error.to_string())) }, filter_sql .as_deref() diff --git a/src-tauri/src/commands/sync/tests.rs b/src-tauri/src/commands/sync/tests.rs index 7522fa263..1f5efc76f 100644 --- a/src-tauri/src/commands/sync/tests.rs +++ b/src-tauri/src/commands/sync/tests.rs @@ -819,9 +819,14 @@ async fn cancel_data_sync_stops_execute_before_start() { #[tokio::test] async fn compare_rejects_mock_driver_that_repeats_keyset_pages() { - use crate::testing::app_state::TestAppState; + use crate::testing::app_state::{rich_mock_options, TestAppState}; + use crate::testing::mock_driver::MockDriverOptions; - let test = TestAppState::with_tables().await; + let test = TestAppState::with_options(MockDriverOptions { + parameterized_writes: true, + ..rich_mock_options() + }) + .await; test.save_and_connect("src-cmp").await; test.save_and_connect("tgt-cmp").await; let src = test.connect_config("src-cmp").await; @@ -864,6 +869,7 @@ async fn generated_binary_preview_uses_the_target_driver_literal_renderer() { ); let test = TestAppState::with_options(MockDriverOptions { table_schema: Some(schema), + parameterized_writes: true, ..MockDriverOptions::default() }) .await; diff --git a/src-tauri/src/data_sync/gate.rs b/src-tauri/src/data_sync/gate.rs index 33e06e3bb..ca3973a46 100644 --- a/src-tauri/src/data_sync/gate.rs +++ b/src-tauri/src/data_sync/gate.rs @@ -16,6 +16,7 @@ pub enum CompatCode { ColumnExtra, TypeMismatch, NullabilityMismatch, + WriteabilityMismatch, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -142,6 +143,34 @@ pub fn check_table_gate(family: &str, source: &TableSchema, target: &TableSchema ), }); } + // SQL Server identity columns affect which DML operations can + // write a value. Treat a source/target mismatch as a hard gate; + // otherwise the preview could contain a write that only fails + // after the apply transaction has started. + if family == "sqlserver" && src_col.is_auto_increment != tgt_col.is_auto_increment { + issues.push(CompatIssue { + code: CompatCode::WriteabilityMismatch, + column: Some(src_col.name.clone()), + message: format!( + "column '{}' has different SQL Server identity/writeability attributes (source={} target={})", + src_col.name, src_col.is_auto_increment, tgt_col.is_auto_increment + ), + }); + } + if family == "sqlserver" + && (src_col.is_auto_increment || tgt_col.is_auto_increment) + && (!src_pk.iter().any(|pk| pk == &src_col.name) + || !tgt_pk.iter().any(|pk| pk == &tgt_col.name)) + { + issues.push(CompatIssue { + code: CompatCode::WriteabilityMismatch, + column: Some(src_col.name.clone()), + message: format!( + "SQL Server identity column '{}' must be part of the matching primary key; non-key identity columns cannot be safely synchronized", + src_col.name + ), + }); + } } } @@ -260,6 +289,26 @@ mod tests { } } + #[test] + fn sqlserver_identity_attributes_must_match_and_identity_must_be_a_key() { + let mut source = schema("t", vec![col("id", "INT", false, true)], vec!["id"]); + source.columns[0].is_auto_increment = true; + let mut target = source.clone(); + target.columns[0].is_auto_increment = false; + let result = check_table_gate("sqlserver", &source, &target); + assert!(matches!(result, GateVerdict::Incompatible { ref issues } + if issues.iter().any(|issue| issue.code == CompatCode::WriteabilityMismatch))); + + target = source.clone(); + source.columns.push(col("sequence", "INT", false, false)); + target.columns.push(col("sequence", "INT", false, false)); + source.columns[1].is_auto_increment = true; + target.columns[1].is_auto_increment = true; + let result = check_table_gate("sqlserver", &source, &target); + assert!(matches!(result, GateVerdict::Incompatible { ref issues } + if issues.iter().any(|issue| issue.code == CompatCode::WriteabilityMismatch))); + } + #[test] fn extra_and_missing_columns_listed() { let src = schema( diff --git a/src-tauri/src/data_sync/keyset.rs b/src-tauri/src/data_sync/keyset.rs index d0e784e57..4bda9509e 100644 --- a/src-tauri/src/data_sync/keyset.rs +++ b/src-tauri/src/data_sync/keyset.rs @@ -22,7 +22,7 @@ pub fn build_keyset_select_sql

( placeholder: P, ) -> Result<(String, Vec), DataSyncError> where - P: Fn(usize) -> String, + P: Fn(usize) -> Result, { let key_idents = pk_columns .iter() @@ -62,7 +62,7 @@ pub fn build_keyset_select_sql_with_order

( placeholder: P, ) -> Result<(String, Vec), DataSyncError> where - P: Fn(usize) -> String, + P: Fn(usize) -> Result, { build_keyset_select_sql_with_order_and_filter( table, @@ -99,7 +99,7 @@ pub fn build_keyset_select_sql_with_order_and_filter

( filter: Option<(&str, &[Value])>, ) -> Result<(String, Vec), DataSyncError> where - P: Fn(usize) -> String, + P: Fn(usize) -> Result, { let pagination_clause = format!("LIMIT {}", limit.max(1)); build_keyset_select_sql_with_order_filter_and_pagination( @@ -133,7 +133,7 @@ pub fn build_keyset_select_sql_with_order_filter_and_pagination

( filter: Option<(&str, &[Value])>, ) -> Result<(String, Vec), DataSyncError> where - P: Fn(usize) -> String, + P: Fn(usize) -> Result, { if pk_columns.is_empty() { return Err(DataSyncError::validation( @@ -179,10 +179,32 @@ where let mut params = Vec::new(); let mut where_clauses = Vec::new(); if let Some(key) = after_key { - let pk_idents = key_order_expressions.join(", "); - let placeholders: Vec = (1..=pk_columns.len()).map(|i| placeholder(i)).collect(); - params.extend_from_slice(key); - where_clauses.push(format!("({pk_idents}) > ({})", placeholders.join(", "))); + // T-SQL has no row-value comparison syntax. Expand the seek into a + // portable lexicographic disjunction, keeping every expression in the + // same order used by ORDER BY. A placeholder is emitted for each + // occurrence so both anonymous (`?`) and positional drivers can bind + // the predicate without dialect-specific rewriting. + let mut next_parameter = 1usize; + let mut alternatives = Vec::with_capacity(key.len()); + for index in 0..key.len() { + let mut terms = Vec::with_capacity(index + 1); + for equal_index in 0..index { + let ph = placeholder(next_parameter)?; + next_parameter += 1; + terms.push(format!("{} = {ph}", key_order_expressions[equal_index])); + params.push(key[equal_index].clone()); + } + let ph = placeholder(next_parameter)?; + next_parameter += 1; + terms.push(format!("{} > {ph}", key_order_expressions[index])); + params.push(key[index].clone()); + alternatives.push(format!("({})", terms.join(" AND "))); + } + where_clauses.push(if alternatives.len() == 1 { + alternatives.remove(0) + } else { + format!("({})", alternatives.join(" OR ")) + }); } if let Some((filter_sql, filter_params)) = filter { if filter_sql.trim().is_empty() { @@ -214,11 +236,29 @@ where Ok((sql, params)) } +/// Number of bound seek parameters emitted for a composite key's portable +/// lexicographic predicate, or zero when there is no `after_key`. +pub fn keyset_seek_parameter_count(key_count: usize, has_after_key: bool) -> usize { + if has_after_key { + key_count.saturating_mul(key_count.saturating_add(1)) / 2 + } else { + 0 + } +} + #[cfg(test)] mod tests { use super::*; use crate::data_sync::{mysql_placeholder, postgres_placeholder}; + fn mysql_ph(index: usize) -> Result { + Ok(mysql_placeholder(index)) + } + + fn postgres_ph(index: usize) -> Result { + Ok(postgres_placeholder(index)) + } + fn cols() -> Vec { vec!["id".into(), "name".into(), "age".into()] } @@ -243,7 +283,7 @@ mod tests { None, 100, '`', - mysql_placeholder, + mysql_ph, ) .unwrap(); assert!(params.is_empty()); @@ -265,7 +305,7 @@ mod tests { None, 100, '`', - mysql_placeholder, + mysql_ph, ) .unwrap(); assert!(params.is_empty()); @@ -287,14 +327,14 @@ mod tests { Some(&[Value::Integer(42)]), 50, '`', - mysql_placeholder, + mysql_ph, ) .unwrap(); assert_eq!(params.len(), 1); assert!(matches!(params[0], Value::Integer(42))); assert_eq!( sql, - "SELECT `id`, `name`, `age` FROM `users` WHERE (`id`) > (?) ORDER BY `id` ASC LIMIT 50" + "SELECT `id`, `name`, `age` FROM `users` WHERE (`id` > ?) ORDER BY `id` ASC LIMIT 50" ); } @@ -311,15 +351,16 @@ mod tests { Some(&[Value::Integer(1), Value::String("east".into())]), 10, '`', - mysql_placeholder, + mysql_ph, ) .unwrap(); - assert_eq!(params.len(), 2); + assert_eq!(params.len(), 3); assert!(matches!(params[0], Value::Integer(1))); - assert!(matches!(params[1], Value::String(ref s) if s == "east")); + assert!(matches!(params[1], Value::Integer(1))); + assert!(matches!(params[2], Value::String(ref s) if s == "east")); assert_eq!( sql, - "SELECT `tenant`, `region`, `n` FROM `shards` WHERE (`tenant`, `region`) > (?, ?) \ + "SELECT `tenant`, `region`, `n` FROM `shards` WHERE ((`tenant` > ?) OR (`tenant` = ? AND `region` > ?)) \ ORDER BY `tenant` ASC, `region` ASC LIMIT 10" ); } @@ -336,7 +377,7 @@ mod tests { None, 25, '"', - postgres_placeholder, + postgres_ph, ) .unwrap(); assert!(params.is_empty()); @@ -359,16 +400,17 @@ mod tests { Some(&[Value::Integer(2), Value::String("west".into())]), 5, '"', - postgres_placeholder, + postgres_ph, ) .unwrap(); - assert_eq!(params.len(), 2); + assert_eq!(params.len(), 3); assert!(matches!(params[0], Value::Integer(2))); - assert!(matches!(params[1], Value::String(ref s) if s == "west")); + assert!(matches!(params[1], Value::Integer(2))); + assert!(matches!(params[2], Value::String(ref s) if s == "west")); assert_eq!( sql, "SELECT \"tenant\", \"region\", \"n\" FROM \"shards\" \ - WHERE (\"tenant\", \"region\") > ($1, $2) \ + WHERE ((\"tenant\" > $1) OR (\"tenant\" = $2 AND \"region\" > $3)) \ ORDER BY \"tenant\" ASC, \"region\" ASC LIMIT 5" ); } @@ -386,7 +428,7 @@ mod tests { Some(&[Value::Integer(9)]), 10, '"', - postgres_placeholder, + postgres_ph, Some(("WHERE (\"name\" = $2)", &[Value::String("active".into())])), ) .unwrap(); @@ -396,7 +438,7 @@ mod tests { )); assert_eq!( sql, - "SELECT \"id\", \"name\", \"age\" FROM \"users\" WHERE (\"id\") > ($1) AND ((\"name\" = $2)) ORDER BY \"id\" ASC LIMIT 10" + "SELECT \"id\", \"name\", \"age\" FROM \"users\" WHERE (\"id\" > $1) AND ((\"name\" = $2)) ORDER BY \"id\" ASC LIMIT 10" ); } @@ -417,9 +459,9 @@ mod tests { Some(&[Value::String("0".into()), Value::Integer(9)]), 25, '"', - postgres_placeholder, + postgres_ph, Some(( - r#"WHERE ("tenant" COLLATE "C", "id") >= ($3::text, $4::integer)"#, + r#"WHERE ("tenant" COLLATE "C", "id") >= ($4::text, $5::integer)"#, &filter_params, )), ) @@ -428,14 +470,15 @@ mod tests { params.as_slice(), [ Value::String(seek_tenant), + Value::String(seek_tenant_equal), Value::Integer(seek_id), Value::String(filter_tenant), Value::String(filter_id), - ] if seek_tenant == "0" && *seek_id == 9 && filter_tenant == "1" && filter_id == "5" + ] if seek_tenant == "0" && seek_tenant_equal == "0" && *seek_id == 9 && filter_tenant == "1" && filter_id == "5" )); assert_eq!( sql, - r#"SELECT "tenant", "id", "value" FROM "public"."events" WHERE ("tenant" COLLATE "C", "id") > ($1, $2) AND (("tenant" COLLATE "C", "id") >= ($3::text, $4::integer)) ORDER BY "tenant" COLLATE "C" ASC, "id" ASC LIMIT 25"# + r#"SELECT "tenant", "id", "value" FROM "public"."events" WHERE (("tenant" COLLATE "C" > $1) OR ("tenant" COLLATE "C" = $2 AND "id" > $3)) AND (("tenant" COLLATE "C", "id") >= ($4::text, $5::integer)) ORDER BY "tenant" COLLATE "C" ASC, "id" ASC LIMIT 25"# ); } @@ -452,17 +495,64 @@ mod tests { Some(&[Value::Integer(9)]), "OFFSET 0 ROWS FETCH NEXT 25 ROWS ONLY", '[', - |index| format!("@p{index}"), + |index| Ok(format!("@p{index}")), None, ) .unwrap(); assert!(matches!(params.as_slice(), [Value::Integer(9)])); assert_eq!( sql, - "SELECT [id], [name], [age] FROM [dbo].[users] WHERE ([id]) > (@p1) ORDER BY [id] ASC OFFSET 0 ROWS FETCH NEXT 25 ROWS ONLY" + "SELECT [id], [name], [age] FROM [dbo].[users] WHERE ([id] > @p1) ORDER BY [id] ASC OFFSET 0 ROWS FETCH NEXT 25 ROWS ONLY" + ); + } + + #[test] + fn sqlserver_composite_keyset_uses_lexicographic_seek_and_driver_pagination() { + let columns = vec!["tenant".into(), "sequence".into(), "value".into()]; + let keys = vec!["tenant".into(), "sequence".into()]; + let (sql, params) = build_keyset_select_sql_with_order_filter_and_pagination( + "events", + Some("archive"), + Some("dbo"), + "sqlserver", + &columns, + &keys, + &["[tenant]".into(), "[sequence]".into()], + Some(&[Value::Integer(3), Value::Integer(12)]), + "OFFSET 0 ROWS FETCH NEXT 25 ROWS ONLY", + '[', + |index| Ok(format!("@P{index}")), + None, + ) + .unwrap(); + assert!(matches!( + params.as_slice(), + [Value::Integer(3), Value::Integer(3), Value::Integer(12)] + )); + assert_eq!( + sql, + "SELECT [tenant], [sequence], [value] FROM [archive].[dbo].[events] WHERE (([tenant] > @P1) OR ([tenant] = @P2 AND [sequence] > @P3)) ORDER BY [tenant] ASC, [sequence] ASC OFFSET 0 ROWS FETCH NEXT 25 ROWS ONLY" ); } + #[test] + fn keyset_placeholder_error_is_returned_before_query_execution() { + let error = build_keyset_select_sql( + "events", + None, + Some("dbo"), + "sqlserver", + &cols(), + &pk1(), + Some(&[Value::Integer(1)]), + 10, + '[', + |_| Err(DataSyncError::validation("parameter limit exceeded")), + ) + .expect_err("unsupported placeholder must not degrade to another dialect"); + assert!(error.to_string().contains("parameter limit exceeded")); + } + #[test] fn rejects_empty_pk() { let err = build_keyset_select_sql( @@ -475,7 +565,7 @@ mod tests { None, 1, '"', - postgres_placeholder, + postgres_ph, ) .unwrap_err(); assert!(err.to_string().contains("primary key")); @@ -493,7 +583,7 @@ mod tests { Some(&[Value::Integer(1)]), 1, '"', - postgres_placeholder, + postgres_ph, ) .unwrap_err(); assert!(err.to_string().contains("after_key length")); @@ -511,7 +601,7 @@ mod tests { None, 10, '"', - postgres_placeholder, + postgres_ph, ) .unwrap(); assert!(params.is_empty()); @@ -534,11 +624,11 @@ mod tests { Some(&[Value::String("a".into())]), 10, '"', - postgres_placeholder, + postgres_ph, ) .unwrap(); assert_eq!(params.len(), 1); - assert!(sql.contains(r#"WHERE ("id" COLLATE "C") > ($1)"#)); + assert!(sql.contains(r#"WHERE ("id" COLLATE "C" > $1)"#)); assert!(sql.contains(r#"ORDER BY "id" COLLATE "C" ASC"#)); } } diff --git a/src-tauri/src/data_sync/mod.rs b/src-tauri/src/data_sync/mod.rs index 27233a0b5..147037738 100644 --- a/src-tauri/src/data_sync/mod.rs +++ b/src-tauri/src/data_sync/mod.rs @@ -40,7 +40,7 @@ pub use gate::{check_table_gate, CompatCode, CompatIssue, GateVerdict}; pub use keyset::{ build_keyset_select_sql, build_keyset_select_sql_with_order, build_keyset_select_sql_with_order_and_filter, - build_keyset_select_sql_with_order_filter_and_pagination, + build_keyset_select_sql_with_order_filter_and_pagination, keyset_seek_parameter_count, }; pub use legacy::{ is_overwrite_copy_retired_message, refuse_overwrite_copy, OVERWRITE_COPY_RETIRED, @@ -59,7 +59,8 @@ pub use recordset::{ pub use session::SyncSession; pub use sql::{ generate_table_sql, generate_table_sql_with_preview_formatter, - generate_table_sql_with_preview_formatter_and_policy, mysql_placeholder, postgres_placeholder, + generate_table_sql_with_preview_formatter_and_policy, + generate_table_sql_with_qualified_table_and_policy, mysql_placeholder, postgres_placeholder, postgres_typed_placeholder, quote_ident_sql, SqlStatement, }; pub use state::SyncPhase; diff --git a/src-tauri/src/data_sync/pairing.rs b/src-tauri/src/data_sync/pairing.rs index 43d06cde8..f47cf297f 100644 --- a/src-tauri/src/data_sync/pairing.rs +++ b/src-tauri/src/data_sync/pairing.rs @@ -5,7 +5,7 @@ use crate::transfer::pairing::{normalize_sync_family, resolve_sync_pairing, Sync use super::error::DataSyncError; /// Families implemented for Data Synchronization V1 (P0). -pub const V1_FAMILIES: &[&str] = &["mysql", "postgresql"]; +pub const V1_FAMILIES: &[&str] = &["mysql", "postgresql", "sqlserver"]; pub fn is_v1_family(family: &str) -> bool { V1_FAMILIES.contains(&family) @@ -109,6 +109,17 @@ mod tests { assert_eq!(family_of("cloudberry"), "postgresql"); } + #[test] + fn sqlserver_is_direct_only_within_its_family() { + assert_eq!( + require_data_sync_family("sqlserver", "mssql").unwrap(), + "sqlserver" + ); + assert!(classify_data_sync_pair("sqlserver", "postgresql") + .reason + .is_some()); + } + #[test] fn pg_to_mysql_is_transfer_not_sync() { let err = require_data_sync_family("postgresql", "mysql").unwrap_err(); @@ -133,8 +144,9 @@ mod tests { fn v1_family_helpers() { assert!(is_v1_family("mysql")); assert!(is_v1_family("postgresql")); + assert!(is_v1_family("sqlserver")); assert!(!is_v1_family("sqlite")); - assert_eq!(V1_FAMILIES.len(), 2); + assert_eq!(V1_FAMILIES.len(), 3); } #[test] diff --git a/src-tauri/src/data_sync/sql.rs b/src-tauri/src/data_sync/sql.rs index 9a9c6c004..d1c88a4b9 100644 --- a/src-tauri/src/data_sync/sql.rs +++ b/src-tauri/src/data_sync/sql.rs @@ -248,6 +248,41 @@ where Q: Fn(&str) -> String + Copy, P: Fn(usize, Option<&str>) -> String, L: Fn(&str, &Option, Option<&str>) -> Result, +{ + let qualified_table = qualify_table_ident(target_schema, &table.target_table, quote_ident); + generate_table_sql_with_qualified_table_and_policy( + table, + &qualified_table, + pk_columns, + column_names, + column_types, + quote_ident, + |index, data_type| Ok(placeholder(index, data_type)), + conflict_policy, + preview_literal, + ) +} + +/// Generate a Data Sync ChangeSet using a relation string rendered by the +/// target driver adapter and fallible driver-owned parameter placeholders. +/// This is needed for dialects such as SQL Server, whose target reference may +/// include database, schema, and table parts and whose placeholder indexes +/// have a finite driver-defined range. +pub fn generate_table_sql_with_qualified_table_and_policy( + table: &TableChangeSet, + qualified_table: &str, + pk_columns: &[String], + column_names: &[String], + column_types: &[String], + quote_ident: Q, + placeholder: P, + conflict_policy: ConflictPolicy, + preview_literal: L, +) -> Result, DataSyncError> +where + Q: Fn(&str) -> String + Copy, + P: Fn(usize, Option<&str>) -> Result, + L: Fn(&str, &Option, Option<&str>) -> Result, { if pk_columns.is_empty() { return Err(DataSyncError::validation( @@ -258,7 +293,7 @@ where for change in &table.changes { out.push(statement_for_change( &table.target_table, - target_schema, + qualified_table, change, pk_columns, column_names, @@ -274,7 +309,7 @@ where fn statement_for_change( table: &str, - schema: Option<&str>, + qualified_table: &str, change: &RowChange, pk_columns: &[String], column_names: &[String], @@ -286,13 +321,13 @@ fn statement_for_change( ) -> Result where Q: Fn(&str) -> String, - P: Fn(usize, Option<&str>) -> String, + P: Fn(usize, Option<&str>) -> Result, L: Fn(&str, &Option, Option<&str>) -> Result, { match change.operation { ChangeOperation::Insert => insert_sql( table, - schema, + qualified_table, change, column_names, column_types, @@ -302,7 +337,7 @@ where ), ChangeOperation::Update => update_sql( table, - schema, + qualified_table, change, pk_columns, column_names, @@ -314,7 +349,7 @@ where ), ChangeOperation::Delete => delete_sql( table, - schema, + qualified_table, change, pk_columns, column_names, @@ -330,17 +365,9 @@ where } } -fn sql_table_ref String>( - table: &str, - schema: Option<&str>, - quote_ident: &Q, -) -> String { - qualify_table_ident(schema, table, quote_ident) -} - fn insert_sql( table: &str, - schema: Option<&str>, + qualified_table: &str, change: &RowChange, column_names: &[String], column_types: &[String], @@ -350,7 +377,7 @@ fn insert_sql( ) -> Result where Q: Fn(&str) -> String, - P: Fn(usize, Option<&str>) -> String, + P: Fn(usize, Option<&str>) -> Result, L: Fn(&str, &Option, Option<&str>) -> Result, { let row = change @@ -372,20 +399,19 @@ where let mut placeholders = Vec::new(); for (i, cell) in row.iter().enumerate() { let col_type = column_types.get(i).map(|s| s.as_str()); - placeholders.push(placeholder(i + 1, col_type)); + placeholders.push(placeholder(i + 1, col_type)?); params.push(cell.clone().unwrap_or(Value::Null)); preview_vals.push(preview_literal(&column_names[i], cell, col_type)?); } - let qtable = sql_table_ref(table, schema, quote_ident); Ok(SqlStatement { table: table.into(), operation: ChangeOperation::Insert, sql: format!( - "INSERT INTO {qtable} ({cols}) VALUES ({})", + "INSERT INTO {qualified_table} ({cols}) VALUES ({})", placeholders.join(", ") ), preview_sql: format!( - "INSERT INTO {qtable} ({cols}) VALUES ({})", + "INSERT INTO {qualified_table} ({cols}) VALUES ({})", preview_vals.join(", ") ), parameters: params, @@ -395,7 +421,7 @@ where fn update_sql( table: &str, - schema: Option<&str>, + qualified_table: &str, change: &RowChange, pk_columns: &[String], column_names: &[String], @@ -407,7 +433,7 @@ fn update_sql( ) -> Result where Q: Fn(&str) -> String, - P: Fn(usize, Option<&str>) -> String, + P: Fn(usize, Option<&str>) -> Result, L: Fn(&str, &Option, Option<&str>) -> Result, { let row = change @@ -432,7 +458,7 @@ where set_ph.push(format!( "{} = {}", quote_ident(col), - placeholder(idx, col_type) + placeholder(idx, col_type)? )); set_lit.push(format!( "{} = {}", @@ -471,17 +497,16 @@ where params.extend(expected_params); let where_ph = join_where_clauses(&where_ph, &expected_ph); let where_lit = join_where_clauses(&where_lit, &expected_lit); - let qtable = sql_table_ref(table, schema, quote_ident); Ok(SqlStatement { table: table.into(), operation: ChangeOperation::Update, sql: format!( - "UPDATE {qtable} SET {} WHERE {}", + "UPDATE {qualified_table} SET {} WHERE {}", set_ph.join(", "), where_ph ), preview_sql: format!( - "UPDATE {qtable} SET {} WHERE {}", + "UPDATE {qualified_table} SET {} WHERE {}", set_lit.join(", "), where_lit ), @@ -492,7 +517,7 @@ where fn delete_sql( table: &str, - schema: Option<&str>, + qualified_table: &str, change: &RowChange, pk_columns: &[String], column_names: &[String], @@ -504,7 +529,7 @@ fn delete_sql( ) -> Result where Q: Fn(&str) -> String, - P: Fn(usize, Option<&str>) -> String, + P: Fn(usize, Option<&str>) -> Result, L: Fn(&str, &Option, Option<&str>) -> Result, { let target_row = change.target_row.as_ref(); @@ -546,12 +571,11 @@ where let where_lit = join_where_clauses(&where_lit, &expected_lit); let mut params = params; params.extend(expected_params); - let qtable = sql_table_ref(table, schema, quote_ident); Ok(SqlStatement { table: table.into(), operation: ChangeOperation::Delete, - sql: format!("DELETE FROM {qtable} WHERE {where_ph}"), - preview_sql: format!("DELETE FROM {qtable} WHERE {where_lit}"), + sql: format!("DELETE FROM {qualified_table} WHERE {where_ph}"), + preview_sql: format!("DELETE FROM {qualified_table} WHERE {where_lit}"), parameters: params, row_key: change.key.clone(), }) @@ -584,7 +608,7 @@ fn where_expected_target( ) -> Result<(String, String, Vec), DataSyncError> where Q: Fn(&str) -> String, - P: Fn(usize, Option<&str>) -> String, + P: Fn(usize, Option<&str>) -> Result, L: Fn(&str, &Option, Option<&str>) -> Result, { let target_row = target_row.ok_or_else(|| { @@ -607,8 +631,8 @@ where let expected = target_row[position].clone().unwrap_or(Value::Null); let expected_option = Some(expected.clone()); let col_type = column_types.get(position).map(|s| s.as_str()); - let first = placeholder(index, col_type); - let second = placeholder(index + 1, col_type); + let first = placeholder(index, col_type)?; + let second = placeholder(index + 1, col_type)?; let first_lit = preview_literal(col, &expected_option, col_type)?; let second_lit = preview_literal(col, &expected_option, col_type)?; ph.push(format!( @@ -636,7 +660,7 @@ fn where_pk( ) -> Result<(String, String, Vec), DataSyncError> where Q: Fn(&str) -> String, - P: Fn(usize, Option<&str>) -> String, + P: Fn(usize, Option<&str>) -> Result, L: Fn(&str, &Option, Option<&str>) -> Result, { if pk_columns.len() != key.len() { @@ -657,7 +681,7 @@ where } v => { let col_type = column_type(column_names, column_types, col); - ph.push(format!("{} = {}", ident, placeholder(index, col_type))); + ph.push(format!("{} = {}", ident, placeholder(index, col_type)?)); lit.push(format!( "{} = {}", ident, @@ -827,6 +851,38 @@ mod tests { ); } + #[test] + fn qualified_target_generator_uses_driver_relation_and_parameter_placeholders() { + let options = SyncOptions::default(); + let insert = RowChange::insert( + vec![Value::Integer(7)], + vec![Some(Value::Integer(7)), Some(Value::String("Ada".into()))], + &options, + ); + let table = TableChangeSet { + source_table: "users".into(), + target_table: "users".into(), + changes: vec![insert], + }; + let statements = generate_table_sql_with_qualified_table_and_policy( + &table, + "[archive].[sales].[users]", + &["id".into()], + &["id".into(), "name".into()], + &["int".into(), "nvarchar(32)".into()], + |name| quote_ident_sql(name, '['), + |index, _| Ok(format!("@P{index}")), + ConflictPolicy::Abort, + |_, value, _| Ok(format_literal(value)), + ) + .unwrap(); + assert_eq!( + statements[0].sql, + "INSERT INTO [archive].[sales].[users] ([id], [name]) VALUES (@P1, @P2)" + ); + assert_eq!(statements[0].parameters.len(), 2); + } + #[test] fn qualify_relation_sql_mysql_uses_database() { assert_eq!( From db5f26e5f471a2634df736feb4d22a31ad49eefa Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 00:06:40 +0800 Subject: [PATCH 005/666] feat(sqlserver): add schema diff migration renderer --- packages/drivers/sqlserver/src/lib.rs | 4 + packages/drivers/sqlserver/src/migration.rs | 20 + .../sqlserver/src/migration/renderer.rs | 470 ++++++++++++++++++ .../drivers/sqlserver/src/migration/sql.rs | 336 +++++++++++++ .../drivers/sqlserver/src/migration/tests.rs | 314 ++++++++++++ .../sqlserver/src/migration/validation.rs | 353 +++++++++++++ .../drivers/sqlserver/src/migration/views.rs | 214 ++++++++ packages/drivers/sqlserver/src/sqlserver.rs | 98 +++- .../drivers/sqlserver/src/type_normalizer.rs | 75 +++ 9 files changed, 1883 insertions(+), 1 deletion(-) create mode 100644 packages/drivers/sqlserver/src/migration.rs create mode 100644 packages/drivers/sqlserver/src/migration/renderer.rs create mode 100644 packages/drivers/sqlserver/src/migration/sql.rs create mode 100644 packages/drivers/sqlserver/src/migration/tests.rs create mode 100644 packages/drivers/sqlserver/src/migration/validation.rs create mode 100644 packages/drivers/sqlserver/src/migration/views.rs create mode 100644 packages/drivers/sqlserver/src/type_normalizer.rs diff --git a/packages/drivers/sqlserver/src/lib.rs b/packages/drivers/sqlserver/src/lib.rs index e715141bc..885df3cbd 100644 --- a/packages/drivers/sqlserver/src/lib.rs +++ b/packages/drivers/sqlserver/src/lib.rs @@ -6,13 +6,17 @@ use datazen_driver_api::*; mod admin_commands; mod metadata; +mod migration; mod parameters; mod sql_target; mod sqlserver; mod structure; mod sync_adapter; +mod type_normalizer; +pub use migration::{SqlServerMigrationCapabilities, SqlServerMigrationRenderer}; pub use sqlserver::*; pub use sync_adapter::SqlServerSyncAdapter; +pub use type_normalizer::SqlServerTypeNormalizer; struct SqlServerFactory; impl DatabaseDriverFactory for SqlServerFactory { diff --git a/packages/drivers/sqlserver/src/migration.rs b/packages/drivers/sqlserver/src/migration.rs new file mode 100644 index 000000000..ff0728fd9 --- /dev/null +++ b/packages/drivers/sqlserver/src/migration.rs @@ -0,0 +1,20 @@ +//! SQL Server Schema Diff rendering. +//! +//! Only the catalog features represented by the shared migration IR are +//! emitted. Metadata that cannot be represented, such as filtered or +//! descending indexes, is rejected by the SQL Server catalog reader before a +//! plan can be produced. + +use datazen_driver_api::*; +pub struct SqlServerMigrationRenderer; + +pub struct SqlServerMigrationCapabilities; + +mod renderer; +mod sql; +mod validation; +mod views; + +#[cfg(test)] +#[path = "migration/tests.rs"] +mod tests; diff --git a/packages/drivers/sqlserver/src/migration/renderer.rs b/packages/drivers/sqlserver/src/migration/renderer.rs new file mode 100644 index 000000000..e400275b8 --- /dev/null +++ b/packages/drivers/sqlserver/src/migration/renderer.rs @@ -0,0 +1,470 @@ +use super::sql::*; +use super::validation::*; +use super::views::*; +use super::*; +use std::collections::HashSet; + +impl MigrationRenderer for SqlServerMigrationRenderer { + fn render(&self, operation: &MigrationOperation) -> Result { + match operation { + MigrationOperation::CreateTable { + table, + columns, + primary_keys, + } => create_table_sql(table, columns, primary_keys), + MigrationOperation::DropTable { table } => { + let table = relation(table)?; + Ok(MigrationStatement { + sql: format!("DROP TABLE {table}"), + risk: MigrationRisk::Destructive, + rollback_sql: None, + summary: format!("DROP TABLE {table}"), + }) + } + MigrationOperation::AddColumn { table, column } => { + let table_sql = relation(table)?; + let definition = validate_column(column)?; + let mut sql = format!("ALTER TABLE {table_sql} ADD {definition}"); + if let Some(comment) = column.comment.as_deref() { + sql.push_str("; "); + sql.push_str(&comment_property_sql(table, &column.name, Some(comment))?); + } + let mut rollback = drop_default_for_column_sql(column, table)?; + if column.comment.is_some() { + rollback.push_str("; "); + rollback.push_str(&comment_property_sql(table, &column.name, None)?); + } + rollback.push_str(&format!( + " ALTER TABLE {table_sql} DROP COLUMN {}", + quote_ident(&column.name)? + )); + Ok(MigrationStatement { + sql, + risk: MigrationRisk::Additive, + rollback_sql: Some(rollback), + summary: format!("ADD COLUMN {}.{}", table, column.name), + }) + } + MigrationOperation::DropColumn { table, column } => { + let table_sql = relation(table)?; + let mut sql = drop_default_for_column_sql(column, table)?; + sql.push_str(&format!( + " ALTER TABLE {table_sql} DROP COLUMN {}", + quote_ident(&column.name)? + )); + Ok(MigrationStatement { + sql, + risk: MigrationRisk::Destructive, + rollback_sql: None, + summary: format!("DROP COLUMN {}.{}", table, column.name), + }) + } + MigrationOperation::AlterColumnType { + table, column, to, .. + } => { + let to = validate_type(to)?; + Ok(MigrationStatement { + sql: alter_column_type_sql(table, column, &to)?, + risk: MigrationRisk::Rewrite, + rollback_sql: None, + summary: format!("ALTER TYPE {table}.{column}"), + }) + } + MigrationOperation::SetNullable { + table, + column, + nullable, + } => Ok(MigrationStatement { + sql: catalog_type_sql(table, column, *nullable)?, + risk: if *nullable { + MigrationRisk::Additive + } else { + MigrationRisk::Rewrite + }, + rollback_sql: None, + summary: format!("ALTER NULLABILITY {table}.{column}"), + }), + MigrationOperation::SetDefault { + table, + column, + from, + to, + } => Ok(MigrationStatement { + sql: set_default_sql(table, column, from.as_deref(), to.as_deref())?, + risk: MigrationRisk::Rewrite, + rollback_sql: Some(set_default_sql( + table, + column, + to.as_deref(), + from.as_deref(), + )?), + summary: format!("ALTER DEFAULT {table}.{column}"), + }), + MigrationOperation::SetComment { + table, + column, + from, + to, + } => Ok(MigrationStatement { + sql: comment_property_sql(table, column, to.as_deref())?, + risk: MigrationRisk::Additive, + rollback_sql: Some(comment_property_sql(table, column, from.as_deref())?), + summary: format!("ALTER COMMENT {table}.{column}"), + }), + MigrationOperation::SetTableOptions { .. } => { + Err("SQL Server table options are outside the shared migration model".into()) + } + MigrationOperation::SetAutoIncrement { .. } => Err( + "SQL Server IDENTITY changes require a table rebuild and are not supported".into(), + ), + MigrationOperation::AddPrimaryKey { table, columns } => { + validate_unique_columns(columns, "primary key")?; + let table = relation(table)?; + let columns_sql = columns + .iter() + .map(|column| quote_ident(column)) + .collect::, _>>()? + .join(", "); + Ok(MigrationStatement { + sql: format!("ALTER TABLE {table} ADD PRIMARY KEY ({columns_sql})"), + risk: MigrationRisk::Additive, + rollback_sql: None, + summary: format!("ADD PRIMARY KEY {table}"), + }) + } + MigrationOperation::DropPrimaryKey { table, columns } => { + let mut sql = primary_key_lookup(table, columns)?; + let table_sql = relation(table)?; + sql.push_str(&format!( + " EXEC(N'ALTER TABLE {table_sql} DROP CONSTRAINT ' + QUOTENAME(@datazen_pk_name));" + )); + Ok(MigrationStatement { + sql, + risk: MigrationRisk::Destructive, + rollback_sql: None, + summary: format!("DROP PRIMARY KEY {table}"), + }) + } + MigrationOperation::CreateIndex { table, index } => render_index_create(table, index), + MigrationOperation::DropIndex { table, index } => { + if index.is_primary { + return Err( + "SQL Server primary-key indexes must be removed with DropPrimaryKey" + .into(), + ); + } + let table = relation(table)?; + let name = quote_ident(&index.name)?; + let is_constraint = index.index_type.starts_with("UNIQUE_CONSTRAINT:"); + let rollback = render_index_create(&table, index)?.sql; + Ok(MigrationStatement { + sql: if is_constraint { + format!("ALTER TABLE {table} DROP CONSTRAINT {name}") + } else { + format!("DROP INDEX {name} ON {table}") + }, + risk: MigrationRisk::Destructive, + rollback_sql: Some(rollback), + summary: format!("DROP INDEX {} on {table}", index.name), + }) + } + MigrationOperation::AddForeignKey { table, foreign_key } => { + validate_unique_columns(&foreign_key.columns, "foreign key")?; + validate_unique_columns(&foreign_key.referenced_columns, "referenced key")?; + if foreign_key.columns.len() != foreign_key.referenced_columns.len() { + return Err("SQL Server foreign-key column lists must have equal lengths".into()); + } + if foreign_key.deferrability != ForeignKeyDeferrability::NotDeferrable { + return Err("SQL Server foreign keys must be proven non-deferrable".into()); + } + let table = relation(table)?; + let referenced = relation(&foreign_key.referenced_table)?; + if relation_parts(&foreign_key.referenced_table)?.len() != 2 { + return Err("SQL Server foreign keys must reference a schema-qualified table in the same database".into()); + } + let name = quote_ident(&foreign_key.name)?; + let columns = foreign_key + .columns + .iter() + .map(|column| quote_ident(column)) + .collect::, _>>()? + .join(", "); + let referenced_columns = foreign_key + .referenced_columns + .iter() + .map(|column| quote_ident(column)) + .collect::, _>>()? + .join(", "); + let action = |value: &str, clause: &str| -> Result { + let action = value.trim().to_ascii_uppercase(); + match action.as_str() { + "" | "NO ACTION" => Ok(String::new()), + "CASCADE" | "SET NULL" | "SET DEFAULT" => { + Ok(format!(" ON {clause} {action}")) + } + other => Err(format!("unsupported SQL Server foreign-key action '{other}'")), + } + }; + let update = action(&foreign_key.on_update, "UPDATE")?; + let delete = action(&foreign_key.on_delete, "DELETE")?; + Ok(MigrationStatement { + sql: format!( + "ALTER TABLE {table} ADD CONSTRAINT {name} FOREIGN KEY ({columns}) REFERENCES {referenced} ({referenced_columns}){delete}{update}" + ), + risk: MigrationRisk::Additive, + rollback_sql: Some(format!("ALTER TABLE {table} DROP CONSTRAINT {name}")), + summary: format!("ADD FOREIGN KEY {}.{}", table, foreign_key.name), + }) + } + MigrationOperation::DropForeignKey { table, foreign_key } => { + let table_sql = relation(table)?; + let rollback = self + .render(&MigrationOperation::AddForeignKey { + table: table.clone(), + foreign_key: foreign_key.clone(), + })? + .sql; + Ok(MigrationStatement { + sql: format!( + "ALTER TABLE {table_sql} DROP CONSTRAINT {}", + quote_ident(&foreign_key.name)? + ), + risk: MigrationRisk::Destructive, + rollback_sql: Some(rollback), + summary: format!("DROP FOREIGN KEY {}.{}", table, foreign_key.name), + }) + } + MigrationOperation::AddCheckConstraint { table, constraint } => { + validate_check_expression(&constraint.expression)?; + let table = relation(table)?; + let name = quote_ident(&constraint.name)?; + let expression = literal_expression(&constraint.expression, "CHECK expression")?; + Ok(MigrationStatement { + sql: format!("ALTER TABLE {table} ADD CONSTRAINT {name} CHECK ({expression})"), + risk: MigrationRisk::Additive, + rollback_sql: Some(format!("ALTER TABLE {table} DROP CONSTRAINT {name}")), + summary: format!("ADD CHECK {}.{}", table, constraint.name), + }) + } + MigrationOperation::DropCheckConstraint { table, constraint } => { + validate_check_expression(&constraint.expression)?; + let table = relation(table)?; + let name = quote_ident(&constraint.name)?; + let rollback = self + .render(&MigrationOperation::AddCheckConstraint { + table: table.clone(), + constraint: constraint.clone(), + })? + .sql; + Ok(MigrationStatement { + sql: format!("ALTER TABLE {table} DROP CONSTRAINT {name}"), + risk: MigrationRisk::Destructive, + rollback_sql: Some(rollback), + summary: format!("DROP CHECK {}.{}", table, constraint.name), + }) + } + MigrationOperation::CreateView { view } => { + let (ident, sql) = validate_view(view)?; + Ok(MigrationStatement { + sql, + risk: MigrationRisk::Additive, + rollback_sql: Some(format!("DROP VIEW {ident}")), + summary: format!("CREATE VIEW {}", view.name), + }) + } + MigrationOperation::ReplaceView { current, desired } => { + if current.schema != desired.schema || current.name != desired.name { + return Err("SQL Server view replacement identities must match exactly".into()); + } + let (_, desired_sql) = validate_view(desired)?; + let (_, current_sql) = validate_view(current)?; + Ok(MigrationStatement { + sql: to_alter_view(&desired_sql)?, + risk: MigrationRisk::Rewrite, + rollback_sql: Some(to_alter_view(¤t_sql)?), + summary: format!("ALTER VIEW {}", desired.name), + }) + } + MigrationOperation::DropView { view } => { + let (ident, definition) = validate_view(view)?; + Ok(MigrationStatement { + sql: format!("DROP VIEW {ident}"), + risk: MigrationRisk::Destructive, + rollback_sql: Some(definition), + summary: format!("DROP VIEW {}", view.name), + }) + } + MigrationOperation::CreateRoutine { .. } + | MigrationOperation::ReplaceRoutine { .. } + | MigrationOperation::DropRoutine { .. } + | MigrationOperation::CreateTrigger { .. } + | MigrationOperation::ReplaceTrigger { .. } + | MigrationOperation::DropTrigger { .. } => Err( + "SQL Server routine and trigger migration is unsupported until exact scope, definition, and dependency rewriting is proven".into(), + ), + MigrationOperation::CreateSequence { .. } + | MigrationOperation::ReplaceSequence { .. } + | MigrationOperation::DropSequence { .. } + | MigrationOperation::CreateType { .. } + | MigrationOperation::ReplaceType { .. } + | MigrationOperation::DropType { .. } => Err( + "SQL Server sequence and user-defined type migration is outside the supported subset".into(), + ), + } + } + + fn render_create_table_with_options( + &self, + operation: &MigrationOperation, + table_options: &TableOptions, + ) -> Result { + if !table_options_supported(table_options) { + return Err("SQL Server CREATE TABLE cannot preserve the supplied table options or metadata blockers".into()); + } + self.render(operation) + } +} + +impl MigrationCapabilities for SqlServerMigrationCapabilities { + fn supports(&self, operation: &MigrationOperation) -> bool { + match operation { + MigrationOperation::CreateTable { + table, + columns, + primary_keys, + .. + } => { + relation(table).is_ok() + && !columns.is_empty() + && columns.iter().all(|column| validate_column(column).is_ok()) + && columns + .iter() + .map(|column| column.name.as_str()) + .collect::>() + .len() + == columns.len() + && (primary_keys.is_empty() + || (validate_unique_columns(primary_keys, "primary key").is_ok() + && primary_keys + .iter() + .all(|column| columns.iter().any(|c| &c.name == column)))) + && columns + .iter() + .filter(|column| column.is_auto_increment) + .count() + <= 1 + } + MigrationOperation::DropTable { table } => relation(table).is_ok(), + MigrationOperation::AddColumn { table, column } + | MigrationOperation::DropColumn { table, column } => { + relation(table).is_ok() && validate_column(column).is_ok() + } + MigrationOperation::AlterColumnType { + table, column, to, .. + } => { + relation(table).is_ok() && quote_ident(column).is_ok() && validate_type(to).is_ok() + } + MigrationOperation::SetNullable { table, column, .. } => { + relation(table).is_ok() && quote_ident(column).is_ok() + } + MigrationOperation::SetDefault { + table, + column, + from, + to, + } => set_default_sql(table, column, from.as_deref(), to.as_deref()).is_ok(), + MigrationOperation::SetComment { + table, + column, + from, + to, + } => { + comment_property_sql(table, column, to.as_deref()).is_ok() + && comment_property_sql(table, column, from.as_deref()).is_ok() + } + MigrationOperation::SetTableOptions { .. } + | MigrationOperation::SetAutoIncrement { .. } => false, + MigrationOperation::AddPrimaryKey { table, columns } + | MigrationOperation::DropPrimaryKey { table, columns } => { + relation(table).is_ok() && validate_unique_columns(columns, "primary key").is_ok() + } + MigrationOperation::CreateIndex { table, index } => { + !index.is_primary + && relation(table).is_ok() + && render_index_create(table, index).is_ok() + } + MigrationOperation::DropIndex { table, index } => { + !index.is_primary + && relation(table).is_ok() + && quote_ident(&index.name).is_ok() + && render_index_create(table, index).is_ok() + } + MigrationOperation::AddForeignKey { table, foreign_key } => { + relation(table).is_ok() + && quote_ident(&foreign_key.name).is_ok() + && relation(&foreign_key.referenced_table).is_ok_and(|_| { + relation_parts(&foreign_key.referenced_table) + .is_ok_and(|parts| parts.len() == 2) + }) + && validate_unique_columns(&foreign_key.columns, "foreign key").is_ok() + && validate_unique_columns(&foreign_key.referenced_columns, "referenced key") + .is_ok() + && foreign_key.columns.len() == foreign_key.referenced_columns.len() + && foreign_key.deferrability == ForeignKeyDeferrability::NotDeferrable + && [ + foreign_key.on_update.as_str(), + foreign_key.on_delete.as_str(), + ] + .iter() + .all(|action| { + matches!( + action.trim().to_ascii_uppercase().as_str(), + "" | "NO ACTION" | "CASCADE" | "SET NULL" | "SET DEFAULT" + ) + }) + } + MigrationOperation::DropForeignKey { table, foreign_key } => { + relation(table).is_ok() + && quote_ident(&foreign_key.name).is_ok() + && SqlServerMigrationRenderer + .render(&MigrationOperation::AddForeignKey { + table: table.clone(), + foreign_key: foreign_key.clone(), + }) + .is_ok() + } + MigrationOperation::AddCheckConstraint { table, constraint } + | MigrationOperation::DropCheckConstraint { table, constraint } => { + relation(table).is_ok() + && quote_ident(&constraint.name).is_ok() + && validate_check_expression(&constraint.expression).is_ok() + && !has_executable_separator(&constraint.expression) + } + MigrationOperation::CreateView { view } | MigrationOperation::DropView { view } => { + validate_view(view).is_ok() + } + MigrationOperation::ReplaceView { current, desired } => { + current.schema == desired.schema + && current.name == desired.name + && validate_view(current).is_ok() + && validate_view(desired).is_ok() + } + MigrationOperation::CreateRoutine { .. } + | MigrationOperation::ReplaceRoutine { .. } + | MigrationOperation::DropRoutine { .. } + | MigrationOperation::CreateTrigger { .. } + | MigrationOperation::ReplaceTrigger { .. } + | MigrationOperation::DropTrigger { .. } + | MigrationOperation::CreateSequence { .. } + | MigrationOperation::ReplaceSequence { .. } + | MigrationOperation::DropSequence { .. } + | MigrationOperation::CreateType { .. } + | MigrationOperation::ReplaceType { .. } + | MigrationOperation::DropType { .. } => false, + } + } + + fn requires_table_rebuild(&self, _operation: &MigrationOperation) -> bool { + false + } +} diff --git a/packages/drivers/sqlserver/src/migration/sql.rs b/packages/drivers/sqlserver/src/migration/sql.rs new file mode 100644 index 000000000..9a4dcd0b3 --- /dev/null +++ b/packages/drivers/sqlserver/src/migration/sql.rs @@ -0,0 +1,336 @@ +use super::validation::*; +use super::*; +use std::collections::HashSet; + +pub(super) fn default_constraint_lookup(table: &str, column: &str) -> Result { + let parts = relation_parts(table)?; + let qualified_table = relation(table)?; + let catalog = relation_catalog_prefix(&parts); + let object_literal = sql_string(&qualified_table); + let column_literal = sql_string(column); + Ok(format!( + "DECLARE @datazen_default_name sysname; DECLARE @datazen_default_definition nvarchar(max); \ + SELECT @datazen_default_name = dc.name, @datazen_default_definition = dc.definition \ + FROM {catalog}sys.default_constraints AS dc \ + JOIN {catalog}sys.columns AS c ON c.object_id = dc.parent_object_id AND c.column_id = dc.parent_column_id \ + WHERE dc.parent_object_id = OBJECT_ID({object_literal}, N'U') AND c.name = {column_literal};" + )) +} + +pub(super) fn set_default_sql( + table: &str, + column: &str, + expected_from: Option<&str>, + desired_to: Option<&str>, +) -> Result { + let table_sql = relation(table)?; + let column_sql = quote_ident(column)?; + let mut sql = default_constraint_lookup(table, column)?; + match expected_from { + Some(expected) => { + let expected = literal_expression(expected, "expected DEFAULT expression")?; + sql.push_str(&format!( + " IF @datazen_default_name IS NULL OR @datazen_default_definition <> {} \ + THROW 51000, N'SQL Server DEFAULT changed after schema comparison', 1;", + sql_string(&expected) + )); + } + None => sql.push_str( + " IF @datazen_default_name IS NOT NULL THROW 51000, N'SQL Server DEFAULT appeared after schema comparison', 1;", + ), + } + sql.push_str(&format!( + " IF @datazen_default_name IS NOT NULL EXEC(N'ALTER TABLE {table_sql} DROP CONSTRAINT ' + QUOTENAME(@datazen_default_name));" + )); + if let Some(default) = desired_to { + let default = literal_expression(default, "DEFAULT expression")?; + sql.push_str(&format!( + " ALTER TABLE {table_sql} ADD DEFAULT ({default}) FOR {column_sql};" + )); + } + Ok(sql) +} + +pub(super) fn drop_default_for_column_sql( + column: &MigrationColumn, + table: &str, +) -> Result { + let table_sql = relation(table)?; + let mut sql = default_constraint_lookup(table, &column.name)?; + match column.default_value.as_deref() { + Some(expected) => { + let expected = literal_expression(expected, "expected DEFAULT expression")?; + sql.push_str(&format!( + " IF @datazen_default_name IS NULL OR @datazen_default_definition <> {} \ + THROW 51000, N'SQL Server DEFAULT changed after schema comparison', 1;", + sql_string(&expected) + )); + } + None => sql.push_str( + " IF @datazen_default_name IS NOT NULL THROW 51000, N'SQL Server DEFAULT appeared after schema comparison', 1;", + ), + } + sql.push_str(&format!( + " IF @datazen_default_name IS NOT NULL EXEC(N'ALTER TABLE {table_sql} DROP CONSTRAINT ' + QUOTENAME(@datazen_default_name));" + )); + Ok(sql) +} + +pub(super) fn is_character_type(data_type: &str) -> bool { + let (base, _, _) = parse_type_parts(data_type); + matches!(base.as_str(), "CHAR" | "VARCHAR" | "NCHAR" | "NVARCHAR") +} + +pub(super) fn alter_column_type_sql(table: &str, column: &str, to: &str) -> Result { + let parts = relation_parts(table)?; + let catalog = relation_catalog_prefix(&parts); + let table_sql = relation(table)?; + let object_literal = sql_string(&table_sql); + let column_literal = sql_string(column); + let column_sql = quote_ident(column)?; + let mut rendered_type = sql_string(to); + if is_character_type(to) { + rendered_type = format!( + "{rendered_type} + CASE WHEN @datazen_collation IS NULL THEN N'' ELSE N' COLLATE ' + QUOTENAME(@datazen_collation) END" + ); + } + Ok(format!( + "DECLARE @datazen_nullability nvarchar(8); DECLARE @datazen_collation sysname; \ + SELECT @datazen_nullability = CASE WHEN c.is_nullable = 1 THEN N' NULL' ELSE N' NOT NULL' END, \ + @datazen_collation = c.collation_name \ + FROM {catalog}sys.columns AS c \ + WHERE c.object_id = OBJECT_ID({object_literal}, N'U') AND c.name = {column_literal}; \ + IF @datazen_nullability IS NULL THROW 51001, N'SQL Server column changed after schema comparison', 1; \ + EXEC(N'ALTER TABLE {table_sql} ALTER COLUMN {column_sql} ' + {rendered_type} + @datazen_nullability);" + )) +} + +pub(super) fn catalog_type_sql( + table: &str, + column: &str, + nullable: bool, +) -> Result { + let parts = relation_parts(table)?; + let catalog = relation_catalog_prefix(&parts); + let table_sql = relation(table)?; + let object_literal = sql_string(&table_sql); + let column_literal = sql_string(column); + let nullability = if nullable { "NULL" } else { "NOT NULL" }; + let generated_type = format!( + "CASE WHEN tp.is_user_defined = 1 THEN QUOTENAME(ts.name) + N'.' + QUOTENAME(tp.name) \ + WHEN tp.name IN ('nvarchar','nchar') THEN tp.name + N'(' + CASE WHEN c.max_length = -1 THEN N'max' ELSE CONVERT(nvarchar(10), c.max_length / 2) END + N')' \ + WHEN tp.name IN ('varchar','char','varbinary','binary') THEN tp.name + N'(' + CASE WHEN c.max_length = -1 THEN N'max' ELSE CONVERT(nvarchar(10), c.max_length) END + N')' \ + WHEN tp.name IN ('decimal','numeric') THEN tp.name + N'(' + CONVERT(nvarchar(10), c.precision) + N',' + CONVERT(nvarchar(10), c.scale) + N')' \ + WHEN tp.name IN ('time','datetime2','datetimeoffset') THEN tp.name + N'(' + CONVERT(nvarchar(10), c.scale) + N')' \ + WHEN tp.name = 'float' THEN tp.name + N'(' + CONVERT(nvarchar(10), c.precision) + N')' \ + ELSE tp.name END" + ); + let column_sql = quote_ident(column)?; + Ok(format!( + "DECLARE @datazen_type nvarchar(512); DECLARE @datazen_collation sysname; DECLARE @datazen_unsupported bit; \ + SELECT @datazen_type = {generated_type}, @datazen_collation = c.collation_name, \ + @datazen_unsupported = CASE WHEN tp.is_user_defined = 1 OR tp.name IN ('xml','sql_variant','geography','geometry','hierarchyid') THEN 1 ELSE 0 END \ + FROM {catalog}sys.columns AS c \ + JOIN {catalog}sys.types AS tp ON tp.user_type_id = c.user_type_id \ + JOIN {catalog}sys.schemas AS ts ON ts.schema_id = tp.schema_id \ + WHERE c.object_id = OBJECT_ID({object_literal}, N'U') AND c.name = {column_literal}; \ + IF @datazen_type IS NULL THROW 51001, N'SQL Server column changed after schema comparison', 1; \ + IF @datazen_unsupported = 1 THROW 51003, N'SQL Server column type is outside the safe nullability migration subset', 1; \ + EXEC(N'ALTER TABLE {table_sql} ALTER COLUMN {column_sql} ' + @datazen_type + \ + CASE WHEN @datazen_collation IS NULL THEN N'' ELSE N' COLLATE ' + QUOTENAME(@datazen_collation) END + \ + N' {nullability}');" + )) +} + +pub(super) fn primary_key_lookup(table: &str, columns: &[String]) -> Result { + validate_unique_columns(columns, "primary key")?; + let parts = relation_parts(table)?; + let catalog = relation_catalog_prefix(&parts); + let table_sql = relation(table)?; + let object_literal = sql_string(&table_sql); + let count = columns.len(); + let matches = columns + .iter() + .enumerate() + .map(|(index, column)| { + Ok(format!( + "(ic.key_ordinal = {} AND COL_NAME(ic.object_id, ic.column_id) = {})", + index + 1, + sql_string(column) + )) + }) + .collect::, String>>()? + .join(" OR "); + Ok(format!( + "DECLARE @datazen_pk_name sysname; DECLARE @datazen_pk_count int; DECLARE @datazen_pk_matches int; \ + SELECT @datazen_pk_name = kc.name, @datazen_pk_count = COUNT(*), \ + @datazen_pk_matches = SUM(CASE WHEN {matches} THEN 1 ELSE 0 END) \ + FROM {catalog}sys.key_constraints AS kc \ + JOIN {catalog}sys.index_columns AS ic ON ic.object_id = kc.parent_object_id AND ic.index_id = kc.unique_index_id \ + WHERE kc.parent_object_id = OBJECT_ID({object_literal}, N'U') AND kc.type = 'PK' AND ic.key_ordinal > 0 \ + GROUP BY kc.name; \ + IF @datazen_pk_name IS NULL OR @datazen_pk_count <> {count} OR @datazen_pk_matches <> {count} \ + THROW 51002, N'SQL Server primary key changed after schema comparison', 1;" + )) +} + +pub(super) fn comment_property_sql( + table: &str, + column: &str, + value: Option<&str>, +) -> Result { + let parts = relation_parts(table)?; + if parts.len() < 2 { + return Err("SQL Server column comments require an explicit schema and table".into()); + } + let schema = parts[parts.len() - 2].as_str(); + let table_name = parts[parts.len() - 1].as_str(); + let catalog = relation_catalog_prefix(&parts); + let procedure_prefix = catalog.trim_end_matches('.'); + let procedure_prefix = if procedure_prefix.is_empty() { + String::new() + } else { + format!("{procedure_prefix}.") + }; + let object_literal = sql_string(&relation(table)?); + let column_literal = sql_string(column); + let property_exists = format!( + "EXISTS (SELECT 1 FROM {catalog}sys.extended_properties AS ep \ + JOIN {catalog}sys.columns AS c ON c.object_id = ep.major_id AND c.column_id = ep.minor_id \ + WHERE ep.class = 1 AND ep.name = N'MS_Description' \ + AND ep.major_id = OBJECT_ID({object_literal}, N'U') AND c.name = {column_literal})" + ); + let levels = format!( + "@name=N'MS_Description', @level0type=N'SCHEMA', @level0name={}, \ + @level1type=N'TABLE', @level1name={}, @level2type=N'COLUMN', @level2name={}", + sql_string(schema), + sql_string(table_name), + sql_string(column) + ); + match value { + Some(value) => { + if value + .chars() + .any(|ch| ch == '\0' || (ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))) + { + return Err("SQL Server column comment contains control characters".into()); + } + let value = sql_string(value); + Ok(format!( + "IF {property_exists} EXEC {procedure_prefix}sys.sp_updateextendedproperty {levels}, @value={value}; \ + ELSE EXEC {procedure_prefix}sys.sp_addextendedproperty {levels}, @value={value};" + )) + } + None => Ok(format!( + "IF {property_exists} EXEC {procedure_prefix}sys.sp_dropextendedproperty {levels};" + )), + } +} + +pub(super) fn create_table_sql( + table: &str, + columns: &[MigrationColumn], + primary_keys: &[String], +) -> Result { + if columns.is_empty() { + return Err("SQL Server CREATE TABLE requires at least one column".into()); + } + let table = relation(table)?; + let mut seen = HashSet::new(); + let mut definitions = Vec::with_capacity(columns.len() + 1); + let mut identity_count = 0; + for column in columns { + if !seen.insert(column.name.as_str()) { + return Err(format!( + "SQL Server CREATE TABLE repeats column '{}'", + column.name + )); + } + identity_count += usize::from(column.is_auto_increment); + definitions.push(validate_column(column)?); + } + if identity_count > 1 { + return Err("SQL Server permits only one IDENTITY column per table".into()); + } + if !primary_keys.is_empty() { + validate_unique_columns(primary_keys, "primary key")?; + if primary_keys + .iter() + .any(|column| !seen.contains(column.as_str())) + { + return Err( + "SQL Server primary key references a column absent from CREATE TABLE".into(), + ); + } + definitions.push(format!( + "PRIMARY KEY ({})", + primary_keys + .iter() + .map(|column| quote_ident(column)) + .collect::, _>>()? + .join(", ") + )); + } + let sql = format!("CREATE TABLE {table} ({})", definitions.join(", ")); + let mut full_sql = sql; + for column in columns { + if let Some(comment) = column.comment.as_deref() { + full_sql.push_str("; "); + full_sql.push_str(&comment_property_sql(&table, &column.name, Some(comment))?); + } + } + Ok(MigrationStatement { + sql: full_sql, + risk: MigrationRisk::Additive, + rollback_sql: Some(format!("DROP TABLE {table}")), + summary: format!("CREATE TABLE {table}"), + }) +} + +pub(super) fn render_index_create( + table: &str, + index: &IndexInfo, +) -> Result { + validate_unique_columns(&index.columns, "index")?; + let table = relation(table)?; + let name = quote_ident(&index.name)?; + let columns = index + .columns + .iter() + .map(|column| quote_ident(column)) + .collect::, _>>()? + .join(", "); + let (is_constraint, index_type) = index + .index_type + .strip_prefix("UNIQUE_CONSTRAINT:") + .map(|kind| (true, kind)) + .unwrap_or((false, index.index_type.as_str())); + let method = match index_type.to_ascii_uppercase().as_str() { + "CLUSTERED" => "CLUSTERED", + "NONCLUSTERED" => "NONCLUSTERED", + other => return Err(format!("unsupported SQL Server index type '{other}'")), + }; + let (sql, rollback_sql) = if is_constraint { + if !index.is_unique { + return Err("SQL Server UNIQUE constraint metadata must be unique".into()); + } + ( + format!("ALTER TABLE {table} ADD CONSTRAINT {name} UNIQUE {method} ({columns})"), + format!("ALTER TABLE {table} DROP CONSTRAINT {name}"), + ) + } else { + ( + format!( + "CREATE {}{method} INDEX {name} ON {table} ({columns})", + if index.is_unique { "UNIQUE " } else { "" } + ), + format!("DROP INDEX {name} ON {table}"), + ) + }; + Ok(MigrationStatement { + sql, + risk: MigrationRisk::Additive, + rollback_sql: Some(rollback_sql), + summary: format!("CREATE INDEX {} on {table}", index.name), + }) +} diff --git a/packages/drivers/sqlserver/src/migration/tests.rs b/packages/drivers/sqlserver/src/migration/tests.rs new file mode 100644 index 000000000..f580b0115 --- /dev/null +++ b/packages/drivers/sqlserver/src/migration/tests.rs @@ -0,0 +1,314 @@ +use super::*; + +fn column(name: &str, data_type: &str) -> MigrationColumn { + MigrationColumn { + name: name.into(), + data_type: data_type.into(), + nullable: false, + default_value: None, + comment: None, + is_auto_increment: false, + } +} + +#[test] +fn create_table_quotes_scope_and_preserves_supported_column_features() { + let mut id = column("id", "int"); + id.is_auto_increment = true; + let mut label = column("label]", "nvarchar(40)"); + label.nullable = true; + label.default_value = Some("(N'new')".into()); + label.comment = Some("label description".into()); + let statement = SqlServerMigrationRenderer + .render(&MigrationOperation::CreateTable { + table: "[audit].[event.log]".into(), + columns: vec![id, label], + primary_keys: vec!["id".into()], + }) + .unwrap(); + assert!(statement + .sql + .starts_with("CREATE TABLE [audit].[event.log] ([id] INT IDENTITY(1,1) NOT NULL")); + assert!(statement + .sql + .contains("[label]]] NVARCHAR(40) NULL DEFAULT (N'new')")); + assert!(statement.sql.contains("PRIMARY KEY ([id])")); + assert!(statement.sql.contains("sp_addextendedproperty")); + assert_eq!(statement.risk, MigrationRisk::Additive); +} + +#[test] +fn default_changes_resolve_the_catalog_constraint_name_and_verify_snapshot() { + let operation = MigrationOperation::SetDefault { + table: "sales.orders".into(), + column: "status".into(), + from: Some("('queued')".into()), + to: Some("('ready')".into()), + }; + let statement = SqlServerMigrationRenderer.render(&operation).unwrap(); + assert!(statement.sql.contains("@datazen_default_name")); + assert!(statement.sql.contains("sys.default_constraints")); + assert!(statement.sql.contains("QUOTENAME(@datazen_default_name)")); + assert!(statement + .sql + .contains("SQL Server DEFAULT changed after schema comparison")); + assert!(statement + .sql + .contains("ADD DEFAULT (('ready')) FOR [status]")); + assert!(statement.rollback_sql.unwrap().contains("('queued')")); + + let unsafe_default = MigrationOperation::SetDefault { + table: "sales.orders".into(), + column: "status".into(), + from: None, + to: Some("0; DROP TABLE sales.orders".into()), + }; + assert!(!SqlServerMigrationCapabilities.supports(&unsafe_default)); + assert!(SqlServerMigrationRenderer.render(&unsafe_default).is_err()); +} + +#[test] +fn drops_primary_key_by_actual_name_after_checking_ordered_columns() { + let statement = SqlServerMigrationRenderer + .render(&MigrationOperation::DropPrimaryKey { + table: "sales.orders".into(), + columns: vec!["tenant_id".into(), "order_id".into()], + }) + .unwrap(); + assert!(statement.sql.contains("sys.key_constraints")); + assert!(statement.sql.contains("QUOTENAME(@datazen_pk_name)")); + assert!(statement + .sql + .contains("ic.key_ordinal = 1 AND COL_NAME(ic.object_id, ic.column_id) = N'tenant_id'")); + assert!(statement + .sql + .contains("ic.key_ordinal = 2 AND COL_NAME(ic.object_id, ic.column_id) = N'order_id'")); +} + +#[test] +fn indexes_preserve_constraint_kind_and_schema_scoped_drop_syntax() { + let unique_constraint = IndexInfo { + name: "UQ orders number".into(), + columns: vec!["number".into()], + is_unique: true, + is_primary: false, + index_type: "UNIQUE_CONSTRAINT:NONCLUSTERED".into(), + }; + let create = SqlServerMigrationRenderer + .render(&MigrationOperation::CreateIndex { + table: "sales.orders".into(), + index: unique_constraint.clone(), + }) + .unwrap(); + assert!(create + .sql + .contains("ADD CONSTRAINT [UQ orders number] UNIQUE NONCLUSTERED ([number])")); + let drop = SqlServerMigrationRenderer + .render(&MigrationOperation::DropIndex { + table: "sales.orders".into(), + index: unique_constraint, + }) + .unwrap(); + assert!(drop + .sql + .contains("ALTER TABLE [sales].[orders] DROP CONSTRAINT")); + assert!(drop.rollback_sql.unwrap().contains("UNIQUE NONCLUSTERED")); + + let index = IndexInfo { + name: "ix_order".into(), + columns: vec!["number".into()], + is_unique: false, + is_primary: false, + index_type: "CLUSTERED".into(), + }; + let statement = SqlServerMigrationRenderer + .render(&MigrationOperation::DropIndex { + table: "sales.orders".into(), + index, + }) + .unwrap(); + assert!(statement + .sql + .contains("DROP INDEX [ix_order] ON [sales].[orders]")); +} + +#[test] +fn foreign_keys_and_checks_use_exact_names_and_tsql_actions() { + let foreign_key = ForeignKeyInfo { + name: "FK order line".into(), + columns: vec!["order_id".into()], + referenced_table: "[sales].[orders]".into(), + referenced_columns: vec!["id".into()], + on_update: "NO ACTION".into(), + on_delete: "CASCADE".into(), + deferrability: ForeignKeyDeferrability::NotDeferrable, + }; + let statement = SqlServerMigrationRenderer + .render(&MigrationOperation::AddForeignKey { + table: "sales.order_lines".into(), + foreign_key, + }) + .unwrap(); + assert!(statement + .sql + .contains("REFERENCES [sales].[orders] ([id]) ON DELETE CASCADE")); + assert!(statement + .rollback_sql + .unwrap() + .contains("DROP CONSTRAINT [FK order line]")); + + let check = CheckConstraint { + name: "CK positive".into(), + expression: "[amount] >= 0".into(), + }; + let statement = SqlServerMigrationRenderer + .render(&MigrationOperation::AddCheckConstraint { + table: "sales.order_lines".into(), + constraint: check.clone(), + }) + .unwrap(); + assert!(statement + .sql + .contains("ADD CONSTRAINT [CK positive] CHECK ([amount] >= 0)")); + let unsafe_check = MigrationOperation::AddCheckConstraint { + table: "sales.order_lines".into(), + constraint: CheckConstraint { + name: "ck".into(), + expression: "1=1; DROP TABLE sales.orders".into(), + }, + }; + assert!(!SqlServerMigrationCapabilities.supports(&unsafe_check)); +} + +#[test] +fn alterations_preserve_target_nullability_and_refuse_identity_changes() { + let operation = MigrationOperation::AlterColumnType { + table: "sales.orders".into(), + column: "amount".into(), + from: "INT".into(), + to: "DECIMAL(12, 2)".into(), + }; + let statement = SqlServerMigrationRenderer.render(&operation).unwrap(); + assert!(statement.sql.contains("N'DECIMAL(12,2)'")); + assert!(statement.sql.contains("@datazen_nullability")); + assert!(statement.sql.contains("@datazen_collation")); + + let nullable = SqlServerMigrationRenderer + .render(&MigrationOperation::SetNullable { + table: "sales.orders".into(), + column: "amount".into(), + nullable: false, + }) + .unwrap(); + assert!(nullable.sql.contains("sys.columns")); + assert!(nullable.sql.contains("@datazen_collation")); + assert!(nullable.sql.contains("N' NOT NULL'")); + + let identity_change = MigrationOperation::SetAutoIncrement { + table: "sales.orders".into(), + column: "id".into(), + from: false, + to: true, + }; + assert!(!SqlServerMigrationCapabilities.supports(&identity_change)); + assert!(SqlServerMigrationRenderer.render(&identity_change).is_err()); +} + +#[test] +fn views_require_exact_identity_and_single_server_batch() { + let view = MigrationView { + schema: Some("审计".into()), + name: "事件".into(), + definition: "CREATE VIEW [审计].[事件] WITH SCHEMABINDING AS SELECT id FROM [审计].[源]" + .into(), + }; + let create = SqlServerMigrationRenderer + .render(&MigrationOperation::CreateView { view: view.clone() }) + .unwrap(); + assert!(create.sql.starts_with("CREATE VIEW [审计].[事件]")); + assert!(create.sql.contains("WITH SCHEMABINDING AS")); + let replace = SqlServerMigrationRenderer + .render(&MigrationOperation::ReplaceView { + current: view.clone(), + desired: MigrationView { + definition: view.definition.replace("SELECT id", "SELECT [id]"), + ..view.clone() + }, + }) + .unwrap(); + assert!(replace.sql.starts_with("ALTER VIEW")); + + let mismatch = MigrationView { + definition: "CREATE VIEW [dbo].[wrong] AS SELECT 1".into(), + ..view.clone() + }; + assert!(!SqlServerMigrationCapabilities + .supports(&MigrationOperation::CreateView { view: mismatch })); + let batch = MigrationView { + definition: "CREATE VIEW [审计].[事件] AS SELECT 1; DROP TABLE x".into(), + ..view + }; + assert!( + !SqlServerMigrationCapabilities.supports(&MigrationOperation::CreateView { view: batch }) + ); + + let unsupported_header = MigrationView { + schema: Some("dbo".into()), + name: "v".into(), + definition: "CREATE OR ALTER VIEW [dbo].[v] AS SELECT 1".into(), + }; + assert!( + !SqlServerMigrationCapabilities.supports(&MigrationOperation::CreateView { + view: unsupported_header + }) + ); +} + +#[test] +fn unsupported_operations_and_unrepresented_table_layouts_fail_closed() { + let caps = SqlServerMigrationCapabilities; + assert!(!caps.supports(&MigrationOperation::CreateRoutine { + routine: MigrationRoutine { + kind: ObjectKind::Procedure, + schema: Some("dbo".into()), + name: "p".into(), + signature: None, + definition: "CREATE PROCEDURE [dbo].[p] AS SELECT 1".into(), + }, + })); + assert!(!caps.supports(&MigrationOperation::SetTableOptions { + table: "dbo.t".into(), + from: TableOptions::default(), + to: TableOptions::default(), + })); + let create = MigrationOperation::CreateTable { + table: "dbo.t".into(), + columns: vec![column("id", "int")], + primary_keys: vec!["id".into()], + }; + let blocker = TableOptions { + migration_blockers: vec!["nonclustered primary key".into()], + ..TableOptions::default() + }; + assert!(SqlServerMigrationRenderer + .render_create_table_with_options(&create, &blocker) + .is_err()); + + let unsupported_type = MigrationOperation::AddColumn { + table: "dbo.t".into(), + column: column("payload", "xml"), + }; + assert!(!caps.supports(&unsupported_type)); + assert!(SqlServerMigrationRenderer + .render(&unsupported_type) + .is_err()); + + for invalid_type in ["CHAR(MAX)", "NCHAR(MAX)", "BINARY(MAX)"] { + let operation = MigrationOperation::AddColumn { + table: "dbo.t".into(), + column: column("invalid", invalid_type), + }; + assert!(!caps.supports(&operation), "{invalid_type}"); + assert!(SqlServerMigrationRenderer.render(&operation).is_err()); + } +} diff --git a/packages/drivers/sqlserver/src/migration/validation.rs b/packages/drivers/sqlserver/src/migration/validation.rs new file mode 100644 index 000000000..dd8fbb73d --- /dev/null +++ b/packages/drivers/sqlserver/src/migration/validation.rs @@ -0,0 +1,353 @@ +use super::*; +use std::collections::HashSet; + +pub(super) fn quote_ident(value: &str) -> Result { + if value.is_empty() || value.chars().any(char::is_control) { + return Err( + "SQL Server migration identifier is empty or contains control characters".into(), + ); + } + Ok(format!("[{}]", value.replace(']', "]]"))) +} + +pub(super) fn quote_path(value: &str) -> Result, String> { + let chars = value.chars().collect::>(); + let mut parts = Vec::new(); + let mut index = 0; + while index < chars.len() { + while chars.get(index).is_some_and(|ch| ch.is_whitespace()) { + index += 1; + } + if index == chars.len() { + return Err("SQL Server relation identity has an empty segment".into()); + } + let mut segment = String::new(); + if chars[index] == '[' { + index += 1; + let mut closed = false; + while index < chars.len() { + match chars[index] { + ']' if chars.get(index + 1) == Some(&']') => { + segment.push(']'); + index += 2; + } + ']' => { + closed = true; + index += 1; + break; + } + ch => { + segment.push(ch); + index += 1; + } + } + } + if !closed { + return Err( + "SQL Server relation identity has an unterminated bracketed segment".into(), + ); + } + if chars + .get(index) + .is_some_and(|ch| !ch.is_whitespace() && *ch != '.') + { + return Err( + "SQL Server relation identity has text after a bracketed segment".into(), + ); + } + } else { + while index < chars.len() && chars[index] != '.' { + if chars[index] == '[' || chars[index] == ']' { + return Err("SQL Server relation identity has an unmatched bracket".into()); + } + segment.push(chars[index]); + index += 1; + } + segment = segment.trim().to_owned(); + } + if segment.is_empty() || segment.chars().any(char::is_control) { + return Err("SQL Server relation identity contains an empty or invalid segment".into()); + } + parts.push(quote_ident(&segment)?); + while chars.get(index).is_some_and(|ch| ch.is_whitespace()) { + index += 1; + } + if index < chars.len() { + if chars[index] != '.' { + return Err("SQL Server relation identity is malformed".into()); + } + index += 1; + if index == chars.len() { + return Err("SQL Server relation identity has an empty segment".into()); + } + } + } + if !(2..=3).contains(&parts.len()) { + return Err("SQL Server migration requires an explicit schema-qualified relation".into()); + } + Ok(parts) +} + +pub(super) fn relation(value: &str) -> Result { + Ok(quote_path(value)?.join(".")) +} + +pub(super) fn relation_parts(value: &str) -> Result, String> { + // quote_path deliberately returns rendered components; decode only the + // known bracket escaping so catalog scope can be addressed without + // splitting a schema or table name containing a dot. + let quoted = quote_path(value)?; + quoted + .iter() + .map(|part| { + let inner = part + .strip_prefix('[') + .and_then(|part| part.strip_suffix(']')) + .ok_or("invalid quoted SQL Server relation component")?; + Ok(inner.replace("]]", "]")) + }) + .collect() +} + +pub(super) fn relation_catalog_prefix(parts: &[String]) -> String { + if parts.len() == 3 { + format!("{}.", parts[0]) + } else { + String::new() + } +} + +pub(super) fn sql_string(value: &str) -> String { + format!("N'{}'", value.replace('\'', "''")) +} + +pub(super) fn literal_expression(value: &str, label: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed + .chars() + .any(|ch| ch == '\0' || (ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))) + || has_executable_separator(trimmed) + { + return Err(format!( + "SQL Server {label} is empty or contains unsafe SQL" + )); + } + if trimmed.to_ascii_uppercase().contains("NEXT VALUE FOR") { + return Err(format!( + "SQL Server {label} depends on a sequence reference that this migration plan cannot verify" + )); + } + Ok(trimmed.to_owned()) +} + +/// Detect statement separators and SQL comments outside quoted strings and +/// identifiers. Semicolons inside a string literal remain valid data. +pub(super) fn has_executable_separator(value: &str) -> bool { + let chars = value.chars().collect::>(); + let mut index = 0; + let mut single_quoted = false; + let mut double_quoted = false; + let mut bracket_quoted = false; + while index < chars.len() { + let ch = chars[index]; + if single_quoted { + if ch == '\'' && chars.get(index + 1) == Some(&'\'') { + index += 2; + continue; + } + if ch == '\'' { + single_quoted = false; + } + } else if double_quoted { + if ch == '"' && chars.get(index + 1) == Some(&'"') { + index += 2; + continue; + } + if ch == '"' { + double_quoted = false; + } + } else if bracket_quoted { + if ch == ']' && chars.get(index + 1) == Some(&']') { + index += 2; + continue; + } + if ch == ']' { + bracket_quoted = false; + } + } else { + match ch { + '\'' => single_quoted = true, + '"' => double_quoted = true, + '[' => bracket_quoted = true, + ';' => return true, + '-' if chars.get(index + 1) == Some(&'-') => return true, + '/' if chars.get(index + 1) == Some(&'*') => return true, + '*' if chars.get(index + 1) == Some(&'/') => return true, + _ => {} + } + } + index += 1; + } + single_quoted || double_quoted || bracket_quoted +} + +pub(super) fn validate_type(raw: &str) -> Result { + let (base, args, suffix) = parse_type_parts(raw); + if !suffix.is_empty() { + return Err(format!( + "SQL Server type suffix '{suffix}' is outside the migration type contract" + )); + } + let base = base.to_ascii_uppercase(); + let allowed = matches!( + base.as_str(), + "BIGINT" + | "INT" + | "INTEGER" + | "SMALLINT" + | "TINYINT" + | "BIT" + | "DECIMAL" + | "NUMERIC" + | "MONEY" + | "SMALLMONEY" + | "FLOAT" + | "REAL" + | "DATE" + | "TIME" + | "SMALLDATETIME" + | "DATETIME" + | "DATETIME2" + | "DATETIMEOFFSET" + | "CHAR" + | "VARCHAR" + | "NCHAR" + | "NVARCHAR" + | "BINARY" + | "VARBINARY" + | "UNIQUEIDENTIFIER" + ); + if !allowed { + return Err(format!( + "SQL Server type '{raw}' is outside the representable migration subset" + )); + } + let args = args.map(|args| { + args.split(',') + .map(str::trim) + .map(str::to_owned) + .collect::>() + }); + let normalized_args = match (base.as_str(), args.as_deref()) { + ("DECIMAL" | "NUMERIC", Some([precision, scale])) + if precision + .parse::() + .is_ok_and(|value| (1..=38).contains(&value)) + && scale.parse::().is_ok_and(|value| value <= 38) => + { + let precision = precision.parse::().map_err(|_| "invalid precision")?; + let scale = scale.parse::().map_err(|_| "invalid scale")?; + if scale > precision { + return Err("SQL Server decimal scale exceeds precision".into()); + } + Some(format!("{precision},{scale}")) + } + ("DECIMAL" | "NUMERIC", Some([precision])) + if precision + .parse::() + .is_ok_and(|value| (1..=38).contains(&value)) => + { + Some(precision.to_string()) + } + ("FLOAT", Some([precision])) + if precision + .parse::() + .is_ok_and(|value| (1..=53).contains(&value)) => + { + Some(precision.to_string()) + } + ("TIME" | "DATETIME2" | "DATETIMEOFFSET", Some([scale])) + if scale.parse::().is_ok_and(|value| value <= 7) => + { + Some(scale.to_string()) + } + ("CHAR" | "VARCHAR" | "BINARY" | "VARBINARY", Some([length])) + if length + .parse::() + .is_ok_and(|value| value > 0 && value <= 8000) => + { + Some(length.to_ascii_uppercase()) + } + ("NCHAR" | "NVARCHAR", Some([length])) + if length + .parse::() + .is_ok_and(|value| value > 0 && value <= 4000) => + { + Some(length.to_ascii_uppercase()) + } + ("VARCHAR" | "VARBINARY" | "NVARCHAR", Some([length])) if *length == "MAX" => { + Some(length.to_ascii_uppercase()) + } + ("DECIMAL" | "NUMERIC" | "FLOAT" | "TIME" | "DATETIME2" | "DATETIMEOFFSET", None) => None, + ("CHAR" | "VARCHAR" | "NCHAR" | "NVARCHAR" | "BINARY" | "VARBINARY", None) => { + return Err(format!("SQL Server type '{base}' requires a length")); + } + (_, None) => None, + _ => return Err(format!("invalid SQL Server type dimensions in '{raw}'")), + }; + Ok(format_type(&base, normalized_args.as_deref(), "")) +} + +pub(super) fn validate_column(column: &MigrationColumn) -> Result { + let name = quote_ident(&column.name)?; + let data_type = validate_type(&column.data_type)?; + if column.is_auto_increment && column.default_value.is_some() { + return Err(format!( + "SQL Server IDENTITY column '{}' cannot also have a DEFAULT constraint", + column.name + )); + } + let mut definition = format!( + "{name} {data_type}{}{}", + if column.is_auto_increment { + " IDENTITY(1,1)" + } else { + "" + }, + if column.nullable { + " NULL" + } else { + " NOT NULL" + } + ); + if let Some(default) = &column.default_value { + definition.push_str(" DEFAULT "); + definition.push_str(&literal_expression(default, "DEFAULT expression")?); + } + Ok(definition) +} + +pub(super) fn validate_unique_columns(columns: &[String], label: &str) -> Result<(), String> { + if columns.is_empty() { + return Err(format!( + "SQL Server {label} must include at least one column" + )); + } + let mut seen = HashSet::new(); + for column in columns { + quote_ident(column)?; + if !seen.insert(column) { + return Err(format!("SQL Server {label} repeats column '{column}'")); + } + } + Ok(()) +} + +pub(super) fn table_options_supported(options: &TableOptions) -> bool { + options.comment.is_none() + && options.engine.is_none() + && options.charset.is_none() + && options.collation.is_none() + && options.migration_blockers.is_empty() +} diff --git a/packages/drivers/sqlserver/src/migration/views.rs b/packages/drivers/sqlserver/src/migration/views.rs new file mode 100644 index 000000000..7552fca6a --- /dev/null +++ b/packages/drivers/sqlserver/src/migration/views.rs @@ -0,0 +1,214 @@ +use super::validation::*; +use super::*; + +pub(super) fn validate_view(view: &MigrationView) -> Result<(String, String), String> { + let schema = view + .schema + .as_deref() + .filter(|schema| !schema.is_empty()) + .ok_or("SQL Server view migration requires its exact schema")?; + let ident = format!("{}.{}", quote_ident(schema)?, quote_ident(&view.name)?); + let definition = view.definition.trim(); + if definition.is_empty() || has_executable_separator(definition) { + return Err("SQL Server view definition must be one complete, safe batch".into()); + } + let upper = definition.to_ascii_uppercase(); + let is_full_ddl = upper.starts_with("CREATE VIEW") || upper.starts_with("ALTER VIEW"); + if !is_full_ddl { + if upper.starts_with("CREATE ") || upper.starts_with("ALTER ") { + return Err( + "SQL Server view DDL must use a supported CREATE VIEW or ALTER VIEW header".into(), + ); + } + validate_view_definition(definition)?; + let sql = format!("CREATE VIEW {ident} AS {definition}"); + return Ok((ident, sql)); + } + + let (declared_schema, declared_name) = view_header_identity(definition)?; + if declared_schema != schema || declared_name != view.name { + return Err(format!( + "SQL Server view definition identity `{}.{}` does not match requested `{}.{}`", + declared_schema, declared_name, schema, view.name + )); + } + let as_offset = find_header_as(definition)?; + validate_view_definition(&definition[as_offset..])?; + Ok((ident, definition.to_owned())) +} + +pub(super) fn view_header_identity(definition: &str) -> Result<(String, String), String> { + let bytes = definition.as_bytes(); + let mut index = 0; + let first = read_word(bytes, &mut index)?; + if !first.eq_ignore_ascii_case("CREATE") && !first.eq_ignore_ascii_case("ALTER") { + return Err("SQL Server view definition must begin with CREATE VIEW or ALTER VIEW".into()); + } + let second = read_word(bytes, &mut index)?; + if !second.eq_ignore_ascii_case("VIEW") { + return Err("SQL Server view definition must begin with CREATE VIEW or ALTER VIEW".into()); + } + let parts = read_identifier_path(bytes, &mut index)?; + if parts.len() != 2 { + return Err( + "SQL Server view definition must declare an exact schema-qualified identity".into(), + ); + } + Ok((parts[0].clone(), parts[1].clone())) +} + +pub(super) fn read_word(bytes: &[u8], index: &mut usize) -> Result { + skip_space(bytes, index); + let start = *index; + while bytes + .get(*index) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'_' | b'@' | b'#')) + { + *index += 1; + } + if *index == start { + return Err("SQL Server object definition header is malformed".into()); + } + Ok(String::from_utf8_lossy(&bytes[start..*index]).into_owned()) +} + +pub(super) fn skip_space(bytes: &[u8], index: &mut usize) { + while bytes.get(*index).is_some_and(u8::is_ascii_whitespace) { + *index += 1; + } +} + +pub(super) fn read_identifier_path(bytes: &[u8], index: &mut usize) -> Result, String> { + let mut parts = Vec::new(); + loop { + skip_space(bytes, index); + let mut part = String::new(); + if bytes.get(*index) == Some(&b'[') { + *index += 1; + let mut start = *index; + let mut closed = false; + while let Some(byte) = bytes.get(*index).copied() { + if byte == b']' && bytes.get(*index + 1) == Some(&b']') { + part.push_str(std::str::from_utf8(&bytes[start..*index]).map_err(|_| { + "SQL Server object definition identifier is not valid UTF-8" + })?); + part.push(']'); + *index += 2; + start = *index; + } else if byte == b']' { + part.push_str(std::str::from_utf8(&bytes[start..*index]).map_err(|_| { + "SQL Server object definition identifier is not valid UTF-8" + })?); + closed = true; + *index += 1; + break; + } else { + *index += 1; + } + } + if !closed { + return Err("SQL Server object definition has an unterminated identifier".into()); + } + } else { + let start = *index; + while bytes.get(*index).is_some_and(|byte| { + !byte.is_ascii_whitespace() && *byte != b'.' && *byte != b'[' && *byte != b']' + }) { + *index += 1; + } + if *index == start { + return Err("SQL Server object definition identity is malformed".into()); + } + part = String::from_utf8(bytes[start..*index].to_vec()) + .map_err(|_| "SQL Server object definition identity is not valid UTF-8")?; + } + if part.is_empty() { + return Err("SQL Server object definition identity has an empty segment".into()); + } + parts.push(part); + skip_space(bytes, index); + if bytes.get(*index) != Some(&b'.') { + break; + } + *index += 1; + } + Ok(parts) +} + +pub(super) fn find_header_as(definition: &str) -> Result { + let bytes = definition.as_bytes(); + let mut index = 0; + let _ = read_word(bytes, &mut index)?; + let _ = read_word(bytes, &mut index)?; + let _ = read_identifier_path(bytes, &mut index)?; + let mut in_single = false; + let mut in_double = false; + let mut in_bracket = false; + let mut depth = 0usize; + while index < bytes.len() { + let byte = bytes[index]; + if in_single { + if byte == b'\'' && bytes.get(index + 1) == Some(&b'\'') { + index += 2; + continue; + } + if byte == b'\'' { + in_single = false; + } + } else if in_double { + if byte == b'"' && bytes.get(index + 1) == Some(&b'"') { + index += 2; + continue; + } + if byte == b'"' { + in_double = false; + } + } else if in_bracket { + if byte == b']' && bytes.get(index + 1) == Some(&b']') { + index += 2; + continue; + } + if byte == b']' { + in_bracket = false; + } + } else { + match byte { + b'\'' => in_single = true, + b'"' => in_double = true, + b'[' => in_bracket = true, + b'(' => depth += 1, + b')' => depth = depth.saturating_sub(1), + b'A' | b'a' if depth == 0 => { + let after_word = index + 2; + let after = bytes + .get(after_word) + .is_some_and(|byte| byte.is_ascii_whitespace() || *byte == b'('); + if after + && bytes + .get(index..after_word) + .is_some_and(|word| word.eq_ignore_ascii_case(b"as")) + { + return Ok(after_word); + } + } + b';' => { + return Err("SQL Server view definition contains multiple statements".into()) + } + _ => {} + } + } + index += 1; + } + Err("SQL Server view definition is missing its AS clause".into()) +} + +pub(super) fn to_alter_view(definition: &str) -> Result { + let trimmed = definition.trim_start(); + if trimmed.len() >= 6 && trimmed[..6].eq_ignore_ascii_case("CREATE") { + Ok(format!("ALTER{}", &trimmed[6..])) + } else if trimmed.len() >= 5 && trimmed[..5].eq_ignore_ascii_case("ALTER") { + Ok(trimmed.to_owned()) + } else { + Err("SQL Server replacement view requires a CREATE/ALTER VIEW definition".into()) + } +} diff --git a/packages/drivers/sqlserver/src/sqlserver.rs b/packages/drivers/sqlserver/src/sqlserver.rs index 9bc7280e3..ffbeb1650 100644 --- a/packages/drivers/sqlserver/src/sqlserver.rs +++ b/packages/drivers/sqlserver/src/sqlserver.rs @@ -733,8 +733,60 @@ fn apply_sqlserver_top(stmt: &str, limit: Option) -> (String, Option) (format!("{prefix} TOP {} {body}", lim + 1), Some(lim)) } +fn schema_migration_blockers_for_indexes(indexes: &[IndexInfo]) -> Vec { + let primary_indexes = indexes + .iter() + .filter(|index| index.is_primary) + .collect::>(); + let mut blockers = Vec::new(); + if primary_indexes.len() > 1 { + blockers.push( + "SQL Server returned multiple primary-key indexes; the shared migration IR cannot identify one safely".into(), + ); + } else if let Some(primary) = primary_indexes.first() { + let kind = primary + .index_type + .strip_prefix("UNIQUE_CONSTRAINT:") + .unwrap_or(&primary.index_type); + if !kind.eq_ignore_ascii_case("CLUSTERED") { + blockers.push( + "SQL Server primary-key clustering is nonclustered; the shared migration IR cannot preserve this property during table creation".into(), + ); + } + } + if indexes.iter().any(|index| { + !index.is_primary + && index + .index_type + .strip_prefix("UNIQUE_CONSTRAINT:") + .unwrap_or(&index.index_type) + .eq_ignore_ascii_case("CLUSTERED") + }) { + blockers.push( + "SQL Server table has a clustered secondary index; the shared migration IR cannot prove this index layout is compatible with every planned primary-key change".into(), + ); + } + blockers +} + #[async_trait] impl DatabaseDriver for SqlServerDriver { + fn migration_renderer( + &self, + ) -> Option> { + Some(std::sync::Arc::new(super::SqlServerMigrationRenderer)) + } + + fn migration_capabilities( + &self, + ) -> Option> { + Some(std::sync::Arc::new(super::SqlServerMigrationCapabilities)) + } + + fn type_normalizer(&self) -> Option> { + Some(std::sync::Arc::new(super::SqlServerTypeNormalizer)) + } + fn driver_type(&self) -> DatabaseType { "sqlserver".to_string() } @@ -944,6 +996,7 @@ impl DatabaseDriver for SqlServerDriver { Self::run_with_params(client, &crate::metadata::checks_sql(database), ¶meters) .await?; let check_constraints = crate::metadata::parse_checks(check_rows)?; + let migration_blockers = schema_migration_blockers_for_indexes(&indexes); Ok(TableSchema { table_name: table.to_string(), columns, @@ -951,7 +1004,10 @@ impl DatabaseDriver for SqlServerDriver { indexes, foreign_keys, check_constraints, - table_options: TableOptions::default(), + table_options: TableOptions { + migration_blockers, + ..TableOptions::default() + }, }) } @@ -1504,6 +1560,46 @@ mod tests { use super::*; use tiberius::EncryptionLevel; + #[test] + fn schema_migration_blockers_cover_unrepresented_clustered_layouts() { + let ordinary_layout = vec![ + IndexInfo { + name: "PK_t".into(), + columns: vec!["id".into()], + is_unique: true, + is_primary: true, + index_type: "CLUSTERED".into(), + }, + IndexInfo { + name: "IX_t_value".into(), + columns: vec!["value".into()], + is_unique: false, + is_primary: false, + index_type: "NONCLUSTERED".into(), + }, + ]; + assert!(schema_migration_blockers_for_indexes(&ordinary_layout).is_empty()); + + let nonclustered_primary = vec![IndexInfo { + index_type: "NONCLUSTERED".into(), + ..ordinary_layout[0].clone() + }]; + assert!(schema_migration_blockers_for_indexes(&nonclustered_primary) + .iter() + .any(|blocker| blocker.contains("primary-key clustering is nonclustered"))); + + let clustered_secondary = vec![IndexInfo { + name: "UQ_t_value".into(), + columns: vec!["value".into()], + is_unique: true, + is_primary: false, + index_type: "UNIQUE_CONSTRAINT:CLUSTERED".into(), + }]; + assert!(schema_migration_blockers_for_indexes(&clustered_secondary) + .iter() + .any(|blocker| blocker.contains("clustered secondary index"))); + } + #[test] fn ssl_disable_is_plaintext() { assert_eq!( diff --git a/packages/drivers/sqlserver/src/type_normalizer.rs b/packages/drivers/sqlserver/src/type_normalizer.rs new file mode 100644 index 000000000..b726347b2 --- /dev/null +++ b/packages/drivers/sqlserver/src/type_normalizer.rs @@ -0,0 +1,75 @@ +use datazen_driver_api::{format_type, parse_type_parts, TypeNormalizer}; + +/// Normalizes only SQL Server synonyms whose storage and comparison semantics +/// are equivalent. Dimensions and suffixes remain part of the comparison. +pub struct SqlServerTypeNormalizer; + +impl TypeNormalizer for SqlServerTypeNormalizer { + fn normalize_type(&self, data_type: &str) -> String { + let (base, args, suffix) = parse_type_parts(data_type); + if base.is_empty() { + return String::new(); + } + let (canonical, effective_args) = match base.as_str() { + "INTEGER" => ("INT", args), + "DEC" | "NUMERIC" => ("DECIMAL", args), + "CHARACTER VARYING" => ("VARCHAR", args), + "CHARACTER" => ("CHAR", args), + "NATIONAL CHARACTER VARYING" => ("NVARCHAR", args), + "NATIONAL CHARACTER" => ("NCHAR", args), + "DOUBLE PRECISION" => ("FLOAT", Some("53".into())), + "FLOAT" if args.is_none() => ("FLOAT", Some("53".into())), + "REAL" => ("FLOAT", Some("24".into())), + "ROWVERSION" | "TIMESTAMP" => ("ROWVERSION", None), + other => (other, args), + }; + let normalized_args = effective_args + .as_deref() + .map(|args| args.split(',').map(str::trim).collect::>().join(",")); + format_type(canonical, normalized_args.as_deref(), &suffix) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonicalizes_only_sql_server_type_synonyms() { + let normalizer = SqlServerTypeNormalizer; + assert_eq!(normalizer.normalize_type("integer"), "INT"); + assert_eq!(normalizer.normalize_type("NUMERIC(12, 4)"), "DECIMAL(12,4)"); + assert_eq!( + normalizer.normalize_type("character varying(32)"), + "VARCHAR(32)" + ); + assert_eq!( + normalizer.normalize_type("national character(4)"), + "NCHAR(4)" + ); + assert_eq!(normalizer.normalize_type("double precision"), "FLOAT(53)"); + assert_eq!(normalizer.normalize_type("real"), "FLOAT(24)"); + assert_eq!(normalizer.normalize_type("timestamp"), "ROWVERSION"); + } + + #[test] + fn preserves_dimensions_collation_and_unicode_distinctions() { + let normalizer = SqlServerTypeNormalizer; + assert_ne!( + normalizer.normalize_type("VARCHAR(20)"), + normalizer.normalize_type("VARCHAR(21)") + ); + assert_ne!( + normalizer.normalize_type("VARCHAR(20)"), + normalizer.normalize_type("NVARCHAR(20)") + ); + assert_ne!( + normalizer.normalize_type("DECIMAL(12,2)"), + normalizer.normalize_type("DECIMAL(12,3)") + ); + assert_ne!( + normalizer.normalize_type("VARCHAR(20) COLLATE Latin1_General_100_CI_AS"), + normalizer.normalize_type("VARCHAR(20) COLLATE Latin1_General_100_CS_AS") + ); + } +} From 995fd9ceda89b642a7128c0716606e9c2c3f849c Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 00:26:06 +0800 Subject: [PATCH 006/666] fix(sync): reject unverified SQL Server text filters --- .../src/commands/sync/filter_validation.rs | 90 ++++++++++++ .../commands/sync/filter_validation_tests.rs | 131 ++++++++++++++++++ src-tauri/src/data_sync/filter.rs | 24 +++- src-tauri/src/data_sync/recordset.rs | 12 ++ 4 files changed, 256 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/sync/filter_validation.rs b/src-tauri/src/commands/sync/filter_validation.rs index 8cf629fd9..e75316c44 100644 --- a/src-tauri/src/commands/sync/filter_validation.rs +++ b/src-tauri/src/commands/sync/filter_validation.rs @@ -5,6 +5,88 @@ use crate::data_sync::{quote_ident_sql, DataSyncError, SyncSourceFilter}; use crate::db::TableSchema; use datazen_driver_api::{DatabaseDriver, SyncKeyContract, SyncSourceAdapter}; +fn sqlserver_filter_type_uses_collation(data_type: &str) -> Option { + let normalized = data_type + .trim() + .to_ascii_lowercase() + .replace(['[', ']'], ""); + let base_type = normalized + .rsplit('.') + .next() + .unwrap_or_default() + .split(['(', ' ', ',']) + .next() + .unwrap_or_default(); + match base_type { + "char" | "nchar" | "varchar" | "nvarchar" | "text" | "ntext" | "sysname" => Some(true), + "bit" | "tinyint" | "smallint" | "int" | "bigint" | "decimal" | "numeric" | "money" + | "smallmoney" | "float" | "real" | "date" | "time" | "datetime" | "smalldatetime" + | "datetime2" | "datetimeoffset" | "uniqueidentifier" | "binary" | "varbinary" + | "timestamp" | "rowversion" | "image" | "xml" | "geography" | "geometry" + | "hierarchyid" => Some(false), + // User-defined aliases and sql_variant can carry string semantics. + // Their base storage type is not represented in TableSchema. + _ => None, + } +} + +fn validate_sqlserver_filter_collations( + filter: &SyncSourceFilter, + source_driver: &dyn DatabaseDriver, + target_driver: &dyn DatabaseDriver, + source_schema: &TableSchema, + target_schema: &TableSchema, + source_table: &str, +) -> Result<(), CommandError> { + let source_is_sqlserver = + crate::transfer::pairing::normalize_sync_family(&source_driver.sync_family()) + == "sqlserver"; + let target_is_sqlserver = + crate::transfer::pairing::normalize_sync_family(&target_driver.sync_family()) + == "sqlserver"; + if !source_is_sqlserver && !target_is_sqlserver { + return Ok(()); + } + + for column in filter + .comparison_columns(source_schema) + .map_err(|error| CommandError::Validation(error.to_string()))? + { + for (is_sqlserver, side, schema) in [ + (source_is_sqlserver, "source", source_schema), + (target_is_sqlserver, "target", target_schema), + ] { + if !is_sqlserver { + continue; + } + let data_type = schema + .columns + .iter() + .find(|candidate| candidate.name == column) + .map(|candidate| candidate.data_type.as_str()) + .ok_or_else(|| { + CommandError::Validation(format!( + "{source_table}: SQL Server {side} filter column '{column}' is missing from inspected schema" + )) + })?; + match sqlserver_filter_type_uses_collation(data_type) { + Some(false) => {} + Some(true) => { + return Err(CommandError::Validation(format!( + "{source_table}: SQL Server {side} filter on text column '{column}' cannot be compared safely because default/per-column collation parity is not represented or verified by Data Sync" + ))); + } + None => { + return Err(CommandError::Validation(format!( + "{source_table}: SQL Server {side} filter on column '{column}' (type '{data_type}') cannot be compared safely because Data Sync cannot verify whether this type uses text collation semantics" + ))); + } + } + } + } + Ok(()) +} + pub(super) fn validate_filter_schemas( filter: &SyncSourceFilter, source_schema: &TableSchema, @@ -84,6 +166,14 @@ pub(super) fn validate_filter_endpoints( tgt_contracts: &[SyncKeyContract], source_table: &str, ) -> Result<(), CommandError> { + validate_sqlserver_filter_collations( + filter, + src_driver, + tgt_driver, + source_schema, + target_schema, + source_table, + )?; let source_key_order_expressions = pk_columns .iter() .zip(src_contracts) diff --git a/src-tauri/src/commands/sync/filter_validation_tests.rs b/src-tauri/src/commands/sync/filter_validation_tests.rs index a739a230e..91eeacf10 100644 --- a/src-tauri/src/commands/sync/filter_validation_tests.rs +++ b/src-tauri/src/commands/sync/filter_validation_tests.rs @@ -3,6 +3,7 @@ use super::filter_validation::{ }; use crate::data_sync::SyncSourceFilter; use crate::db::{ColumnSchema, TableSchema}; +use crate::testing::mock_driver::{MockDriver, MockDriverOptions}; use datazen_driver_mysql::{MysqlDriver, MysqlSyncAdapter}; use datazen_driver_postgres::{PgSyncAdapter, PostgresDriver}; @@ -47,6 +48,136 @@ fn keys() -> Vec { vec!["tenant_id".into(), "id".into()] } +fn sqlserver_filter_schema(filter_type: &str) -> TableSchema { + TableSchema { + table_name: "people".into(), + columns: vec![ + ColumnSchema { + name: "id".into(), + data_type: "int".into(), + nullable: false, + default_value: None, + comment: None, + is_primary_key: true, + is_auto_increment: false, + }, + ColumnSchema { + name: "name".into(), + data_type: filter_type.into(), + nullable: true, + default_value: None, + comment: None, + is_primary_key: false, + is_auto_increment: false, + }, + ColumnSchema { + name: "age".into(), + data_type: "int".into(), + nullable: false, + default_value: None, + comment: None, + is_primary_key: false, + is_auto_increment: false, + }, + ], + primary_keys: vec!["id".into()], + indexes: vec![], + foreign_keys: vec![], + check_constraints: vec![], + table_options: Default::default(), + } +} + +fn scalar_filter(column: &str, operator: &str, value: serde_json::Value) -> SyncSourceFilter { + serde_json::from_value(serde_json::json!({ + "filters": [{"column": column, "operator": operator, "value": value}] + })) + .expect("valid scalar sync filter JSON") +} + +fn validate_sqlserver_filter( + filter: &SyncSourceFilter, + source: &TableSchema, + target: &TableSchema, +) -> Result<(), String> { + let driver = MockDriver::new( + "sqlserver", + MockDriverOptions { + parameterized_writes: true, + ..MockDriverOptions::default() + }, + ); + let adapter = PgSyncAdapter; + let key_columns = vec!["id".into()]; + let (source_contracts, target_contracts) = resolve_key_contracts( + &key_columns, + &adapter, + &adapter, + source, + target, + "people", + "people", + ) + .map_err(|error| error.to_string())?; + validate_filter_endpoints( + filter, + &key_columns, + driver.as_ref(), + driver.as_ref(), + &adapter, + &adapter, + source, + target, + &source_contracts, + &target_contracts, + "people", + ) + .map_err(|error| error.to_string()) +} + +#[test] +fn sqlserver_text_filter_is_rejected_when_collation_parity_is_unrepresented() { + let source = sqlserver_filter_schema("nvarchar(64)"); + let target = source.clone(); + let filter = scalar_filter("name", "eq", serde_json::json!("Ada")); + + let error = validate_sqlserver_filter(&filter, &source, &target).unwrap_err(); + + assert!( + error.contains("default/per-column collation parity"), + "{error}" + ); +} + +#[test] +fn sqlserver_non_text_filter_is_safe_without_collation_metadata() { + let source = sqlserver_filter_schema("nvarchar(64)"); + let target = source.clone(); + let filter = scalar_filter("age", "gte", serde_json::json!(18)); + + validate_sqlserver_filter(&filter, &source, &target).unwrap(); +} + +#[test] +fn sqlserver_text_null_check_is_safe_without_collation_metadata() { + let source = sqlserver_filter_schema("nvarchar(64)"); + let target = source.clone(); + let filter = scalar_filter("name", "isNull", serde_json::Value::Null); + + validate_sqlserver_filter(&filter, &source, &target).unwrap(); +} + +#[test] +fn sqlserver_filter_rejects_unknown_alias_type_that_may_be_textual() { + let source = sqlserver_filter_schema("[dbo].[PersonName]"); + let target = source.clone(); + let filter = scalar_filter("name", "like", serde_json::json!("A%")); + + let error = validate_sqlserver_filter(&filter, &source, &target).unwrap_err(); + + assert!(error.contains("cannot verify whether this type uses text collation semantics")); +} + #[test] fn test_tester_filter_schema_validation_checks_both_endpoints() { let filter = tuple_filter(["1", "10"], ["2", "20"]); diff --git a/src-tauri/src/data_sync/filter.rs b/src-tauri/src/data_sync/filter.rs index 3bcf3962e..9f493dc49 100644 --- a/src-tauri/src/data_sync/filter.rs +++ b/src-tauri/src/data_sync/filter.rs @@ -7,7 +7,8 @@ use super::filter_values::{in_values, scalar_value, value_to_json}; use super::recordset::{ - build_predicate, recordset_limit, validate_recordset, validate_tuple_range_order, + build_predicate, comparison_columns as recordset_comparison_columns, recordset_limit, + validate_recordset, validate_tuple_range_order, }; pub use super::recordset::{SyncRecordset, SyncRecordsetBound}; use crate::data_sync::sql::quote_ident_sql; @@ -144,6 +145,27 @@ impl SyncSourceFilter { .is_some_and(|recordset| recordset.tuple_range.is_some())) } + /// Columns whose filter predicates compare values or define a range. + /// Null checks and limit-only recordsets do not depend on collation. + pub fn comparison_columns(&self, schema: &TableSchema) -> Result, DataSyncError> { + let payload = self.payload()?; + let mut columns = Vec::new(); + for condition in payload.filters { + if !matches!( + condition.operator, + FilterOperator::IsNull | FilterOperator::IsNotNull + ) { + columns.push(condition.column); + } + } + if let Some(recordset) = payload.recordset { + columns.extend(recordset_comparison_columns(&recordset, schema)?); + } + let mut seen = std::collections::HashSet::new(); + columns.retain(|column| seen.insert(column.clone())); + Ok(columns) + } + /// Build a `WHERE ...` fragment and the values bound to its placeholders. pub fn build_where

( &self, diff --git a/src-tauri/src/data_sync/recordset.rs b/src-tauri/src/data_sync/recordset.rs index 13892a8c3..12cad4369 100644 --- a/src-tauri/src/data_sync/recordset.rs +++ b/src-tauri/src/data_sync/recordset.rs @@ -156,6 +156,18 @@ pub(super) fn validate_recordset( resolve_recordset(recordset, schema).map(|_| ()) } +pub(super) fn comparison_columns( + recordset: &SyncRecordset, + schema: &TableSchema, +) -> Result, DataSyncError> { + let resolved = resolve_recordset(recordset, schema)?; + if resolved.start.is_some() || resolved.end.is_some() { + Ok(resolved.order_by) + } else { + Ok(Vec::new()) + } +} + pub(super) fn recordset_limit( recordset: &SyncRecordset, schema: &TableSchema, From 69864ee15f8a62317e11851b4911dac869fe3eef Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 00:36:20 +0800 Subject: [PATCH 007/666] test(sync): cover SQL Server filter collation operators --- .../commands/sync/filter_validation_tests.rs | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/sync/filter_validation_tests.rs b/src-tauri/src/commands/sync/filter_validation_tests.rs index 91eeacf10..513465ad8 100644 --- a/src-tauri/src/commands/sync/filter_validation_tests.rs +++ b/src-tauri/src/commands/sync/filter_validation_tests.rs @@ -149,6 +149,26 @@ fn sqlserver_text_filter_is_rejected_when_collation_parity_is_unrepresented() { ); } +#[test] +fn sqlserver_text_equality_range_like_and_membership_filters_fail_closed() { + let source = sqlserver_filter_schema("nvarchar(64)"); + let target = source.clone(); + for (operator, value) in [ + ("eq", serde_json::json!("Ada")), + ("gt", serde_json::json!("Ada")), + ("lte", serde_json::json!("Ada")), + ("like", serde_json::json!("A%")), + ("in", serde_json::json!(["Ada", "Grace"])), + ] { + let filter = scalar_filter("name", operator, value); + let error = validate_sqlserver_filter(&filter, &source, &target).unwrap_err(); + assert!( + error.contains("text column 'name'"), + "operator {operator} was not rejected for unverified SQL Server text collation: {error}" + ); + } +} + #[test] fn sqlserver_non_text_filter_is_safe_without_collation_metadata() { let source = sqlserver_filter_schema("nvarchar(64)"); @@ -162,9 +182,10 @@ fn sqlserver_non_text_filter_is_safe_without_collation_metadata() { fn sqlserver_text_null_check_is_safe_without_collation_metadata() { let source = sqlserver_filter_schema("nvarchar(64)"); let target = source.clone(); - let filter = scalar_filter("name", "isNull", serde_json::Value::Null); - - validate_sqlserver_filter(&filter, &source, &target).unwrap(); + for operator in ["isNull", "isNotNull"] { + let filter = scalar_filter("name", operator, serde_json::Value::Null); + validate_sqlserver_filter(&filter, &source, &target).unwrap(); + } } #[test] From be0c0e3d4a5bda07c4682f6d7a4d6357a77622c7 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 00:39:58 +0800 Subject: [PATCH 008/666] fix(schema-diff): scope SQL Server schemas and honor layout blockers --- src-tauri/src/commands/schema_diff.rs | 30 ++++++---- .../src/commands/schema_diff/unified_plan.rs | 20 +++---- .../schema_diff/unified_plan/catalog.rs | 2 +- src-tauri/src/schema_diff/plan.rs | 46 ++++++++++++++- src-tauri/src/schema_diff/plan_tests.rs | 36 +++++++++++ src-tauri/src/schema_diff/types.rs | 23 +++++++- src-tauri/src/schema_diff/unified.rs | 7 ++- src-tauri/src/schema_diff/unified_scope.rs | 2 +- src-tauri/src/schema_diff/unified_tests.rs | 50 ++++++++++++++++ .../unified_tests/scope_mapping.rs | 59 +++++++++++++++++++ .../src/schema_diff/unified_validation.rs | 2 +- 11 files changed, 249 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/commands/schema_diff.rs b/src-tauri/src/commands/schema_diff.rs index f3122bf1a..0b5bd1f30 100644 --- a/src-tauri/src/commands/schema_diff.rs +++ b/src-tauri/src/commands/schema_diff.rs @@ -19,8 +19,8 @@ use crate::schema_diff::objects::{ use crate::schema_diff::plan::{is_source_unbounded_text, PlanOptions}; use crate::schema_diff::types::TableColumnDiff; use crate::schema_diff::types::{ - normalize_dialect, resolve_table_for_dialect, ColumnTypeOverride, SchemaDiffDeployResult, - SchemaDiffPlan, + normalize_dialect, resolve_table_for_dialect, uses_schema_scope, ColumnTypeOverride, + SchemaDiffDeployResult, SchemaDiffPlan, }; use crate::schema_diff::SchemaDiffProfile; use crate::services::job_registry::{cancel_job, ensure_job, remove_job}; @@ -593,7 +593,7 @@ async fn fetch_target_table_dependency_catalog( continue; } - if normalize_dialect(dialect) == "postgresql" && !selected_table.contains('.') { + if uses_schema_scope(dialect) && !selected_table.contains('.') { let candidates = snapshots .iter() .filter(|(identity, _)| { @@ -656,7 +656,7 @@ fn resolve_profile_table(dialect: &str, table: &str, schema: Option<&str>) -> St fn dependency_relation_identity(dialect: &str, table: &str, schema_scope: Option<&str>) -> String { let table = table.trim(); - if normalize_dialect(dialect) == "postgresql" && !table.contains('.') { + if uses_schema_scope(dialect) && !table.contains('.') { if let Some(schema) = schema_scope.filter(|value| !value.trim().is_empty()) { return format!("{}.{}", schema.trim(), table); } @@ -670,7 +670,7 @@ fn resolve_reviewed_table_snapshot( database_scope: &str, schema_scope: Option<&str>, ) -> (String, String, Option) { - if normalize_dialect(dialect) == "postgresql" { + if uses_schema_scope(dialect) { if let Some((schema, relation)) = table.rsplit_once('.') { return ( relation.to_string(), @@ -893,12 +893,11 @@ async fn prepare_schema_diff_plan_with_schemas_impl( .get_session(&target_db_session_id) .await .cmd_err("prepare_schema_diff_plan")?; - let target_dependency_schema_scope = - if normalize_dialect(&tgt_config.database_type) == "postgresql" { - target_schema_scope.or(tgt_driver.default_schema()) - } else { - None - }; + let target_dependency_schema_scope = if uses_schema_scope(&tgt_config.database_type) { + target_schema_scope.or(tgt_driver.default_schema()) + } else { + None + }; ensure_distinct_schema_scope( src_driver.as_ref(), &src_handle, @@ -2806,6 +2805,14 @@ mod tests { resolve_reviewed_table_snapshot("postgresql", "events", "app", Some("public")), ("events".into(), "app".into(), Some("public".into())) ); + assert_eq!( + resolve_reviewed_table_snapshot("sqlserver", "sales.orders", "app", Some("dbo")), + ("orders".into(), "app".into(), Some("sales".into())) + ); + assert_eq!( + resolve_reviewed_table_snapshot("sqlserver", "orders", "app", Some("dbo")), + ("orders".into(), "app".into(), Some("dbo".into())) + ); assert_eq!( resolve_reviewed_table_snapshot("mysql", "events", "app", None), ("events".into(), "app".into(), None) @@ -2988,6 +2995,7 @@ mod tests { "main" ); assert_eq!(schema_catalog_database("postgresql", Some("app")), "app"); + assert_eq!(schema_catalog_database("sqlserver", Some("app")), "app"); assert_eq!(schema_catalog_database("mysql", None), ""); assert_eq!( schema_catalog_scope("sqlite", Some("/tmp/target.sqlite")), diff --git a/src-tauri/src/commands/schema_diff/unified_plan.rs b/src-tauri/src/commands/schema_diff/unified_plan.rs index cffd4c01f..f5752fde3 100644 --- a/src-tauri/src/commands/schema_diff/unified_plan.rs +++ b/src-tauri/src/commands/schema_diff/unified_plan.rs @@ -156,12 +156,11 @@ pub async fn prepare_schema_unified_plan( .get_session(&target_db_session_id) .await .cmd_err("prepare_schema_unified_plan")?; - let target_dependency_schema_scope = - if normalize_dialect(&target_config.database_type) == "postgresql" { - target_schema_scope.or(target_driver.default_schema()) - } else { - None - }; + let target_dependency_schema_scope = if uses_schema_scope(&target_config.database_type) { + target_schema_scope.or(target_driver.default_schema()) + } else { + None + }; ensure_distinct_schema_scope( source_driver.as_ref(), &source_handle, @@ -334,8 +333,7 @@ pub async fn prepare_schema_unified_plan( let mut planner_source_snapshots = source_snapshots.clone(); for snapshot in &mut planner_source_snapshots { - let target_object_scope = if normalize_dialect(&target_config.database_type) == "postgresql" - { + let target_object_scope = if uses_schema_scope(&target_config.database_type) { target_dependency_schema_scope } else { Some(super::schema_catalog_database( @@ -500,7 +498,7 @@ pub async fn prepare_schema_unified_plan( target_config.database_type ))); }; - let source_object_scope = if normalize_dialect(&source_config.database_type) == "postgresql" { + let source_object_scope = if uses_schema_scope(&source_config.database_type) { source_schema_scope.or(source_driver.default_schema()) } else { Some(super::schema_catalog_database( @@ -694,7 +692,7 @@ fn table_identities_from_catalog( .filter(|table| matches!(table.table_type, TableType::Table)) .map(|table| { let scope = table.schema.as_deref().or_else(|| { - if normalize_dialect(dialect) == "postgresql" { + if uses_schema_scope(dialect) { schema_scope } else { database @@ -719,7 +717,7 @@ fn table_identity_for_target( .map(|(schema, name)| (Some(schema), name)) .unwrap_or((None, table)); let scope = schema.or_else(|| { - if normalize_dialect(dialect) == "postgresql" { + if uses_schema_scope(dialect) { schema_scope } else { database diff --git a/src-tauri/src/commands/schema_diff/unified_plan/catalog.rs b/src-tauri/src/commands/schema_diff/unified_plan/catalog.rs index a2585d472..5f839c613 100644 --- a/src-tauri/src/commands/schema_diff/unified_plan/catalog.rs +++ b/src-tauri/src/commands/schema_diff/unified_plan/catalog.rs @@ -37,7 +37,7 @@ pub(crate) async fn read_target_object_catalog( .map(|table| DatabaseObject { kind: ObjectKind::Table.as_str().into(), schema: table.schema.or_else(|| { - if normalize_dialect(dialect) == "postgresql" { + if uses_schema_scope(dialect) { schema_scope.map(str::to_owned) } else { Some(metadata_database.to_owned()) diff --git a/src-tauri/src/schema_diff/plan.rs b/src-tauri/src/schema_diff/plan.rs index 4868bb6d0..6afbd0d85 100644 --- a/src-tauri/src/schema_diff/plan.rs +++ b/src-tauri/src/schema_diff/plan.rs @@ -944,6 +944,7 @@ fn plan_single_table( statements: &mut Vec, warnings: &mut Vec, requirements: &mut Vec, + migration_layout_blocked: &mut bool, ) { let table = match datazen_driver_api::validate_migration_identifier(table) { Ok(value) => value, @@ -972,6 +973,17 @@ fn plan_single_table( let normalizer_ref = normalizer.as_deref(); let mut operations = super::ir::diff_to_operations(table, src, tgt, normalizer_ref); + if !operations.is_empty() { + if let Some(reason) = table_migration_blocker_reason(src, tgt) { + requirements.push(PlanRequirement::Unsupported { + operation: format!("table:{table}"), + reason, + }); + *migration_layout_blocked = true; + return; + } + } + // Type mapping belongs at the boundary between source snapshot and target driver. // The IR remains dialect-neutral; only replace types before rendering. if opts.type_mapper.is_some() { @@ -1276,6 +1288,29 @@ fn plan_single_table( } } +pub(crate) fn table_migration_blocker_reason( + source: &TableSchema, + target: &TableSchema, +) -> Option { + let source_blockers = &source.table_options.migration_blockers; + let target_blockers = &target.table_options.migration_blockers; + if source_blockers.is_empty() && target_blockers.is_empty() { + return None; + } + + let mut details = Vec::new(); + if !source_blockers.is_empty() { + details.push(format!("source: {}", source_blockers.join(", "))); + } + if !target_blockers.is_empty() { + details.push(format!("target: {}", target_blockers.join(", "))); + } + Some(format!( + "This table has physical layout metadata that the migration model cannot represent ({}); no DDL plan can safely alter it. Resolve the layout or compare a table whose physical structure is fully represented.", + details.join("; ") + )) +} + fn is_type_narrowing(desired: &ColumnSnapshot, current: &ColumnSnapshot) -> bool { let desired_l = desired.data_type.to_ascii_lowercase(); let current_l = current.data_type.to_ascii_lowercase(); @@ -1773,7 +1808,7 @@ fn target_drop_relation_identity( table.to_string() } } - "postgresql" => { + "postgresql" | "sqlserver" => { let table = table.trim(); if table.contains('.') { table.to_string() @@ -1953,6 +1988,7 @@ pub fn build_schema_diff_plan_with_target_only_catalog_in_schema_scope( }; let mut tables = Vec::new(); + let mut migration_layout_blocked = false; let selected_drop_identities = target_only_tables .iter() .map(|table| target_drop_relation_identity(&tgt_d, table, target_database, target_schema)) @@ -1990,6 +2026,7 @@ pub fn build_schema_diff_plan_with_target_only_catalog_in_schema_scope( &mut statements, &mut warnings, &mut requirements, + &mut migration_layout_blocked, ); } @@ -2054,6 +2091,13 @@ pub fn build_schema_diff_plan_with_target_only_catalog_in_schema_scope( } } + if migration_layout_blocked { + // A blocker on any selected table invalidates the complete reviewed + // plan. Do not leave executable statements for other tables or + // target-only drops in a partially renderable plan. + statements.clear(); + } + statements = reorder_foreign_key_statements(statements); let primary = tables.first().cloned().unwrap_or_default(); let completeness = rollback_completeness(&statements); diff --git a/src-tauri/src/schema_diff/plan_tests.rs b/src-tauri/src/schema_diff/plan_tests.rs index 1aaa7f2bd..47745a4a4 100644 --- a/src-tauri/src/schema_diff/plan_tests.rs +++ b/src-tauri/src/schema_diff/plan_tests.rs @@ -1662,6 +1662,42 @@ fn sqlite_rebuild_is_blocked_before_sql_when_target_catalog_is_not_round_trippab ))); } +#[test] +fn unsupported_physical_layout_blocks_primary_key_change_and_clears_other_table_sql() { + let mut source = schema(vec![col("id", "integer"), col("new_id", "integer")]); + source.primary_keys = vec!["new_id".into()]; + source + .table_options + .migration_blockers + .push("nonclustered primary key cannot be represented".into()); + let mut target = source.clone(); + target.primary_keys = vec!["id".into()]; + target.table_options.migration_blockers.clear(); + + let safe_source = schema(vec![col("id", "integer"), col("email", "text")]); + let safe_target = schema(vec![col("id", "integer")]); + let plan = build_schema_diff_plan( + &[ + ("blocked".into(), source, target), + ("safe".into(), safe_source, safe_target), + ], + "postgresql", + "postgresql", + PlanOptions { + allow_destructive: true, + include_indexes: true, + ..PlanOptions::default() + }, + ); + + assert!(plan.statements.is_empty(), "{:?}", plan.statements); + assert!(plan.requirements.iter().any(|requirement| matches!( + requirement, + PlanRequirement::Unsupported { operation, reason } + if operation == "table:blocked" && reason.contains("nonclustered primary key") + ))); +} + #[test] fn mysql_indexes_on_blob_text_columns_receive_prefix_length() { let mut src = schema(vec![ diff --git a/src-tauri/src/schema_diff/types.rs b/src-tauri/src/schema_diff/types.rs index e269b50e4..07489857b 100644 --- a/src-tauri/src/schema_diff/types.rs +++ b/src-tauri/src/schema_diff/types.rs @@ -249,6 +249,16 @@ pub fn normalize_dialect(raw: &str) -> String { } } +/// Whether a dialect uses schema as the relation scope inside the configured +/// database. SQL Server and PostgreSQL both keep schema identity separate from +/// database/catalog identity. +pub fn uses_schema_scope(dialect: &str) -> bool { + matches!( + normalize_dialect(dialect).as_str(), + "postgresql" | "sqlserver" + ) +} + pub fn resolve_table_for_dialect(dialect: &str, table: &str) -> String { let trimmed = table.trim(); match normalize_dialect(dialect).as_str() { @@ -262,7 +272,7 @@ pub fn resolve_table_for_dialect(dialect: &str, table: &str) -> String { #[cfg(test)] mod resolve_tests { - use super::resolve_table_for_dialect; + use super::{resolve_table_for_dialect, uses_schema_scope}; #[test] fn mysql_strips_pg_schema_prefix() { assert_eq!( @@ -277,6 +287,17 @@ mod resolve_tests { "public.users" ); } + + #[test] + fn sqlserver_uses_schema_scope_and_keeps_qualified_relation() { + assert!(uses_schema_scope("sqlserver")); + assert!(uses_schema_scope("postgres")); + assert!(!uses_schema_scope("mysql")); + assert_eq!( + resolve_table_for_dialect("sqlserver", "sales.orders"), + "sales.orders" + ); + } #[test] fn bare_name_unchanged_for_all_dialects() { assert_eq!(resolve_table_for_dialect("mysql", "users"), "users"); diff --git a/src-tauri/src/schema_diff/unified.rs b/src-tauri/src/schema_diff/unified.rs index fe346f309..70c5e47c8 100644 --- a/src-tauri/src/schema_diff/unified.rs +++ b/src-tauri/src/schema_diff/unified.rs @@ -177,7 +177,7 @@ pub(crate) fn build_unified_schema_diff_plan_with_source_scope( requirements, ); } - let target_object_scope = if target_dialect == "postgresql" { + let target_object_scope = if uses_schema_scope(&target_dialect) { target_schema } else { target_database @@ -280,6 +280,11 @@ pub(crate) fn build_unified_schema_diff_plan_with_source_scope( ) }); } + if !table_operations.is_empty() { + if let Some(reason) = super::plan::table_migration_blocker_reason(source, target) { + requirements.push(unsupported(format!("table:{table}"), reason)); + } + } for operation in &table_operations { if let MigrationOperation::AddColumn { table, column } = operation { if !column.nullable && column.default_value.is_none() { diff --git a/src-tauri/src/schema_diff/unified_scope.rs b/src-tauri/src/schema_diff/unified_scope.rs index 1b757e1ef..aa464c269 100644 --- a/src-tauri/src/schema_diff/unified_scope.rs +++ b/src-tauri/src/schema_diff/unified_scope.rs @@ -27,7 +27,7 @@ pub(super) fn map_source_objects_for_target_scope( ) -> (Vec, Vec) { let mut mapped = Vec::with_capacity(source.len()); let mut requirements = Vec::new(); - if !matches!(target_dialect, "mysql" | "postgresql") { + if !matches!(target_dialect, "mysql" | "postgresql" | "sqlserver") { return (source.to_vec(), requirements); } for object in source { diff --git a/src-tauri/src/schema_diff/unified_tests.rs b/src-tauri/src/schema_diff/unified_tests.rs index 51e770f6c..a7578c68f 100644 --- a/src-tauri/src/schema_diff/unified_tests.rs +++ b/src-tauri/src/schema_diff/unified_tests.rs @@ -115,6 +115,56 @@ fn target_table(name: &str) -> SchemaObjectIdentity { SchemaObjectIdentity::table(Some("public"), name) } +#[test] +fn physical_layout_blocker_prevents_unified_pk_change_and_partial_sql() { + let mut source = table( + "blocked", + vec![column("id", "integer"), column("new_id", "integer")], + vec![], + ); + source.primary_keys = vec!["new_id".into()]; + source + .table_options + .migration_blockers + .push("nonclustered primary key cannot be represented".into()); + let mut target = table( + "blocked", + vec![column("id", "integer"), column("new_id", "integer")], + vec![], + ); + target.primary_keys = vec!["id".into()]; + + let safe_source = table( + "safe", + vec![column("id", "integer"), column("email", "text")], + vec![], + ); + let safe_target = table("safe", vec![column("id", "integer")], vec![]); + let renderer = TestRenderer { + omit_rollback: false, + }; + let plan = build( + &[ + ("blocked".into(), source, target), + ("safe".into(), safe_source, safe_target), + ], + &[], + &[], + &[], + &[], + &[], + true, + &renderer, + ); + + assert!(plan.statements.is_empty(), "{:?}", plan.statements); + assert!(plan.requirements.iter().any(|requirement| matches!( + requirement, + PlanRequirement::Unsupported { operation, reason } + if operation == "table:blocked" && reason.contains("nonclustered primary key") + ))); +} + fn build( table_pairs: &[(String, TableSchema, TableSchema)], target_only_tables: &[String], diff --git a/src-tauri/src/schema_diff/unified_tests/scope_mapping.rs b/src-tauri/src/schema_diff/unified_tests/scope_mapping.rs index ad8282672..ac2ed2a75 100644 --- a/src-tauri/src/schema_diff/unified_tests/scope_mapping.rs +++ b/src-tauri/src/schema_diff/unified_tests/scope_mapping.rs @@ -288,3 +288,62 @@ fn target_view_with_unpreserved_metadata_blocks_create_and_rollback_rendering() assert!(format!("{:?}", plan.requirements).contains("target_db.item_view")); assert!(format!("{:?}", plan.requirements).contains("non-default creation semantics")); } + +#[test] +fn sqlserver_object_scope_uses_schema_and_rejects_cross_schema_mapping() { + let view = SchemaObjectSnapshot::view( + Some("dbo"), + "item_view", + "CREATE VIEW [dbo].[item_view] AS SELECT 1 AS value", + ) + .with_dependencies(vec![]); + let plan = build_unified_schema_diff_plan_with_source_scope( + &[], + &[], + &[view.clone()], + &[], + &[], + &[], + &[], + true, + &[], + "sqlserver", + "sqlserver", + Some("dbo"), + Some("target_database"), + false, + true, + &[], + &MappingRenderer, + &TestCapabilities, + Some("dbo"), + None, + ); + assert!(plan.requirements.is_empty(), "{:?}", plan.requirements); + assert_eq!(plan.statements.len(), 1); + + let cross_schema = build_unified_schema_diff_plan_with_source_scope( + &[], + &[], + &[view], + &[], + &[], + &[], + &[], + true, + &[], + "sqlserver", + "sqlserver", + Some("sales"), + Some("target_database"), + false, + true, + &[], + &MappingRenderer, + &TestCapabilities, + Some("dbo"), + None, + ); + assert!(cross_schema.statements.is_empty()); + assert!(format!("{:?}", cross_schema.requirements).contains("Cross-scope migration")); +} diff --git a/src-tauri/src/schema_diff/unified_validation.rs b/src-tauri/src/schema_diff/unified_validation.rs index ab5e72e88..098bbaa80 100644 --- a/src-tauri/src/schema_diff/unified_validation.rs +++ b/src-tauri/src/schema_diff/unified_validation.rs @@ -571,7 +571,7 @@ fn table_identity( .map(|(scope, name)| (Some(scope), name)) .unwrap_or((None, raw)); let scope = match normalize_dialect(dialect).as_str() { - "postgresql" => scope.or(target_schema), + "postgresql" | "sqlserver" => scope.or(target_schema), "mysql" => scope.or(target_database), _ => scope, }; From 7d5ee0c2d9474c1946934560090e9b3dea05b95d Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 01:01:43 +0800 Subject: [PATCH 009/666] feat(sqlserver): support identity values in transfer --- packages/driver-api/src/reuse.rs | 58 +++ packages/driver-api/src/sync/adapter.rs | 18 +- packages/driver-api/src/traits.rs | 64 ++++ packages/drivers/sqlserver/src/sqlserver.rs | 266 ++++++++++++++ .../drivers/sqlserver/src/sync_adapter.rs | 338 ++++++++++++++++-- .../src/data_transfer/execute/dispatcher.rs | 104 +++++- .../src/data_transfer/execution_tests.rs | 261 ++++++++++++++ src-tauri/src/data_transfer/resume.rs | 6 +- .../src/data_transfer/resume/fingerprint.rs | 5 +- src-tauri/src/data_transfer/resume/tests.rs | 12 +- src-tauri/src/data_transfer/sql_file.rs | 69 +++- src-tauri/src/data_transfer/writer.rs | 9 + 12 files changed, 1152 insertions(+), 58 deletions(-) diff --git a/packages/driver-api/src/reuse.rs b/packages/driver-api/src/reuse.rs index 731083cea..fa1e0e9ea 100644 --- a/packages/driver-api/src/reuse.rs +++ b/packages/driver-api/src/reuse.rs @@ -60,10 +60,55 @@ impl DatabaseDriver for ReuseDriver { self.inner.transfer_explicit_identity_insert_clause() } + fn transfer_explicit_identity_insert_requires_session_toggle(&self) -> bool { + self.inner + .transfer_explicit_identity_insert_requires_session_toggle() + } + + async fn advance_transfer_identity_sequences( + &self, + handle: &ConnectionHandle, + schema: Option<&str>, + table: &str, + columns: &[String], + ) -> Result<(), DriverError> { + self.inner + .advance_transfer_identity_sequences(handle, schema, table, columns) + .await + } + + async fn set_transfer_identity_insert( + &self, + handle: &ConnectionHandle, + database: &str, + schema: Option<&str>, + table: &str, + enabled: bool, + ) -> Result<(), DriverError> { + self.inner + .set_transfer_identity_insert(handle, database, schema, table, enabled) + .await + } + + async fn discard_transfer_connection( + &self, + handle: &ConnectionHandle, + ) -> Result<(), DriverError> { + self.inner.discard_transfer_connection(handle).await + } + fn transfer_sql_file_insert_batch_size(&self) -> usize { self.inner.transfer_sql_file_insert_batch_size() } + fn transfer_sql_file_begin_transaction(&self) -> &'static str { + self.inner.transfer_sql_file_begin_transaction() + } + + fn transfer_sql_file_commit_transaction(&self) -> &'static str { + self.inner.transfer_sql_file_commit_transaction() + } + fn render_transfer_sql_file_insert( &self, insert_template: &str, @@ -73,6 +118,15 @@ impl DatabaseDriver for ReuseDriver { .render_transfer_sql_file_insert(insert_template, identity_override_marker) } + fn render_transfer_sql_file_identity_insert( + &self, + insert_sql: &str, + target_relation: &str, + ) -> Result { + self.inner + .render_transfer_sql_file_identity_insert(insert_sql, target_relation) + } + fn type_normalizer(&self) -> Option> { self.inner.type_normalizer() } @@ -282,6 +336,10 @@ impl DatabaseDriver for ReuseDriver { self.inner.parameter_placeholder(index, data_type) } + fn max_bound_parameters(&self) -> usize { + self.inner.max_bound_parameters() + } + async fn execute_with_params( &self, handle: &ConnectionHandle, diff --git a/packages/driver-api/src/sync/adapter.rs b/packages/driver-api/src/sync/adapter.rs index 070f8141b..49db2e23b 100644 --- a/packages/driver-api/src/sync/adapter.rs +++ b/packages/driver-api/src/sync/adapter.rs @@ -220,14 +220,21 @@ pub trait SyncTargetAdapter: Send + Sync { /// The default is a no-op so this does not alter Data Sync or Schema Diff. fn validate_transfer_column_type( &self, - _source_column: &ColumnSchema, - _source_ir: &IRColumn, + source_column: &ColumnSchema, + source_ir: &IRColumn, _source_text_limit_bytes: Option, _source_requires_collation_preservation: bool, - _source_type_is_native_only: bool, + source_type_is_native_only: bool, _target_native_type: Option<&str>, _creating_target: bool, ) -> Result<(), String> { + if source_type_is_native_only { + return Err(format!( + "source type '{}' has no target adapter proof of a lossless native mapping", + source_column.data_type + )); + } + let _ = source_ir; Ok(()) } @@ -320,9 +327,8 @@ pub trait SyncTargetAdapter: Send + Sync { } /// Whether the target accepts explicit values for identity/auto-increment - /// columns during INSERT. The transfer writer currently inserts mapped - /// source values directly and does not toggle engine-specific session - /// modes such as SQL Server IDENTITY_INSERT. + /// columns during INSERT, either directly or through a driver-managed + /// session mode such as SQL Server IDENTITY_INSERT. fn supports_explicit_identity_values(&self) -> bool { false } diff --git a/packages/driver-api/src/traits.rs b/packages/driver-api/src/traits.rs index d8b03f6e0..b855110f7 100644 --- a/packages/driver-api/src/traits.rs +++ b/packages/driver-api/src/traits.rs @@ -451,6 +451,14 @@ pub trait DatabaseDriver: Send + Sync { )) } + /// Maximum number of bound parameters accepted by one statement. + /// Drivers should report the server's actual statement limit so bulk + /// writers can split batches before execution. The default is a + /// conservative portable ceiling for engines without a lower limit. + fn max_bound_parameters(&self) -> usize { + 60_000 + } + /// Execute bound DML and report actual affected rows; never serialize values as SQL. async fn execute_with_params( &self, @@ -615,6 +623,40 @@ pub trait DatabaseDriver: Send + Sync { Ok(()) } + /// Whether Data Transfer must bracket explicit identity values with a + /// session-scoped mode on this target connection. + fn transfer_explicit_identity_insert_requires_session_toggle(&self) -> bool { + false + } + + /// Enable or disable explicit identity insertion for one transfer table. + /// The caller keeps the toggle within the table transaction and attempts + /// cleanup on every success, error, and cancellation path. + async fn set_transfer_identity_insert( + &self, + _handle: &ConnectionHandle, + _database: &str, + _schema: Option<&str>, + _table: &str, + _enabled: bool, + ) -> Result<(), DriverError> { + Err(DriverError::Unsupported( + "target does not implement session-scoped identity insertion".into(), + )) + } + + /// Drop a target session whose transfer-scoped state could not be reset. + /// Drivers that require session toggles must implement this; the default + /// fails closed so the host never reports that an un-dropped session is safe. + async fn discard_transfer_connection( + &self, + _handle: &ConnectionHandle, + ) -> Result<(), DriverError> { + Err(DriverError::Unsupported( + "driver cannot discard a transfer connection after session cleanup failed".into(), + )) + } + /// Return the clause required by this dialect to accept explicit values /// for generated identity columns during Data Transfer. This is a /// transfer-only DML extension; the default keeps existing drivers and @@ -630,6 +672,16 @@ pub trait DatabaseDriver: Send + Sync { 1 } + /// Transaction delimiters for one atomic Data Transfer SQL-file artifact. + /// Engines with different transaction batch syntax may override these. + fn transfer_sql_file_begin_transaction(&self) -> &'static str { + "BEGIN;" + } + + fn transfer_sql_file_commit_transaction(&self) -> &'static str { + "COMMIT;" + } + /// Render a Data Transfer SQL-file INSERT when the target schema cannot /// be inspected while exporting. The template may contain multiple /// VALUES rows. `identity_override_marker` is a unique placeholder @@ -649,6 +701,18 @@ pub trait DatabaseDriver: Send + Sync { Ok(insert_template.replacen(&format!("{identity_override_marker} "), "", 1)) } + /// Wrap an SQL-file INSERT that carries explicit values for a mapped + /// identity column. The target relation is already safely quoted by the + /// host for this dialect. Drivers without a session toggle return the + /// statement unchanged. + fn render_transfer_sql_file_identity_insert( + &self, + insert_sql: &str, + _target_relation: &str, + ) -> Result { + Ok(insert_sql.to_string()) + } + /// Render Data Transfer sequence synchronization statements for an SQL /// file targeting this driver's dialect. Drivers without such a concept /// may keep the empty default. diff --git a/packages/drivers/sqlserver/src/sqlserver.rs b/packages/drivers/sqlserver/src/sqlserver.rs index 9bc7280e3..7f5034c48 100644 --- a/packages/drivers/sqlserver/src/sqlserver.rs +++ b/packages/drivers/sqlserver/src/sqlserver.rs @@ -58,6 +58,105 @@ impl SqlServerDriver { } } + fn quote_identifier(identifier: &str) -> Result { + if identifier.is_empty() || identifier.contains('\0') { + return Err(DriverError::InvalidConfig( + "SQL Server transfer relation identifiers must be non-empty and contain no NUL" + .into(), + )); + } + Ok(format!("[{}]", identifier.replace(']', "]]"))) + } + + fn transfer_identity_relation( + database: &str, + schema: Option<&str>, + table: &str, + ) -> Result { + let mut parts = Vec::with_capacity(3); + if !database.trim().is_empty() { + parts.push(Self::quote_identifier(database)?); + } + let schema = schema + .map(str::trim) + .filter(|schema| !schema.is_empty()) + .unwrap_or("dbo"); + parts.push(Self::quote_identifier(schema)?); + parts.push(Self::quote_identifier(table)?); + Ok(parts.join(".")) + } + + /// Accept only a relation rendered by Data Transfer's SQL Server + /// identifier quoter. This prevents a caller from smuggling SQL into the + /// session toggle or the OBJECT_ID condition. + fn quoted_transfer_relation_parts(relation: &str) -> Option> { + let bytes = relation.as_bytes(); + let mut cursor = 0; + let mut parts = Vec::new(); + while cursor < bytes.len() { + if bytes[cursor] != b'[' { + return None; + } + let part_start = cursor; + cursor += 1; + let mut has_identifier_content = false; + let mut closed = false; + while cursor < bytes.len() { + match bytes[cursor] { + b']' if bytes.get(cursor + 1) == Some(&b']') => { + has_identifier_content = true; + cursor += 2; + } + b']' => { + cursor += 1; + closed = true; + break; + } + _ => { + has_identifier_content = true; + cursor += 1; + } + } + } + if !closed || !has_identifier_content { + return None; + } + parts.push(&relation[part_start..cursor]); + if cursor == bytes.len() { + break; + } + if bytes[cursor] != b'.' { + return None; + } + cursor += 1; + if cursor == bytes.len() { + return None; + } + } + (1..=3).contains(&parts.len()).then_some(parts) + } + + fn render_sql_file_identity_insert( + insert_sql: &str, + target_relation: &str, + ) -> Result { + let relation_parts = + Self::quoted_transfer_relation_parts(target_relation).ok_or_else(|| { + DriverError::InvalidConfig( + "SQL Server SQL-file identity wrapper requires a safely quoted target relation" + .into(), + ) + })?; + let object_name = target_relation.replace('\'', "''"); + let identity_catalog = relation_parts + .get(2) + .map(|_| format!("{}.sys.identity_columns", relation_parts[0])) + .unwrap_or_else(|| "sys.identity_columns".into()); + Ok(format!( + "IF EXISTS (SELECT 1 FROM {identity_catalog} WHERE object_id = OBJECT_ID(N'{object_name}', N'U'))\nBEGIN\n BEGIN TRY\n SET IDENTITY_INSERT {target_relation} ON;\n {insert_sql};\n SET IDENTITY_INSERT {target_relation} OFF;\n END TRY\n BEGIN CATCH\n BEGIN TRY\n SET IDENTITY_INSERT {target_relation} OFF;\n END TRY\n BEGIN CATCH\n THROW;\n END CATCH\n THROW;\n END CATCH\nEND\nELSE\nBEGIN\n {insert_sql};\nEND" + )) + } + /// List tables and views together with the schema that owns them. /// /// The schema column is mandatory: SQL Server has a real schema level, and @@ -1186,6 +1285,57 @@ impl DatabaseDriver for SqlServerDriver { Ok(format!("@P{index}")) } + fn max_bound_parameters(&self) -> usize { + 2100 + } + + fn transfer_explicit_identity_insert_requires_session_toggle(&self) -> bool { + true + } + + fn transfer_sql_file_begin_transaction(&self) -> &'static str { + "BEGIN TRANSACTION;" + } + + fn transfer_sql_file_commit_transaction(&self) -> &'static str { + "COMMIT TRANSACTION;" + } + + async fn set_transfer_identity_insert( + &self, + handle: &ConnectionHandle, + database: &str, + schema: Option<&str>, + table: &str, + enabled: bool, + ) -> Result<(), DriverError> { + let relation = Self::transfer_identity_relation(database, schema, table)?; + let mode = if enabled { "ON" } else { "OFF" }; + let statement = format!("SET IDENTITY_INSERT {relation} {mode}"); + let mut clients = self.clients.write().await; + let client = clients + .get_mut(&handle.pool_id) + .ok_or_else(|| DriverError::ConnectionFailed("Connection pool not found".into()))?; + Self::execute_batch(client, &statement).await + } + + async fn discard_transfer_connection( + &self, + handle: &ConnectionHandle, + ) -> Result<(), DriverError> { + self.transactions.lock().await.remove(&handle.id); + self.clients.write().await.remove(&handle.pool_id); + Ok(()) + } + + fn render_transfer_sql_file_identity_insert( + &self, + insert_sql: &str, + target_relation: &str, + ) -> Result { + Self::render_sql_file_identity_insert(insert_sql, target_relation) + } + async fn execute_with_params( &self, handle: &ConnectionHandle, @@ -1846,4 +1996,120 @@ mod tests { assert_eq!(temporal_text(&ColumnData::Date(None)), None); assert_eq!(temporal_text(&ColumnData::DateTimeOffset(None)), None); } + + #[test] + fn reports_sql_server_bound_parameter_limit() { + assert_eq!(SqlServerDriver::new().max_bound_parameters(), 2100); + } + + #[test] + fn transfer_identity_insert_relation_quotes_every_sql_server_identifier() { + assert_eq!( + SqlServerDriver::transfer_identity_relation( + "data]zen", + Some("odd.schema"), + "order]details" + ) + .unwrap(), + "[data]]zen].[odd.schema].[order]]details]" + ); + assert_eq!( + SqlServerDriver::transfer_identity_relation("", None, "items").unwrap(), + "[dbo].[items]" + ); + assert!(SqlServerDriver::transfer_identity_relation("db", Some(""), "").is_err()); + } + + #[test] + fn sql_file_identity_wrapper_checks_catalog_and_cleans_up_on_error() { + let script = SqlServerDriver::render_sql_file_identity_insert( + "INSERT INTO [odd.schema].[order]]details] ([id]) VALUES (1)", + "[data]]zen].[odd.schema].[order]]details]", + ) + .unwrap(); + assert!(script.contains("OBJECT_ID(N'[data]]zen].[odd.schema].[order]]details]', N'U')")); + assert!(script.contains("FROM [data]]zen].sys.identity_columns WHERE object_id")); + assert!( + script.contains("SET IDENTITY_INSERT [data]]zen].[odd.schema].[order]]details] ON;") + ); + assert!( + script.contains("SET IDENTITY_INSERT [data]]zen].[odd.schema].[order]]details] OFF;") + ); + assert!(script.contains("BEGIN CATCH\n BEGIN TRY\n SET IDENTITY_INSERT")); + assert!(script.contains(" THROW;")); + assert!(script.contains("ELSE\nBEGIN\n INSERT INTO [odd.schema]")); + let local_script = SqlServerDriver::render_sql_file_identity_insert( + "INSERT INTO [dbo].[items] ([id]) VALUES (1)", + "[dbo].[items]", + ) + .unwrap(); + assert!(local_script.contains("FROM sys.identity_columns WHERE object_id")); + let apostrophe_script = SqlServerDriver::render_sql_file_identity_insert( + "INSERT INTO [O'Brien].[items] ([id]) VALUES (1)", + "[db].[O'Brien].[items]", + ) + .unwrap(); + assert!(apostrophe_script.contains("OBJECT_ID(N'[db].[O''Brien].[items]'")); + } + + #[test] + fn sql_file_identity_wrapper_rejects_unquoted_relation_fragments() { + assert!(SqlServerDriver::render_sql_file_identity_insert( + "INSERT INTO t (id) VALUES (1)", + "dbo.t; DROP TABLE users" + ) + .is_err()); + assert!(SqlServerDriver::render_sql_file_identity_insert( + "INSERT INTO [dbo].[t] ([id]) VALUES (1)", + "[dbo]." + ) + .is_err()); + } + + #[test] + fn explicit_transfer_identity_insert_uses_a_session_toggle() { + let driver = SqlServerDriver::new(); + assert!(driver.transfer_explicit_identity_insert_requires_session_toggle()); + assert_eq!( + driver.transfer_sql_file_begin_transaction(), + "BEGIN TRANSACTION;" + ); + assert_eq!( + driver.transfer_sql_file_commit_transaction(), + "COMMIT TRANSACTION;" + ); + } + + #[tokio::test] + async fn reuse_driver_forwards_transfer_identity_and_sql_file_hooks() { + let inner: Arc = Arc::new(SqlServerDriver::new()); + let driver = ReuseDriver::new(inner, "sqlserver-alias"); + assert!(driver.transfer_explicit_identity_insert_requires_session_toggle()); + let script = driver + .render_transfer_sql_file_identity_insert( + "INSERT INTO [dbo].[items] ([id]) VALUES (1)", + "[dbo].[items]", + ) + .unwrap(); + assert!(script.contains("SET IDENTITY_INSERT [dbo].[items] ON;")); + assert_eq!( + driver.transfer_sql_file_begin_transaction(), + "BEGIN TRANSACTION;" + ); + assert_eq!( + driver.transfer_sql_file_commit_transaction(), + "COMMIT TRANSACTION;" + ); + + let handle = ConnectionHandle { + id: "missing".into(), + pool_id: "missing".into(), + }; + let error = driver + .set_transfer_identity_insert(&handle, "db", Some("dbo"), "items", true) + .await + .unwrap_err(); + assert!(matches!(error, DriverError::ConnectionFailed(_))); + driver.discard_transfer_connection(&handle).await.unwrap(); + } } diff --git a/packages/drivers/sqlserver/src/sync_adapter.rs b/packages/drivers/sqlserver/src/sync_adapter.rs index 3826ad7e7..92f1651d2 100644 --- a/packages/drivers/sqlserver/src/sync_adapter.rs +++ b/packages/drivers/sqlserver/src/sync_adapter.rs @@ -92,9 +92,61 @@ fn base_type(raw: &str) -> String { lower } +fn native_type_name(raw: &str) -> &str { + raw.trim() + .split(|character: char| character == '(' || character.is_whitespace()) + .next() + .unwrap_or_default() +} + +fn is_safe_native_only_type(raw: &str) -> bool { + let normalized = raw.trim().to_ascii_lowercase(); + match native_type_name(&normalized) { + "tinyint" | "xml" => normalized == native_type_name(&normalized), + "binary" => { + parse_length(&normalized, "binary").is_some_and(|length| (1..=8_000).contains(&length)) + } + _ => false, + } +} + +fn normalize_native_type(raw: &str) -> String { + raw.split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + // ── SyncSourceAdapter ────────────────────────────────────────────── impl SyncSourceAdapter for SqlServerSyncAdapter { + fn validate_transfer_source_column(&self, column: &ColumnSchema) -> Result<(), String> { + let native = native_type_name(&column.data_type).to_ascii_lowercase(); + if matches!(native.as_str(), "timestamp" | "rowversion") { + return Err(format!( + "SQL Server {native} is a generated row version, not a timestamp value; materialize it into a regular binary column before transfer" + )); + } + Ok(()) + } + + fn transfer_source_type_is_native_only( + &self, + _column: &ColumnSchema, + source_ir: &IRColumn, + ) -> bool { + matches!(&source_ir.ir_type, IRType::Other(_)) + } + + fn transfer_source_requires_collation_preservation(&self, column: &ColumnSchema) -> bool { + matches!( + native_type_name(&column.data_type) + .to_ascii_lowercase() + .as_str(), + "char" | "nchar" | "varchar" | "nvarchar" | "text" | "ntext" + ) + } + /// Rebuild declared type dimensions that INFORMATION_SCHEMA omits, so a /// transfer preserves bounded strings and decimal precision/scale. fn full_column_types_query(&self, table: &str) -> Option { @@ -110,6 +162,8 @@ impl SyncSourceAdapter for SqlServerSyncAdapter { ELSE CAST(c.max_length AS varchar(10)) END + ')' \ WHEN t.name IN ('decimal','numeric') THEN \ '(' + CAST(c.precision AS varchar(10)) + ',' + CAST(c.scale AS varchar(10)) + ')' \ + WHEN t.name = 'float' THEN \ + '(' + CAST(c.precision AS varchar(10)) + ')' \ WHEN t.name IN ('datetime2','datetimeoffset','time') THEN \ '(' + CAST(c.scale AS varchar(10)) + ')' \ ELSE '' END AS full_type \ @@ -176,73 +230,82 @@ impl SyncSourceAdapter for SqlServerSyncAdapter { fn column_to_ir(&self, column: &ColumnSchema, native_full_type: Option<&str>) -> IRColumn { let raw = native_full_type.unwrap_or(&column.data_type); let lower = base_type(raw); + let native = native_type_name(&lower); - let ir_type = if lower.starts_with("nvarchar") { + let ir_type = if native == "nvarchar" { let len = parse_length(&lower, "nvarchar"); IRType::Varchar { length: len } - } else if lower.starts_with("varchar") { + } else if native == "varchar" { let len = parse_length(&lower, "varchar"); IRType::Varchar { length: len } - } else if lower.starts_with("nchar") { + } else if native == "nchar" { let len = parse_length(&lower, "nchar").unwrap_or(1); IRType::Char { length: len } - } else if lower.starts_with("char(") || lower == "char" { + } else if native == "char" { let len = parse_length(&lower, "char").unwrap_or(1); IRType::Char { length: len } - } else if lower.starts_with("decimal") { + } else if native == "decimal" { let (p, s) = parse_precision(&lower, "decimal"); IRType::Decimal { precision: p, scale: s, } - } else if lower.starts_with("numeric") { + } else if native == "numeric" { let (p, s) = parse_precision(&lower, "numeric"); IRType::Decimal { precision: p, scale: s, } - } else if lower.starts_with("varbinary") { + } else if native == "money" { + IRType::Decimal { + precision: 19, + scale: 4, + } + } else if native == "smallmoney" { + IRType::Decimal { + precision: 10, + scale: 4, + } + } else if native == "varbinary" { let len = parse_length(&lower, "varbinary"); IRType::Binary { length: len } - } else if lower.starts_with("binary") { - let len = parse_length(&lower, "binary"); - IRType::Binary { length: len } - } else if lower == "bit" || lower.starts_with("bit(") { + } else if native == "binary" || native == "xml" || native == "tinyint" { + IRType::Other(lower.clone()) + } else if native == "bit" { IRType::Bool - } else if lower == "tinyint" { - IRType::Int8 - } else if lower == "smallint" { + } else if native == "smallint" { IRType::Int16 - } else if lower == "int" || lower == "integer" { + } else if native == "int" || native == "integer" { IRType::Int32 - } else if lower == "bigint" { + } else if native == "bigint" { IRType::Int64 - } else if lower == "real" { + } else if native == "real" { IRType::Float32 - } else if lower == "float" || lower.starts_with("float(") { - IRType::Float64 - } else if lower == "text" || lower == "ntext" { + } else if native == "float" { + match parse_length(&lower, "float") { + Some(precision) if precision <= 24 => IRType::Float32, + _ => IRType::Float64, + } + } else if native == "text" || native == "ntext" { IRType::Text - } else if lower == "image" { + } else if native == "image" { IRType::Blob - } else if lower == "date" { + } else if native == "date" { IRType::Date - } else if lower == "time" || lower.starts_with("time(") { + } else if native == "time" { IRType::Time { with_timezone: false, } - } else if lower == "datetime" - || lower == "datetime2" - || lower.starts_with("datetime2(") - || lower == "smalldatetime" - { + } else if native == "datetimeoffset" { + IRType::Timestamp { + with_timezone: true, + } + } else if matches!(native, "datetime" | "datetime2" | "smalldatetime") { IRType::Timestamp { with_timezone: false, } - } else if lower == "uniqueidentifier" { + } else if native == "uniqueidentifier" { IRType::Uuid - } else if lower == "xml" { - IRType::Text } else { IRType::Other(raw.to_string()) }; @@ -283,10 +346,16 @@ impl SyncTargetAdapter for SqlServerSyncAdapter { IRType::Binary { length: None } | IRType::Blob => "VARBINARY(MAX)".into(), IRType::Date => "DATE".into(), IRType::Time { .. } => "TIME".into(), - IRType::Timestamp { .. } => "DATETIME2".into(), + IRType::Timestamp { + with_timezone: true, + } => "DATETIMEOFFSET".into(), + IRType::Timestamp { + with_timezone: false, + } => "DATETIME2".into(), IRType::Json => "NVARCHAR(MAX)".into(), IRType::Uuid => "UNIQUEIDENTIFIER".into(), IRType::Bit { .. } => "BIT".into(), + IRType::Other(native) if is_safe_native_only_type(native) => native.clone(), IRType::Other(_) => "NVARCHAR(MAX)".into(), } } @@ -349,6 +418,52 @@ impl SyncTargetAdapter for SqlServerSyncAdapter { fn auto_increment_keyword(&self) -> Option<&str> { Some("IDENTITY(1,1)") } + + fn supports_explicit_identity_values(&self) -> bool { + true + } + + fn validate_transfer_column_type( + &self, + source_column: &ColumnSchema, + source_ir: &IRColumn, + _source_text_limit_bytes: Option, + source_requires_collation_preservation: bool, + source_type_is_native_only: bool, + target_native_type: Option<&str>, + _creating_target: bool, + ) -> Result<(), String> { + if source_requires_collation_preservation { + return Err(format!( + "source column '{}' uses SQL Server collation semantics that cannot be proven equivalent; choose the target default collation explicitly or review a matching target collation", + source_column.name + )); + } + if matches!(&source_ir.ir_type, IRType::Decimal { precision: 0, .. }) { + return Err( + "unbounded decimal source values cannot be proven to fit SQL Server's maximum precision 38; select a bounded reviewed target type".into(), + ); + } + if source_type_is_native_only { + let IRType::Other(source_native) = &source_ir.ir_type else { + return Err("native-only source type marker is inconsistent".into()); + }; + if !is_safe_native_only_type(source_native) { + return Err(format!( + "SQL Server cannot safely recreate native-only source type '{source_native}'" + )); + } + let target_native = target_native_type.ok_or_else(|| { + "target native type is unavailable for native-only source data".to_string() + })?; + if normalize_native_type(source_native) != normalize_native_type(target_native) { + return Err(format!( + "native-only source type '{source_native}' requires the same target type; found '{target_native}'" + )); + } + } + Ok(()) + } } #[cfg(test)] @@ -375,6 +490,7 @@ mod tests { assert!(sql.contains("sys.columns")); assert!(sql.contains("c.max_length / 2")); assert!(sql.contains("c.precision")); + assert!(sql.contains("WHEN t.name = 'float'")); let quoted = SqlServerSyncAdapter .full_column_types_query("dbo.o'brien") @@ -393,7 +509,7 @@ mod tests { let a = SqlServerSyncAdapter; assert_eq!( a.column_to_ir(&col("a", "tinyint"), None).ir_type, - IRType::Int8 + IRType::Other("tinyint".into()) ); assert_eq!( a.column_to_ir(&col("a", "smallint"), None).ir_type, @@ -440,7 +556,161 @@ mod tests { a.column_to_ir(&col("t", "uniqueidentifier"), None).ir_type, IRType::Uuid ); - assert_eq!(a.column_to_ir(&col("x", "xml"), None).ir_type, IRType::Text); + assert_eq!( + a.column_to_ir(&col("x", "xml"), None).ir_type, + IRType::Other("xml".into()) + ); + } + + #[test] + fn sqlserver_transfer_preserves_exact_and_native_only_types() { + let adapter = SqlServerSyncAdapter; + assert!(adapter.supports_explicit_identity_values()); + assert_eq!( + adapter.column_to_ir(&col("amount", "money"), None).ir_type, + IRType::Decimal { + precision: 19, + scale: 4 + } + ); + assert_eq!( + adapter + .column_to_ir(&col("small_amount", "smallmoney"), None) + .ir_type, + IRType::Decimal { + precision: 10, + scale: 4 + } + ); + assert_eq!( + adapter + .column_to_ir(&col("at", "datetimeoffset(7)"), None) + .ir_type, + IRType::Timestamp { + with_timezone: true + } + ); + assert_eq!( + adapter.ir_type_to_native(&IRType::Timestamp { + with_timezone: true + }), + "DATETIMEOFFSET" + ); + assert_eq!( + adapter + .column_to_ir(&col("raw", "binary(16)"), None) + .ir_type, + IRType::Other("binary(16)".into()) + ); + assert_eq!( + adapter.ir_type_to_native(&IRType::Other("binary(16)".into())), + "binary(16)" + ); + assert_eq!( + adapter.column_to_ir(&col("f", "float(24)"), None).ir_type, + IRType::Float32 + ); + assert_eq!( + adapter.column_to_ir(&col("f", "float(53)"), None).ir_type, + IRType::Float64 + ); + } + + #[test] + fn sqlserver_transfer_rejects_generated_rowversion_and_unsafe_native_types() { + let adapter = SqlServerSyncAdapter; + assert!(adapter + .validate_transfer_source_column(&col("version", "rowversion")) + .is_err()); + assert!(adapter + .validate_transfer_source_column(&col("version", "timestamp")) + .is_err()); + assert!(!is_safe_native_only_type("custom_type")); + assert!(!is_safe_native_only_type("binary(MAX)")); + assert!(is_safe_native_only_type("binary(8000)")); + } + + #[test] + fn sqlserver_transfer_native_only_mapping_requires_exact_target_type() { + let adapter = SqlServerSyncAdapter; + let source_column = col("raw", "tinyint"); + let source_ir = adapter.column_to_ir(&source_column, None); + assert!(adapter + .validate_transfer_column_type( + &source_column, + &source_ir, + None, + false, + true, + Some("TINYINT"), + false, + ) + .is_ok()); + assert!(adapter + .validate_transfer_column_type( + &source_column, + &source_ir, + None, + false, + true, + Some("SMALLINT"), + false, + ) + .is_err()); + + let unbounded_decimal = IRColumn { + name: "amount".into(), + ir_type: IRType::Decimal { + precision: 0, + scale: 0, + }, + nullable: true, + default_expr: None, + is_primary_key: false, + is_auto_increment: false, + comment: None, + }; + assert!(adapter + .validate_transfer_column_type( + &col("amount", "numeric"), + &unbounded_decimal, + None, + false, + false, + Some("DECIMAL(38,18)"), + true, + ) + .is_err()); + } + + #[test] + fn sqlserver_transfer_requires_explicit_collation_decision() { + let adapter = SqlServerSyncAdapter; + let source_column = col("name", "nvarchar(20)"); + assert!(adapter.transfer_source_requires_collation_preservation(&source_column)); + let source_ir = adapter.column_to_ir(&source_column, None); + assert!(adapter + .validate_transfer_column_type( + &source_column, + &source_ir, + None, + true, + false, + Some("NVARCHAR(20)"), + true, + ) + .is_err()); + assert!(adapter + .validate_transfer_column_type( + &source_column, + &source_ir, + None, + false, + false, + Some("NVARCHAR(20)"), + true, + ) + .is_ok()); } #[test] diff --git a/src-tauri/src/data_transfer/execute/dispatcher.rs b/src-tauri/src/data_transfer/execute/dispatcher.rs index 906996eb6..d58118c15 100644 --- a/src-tauri/src/data_transfer/execute/dispatcher.rs +++ b/src-tauri/src/data_transfer/execute/dispatcher.rs @@ -48,7 +48,6 @@ pub(crate) async fn execute_transfer_data_with_resume_checkpoint( let tgt_quote = tgt_driver.quote_char(); let src_family = src_driver.driver_type(); let tgt_family = tgt_driver.driver_type(); - let batch = job.options.batch_size as usize; let mut tables_out = Vec::new(); let mut total_rows = 0u64; let mut partial = false; @@ -100,6 +99,27 @@ pub(crate) async fn execute_transfer_data_with_resume_checkpoint( continue; } + let max_parameters = tgt_driver.max_bound_parameters(); + let max_batch_rows = max_parameters / columns.len(); + if max_batch_rows == 0 { + tables_out.push(TableExecutionResult::database( + &table.source_table, + &table.target_table, + Some(0), + outcome_before_data_transaction(known_preamble_applied), + Some(format!( + "target mapping has {} columns, exceeding the driver's {max_parameters}-parameter statement limit", + columns.len() + )), + )); + partial = true; + if job.options.stop_on_error { + break; + } + continue; + } + let batch = (job.options.batch_size as usize).min(max_batch_rows); + let Some(src_schema) = source_schemas.get(&table.source_table) else { tables_out.push(TableExecutionResult::database( &table.source_table, @@ -478,6 +498,18 @@ pub(crate) async fn execute_transfer_data_with_resume_checkpoint( continue; } }; + let explicit_identity_columns = columns + .iter() + .filter(|mapping| { + target_schema + .columns + .iter() + .any(|column| column.name == mapping.target_column && column.is_auto_increment) + }) + .map(|mapping| mapping.target_column.clone()) + .collect::>(); + let identity_insert_requires_toggle = !explicit_identity_columns.is_empty() + && tgt_driver.transfer_explicit_identity_insert_requires_session_toggle(); let tx = match tgt_driver.begin_transaction(tgt_handle).await { Ok(tx) => tx, Err(error) => { @@ -499,7 +531,31 @@ pub(crate) async fn execute_transfer_data_with_resume_checkpoint( let mut table_error: Option = None; let mut was_cancelled = false; let mut successful_data_batches = false; + let mut identity_insert_may_be_enabled = false; + if identity_insert_requires_toggle { + // Treat ON as potentially applied even when the call fails: the + // server may have changed session state before the client saw an + // error, so every attempted ON must be followed by an OFF attempt. + identity_insert_may_be_enabled = true; + if let Err(error) = tgt_driver + .set_transfer_identity_insert( + tgt_handle, + &target.database, + target.schema.as_deref(), + &table.target_table, + true, + ) + .await + { + table_error = Some(format!( + "cannot enable explicit identity insertion: {error}" + )); + } + } 'batches: loop { + if table_error.is_some() { + break; + } if cancelled.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) { was_cancelled = true; table_error = @@ -570,20 +626,33 @@ pub(crate) async fn execute_transfer_data_with_resume_checkpoint( was_cancelled = true; table_error = Some("transfer cancelled; current data transaction rolled back".into()); } + let mut identity_insert_cleanup_failed = false; + if identity_insert_may_be_enabled { + if let Err(error) = tgt_driver + .set_transfer_identity_insert( + tgt_handle, + &target.database, + target.schema.as_deref(), + &table.target_table, + false, + ) + .await + { + identity_insert_cleanup_failed = true; + let prior_error = table_error + .take() + .map(|message| format!("{message}; ")) + .unwrap_or_default(); + table_error = Some(format!( + "{prior_error}cannot disable explicit identity insertion: {error}; target connection will be discarded after rollback" + )); + } + } // `affected` can be zero for a successful INSERT ... ON CONFLICT // DO NOTHING / ignore batch that still carried explicit identity // values. Reseed based on successful batch execution, not row-count // reporting, so the target sequence remains beyond imported IDs. if table_error.is_none() && successful_data_batches { - let explicit_identity_columns = columns - .iter() - .filter(|mapping| { - target_schema.columns.iter().any(|column| { - column.name == mapping.target_column && column.is_auto_increment - }) - }) - .map(|mapping| mapping.target_column.clone()) - .collect::>(); if !explicit_identity_columns.is_empty() { if let Err(error) = tgt_driver .advance_transfer_identity_sequences( @@ -607,7 +676,7 @@ pub(crate) async fn execute_transfer_data_with_resume_checkpoint( let outcome = if table_error.is_some() { table_rows = 0; partial = true; - match tgt_driver.rollback(tx).await { + let rollback_outcome = match tgt_driver.rollback(tx).await { Ok(()) => { if known_preamble_applied { TableExecutionOutcome::PartiallyApplied @@ -622,6 +691,19 @@ pub(crate) async fn execute_transfer_data_with_resume_checkpoint( )); TableExecutionOutcome::Unknown } + }; + if identity_insert_cleanup_failed { + if let Err(error) = tgt_driver.discard_transfer_connection(tgt_handle).await { + table_error = Some(format!( + "{}; failed to discard target connection after IDENTITY_INSERT OFF failed: {error}; outcome UNKNOWN", + table_error.as_deref().unwrap_or("identity cleanup failed") + )); + TableExecutionOutcome::Unknown + } else { + rollback_outcome + } + } else { + rollback_outcome } } else if let Err(error) = tgt_driver.commit(tx).await { table_error = Some(format!("commit failed, outcome UNKNOWN: {error}")); diff --git a/src-tauri/src/data_transfer/execution_tests.rs b/src-tauri/src/data_transfer/execution_tests.rs index 926bba1cd..34d331bf0 100644 --- a/src-tauri/src/data_transfer/execution_tests.rs +++ b/src-tauri/src/data_transfer/execution_tests.rs @@ -24,6 +24,8 @@ struct State { metadata_refs: Vec, source_queries: Vec<(String, Vec)>, identity_sync_calls: Vec<(Option, String, Vec)>, + identity_insert_calls: Vec<(String, Option, String, bool)>, + discard_transfer_connection_calls: usize, transfer_order: Vec<&'static str>, write_sqls: Vec, } @@ -40,6 +42,10 @@ struct Driver { schema_error_on_call: Option, execute_error_on_call: Option, identity_sync_error: bool, + session_identity_insert: bool, + identity_insert_on_error: bool, + identity_insert_off_error: bool, + discard_transfer_connection_error: bool, affected_override: Option, include_identity_insert_clause: bool, stream_mode: u8, @@ -235,6 +241,54 @@ impl DatabaseDriver for Driver { } Ok(()) } + fn transfer_explicit_identity_insert_requires_session_toggle(&self) -> bool { + self.session_identity_insert + } + async fn set_transfer_identity_insert( + &self, + _: &ConnectionHandle, + database: &str, + schema: Option<&str>, + table: &str, + enabled: bool, + ) -> Result<(), DriverError> { + let mut state = self.state.lock().unwrap(); + state.identity_insert_calls.push(( + database.to_string(), + schema.map(str::to_string), + table.to_string(), + enabled, + )); + state.transfer_order.push(if enabled { + "identity_on" + } else { + "identity_off" + }); + let fail = if enabled { + self.identity_insert_on_error + } else { + self.identity_insert_off_error + }; + if fail { + return Err(DriverError::QueryFailed(if enabled { + "injected IDENTITY_INSERT ON failure".into() + } else { + "injected IDENTITY_INSERT OFF failure".into() + })); + } + Ok(()) + } + async fn discard_transfer_connection(&self, _: &ConnectionHandle) -> Result<(), DriverError> { + let mut state = self.state.lock().unwrap(); + state.discard_transfer_connection_calls += 1; + state.transfer_order.push("discard"); + if self.discard_transfer_connection_error { + return Err(DriverError::ConnectionFailed( + "injected transfer connection discard failure".into(), + )); + } + Ok(()) + } fn transfer_explicit_identity_insert_clause(&self) -> Option<&'static str> { self.include_identity_insert_clause .then_some("OVERRIDING SYSTEM VALUE") @@ -288,6 +342,7 @@ impl DatabaseDriver for Driver { async fn rollback(&self, _: TransactionHandle) -> Result<(), DriverError> { let mut state = self.state.lock().unwrap(); state.rollback += 1; + state.transfer_order.push("rollback"); if self.rollback_fails { return Err(DriverError::TransactionError( "injected rollback failure".into(), @@ -339,6 +394,10 @@ fn driver(rows: Rows, schema: TableSchema) -> Driver { schema_error_on_call: None, execute_error_on_call: None, identity_sync_error: false, + session_identity_insert: false, + identity_insert_on_error: false, + identity_insert_off_error: false, + discard_transfer_connection_error: false, affected_override: None, include_identity_insert_clause: false, stream_mode: 0, @@ -564,6 +623,199 @@ async fn explicit_identity_import_synchronizes_when_driver_reports_zero_affected assert_eq!(state.committed.len(), 1); } +#[tokio::test] +async fn session_identity_insert_is_enabled_and_disabled_inside_the_transaction() { + let source = driver(vec![vec![Some(Value::Integer(41))]], schema(&["id"])); + let mut target_schema = schema(&["id"]); + target_schema.columns[0].is_auto_increment = true; + let mut target = driver(vec![], target_schema); + target.session_identity_insert = true; + let result = run( + &source, + &target, + &[inspected("source_table", vec![mapping("id", "id")])], + None, + ) + .await; + + assert!(!result.partial); + assert_eq!( + result.tables[0].outcome, + Some(TableExecutionOutcome::Committed) + ); + let state = target.state.lock().unwrap(); + assert_eq!( + state.identity_insert_calls, + vec![ + ("t".into(), None, "source_table".into(), true), + ("t".into(), None, "source_table".into(), false), + ] + ); + assert_eq!( + state.transfer_order, + vec!["identity_on", "write", "identity_off", "sync", "commit"] + ); +} + +#[tokio::test] +async fn session_identity_insert_is_not_toggled_without_a_mapped_target_identity() { + let source = driver(vec![vec![Some(Value::Integer(41))]], schema(&["id"])); + let mut target = driver(vec![], schema(&["id"])); + target.session_identity_insert = true; + let result = run( + &source, + &target, + &[inspected("source_table", vec![mapping("id", "id")])], + None, + ) + .await; + + assert!(!result.partial); + assert!(target + .state + .lock() + .unwrap() + .identity_insert_calls + .is_empty()); +} + +#[tokio::test] +async fn failed_identity_insert_on_still_attempts_off_then_rolls_back() { + let source = driver(vec![vec![Some(Value::Integer(41))]], schema(&["id"])); + let mut target_schema = schema(&["id"]); + target_schema.columns[0].is_auto_increment = true; + let mut target = driver(vec![], target_schema); + target.session_identity_insert = true; + target.identity_insert_on_error = true; + let result = run( + &source, + &target, + &[inspected("source_table", vec![mapping("id", "id")])], + None, + ) + .await; + + assert!(result.partial); + assert_eq!( + result.tables[0].outcome, + Some(TableExecutionOutcome::RolledBack) + ); + let state = target.state.lock().unwrap(); + assert_eq!(state.calls, 0); + assert_eq!(state.rollback, 1); + assert_eq!(state.discard_transfer_connection_calls, 0); + assert_eq!( + state.transfer_order, + vec!["identity_on", "identity_off", "rollback"] + ); +} + +#[tokio::test] +async fn failed_identity_insert_off_rolls_back_before_discarding_the_connection() { + let source = driver(vec![vec![Some(Value::Integer(41))]], schema(&["id"])); + let mut target_schema = schema(&["id"]); + target_schema.columns[0].is_auto_increment = true; + let mut target = driver(vec![], target_schema); + target.session_identity_insert = true; + target.identity_insert_off_error = true; + let result = run( + &source, + &target, + &[inspected("source_table", vec![mapping("id", "id")])], + None, + ) + .await; + + assert!(result.partial); + assert_eq!( + result.tables[0].outcome, + Some(TableExecutionOutcome::RolledBack) + ); + let state = target.state.lock().unwrap(); + assert_eq!(state.rollback, 1); + assert_eq!(state.discard_transfer_connection_calls, 1); + assert_eq!(state.committed.len(), 0); + assert_eq!( + state.transfer_order, + vec![ + "identity_on", + "write", + "identity_off", + "rollback", + "discard" + ] + ); +} + +#[tokio::test] +async fn failed_identity_insert_connection_discard_marks_outcome_unknown() { + let source = driver(vec![vec![Some(Value::Integer(41))]], schema(&["id"])); + let mut target_schema = schema(&["id"]); + target_schema.columns[0].is_auto_increment = true; + let mut target = driver(vec![], target_schema); + target.session_identity_insert = true; + target.identity_insert_off_error = true; + target.discard_transfer_connection_error = true; + let result = run( + &source, + &target, + &[inspected("source_table", vec![mapping("id", "id")])], + None, + ) + .await; + + assert!(result.partial); + assert_eq!( + result.tables[0].outcome, + Some(TableExecutionOutcome::Unknown) + ); + assert!(result.tables[0] + .error + .as_deref() + .is_some_and(|error| error.contains("failed to discard target connection"))); + let state = target.state.lock().unwrap(); + assert_eq!(state.rollback, 1); + assert_eq!(state.discard_transfer_connection_calls, 1); + assert_eq!(state.transfer_order.last(), Some(&"discard")); +} + +#[tokio::test] +async fn failed_identity_insert_off_still_discards_after_rollback_failure() { + let source = driver(vec![vec![Some(Value::Integer(41))]], schema(&["id"])); + let mut target_schema = schema(&["id"]); + target_schema.columns[0].is_auto_increment = true; + let mut target = driver(vec![], target_schema); + target.session_identity_insert = true; + target.identity_insert_off_error = true; + target.rollback_fails = true; + let result = run( + &source, + &target, + &[inspected("source_table", vec![mapping("id", "id")])], + None, + ) + .await; + + assert!(result.partial); + assert_eq!( + result.tables[0].outcome, + Some(TableExecutionOutcome::Unknown) + ); + let state = target.state.lock().unwrap(); + assert_eq!(state.rollback, 1); + assert_eq!(state.discard_transfer_connection_calls, 1); + assert_eq!( + state.transfer_order, + vec![ + "identity_on", + "write", + "identity_off", + "rollback", + "discard" + ] + ); +} + #[tokio::test] async fn identity_override_clause_requires_a_mapped_target_identity_column() { let source = driver(vec![vec![Some(Value::Integer(41))]], schema(&["id"])); @@ -1477,6 +1729,7 @@ async fn cancel_after_write_reports_current_table_and_rolls_back() { ); let mut target = driver(vec![], schema(&["id"])); target.schema.columns[0].is_auto_increment = true; + target.session_identity_insert = true; target.cancel = Some(Arc::clone(&flag)); let result = run( &source, @@ -1498,6 +1751,14 @@ async fn cancel_after_write_reports_current_table_and_rolls_back() { assert_eq!(state.rollback, 1); assert!(state.committed.is_empty()); assert!(state.identity_sync_calls.is_empty()); + assert_eq!( + state + .identity_insert_calls + .iter() + .map(|call| call.3) + .collect::>(), + vec![true, false] + ); } #[test] diff --git a/src-tauri/src/data_transfer/resume.rs b/src-tauri/src/data_transfer/resume.rs index bec79d96a..23d1129da 100644 --- a/src-tauri/src/data_transfer/resume.rs +++ b/src-tauri/src/data_transfer/resume.rs @@ -236,7 +236,11 @@ pub(crate) async fn execute_chunked_table( } let projection = source_projection(context.columns, &keys); - let chunk_size = effective_chunk_size(context.job.options.batch_size, context.columns.len())?; + let chunk_size = effective_chunk_size( + context.job.options.batch_size, + context.columns.len(), + context.target_driver.max_bound_parameters(), + )?; let cursor_indexes = keys .iter() .map(|key| { diff --git a/src-tauri/src/data_transfer/resume/fingerprint.rs b/src-tauri/src/data_transfer/resume/fingerprint.rs index ed91d1542..32510ce5a 100644 --- a/src-tauri/src/data_transfer/resume/fingerprint.rs +++ b/src-tauri/src/data_transfer/resume/fingerprint.rs @@ -161,16 +161,17 @@ pub(super) fn remaining_page_limit( pub(super) fn effective_chunk_size( user_batch_size: u32, target_columns: usize, + max_bound_parameters: usize, ) -> Result { if target_columns == 0 { return Err(TransferError::validation( "in-table resume requires at least one target column", )); } - let max_rows_by_parameters = MAX_BOUND_QUERY_PARAMETERS / target_columns; + let max_rows_by_parameters = max_bound_parameters / target_columns; if max_rows_by_parameters == 0 { return Err(TransferError::unsupported( - "target mapping exceeds the safe bound-parameter limit for a single row", + "target mapping exceeds the driver's bound-parameter limit for a single row", )); } Ok(user_batch_size.min(max_rows_by_parameters as u32)) diff --git a/src-tauri/src/data_transfer/resume/tests.rs b/src-tauri/src/data_transfer/resume/tests.rs index 61d67673b..90e5fc4b3 100644 --- a/src-tauri/src/data_transfer/resume/tests.rs +++ b/src-tauri/src/data_transfer/resume/tests.rs @@ -248,8 +248,16 @@ fn batch_size_hard_limit_is_user_visible_api_validation() { }; job.options.batch_size = MAX_TRANSFER_BATCH_SIZE + 1; assert!(job.options.validate().is_err()); - assert_eq!(effective_chunk_size(500, 200).unwrap(), 300); - assert_eq!(effective_chunk_size(500, 1_000).unwrap(), 60); + assert_eq!( + effective_chunk_size(500, 200, MAX_BOUND_QUERY_PARAMETERS).unwrap(), + 300 + ); + assert_eq!( + effective_chunk_size(500, 1_000, MAX_BOUND_QUERY_PARAMETERS).unwrap(), + 60 + ); + assert_eq!(effective_chunk_size(500, 200, 2_100).unwrap(), 10); + assert!(effective_chunk_size(500, 2_101, 2_100).is_err()); assert!(remaining_page_limit(500, 5, Some(6)).is_some_and(|limit| limit == 1)); assert!(remaining_page_limit(500, 6, Some(6)).is_none()); } diff --git a/src-tauri/src/data_transfer/sql_file.rs b/src-tauri/src/data_transfer/sql_file.rs index aad7cdd57..be01865d2 100644 --- a/src-tauri/src/data_transfer/sql_file.rs +++ b/src-tauri/src/data_transfer/sql_file.rs @@ -472,6 +472,18 @@ pub(crate) fn target_table_ref( qualify_target_relation(driver, database, schema, table) } +fn mappings_include_source_identity( + mappings: &[&ColumnMapping], + source_schema: &TableSchema, +) -> bool { + mappings.iter().any(|mapping| { + source_schema + .columns + .iter() + .any(|column| column.name == mapping.source_column && column.is_auto_increment) + }) +} + /// A custom DDL string is an opaque dialect-specific escape hatch. It cannot /// be safely re-rendered when the SQL-file target dialect differs from the /// source, so reject it before preview and execution instead of emitting a @@ -929,7 +941,7 @@ pub async fn execute_with_target( .unwrap_or_default(); let mut output = AtomicSqlFile::create_with_format(destination, encoding, compression)?; output.line("-- DataZen Data Transfer SQL export")?; - output.line("BEGIN;")?; + output.line(target_driver.transfer_sql_file_begin_transaction())?; let mut results = Vec::new(); let mut total = 0u64; let mut partial = false; @@ -1098,6 +1110,8 @@ pub async fn execute_with_target( ), None => None, }; + let carries_source_identity = mappings_include_source_identity(&mappings, schema); + let target_relation = target_table_ref(target_driver, job, &table.target_table); let mut scan = super::scan::scan_rows_with_params( source_driver, handle, @@ -1146,6 +1160,15 @@ pub async fn execute_with_target( } None => insert_sql_batch(target_driver, job, table, &mappings, row_chunk), }; + let rendered = rendered.and_then(|sql| { + if carries_source_identity { + target_driver + .render_transfer_sql_file_identity_insert(&sql, &target_relation) + .map_err(|error| TransferError::unsupported(error.to_string())) + } else { + Ok(sql) + } + }); match rendered { Ok(sql) => { output.line(&format!("{sql};"))?; @@ -1219,7 +1242,7 @@ pub async fn execute_with_target( .as_ref() .is_some_and(|flag| flag.load(Ordering::SeqCst)) { - output.line("COMMIT;")?; + output.line(target_driver.transfer_sql_file_commit_transaction())?; output.finish()?; Ok(TransferExecutionResult { tables: results, @@ -1282,6 +1305,48 @@ mod tests { assert!(serde_json::from_str::(r#""brotli""#).is_err()); } + #[test] + fn sql_file_identity_wrapper_is_requested_only_for_mapped_source_identity_columns() { + let schema = TableSchema { + table_name: "source".into(), + columns: vec![ColumnSchema { + name: "generated_id".into(), + data_type: "BIGINT".into(), + nullable: false, + default_value: None, + comment: None, + is_primary_key: true, + is_auto_increment: true, + }], + primary_keys: vec!["generated_id".into()], + indexes: vec![], + foreign_keys: vec![], + check_constraints: vec![], + table_options: Default::default(), + }; + let identity_mapping = ColumnMapping { + source_column: "generated_id".into(), + target_column: "id".into(), + skip: false, + target_native_type: None, + }; + let other_mapping = ColumnMapping { + source_column: "regular".into(), + target_column: "id".into(), + skip: false, + target_native_type: None, + }; + assert!(mappings_include_source_identity( + &[&identity_mapping], + &schema + )); + assert!(!mappings_include_source_identity( + &[&other_mapping], + &schema + )); + assert!(!mappings_include_source_identity(&[], &schema)); + } + #[tokio::test] async fn postgres_sql_file_emits_identity_sequence_sync_after_data_inserts() { let source_schema = TableSchema { diff --git a/src-tauri/src/data_transfer/writer.rs b/src-tauri/src/data_transfer/writer.rs index 91c2ba03e..43e4eabff 100644 --- a/src-tauri/src/data_transfer/writer.rs +++ b/src-tauri/src/data_transfer/writer.rs @@ -41,6 +41,15 @@ pub fn bound_insert_batch( "INSERT batch does not contain an active projection", )); } + let parameter_count = rows.len().checked_mul(columns.len()).ok_or_else(|| { + TransferError::validation("INSERT batch parameter count exceeds the supported range") + })?; + if parameter_count > driver.max_bound_parameters() { + return Err(TransferError::validation(format!( + "INSERT batch requires {parameter_count} bound parameters, exceeding the driver's {}-parameter limit", + driver.max_bound_parameters() + ))); + } let mut names = std::collections::HashSet::new(); let mut target_types = Vec::with_capacity(columns.len()); for binding in columns { From c532e4bc4697320e80c28cf43851400181782939 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 01:12:35 +0800 Subject: [PATCH 010/666] fix(sqlserver): prove database and schema scope identity --- packages/driver-api/src/reuse.rs | 64 +++++++ packages/driver-api/src/traits.rs | 13 ++ packages/drivers/sqlserver/src/metadata.rs | 12 ++ .../drivers/sqlserver/src/migration/tests.rs | 15 ++ packages/drivers/sqlserver/src/sqlserver.rs | 176 ++++++++++++++++++ src-tauri/src/commands/schema_diff.rs | 56 ++++++ src-tauri/src/schema_diff/reviewed.rs | 13 +- src-tauri/src/schema_diff/reviewed_tests.rs | 126 ++++++++++++- 8 files changed, 471 insertions(+), 4 deletions(-) diff --git a/packages/driver-api/src/reuse.rs b/packages/driver-api/src/reuse.rs index 731083cea..9c13ad660 100644 --- a/packages/driver-api/src/reuse.rs +++ b/packages/driver-api/src/reuse.rs @@ -443,6 +443,27 @@ impl DatabaseDriver for ReuseDriver { }) } + async fn physical_database_identity( + &self, + handle: &ConnectionHandle, + database: &str, + ) -> Result, DriverError> { + self.inner + .physical_database_identity(handle, database) + .await + } + + async fn schema_scope_identity( + &self, + handle: &ConnectionHandle, + database: &str, + schema: &str, + ) -> Result, DriverError> { + self.inner + .schema_scope_identity(handle, database, schema) + .await + } + fn prompt_overrides(&self) -> HashMap { self.inner.prompt_overrides() } @@ -661,6 +682,23 @@ mod tests { Ok(self.full_fk_catalog_visibility) } + async fn physical_database_identity( + &self, + _handle: &ConnectionHandle, + database: &str, + ) -> Result, DriverError> { + Ok(Some(format!("physical:{database}"))) + } + + async fn schema_scope_identity( + &self, + _handle: &ConnectionHandle, + database: &str, + schema: &str, + ) -> Result, DriverError> { + Ok(Some(format!("schema:{database}:{schema}"))) + } + async fn connect( &self, _config: &ConnectionConfig, @@ -1027,6 +1065,32 @@ mod tests { ); } + #[tokio::test] + async fn reuse_driver_forwards_database_and_schema_scope_identities() { + let reuse = ReuseDriver::new(FakeDriver::new(1, false), "sqlserver"); + let handle = ConnectionHandle { + id: "h".into(), + pool_id: "p".into(), + }; + + assert_eq!( + reuse + .physical_database_identity(&handle, "DataZen") + .await + .unwrap() + .as_deref(), + Some("physical:DataZen") + ); + assert_eq!( + reuse + .schema_scope_identity(&handle, "DataZen", "sales") + .await + .unwrap() + .as_deref(), + Some("schema:DataZen:sales") + ); + } + #[test] fn test_tester_reuse_driver_forwards_ddl_atomicity() { let inner = FakeDriver::with_ddl_atomicity(1, DdlAtomicity::Transactional); diff --git a/packages/driver-api/src/traits.rs b/packages/driver-api/src/traits.rs index d8b03f6e0..a7742f83d 100644 --- a/packages/driver-api/src/traits.rs +++ b/packages/driver-api/src/traits.rs @@ -774,6 +774,19 @@ pub trait DatabaseDriver: Send + Sync { Ok(None) } + /// Return a catalog identity for one exact schema scope when the driver + /// can prove it. The value is compared only when both endpoints have the + /// same physical database identity; drivers must not include credentials + /// or other secrets. + async fn schema_scope_identity( + &self, + _handle: &ConnectionHandle, + _database: &str, + _schema: &str, + ) -> Result, DriverError> { + Ok(None) + } + /// Close whatever the driver opened for `database` on this handle — the /// right-click "close database connection" action. /// diff --git a/packages/drivers/sqlserver/src/metadata.rs b/packages/drivers/sqlserver/src/metadata.rs index ca2f3a2f3..49400684c 100644 --- a/packages/drivers/sqlserver/src/metadata.rs +++ b/packages/drivers/sqlserver/src/metadata.rs @@ -69,6 +69,13 @@ pub(crate) fn columns_sql(database: &str) -> String { ) } +pub(crate) fn schema_scope_identity_sql(_database: &str) -> String { + // The physical identity check proves this is the configured current DB; + // staying unqualified also works on Azure SQL Database, which rejects + // three-part cross-database catalog references. + "SELECT schema_id FROM sys.schemas WHERE name = @P1".to_owned() +} + pub(crate) fn indexes_sql(database: &str) -> String { let catalog = catalog_prefix(database); format!( @@ -522,6 +529,11 @@ mod tests { columns.contains("DATABASEPROPERTYEX(CASE WHEN @P3 = N'' THEN DB_NAME() ELSE @P3 END") ); assert!(!columns.contains("@P1'")); + let schema_identity = schema_scope_identity_sql("db]; injected"); + assert_eq!( + schema_identity, + "SELECT schema_id FROM sys.schemas WHERE name = @P1" + ); assert!(indexes_sql("db").contains("ic.key_ordinal")); assert!(foreign_keys_sql("db").contains("fkc.constraint_column_id")); assert!(checks_sql("db").contains("cc.is_not_trusted")); diff --git a/packages/drivers/sqlserver/src/migration/tests.rs b/packages/drivers/sqlserver/src/migration/tests.rs index f580b0115..0322ed319 100644 --- a/packages/drivers/sqlserver/src/migration/tests.rs +++ b/packages/drivers/sqlserver/src/migration/tests.rs @@ -312,3 +312,18 @@ fn unsupported_operations_and_unrepresented_table_layouts_fail_closed() { assert!(SqlServerMigrationRenderer.render(&operation).is_err()); } } + +#[test] +fn schema_object_cross_scope_mapping_stays_fail_closed() { + for kind in [ + ObjectKind::View, + ObjectKind::Function, + ObjectKind::Procedure, + ObjectKind::Trigger, + ] { + assert!(SqlServerMigrationRenderer + .map_schema_object_scope(kind, "dbo", "archive", "SELECT 1", &[]) + .unwrap() + .is_none()); + } +} diff --git a/packages/drivers/sqlserver/src/sqlserver.rs b/packages/drivers/sqlserver/src/sqlserver.rs index ffbeb1650..3a7378e37 100644 --- a/packages/drivers/sqlserver/src/sqlserver.rs +++ b/packages/drivers/sqlserver/src/sqlserver.rs @@ -769,6 +769,50 @@ fn schema_migration_blockers_for_indexes(indexes: &[IndexInfo]) -> Vec { blockers } +const PHYSICAL_DATABASE_IDENTITY_SQL: &str = + "SELECT CONVERT(nvarchar(256), SERVERPROPERTY('ServerName')) AS server_name, DB_ID() AS database_id WHERE NULLIF(CONVERT(nvarchar(256), SERVERPROPERTY('ServerName')), N'') IS NOT NULL AND DB_ID() IS NOT NULL AND DB_ID(NULLIF(@P1, N'')) = DB_ID()"; + +fn parse_physical_database_identity(result: &QueryResult) -> Option { + if result.rows.len() != 1 || result.rows[0].len() != 2 { + return None; + } + let row = result.rows.first()?; + let server_name = match row.first()?.as_ref()? { + Value::String(value) if !value.trim().is_empty() => value.trim(), + _ => return None, + }; + let database_id = match row.get(1)?.as_ref()? { + Value::Integer(value) if *value > 0 => value, + _ => return None, + }; + Some(format!( + "sqlserver:server:{}:{}:database:{}", + server_name.len(), + server_name, + database_id + )) +} + +fn parse_schema_scope_identity(result: &QueryResult) -> Option { + if result.rows.len() != 1 || result.rows[0].len() != 1 { + return None; + } + let schema_id = match result + .rows + .first() + .and_then(|row| row.first()) + .and_then(Option::as_ref) + { + Some(Value::Integer(value)) if *value > 0 => *value, + Some(Value::String(value)) => match value.trim().parse::() { + Ok(value) if value > 0 => value, + _ => return None, + }, + _ => return None, + }; + Some(format!("sqlserver:schema-id:{schema_id}")) +} + #[async_trait] impl DatabaseDriver for SqlServerDriver { fn migration_renderer( @@ -787,6 +831,41 @@ impl DatabaseDriver for SqlServerDriver { Some(std::sync::Arc::new(super::SqlServerTypeNormalizer)) } + async fn physical_database_identity( + &self, + handle: &ConnectionHandle, + database: &str, + ) -> Result, DriverError> { + let mut clients = self.clients.write().await; + let client = clients + .get_mut(&handle.pool_id) + .ok_or_else(|| DriverError::ConnectionFailed("Connection pool not found".into()))?; + let parameters = [Value::String(database.trim().to_owned())]; + let result = + Self::run_with_params(client, PHYSICAL_DATABASE_IDENTITY_SQL, ¶meters).await?; + Ok(parse_physical_database_identity(&result)) + } + + async fn schema_scope_identity( + &self, + handle: &ConnectionHandle, + database: &str, + schema: &str, + ) -> Result, DriverError> { + let mut clients = self.clients.write().await; + let client = clients + .get_mut(&handle.pool_id) + .ok_or_else(|| DriverError::ConnectionFailed("Connection pool not found".into()))?; + let parameters = [Value::String(schema.trim().to_owned())]; + let result = Self::run_with_params( + client, + &crate::metadata::schema_scope_identity_sql(database), + ¶meters, + ) + .await?; + Ok(parse_schema_scope_identity(&result)) + } + fn driver_type(&self) -> DatabaseType { "sqlserver".to_string() } @@ -1560,6 +1639,103 @@ mod tests { use super::*; use tiberius::EncryptionLevel; + #[test] + fn physical_database_identity_uses_server_name_and_current_database_id() { + assert!(PHYSICAL_DATABASE_IDENTITY_SQL.contains("SERVERPROPERTY('ServerName')")); + assert!(PHYSICAL_DATABASE_IDENTITY_SQL.contains("DB_ID(NULLIF(@P1, N'')) = DB_ID()")); + assert!(PHYSICAL_DATABASE_IDENTITY_SQL.contains("DB_ID() AS database_id")); + assert!(!PHYSICAL_DATABASE_IDENTITY_SQL.contains("password")); + + let result = QueryResult { + columns: Vec::new(), + rows: vec![vec![ + Some(Value::String("sqlserver-prod\\instance1".into())), + Some(Value::Integer(23)), + ]], + rows_affected: None, + execution_time_ms: 0, + }; + assert_eq!( + parse_physical_database_identity(&result).as_deref(), + Some("sqlserver:server:24:sqlserver-prod\\instance1:database:23") + ); + + let unknown = QueryResult { + columns: Vec::new(), + rows: vec![vec![ + Some(Value::String("server".into())), + Some(Value::Null), + ]], + rows_affected: None, + execution_time_ms: 0, + }; + assert_eq!(parse_physical_database_identity(&unknown), None); + + let no_server_name = QueryResult { + columns: Vec::new(), + rows: vec![vec![ + Some(Value::String(" ".into())), + Some(Value::Integer(23)), + ]], + rows_affected: None, + execution_time_ms: 0, + }; + assert_eq!(parse_physical_database_identity(&no_server_name), None); + + let ambiguous = QueryResult { + columns: Vec::new(), + rows: vec![ + vec![ + Some(Value::String("server-a".into())), + Some(Value::Integer(23)), + ], + vec![ + Some(Value::String("server-b".into())), + Some(Value::Integer(23)), + ], + ], + rows_affected: None, + execution_time_ms: 0, + }; + assert_eq!(parse_physical_database_identity(&ambiguous), None); + } + + #[test] + fn schema_scope_identity_requires_one_positive_catalog_id() { + let identity = |rows| QueryResult { + columns: Vec::new(), + rows, + rows_affected: None, + execution_time_ms: 0, + }; + + assert_eq!( + parse_schema_scope_identity(&identity(vec![vec![Some(Value::Integer(5))]])).as_deref(), + Some("sqlserver:schema-id:5") + ); + assert_eq!( + parse_schema_scope_identity(&identity(vec![vec![Some(Value::String("7".into()))]])) + .as_deref(), + Some("sqlserver:schema-id:7") + ); + assert_eq!(parse_schema_scope_identity(&identity(Vec::new())), None); + assert_eq!( + parse_schema_scope_identity(&identity(vec![ + vec![Some(Value::Integer(1))], + vec![Some(Value::Integer(2))] + ])), + None + ); + assert_eq!( + parse_schema_scope_identity(&identity(vec![vec![Some(Value::Integer(0))]])), + None + ); + assert_eq!( + parse_schema_scope_identity(&identity(vec![vec![Some(Value::Null)]])), + None + ); + } + #[test] fn schema_migration_blockers_cover_unrepresented_clustered_layouts() { let ordinary_layout = vec![ diff --git a/src-tauri/src/commands/schema_diff.rs b/src-tauri/src/commands/schema_diff.rs index 0b5bd1f30..d1393fcf2 100644 --- a/src-tauri/src/commands/schema_diff.rs +++ b/src-tauri/src/commands/schema_diff.rs @@ -82,6 +82,60 @@ async fn ensure_distinct_schema_scope( .await .ok() .flatten(); + let is_sqlserver = crate::schema_diff::types::normalize_dialect(&source_config.database_type) + == "sqlserver" + && crate::schema_diff::types::normalize_dialect(&target_config.database_type) + == "sqlserver"; + let same_physical_database = matches!( + (source_identity.as_deref(), target_identity.as_deref()), + (Some(source), Some(target)) if source == target + ); + let source_schema = source_schema_scope + .and_then(|schema| (!schema.trim().is_empty()).then_some(schema)) + .or_else(|| { + source_config + .schema + .as_deref() + .filter(|schema| !schema.trim().is_empty()) + }); + let target_schema = target_schema_scope + .and_then(|schema| (!schema.trim().is_empty()).then_some(schema)) + .or_else(|| { + target_config + .schema + .as_deref() + .filter(|schema| !schema.trim().is_empty()) + }); + let (source_schema_identity, target_schema_identity) = if is_sqlserver && same_physical_database + { + let source_schema_identity = match source_schema { + Some(schema) => source_driver + .schema_scope_identity( + source_handle, + source_config.database.as_deref().unwrap_or_default(), + schema, + ) + .await + .ok() + .flatten(), + None => None, + }; + let target_schema_identity = match target_schema { + Some(schema) => target_driver + .schema_scope_identity( + target_handle, + target_config.database.as_deref().unwrap_or_default(), + schema, + ) + .await + .ok() + .flatten(), + None => None, + }; + (source_schema_identity, target_schema_identity) + } else { + (None, None) + }; match crate::schema_diff::reviewed::physical_database_scope( source_config, @@ -90,6 +144,8 @@ async fn ensure_distinct_schema_scope( target_identity.as_deref(), source_schema_scope, target_schema_scope, + source_schema_identity.as_deref(), + target_schema_identity.as_deref(), ) { crate::schema_diff::reviewed::PhysicalDatabaseScope::Same => { Err(reject_same_schema_scope()) diff --git a/src-tauri/src/schema_diff/reviewed.rs b/src-tauri/src/schema_diff/reviewed.rs index b3c8e4ec5..b297bde09 100644 --- a/src-tauri/src/schema_diff/reviewed.rs +++ b/src-tauri/src/schema_diff/reviewed.rs @@ -377,7 +377,7 @@ pub fn same_endpoint(source: &ConnectionConfig, target: &ConnectionConfig) -> bo /// Detect aliases that reach the same database through different connection /// settings. Database and schema names are only used as proof of distinct -/// scopes when PostgreSQL's namespace rules make that conclusion reliable; +/// scopes when the dialect's namespace rules make that conclusion reliable; /// MySQL database names may be case-folded and SQLite paths may be hard links. pub fn physical_database_scope( source: &ConnectionConfig, @@ -386,6 +386,8 @@ pub fn physical_database_scope( target_identity: Option<&str>, source_schema_scope: Option<&str>, target_schema_scope: Option<&str>, + source_schema_identity: Option<&str>, + target_schema_identity: Option<&str>, ) -> PhysicalDatabaseScope { let source_dialect = crate::schema_diff::types::normalize_dialect(&source.database_type); let target_dialect = crate::schema_diff::types::normalize_dialect(&target.database_type); @@ -409,6 +411,15 @@ pub fn physical_database_scope( { return PhysicalDatabaseScope::Different; } + // SQL Server identifier equality follows the database collation, so + // only driver-proven schema catalog identities can establish distinct + // scopes. Object kinds without a proven definition rewrite remain + // fail-closed in the unified planner's scope-mapping checks. + if source_dialect == "sqlserver" + && matches!((source_schema_identity, target_schema_identity), (Some(left), Some(right)) if left != right) + { + return PhysicalDatabaseScope::Different; + } return PhysicalDatabaseScope::Same; } diff --git a/src-tauri/src/schema_diff/reviewed_tests.rs b/src-tauri/src/schema_diff/reviewed_tests.rs index 6e1a20720..8a51acbac 100644 --- a/src-tauri/src/schema_diff/reviewed_tests.rs +++ b/src-tauri/src/schema_diff/reviewed_tests.rs @@ -159,6 +159,8 @@ fn physical_database_scope_detects_aliases_and_fails_closed_when_unknown() { Some("postgresql:cluster:app"), None, None, + None, + None, ), Same ); @@ -174,6 +176,8 @@ fn physical_database_scope_detects_aliases_and_fails_closed_when_unknown() { Some("postgresql:cluster:app"), None, None, + None, + None, ), Same ); @@ -187,6 +191,8 @@ fn physical_database_scope_detects_aliases_and_fails_closed_when_unknown() { Some("postgresql:cluster:app"), Some("public"), Some("archive"), + None, + None, ), Different ); @@ -198,6 +204,8 @@ fn physical_database_scope_detects_aliases_and_fails_closed_when_unknown() { Some("mysql:cluster:app"), None, None, + None, + None, ), Different ); @@ -209,11 +217,110 @@ fn physical_database_scope_detects_aliases_and_fails_closed_when_unknown() { Some("postgresql:cluster:app"), Some("public"), Some("archive"), + None, + None, + ), + Different + ); + assert_eq!( + physical_database_scope(&a, &alias, None, None, None, None, None, None), + Unknown + ); +} + +#[test] +fn sqlserver_physical_scope_allows_only_proven_distinct_schemas() { + use PhysicalDatabaseScope::{Different, Same, Unknown}; + + let mut source = config(); + source.database_type = "sqlserver".into(); + source.database = Some("DataZen".into()); + source.schema = Some("dbo".into()); + let identity = "sqlserver:server:13:prod-instance:database:23"; + + let same_schema = source.clone(); + assert_eq!( + physical_database_scope( + &source, + &same_schema, + Some(identity), + Some(identity), + Some("dbo"), + Some("dbo"), + Some("sqlserver:schema-id:1"), + Some("sqlserver:schema-id:1"), + ), + Same + ); + + let mut other_schema = source.clone(); + other_schema.schema = Some("sales".into()); + assert_eq!( + physical_database_scope( + &source, + &other_schema, + Some(identity), + Some(identity), + Some("dbo"), + Some("sales"), + Some("sqlserver:schema-id:1"), + Some("sqlserver:schema-id:2"), + ), + Different + ); + + // Collation can make distinct spellings resolve to the same schema_id. + assert_eq!( + physical_database_scope( + &source, + &source, + Some(identity), + Some(identity), + Some("dbo"), + Some("DBO"), + Some("sqlserver:schema-id:1"), + Some("sqlserver:schema-id:1"), + ), + Same + ); + assert_eq!( + physical_database_scope( + &source, + &source, + Some(identity), + Some(identity), + Some("dbo"), + Some("审计"), + Some("sqlserver:schema-id:1"), + None, + ), + Same + ); + + assert_eq!( + physical_database_scope( + &source, + &other_schema, + Some(identity), + Some("sqlserver:server:13:prod-instance:database:24"), + Some("dbo"), + Some("sales"), + None, + None, ), Different ); assert_eq!( - physical_database_scope(&a, &alias, None, None, None, None), + physical_database_scope( + &source, + &other_schema, + None, + None, + Some("dbo"), + Some("sales"), + None, + None, + ), Unknown ); } @@ -235,11 +342,13 @@ fn physical_scope_uses_mysql_identity_before_case_foldable_names() { Some("mysql:server:canonical-app-db"), None, None, + None, + None, ), Same ); assert_eq!( - physical_database_scope(&source, &target, None, None, None, None), + physical_database_scope(&source, &target, None, None, None, None, None, None), Unknown ); } @@ -400,7 +509,16 @@ fn postgres_names_can_prove_distinct_database_or_schema_without_identity() { let mut different_database = source.clone(); different_database.database = Some("another_db".into()); assert_eq!( - physical_database_scope(&source, &different_database, None, None, None, None), + physical_database_scope( + &source, + &different_database, + None, + None, + None, + None, + None, + None, + ), Different ); @@ -413,6 +531,8 @@ fn postgres_names_can_prove_distinct_database_or_schema_without_identity() { None, Some("public"), Some("archive"), + None, + None, ), Different ); From 5fc626976b8bae7607dd7acbd1035043787e9819 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 01:15:05 +0800 Subject: [PATCH 011/666] fix(sqlserver): use catalog database identity --- packages/drivers/sqlserver/src/sqlserver.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/drivers/sqlserver/src/sqlserver.rs b/packages/drivers/sqlserver/src/sqlserver.rs index 3a7378e37..94d1a3d66 100644 --- a/packages/drivers/sqlserver/src/sqlserver.rs +++ b/packages/drivers/sqlserver/src/sqlserver.rs @@ -770,7 +770,7 @@ fn schema_migration_blockers_for_indexes(indexes: &[IndexInfo]) -> Vec { } const PHYSICAL_DATABASE_IDENTITY_SQL: &str = - "SELECT CONVERT(nvarchar(256), SERVERPROPERTY('ServerName')) AS server_name, DB_ID() AS database_id WHERE NULLIF(CONVERT(nvarchar(256), SERVERPROPERTY('ServerName')), N'') IS NOT NULL AND DB_ID() IS NOT NULL AND DB_ID(NULLIF(@P1, N'')) = DB_ID()"; + "SELECT CONVERT(nvarchar(256), SERVERPROPERTY('ServerName')) AS server_name, d.database_id AS database_id FROM sys.databases AS d WHERE d.name = DB_NAME() AND NULLIF(CONVERT(nvarchar(256), SERVERPROPERTY('ServerName')), N'') IS NOT NULL AND DB_ID() IS NOT NULL AND DB_ID(NULLIF(@P1, N'')) = DB_ID()"; fn parse_physical_database_identity(result: &QueryResult) -> Option { if result.rows.len() != 1 || result.rows[0].len() != 2 { @@ -1640,10 +1640,12 @@ mod tests { use tiberius::EncryptionLevel; #[test] - fn physical_database_identity_uses_server_name_and_current_database_id() { + fn physical_database_identity_uses_server_name_and_current_catalog_database_id() { assert!(PHYSICAL_DATABASE_IDENTITY_SQL.contains("SERVERPROPERTY('ServerName')")); + assert!(PHYSICAL_DATABASE_IDENTITY_SQL.contains("d.database_id AS database_id")); + assert!(PHYSICAL_DATABASE_IDENTITY_SQL.contains("FROM sys.databases AS d")); + assert!(PHYSICAL_DATABASE_IDENTITY_SQL.contains("d.name = DB_NAME()")); assert!(PHYSICAL_DATABASE_IDENTITY_SQL.contains("DB_ID(NULLIF(@P1, N'')) = DB_ID()")); - assert!(PHYSICAL_DATABASE_IDENTITY_SQL.contains("DB_ID() AS database_id")); assert!(!PHYSICAL_DATABASE_IDENTITY_SQL.contains("password")); let result = QueryResult { From 84c22e94ed0a7c3c3f87c065c0d86d6827ad9cee Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 01:23:08 +0800 Subject: [PATCH 012/666] fix(sqlserver): match identity insert to mapped target columns --- packages/driver-api/src/reuse.rs | 8 +- packages/driver-api/src/traits.rs | 10 +- packages/drivers/sqlserver/src/sqlserver.rs | 84 +++++++++++++++- src-tauri/src/data_transfer/sql_file.rs | 103 +++++++++----------- 4 files changed, 135 insertions(+), 70 deletions(-) diff --git a/packages/driver-api/src/reuse.rs b/packages/driver-api/src/reuse.rs index fa1e0e9ea..392e5b9a3 100644 --- a/packages/driver-api/src/reuse.rs +++ b/packages/driver-api/src/reuse.rs @@ -122,9 +122,13 @@ impl DatabaseDriver for ReuseDriver { &self, insert_sql: &str, target_relation: &str, + mapped_target_columns: &[String], ) -> Result { - self.inner - .render_transfer_sql_file_identity_insert(insert_sql, target_relation) + self.inner.render_transfer_sql_file_identity_insert( + insert_sql, + target_relation, + mapped_target_columns, + ) } fn type_normalizer(&self) -> Option> { diff --git a/packages/driver-api/src/traits.rs b/packages/driver-api/src/traits.rs index b855110f7..94ef2253e 100644 --- a/packages/driver-api/src/traits.rs +++ b/packages/driver-api/src/traits.rs @@ -701,14 +701,16 @@ pub trait DatabaseDriver: Send + Sync { Ok(insert_template.replacen(&format!("{identity_override_marker} "), "", 1)) } - /// Wrap an SQL-file INSERT that carries explicit values for a mapped - /// identity column. The target relation is already safely quoted by the - /// host for this dialect. Drivers without a session toggle return the - /// statement unchanged. + /// Wrap an SQL-file INSERT using its actual mapped target columns and + /// target relation. Drivers can intersect those columns with the target + /// identity metadata at script execution time. The target relation is + /// already safely quoted by the host. Drivers without a session toggle + /// return the statement unchanged. fn render_transfer_sql_file_identity_insert( &self, insert_sql: &str, _target_relation: &str, + _mapped_target_columns: &[String], ) -> Result { Ok(insert_sql.to_string()) } diff --git a/packages/drivers/sqlserver/src/sqlserver.rs b/packages/drivers/sqlserver/src/sqlserver.rs index 7f5034c48..391552c98 100644 --- a/packages/drivers/sqlserver/src/sqlserver.rs +++ b/packages/drivers/sqlserver/src/sqlserver.rs @@ -139,7 +139,20 @@ impl SqlServerDriver { fn render_sql_file_identity_insert( insert_sql: &str, target_relation: &str, + mapped_target_columns: &[String], ) -> Result { + if mapped_target_columns.is_empty() { + return Ok(insert_sql.to_string()); + } + if mapped_target_columns + .iter() + .any(|column| column.is_empty() || column.contains('\0')) + { + return Err(DriverError::InvalidConfig( + "SQL Server SQL-file identity wrapper requires valid mapped target column names" + .into(), + )); + } let relation_parts = Self::quoted_transfer_relation_parts(target_relation).ok_or_else(|| { DriverError::InvalidConfig( @@ -152,8 +165,13 @@ impl SqlServerDriver { .get(2) .map(|_| format!("{}.sys.identity_columns", relation_parts[0])) .unwrap_or_else(|| "sys.identity_columns".into()); + let mapped_columns = mapped_target_columns + .iter() + .map(|column| format!("N'{}'", column.replace('\'', "''"))) + .collect::>() + .join(", "); Ok(format!( - "IF EXISTS (SELECT 1 FROM {identity_catalog} WHERE object_id = OBJECT_ID(N'{object_name}', N'U'))\nBEGIN\n BEGIN TRY\n SET IDENTITY_INSERT {target_relation} ON;\n {insert_sql};\n SET IDENTITY_INSERT {target_relation} OFF;\n END TRY\n BEGIN CATCH\n BEGIN TRY\n SET IDENTITY_INSERT {target_relation} OFF;\n END TRY\n BEGIN CATCH\n THROW;\n END CATCH\n THROW;\n END CATCH\nEND\nELSE\nBEGIN\n {insert_sql};\nEND" + "IF EXISTS (SELECT 1 FROM {identity_catalog} WHERE object_id = OBJECT_ID(N'{object_name}', N'U') AND name IN ({mapped_columns}))\nBEGIN\n BEGIN TRY\n SET IDENTITY_INSERT {target_relation} ON;\n {insert_sql};\n SET IDENTITY_INSERT {target_relation} OFF;\n END TRY\n BEGIN CATCH\n BEGIN TRY\n SET IDENTITY_INSERT {target_relation} OFF;\n END TRY\n BEGIN CATCH\n THROW;\n END CATCH\n THROW;\n END CATCH\nEND\nELSE\nBEGIN\n {insert_sql};\nEND" )) } @@ -1332,8 +1350,9 @@ impl DatabaseDriver for SqlServerDriver { &self, insert_sql: &str, target_relation: &str, + mapped_target_columns: &[String], ) -> Result { - Self::render_sql_file_identity_insert(insert_sql, target_relation) + Self::render_sql_file_identity_insert(insert_sql, target_relation, mapped_target_columns) } async fn execute_with_params( @@ -2025,10 +2044,13 @@ mod tests { let script = SqlServerDriver::render_sql_file_identity_insert( "INSERT INTO [odd.schema].[order]]details] ([id]) VALUES (1)", "[data]]zen].[odd.schema].[order]]details]", + &["id".into()], ) .unwrap(); assert!(script.contains("OBJECT_ID(N'[data]]zen].[odd.schema].[order]]details]', N'U')")); - assert!(script.contains("FROM [data]]zen].sys.identity_columns WHERE object_id")); + assert!(script.contains( + "FROM [data]]zen].sys.identity_columns WHERE object_id = OBJECT_ID(N'[data]]zen].[odd.schema].[order]]details]', N'U') AND name IN (N'id')" + )); assert!( script.contains("SET IDENTITY_INSERT [data]]zen].[odd.schema].[order]]details] ON;") ); @@ -2041,27 +2063,78 @@ mod tests { let local_script = SqlServerDriver::render_sql_file_identity_insert( "INSERT INTO [dbo].[items] ([id]) VALUES (1)", "[dbo].[items]", + &["id".into()], ) .unwrap(); assert!(local_script.contains("FROM sys.identity_columns WHERE object_id")); let apostrophe_script = SqlServerDriver::render_sql_file_identity_insert( "INSERT INTO [O'Brien].[items] ([id]) VALUES (1)", "[db].[O'Brien].[items]", + &["user'id".into()], ) .unwrap(); assert!(apostrophe_script.contains("OBJECT_ID(N'[db].[O''Brien].[items]'")); + assert!(apostrophe_script.contains("name IN (N'user''id')")); + } + + #[test] + fn sql_file_identity_wrapper_only_checks_inserted_mapped_columns() { + // The first case represents a regular source column mapped to the + // target identity column. The target metadata predicate discovers + // that mapped identity at execution time, independently of source + // auto-increment metadata. + let mapped_identity = SqlServerDriver::render_sql_file_identity_insert( + "INSERT INTO [dbo].[items] ([id], [label]) VALUES (7, N'x')", + "[dbo].[items]", + &["id".into(), "label".into()], + ) + .unwrap(); + assert!(mapped_identity.contains("AND name IN (N'id', N'label')")); + assert!(mapped_identity.contains("SET IDENTITY_INSERT [dbo].[items] ON;")); + + // Here the source identity maps to a regular target column while a + // different target identity column is omitted from the INSERT. The + // script's predicate can only match the actual mapped target column, + // so that unrelated identity cannot enable IDENTITY_INSERT. + let unrelated_identity = SqlServerDriver::render_sql_file_identity_insert( + "INSERT INTO [dbo].[items] ([external_id]) VALUES (7)", + "[dbo].[items]", + &["external_id".into()], + ) + .unwrap(); + assert!(unrelated_identity.contains("AND name IN (N'external_id')")); + assert!(!unrelated_identity.contains("name IN (N'id'")); + assert!(unrelated_identity.contains("ELSE\nBEGIN\n INSERT INTO [dbo].[items]")); + } + + #[test] + fn sql_file_identity_wrapper_skips_empty_columns_and_rejects_invalid_names() { + let insert_sql = "INSERT INTO [dbo].[items] DEFAULT VALUES"; + assert_eq!( + SqlServerDriver::render_sql_file_identity_insert(insert_sql, "[dbo].[items]", &[],) + .unwrap(), + insert_sql + ); + assert!(SqlServerDriver::render_sql_file_identity_insert( + "INSERT INTO [dbo].[items] ([id]) VALUES (1)", + "[dbo].[items]", + &["bad\0name".into()], + ) + .is_err()); } #[test] fn sql_file_identity_wrapper_rejects_unquoted_relation_fragments() { assert!(SqlServerDriver::render_sql_file_identity_insert( "INSERT INTO t (id) VALUES (1)", - "dbo.t; DROP TABLE users" + "dbo.t; DROP TABLE users", + &["id".into()], ) .is_err()); assert!(SqlServerDriver::render_sql_file_identity_insert( "INSERT INTO [dbo].[t] ([id]) VALUES (1)", - "[dbo]." + "[dbo].", + &["id".into()], ) .is_err()); } @@ -2089,6 +2162,7 @@ mod tests { .render_transfer_sql_file_identity_insert( "INSERT INTO [dbo].[items] ([id]) VALUES (1)", "[dbo].[items]", + &["id".into()], ) .unwrap(); assert!(script.contains("SET IDENTITY_INSERT [dbo].[items] ON;")); diff --git a/src-tauri/src/data_transfer/sql_file.rs b/src-tauri/src/data_transfer/sql_file.rs index be01865d2..0822c0241 100644 --- a/src-tauri/src/data_transfer/sql_file.rs +++ b/src-tauri/src/data_transfer/sql_file.rs @@ -472,18 +472,6 @@ pub(crate) fn target_table_ref( qualify_target_relation(driver, database, schema, table) } -fn mappings_include_source_identity( - mappings: &[&ColumnMapping], - source_schema: &TableSchema, -) -> bool { - mappings.iter().any(|mapping| { - source_schema - .columns - .iter() - .any(|column| column.name == mapping.source_column && column.is_auto_increment) - }) -} - /// A custom DDL string is an opaque dialect-specific escape hatch. It cannot /// be safely re-rendered when the SQL-file target dialect differs from the /// source, so reject it before preview and execution instead of emitting a @@ -712,6 +700,13 @@ fn insert_sql( insert_sql_batch(driver, job, table, mappings, &[row.to_vec()]) } +fn mapped_insert_target_columns(mappings: &[&ColumnMapping]) -> Vec { + mappings + .iter() + .map(|mapping| mapping.target_column.clone()) + .collect() +} + fn insert_sql_batch( driver: &dyn DatabaseDriver, job: &TransferJob, @@ -1110,8 +1105,8 @@ pub async fn execute_with_target( ), None => None, }; - let carries_source_identity = mappings_include_source_identity(&mappings, schema); let target_relation = target_table_ref(target_driver, job, &table.target_table); + let mapped_target_columns = mapped_insert_target_columns(&mappings); let mut scan = super::scan::scan_rows_with_params( source_driver, handle, @@ -1161,13 +1156,13 @@ pub async fn execute_with_target( None => insert_sql_batch(target_driver, job, table, &mappings, row_chunk), }; let rendered = rendered.and_then(|sql| { - if carries_source_identity { - target_driver - .render_transfer_sql_file_identity_insert(&sql, &target_relation) - .map_err(|error| TransferError::unsupported(error.to_string())) - } else { - Ok(sql) - } + target_driver + .render_transfer_sql_file_identity_insert( + &sql, + &target_relation, + &mapped_target_columns, + ) + .map_err(|error| TransferError::unsupported(error.to_string())) }); match rendered { Ok(sql) => { @@ -1306,45 +1301,35 @@ mod tests { } #[test] - fn sql_file_identity_wrapper_is_requested_only_for_mapped_source_identity_columns() { - let schema = TableSchema { - table_name: "source".into(), - columns: vec![ColumnSchema { - name: "generated_id".into(), - data_type: "BIGINT".into(), - nullable: false, - default_value: None, - comment: None, - is_primary_key: true, - is_auto_increment: true, - }], - primary_keys: vec!["generated_id".into()], - indexes: vec![], - foreign_keys: vec![], - check_constraints: vec![], - table_options: Default::default(), - }; - let identity_mapping = ColumnMapping { - source_column: "generated_id".into(), - target_column: "id".into(), - skip: false, - target_native_type: None, - }; - let other_mapping = ColumnMapping { - source_column: "regular".into(), - target_column: "id".into(), - skip: false, - target_native_type: None, - }; - assert!(mappings_include_source_identity( - &[&identity_mapping], - &schema - )); - assert!(!mappings_include_source_identity( - &[&other_mapping], - &schema - )); - assert!(!mappings_include_source_identity(&[], &schema)); + fn sql_file_identity_wrapper_receives_actual_mapped_target_columns() { + let mappings = vec![ + ColumnMapping { + source_column: "ordinary_source_column".into(), + target_column: "target_identity".into(), + skip: false, + target_native_type: None, + }, + ColumnMapping { + source_column: "source_identity".into(), + target_column: "ordinary_target_column".into(), + skip: false, + target_native_type: None, + }, + ColumnMapping { + source_column: "unused_source_column".into(), + target_column: "unmapped_target_identity".into(), + skip: true, + target_native_type: None, + }, + ]; + let active_mappings = active_column_mappings(&mappings); + assert_eq!( + mapped_insert_target_columns(&active_mappings), + vec![ + "target_identity".to_string(), + "ordinary_target_column".to_string() + ] + ); } #[tokio::test] From f6fa60f3897af60e38d235474731e2ff5381aae3 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 01:37:31 +0800 Subject: [PATCH 013/666] fix(schema-diff): block unmapped SQL Server object schemas --- src-tauri/src/commands/schema_diff.rs | 187 +++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/schema_diff.rs b/src-tauri/src/commands/schema_diff.rs index d1393fcf2..f1634f029 100644 --- a/src-tauri/src/commands/schema_diff.rs +++ b/src-tauri/src/commands/schema_diff.rs @@ -20,7 +20,7 @@ use crate::schema_diff::plan::{is_source_unbounded_text, PlanOptions}; use crate::schema_diff::types::TableColumnDiff; use crate::schema_diff::types::{ normalize_dialect, resolve_table_for_dialect, uses_schema_scope, ColumnTypeOverride, - SchemaDiffDeployResult, SchemaDiffPlan, + PlanRequirement, SchemaDiffDeployResult, SchemaDiffPlan, }; use crate::schema_diff::SchemaDiffProfile; use crate::services::job_registry::{cancel_job, ensure_job, remove_job}; @@ -157,6 +157,81 @@ async fn ensure_distinct_schema_scope( } } +/// Object-only plans do not have the unified planner's SQL Server schema +/// mapper. Keep their source and target objects in the same configured schema +/// so a plan cannot render source-schema DDL or drop a same-named object from +/// another target schema. +fn sqlserver_object_scope_requirement( + source_dialect: &str, + target_dialect: &str, + source_schema: Option<&str>, + target_schema: Option<&str>, + source: &[SchemaObjectSnapshot], + target: &[SchemaObjectSnapshot], +) -> Option { + if source_dialect != "sqlserver" || target_dialect != "sqlserver" { + return None; + } + + // SQL Server's driver-level default schema is dbo when no explicit schema + // is configured. Object snapshots always carry their catalog schema. + let source_scope = source_schema + .map(str::trim) + .filter(|schema| !schema.is_empty()) + .unwrap_or("dbo"); + let target_scope = target_schema + .map(str::trim) + .filter(|schema| !schema.is_empty()) + .unwrap_or("dbo"); + let source_matches_scope = source.iter().all(|object| { + object.schema.as_deref() == Some(source_scope) + && object + .target_schema + .as_deref() + .is_none_or(|schema| schema == source_scope) + }); + let target_matches_scope = target.iter().all(|object| { + object.schema.as_deref() == Some(target_scope) + && object + .target_schema + .as_deref() + .is_none_or(|schema| schema == target_scope) + }); + + if source_scope == target_scope && source_matches_scope && target_matches_scope { + return None; + } + + Some(PlanRequirement::Unsupported { + operation: "sqlserver-object-schema-scope".into(), + reason: format!( + "SQL Server object-only plans cannot rewrite object definitions across schemas. Source and target objects must both match the same configured schema; source scope is `{source_scope}`, target scope is `{target_scope}`." + ), + }) +} + +fn apply_sqlserver_object_scope_gate( + plan: &mut SchemaDiffPlan, + source_dialect: &str, + target_dialect: &str, + source_schema: Option<&str>, + target_schema: Option<&str>, + source: &[SchemaObjectSnapshot], + target: &[SchemaObjectSnapshot], +) { + if let Some(requirement) = sqlserver_object_scope_requirement( + source_dialect, + target_dialect, + source_schema, + target_schema, + source, + target, + ) { + plan.requirements.push(requirement); + plan.statements.clear(); + } +} + fn reject_same_schema_scope() -> CommandError { CommandError::Validation("Source and target must identify different database scopes".into()) } @@ -1329,6 +1404,15 @@ pub async fn prepare_schema_view_plan( renderer.as_ref(), capabilities.as_ref(), ); + apply_sqlserver_object_scope_gate( + &mut plan, + &src_dialect, + &tgt_dialect, + src_config.schema.as_deref(), + tgt_config.schema.as_deref(), + &source_snapshots, + &target_snapshots, + ); if tgt_dialect == "mysql" { for object in source_snapshots.iter().chain(&target_snapshots) { if let Err(reason) = @@ -1475,6 +1559,15 @@ pub async fn prepare_schema_routine_trigger_plan( renderer.as_ref(), capabilities.as_ref(), ); + apply_sqlserver_object_scope_gate( + &mut plan, + &src_dialect, + &tgt_dialect, + src_config.schema.as_deref(), + tgt_config.schema.as_deref(), + &source_snapshots, + &target_snapshots, + ); crate::schema_diff::reviewed::freeze_with_objects( &mut plan, target_db_session_id, @@ -1591,6 +1684,15 @@ pub async fn prepare_schema_sequence_plan( renderer.as_ref(), capabilities.as_ref(), ); + apply_sqlserver_object_scope_gate( + &mut plan, + &src_dialect, + &tgt_dialect, + src_config.schema.as_deref(), + tgt_config.schema.as_deref(), + &source_snapshots, + &target_snapshots, + ); crate::schema_diff::reviewed::freeze_with_objects( &mut plan, target_db_session_id, @@ -1707,6 +1809,15 @@ pub async fn prepare_schema_type_plan( renderer.as_ref(), capabilities.as_ref(), ); + apply_sqlserver_object_scope_gate( + &mut plan, + &src_dialect, + &tgt_dialect, + src_config.schema.as_deref(), + tgt_config.schema.as_deref(), + &source_snapshots, + &target_snapshots, + ); crate::schema_diff::reviewed::freeze_with_objects( &mut plan, target_db_session_id, @@ -2291,9 +2402,83 @@ pub async fn compare_table_schemas( mod tests { use super::*; use crate::db::{ColumnSchema, ForeignKeyDeferrability, ForeignKeyInfo, TableInfo, TableType}; + use crate::schema_diff::types::{PlanStatement, RollbackCompleteness, StatementRisk}; use crate::testing::mock_driver::{MockDriver, MockDriverOptions}; use std::collections::HashMap; + #[test] + fn sqlserver_object_only_plan_blocks_cross_schema_statements() { + let source = vec![SchemaObjectSnapshot::view( + Some("dbo"), + "v", + "SELECT 1 AS value", + )]; + let target = vec![SchemaObjectSnapshot::view( + Some("sales"), + "v", + "SELECT 2 AS value", + )]; + let mut plan = SchemaDiffPlan { + plan_id: None, + table: "v".into(), + tables: Vec::new(), + source_dialect: "sqlserver".into(), + target_dialect: "sqlserver".into(), + same_dialect: true, + statements: vec![PlanStatement { + sql: "CREATE VIEW [dbo].[v] AS SELECT 1 AS value".into(), + risk: StatementRisk::Additive, + rollback_sql: None, + summary: "create view".into(), + requires_transaction: false, + }], + warnings: Vec::new(), + requirements: Vec::new(), + rollback_completeness: RollbackCompleteness { + complete: false, + missing: vec!["view rollback is not complete".into()], + }, + type_suggestions: Vec::new(), + expected_target_schemas: Vec::new(), + }; + + apply_sqlserver_object_scope_gate( + &mut plan, + "sqlserver", + "sqlserver", + Some("dbo"), + Some("sales"), + &source, + &target, + ); + + assert!(plan.statements.is_empty()); + assert!(matches!( + plan.requirements.as_slice(), + [PlanRequirement::Unsupported { operation, reason }] + if operation == "sqlserver-object-schema-scope" + && reason.contains("cannot rewrite object definitions across schemas") + )); + + assert!(sqlserver_object_scope_requirement( + "sqlserver", + "sqlserver", + None, + None, + &[SchemaObjectSnapshot::view( + Some("dbo"), + "v", + "SELECT 1 AS value", + )], + &[SchemaObjectSnapshot::view( + Some("dbo"), + "v", + "SELECT 2 AS value", + )], + ) + .is_none()); + } + fn test_profile() -> SchemaDiffProfile { let now = chrono::Utc::now(); SchemaDiffProfile { From e6d6f1b67c5b2270094af393530c24ff3126d2af Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 02:06:22 +0800 Subject: [PATCH 014/666] fix(sync): manage SQL Server identity insert lifecycle --- packages/driver-api/src/reuse.rs | 15 +- packages/driver-api/src/traits.rs | 29 +- packages/drivers/sqlserver/src/sqlserver.rs | 60 +- src-tauri/src/commands/sync/apply.rs | 181 ++++- src-tauri/src/commands/sync/exec.rs | 30 +- src-tauri/src/commands/sync/tests.rs | 2 + src-tauri/src/data_sync/execute.rs | 631 ++++++++++++++++-- src-tauri/src/data_sync/mod.rs | 2 +- src-tauri/src/data_sync/sql.rs | 15 + .../src/data_transfer/execute/dispatcher.rs | 8 +- .../src/data_transfer/execution_tests.rs | 26 +- 11 files changed, 881 insertions(+), 118 deletions(-) diff --git a/packages/driver-api/src/reuse.rs b/packages/driver-api/src/reuse.rs index 800d9da36..07b1f98df 100644 --- a/packages/driver-api/src/reuse.rs +++ b/packages/driver-api/src/reuse.rs @@ -60,9 +60,9 @@ impl DatabaseDriver for ReuseDriver { self.inner.transfer_explicit_identity_insert_clause() } - fn transfer_explicit_identity_insert_requires_session_toggle(&self) -> bool { + fn explicit_identity_insert_requires_session_toggle(&self) -> bool { self.inner - .transfer_explicit_identity_insert_requires_session_toggle() + .explicit_identity_insert_requires_session_toggle() } async fn advance_transfer_identity_sequences( @@ -77,7 +77,7 @@ impl DatabaseDriver for ReuseDriver { .await } - async fn set_transfer_identity_insert( + async fn set_identity_insert( &self, handle: &ConnectionHandle, database: &str, @@ -86,15 +86,12 @@ impl DatabaseDriver for ReuseDriver { enabled: bool, ) -> Result<(), DriverError> { self.inner - .set_transfer_identity_insert(handle, database, schema, table, enabled) + .set_identity_insert(handle, database, schema, table, enabled) .await } - async fn discard_transfer_connection( - &self, - handle: &ConnectionHandle, - ) -> Result<(), DriverError> { - self.inner.discard_transfer_connection(handle).await + async fn discard_connection(&self, handle: &ConnectionHandle) -> Result<(), DriverError> { + self.inner.discard_connection(handle).await } fn transfer_sql_file_insert_batch_size(&self) -> usize { diff --git a/packages/driver-api/src/traits.rs b/packages/driver-api/src/traits.rs index ab4f204c9..0571e7fc3 100644 --- a/packages/driver-api/src/traits.rs +++ b/packages/driver-api/src/traits.rs @@ -623,16 +623,18 @@ pub trait DatabaseDriver: Send + Sync { Ok(()) } - /// Whether Data Transfer must bracket explicit identity values with a - /// session-scoped mode on this target connection. - fn transfer_explicit_identity_insert_requires_session_toggle(&self) -> bool { + /// Whether explicit identity inserts require a session-scoped mode on + /// this connection. Shared migration paths must turn this mode off before + /// committing, rolling back, switching target tables, or returning the + /// session to general use. + fn explicit_identity_insert_requires_session_toggle(&self) -> bool { false } - /// Enable or disable explicit identity insertion for one transfer table. - /// The caller keeps the toggle within the table transaction and attempts - /// cleanup on every success, error, and cancellation path. - async fn set_transfer_identity_insert( + /// Enable or disable explicit identity insertion for one target table. + /// The caller attempts cleanup on every success, error, and cancellation + /// path because the state may survive transaction rollback. + async fn set_identity_insert( &self, _handle: &ConnectionHandle, _database: &str, @@ -645,15 +647,12 @@ pub trait DatabaseDriver: Send + Sync { )) } - /// Drop a target session whose transfer-scoped state could not be reset. - /// Drivers that require session toggles must implement this; the default - /// fails closed so the host never reports that an un-dropped session is safe. - async fn discard_transfer_connection( - &self, - _handle: &ConnectionHandle, - ) -> Result<(), DriverError> { + /// Drop a connection whose session-scoped identity mode could not be + /// reset. The default fails closed so the host never reports that an + /// un-dropped session is safe. + async fn discard_connection(&self, _handle: &ConnectionHandle) -> Result<(), DriverError> { Err(DriverError::Unsupported( - "driver cannot discard a transfer connection after session cleanup failed".into(), + "driver cannot discard this connection after session cleanup failed".into(), )) } diff --git a/packages/drivers/sqlserver/src/sqlserver.rs b/packages/drivers/sqlserver/src/sqlserver.rs index 7ca71be9e..661207f08 100644 --- a/packages/drivers/sqlserver/src/sqlserver.rs +++ b/packages/drivers/sqlserver/src/sqlserver.rs @@ -61,14 +61,13 @@ impl SqlServerDriver { fn quote_identifier(identifier: &str) -> Result { if identifier.is_empty() || identifier.contains('\0') { return Err(DriverError::InvalidConfig( - "SQL Server transfer relation identifiers must be non-empty and contain no NUL" - .into(), + "SQL Server relation identifiers must be non-empty and contain no NUL".into(), )); } Ok(format!("[{}]", identifier.replace(']', "]]"))) } - fn transfer_identity_relation( + fn identity_insert_relation( database: &str, schema: Option<&str>, table: &str, @@ -86,6 +85,17 @@ impl SqlServerDriver { Ok(parts.join(".")) } + fn identity_insert_statement( + database: &str, + schema: Option<&str>, + table: &str, + enabled: bool, + ) -> Result { + let relation = Self::identity_insert_relation(database, schema, table)?; + let mode = if enabled { "ON" } else { "OFF" }; + Ok(format!("SET IDENTITY_INSERT {relation} {mode}")) + } + /// Accept only a relation rendered by Data Transfer's SQL Server /// identifier quoter. This prevents a caller from smuggling SQL into the /// session toggle or the OBJECT_ID condition. @@ -1442,7 +1452,7 @@ impl DatabaseDriver for SqlServerDriver { 2100 } - fn transfer_explicit_identity_insert_requires_session_toggle(&self) -> bool { + fn explicit_identity_insert_requires_session_toggle(&self) -> bool { true } @@ -1454,7 +1464,7 @@ impl DatabaseDriver for SqlServerDriver { "COMMIT TRANSACTION;" } - async fn set_transfer_identity_insert( + async fn set_identity_insert( &self, handle: &ConnectionHandle, database: &str, @@ -1462,20 +1472,17 @@ impl DatabaseDriver for SqlServerDriver { table: &str, enabled: bool, ) -> Result<(), DriverError> { - let relation = Self::transfer_identity_relation(database, schema, table)?; - let mode = if enabled { "ON" } else { "OFF" }; - let statement = format!("SET IDENTITY_INSERT {relation} {mode}"); + let statement = Self::identity_insert_statement(database, schema, table, enabled)?; let mut clients = self.clients.write().await; let client = clients .get_mut(&handle.pool_id) .ok_or_else(|| DriverError::ConnectionFailed("Connection pool not found".into()))?; + // IDENTITY_INSERT is scoped to the physical SQL Server session, so + // send it as a standalone batch on this mapped client. Self::execute_batch(client, &statement).await } - async fn discard_transfer_connection( - &self, - handle: &ConnectionHandle, - ) -> Result<(), DriverError> { + async fn discard_connection(&self, handle: &ConnectionHandle) -> Result<(), DriverError> { self.transactions.lock().await.remove(&handle.id); self.clients.write().await.remove(&handle.pool_id); Ok(()) @@ -2296,9 +2303,9 @@ mod tests { } #[test] - fn transfer_identity_insert_relation_quotes_every_sql_server_identifier() { + fn identity_insert_relation_quotes_every_sql_server_identifier() { assert_eq!( - SqlServerDriver::transfer_identity_relation( + SqlServerDriver::identity_insert_relation( "data]zen", Some("odd.schema"), "order]details" @@ -2307,10 +2314,10 @@ mod tests { "[data]]zen].[odd.schema].[order]]details]" ); assert_eq!( - SqlServerDriver::transfer_identity_relation("", None, "items").unwrap(), + SqlServerDriver::identity_insert_relation("", None, "items").unwrap(), "[dbo].[items]" ); - assert!(SqlServerDriver::transfer_identity_relation("db", Some(""), "").is_err()); + assert!(SqlServerDriver::identity_insert_relation("db", Some(""), "").is_err()); } #[test] @@ -2414,9 +2421,18 @@ mod tests { } #[test] - fn explicit_transfer_identity_insert_uses_a_session_toggle() { + fn explicit_identity_insert_uses_a_quoted_session_toggle_statement() { let driver = SqlServerDriver::new(); - assert!(driver.transfer_explicit_identity_insert_requires_session_toggle()); + assert!(driver.explicit_identity_insert_requires_session_toggle()); + assert_eq!( + SqlServerDriver::identity_insert_statement("db]name", Some("dbo"), "t]able", true) + .unwrap(), + "SET IDENTITY_INSERT [db]]name].[dbo].[t]]able] ON" + ); + assert_eq!( + SqlServerDriver::identity_insert_statement("db", None, "items", false).unwrap(), + "SET IDENTITY_INSERT [db].[dbo].[items] OFF" + ); assert_eq!( driver.transfer_sql_file_begin_transaction(), "BEGIN TRANSACTION;" @@ -2428,10 +2444,10 @@ mod tests { } #[tokio::test] - async fn reuse_driver_forwards_transfer_identity_and_sql_file_hooks() { + async fn reuse_driver_forwards_identity_session_and_sql_file_hooks() { let inner: Arc = Arc::new(SqlServerDriver::new()); let driver = ReuseDriver::new(inner, "sqlserver-alias"); - assert!(driver.transfer_explicit_identity_insert_requires_session_toggle()); + assert!(driver.explicit_identity_insert_requires_session_toggle()); let script = driver .render_transfer_sql_file_identity_insert( "INSERT INTO [dbo].[items] ([id]) VALUES (1)", @@ -2454,10 +2470,10 @@ mod tests { pool_id: "missing".into(), }; let error = driver - .set_transfer_identity_insert(&handle, "db", Some("dbo"), "items", true) + .set_identity_insert(&handle, "db", Some("dbo"), "items", true) .await .unwrap_err(); assert!(matches!(error, DriverError::ConnectionFailed(_))); - driver.discard_transfer_connection(&handle).await.unwrap(); + driver.discard_connection(&handle).await.unwrap(); } } diff --git a/src-tauri/src/commands/sync/apply.rs b/src-tauri/src/commands/sync/apply.rs index cdd2a6640..3f2746167 100644 --- a/src-tauri/src/commands/sync/apply.rs +++ b/src-tauri/src/commands/sync/apply.rs @@ -541,6 +541,71 @@ fn sqlserver_write_preflight( Ok(()) } +fn identity_insert_target_for_projection( + family: &str, + schema: &datazen_driver_api::TableSchema, + projection_columns: &[String], + database: &str, + target_schema: Option<&str>, + table: &str, + supports_explicit_values: bool, + requires_session_toggle: bool, +) -> Option { + if !family.eq_ignore_ascii_case("sqlserver") + || !supports_explicit_values + || !requires_session_toggle + { + return None; + } + let has_projected_identity = schema.columns.iter().any(|column| { + column.is_auto_increment && projection_columns.iter().any(|name| name == &column.name) + }); + has_projected_identity.then(|| crate::data_sync::IdentityInsertTarget { + database: database.to_string(), + schema: target_schema.map(str::to_string), + table: table.to_string(), + }) +} + +fn validate_projected_identity_values( + schema: &datazen_driver_api::TableSchema, + projection_columns: &[String], + changes: &TableChangeSet, +) -> Result<(), String> { + let identities = schema + .columns + .iter() + .filter(|column| { + column.is_auto_increment && projection_columns.iter().any(|name| name == &column.name) + }) + .filter_map(|column| { + projection_columns + .iter() + .position(|name| name == &column.name) + .map(|index| (column.name.as_str(), index)) + }) + .collect::>(); + for (column, index) in identities { + for change in changes + .changes + .iter() + .filter(|change| change.operation == ChangeOperation::Insert) + { + let value = change + .source_row + .as_ref() + .and_then(|row| row.get(index)) + .and_then(Option::as_ref); + if value.is_none() || matches!(value, Some(datazen_driver_api::Value::Null)) { + return Err(format!( + "SQL Server Data Sync cannot safely insert selected rows because identity column '{column}' has a missing or NULL source value" + )); + } + } + } + Ok(()) +} + pub(crate) async fn generate_data_sync_sql_impl( state: &AppState, target_db_session_id: String, @@ -621,6 +686,10 @@ pub(crate) async fn generate_data_sync_sql_impl( CommandError::Validation("comparison projection missing; compare again".into()) })?; let column_names = &projection.columns; + if family == "sqlserver" && preview_target.supports_explicit_identity_values() { + validate_projected_identity_values(&schema, column_names, table) + .map_err(CommandError::Validation)?; + } let column_types = resolve_projection_types(projection, &schema, &family)?; let pk = &projection.primary_keys; let preview_types = schema @@ -663,7 +732,30 @@ pub(crate) async fn generate_data_sync_sql_impl( preview_literal, ) .map_err(CommandError::from)?; - statements.extend(stmts); + let has_insert = table + .changes + .iter() + .any(|change| change.operation == ChangeOperation::Insert); + let identity_insert_target = has_insert + .then(|| { + identity_insert_target_for_projection( + &family, + &schema, + column_names, + &target_database_name, + target_schema.as_deref(), + &table.target_table, + preview_target.supports_explicit_identity_values(), + tgt_driver.explicit_identity_insert_requires_session_toggle(), + ) + }) + .flatten(); + statements.extend(stmts.into_iter().map(|mut statement| { + if statement.operation == ChangeOperation::Insert { + statement.identity_insert = identity_insert_target.clone(); + } + statement + })); } Ok(statements) } @@ -743,7 +835,10 @@ pub(crate) async fn revalidate_data_sync_impl( #[cfg(test)] mod tests { - use super::sqlserver_write_preflight; + use super::{ + identity_insert_target_for_projection, sqlserver_write_preflight, + validate_projected_identity_values, + }; use crate::commands::sync::types::{resolve_options, SyncOptionsInput}; use crate::data_sync::{ChangeOperation, ConflictPolicy, RowChange, TableChangeSet}; use datazen_driver_api::{ColumnSchema, TableSchema, Value}; @@ -771,6 +866,88 @@ mod tests { assert!(error.contains("IDENTITY_INSERT"), "{error}"); } + #[test] + fn identity_insert_runtime_target_requires_projected_identity_and_session_toggle() { + let schema = test_sqlserver_schema(true); + let projection = vec!["id".to_string(), "label".to_string()]; + let target = identity_insert_target_for_projection( + "sqlserver", + &schema, + &projection, + "target_db", + Some("dbo"), + "target", + true, + true, + ) + .expect("projected identity insert must carry its runtime session target"); + assert_eq!(target.database, "target_db"); + assert_eq!(target.schema.as_deref(), Some("dbo")); + assert_eq!(target.table, "target"); + + assert!(identity_insert_target_for_projection( + "sqlserver", + &schema, + &["label".into()], + "target_db", + Some("dbo"), + "target", + true, + true, + ) + .is_none()); + assert!(identity_insert_target_for_projection( + "sqlserver", + &schema, + &projection, + "target_db", + Some("dbo"), + "target", + true, + false, + ) + .is_none()); + } + + #[test] + fn explicit_identity_insert_rejects_missing_or_null_source_identity_values() { + let schema = test_sqlserver_schema(true); + let projection = vec!["id".to_string(), "label".to_string()]; + let changes = |id: Option| TableChangeSet { + source_table: "source".into(), + target_table: "target".into(), + changes: vec![RowChange { + operation: ChangeOperation::Insert, + key: vec![Value::Integer(1)], + source_row: Some(vec![id, Some(Value::String("x".into()))]), + target_row: None, + changed_columns: Vec::new(), + selected: true, + }], + }; + assert!( + validate_projected_identity_values(&schema, &projection, &changes(None)) + .unwrap_err() + .contains("missing or NULL") + ); + assert!(validate_projected_identity_values( + &schema, + &projection, + &changes(Some(Value::Null)) + ) + .unwrap_err() + .contains("missing or NULL")); + assert!(validate_projected_identity_values( + &schema, + &projection, + &changes(Some(Value::Integer(1))) + ) + .is_ok()); + assert!( + validate_projected_identity_values(&schema, &["label".into()], &changes(None)).is_ok() + ); + } + #[test] fn sqlserver_write_preflight_rejects_unverifiable_string_conditions() { let schema = test_sqlserver_schema(false); diff --git a/src-tauri/src/commands/sync/exec.rs b/src-tauri/src/commands/sync/exec.rs index c34e16b07..7562eb52e 100644 --- a/src-tauri/src/commands/sync/exec.rs +++ b/src-tauri/src/commands/sync/exec.rs @@ -8,8 +8,8 @@ use super::plans::{self, SelectionMatcher, StoredSyncPlan, SyncRunRequest, SyncR #[cfg(test)] use crate::data_sync::execute_statements; use crate::data_sync::{ - execute_statement_batches_with_policy, DataSyncError, ExecutionResult, StatementBatchSource, - StatementExecutor, SyncOptions, + execute_statement_batches_with_policy, DataSyncError, ExecutionResult, IdentityInsertTarget, + StatementBatchSource, StatementExecutor, SyncOptions, }; use crate::db::{ConnectionHandle, DatabaseDriver, TransactionHandle, Value}; use async_trait::async_trait; @@ -68,6 +68,30 @@ impl StatementExecutor for LiveExecutor { .map_err(|e| crate::data_sync::DataSyncError::validation(e.to_string())) } + async fn set_identity_insert( + &mut self, + target: &IdentityInsertTarget, + enabled: bool, + ) -> Result<(), crate::data_sync::DataSyncError> { + self.driver + .set_identity_insert( + &self.handle, + &target.database, + target.schema.as_deref(), + &target.table, + enabled, + ) + .await + .map_err(|error| DataSyncError::validation(error.to_string())) + } + + async fn discard_connection(&mut self) -> Result<(), crate::data_sync::DataSyncError> { + self.driver + .discard_connection(&self.handle) + .await + .map_err(|error| DataSyncError::validation(error.to_string())) + } + async fn commit(&mut self) -> Result<(), crate::data_sync::DataSyncError> { if let Some(tx) = self.tx.take() { #[cfg(feature = "webdriver")] @@ -729,6 +753,7 @@ mod tests { preview_sql: "INSERT INTO users VALUES (?)".into(), parameters: vec![Value::String("x".repeat(SQL_PREVIEW_IPC_MAX_BYTES + 1))], row_key: vec![Value::Integer(1)], + identity_insert: None, }; let mut statements = Vec::new(); let mut response_bytes = 2; @@ -750,6 +775,7 @@ mod tests { preview_sql: "INSERT INTO users VALUES (?)".into(), parameters: vec![Value::String("first".into())], row_key: vec![Value::Integer(1)], + identity_insert: None, }; let second = crate::data_sync::SqlStatement { parameters: vec![Value::String("second".into())], diff --git a/src-tauri/src/commands/sync/tests.rs b/src-tauri/src/commands/sync/tests.rs index 1f5efc76f..5179baa25 100644 --- a/src-tauri/src/commands/sync/tests.rs +++ b/src-tauri/src/commands/sync/tests.rs @@ -603,6 +603,7 @@ async fn execute_data_sync_rejects_read_only_target() { preview_sql: "INSERT INTO t VALUES (1)".into(), parameters: vec![], row_key: vec![], + identity_insert: None, }; let err = super::execute_data_sync_impl(&test.state, id, vec![stmt], None, None) .await @@ -806,6 +807,7 @@ async fn cancel_data_sync_stops_execute_before_start() { preview_sql: "INSERT INTO t VALUES (1)".into(), parameters: vec![], row_key: vec![], + identity_insert: None, }; let error = super::execute_data_sync_impl(&test.state, id, vec![stmt], Some(job), None) .await diff --git a/src-tauri/src/data_sync/execute.rs b/src-tauri/src/data_sync/execute.rs index 27eb93fbb..5ff8feffd 100644 --- a/src-tauri/src/data_sync/execute.rs +++ b/src-tauri/src/data_sync/execute.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use super::error::DataSyncError; use super::model::{ChangeOperation, ConflictPolicy}; -use super::sql::SqlStatement; +use super::sql::{IdentityInsertTarget, SqlStatement}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -124,10 +124,113 @@ pub trait StatementExecutor: Send { fn is_read_only(&self) -> bool; async fn begin(&mut self) -> Result<(), DataSyncError>; async fn execute(&mut self, sql: &str, params: &[Value]) -> Result; + async fn set_identity_insert( + &mut self, + _target: &IdentityInsertTarget, + _enabled: bool, + ) -> Result<(), DataSyncError> { + Err(DataSyncError::validation( + "executor does not support session-scoped identity insertion", + )) + } + async fn discard_connection(&mut self) -> Result<(), DataSyncError> { + Err(DataSyncError::validation( + "executor cannot discard a connection after identity cleanup failed", + )) + } async fn commit(&mut self) -> Result<(), DataSyncError>; async fn rollback(&mut self) -> Result<(), DataSyncError>; } +#[derive(Default)] +struct IdentityInsertState { + /// A toggle may be on after an ON attempt, even when the driver returned + /// an error. Keep this until OFF succeeds or the connection is discarded. + active: Option, + cleanup_error: Option, +} + +impl IdentityInsertState { + async fn set_for_statement( + &mut self, + executor: &mut dyn StatementExecutor, + target: Option<&IdentityInsertTarget>, + ) -> Result<(), DataSyncError> { + if self.active.as_ref() == target { + return Ok(()); + } + if let Some(current) = self.active.clone() { + match executor.set_identity_insert(¤t, false).await { + Ok(()) => self.active = None, + Err(error) => { + self.cleanup_error = Some(error.to_string()); + return Err(error); + } + } + } + if let Some(target) = target { + // ON may have taken effect even if its reply is lost. Record the + // target before awaiting so every failure path attempts OFF. + self.active = Some(target.clone()); + executor.set_identity_insert(target, true).await?; + } + Ok(()) + } + + async fn rollback( + &mut self, + executor: &mut dyn StatementExecutor, + reason: &str, + ) -> Result { + if self.active.is_some() && self.cleanup_error.is_none() { + if let Some(target) = self.active.clone() { + if let Err(error) = executor.set_identity_insert(&target, false).await { + self.cleanup_error = Some(error.to_string()); + } else { + self.active = None; + } + } + } + + let rollback_result = executor.rollback().await; + let discard_result = if self.cleanup_error.is_some() { + Some(executor.discard_connection().await) + } else { + None + }; + + if let Err(error) = rollback_result { + let cleanup = self + .cleanup_error + .as_deref() + .map(|message| format!("; identity insert cleanup failed: {message}")) + .unwrap_or_default(); + let discard = discard_result + .as_ref() + .and_then(|result| result.as_ref().err()) + .map(|error| format!("; connection discard failed: {error}")) + .unwrap_or_default(); + return Err(DataSyncError::outcome_unknown(format!( + "{reason}{cleanup}; rollback failed, outcome UNKNOWN: {error}{discard}" + ))); + } + + if let Some(Err(error)) = discard_result { + return Err(DataSyncError::outcome_unknown(format!( + "{reason}; identity insert cleanup failed: {}; connection discard failed, session state UNKNOWN: {error}", + self.cleanup_error.as_deref().unwrap_or("unknown error") + ))); + } + + Ok(match self.cleanup_error.as_deref() { + Some(error) => format!( + "{reason}; identity insert cleanup failed: {error}; target connection discarded after rollback" + ), + None => reason.to_string(), + }) + } +} + /// Produces one bounded group of statements at a time for large sync plans. /// A source error after execution has started is handled like a statement /// failure: the active target transaction is rolled back. @@ -175,17 +278,32 @@ pub async fn execute_statements_with_policy( let mut affected_rows = 0u64; let mut skipped = 0usize; let mut conflicts = Vec::new(); + let mut identity_insert = IdentityInsertState::default(); for stmt in statements { if cancelled.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) { - executor.rollback().await.map_err(|error| { - DataSyncError::outcome_unknown(format!( - "execute cancelled; rollback failed, outcome UNKNOWN: {error}" - )) - })?; + let reason = identity_insert + .rollback(executor, "execute cancelled; all changes were rolled back") + .await?; + return Ok(ExecutionResult { + applied, + rolled_back: true, + rollback_reason: Some(reason), + affected_rows, + skipped: 0, + conflicts: Vec::new(), + }); + } + if let Err(error) = identity_insert + .set_for_statement(executor, stmt.identity_insert.as_ref()) + .await + { + let reason = identity_insert + .rollback(executor, &format!("identity insert setup failed: {error}")) + .await?; return Ok(ExecutionResult { applied, rolled_back: true, - rollback_reason: Some("execute cancelled; all changes were rolled back".into()), + rollback_reason: Some(reason), affected_rows, skipped: 0, conflicts: Vec::new(), @@ -216,15 +334,11 @@ pub async fn execute_statements_with_policy( }); continue; } - executor.rollback().await.map_err(|error| { - DataSyncError::outcome_unknown(format!( - "{message}; rollback failed, outcome UNKNOWN: {error}" - )) - })?; + let reason = identity_insert.rollback(executor, &message).await?; return Ok(ExecutionResult { applied, rolled_back: true, - rollback_reason: Some(message.clone()), + rollback_reason: Some(reason), affected_rows, skipped: 0, conflicts: vec![SyncConflict { @@ -240,11 +354,7 @@ pub async fn execute_statements_with_policy( } Err(err) => { let reason = format!("execution failed after {applied} statements: {err}"); - executor.rollback().await.map_err(|rollback_error| { - DataSyncError::outcome_unknown(format!( - "{reason}; rollback failed, outcome UNKNOWN: {rollback_error}" - )) - })?; + let reason = identity_insert.rollback(executor, &reason).await?; return Ok(ExecutionResult { applied, rolled_back: true, @@ -256,6 +366,35 @@ pub async fn execute_statements_with_policy( } } } + if cancelled.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) { + let reason = identity_insert + .rollback(executor, "execute cancelled; all changes were rolled back") + .await?; + return Ok(ExecutionResult { + applied, + rolled_back: true, + rollback_reason: Some(reason), + affected_rows, + skipped: 0, + conflicts: Vec::new(), + }); + } + if let Err(error) = identity_insert.set_for_statement(executor, None).await { + let reason = identity_insert + .rollback( + executor, + &format!("cannot disable identity insert before commit: {error}"), + ) + .await?; + return Ok(ExecutionResult { + applied, + rolled_back: true, + rollback_reason: Some(reason), + affected_rows, + skipped: 0, + conflicts: Vec::new(), + }); + } executor.commit().await.map_err(|error| { DataSyncError::outcome_unknown(format!( "commit result could not be confirmed; outcome UNKNOWN: {error}" @@ -310,6 +449,7 @@ pub async fn execute_statement_batches_with_policy( let mut affected_rows = 0u64; let mut skipped = 0usize; let mut conflicts = Vec::new(); + let mut identity_insert = IdentityInsertState::default(); let mut batch = Some(first_batch); loop { let statements = match batch.take() { @@ -321,11 +461,7 @@ pub async fn execute_statement_batches_with_policy( Err(error) => { let reason = format!("statement generation failed after {applied} statements: {error}"); - executor.rollback().await.map_err(|rollback_error| { - DataSyncError::outcome_unknown(format!( - "{reason}; rollback failed, outcome UNKNOWN: {rollback_error}" - )) - })?; + let reason = identity_insert.rollback(executor, &reason).await?; return Ok(ExecutionResult { applied, rolled_back: true, @@ -340,15 +476,29 @@ pub async fn execute_statement_batches_with_policy( for stmt in statements { if cancelled.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) { - executor.rollback().await.map_err(|error| { - DataSyncError::outcome_unknown(format!( - "execute cancelled; rollback failed, outcome UNKNOWN: {error}" - )) - })?; + let reason = identity_insert + .rollback(executor, "execute cancelled; all changes were rolled back") + .await?; return Ok(ExecutionResult { applied, rolled_back: true, - rollback_reason: Some("execute cancelled; all changes were rolled back".into()), + rollback_reason: Some(reason), + affected_rows, + skipped: 0, + conflicts: Vec::new(), + }); + } + if let Err(error) = identity_insert + .set_for_statement(executor, stmt.identity_insert.as_ref()) + .await + { + let reason = identity_insert + .rollback(executor, &format!("identity insert setup failed: {error}")) + .await?; + return Ok(ExecutionResult { + applied, + rolled_back: true, + rollback_reason: Some(reason), affected_rows, skipped: 0, conflicts: Vec::new(), @@ -379,15 +529,11 @@ pub async fn execute_statement_batches_with_policy( }); continue; } - executor.rollback().await.map_err(|error| { - DataSyncError::outcome_unknown(format!( - "{message}; rollback failed, outcome UNKNOWN: {error}" - )) - })?; + let reason = identity_insert.rollback(executor, &message).await?; return Ok(ExecutionResult { applied, rolled_back: true, - rollback_reason: Some(message.clone()), + rollback_reason: Some(reason), affected_rows, skipped: 0, conflicts: vec![SyncConflict { @@ -403,11 +549,7 @@ pub async fn execute_statement_batches_with_policy( } Err(error) => { let reason = format!("execution failed after {applied} statements: {error}"); - executor.rollback().await.map_err(|rollback_error| { - DataSyncError::outcome_unknown(format!( - "{reason}; rollback failed, outcome UNKNOWN: {rollback_error}" - )) - })?; + let reason = identity_insert.rollback(executor, &reason).await?; return Ok(ExecutionResult { applied, rolled_back: true, @@ -421,6 +563,37 @@ pub async fn execute_statement_batches_with_policy( } } + if let Err(error) = identity_insert.set_for_statement(executor, None).await { + let reason = identity_insert + .rollback( + executor, + &format!("cannot disable identity insert before commit: {error}"), + ) + .await?; + return Ok(ExecutionResult { + applied, + rolled_back: true, + rollback_reason: Some(reason), + affected_rows, + skipped: 0, + conflicts: Vec::new(), + }); + } + + if cancelled.as_ref().is_some_and(|c| c.load(Ordering::SeqCst)) { + let reason = identity_insert + .rollback(executor, "execute cancelled; all changes were rolled back") + .await?; + return Ok(ExecutionResult { + applied, + rolled_back: true, + rollback_reason: Some(reason), + affected_rows, + skipped: 0, + conflicts: Vec::new(), + }); + } + executor.commit().await.map_err(|error| { DataSyncError::outcome_unknown(format!( "commit result could not be confirmed; outcome UNKNOWN: {error}" @@ -440,6 +613,10 @@ pub async fn execute_statement_batches_with_policy( pub struct RecordingExecutor { pub read_only: bool, pub fail_at: Option, + pub zero_at: Option, + pub fail_identity_toggle: Option<(String, bool)>, + pub fail_discard: bool, + pub cancel_after_execute: Option>, pub calls: Vec, pub begun: bool, } @@ -458,18 +635,55 @@ impl StatementExecutor for RecordingExecutor { async fn execute(&mut self, sql: &str, params: &[Value]) -> Result { self.calls.push(format!("execute:{}:{}", params.len(), sql)); - if self.fail_at - == Some( - self.calls - .iter() - .filter(|c| c.starts_with("execute:")) - .count() - - 1, - ) - { + let execute_index = self + .calls + .iter() + .filter(|call| call.starts_with("execute:")) + .count() + - 1; + if self.fail_at == Some(execute_index) { return Err(DataSyncError::validation("injected failure")); } - Ok(1) + if let Some(cancelled) = &self.cancel_after_execute { + cancelled.store(true, Ordering::SeqCst); + } + Ok(if self.zero_at == Some(execute_index) { + 0 + } else { + 1 + }) + } + + async fn set_identity_insert( + &mut self, + target: &IdentityInsertTarget, + enabled: bool, + ) -> Result<(), DataSyncError> { + self.calls.push(format!( + "identity:{}:{}:{}:{}", + if enabled { "on" } else { "off" }, + target.database, + target.schema.as_deref().unwrap_or_default(), + target.table + )); + if self + .fail_identity_toggle + .as_ref() + .is_some_and(|(table, toggle)| table == &target.table && *toggle == enabled) + { + return Err(DataSyncError::validation( + "injected identity toggle failure", + )); + } + Ok(()) + } + + async fn discard_connection(&mut self) -> Result<(), DataSyncError> { + self.calls.push("discard".into()); + if self.fail_discard { + return Err(DataSyncError::validation("injected discard failure")); + } + Ok(()) } async fn commit(&mut self) -> Result<(), DataSyncError> { @@ -518,9 +732,29 @@ mod tests { preview_sql: sql.into(), parameters: vec![Value::Integer(1)], row_key: vec![Value::Integer(1)], + identity_insert: None, } } + fn identity_stmt(sql: &str, table: &str) -> SqlStatement { + let mut statement = stmt(sql); + statement.identity_insert = Some(IdentityInsertTarget { + database: "db".into(), + schema: Some("dbo".into()), + table: table.into(), + }); + statement + } + + #[test] + fn identity_insert_target_is_not_serialized_into_sql_preview_payloads() { + let statement = identity_stmt("INSERT", "users"); + let json = serde_json::to_value(&statement).unwrap(); + assert!(json.get("identityInsert").is_none()); + let decoded: SqlStatement = serde_json::from_value(json).unwrap(); + assert!(decoded.identity_insert.is_none()); + } + #[tokio::test] async fn test_tester_stream_rejects_empty_first_batch_before_opening_a_transaction() { let mut exec = RecordingExecutor::default(); @@ -670,6 +904,26 @@ mod tests { ); } + #[tokio::test] + async fn identity_insert_is_cleaned_for_non_batched_execution() { + let mut exec = RecordingExecutor::default(); + let result = execute_statements(&[identity_stmt("INSERT", "users")], &mut exec, None) + .await + .unwrap(); + + assert!(!result.rolled_back); + assert_eq!( + exec.calls, + vec![ + "begin", + "identity:on:db:dbo:users", + "execute:1:INSERT", + "identity:off:db:dbo:users", + "commit" + ] + ); + } + #[tokio::test] async fn commit_response_loss_is_unknown_whether_commit_reached_the_server_or_not() { struct CommitResponseLostExecutor { @@ -1102,6 +1356,283 @@ mod tests { ); } + #[tokio::test] + async fn identity_insert_stays_enabled_across_pages_and_closes_before_commit() { + let mut exec = RecordingExecutor::default(); + let mut source = batch_source(vec![vec![identity_stmt("page-2", "users")]]); + let result = execute_statement_batches_with_policy( + vec![identity_stmt("page-1", "users")], + &mut source, + &mut exec, + None, + ConflictPolicy::Abort, + ) + .await + .unwrap(); + + assert!(!result.rolled_back); + assert_eq!( + exec.calls, + vec![ + "begin", + "identity:on:db:dbo:users", + "execute:1:page-1", + "execute:1:page-2", + "identity:off:db:dbo:users", + "commit" + ] + ); + } + + #[tokio::test] + async fn identity_insert_switches_tables_only_after_turning_the_previous_table_off() { + let mut exec = RecordingExecutor::default(); + let mut source = batch_source(vec![vec![identity_stmt("second", "orders")]]); + let result = execute_statement_batches_with_policy( + vec![identity_stmt("first", "users")], + &mut source, + &mut exec, + None, + ConflictPolicy::Abort, + ) + .await + .unwrap(); + + assert!(!result.rolled_back); + assert_eq!( + exec.calls, + vec![ + "begin", + "identity:on:db:dbo:users", + "execute:1:first", + "identity:off:db:dbo:users", + "identity:on:db:dbo:orders", + "execute:1:second", + "identity:off:db:dbo:orders", + "commit" + ] + ); + } + + #[tokio::test] + async fn identity_on_failure_attempts_off_then_rolls_back_without_dml() { + let mut exec = RecordingExecutor { + fail_identity_toggle: Some(("users".into(), true)), + ..RecordingExecutor::default() + }; + let mut source = batch_source(Vec::>::new()); + let result = execute_statement_batches_with_policy( + vec![identity_stmt("must-not-run", "users")], + &mut source, + &mut exec, + None, + ConflictPolicy::Abort, + ) + .await + .unwrap(); + + assert!(result.rolled_back); + assert_eq!( + exec.calls, + vec![ + "begin", + "identity:on:db:dbo:users", + "identity:off:db:dbo:users", + "rollback" + ] + ); + } + + #[tokio::test] + async fn identity_off_failure_rolls_back_then_discards_the_connection() { + let mut exec = RecordingExecutor { + fail_identity_toggle: Some(("users".into(), false)), + ..RecordingExecutor::default() + }; + let mut source = batch_source(Vec::>::new()); + let result = execute_statement_batches_with_policy( + vec![identity_stmt("insert", "users")], + &mut source, + &mut exec, + None, + ConflictPolicy::Abort, + ) + .await + .unwrap(); + + assert!(result.rolled_back); + assert!(result.rollback_reason.as_deref().is_some_and(|reason| { + reason.contains("identity insert cleanup failed") + && reason.contains("target connection discarded") + })); + assert_eq!( + exec.calls, + vec![ + "begin", + "identity:on:db:dbo:users", + "execute:1:insert", + "identity:off:db:dbo:users", + "rollback", + "discard" + ] + ); + } + + #[tokio::test] + async fn identity_dml_failure_turns_mode_off_before_rollback() { + let mut exec = RecordingExecutor { + fail_at: Some(0), + ..RecordingExecutor::default() + }; + let mut source = batch_source(Vec::>::new()); + let result = execute_statement_batches_with_policy( + vec![identity_stmt("insert-fails", "users")], + &mut source, + &mut exec, + None, + ConflictPolicy::Abort, + ) + .await + .unwrap(); + + assert!(result.rolled_back); + assert_eq!( + exec.calls, + vec![ + "begin", + "identity:on:db:dbo:users", + "execute:1:insert-fails", + "identity:off:db:dbo:users", + "rollback" + ] + ); + } + + #[tokio::test] + async fn identity_source_failure_turns_mode_off_before_rollback() { + let mut exec = RecordingExecutor::default(); + let mut source = VecBatchSource(VecDeque::from([Err(DataSyncError::validation( + "injected page generation failure", + ))])); + let result = execute_statement_batches_with_policy( + vec![identity_stmt("insert", "users")], + &mut source, + &mut exec, + None, + ConflictPolicy::Abort, + ) + .await + .unwrap(); + + assert!(result.rolled_back); + assert_eq!( + exec.calls, + vec![ + "begin", + "identity:on:db:dbo:users", + "execute:1:insert", + "identity:off:db:dbo:users", + "rollback" + ] + ); + } + + #[tokio::test] + async fn identity_insert_is_cleaned_on_cancellation_after_the_last_page() { + let cancelled = Arc::new(AtomicBool::new(false)); + let mut exec = RecordingExecutor { + cancel_after_execute: Some(cancelled.clone()), + ..RecordingExecutor::default() + }; + let mut source = batch_source(Vec::>::new()); + let result = execute_statement_batches_with_policy( + vec![identity_stmt("insert", "users")], + &mut source, + &mut exec, + Some(cancelled), + ConflictPolicy::Abort, + ) + .await + .unwrap(); + + assert!(result.rolled_back); + assert_eq!( + exec.calls, + vec![ + "begin", + "identity:on:db:dbo:users", + "execute:1:insert", + "identity:off:db:dbo:users", + "rollback" + ] + ); + } + + #[tokio::test] + async fn optimistic_conflict_disables_identity_mode_before_rollback() { + let mut update = stmt("update"); + update.operation = ChangeOperation::Update; + let mut exec = RecordingExecutor { + zero_at: Some(1), + ..RecordingExecutor::default() + }; + let mut source = batch_source(Vec::>::new()); + let result = execute_statement_batches_with_policy( + vec![identity_stmt("insert", "users"), update], + &mut source, + &mut exec, + None, + ConflictPolicy::Abort, + ) + .await + .unwrap(); + + assert!(result.rolled_back); + assert_eq!( + exec.calls, + vec![ + "begin", + "identity:on:db:dbo:users", + "execute:1:insert", + "identity:off:db:dbo:users", + "execute:1:update", + "rollback" + ] + ); + } + + #[tokio::test] + async fn identity_cleanup_failure_and_failed_discard_report_unknown() { + let mut exec = RecordingExecutor { + fail_identity_toggle: Some(("users".into(), false)), + fail_discard: true, + ..RecordingExecutor::default() + }; + let mut source = batch_source(Vec::>::new()); + let error = execute_statement_batches_with_policy( + vec![identity_stmt("insert", "users")], + &mut source, + &mut exec, + None, + ConflictPolicy::Abort, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("session state UNKNOWN")); + assert_eq!( + exec.calls, + vec![ + "begin", + "identity:on:db:dbo:users", + "execute:1:insert", + "identity:off:db:dbo:users", + "rollback", + "discard" + ] + ); + } + #[tokio::test] async fn statement_source_error_after_a_page_rolls_back_prior_writes() { let mut exec = RecordingExecutor::default(); diff --git a/src-tauri/src/data_sync/mod.rs b/src-tauri/src/data_sync/mod.rs index 147037738..2455d4e66 100644 --- a/src-tauri/src/data_sync/mod.rs +++ b/src-tauri/src/data_sync/mod.rs @@ -61,6 +61,6 @@ pub use sql::{ generate_table_sql, generate_table_sql_with_preview_formatter, generate_table_sql_with_preview_formatter_and_policy, generate_table_sql_with_qualified_table_and_policy, mysql_placeholder, postgres_placeholder, - postgres_typed_placeholder, quote_ident_sql, SqlStatement, + postgres_typed_placeholder, quote_ident_sql, IdentityInsertTarget, SqlStatement, }; pub use state::SyncPhase; diff --git a/src-tauri/src/data_sync/sql.rs b/src-tauri/src/data_sync/sql.rs index d1c88a4b9..644c878cd 100644 --- a/src-tauri/src/data_sync/sql.rs +++ b/src-tauri/src/data_sync/sql.rs @@ -7,6 +7,16 @@ use super::changeset::TableChangeSet; use super::error::DataSyncError; use super::model::{ChangeOperation, ConflictPolicy, RowChange}; +/// Runtime-only identity-insert target for a generated SQL Server INSERT. +/// This is deliberately excluded from preview JSON; apply regenerates SQL +/// from the validated plan and attaches the trusted target metadata again. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentityInsertTarget { + pub database: String, + pub schema: Option, + pub table: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SqlStatement { @@ -16,6 +26,8 @@ pub struct SqlStatement { pub preview_sql: String, pub parameters: Vec, pub row_key: Vec, + #[serde(skip)] + pub identity_insert: Option, } pub fn quote_ident_sql(name: &str, quote: char) -> String { @@ -416,6 +428,7 @@ where ), parameters: params, row_key: change.key.clone(), + identity_insert: None, }) } @@ -512,6 +525,7 @@ where ), parameters: params, row_key: change.key.clone(), + identity_insert: None, }) } @@ -578,6 +592,7 @@ where preview_sql: format!("DELETE FROM {qualified_table} WHERE {where_lit}"), parameters: params, row_key: change.key.clone(), + identity_insert: None, }) } diff --git a/src-tauri/src/data_transfer/execute/dispatcher.rs b/src-tauri/src/data_transfer/execute/dispatcher.rs index d58118c15..17d8c3de8 100644 --- a/src-tauri/src/data_transfer/execute/dispatcher.rs +++ b/src-tauri/src/data_transfer/execute/dispatcher.rs @@ -509,7 +509,7 @@ pub(crate) async fn execute_transfer_data_with_resume_checkpoint( .map(|mapping| mapping.target_column.clone()) .collect::>(); let identity_insert_requires_toggle = !explicit_identity_columns.is_empty() - && tgt_driver.transfer_explicit_identity_insert_requires_session_toggle(); + && tgt_driver.explicit_identity_insert_requires_session_toggle(); let tx = match tgt_driver.begin_transaction(tgt_handle).await { Ok(tx) => tx, Err(error) => { @@ -538,7 +538,7 @@ pub(crate) async fn execute_transfer_data_with_resume_checkpoint( // error, so every attempted ON must be followed by an OFF attempt. identity_insert_may_be_enabled = true; if let Err(error) = tgt_driver - .set_transfer_identity_insert( + .set_identity_insert( tgt_handle, &target.database, target.schema.as_deref(), @@ -629,7 +629,7 @@ pub(crate) async fn execute_transfer_data_with_resume_checkpoint( let mut identity_insert_cleanup_failed = false; if identity_insert_may_be_enabled { if let Err(error) = tgt_driver - .set_transfer_identity_insert( + .set_identity_insert( tgt_handle, &target.database, target.schema.as_deref(), @@ -693,7 +693,7 @@ pub(crate) async fn execute_transfer_data_with_resume_checkpoint( } }; if identity_insert_cleanup_failed { - if let Err(error) = tgt_driver.discard_transfer_connection(tgt_handle).await { + if let Err(error) = tgt_driver.discard_connection(tgt_handle).await { table_error = Some(format!( "{}; failed to discard target connection after IDENTITY_INSERT OFF failed: {error}; outcome UNKNOWN", table_error.as_deref().unwrap_or("identity cleanup failed") diff --git a/src-tauri/src/data_transfer/execution_tests.rs b/src-tauri/src/data_transfer/execution_tests.rs index 34d331bf0..a9534661b 100644 --- a/src-tauri/src/data_transfer/execution_tests.rs +++ b/src-tauri/src/data_transfer/execution_tests.rs @@ -25,7 +25,7 @@ struct State { source_queries: Vec<(String, Vec)>, identity_sync_calls: Vec<(Option, String, Vec)>, identity_insert_calls: Vec<(String, Option, String, bool)>, - discard_transfer_connection_calls: usize, + discard_connection_calls: usize, transfer_order: Vec<&'static str>, write_sqls: Vec, } @@ -45,7 +45,7 @@ struct Driver { session_identity_insert: bool, identity_insert_on_error: bool, identity_insert_off_error: bool, - discard_transfer_connection_error: bool, + discard_connection_error: bool, affected_override: Option, include_identity_insert_clause: bool, stream_mode: u8, @@ -241,10 +241,10 @@ impl DatabaseDriver for Driver { } Ok(()) } - fn transfer_explicit_identity_insert_requires_session_toggle(&self) -> bool { + fn explicit_identity_insert_requires_session_toggle(&self) -> bool { self.session_identity_insert } - async fn set_transfer_identity_insert( + async fn set_identity_insert( &self, _: &ConnectionHandle, database: &str, @@ -278,11 +278,11 @@ impl DatabaseDriver for Driver { } Ok(()) } - async fn discard_transfer_connection(&self, _: &ConnectionHandle) -> Result<(), DriverError> { + async fn discard_connection(&self, _: &ConnectionHandle) -> Result<(), DriverError> { let mut state = self.state.lock().unwrap(); - state.discard_transfer_connection_calls += 1; + state.discard_connection_calls += 1; state.transfer_order.push("discard"); - if self.discard_transfer_connection_error { + if self.discard_connection_error { return Err(DriverError::ConnectionFailed( "injected transfer connection discard failure".into(), )); @@ -397,7 +397,7 @@ fn driver(rows: Rows, schema: TableSchema) -> Driver { session_identity_insert: false, identity_insert_on_error: false, identity_insert_off_error: false, - discard_transfer_connection_error: false, + discard_connection_error: false, affected_override: None, include_identity_insert_clause: false, stream_mode: 0, @@ -703,7 +703,7 @@ async fn failed_identity_insert_on_still_attempts_off_then_rolls_back() { let state = target.state.lock().unwrap(); assert_eq!(state.calls, 0); assert_eq!(state.rollback, 1); - assert_eq!(state.discard_transfer_connection_calls, 0); + assert_eq!(state.discard_connection_calls, 0); assert_eq!( state.transfer_order, vec!["identity_on", "identity_off", "rollback"] @@ -733,7 +733,7 @@ async fn failed_identity_insert_off_rolls_back_before_discarding_the_connection( ); let state = target.state.lock().unwrap(); assert_eq!(state.rollback, 1); - assert_eq!(state.discard_transfer_connection_calls, 1); + assert_eq!(state.discard_connection_calls, 1); assert_eq!(state.committed.len(), 0); assert_eq!( state.transfer_order, @@ -755,7 +755,7 @@ async fn failed_identity_insert_connection_discard_marks_outcome_unknown() { let mut target = driver(vec![], target_schema); target.session_identity_insert = true; target.identity_insert_off_error = true; - target.discard_transfer_connection_error = true; + target.discard_connection_error = true; let result = run( &source, &target, @@ -775,7 +775,7 @@ async fn failed_identity_insert_connection_discard_marks_outcome_unknown() { .is_some_and(|error| error.contains("failed to discard target connection"))); let state = target.state.lock().unwrap(); assert_eq!(state.rollback, 1); - assert_eq!(state.discard_transfer_connection_calls, 1); + assert_eq!(state.discard_connection_calls, 1); assert_eq!(state.transfer_order.last(), Some(&"discard")); } @@ -803,7 +803,7 @@ async fn failed_identity_insert_off_still_discards_after_rollback_failure() { ); let state = target.state.lock().unwrap(); assert_eq!(state.rollback, 1); - assert_eq!(state.discard_transfer_connection_calls, 1); + assert_eq!(state.discard_connection_calls, 1); assert_eq!( state.transfer_order, vec![ From 1e9a42d38ead639348e006b1278e96d339692b5c Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 02:13:09 +0800 Subject: [PATCH 015/666] docs: record SQL Server migration support boundaries --- AGENTS.md | 1 + docs/todos/sync-sqlserver.md | 262 +++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 docs/todos/sync-sqlserver.md diff --git a/AGENTS.md b/AGENTS.md index fac152aca..83b3ac519 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -254,6 +254,7 @@ pnpm e2e:contract:matrix # Host 契约 × 驱动矩阵 - `PROTOCOL_VERSION`(`packages/driver-api`)变更时需同步更新所有插件。 - `AI_PROTOCOL_VERSION`(`packages/ai-api`)变更时需同步更新所有 AI Provider 插件。 - AI 配置加密存储在 `ai_config.enc`,不会出现在日志中。 +- **本地环境变量文件保护**:任何 Agent(含子代理)不得打开、读取、解析、source 或打印仓库及其 worktree 中 `.env` / `.env.test` 文件的内容,也不得运行会使这些文件被读取的命令或程序;不得将凭据写入提示、日志或报告。只允许检查文件是否存在及 Git 忽略状态。 - 连接密码等凭据:AES-256-GCM;**主密钥**默认在系统钥匙串,开发/adhoc 或 `DATAZEN_KEYRING=file` 时用 `{appData}/.key`。 - 日志文件位于 `{data_dir}/logs/`。 diff --git a/docs/todos/sync-sqlserver.md b/docs/todos/sync-sqlserver.md new file mode 100644 index 000000000..2ec74a27d --- /dev/null +++ b/docs/todos/sync-sqlserver.md @@ -0,0 +1,262 @@ +# 数据迁移三件套支持 SQL Server:设计与实施方案 + +日期:2026-09-29 + +状态:首轮安全支持范围已实施;原始设计目标与当前能力边界见第 9 节。 + +工作分支:`codex/sqlserver-migration-plan`。 +最近更新:2026-09-30。 + +## 1. 目标与范围 + +三项功能分别交付: + +- **Schema Diff**:SQL Server → SQL Server 的结构比较、计划预览、执行和执行后复核。 +- **Data Sync**:SQL Server → SQL Server 的数据同步,保持同族、结构一致、主键一致的门槛。 +- **Data Transfer**:SQL Server 作为源和目标,支持同族传输,以及与现有 SQL 驱动间的异构传输。 + +能力必须区分可读取、可比较、可生成计划、可执行和可恢复,不能用单一“支持”开关掩盖差异。支持范围内应端到端可执行、结果可复核;范围外必须在写入前明确拒绝,不能静默损失数据或结构。 + +## 2. 实施前代码基线 + +以下记录是 2026-09-29 编写方案时核对的基线,不代表当前代码状态。 + +| 模块 | 当前代码事实 | 主要缺口 | +| --- | --- | --- | +| Schema Diff | Host 已有中立 Migration IR、能力接口和执行流程 | SQL Server 未接入 `migration_renderer`、`migration_capabilities`、`type_normalizer` | +| Data Sync | 已有同族分类、比较和 ChangeSet 流程 | Host 部分路径仍采用 MySQL / PostgreSQL 二分方言;SQL Server 参数绑定、事务和稳定快照需要补齐 | +| Data Transfer | 已注册 `SqlServerSyncAdapter`,已有类型转换、字面量和三段式表名处理 | 类型语义、IDENTITY、复杂结构和可靠写入尚不完整 | +| 结构元数据 | 已读取列、默认值、自增和主键标记 | 索引、外键、CHECK 返回空;复合主键由列顺序收集,不能代表真实键顺序 | +| 查询执行 | 已有查询、流式读取和执行接口 | `query_with_params` 当前忽略 `_params`;取消接口直接返回成功 | + +主要核对入口: + +- [SQL Server 驱动](../../packages/drivers/sqlserver/src/sqlserver.rs) +- [现有传输适配器](../../packages/drivers/sqlserver/src/sync_adapter.rs) +- [SQL 目标作用域处理](../../packages/drivers/sqlserver/src/sql_target.rs) +- [公共驱动契约](../../packages/driver-api/src/traits.rs) +- [结构迁移契约](../../packages/driver-api/src/schema_migration.rs) +- [同步与传输适配器契约](../../packages/driver-api/src/sync/adapter.rs) +- [同步比较](../../src-tauri/src/commands/sync/compare.rs) +- [同步应用](../../src-tauri/src/commands/sync/apply.rs) +- [同步 Keyset 读取](../../src-tauri/src/commands/sync/keyset_source.rs) + +## 3. 共用基础设计 + +先完成共享基础,再接入三条业务链路。SQL Server 方言和专属行为留在驱动,Host 只负责通用规划、调度、校验和结果汇总。 + +### 3.1 完整结构元数据 + +在 SQL Server 驱动内部建立统一 catalog 读取模块,覆盖: + +- 列完整类型:长度、精度、小数位、时间精度、字符排序规则。 +- 主键:约束名、真实键顺序、聚集属性。 +- 索引:唯一性、键顺序、排序方向、INCLUDE、过滤条件。 +- 外键:列映射顺序、引用对象、更新及删除动作、启用和可信状态。 +- DEFAULT / CHECK:约束名称、表达式及状态。 +- IDENTITY:seed、increment;当前计数值属于运行状态,不参与结构相等判断。 +- computed、rowversion、temporal、特殊索引等特征,用于能力检查。 + +现有公共模型不能表达的信息,应扩展有明确语义的字段或类型;不能将缺失属性塞进 SQL 字符串后声称结构完整。 + +读取权限不足与对象确实不存在必须区分。不完整元数据不能生成具有删除能力的计划。 + +### 3.2 命名与作用域 + +统一使用显式 `database + schema + object`: + +- 标识符逐段引用,正确处理 `]`、点号、空格和 Unicode。 +- 元数据查询与执行 SQL 必须指向同一作用域。 +- 用户选定的 schema 全程透传,不能执行时静默回退到 `dbo`。 +- 对象名称比较依据实际排序规则,不能统一转小写。 +- 检测源、目标是否指向同一物理对象,避免自同步和自覆盖。 +- 配置归属使用 `connectionId`,已建立会话操作使用 `dbSessionId`,禁止双模回退。 + +### 3.3 参数、事务与会话 + +补齐驱动契约: + +- 真正绑定 `@P1` 等参数,覆盖 NULL、Unicode、二进制、decimal、UUID、日期时间。 +- 参数类型结合列类型,避免 decimal 经浮点数中转、带时区时间丢失偏移量。 +- 实现事务开始、提交和回滚,保证整个事务固定在同一物理连接。 +- 迁移任务拥有独立会话,或明确的会话独占机制。 +- 失败、取消、断线后清理事务和会话状态;无法证明干净的连接直接废弃。 +- 区分请求取消、数据库执行已停止、事务已回滚,不能提前报告取消成功。 + +现有 `sqlserver.rs` 已较大,新增能力拆为 metadata、binding、transaction、migration 等职责模块,避免继续扩大单文件。 + +## 4. Schema Diff + +### 4.1 接入方式与能力范围 + +接入 `MigrationRenderer`、`MigrationCapabilities`、`TypeNormalizer`。Host 继续生成中立操作,T-SQL 由驱动生成。 + +| 对象 | 首批计划支持 | +| --- | --- | +| 表、普通列 | 创建、删除、增删列、类型与可空性变更 | +| DEFAULT | 读取实际约束名,删除旧约束并创建新约束 | +| 主键、普通索引、唯一约束 | 基于完整元数据创建、删除和重建 | +| 外键、CHECK | 创建、删除及必要的依赖处理 | +| 视图、过程、函数、触发器 | 定义完整可见、作用域映射可靠时支持 | +| 特殊对象 | 明确显示不支持原因,禁止生成部分可执行计划 | + +### 4.2 规划与执行规则 + +1. 类型归一化只消除真正等价的写法。长度、精度、排序规则以及 Unicode / 非 Unicode 差异不能被抹掉。 +2. DEFAULT 作为独立约束处理。修改默认值必须使用目标实际约束名,不能套用其他数据库的列默认值语法。 +3. 依赖顺序必须完整:先处理阻塞变更的依赖,再变更主体,最后恢复约束和依赖对象;同时检查未被选择的对象。 +4. IDENTITY 属性变化不能当作普通 ALTER COLUMN。首版阻止自动执行并解释原因;后续若支持表重建,独立实现数据复制、依赖恢复和失败恢复流程。 +5. 模块定义保持批次语义。复用并验证已有独立批次处理,客户端分隔符不能直接作为服务器 SQL 执行。 +6. 执行前重新验证目标快照。预览后若结构变化,应重新生成计划。 +7. 如实区分事务回滚、逆向 DDL 和数据恢复。删除数据后的逆向建表不能标记为完整恢复。 + +### 4.3 验收 + +执行成功后重新比较,受支持对象不再出现差异。不支持或依赖不完整时,执行前阻断,不能执行计划前缀后才报告不支持。 + +## 5. Data Sync + +### 5.1 接入方式 + +保留 Gate → Compare → ChangeSet → Preview → Apply 流程,替换其中的方言假设。 + +重点改造 `commands/sync/compare.rs`、`apply.rs`、`keyset_source.rs`: + +- 引用符、参数占位符、分页、字面量和键排序通过驱动能力获取。 +- 消除“非 MySQL 就按 PostgreSQL 处理”的分支。 +- Host 只保留通用比较、冲突处理和任务管理逻辑。 + +### 5.2 比较一致性 + +- 同族校验后,验证列结构、主键列及顺序、可写属性。 +- 使用稳定读取快照。数据库未启用所需能力时,在预检解释原因,不自动修改数据库配置。 +- 源和目标快照分别稳定,不宣称两端拥有同一时间点的分布式快照。 +- Keyset 分页必须使用与比较器一致的排序语义。 +- 特别验证字符串排序规则、尾随空格、复合主键、`uniqueidentifier`、decimal 键。 +- 无法保证排序一致的键类型明确拒绝,不能套用不可靠的默认字符串比较。 + +### 5.3 写入与冲突 + +- INSERT / UPDATE / DELETE 使用参数绑定。 +- 更新和删除带旧值或已定义的并发校验条件,检测比较后目标被修改。 +- 受影响行数异常进入冲突处理,不能计为成功;验证触发器和 `NOCOUNT` 场景。 +- 保留已有跳过、覆盖等策略语义,不以 `MERGE` 替换整套冲突逻辑。 +- IDENTITY 列允许按原值插入,但不作为普通可更新列。 +- computed 与 rowversion 不直接写入;生成列策略在预览中可见。 +- rowversion 用于目标并发校验,不作为普通业务差异列;不能要求两端版本值相等。 + +rowversion 是数据库生成的版本值,不是日期时间。参考 [Microsoft rowversion 文档](https://learn.microsoft.com/en-us/sql/t-sql/data-types/rowversion-transact-sql?view=sql-server-ver17)。 + +### 5.4 验收 + +应用成功后再次比较,业务可写列无差异。冲突、取消和失败不会留下未经报告的部分写入。生成列的差异处理规则明确、可重复,不造成无限重复同步。 + +## 6. Data Transfer + +扩展现有 `SqlServerSyncAdapter`,不新建平行传输框架。 + +### 6.1 类型转换 + +优先纠正或补齐以下语义: + +- `tinyint` 与 IR `Int8`:明确无符号范围,双向转换不能直接视为等价。 +- `datetimeoffset`:保留时区偏移,不能统一转成不带偏移的 `DATETIME2`。 +- `decimal/numeric`:完整保留 precision / scale,禁止浮点中转。 +- `money/smallmoney`:定义精确映射及范围检查。 +- `char/varchar` 与 `nchar/nvarchar`:检查字符集、长度单位和排序规则。 +- `binary/varbinary`:保留固定长度与变长语义。 +- XML、空间类型、UDT、`sql_variant`:逐项明确原生保留、显式转换或拒绝。 +- 未知类型不能静默退化为 `NVARCHAR(MAX)`。 +- 默认表达式经过目标方言确认后才能迁移,不能原样跨库复制。 + +计划预览为每列显示目标类型,以及无损、需确认转换、不支持的结果。 + +### 6.2 IDENTITY 与生成列 + +- 保留显式主键值时,在同一会话管理 `IDENTITY_INSERT ON/OFF`。 +- 一个会话同时只能为一张表开启该设置,因此并行任务使用独立会话,或按表串行管理。 +- 成功、异常、取消路径都关闭设置;清理失败则废弃连接。 +- 非默认 seed / increment 完整保留,或在规划阶段拒绝。 +- computed、rowversion 默认由目标生成;异构目标若需保留源值,应明确转为普通列,不能混淆两种语义。 + +参考 [Microsoft SET IDENTITY_INSERT 文档](https://learn.microsoft.com/en-us/sql/t-sql/statements/set-identity-insert-transact-sql?view=sql-server-ver17)。 + +### 6.3 结构与批量写入 + +- 根据参数预算、行数和数据体积动态划分批次,为实际执行路径保留参数余量。 +- 创建表、导入数据、创建索引和外键按依赖图排序。 +- 循环外键单独规划;恢复约束后验证数据和约束状态。 +- 默认不禁用触发器,执行影响在预检中提示。 +- 现有适配器对复杂结构的拒绝检查逐项解除:某类元数据和渲染能力完成后,才开放该类对象。 +- SQL 文件导出和在线传输使用一致的类型、IDENTITY、作用域规则。 +- 恢复点只在事务提交后落盘,绑定源范围、目标结构和配置指纹,防止恢复时重复或漏写。 + +## 7. 实施顺序 + +| 阶段 | 主要落点 | 验收条件 | +| --- | --- | --- | +| 1. 能力与模型 | `packages/driver-api`、SQL Server 驱动 | 元数据完整度、可写列、类型与事务能力可表达 | +| 2. 驱动执行基础 | SQL Server binding / transaction / metadata 模块 | 参数往返、回滚、快照、会话清理通过真实数据库测试 | +| 3. Schema Diff | 驱动 migration 模块、Host 通用规划接口 | 比较→预览→执行→复核完整闭环 | +| 4. Data Sync | 驱动键契约、Host sync 方言调用点 | 比较准确、冲突可检测、应用后业务差异归零 | +| 5. Data Transfer | 现有 adapter、通用 writer / preflight 接口 | 双向传输无静默损失,IDENTITY 与失败恢复可靠 | +| 6. UI 与回归 | 三件套窗口、驱动 UI、正式文档 | 能力提示准确,连续用户旅程与既有驱动回归通过 | + +公共协议若发生需要版本升级的变化,同步更新所有受影响插件。新增接口尽量提供明确的“不支持”默认行为,避免其他驱动被错误视为具备能力。 + +前端通过驱动元数据与后端能力结果展示状态,不添加 Host 数据库类型硬编码。开发期间仅修改英文 `en.ts`;发布前检查并补齐翻译。 + +完成实现后,将长期有效的使用边界和架构事实并入 `docs/features/`、`docs/architecture/`、`docs/development/` 对应文档,不把未实现方案描述成已实现事实。 + +## 8. 测试与发布标准 + +### 8.1 测试落点 + +SQL Server 专属实现、方言、Command 和 UI 测试全部放在 `packages/drivers/sqlserver/`: + +- Rust 单元测试:对应模块内。 +- Rust 集成测试:`packages/drivers/sqlserver/tests/`。 +- 驱动 UI 单测:`packages/drivers/sqlserver/ui/__tests__/`。 +- 驱动专属 E2E:`packages/drivers/sqlserver/e2e/`。 + +Host 只测试通用能力分派、任务生命周期及跨驱动契约,不承载 SQL Server 专属测试。 + +### 8.2 必测场景 + +- 多 database、多 schema、同名表、特殊字符名称。 +- 列顺序不同于复合主键顺序。 +- Unicode、二进制、NULL、decimal 边界、日期时间精度和偏移量。 +- IDENTITY 插入、失败清理、下一次任务继续执行。 +- 过滤索引、INCLUDE、外键、DEFAULT、CHECK 的完整性。 +- 无元数据权限、快照不可用、断线、死锁、目标并发修改。 +- 分批边界、取消、恢复、触发器和受影响行数。 +- SQL Server 作为源和目标,以及 PostgreSQL / MySQL 与 SQL Server 的双向传输。 +- 用户切换连接、database、schema 后旧比较结果失效;失败或取消后可重新规划执行。 +- 对不支持的对象或类型,验证执行前拒绝且没有部分写入。 +- 对既有驱动验证能力接口变更没有引入回归。 + +交互测试必须覆盖连续用户旅程及中间状态,不能只测试静态合法输入。 + +### 8.3 验证方式 + +使用驱动单测、真实数据库集成测试、`pnpm typecheck` 和 WDIO。测试文件参与类型检查,禁止用 `any` 绕过。 + +WDIO 必须经 `pnpm tauri:build:webdriver` 构建并确保注入 SQL Server 驱动,不能用裸 `cargo build` 替代。已生成可运行 `.app` 后单独发生的 DMG 打包失败不阻塞功能验收,继续用该应用运行 WDIO。 + +本节是实施前的验证门槛。首轮实现后的测试范围及仍未完成的真实数据库验证见第 9 节。 + +## 9. 首轮实现结果与边界 + +代码已在 `codex/sqlserver-migration-plan` 分支集成。首轮实现开放了能通过驱动能力和元数据证明安全的子集;不代表第 1 节列出的所有数据库对象、类型和恢复场景都已支持。 + +共享驱动基础已增加 SQL Server 列、主键顺序、索引、外键和 CHECK 目录读取,并在元数据缺失、排序语义不可表达、特殊列或特殊索引无法安全建模时拒绝继续。数据库物理身份使用服务器身份与目录中的当前数据库 ID;Schema Diff 同库作用域再核对 `schema_id`。`ReuseDriver` 转发这些身份能力。参数绑定上限按 SQL Server 的 2100 个参数处理。 + +Schema Diff 已接入 SQL Server migration renderer、能力检查和类型归一化。只执行 renderer 声明支持且元数据完整的操作;IDENTITY 属性重建、routine/trigger、sequence、用户定义类型,以及无法证明安全的表选项或索引特征会被阻断。SQL Server 对象专用计划暂不做跨 schema DDL 重写,检测到来源与目标 schema 不一致时清空计划语句并返回 `Unsupported`。 + +Data Sync 已接入 SQL Server 同族比较、参数化写入和会话级 IDENTITY_INSERT 管理。相同目标表跨分页时复用开关,切换表、失败、取消、冲突和提交前均执行清理;关闭失败时先回滚再丢弃连接。运行时目标信息不进入预览 JSON。无法证明排序语义的文本、`uniqueidentifier` 和 `rowversion` 键会被拒绝;投影中的 identity 值缺失或为 NULL 时也在执行前拒绝。 + +Data Transfer 已支持 SQL Server 源和目标路径,包括安全目标关系引用、2100 参数分块和会话级 identity 清理。SQL 文件按 INSERT 实际映射的目标列与目标表 identity 目录信息的交集决定是否生成 IDENTITY_INSERT 包装,避免用来源列属性猜测目标 identity。未知类型、无法保留的排序规则或不完整结构仍在预检中拒绝。 + +离线验证结果:Schema Diff Host 定向测试 228 项通过;Data Sync 执行器 37 项、SQL 生成 6 项、命令路径 28 项、执行 IPC 6 项通过;Data Transfer Host 定向测试 203 项通过;SQL Server 驱动 96 项、driver-api 199 项通过。格式检查和提交差异检查通过。 + +尚未连接真实 SQL Server,因此 TDS 会话 toggle、Azure SQL 实际权限/目录可见性、跨数据库权限及端到端迁移结果仍需 live/integration 验证;WDIO/E2E 也未运行。实现与测试均没有读取或加载 `.env` / `.env.test`。发布前仍需按第 8 节补齐真实数据库验证,并继续确认所有不支持路径都在写入前拒绝。 From 50a6a3261a2d38891c5578e938d722f8701a6734 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 02:34:07 +0800 Subject: [PATCH 016/666] test(sqlserver): add data transfer live journey --- .../sqlserver/e2e/sqlserver-data-transfer.ts | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts diff --git a/packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts b/packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts new file mode 100644 index 000000000..18750fc8e --- /dev/null +++ b/packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts @@ -0,0 +1,266 @@ +/** + * SQL Server Data Transfer live journey through the public IPC surface. + * + * Copies explicit source identity values into an existing SQL Server identity + * table, then verifies the destination rows. Every object belongs to a unique + * dz_e2e_transfer_* schema created by this spec. + */ +import { expect } from '@wdio/globals'; + +const HOST = (process.env.E2E_SQLSERVER_HOST || '').trim(); +const PORT = Number(process.env.E2E_SQLSERVER_PORT || '1433'); +const USER = (process.env.E2E_SQLSERVER_USER || '').trim(); +const PASSWORD = process.env.E2E_SQLSERVER_PASSWORD || ''; +const DATABASE = (process.env.E2E_SQLSERVER_DATABASE || '').trim(); +const SSL_MODE = (process.env.E2E_SQLSERVER_SSL_MODE || 'require').trim(); +const TRUST_CERT = process.env.E2E_SQLSERVER_TRUST_CERT !== '0'; + +const SOURCE_CONNECTION_ID = 'e2e-sqlserver-transfer-source'; +const TARGET_CONNECTION_ID = 'e2e-sqlserver-transfer-target'; +const TABLE = 'identity_rows'; +const SOURCE_SCHEMA = `dz_e2e_transfer_src_${Date.now().toString(36)}`; +const TARGET_SCHEMA = `dz_e2e_transfer_tgt_${Date.now().toString(36)}`; + +interface StatementPayload { + rows: Array>; +} + +interface MultiQueryPayload { + results: StatementPayload[]; +} + +interface TransferPreview { + planId: string; + pairingPath: string; + canExecute: boolean; + blockReason?: string | null; + writePlans: Array<{ + sourceTable: string; + targetTable: string; + mappedColumns: Array<{ sourceColumn: string; targetColumn: string; skip?: boolean }>; + }>; +} + +async function invoke(command: string, args: Record = {}): Promise { + const result = await browser.executeAsync( + (name: string, serializedArgs: string, done: (value: unknown) => void) => { + const internals = ( + window as unknown as { + __TAURI_INTERNALS__?: { invoke: (command: string, input: unknown) => Promise }; + } + ).__TAURI_INTERNALS__; + if (!internals) { + done({ __error: '__TAURI_INTERNALS__ is unavailable (not a webdriver build?)' }); + return; + } + internals + .invoke(name, JSON.parse(serializedArgs)) + .then((value) => done(value)) + .catch((error: unknown) => done({ __error: String(error) })); + }, + command, + JSON.stringify(args), + ); + if (result && typeof result === 'object' && '__error' in (result as Record)) { + throw new Error(String((result as { __error: string }).__error)); + } + return result as T; +} + +function bracket(name: string): string { + return `[${name.replaceAll(']', ']]')}]`; +} + +function skipReason(): string | null { + if (process.env.E2E_SKIP_SQLSERVER === '1') return 'E2E_SKIP_SQLSERVER=1'; + if (!HOST || !USER || !PASSWORD) { + return 'set E2E_SQLSERVER_HOST / E2E_SQLSERVER_USER / E2E_SQLSERVER_PASSWORD (see README.md)'; + } + return null; +} + +describe('SQL Server Data Transfer (live)', () => { + let sourceSessionId = ''; + let targetSessionId = ''; + let database = DATABASE; + let originalSafeMode: boolean | undefined; + + const run = (dbSessionId: string, sql: string) => + invoke('execute_query', { + dbSessionId, + sql, + ...(database ? { database } : {}), + }); + + const qualified = (schema: string) => `${bracket(schema)}.${bracket(TABLE)}`; + + const setSafeMode = async (enabled: boolean) => { + const settings = await invoke>('get_settings'); + if (originalSafeMode === undefined) originalSafeMode = settings.safeMode !== false; + await invoke('save_settings', { settings: { ...settings, safeMode: enabled } }); + }; + + const connectionConfig = (id: string, name: string) => ({ + id, + name, + databaseType: 'sqlserver', + host: HOST, + port: PORT, + ...(DATABASE ? { database: DATABASE } : {}), + username: USER, + password: PASSWORD, + sslMode: SSL_MODE, + connectionTimeout: 30, + maxPoolSize: 5, + options: { trustServerCertificate: TRUST_CERT }, + }); + + const cleanup = async () => { + const cleanupSession = sourceSessionId || targetSessionId; + if (cleanupSession) { + try { + await setSafeMode(false); + await run(cleanupSession, `DROP TABLE IF EXISTS ${qualified(SOURCE_SCHEMA)}`).catch( + () => undefined, + ); + await run(cleanupSession, `DROP TABLE IF EXISTS ${qualified(TARGET_SCHEMA)}`).catch( + () => undefined, + ); + await run(cleanupSession, `DROP SCHEMA IF EXISTS ${bracket(SOURCE_SCHEMA)}`).catch( + () => undefined, + ); + await run(cleanupSession, `DROP SCHEMA IF EXISTS ${bracket(TARGET_SCHEMA)}`).catch( + () => undefined, + ); + } finally { + if (sourceSessionId) { + await invoke('disconnect', { dbSessionId: sourceSessionId }).catch(() => undefined); + sourceSessionId = ''; + } + if (targetSessionId) { + await invoke('disconnect', { dbSessionId: targetSessionId }).catch(() => undefined); + targetSessionId = ''; + } + } + } + + await invoke('delete_connection', { id: SOURCE_CONNECTION_ID }).catch(() => undefined); + await invoke('delete_connection', { id: TARGET_CONNECTION_ID }).catch(() => undefined); + if (originalSafeMode !== undefined) { + const settings = await invoke>('get_settings').catch(() => null); + if (settings) { + await invoke('save_settings', { + settings: { ...settings, safeMode: originalSafeMode }, + }).catch(() => undefined); + } + } + }; + + before(async function () { + this.timeout(120_000); + const reason = skipReason(); + if (reason) { + console.warn(`⏩ Skipping SQL Server Data Transfer E2E: ${reason}`); + this.skip(); + } + + const handles = await browser.getWindowHandles(); + if (handles[0]) await browser.switchToWindow(handles[0]); + + await invoke('save_connection', { + config: connectionConfig(SOURCE_CONNECTION_ID, 'E2E SQL Server transfer source'), + }); + await invoke('save_connection', { + config: connectionConfig(TARGET_CONNECTION_ID, 'E2E SQL Server transfer target'), + }); + sourceSessionId = await invoke('connect', { connectionId: SOURCE_CONNECTION_ID }); + targetSessionId = await invoke('connect', { connectionId: TARGET_CONNECTION_ID }); + + if (!database) { + const active = await run(sourceSessionId, 'SELECT DB_NAME() AS database_name'); + database = String(active.results[0]?.rows[0]?.[0] ?? ''); + if (!database) throw new Error('SQL Server did not return its active database name'); + } + + await setSafeMode(false); + await run(sourceSessionId, `CREATE SCHEMA ${bracket(SOURCE_SCHEMA)}`); + await run(sourceSessionId, `CREATE SCHEMA ${bracket(TARGET_SCHEMA)}`); + await run( + sourceSessionId, + `CREATE TABLE ${qualified(SOURCE_SCHEMA)} (` + + '[id] INT IDENTITY(1,1) NOT NULL PRIMARY KEY, [payload] NVARCHAR(80) NOT NULL)', + ); + await run( + targetSessionId, + `CREATE TABLE ${qualified(TARGET_SCHEMA)} (` + + '[id] INT IDENTITY(1,1) NOT NULL PRIMARY KEY, [payload] NVARCHAR(80) NOT NULL)', + ); + // Make generated target IDs differ from the source so the assertion below + // proves that Transfer inserted the explicit source identity values. + await run(targetSessionId, `DBCC CHECKIDENT (N'${qualified(TARGET_SCHEMA)}', RESEED, 100)`); + await run( + sourceSessionId, + `INSERT INTO ${qualified(SOURCE_SCHEMA)} ([payload]) VALUES ` + + `(N'identity alpha'), (N'identity beta'), (N'identity gamma')`, + ); + }); + + after(async function () { + this.timeout(60_000); + await cleanup(); + }); + + it('copies source identity values into a matching target identity column', async () => { + const preview = await invoke('preview_data_transfer', { + job: { + source: { dbSessionId: sourceSessionId, database, schema: SOURCE_SCHEMA }, + target: { dbSessionId: targetSessionId, database, schema: TARGET_SCHEMA }, + mode: 'data', + writeMode: 'insert', + tables: [{ sourceTable: TABLE, targetTable: TABLE, enabled: true }], + options: { batchSize: 2, stopOnError: true }, + }, + }); + + expect(preview.pairingPath).toBe('direct'); + if (!preview.canExecute) { + throw new Error(preview.blockReason || 'transfer preview should be executable'); + } + expect(preview.planId).not.toBe(''); + expect(preview.writePlans).toHaveLength(1); + expect(preview.writePlans[0]?.mappedColumns).toHaveLength(2); + expect( + preview.writePlans[0]?.mappedColumns.some( + (mapping) => + mapping.sourceColumn === 'id' && mapping.targetColumn === 'id' && mapping.skip !== true, + ), + ).toBe(true); + + await invoke('execute_data_transfer', { + request: { planId: preview.planId, selection: { sourceTables: [TABLE] } }, + }); + + const copied = await run( + targetSessionId, + `SELECT [id], [payload] FROM ${qualified(TARGET_SCHEMA)} ORDER BY [id]`, + ); + expect(copied.results[0]?.rows).toEqual([ + [1, 'identity alpha'], + [2, 'identity beta'], + [3, 'identity gamma'], + ]); + + // A normal generated-identity insert on the same target session proves the + // transfer left IDENTITY_INSERT OFF after copying explicit values. + await run( + targetSessionId, + `INSERT INTO ${qualified(TARGET_SCHEMA)} ([payload]) VALUES (N'after transfer')`, + ); + const afterTransfer = await run( + targetSessionId, + `SELECT [id], [payload] FROM ${qualified(TARGET_SCHEMA)} WHERE [payload] = N'after transfer'`, + ); + expect(afterTransfer.results[0]?.rows).toHaveLength(1); + expect(Number(afterTransfer.results[0]?.rows[0]?.[0])).toBeGreaterThan(3); + }); +}); From 0f09c2c869a74501196022e2606c7f9c6d348ae1 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 02:29:00 +0800 Subject: [PATCH 017/666] test(sqlserver): add schema diff live journey --- .../sqlserver/e2e/sqlserver-schema-diff.ts | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 packages/drivers/sqlserver/e2e/sqlserver-schema-diff.ts diff --git a/packages/drivers/sqlserver/e2e/sqlserver-schema-diff.ts b/packages/drivers/sqlserver/e2e/sqlserver-schema-diff.ts new file mode 100644 index 000000000..0830c0356 --- /dev/null +++ b/packages/drivers/sqlserver/e2e/sqlserver-schema-diff.ts @@ -0,0 +1,273 @@ +/** + * SQL Server → SQL Server Schema Diff live journey through the real UI. + * + * The source and target must be separate databases on an instance reachable + * with the E2E_SQLSERVER_* credentials. Both databases receive the same unique + * scratch schema so the SQL Server same-schema safety boundary is exercised. + * The source gets one table that is absent from the target; the journey then + * compares, generates a plan, deploys it, and checks the target catalog. + * + * Skips when credentials or distinct source/target database names are absent. + */ +import { expect, browser, $ } from '@wdio/globals'; +import { + advanceSchemaDiffToReview, + assertSchemaDiffDeploySuccess, + assertSchemaDiffNoErrors, + clickSchemaDiffCompare, + clickSchemaDiffGeneratePlan, + closeExtraWindows, + deploySchemaDiffPlan, + disconnectBackend, + invokeBackend, + openSchemaDiffWindow, + parseQueryRows, + queryScalar, + selectSchemaDiffEndpoints, + setSchemaDiffTables, + withSafeModeOff, + type QueryResultPayload, +} from '../../../../e2e/helpers.js'; + +const HOST = (process.env.E2E_SQLSERVER_HOST || '').trim(); +const PORT = Number(process.env.E2E_SQLSERVER_PORT || '1433'); +const USER = (process.env.E2E_SQLSERVER_USER || '').trim(); +const PASSWORD = process.env.E2E_SQLSERVER_PASSWORD || ''; +const SOURCE_DATABASE = ( + process.env.E2E_SQLSERVER_SOURCE_DATABASE || + process.env.E2E_SQLSERVER_DATABASE || + '' +).trim(); +const TARGET_DATABASE = (process.env.E2E_SQLSERVER_TARGET_DATABASE || '').trim(); +const SSL_MODE = (process.env.E2E_SQLSERVER_SSL_MODE || 'require').trim(); +const TRUST_CERT = process.env.E2E_SQLSERVER_TRUST_CERT !== '0'; + +const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; +const SOURCE_ID = `e2e-sqlserver-sd-src-${STAMP}`; +const TARGET_ID = `e2e-sqlserver-sd-tgt-${STAMP}`; +const SOURCE_NAME = `E2E SQL Server SD Source ${STAMP}`; +const TARGET_NAME = `E2E SQL Server SD Target ${STAMP}`; +const SCRATCH_SCHEMA = `dz_e2e_sd_${STAMP}`; +const TABLE = `schema_diff_${STAMP}`; + +let mainWindow = ''; +let sourceSaved = false; +let targetSaved = false; + +function skipReason(): string | null { + if (process.env.E2E_SKIP_SQLSERVER === '1') return 'E2E_SKIP_SQLSERVER=1'; + if (!HOST || !USER || !PASSWORD) { + return 'set E2E_SQLSERVER_HOST / E2E_SQLSERVER_USER / E2E_SQLSERVER_PASSWORD'; + } + if (!SOURCE_DATABASE || !TARGET_DATABASE) { + return 'set distinct E2E_SQLSERVER_SOURCE_DATABASE and E2E_SQLSERVER_TARGET_DATABASE values'; + } + if (SOURCE_DATABASE.toLowerCase() === TARGET_DATABASE.toLowerCase()) { + return 'source and target databases must be different'; + } + return null; +} + +async function invoke(command: string, args: Record = {}): Promise { + const result = await browser.executeAsync( + (name: string, serializedArgs: string, done: (value: unknown) => void) => { + const internals = ( + window as unknown as { + __TAURI_INTERNALS__?: { + invoke: (commandName: string, input: unknown) => Promise; + }; + } + ).__TAURI_INTERNALS__; + if (!internals) { + done({ __error: '__TAURI_INTERNALS__ is unavailable (not a webdriver build?)' }); + return; + } + internals + .invoke(name, JSON.parse(serializedArgs)) + .then((value) => done(value)) + .catch((error: unknown) => done({ __error: String(error) })); + }, + command, + JSON.stringify(args), + ); + if (result && typeof result === 'object' && '__error' in (result as Record)) { + throw new Error(String((result as { __error: string }).__error)); + } + return result as T; +} + +function bracket(identifier: string): string { + return `[${identifier.replaceAll(']', ']]')}]`; +} + +function connectionConfig(id: string, name: string, database: string) { + return { + id, + name, + databaseType: 'sqlserver', + host: HOST, + port: PORT, + database, + schema: SCRATCH_SCHEMA, + username: USER, + password: PASSWORD, + sslMode: SSL_MODE, + connectionTimeout: 30, + maxPoolSize: 5, + options: { trustServerCertificate: TRUST_CERT }, + }; +} + +async function withConnection(connectionId: string, work: (sessionId: string) => Promise) { + const sessionId = await invoke('connect', { connectionId }); + try { + return await work(sessionId); + } finally { + await disconnectBackend(sessionId); + } +} + +async function run(sessionId: string, sql: string): Promise { + return invoke('execute_query', { dbSessionId: sessionId, sql }); +} + +async function createScratchObjects(): Promise { + const sourceSession = await invoke('connect', { connectionId: SOURCE_ID }); + const targetSession = await invoke('connect', { connectionId: TARGET_ID }); + try { + await withSafeModeOff(async () => { + for (const session of [sourceSession, targetSession]) { + await run(session, `CREATE SCHEMA ${bracket(SCRATCH_SCHEMA)}`); + } + await run( + sourceSession, + `CREATE TABLE ${bracket(SCRATCH_SCHEMA)}.${bracket(TABLE)} (` + + '[id] INT NOT NULL PRIMARY KEY, [label] NVARCHAR(80) NOT NULL)', + ); + }); + } finally { + await disconnectBackend(sourceSession); + await disconnectBackend(targetSession); + } +} + +async function cleanupScratchObjects(): Promise { + const savedConnections = [ + { id: SOURCE_ID, saved: sourceSaved }, + { id: TARGET_ID, saved: targetSaved }, + ].filter((entry) => entry.saved); + + if (savedConnections.length > 0) { + await withSafeModeOff(async () => { + for (const { id } of savedConnections) { + await withConnection(id, async (session) => { + await run( + session, + `DROP TABLE IF EXISTS ${bracket(SCRATCH_SCHEMA)}.${bracket(TABLE)}`, + ).catch((error: unknown) => { + console.warn( + `SQL Server Schema Diff cleanup could not drop scratch table: ${String(error)}`, + ); + }); + await run(session, `DROP SCHEMA IF EXISTS ${bracket(SCRATCH_SCHEMA)}`).catch( + (error: unknown) => { + console.warn( + `SQL Server Schema Diff cleanup could not drop scratch schema: ${String(error)}`, + ); + }, + ); + }).catch((error: unknown) => { + console.warn(`SQL Server Schema Diff cleanup could not connect: ${String(error)}`); + }); + } + }).catch((error: unknown) => { + console.warn(`SQL Server Schema Diff cleanup could not disable Safe Mode: ${String(error)}`); + }); + } + + for (const { id, saved } of [ + { id: SOURCE_ID, saved: sourceSaved }, + { id: TARGET_ID, saved: targetSaved }, + ]) { + if (saved) await invoke('delete_connection', { id }).catch(() => undefined); + } +} + +describe('SQL Server Schema Diff live journey', () => { + let reason: string | null = null; + + before(async function () { + this.timeout(120_000); + reason = skipReason(); + if (reason) { + console.warn(`⏩ Skipping SQL Server Schema Diff E2E: ${reason}`); + this.skip(); + } + + mainWindow = await browser.getWindowHandle(); + const handles = await browser.getWindowHandles(); + if (handles[0]) await browser.switchToWindow(handles[0]); + await $('[data-testid="workspace-nav-databases"]').waitForDisplayed({ timeout: 15000 }); + + await invoke('save_connection', { + config: connectionConfig(SOURCE_ID, SOURCE_NAME, SOURCE_DATABASE), + }); + sourceSaved = true; + await invoke('save_connection', { + config: connectionConfig(TARGET_ID, TARGET_NAME, TARGET_DATABASE), + }); + targetSaved = true; + + await createScratchObjects(); + }); + + after(async function () { + this.timeout(60_000); + if (mainWindow) { + await closeExtraWindows(mainWindow); + await browser.switchToWindow(mainWindow); + } + await cleanupScratchObjects(); + }); + + it('compares SQL Server schemas, deploys the generated plan, and verifies the target table', async function () { + this.timeout(120_000); + await openSchemaDiffWindow(); + await selectSchemaDiffEndpoints(SOURCE_NAME, TARGET_NAME); + await setSchemaDiffTables(TABLE); + await clickSchemaDiffCompare(); + + const compareText = await $('[data-testid="schema-diff-detail-panel"]').getText(); + expect(compareText).toContain(TABLE); + await assertSchemaDiffNoErrors(); + + await clickSchemaDiffGeneratePlan(); + const planText = await $('[data-testid="schema-diff-plan-panel"]').getText(); + expect(planText).toContain(TABLE); + expect(planText).toMatch(/CREATE\s+TABLE/i); + await assertSchemaDiffNoErrors(); + + await advanceSchemaDiffToReview(); + await deploySchemaDiffPlan(); + await assertSchemaDiffDeploySuccess(); + + await withConnection(TARGET_ID, async (session) => { + const result = await run( + session, + 'SELECT [COLUMN_NAME] FROM [INFORMATION_SCHEMA].[COLUMNS] ' + + `WHERE [TABLE_SCHEMA] = N'${SCRATCH_SCHEMA}' AND [TABLE_NAME] = N'${TABLE}' ` + + 'ORDER BY [ORDINAL_POSITION]', + ); + const columns = parseQueryRows(result).map((row) => String(row[0])); + expect(columns).toEqual(['id', 'label']); + + const primaryKeyCount = await run( + session, + 'SELECT COUNT(*) AS [c] FROM [INFORMATION_SCHEMA].[TABLE_CONSTRAINTS] ' + + `WHERE [TABLE_SCHEMA] = N'${SCRATCH_SCHEMA}' AND [TABLE_NAME] = N'${TABLE}' ` + + "AND [CONSTRAINT_TYPE] = N'PRIMARY KEY'", + ); + expect(queryScalar(primaryKeyCount, 'c')).toBe(1); + }); + }); +}); From 931bc189b598dcddb3b52b107e35b2d421365d8c Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 02:30:44 +0800 Subject: [PATCH 018/666] test(sqlserver): add Data Sync identity live journey --- .../sqlserver/e2e/sqlserver-data-sync.ts | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 packages/drivers/sqlserver/e2e/sqlserver-data-sync.ts diff --git a/packages/drivers/sqlserver/e2e/sqlserver-data-sync.ts b/packages/drivers/sqlserver/e2e/sqlserver-data-sync.ts new file mode 100644 index 000000000..d906e6b07 --- /dev/null +++ b/packages/drivers/sqlserver/e2e/sqlserver-data-sync.ts @@ -0,0 +1,371 @@ +/** + * SQL Server Data Sync journey through the host's public IPC surface. + * + * The test compares a source IDENTITY table with an empty target IDENTITY + * table, selects the INSERT operation from the immutable compare plan, + * inspects the client-safe SQL preview, applies the plan and verifies that + * explicit identity values were preserved. It skips without SQL Server E2E + * credentials. All scratch objects use a per-run `dz_e2e_*` schema. + */ +import { expect, browser } from '@wdio/globals'; + +const HOST = (process.env.E2E_SQLSERVER_HOST || '').trim(); +const PORT = Number(process.env.E2E_SQLSERVER_PORT || '1433'); +const USER = (process.env.E2E_SQLSERVER_USER || '').trim(); +const PASSWORD = process.env.E2E_SQLSERVER_PASSWORD || ''; +const DATABASE = (process.env.E2E_SQLSERVER_DATABASE || '').trim(); +const SSL_MODE = (process.env.E2E_SQLSERVER_SSL_MODE || 'require').trim(); +const TRUST_CERT = process.env.E2E_SQLSERVER_TRUST_CERT !== '0'; + +const STAMP = Date.now().toString(36); +const SOURCE_SCHEMA = `dz_e2e_sync_src_${STAMP}`; +const TARGET_SCHEMA = `dz_e2e_sync_tgt_${STAMP}`; +const TABLE = `dz_e2e_identity_${STAMP}`; +const SOURCE_CONNECTION_ID = `e2e-sqlserver-ds-src-${STAMP}`; +const TARGET_CONNECTION_ID = `e2e-sqlserver-ds-tgt-${STAMP}`; +const IDS = [101, 202, 303]; + +type Cell = string | number | boolean | null; + +interface QueryResult { + rows?: Cell[][]; +} + +interface MultiQueryPayload { + results?: QueryResult[]; +} + +interface SyncComparisonTable { + sourceTable: string; + targetTable: string; + status: string; + insertCount: number; + updateCount: number; + deleteCount: number; +} + +interface SyncComparison { + planId: string; + selectionRevision: number; + tables: SyncComparisonTable[]; +} + +interface SyncSqlStatement { + table: string; + operation: string; + sql: string; + previewSql: string; + parameters: Cell[]; + rowKey: Cell[]; + identityInsert?: unknown; + identity_insert?: unknown; +} + +interface SyncExecutionResult { + applied: number; + affectedRows: number; + outcome: string; + error?: string; + rolledBack: boolean; +} + +function skipReason(): string | null { + if (process.env.E2E_SKIP_SQLSERVER === '1') return 'E2E_SKIP_SQLSERVER=1'; + if (!HOST || !USER || !PASSWORD) { + return 'set E2E_SQLSERVER_HOST / E2E_SQLSERVER_USER / E2E_SQLSERVER_PASSWORD'; + } + return null; +} + +function bracket(identifier: string): string { + return `[${identifier.replaceAll(']', ']]')}]`; +} + +function qualified(schema: string): string { + return `${bracket(schema)}.${bracket(TABLE)}`; +} + +/** Invoke one Tauri command without putting connection secrets in diagnostics. */ +async function invoke(command: string, args: Record = {}): Promise { + const result = await browser.executeAsync( + (name: string, serializedArgs: string, done: (value: unknown) => void) => { + const internals = ( + window as unknown as { + __TAURI_INTERNALS__?: { invoke: (cmd: string, input: unknown) => Promise }; + } + ).__TAURI_INTERNALS__; + if (!internals) { + done({ __error: '__TAURI_INTERNALS__ is unavailable (not a webdriver build?)' }); + return; + } + internals + .invoke(name, JSON.parse(serializedArgs)) + .then((value) => done(value)) + .catch((error: unknown) => done({ __error: String(error) })); + }, + command, + JSON.stringify(args), + ); + if (result && typeof result === 'object' && '__error' in (result as Record)) { + const detail = String((result as { __error: string }).__error).replaceAll( + PASSWORD, + '[redacted]', + ); + throw new Error(`${command} failed: ${detail}`); + } + return result as T; +} + +describe('SQL Server Data Sync identity insert (live)', () => { + let sourceSessionId = ''; + let targetSessionId = ''; + let originalSafeMode: boolean | null = null; + + const run = (sessionId: string, sql: string) => + invoke('execute_query', { + dbSessionId: sessionId, + sql, + ...(DATABASE ? { database: DATABASE } : {}), + }); + + const setSafeMode = async (enabled: boolean) => { + const settings = await invoke>('get_settings'); + if (originalSafeMode === null) originalSafeMode = settings.safeMode !== false; + if ((settings.safeMode !== false) === enabled) return; + await invoke('save_settings', { settings: { ...settings, safeMode: enabled } }); + }; + + const saveAndConnect = async (id: string, name: string, schema: string): Promise => { + await invoke('save_connection', { + config: { + id, + name, + databaseType: 'sqlserver', + host: HOST, + port: PORT, + ...(DATABASE ? { database: DATABASE } : {}), + schema, + username: USER, + password: PASSWORD, + sslMode: SSL_MODE, + connectionTimeout: 30, + maxPoolSize: 5, + options: { trustServerCertificate: TRUST_CERT }, + }, + }); + + let lastError: unknown; + for (let attempt = 1; attempt <= 4; attempt += 1) { + try { + return await invoke('connect', { connectionId: id }); + } catch (error) { + lastError = error; + if (!String(error).toLowerCase().includes('not currently available') || attempt === 4) { + throw error; + } + console.warn(`⏳ SQL Server database resuming (attempt ${attempt}/4), retrying in 8s`); + await browser.pause(8000); + } + } + throw lastError ?? new Error('connect returned no dbSessionId'); + }; + + const dropSchema = async (sessionId: string, schema: string) => { + try { + await run(sessionId, `DROP TABLE IF EXISTS ${qualified(schema)}`); + } catch (error) { + console.warn(`SQL Server Data Sync cleanup could not drop scratch table: ${String(error)}`); + } + try { + await run(sessionId, `DROP SCHEMA IF EXISTS ${bracket(schema)}`); + } catch (error) { + console.warn(`SQL Server Data Sync cleanup could not drop scratch schema: ${String(error)}`); + } + }; + + const restoreSafeMode = async () => { + if (originalSafeMode === null) return; + const settings = await invoke>('get_settings').catch(() => null); + if (!settings || (settings.safeMode !== false) === originalSafeMode) return; + await invoke('save_settings', { + settings: { ...settings, safeMode: originalSafeMode }, + }).catch(() => undefined); + }; + + before(async function () { + this.timeout(120_000); + const reason = skipReason(); + if (reason) { + console.warn(`⏩ Skipping SQL Server Data Sync live E2E: ${reason}`); + this.skip(); + } + + const handles = await browser.getWindowHandles(); + if (handles[0]) await browser.switchToWindow(handles[0]); + await browser.url('tauri://localhost/'); + + sourceSessionId = await saveAndConnect( + SOURCE_CONNECTION_ID, + 'E2E SQL Server Data Sync source', + SOURCE_SCHEMA, + ); + targetSessionId = await saveAndConnect( + TARGET_CONNECTION_ID, + 'E2E SQL Server Data Sync target', + TARGET_SCHEMA, + ); + + // Safe Mode blocks scratch DDL. The exact pre-test preference is restored + // in `after`, including when setup or an assertion fails. + await setSafeMode(false); + await run(sourceSessionId, `CREATE SCHEMA ${bracket(SOURCE_SCHEMA)}`); + await run(targetSessionId, `CREATE SCHEMA ${bracket(TARGET_SCHEMA)}`); + + const sourceTable = qualified(SOURCE_SCHEMA); + const targetTable = qualified(TARGET_SCHEMA); + const ddl = + `CREATE TABLE ${sourceTable} (` + + '[id] INT IDENTITY(1,1) NOT NULL PRIMARY KEY, ' + + '[label] NVARCHAR(64) NOT NULL)'; + await run(sourceSessionId, ddl); + await run( + targetSessionId, + `CREATE TABLE ${targetTable} (` + + '[id] INT IDENTITY(1,1) NOT NULL PRIMARY KEY, ' + + '[label] NVARCHAR(64) NOT NULL)', + ); + + await run(sourceSessionId, `SET IDENTITY_INSERT ${sourceTable} ON`); + try { + await run( + sourceSessionId, + `INSERT INTO ${sourceTable} ([id], [label]) VALUES ` + + IDS.map((id) => `(${id}, N'identity-${id}')`).join(', '), + ); + } finally { + await run(sourceSessionId, `SET IDENTITY_INSERT ${sourceTable} OFF`); + } + }); + + after(async function () { + this.timeout(60_000); + try { + if (sourceSessionId || targetSessionId) await setSafeMode(false); + if (sourceSessionId) await dropSchema(sourceSessionId, SOURCE_SCHEMA); + if (targetSessionId) await dropSchema(targetSessionId, TARGET_SCHEMA); + } finally { + if (sourceSessionId) { + await invoke('disconnect', { dbSessionId: sourceSessionId }).catch(() => undefined); + sourceSessionId = ''; + } + if (targetSessionId) { + await invoke('disconnect', { dbSessionId: targetSessionId }).catch(() => undefined); + targetSessionId = ''; + } + await invoke('delete_connection', { id: SOURCE_CONNECTION_ID }).catch(() => undefined); + await invoke('delete_connection', { id: TARGET_CONNECTION_ID }).catch(() => undefined); + await restoreSafeMode(); + } + }); + + it('compares, selects and applies explicit identity values without preview metadata', async function () { + this.timeout(180_000); + + const options = { + insert: true, + update: false, + delete: false, + matchingStrategy: 'primaryKey', + // One row per generated batch exercises IDENTITY_INSERT remaining on + // across pages and being turned off before the transaction commits. + batchSize: 1, + largeValueMode: 'full', + conflictPolicy: 'abort', + }; + + const comparison = await invoke('compare_data_sync', { + sourceDbSessionId: sourceSessionId, + targetDbSessionId: targetSessionId, + tables: [TABLE], + jobId: null, + sourceDatabase: DATABASE || null, + targetDatabase: DATABASE || null, + sourceSchema: SOURCE_SCHEMA, + targetSchema: TARGET_SCHEMA, + options, + filters: null, + }); + expect(comparison.planId).not.toBe(''); + expect(comparison.selectionRevision).toBeGreaterThan(0); + + const table = comparison.tables.find( + (entry) => entry.sourceTable === TABLE && entry.targetTable === TABLE, + ); + expect(table).toBeDefined(); + expect(table?.status).toBe('MATCHED'); + expect(table?.insertCount).toBe(IDS.length); + expect(table?.updateCount).toBe(0); + expect(table?.deleteCount).toBe(0); + + const selection = { + revision: comparison.selectionRevision, + rows: [], + scopes: [ + { + sourceTable: TABLE, + targetTable: TABLE, + selectionMode: 'all', + operations: ['INSERT'], + excludedRows: [], + }, + ], + }; + const preview = await invoke('generate_data_sync_sql', { + planId: comparison.planId, + selection, + options, + }); + expect(preview).toHaveLength(IDS.length); + expect(preview.every((statement) => statement.operation === 'INSERT')).toBe(true); + expect(preview.map((statement) => Number(statement.rowKey[0])).sort((a, b) => a - b)).toEqual( + IDS, + ); + for (const statement of preview) { + expect(Object.keys(statement)).toEqual([ + 'table', + 'operation', + 'sql', + 'previewSql', + 'parameters', + 'rowKey', + ]); + expect(statement.identityInsert).toBeUndefined(); + expect(statement.identity_insert).toBeUndefined(); + expect(statement.sql.toUpperCase()).not.toContain('IDENTITY_INSERT'); + expect(statement.previewSql.toUpperCase()).not.toContain('IDENTITY_INSERT'); + } + + const applied = await invoke('execute_data_sync', { + request: { + planId: comparison.planId, + selection, + options, + jobId: null, + }, + profile: null, + }); + expect(applied.outcome).toBe('committed'); + expect(applied.error).toBeUndefined(); + expect(applied.rolledBack).toBe(false); + expect(applied.applied).toBe(IDS.length); + expect(applied.affectedRows).toBe(IDS.length); + + const verify = await run( + targetSessionId, + `SELECT [id], [label] FROM ${qualified(TARGET_SCHEMA)} ORDER BY [id]`, + ); + const rows = verify.results?.[0]?.rows ?? []; + expect(rows.map((row) => Number(row[0]))).toEqual(IDS); + expect(rows.map((row) => String(row[1]))).toEqual(IDS.map((id) => `identity-${id}`)); + }); +}); From 6c8206da9eefc656d9186910152d95157e7450e2 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 02:41:39 +0800 Subject: [PATCH 019/666] test(sqlserver): document migration live journeys --- docs/todos/sync-sqlserver.md | 4 +++- packages/drivers/sqlserver/e2e/README.md | 23 +++++++++++++++++++ .../sqlserver/e2e/sqlserver-data-transfer.ts | 12 ++++++---- .../sqlserver/e2e/sqlserver-schema-diff.ts | 3 ++- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/todos/sync-sqlserver.md b/docs/todos/sync-sqlserver.md index 2ec74a27d..3d82f9012 100644 --- a/docs/todos/sync-sqlserver.md +++ b/docs/todos/sync-sqlserver.md @@ -259,4 +259,6 @@ Data Transfer 已支持 SQL Server 源和目标路径,包括安全目标关系 离线验证结果:Schema Diff Host 定向测试 228 项通过;Data Sync 执行器 37 项、SQL 生成 6 项、命令路径 28 项、执行 IPC 6 项通过;Data Transfer Host 定向测试 203 项通过;SQL Server 驱动 96 项、driver-api 199 项通过。格式检查和提交差异检查通过。 -尚未连接真实 SQL Server,因此 TDS 会话 toggle、Azure SQL 实际权限/目录可见性、跨数据库权限及端到端迁移结果仍需 live/integration 验证;WDIO/E2E 也未运行。实现与测试均没有读取或加载 `.env` / `.env.test`。发布前仍需按第 8 节补齐真实数据库验证,并继续确认所有不支持路径都在写入前拒绝。 +已在 `packages/drivers/sqlserver/e2e/` 增加三条可选 live WDIO journey:Schema Diff 比较、生成并部署计划;Data Sync 比较、预览并应用显式 identity 值;Data Transfer 向 identity 目标写入并验证目标会话恢复普通 identity 插入。运行参数与 scratch 数据边界见同目录 `README.md`。新 journey 的单文件严格 TypeScript 检查、Prettier 与 `git diff --check` 均通过;完整 E2E TypeScript 项目检查仍被仓库其他既有类型错误阻断。 + +本轮未连接真实 SQL Server:执行环境没有注入 `E2E_SQLSERVER_*` 变量,Schema Diff 还需要预先存在的两个不同数据库。因此 WDIO live journey 尚待在有凭据的环境运行。实现与测试均没有读取或加载 `.env` / `.env.test`。发布前仍需按第 8 节补齐真实数据库验证,并继续确认所有不支持路径都在写入前拒绝。 diff --git a/packages/drivers/sqlserver/e2e/README.md b/packages/drivers/sqlserver/e2e/README.md index c74aea669..2f9e10723 100644 --- a/packages/drivers/sqlserver/e2e/README.md +++ b/packages/drivers/sqlserver/e2e/README.md @@ -8,6 +8,9 @@ Optional WebdriverIO specs for the SQL Server path driver. Not included in defau | `sqlserver-live-e2e.ts` | Live journey through the app's own IPC (`save_connection` → `connect` → `execute_query` → `get_table_data`). Asserts dialect pagination (`OFFSET … FETCH NEXT`, never `LIMIT`), batch-only DDL such as `CREATE SCHEMA`/`CREATE VIEW`, temporal rendering and filtered/sorted paging. Creates only `dz_e2e_*` objects and drops them in `after`. | | `sqlserver-live-ui.ts` | Live journey through the **real window**: connects via the navigator card, expands ` → → Tables`, opens the scratch table in the data grid and asserts every column header, the temporal cell text (`2026-03-01`, never `Date(`/`increments`) and next-page navigation. Reuses the host harness helpers (`e2e/helpers.ts`) for tree expansion, so it also exercises the same navigator code path as the other drivers' specs. | | `sqlserver-metadata.ts` | Live journey through `get_table_schema` IPC. Creates parent/child scratch tables with composite primary keys, a secondary index, a composite foreign key and a CHECK constraint; verifies catalog names, columns, actions and key ordering. It also checks that a non-default column collation is preserved or rejected explicitly, then drops the scratch objects. | +| `sqlserver-schema-diff.ts` | Real-window Schema Diff from a source database to a distinct target database on the configured server. Compares one same-schema table, generates and deploys its plan, then verifies target columns and primary key. | +| `sqlserver-data-sync.ts` | IPC Data Sync compare → selected INSERT preview → apply. Copies explicit identity values between same-shaped SQL Server tables, checks preview serialization and verifies the target rows. | +| `sqlserver-data-transfer.ts` | IPC Data Transfer preview → execute between two scratch schemas. Copies into a target identity table with a deliberately different seed, verifies source IDs were preserved, then confirms a normal generated-ID insert still works on the target session. | ## Prerequisites @@ -20,6 +23,9 @@ Optional WebdriverIO specs for the SQL Server path driver. Not included in defau at: …/target/debug/datazen`); run the specs with `pnpm e2e:skip-build` in that case. - A reachable SQL Server / Azure SQL Database instance whose login may create and drop scratch schemas, tables and views. +- The Schema Diff journey also needs two already-created, distinct databases on that + instance; the Data Sync and Data Transfer journeys use isolated schemas in the selected + database. ## Environment @@ -30,6 +36,8 @@ Optional WebdriverIO specs for the SQL Server path driver. Not included in defau | `E2E_SQLSERVER_USER` | Login user | | `E2E_SQLSERVER_PASSWORD` | Password | | `E2E_SQLSERVER_DATABASE` | Database to connect to (optional) | +| `E2E_SQLSERVER_SOURCE_DATABASE` | Schema Diff source database (falls back to `E2E_SQLSERVER_DATABASE`) | +| `E2E_SQLSERVER_TARGET_DATABASE` | Schema Diff target database; must differ from the source | | `E2E_SQLSERVER_SCHEMA` | Default schema used by the connection config (default `dbo`) | | `E2E_SQLSERVER_SSL_MODE` | `disable` \| `prefer` \| `require` \| `verifyCa` \| `verifyFull` (default `require`) | | `E2E_SQLSERVER_TRUST_CERT` | Set to `0` to validate the server certificate chain (default trusts it) | @@ -57,6 +65,21 @@ E2E_SQLSERVER_DATABASE=master \ E2E_SQLSERVER_HOST=127.0.0.1 E2E_SQLSERVER_USER=sa E2E_SQLSERVER_PASSWORD='YourPassword' \ E2E_SQLSERVER_DATABASE=master \ pnpm e2e:skip-build -- --spec packages/drivers/sqlserver/e2e/sqlserver-metadata.ts + +# Schema Diff compare, plan, and deploy (source and target databases must exist) +E2E_SQLSERVER_HOST=127.0.0.1 E2E_SQLSERVER_USER=sa E2E_SQLSERVER_PASSWORD='YourPassword' \ +E2E_SQLSERVER_SOURCE_DATABASE=DataZenSource E2E_SQLSERVER_TARGET_DATABASE=DataZenTarget \ + pnpm e2e:skip-build -- --spec packages/drivers/sqlserver/e2e/sqlserver-schema-diff.ts + +# Data Sync selected identity INSERT journey +E2E_SQLSERVER_HOST=127.0.0.1 E2E_SQLSERVER_USER=sa E2E_SQLSERVER_PASSWORD='YourPassword' \ +E2E_SQLSERVER_DATABASE=DataZen \ + pnpm e2e:skip-build -- --spec packages/drivers/sqlserver/e2e/sqlserver-data-sync.ts + +# Data Transfer into a target identity table, followed by a generated-ID insert +E2E_SQLSERVER_HOST=127.0.0.1 E2E_SQLSERVER_USER=sa E2E_SQLSERVER_PASSWORD='YourPassword' \ +E2E_SQLSERVER_DATABASE=DataZen \ + pnpm e2e:skip-build -- --spec packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts ``` Without credentials, the specs skip cleanly. The live specs make no assumption about diff --git a/packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts b/packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts index 18750fc8e..8bf37d2ac 100644 --- a/packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts +++ b/packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts @@ -15,11 +15,12 @@ const DATABASE = (process.env.E2E_SQLSERVER_DATABASE || '').trim(); const SSL_MODE = (process.env.E2E_SQLSERVER_SSL_MODE || 'require').trim(); const TRUST_CERT = process.env.E2E_SQLSERVER_TRUST_CERT !== '0'; -const SOURCE_CONNECTION_ID = 'e2e-sqlserver-transfer-source'; -const TARGET_CONNECTION_ID = 'e2e-sqlserver-transfer-target'; +const STAMP = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; +const SOURCE_CONNECTION_ID = `e2e-sqlserver-transfer-source-${STAMP}`; +const TARGET_CONNECTION_ID = `e2e-sqlserver-transfer-target-${STAMP}`; const TABLE = 'identity_rows'; -const SOURCE_SCHEMA = `dz_e2e_transfer_src_${Date.now().toString(36)}`; -const TARGET_SCHEMA = `dz_e2e_transfer_tgt_${Date.now().toString(36)}`; +const SOURCE_SCHEMA = `dz_e2e_transfer_src_${STAMP}`; +const TARGET_SCHEMA = `dz_e2e_transfer_tgt_${STAMP}`; interface StatementPayload { rows: Array>; @@ -62,7 +63,8 @@ async function invoke(command: string, args: Record = {}): P JSON.stringify(args), ); if (result && typeof result === 'object' && '__error' in (result as Record)) { - throw new Error(String((result as { __error: string }).__error)); + const detail = String((result as { __error: string }).__error); + throw new Error(PASSWORD ? detail.replaceAll(PASSWORD, '[redacted]') : detail); } return result as T; } diff --git a/packages/drivers/sqlserver/e2e/sqlserver-schema-diff.ts b/packages/drivers/sqlserver/e2e/sqlserver-schema-diff.ts index 0830c0356..07fab5aea 100644 --- a/packages/drivers/sqlserver/e2e/sqlserver-schema-diff.ts +++ b/packages/drivers/sqlserver/e2e/sqlserver-schema-diff.ts @@ -91,7 +91,8 @@ async function invoke(command: string, args: Record = {}): P JSON.stringify(args), ); if (result && typeof result === 'object' && '__error' in (result as Record)) { - throw new Error(String((result as { __error: string }).__error)); + const detail = String((result as { __error: string }).__error); + throw new Error(PASSWORD ? detail.replaceAll(PASSWORD, '[redacted]') : detail); } return result as T; } From 303419e06efbe51806a49008b16e70a050fa787d Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 11:54:32 +0800 Subject: [PATCH 020/666] test(sqlserver): cover column metadata edge cases --- packages/drivers/sqlserver/src/metadata.rs | 277 +++++++++++++++------ 1 file changed, 205 insertions(+), 72 deletions(-) diff --git a/packages/drivers/sqlserver/src/metadata.rs b/packages/drivers/sqlserver/src/metadata.rs index 49400684c..5fa594d45 100644 --- a/packages/drivers/sqlserver/src/metadata.rs +++ b/packages/drivers/sqlserver/src/metadata.rs @@ -501,6 +501,8 @@ fn incomplete(message: impl Into) -> DriverError { mod tests { use super::*; + const DATABASE_COLLATION: &str = "SQL_Latin1_General_CP1_CI_AS"; + fn result(rows: Vec>>) -> QueryResult { QueryResult { columns: Vec::new(), @@ -518,6 +520,51 @@ mod tests { Some(Value::Bool(value)) } + /// A representative row returned by `columns_sql`, with all SQL Server-only + /// metadata flags clear and no per-column collation override. + fn column_row(name: &str) -> Vec> { + vec![ + text(name), + text("int"), + bit(true), + bit(false), + None, + None, + bit(false), + Some(Value::Integer(0)), + bit(false), + Some(Value::Integer(0)), + bit(false), + bit(false), + bit(false), + bit(false), + bit(false), + bit(false), + None, + text(DATABASE_COLLATION), + ] + } + + fn assert_unsupported_column(row: Vec>, reason: &str, case: &str) { + match parse_columns(result(vec![row])) { + Err(DriverError::Unsupported(message)) => assert!( + message.contains(reason), + "{case}: expected rejection mentioning {reason:?}, got {message:?}" + ), + other => panic!("{case}: expected Unsupported, got {other:?}"), + } + } + + fn assert_incomplete_column(row: Vec>, reason: &str, case: &str) { + match parse_columns(result(vec![row])) { + Err(DriverError::QueryFailed(message)) => assert!( + message.contains(reason), + "{case}: expected incomplete metadata mentioning {reason:?}, got {message:?}" + ), + other => panic!("{case}: expected incomplete metadata, got {other:?}"), + } + } + #[test] fn metadata_queries_quote_catalog_and_bind_object_names() { let columns = columns_sql("db]"); @@ -541,29 +588,12 @@ mod tests { #[test] fn composite_primary_key_keeps_catalog_key_order() { - let mut a = vec![ - text("first"), - text("int"), - bit(false), - bit(false), - None, - None, - bit(true), - Some(Value::Integer(2)), - bit(false), - Some(Value::Integer(0)), - bit(false), - bit(false), - bit(false), - bit(false), - bit(false), - bit(false), - None, - text("Latin1_General_100_CI_AS_SC_UTF8"), - ]; + let mut a = column_row("first"); let mut b = a.clone(); a[0] = text("a"); b[0] = text("z"); + a[6] = bit(true); + b[6] = bit(true); a[7] = Some(Value::Integer(2)); b[7] = Some(Value::Integer(1)); let (columns, keys) = parse_columns(result(vec![a, b])).expect("parse columns"); @@ -573,35 +603,167 @@ mod tests { #[test] fn duplicate_primary_key_ordinals_are_rejected() { - let mut first = vec![ - text("first"), - text("int"), - bit(false), - bit(false), - None, - None, - bit(true), - Some(Value::Integer(1)), - bit(false), - Some(Value::Integer(0)), - bit(false), - bit(false), - bit(false), - bit(false), - bit(false), - bit(false), - None, - text("Latin1_General_100_CI_AS_SC_UTF8"), - ]; + let mut first = column_row("first"); let mut second = first.clone(); - first[0] = text("first"); second[0] = text("second"); + first[6] = bit(true); + second[6] = bit(true); + first[7] = Some(Value::Integer(1)); + second[7] = Some(Value::Integer(1)); let parsed = parse_columns(result(vec![first, second])); assert!( - matches!(parsed, Err(DriverError::QueryFailed(ref message)) if message.contains("duplicate") || message.contains("ordinal")), - "duplicate primary-key ordinals should fail as incomplete metadata, got {parsed:?}" + matches!( + parsed, + Err(DriverError::QueryFailed(ref message)) + if message.contains("duplicate primary-key ordinal 1") + ), + "duplicate primary-key ordinals should identify the duplicate ordinal, got {parsed:?}" + ); + } + + #[test] + fn sql_server_only_column_features_are_rejected_with_specific_reasons() { + let cases = [ + ( + 8, + Some(Value::Bool(true)), + "computed columns", + "computed column", + ), + ( + 9, + Some(Value::Integer(1)), + "generated-always columns", + "temporal row-start column", + ), + ( + 9, + Some(Value::Integer(2)), + "generated-always columns", + "temporal row-end column", + ), + ( + 10, + Some(Value::Bool(true)), + "FILESTREAM columns", + "FILESTREAM", + ), + (11, Some(Value::Bool(true)), "sparse columns", "sparse"), + ( + 12, + Some(Value::Bool(true)), + "column-set columns", + "XML column set", + ), + ( + 14, + Some(Value::Bool(true)), + "rowversion columns", + "rowversion", + ), + ( + 15, + Some(Value::Bool(true)), + "non-default IDENTITY seed/increment", + "IDENTITY(10, 1) seed", + ), + ( + 15, + Some(Value::Bool(true)), + "non-default IDENTITY seed/increment", + "IDENTITY(1, 5) increment", + ), + ( + 13, + Some(Value::Bool(true)), + "legacy bound DEFAULT", + "legacy bound default", + ), + ]; + + for (index, value, reason, case) in cases { + let mut row = column_row(case); + row[index] = value; + assert_unsupported_column(row, reason, case); + } + } + + #[test] + fn nondefault_collation_is_rejected_and_default_collation_is_case_insensitive() { + let mut row = column_row("label"); + row[1] = text("nvarchar(40)"); + row[16] = text("Latin1_General_100_CI_AS_SC_UTF8"); + assert_unsupported_column(row, "non-default collation", "column collation override"); + + let mut row = column_row("label"); + row[1] = text("nvarchar(40)"); + row[16] = text("sql_latin1_general_cp1_ci_as"); + row[17] = text("SQL_Latin1_General_CP1_CI_AS"); + assert!( + parse_columns(result(vec![row])).is_ok(), + "same database collation with different case should be accepted" + ); + } + + #[test] + fn ordinary_identity_and_default_metadata_remain_representable() { + let mut row = column_row("id"); + row[1] = text("int"); + row[3] = bit(true); + row[4] = text("((1))"); + row[6] = bit(true); + row[7] = Some(Value::Integer(1)); + row[15] = bit(false); // IDENTITY(1, 1) has the supported seed/increment. + + let (columns, primary_keys) = + parse_columns(result(vec![row])).expect("parse ordinary identity"); + assert_eq!(primary_keys, vec!["id"]); + assert_eq!(columns[0].default_value.as_deref(), Some("((1))")); + assert!(columns[0].is_auto_increment); + assert!(columns[0].is_primary_key); + } + + #[test] + fn incomplete_or_invalid_catalog_rows_name_the_missing_metadata() { + let short_row = column_row("id")[..17].to_vec(); + assert_incomplete_column( + short_row, + "omitted column collation metadata", + "short catalog row", + ); + + let cases = [ + (0, None, "column name"), + (1, None, "declared column type"), + (2, None, "nullability"), + (3, None, "identity flag"), + (6, None, "primary key flag"), + (7, None, "primary key ordinal"), + (17, None, "database collation"), + (17, text(""), "database collation"), + ]; + for (index, value, reason) in cases { + let mut row = column_row("id"); + row[index] = value; + assert_incomplete_column(row, reason, reason); + } + + let mut invalid_ordinal = column_row("id"); + invalid_ordinal[6] = bit(true); + invalid_ordinal[7] = text("not-an-ordinal"); + assert_incomplete_column( + invalid_ordinal, + "primary key ordinal", + "invalid key ordinal type", ); + + for ordinal in [0, -1] { + let mut invalid_ordinal = column_row("id"); + invalid_ordinal[6] = bit(true); + invalid_ordinal[7] = Some(Value::Integer(ordinal)); + assert_incomplete_column(invalid_ordinal, "no key ordinal", "nonpositive key ordinal"); + } } #[test] @@ -660,35 +822,6 @@ mod tests { ); } - #[test] - fn nondefault_column_collation_is_rejected() { - let row = vec![ - text("label"), - text("nvarchar(40)"), - bit(true), - bit(false), - None, - None, - bit(false), - Some(Value::Integer(0)), - bit(false), - Some(Value::Integer(0)), - bit(false), - bit(false), - bit(false), - bit(false), - bit(false), - bit(false), - text("Latin1_General_100_CI_AS_SC_UTF8"), - text("SQL_Latin1_General_CP1_CI_AS"), - ]; - - assert!(matches!( - parse_columns(result(vec![row])), - Err(DriverError::Unsupported(message)) if message.contains("non-default collation") - )); - } - #[test] fn filtered_and_untrusted_objects_fail_closed() { let index_row = vec![ From 5f21cdf1b8d6a9ffd9e12b79bf4ab1bade2c6388 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 12:05:27 +0800 Subject: [PATCH 021/666] test(sqlserver): cover metadata edge cases --- .../sqlserver/e2e/sqlserver-metadata.ts | 125 +++++++++++++++++- 1 file changed, 118 insertions(+), 7 deletions(-) diff --git a/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts index dc85708cb..9c38e5392 100644 --- a/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts +++ b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts @@ -21,6 +21,12 @@ const scratchSchema = `dz_e2e_meta_${Date.now().toString(36)}`; const parentTable = 'parent_pair'; const childTable = 'child_pair'; const collationTable = 'collation_probe'; +const identityTable = 'identity_probe'; +const nonDefaultIdentityTable = 'identity_seed_probe'; +const computedTable = 'computed_probe'; +const rowversionTable = 'rowversion_probe'; +const filteredIndexTable = 'filtered_index_probe'; +const includedIndexTable = 'included_index_probe'; let nonDefaultCollation = ''; interface ColumnSchemaPayload { @@ -28,6 +34,7 @@ interface ColumnSchemaPayload { dataType: string; nullable: boolean; isPrimaryKey: boolean; + isAutoIncrement: boolean; } interface IndexPayload { @@ -130,14 +137,26 @@ describe('SQL Server schema metadata IPC (live)', () => { const cleanup = async () => { if (dbSessionId) { try { - await setSafeMode(false); - for (const table of [childTable, parentTable, collationTable]) { + await setSafeMode(false).catch(() => undefined); + for (const table of [ + childTable, + parentTable, + collationTable, + identityTable, + nonDefaultIdentityTable, + computedTable, + rowversionTable, + filteredIndexTable, + includedIndexTable, + ]) { await run(`DROP TABLE IF EXISTS ${qualified(table)}`).catch((error: unknown) => { console.warn(`SQL Server metadata cleanup could not drop ${table}: ${String(error)}`); }); } await run(`DROP SCHEMA IF EXISTS ${bracket(scratchSchema)}`).catch((error: unknown) => { - console.warn(`SQL Server metadata cleanup could not drop scratch schema: ${String(error)}`); + console.warn( + `SQL Server metadata cleanup could not drop scratch schema: ${String(error)}`, + ); }); } finally { await invoke('disconnect', { dbSessionId }).catch(() => undefined); @@ -221,6 +240,32 @@ describe('SQL Server schema metadata IPC (live)', () => { await run( `CREATE TABLE ${qualified(collationTable)} ([text_value] NVARCHAR(20) COLLATE ${nonDefaultCollation} NULL)`, ); + await run( + `CREATE TABLE ${qualified(identityTable)} (` + + '[id] INT IDENTITY(1,1) NOT NULL PRIMARY KEY, [label] NVARCHAR(40) NULL)', + ); + await run( + `CREATE TABLE ${qualified(nonDefaultIdentityTable)} (` + + '[id] INT IDENTITY(10,2) NOT NULL, [label] NVARCHAR(40) NULL)', + ); + await run( + `CREATE TABLE ${qualified(computedTable)} (` + + '[base_value] INT NOT NULL, [computed_value] AS ([base_value] * 2) PERSISTED)', + ); + await run( + `CREATE TABLE ${qualified(rowversionTable)} (` + + '[id] INT NOT NULL, [row_version] ROWVERSION)', + ); + await run(`CREATE TABLE ${qualified(filteredIndexTable)} ([value] INT NULL)`); + await run( + `CREATE NONCLUSTERED INDEX [IX_filtered_index_probe] ON ${qualified(filteredIndexTable)} ([value]) WHERE [value] IS NOT NULL`, + ); + await run( + `CREATE TABLE ${qualified(includedIndexTable)} ([id] INT NOT NULL, [payload] NVARCHAR(40) NULL)`, + ); + await run( + `CREATE NONCLUSTERED INDEX [IX_included_index_probe] ON ${qualified(includedIndexTable)} ([id]) INCLUDE ([payload])`, + ); }); after(async function () { @@ -232,10 +277,9 @@ describe('SQL Server schema metadata IPC (live)', () => { const parent = await tableSchema(parentTable); expect(parent.columns.map((column) => column.name)).toEqual(['part_b', 'part_a', 'label']); expect(parent.primaryKeys).toEqual(['part_a', 'part_b']); - expect(parent.columns.filter((column) => column.isPrimaryKey).map((column) => column.name)).toEqual([ - 'part_b', - 'part_a', - ]); + expect( + parent.columns.filter((column) => column.isPrimaryKey).map((column) => column.name), + ).toEqual(['part_b', 'part_a']); const child = await tableSchema(childTable); expect(child.primaryKeys).toEqual(['part_b', 'part_a']); @@ -270,4 +314,71 @@ describe('SQL Server schema metadata IPC (live)', () => { } expect(error.toLowerCase()).toMatch(/collation|unsupported|not represent/); }); + + it('preserves SQL Server IDENTITY(1,1) as the auto-increment flag', async () => { + const schema = await tableSchema(identityTable); + expect(schema.columns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'id', isAutoIncrement: true }), + expect.objectContaining({ name: 'label', isAutoIncrement: false }), + ]), + ); + }); + + it('rejects non-default IDENTITY seed and increment values explicitly', async () => { + let error = ''; + try { + await tableSchema(nonDefaultIdentityTable); + } catch (cause) { + error = String(cause); + } + expect(error).toMatch(/unsupported/i); + expect(error).toMatch(/non-default IDENTITY seed\/increment/i); + }); + + it('rejects computed columns with an explicit Unsupported error', async () => { + let error = ''; + try { + await tableSchema(computedTable); + } catch (cause) { + error = String(cause); + } + expect(error).toMatch(/unsupported/i); + expect(error).toMatch(/computed columns/i); + }); + + it('rejects rowversion columns with an explicit Unsupported error', async () => { + let error = ''; + try { + await tableSchema(rowversionTable); + } catch (cause) { + error = String(cause); + } + expect(error).toMatch(/unsupported/i); + expect(error).toMatch(/rowversion columns/i); + }); + + it('rejects filtered indexes with the index name and unsupported feature in the error', async () => { + let error = ''; + try { + await tableSchema(filteredIndexTable); + } catch (cause) { + error = String(cause); + } + expect(error).toMatch(/unsupported/i); + expect(error).toContain('IX_filtered_index_probe'); + expect(error).toMatch(/filtered/i); + }); + + it('rejects INCLUDE index columns with the index name and unsupported feature in the error', async () => { + let error = ''; + try { + await tableSchema(includedIndexTable); + } catch (cause) { + error = String(cause); + } + expect(error).toMatch(/unsupported/i); + expect(error).toContain('IX_included_index_probe'); + expect(error).toMatch(/INCLUDE or descending key columns/i); + }); }); From 99c7fe502dce5250c26e60fb6a1151839af62bfc Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 12:09:53 +0800 Subject: [PATCH 022/666] test(sqlserver): cover temporal metadata rejection --- .../sqlserver/e2e/sqlserver-metadata.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts index 9c38e5392..72a05810e 100644 --- a/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts +++ b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts @@ -25,6 +25,8 @@ const identityTable = 'identity_probe'; const nonDefaultIdentityTable = 'identity_seed_probe'; const computedTable = 'computed_probe'; const rowversionTable = 'rowversion_probe'; +const temporalTable = 'temporal_probe'; +const temporalHistoryTable = 'temporal_history_probe'; const filteredIndexTable = 'filtered_index_probe'; const includedIndexTable = 'included_index_probe'; let nonDefaultCollation = ''; @@ -138,6 +140,9 @@ describe('SQL Server schema metadata IPC (live)', () => { if (dbSessionId) { try { await setSafeMode(false).catch(() => undefined); + await run( + `ALTER TABLE ${qualified(temporalTable)} SET (SYSTEM_VERSIONING = OFF)`, + ).catch(() => undefined); for (const table of [ childTable, parentTable, @@ -146,6 +151,8 @@ describe('SQL Server schema metadata IPC (live)', () => { nonDefaultIdentityTable, computedTable, rowversionTable, + temporalTable, + temporalHistoryTable, filteredIndexTable, includedIndexTable, ]) { @@ -256,6 +263,14 @@ describe('SQL Server schema metadata IPC (live)', () => { `CREATE TABLE ${qualified(rowversionTable)} (` + '[id] INT NOT NULL, [row_version] ROWVERSION)', ); + await run( + `CREATE TABLE ${qualified(temporalTable)} (` + + '[id] INT NOT NULL PRIMARY KEY CLUSTERED, ' + + '[valid_from] DATETIME2 GENERATED ALWAYS AS ROW START NOT NULL, ' + + '[valid_to] DATETIME2 GENERATED ALWAYS AS ROW END NOT NULL, ' + + 'PERIOD FOR SYSTEM_TIME ([valid_from], [valid_to])) ' + + `WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = ${qualified(temporalHistoryTable)}))`, + ); await run(`CREATE TABLE ${qualified(filteredIndexTable)} ([value] INT NULL)`); await run( `CREATE NONCLUSTERED INDEX [IX_filtered_index_probe] ON ${qualified(filteredIndexTable)} ([value]) WHERE [value] IS NOT NULL`, @@ -358,6 +373,17 @@ describe('SQL Server schema metadata IPC (live)', () => { expect(error).toMatch(/rowversion columns/i); }); + it('rejects temporal generated-always columns with an explicit Unsupported error', async () => { + let error = ''; + try { + await tableSchema(temporalTable); + } catch (cause) { + error = String(cause); + } + expect(error).toMatch(/unsupported/i); + expect(error).toMatch(/generated-always columns/i); + }); + it('rejects filtered indexes with the index name and unsupported feature in the error', async () => { let error = ''; try { From 844e714b0f87ea7ff4fe8fb0d4f4b09a870adbbf Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 12:10:43 +0800 Subject: [PATCH 023/666] test(sqlserver): fail closed on incomplete column flags --- packages/drivers/sqlserver/src/metadata.rs | 103 +++++++++++++++++++-- 1 file changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/drivers/sqlserver/src/metadata.rs b/packages/drivers/sqlserver/src/metadata.rs index 5fa594d45..667baba03 100644 --- a/packages/drivers/sqlserver/src/metadata.rs +++ b/packages/drivers/sqlserver/src/metadata.rs @@ -521,7 +521,11 @@ mod tests { } /// A representative row returned by `columns_sql`, with all SQL Server-only - /// metadata flags clear and no per-column collation override. + /// metadata flags clear and no per-column collation override. Keep this in + /// the query projection order: name, type, nullable, identity, default, + /// comment, PK flag, PK ordinal, computed, generated-always, FILESTREAM, + /// sparse, column-set, legacy default, rowversion, non-default identity, + /// column collation, database collation. fn column_row(name: &str) -> Vec> { vec![ text(name), @@ -575,6 +579,37 @@ mod tests { assert!( columns.contains("DATABASEPROPERTYEX(CASE WHEN @P3 = N'' THEN DB_NAME() ELSE @P3 END") ); + let projection = columns + .split(" FROM [db]]].sys.columns") + .next() + .expect("column query has a catalog source"); + let expected_projection_order = [ + "AS column_name", + "AS data_type", + "c.is_nullable", + "c.is_identity", + "AS default_value", + "AS comment", + "AS is_pk", + "AS pk_ordinal", + "AS is_computed", + "c.generated_always_type", + "c.is_filestream", + "c.is_sparse", + "c.is_column_set", + "AS has_legacy_default", + "AS is_rowversion", + "AS has_nondefault_identity", + "AS column_collation", + "AS database_collation", + ]; + let mut cursor = 0; + for expression in expected_projection_order { + let Some(offset) = projection[cursor..].find(expression) else { + panic!("column catalog projection is missing or reorders {expression:?}"); + }; + cursor += offset + expression.len(); + } assert!(!columns.contains("@P1'")); let schema_identity = schema_scope_identity_sql("db]; injected"); assert_eq!( @@ -586,6 +621,32 @@ mod tests { assert!(checks_sql("db").contains("cc.is_not_trusted")); } + #[test] + fn identity_metadata_projection_checks_nondefault_seed_and_increment() { + let columns = columns_sql(""); + let identity_guard = columns + .split("AS has_nondefault_identity") + .next() + .expect("identity guard has a result alias"); + for (case, expression) in [ + ( + "IDENTITY(10, 1) seed-only deviation", + "TRY_CONVERT(decimal(38,0), ic.seed_value) <> 1", + ), + ( + "IDENTITY(1, 5) increment-only deviation", + "TRY_CONVERT(decimal(38,0), ic.increment_value) <> 1", + ), + ] { + assert!( + identity_guard.contains(expression), + "{case} must set the aggregate non-default identity flag" + ); + } + assert!(identity_guard.contains("ic.seed_value) <> 1 OR TRY_CONVERT")); + assert!(columns.contains("AS has_nondefault_identity")); + } + #[test] fn composite_primary_key_keeps_catalog_key_order() { let mut a = column_row("first"); @@ -666,13 +727,7 @@ mod tests { 15, Some(Value::Bool(true)), "non-default IDENTITY seed/increment", - "IDENTITY(10, 1) seed", - ), - ( - 15, - Some(Value::Bool(true)), "non-default IDENTITY seed/increment", - "IDENTITY(1, 5) increment", ), ( 13, @@ -689,6 +744,40 @@ mod tests { } } + #[test] + fn missing_or_unreadable_sql_server_column_flags_fail_closed() { + let cases = [ + (8, "computed"), + (9, "generated-always"), + (10, "FILESTREAM"), + (11, "sparse"), + (12, "column-set"), + (13, "legacy default"), + (14, "rowversion"), + (15, "IDENTITY"), + ]; + let mut failures = Vec::new(); + + for (index, feature) in cases { + for (fault, value) in [("NULL", None), ("unreadable", text("not-a-bit"))] { + let mut row = column_row("special_column"); + row[index] = value; + match parse_columns(result(vec![row])) { + Err(DriverError::QueryFailed(message)) if message.contains(feature) => {} + other => failures.push(format!( + "{feature} flag {fault} at projection index {index}: got {other:?}" + )), + } + } + } + + assert!( + failures.is_empty(), + "SQL Server-specific catalog flags must fail closed when absent or unreadable:\n{}", + failures.join("\n") + ); + } + #[test] fn nondefault_collation_is_rejected_and_default_collation_is_case_insensitive() { let mut row = column_row("label"); From 3849d6b68a040e37ba6dfd0e945511e572644a00 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 12:11:06 +0800 Subject: [PATCH 024/666] test(sqlserver): cover T-SQL local parameters in UI E2E --- .../sqlserver/e2e/sqlserver-live-ui.ts | 145 +++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts b/packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts index c114b370a..68fdb3e1f 100644 --- a/packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts +++ b/packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts @@ -22,16 +22,20 @@ * pnpm e2e:skip-build -- --spec packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts */ import { mkdirSync } from 'node:fs'; -import { browser, $ } from '@wdio/globals'; +import { browser, $, expect } from '@wdio/globals'; import { clickCardConnectButton, ensureMainWindowForIpc, expandAllGroups, expandConnectedConnectionInNavigator, expandSchemaCategory, + openConnectionsWorkspace, + openQueryTab, + setEditorContent, waitForConnectionToolbar, waitForSchemaTreeLoaded, } from '../../../../e2e/helpers.js'; +import { selectSqlEditorSubstring } from '../../../../e2e/helpers/sqlEditorHelper.js'; const HOST = (process.env.E2E_SQLSERVER_HOST || '').trim(); const PORT = Number(process.env.E2E_SQLSERVER_PORT || '1433'); @@ -48,6 +52,7 @@ const COLUMNS = ['id', 'label', 'created_on', 'recorded_at', 'amount', 'payload' const ROW_COUNT = 120; const SCREENSHOT_DIR = 'e2e/screenshots/sqlserver-driver-ui'; const RESUME_CODE = 'not currently available'; +const FUNCTION_NAME = 'fn_NormalizeCode'; const scratchSchema = `dz_e2e_ui_${Date.now().toString(36)}`; const scratchTableName = 'e2e_ui_rows'; @@ -137,6 +142,7 @@ describe('SQL Server driver GUI (live)', () => { const dropScratch = async () => { for (const statement of [ + `DROP FUNCTION IF EXISTS [${scratchSchema}].[${FUNCTION_NAME}]`, `DROP TABLE IF EXISTS [${scratchSchema}].[${scratchTableName}]`, `DROP SCHEMA IF EXISTS [${scratchSchema}]`, ]) { @@ -148,6 +154,58 @@ describe('SQL Server driver GUI (live)', () => { } }; + /** + * Execute a selected SQL string through the actual SQL Editor gate, then + * return whichever observable outcome it produced. Selecting the full text + * avoids the editor's current-statement mode splitting routine bodies at + * their internal semicolons before the SQL reaches the driver. + */ + const executeSelectedSql = async ( + sql: string, + ): Promise<{ + executionAdvanced: boolean; + gateMessage: string | null; + queryError: string | null; + }> => { + await setEditorContent(sql); + if (!(await selectSqlEditorSubstring(sql))) { + throw new Error('the SQL Editor did not select the complete SQL text'); + } + + const queryPanel = await $('[data-testid="query-panel"]'); + const beforeSeq = Number((await queryPanel.getAttribute('data-execution-seq')) ?? '0'); + await $('[data-testid="editor-execute-button"]').click(); + await browser.waitUntil( + async () => { + const currentSeq = Number((await queryPanel.getAttribute('data-execution-seq')) ?? '0'); + const gate = await $('[data-testid="result-message-ok"]'); + const error = await $('[data-testid="query-error-message"]'); + const executeButton = await $('[data-testid="editor-execute-button"]'); + return ( + (currentSeq > beforeSeq && (await executeButton.isDisplayed().catch(() => false))) || + (await gate.isDisplayed().catch(() => false)) || + (await error.isDisplayed().catch(() => false)) + ); + }, + { timeout: 20000, timeoutMsg: 'the SQL Editor produced no execution or diagnostic result' }, + ); + + const gateButton = await $('[data-testid="result-message-ok"]'); + let gateMessage: string | null = null; + if (await gateButton.isDisplayed().catch(() => false)) { + gateMessage = await $('body').getText(); + await gateButton.click(); + } + const queryError = await browser.execute(() => { + const error = Array.from( + document.querySelectorAll('[data-testid="query-error-message"]'), + ).find((element) => element.getClientRects().length > 0); + return error?.innerText.trim() || null; + }); + const afterSeq = Number((await queryPanel.getAttribute('data-execution-seq')) ?? '0'); + return { executionAdvanced: afterSeq > beforeSeq, gateMessage, queryError }; + }; + before(async function () { this.timeout(180_000); reason = skipReason(); @@ -391,4 +449,89 @@ describe('SQL Server driver GUI (live)', () => { mkdirSync(SCREENSHOT_DIR, { recursive: true }); await browser.saveScreenshot(`${SCREENSHOT_DIR}/table-grid.png`); }); + + it('creates and calls a scalar function with a T-SQL local parameter in the SQL Editor', async function () { + this.timeout(120_000); + + // The `execute_query` IPC helper bypasses the SQL Editor's bind-parameter + // gate, so this regression deliberately enters through the real UI. The + // connection was seeded after the initial app mount; reload to refresh the + // navigator, then connect through the same path a user follows. + await ensureMainWindowForIpc(); + await browser.execute(() => location.reload()); + await browser.waitUntil( + async () => + browser.execute(() => !!document.querySelector('[data-testid="workspace-nav-databases"]')), + { + timeout: 60000, + timeoutMsg: 'the workspace did not remount after refreshing the navigator', + }, + ); + await openConnectionsWorkspace(); + await expandAllGroups(); + await browser.waitUntil( + async () => + browser.execute( + (name: string) => + Array.from(document.querySelectorAll('[data-conn-item]')).some((item) => + (item.getAttribute('data-conn-name') || item.textContent || '').includes(name), + ), + CONNECTION_NAME, + ), + { timeout: 20000, timeoutMsg: 'the SQL Server connection did not appear in the navigator' }, + ); + await clickCardConnectButton(CONNECTION_NAME); + await waitForConnectionToolbar(); + await openQueryTab(); + + const createFunctionSql = + `CREATE FUNCTION [${scratchSchema}].[${FUNCTION_NAME}]\n` + + '(\n' + + ' @value NVARCHAR(64)\n' + + ')\n' + + 'RETURNS NVARCHAR(64)\n' + + 'AS\n' + + 'BEGIN\n' + + ' RETURN UPPER(LTRIM(RTRIM(@value)));\n' + + 'END;'; + const createResult = await executeSelectedSql(createFunctionSql); + expect( + createResult.gateMessage, + 'T-SQL @value must not be treated as an unbound editor parameter', + ).toBeNull(); + expect( + createResult.queryError, + 'SQL Server must accept the scalar function definition', + ).toBeNull(); + expect(createResult.executionAdvanced).toBe(true); + + const callSql = `SELECT [${scratchSchema}].[${FUNCTION_NAME}](N' Mixed Code ') AS [normalized]`; + const callResult = await executeSelectedSql(callSql); + expect(callResult.gateMessage).toBeNull(); + expect(callResult.queryError).toBeNull(); + expect(callResult.executionAdvanced).toBe(true); + await browser.waitUntil( + async () => + browser.execute(() => + Array.from(document.querySelectorAll('[data-testid="data-table-cell"]')) + .filter((cell) => cell.getClientRects().length > 0) + .some((cell) => cell.innerText.trim() === 'MIXED CODE'), + ), + { timeout: 15000, timeoutMsg: 'the scalar function did not return trimmed uppercase text' }, + ); + }); + + it('shows a SQL Server missing-object diagnostic for invalid SQL in the editor', async function () { + this.timeout(60_000); + const missingObject = 'dz_e2e_intentionally_missing_object'; + const result = await executeSelectedSql(`SELECT * FROM [${scratchSchema}].[${missingObject}]`); + + expect( + result.gateMessage, + 'invalid SQL must reach SQL Server instead of stopping at the bind gate', + ).toBeNull(); + expect(result.executionAdvanced).toBe(true); + expect(result.queryError).toContain(missingObject); + expect(result.queryError).toMatch(/invalid object name/i); + }); }); From b2c61da456211ef8d025ed5c8036decc7a849199 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 12:12:29 +0800 Subject: [PATCH 025/666] test(sqlserver): isolate identity metadata edge cases --- .../sqlserver/e2e/sqlserver-metadata.ts | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts index 72a05810e..5d9bd4e08 100644 --- a/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts +++ b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts @@ -22,7 +22,8 @@ const parentTable = 'parent_pair'; const childTable = 'child_pair'; const collationTable = 'collation_probe'; const identityTable = 'identity_probe'; -const nonDefaultIdentityTable = 'identity_seed_probe'; +const identitySeedTable = 'identity_seed_probe'; +const identityIncrementTable = 'identity_increment_probe'; const computedTable = 'computed_probe'; const rowversionTable = 'rowversion_probe'; const temporalTable = 'temporal_probe'; @@ -140,15 +141,16 @@ describe('SQL Server schema metadata IPC (live)', () => { if (dbSessionId) { try { await setSafeMode(false).catch(() => undefined); - await run( - `ALTER TABLE ${qualified(temporalTable)} SET (SYSTEM_VERSIONING = OFF)`, - ).catch(() => undefined); + await run(`ALTER TABLE ${qualified(temporalTable)} SET (SYSTEM_VERSIONING = OFF)`).catch( + () => undefined, + ); for (const table of [ childTable, parentTable, collationTable, identityTable, - nonDefaultIdentityTable, + identitySeedTable, + identityIncrementTable, computedTable, rowversionTable, temporalTable, @@ -252,8 +254,12 @@ describe('SQL Server schema metadata IPC (live)', () => { '[id] INT IDENTITY(1,1) NOT NULL PRIMARY KEY, [label] NVARCHAR(40) NULL)', ); await run( - `CREATE TABLE ${qualified(nonDefaultIdentityTable)} (` + - '[id] INT IDENTITY(10,2) NOT NULL, [label] NVARCHAR(40) NULL)', + `CREATE TABLE ${qualified(identitySeedTable)} (` + + '[id] INT IDENTITY(10,1) NOT NULL, [label] NVARCHAR(40) NULL)', + ); + await run( + `CREATE TABLE ${qualified(identityIncrementTable)} (` + + '[id] INT IDENTITY(1,2) NOT NULL, [label] NVARCHAR(40) NULL)', ); await run( `CREATE TABLE ${qualified(computedTable)} (` + @@ -340,10 +346,21 @@ describe('SQL Server schema metadata IPC (live)', () => { ); }); - it('rejects non-default IDENTITY seed and increment values explicitly', async () => { + it('rejects a non-default IDENTITY seed explicitly', async () => { + let error = ''; + try { + await tableSchema(identitySeedTable); + } catch (cause) { + error = String(cause); + } + expect(error).toMatch(/unsupported/i); + expect(error).toMatch(/non-default IDENTITY seed\/increment/i); + }); + + it('rejects a non-default IDENTITY increment explicitly', async () => { let error = ''; try { - await tableSchema(nonDefaultIdentityTable); + await tableSchema(identityIncrementTable); } catch (cause) { error = String(cause); } From a173b2bdf951fe08eb2495c3ad2ab60e9597fa7b Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 12:13:30 +0800 Subject: [PATCH 026/666] fix(sqlserver): reject incomplete column flags --- packages/drivers/sqlserver/src/metadata.rs | 42 ++++++++++++++-------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/drivers/sqlserver/src/metadata.rs b/packages/drivers/sqlserver/src/metadata.rs index 667baba03..05b38aaab 100644 --- a/packages/drivers/sqlserver/src/metadata.rs +++ b/packages/drivers/sqlserver/src/metadata.rs @@ -148,26 +148,40 @@ pub(crate) fn parse_columns( ))); } } - for (index, reason) in [ - (8, "computed columns"), - (9, "generated-always columns"), - (10, "FILESTREAM columns"), - (11, "sparse columns"), - (12, "column-set columns"), - (14, "rowversion columns"), - (15, "non-default IDENTITY seed/increment"), + for (index, label, reason) in [ + (8, "computed-column flag", "computed columns"), + (9, "generated-always type", "generated-always columns"), + (10, "FILESTREAM flag", "FILESTREAM columns"), + (11, "sparse-column flag", "sparse columns"), + (12, "column-set flag", "column-set columns"), + (13, "legacy default flag", "legacy bound DEFAULT"), + (14, "rowversion flag", "rowversion columns"), + ( + 15, + "non-default IDENTITY seed/increment flag", + "non-default IDENTITY seed/increment", + ), ] { - if optional_bool(&row, index) == Some(true) { + // SQL Server returns generated_always_type as an integer: zero is + // ordinary, while 1/2 identify temporal row-start/row-end columns. + // Reject every non-zero value so newer generated column kinds also + // remain fail-closed. + let flag_is_set = if index == 9 { + required_integer(&row, index, label)? != 0 + } else { + required_bool(&row, index, label)? + }; + if flag_is_set && index == 13 { + return Err(unsupported( + "SQL Server table uses a legacy bound DEFAULT; its expression is not represented by sys.default_constraints", + )); + } + if flag_is_set { return Err(unsupported(format!( "SQL Server table contains {reason}; this metadata contract cannot represent it safely" ))); } } - if optional_bool(&row, 13) == Some(true) { - return Err(unsupported( - "SQL Server table uses a legacy bound DEFAULT; its expression is not represented by sys.default_constraints", - )); - } if is_primary_key { if key_ordinal <= 0 { return Err(incomplete("primary key column has no key ordinal")); From 0d5ec5f50a504763d8f4ecda62ac79f267f3cc80 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 12:16:28 +0800 Subject: [PATCH 027/666] test(sqlserver): assert unsupported collation metadata --- packages/drivers/sqlserver/e2e/sqlserver-metadata.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts index 5d9bd4e08..938f639ca 100644 --- a/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts +++ b/packages/drivers/sqlserver/e2e/sqlserver-metadata.ts @@ -333,7 +333,8 @@ describe('SQL Server schema metadata IPC (live)', () => { } catch (cause) { error = String(cause); } - expect(error.toLowerCase()).toMatch(/collation|unsupported|not represent/); + expect(error).toMatch(/unsupported/i); + expect(error).toMatch(/non-default collation/i); }); it('preserves SQL Server IDENTITY(1,1) as the auto-increment flag', async () => { From 60595b8783338806d012b4b2184228058dfd5218 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 12:18:26 +0800 Subject: [PATCH 028/666] test(sqlserver): cover generated column metadata edge cases --- packages/drivers/sqlserver/src/metadata.rs | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/drivers/sqlserver/src/metadata.rs b/packages/drivers/sqlserver/src/metadata.rs index 05b38aaab..084694a12 100644 --- a/packages/drivers/sqlserver/src/metadata.rs +++ b/packages/drivers/sqlserver/src/metadata.rs @@ -718,6 +718,12 @@ mod tests { "generated-always columns", "temporal row-end column", ), + ( + 9, + Some(Value::Integer(3)), + "generated-always columns", + "unknown generated-always type", + ), ( 10, Some(Value::Bool(true)), @@ -756,6 +762,14 @@ mod tests { row[index] = value; assert_unsupported_column(row, reason, case); } + + let mut legacy_default = column_row("legacy_default_column"); + legacy_default[13] = bit(true); + assert!(matches!( + parse_columns(result(vec![legacy_default])), + Err(DriverError::Unsupported(message)) + if message == "SQL Server table uses a legacy bound DEFAULT; its expression is not represented by sys.default_constraints" + )); } #[test] @@ -792,6 +806,19 @@ mod tests { ); } + #[test] + fn generated_always_type_does_not_accept_lossy_float_conversion() { + for value in [0.0, 0.5, -0.5] { + let mut row = column_row("generated_column"); + row[9] = Some(Value::Float(value)); + let parsed = parse_columns(result(vec![row])); + assert!( + parsed.is_err(), + "generated_always_type must be an integer; float {value} must not be truncated to zero and accepted as an ordinary column" + ); + } + } + #[test] fn nondefault_collation_is_rejected_and_default_collation_is_case_insensitive() { let mut row = column_row("label"); From cf80b8004b4fa229ceb9899d1a0812bff4aac9fc Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 12:26:45 +0800 Subject: [PATCH 029/666] fix(sqlserver): parse generated column type strictly --- packages/drivers/sqlserver/src/metadata.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/drivers/sqlserver/src/metadata.rs b/packages/drivers/sqlserver/src/metadata.rs index 084694a12..25e2e810a 100644 --- a/packages/drivers/sqlserver/src/metadata.rs +++ b/packages/drivers/sqlserver/src/metadata.rs @@ -167,7 +167,7 @@ pub(crate) fn parse_columns( // Reject every non-zero value so newer generated column kinds also // remain fail-closed. let flag_is_set = if index == 9 { - required_integer(&row, index, label)? != 0 + required_catalog_integer(&row, index, label)? != 0 } else { required_bool(&row, index, label)? }; @@ -500,6 +500,19 @@ fn required_integer(row: &[Option], index: usize, label: &str) -> Result< optional_integer(row, index).ok_or_else(|| incomplete(format!("catalog row omitted {label}"))) } +fn required_catalog_integer( + row: &[Option], + index: usize, + label: &str, +) -> Result { + let value = match value_at(row, index) { + Some(Value::Integer(value)) => Some(*value), + Some(Value::String(value)) => value.parse().ok(), + _ => None, + }; + value.ok_or_else(|| incomplete(format!("catalog row omitted {label}"))) +} + fn unsupported(message: impl Into) -> DriverError { DriverError::Unsupported(message.into()) } From a79efa9328fe9a68ab9d1694423b82ab13909b26 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 12:29:24 +0800 Subject: [PATCH 030/666] fix(sql-editor): scope T-SQL routine parameters --- .../src/__tests__/bindParams.test.ts | 38 ++++ .../src/sql-editor/bindParams.ts | 175 +++++++++++++++--- src/lib/__tests__/sqlBindParams.test.ts | 58 ++++++ src/lib/sqlBindParams.ts | 175 +++++++++++++++--- 4 files changed, 398 insertions(+), 48 deletions(-) create mode 100644 packages/extension-points/src/__tests__/bindParams.test.ts diff --git a/packages/extension-points/src/__tests__/bindParams.test.ts b/packages/extension-points/src/__tests__/bindParams.test.ts new file mode 100644 index 000000000..e78ad7722 --- /dev/null +++ b/packages/extension-points/src/__tests__/bindParams.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { findDeclaredAtVars, parseSqlParams } from '../sql-editor/bindParams'; + +describe('extension-point SQL bind parameter parser', () => { + it('treats T-SQL function signature parameters as local variables', () => { + const sql = `CREATE FUNCTION [dbo].[fn_NormalizeCode] +( + @value NVARCHAR(64) +) +RETURNS NVARCHAR(64) +AS +BEGIN + RETURN UPPER(LTRIM(RTRIM(@value))); +END;`; + + expect(findDeclaredAtVars(sql)).toEqual(new Set(['value'])); + expect(parseSqlParams(sql)).toEqual([]); + expect(parseSqlParams(`${sql}\nSELECT @value`)).toEqual([ + { + name: 'value', + kind: 'named', + syntax: 'at', + stableId: 'named:value', + }, + ]); + }); + + it('keeps ordinary T-SQL @name placeholders enabled', () => { + expect(parseSqlParams('SELECT @value')).toEqual([ + { + name: 'value', + kind: 'named', + syntax: 'at', + stableId: 'named:value', + }, + ]); + }); +}); diff --git a/packages/extension-points/src/sql-editor/bindParams.ts b/packages/extension-points/src/sql-editor/bindParams.ts index 3390774e2..0f8daafaa 100644 --- a/packages/extension-points/src/sql-editor/bindParams.ts +++ b/packages/extension-points/src/sql-editor/bindParams.ts @@ -11,7 +11,7 @@ export interface SqlParamDialectPolicy { enableQuestion?: boolean; /** Recognize `${name}` template placeholders (default true). */ enableTemplate?: boolean; - /** Exclude `DECLARE @x` / `SET @x =` variables from bind descriptors (default true when enableAt). */ + /** Exclude `DECLARE` / `SET` variables and T-SQL routine parameters (default true when enableAt). */ excludeDeclaredAtVars?: boolean; } @@ -79,10 +79,10 @@ export function parseSqlParamOccurrences( policy?: SqlParamDialectPolicy, ): SqlParamOccurrence[] { const resolved = resolvePolicy(policy); - const declaredAt = - resolved.enableAt && resolved.excludeDeclaredAtVars - ? findDeclaredAtVars(sql) - : new Set(); + const excludeAtVars = resolved.enableAt && resolved.excludeDeclaredAtVars; + const declaredAt = excludeAtVars ? findNonRoutineDeclaredAtVars(sql) : new Set(); + const routineAtVars = excludeAtVars ? findTsqlRoutineSignatureAtVars(sql) : new Set(); + const routineRanges = excludeAtVars ? findTsqlRoutineRanges(sql) : []; const occurrences: SqlParamOccurrence[] = []; let questionOrdinal = 0; let i = 0; @@ -129,7 +129,11 @@ export function parseSqlParamOccurrences( const m = IDENT.exec(sql); if (m) { const name = m[0]; - if (!declaredAt.has(name.toLowerCase())) { + const normalizedName = name.toLowerCase(); + const isRoutineLocal = + routineAtVars.has(normalizedName) && + routineRanges.some(({ from, to }) => i >= from && i < to); + if (!declaredAt.has(normalizedName) && !isRoutineLocal) { const from = i; const to = IDENT.lastIndex; pushOccurrence(occurrences, { @@ -442,15 +446,154 @@ function isIdentChar(ch: string): boolean { return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57); } -/** Collect `@name` variables introduced by top-level DECLARE / SET in each statement. */ +/** Collect `@name` variables introduced by DECLARE / SET and T-SQL routine signatures. */ export function findDeclaredAtVars(sql: string): Set { const declared = new Set(); for (const stmt of splitStatements(sql)) { + collectTsqlRoutineSignatureAtVars(stmt, declared); collectDeclaredAtVarsInStatement(stmt, declared); } return declared; } +function findNonRoutineDeclaredAtVars(sql: string): Set { + const declared = new Set(); + for (const stmt of splitStatements(sql)) { + collectDeclaredAtVarsInStatement(stmt, declared); + } + return declared; +} + +function findTsqlRoutineSignatureAtVars(sql: string): Set { + const declared = new Set(); + for (const stmt of splitStatements(sql)) { + collectTsqlRoutineSignatureAtVars(stmt, declared); + } + return declared; +} + +function findTsqlRoutineRanges(sql: string): Array<{ from: number; to: number }> { + const ranges: Array<{ from: number; to: number }> = []; + for (const { statement, from } of splitStatementsWithOffsets(sql)) { + const prefix = maskNonCodeForTsql(statement); + const definition = + /^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?:FUNCTION|PROC(?:EDURE)?)\b/i.exec(prefix); + if (!definition) continue; + + const remaining = maskNonCodeForTsql(sql.slice(from)); + const header = remaining.slice(definition[0].length); + const bodyMarker = /\bAS\b/i.exec(header); + if (!bodyMarker) continue; + + const bodyStart = definition[0].length + bodyMarker.index + bodyMarker[0].length; + const body = remaining.slice(bodyStart); + const begin = /\bBEGIN\b/i.exec(body); + if (!begin) { + const semicolon = remaining.indexOf(';', bodyStart); + ranges.push({ from, to: semicolon === -1 ? sql.length : from + semicolon }); + continue; + } + + const blockStart = bodyStart + begin.index; + const keywords = /\b(BEGIN|CASE|END)\b/gi; + keywords.lastIndex = blockStart; + let depth = 0; + let end = sql.length; + let match: RegExpExecArray | null; + while ((match = keywords.exec(remaining))) { + const keyword = match[1].toUpperCase(); + if (keyword === 'BEGIN') { + const next = /^\s+(?:TRAN(?:SACTION)?|DIALOG|CONVERSATION|DISTRIBUTED)\b/i.exec( + remaining.slice(keywords.lastIndex), + ); + if (!next) depth += 1; + } else if (keyword === 'CASE') { + depth += 1; + } else { + depth -= 1; + if (depth === 0) { + end = from + keywords.lastIndex; + const terminator = /^\s*;/.exec(sql.slice(end)); + if (terminator) end += terminator[0].length; + break; + } + } + } + ranges.push({ from, to: end }); + } + return ranges; +} + +function splitStatementsWithOffsets(sql: string): Array<{ statement: string; from: number }> { + const statements: Array<{ statement: string; from: number }> = []; + let start = 0; + let i = 0; + while (i < sql.length) { + const skipped = skipNonCode(sql, i); + if (skipped !== i) { + i = skipped; + continue; + } + if (sql[i] === ';') { + statements.push({ statement: sql.slice(start, i), from: start }); + start = i + 1; + } + i += 1; + } + statements.push({ statement: sql.slice(start), from: start }); + return statements; +} + +/** A routine parameter belongs to the T-SQL body, not to the editor's bind payload. */ +function collectTsqlRoutineSignatureAtVars(stmt: string, declared: Set) { + const code = maskNonCodeForTsql(stmt); + const definition = /^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?:FUNCTION|PROC(?:EDURE)?)\b/i.exec( + code, + ); + if (!definition) return; + + const header = code.slice(definition[0].length); + const bodyMarker = /\bAS\b/i.exec(header); + if (!bodyMarker) return; + + const signature = header.slice(0, bodyMarker.index); + for (const match of signature.matchAll(/@([A-Za-z_][A-Za-z0-9_]*)/g)) { + declared.add(match[1].toLowerCase()); + } +} + +/** Keep offsets stable while hiding comments, literals, and bracketed identifiers. */ +function maskNonCodeForTsql(sql: string): string { + const masked = sql.split(''); + for (let i = 0; i < sql.length; ) { + const end = sql[i] === '[' ? skipBracketIdentifier(sql, i) : skipNonCode(sql, i); + if (end === i) { + i += 1; + continue; + } + for (let j = i; j < end; j += 1) { + if (sql[j] !== '\n' && sql[j] !== '\r') masked[j] = ' '; + } + i = end; + } + return masked.join(''); +} + +function skipBracketIdentifier(sql: string, i: number): number { + let j = i + 1; + while (j < sql.length) { + if (sql[j] === ']') { + if (sql[j + 1] === ']') { + j += 2; + continue; + } + return j + 1; + } + j += 1; + } + return sql.length; +} + function collectDeclaredAtVarsInStatement(stmt: string, declared: Set) { const trimmed = stmt.trimStart(); if (!trimmed) return; @@ -473,21 +616,5 @@ function collectDeclaredAtVarsInStatement(stmt: string, declared: Set) { } function splitStatements(sql: string): string[] { - const stmts: string[] = []; - let start = 0; - let i = 0; - while (i < sql.length) { - const skipped = skipNonCode(sql, i); - if (skipped !== i) { - i = skipped; - continue; - } - if (sql[i] === ';') { - stmts.push(sql.slice(start, i)); - start = i + 1; - } - i += 1; - } - stmts.push(sql.slice(start)); - return stmts; + return splitStatementsWithOffsets(sql).map(({ statement }) => statement); } diff --git a/src/lib/__tests__/sqlBindParams.test.ts b/src/lib/__tests__/sqlBindParams.test.ts index 5b15f6a1a..77ece9a67 100644 --- a/src/lib/__tests__/sqlBindParams.test.ts +++ b/src/lib/__tests__/sqlBindParams.test.ts @@ -115,6 +115,64 @@ describe('parseSqlParams', () => { }, ]); }); + + it('excludes T-SQL function parameters while retaining ordinary @ binds', () => { + const sql = `-- @comment is not a routine parameter +CREATE FUNCTION [dbo].[fn_Normalize_AS_Code] +( + @value NVARCHAR(64) +) +RETURNS NVARCHAR(64) +AS +BEGIN + RETURN UPPER(LTRIM(RTRIM(@value))); +END;`; + + expect(findDeclaredAtVars(sql)).toEqual(new Set(['value'])); + expect(parseSqlParams(sql)).toEqual([]); + expect(parseSqlParams(`${sql}\nSELECT @value`)).toEqual([ + { + name: 'value', + kind: 'named', + syntax: 'at', + stableId: 'named:value', + }, + ]); + expect(parseSqlParams('SELECT @value')).toEqual([ + { + name: 'value', + kind: 'named', + syntax: 'at', + stableId: 'named:value', + }, + ]); + }); + + it('excludes T-SQL procedure signature parameters without disabling unrelated @ binds', () => { + const sql = `CREATE OR ALTER PROCEDURE [dbo].[p_Filter] + @input NVARCHAR(64), @limit INT +AS +BEGIN + SELECT @input, @limit; +END; +SELECT @input, @filter;`; + + expect(findDeclaredAtVars(sql)).toEqual(new Set(['input', 'limit'])); + expect(parseSqlParams(sql)).toEqual([ + { + name: 'input', + kind: 'named', + syntax: 'at', + stableId: 'named:input', + }, + { + name: 'filter', + kind: 'named', + syntax: 'at', + stableId: 'named:filter', + }, + ]); + }); }); describe('parseSqlParamOccurrences', () => { diff --git a/src/lib/sqlBindParams.ts b/src/lib/sqlBindParams.ts index 3390774e2..0f8daafaa 100644 --- a/src/lib/sqlBindParams.ts +++ b/src/lib/sqlBindParams.ts @@ -11,7 +11,7 @@ export interface SqlParamDialectPolicy { enableQuestion?: boolean; /** Recognize `${name}` template placeholders (default true). */ enableTemplate?: boolean; - /** Exclude `DECLARE @x` / `SET @x =` variables from bind descriptors (default true when enableAt). */ + /** Exclude `DECLARE` / `SET` variables and T-SQL routine parameters (default true when enableAt). */ excludeDeclaredAtVars?: boolean; } @@ -79,10 +79,10 @@ export function parseSqlParamOccurrences( policy?: SqlParamDialectPolicy, ): SqlParamOccurrence[] { const resolved = resolvePolicy(policy); - const declaredAt = - resolved.enableAt && resolved.excludeDeclaredAtVars - ? findDeclaredAtVars(sql) - : new Set(); + const excludeAtVars = resolved.enableAt && resolved.excludeDeclaredAtVars; + const declaredAt = excludeAtVars ? findNonRoutineDeclaredAtVars(sql) : new Set(); + const routineAtVars = excludeAtVars ? findTsqlRoutineSignatureAtVars(sql) : new Set(); + const routineRanges = excludeAtVars ? findTsqlRoutineRanges(sql) : []; const occurrences: SqlParamOccurrence[] = []; let questionOrdinal = 0; let i = 0; @@ -129,7 +129,11 @@ export function parseSqlParamOccurrences( const m = IDENT.exec(sql); if (m) { const name = m[0]; - if (!declaredAt.has(name.toLowerCase())) { + const normalizedName = name.toLowerCase(); + const isRoutineLocal = + routineAtVars.has(normalizedName) && + routineRanges.some(({ from, to }) => i >= from && i < to); + if (!declaredAt.has(normalizedName) && !isRoutineLocal) { const from = i; const to = IDENT.lastIndex; pushOccurrence(occurrences, { @@ -442,15 +446,154 @@ function isIdentChar(ch: string): boolean { return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57); } -/** Collect `@name` variables introduced by top-level DECLARE / SET in each statement. */ +/** Collect `@name` variables introduced by DECLARE / SET and T-SQL routine signatures. */ export function findDeclaredAtVars(sql: string): Set { const declared = new Set(); for (const stmt of splitStatements(sql)) { + collectTsqlRoutineSignatureAtVars(stmt, declared); collectDeclaredAtVarsInStatement(stmt, declared); } return declared; } +function findNonRoutineDeclaredAtVars(sql: string): Set { + const declared = new Set(); + for (const stmt of splitStatements(sql)) { + collectDeclaredAtVarsInStatement(stmt, declared); + } + return declared; +} + +function findTsqlRoutineSignatureAtVars(sql: string): Set { + const declared = new Set(); + for (const stmt of splitStatements(sql)) { + collectTsqlRoutineSignatureAtVars(stmt, declared); + } + return declared; +} + +function findTsqlRoutineRanges(sql: string): Array<{ from: number; to: number }> { + const ranges: Array<{ from: number; to: number }> = []; + for (const { statement, from } of splitStatementsWithOffsets(sql)) { + const prefix = maskNonCodeForTsql(statement); + const definition = + /^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?:FUNCTION|PROC(?:EDURE)?)\b/i.exec(prefix); + if (!definition) continue; + + const remaining = maskNonCodeForTsql(sql.slice(from)); + const header = remaining.slice(definition[0].length); + const bodyMarker = /\bAS\b/i.exec(header); + if (!bodyMarker) continue; + + const bodyStart = definition[0].length + bodyMarker.index + bodyMarker[0].length; + const body = remaining.slice(bodyStart); + const begin = /\bBEGIN\b/i.exec(body); + if (!begin) { + const semicolon = remaining.indexOf(';', bodyStart); + ranges.push({ from, to: semicolon === -1 ? sql.length : from + semicolon }); + continue; + } + + const blockStart = bodyStart + begin.index; + const keywords = /\b(BEGIN|CASE|END)\b/gi; + keywords.lastIndex = blockStart; + let depth = 0; + let end = sql.length; + let match: RegExpExecArray | null; + while ((match = keywords.exec(remaining))) { + const keyword = match[1].toUpperCase(); + if (keyword === 'BEGIN') { + const next = /^\s+(?:TRAN(?:SACTION)?|DIALOG|CONVERSATION|DISTRIBUTED)\b/i.exec( + remaining.slice(keywords.lastIndex), + ); + if (!next) depth += 1; + } else if (keyword === 'CASE') { + depth += 1; + } else { + depth -= 1; + if (depth === 0) { + end = from + keywords.lastIndex; + const terminator = /^\s*;/.exec(sql.slice(end)); + if (terminator) end += terminator[0].length; + break; + } + } + } + ranges.push({ from, to: end }); + } + return ranges; +} + +function splitStatementsWithOffsets(sql: string): Array<{ statement: string; from: number }> { + const statements: Array<{ statement: string; from: number }> = []; + let start = 0; + let i = 0; + while (i < sql.length) { + const skipped = skipNonCode(sql, i); + if (skipped !== i) { + i = skipped; + continue; + } + if (sql[i] === ';') { + statements.push({ statement: sql.slice(start, i), from: start }); + start = i + 1; + } + i += 1; + } + statements.push({ statement: sql.slice(start), from: start }); + return statements; +} + +/** A routine parameter belongs to the T-SQL body, not to the editor's bind payload. */ +function collectTsqlRoutineSignatureAtVars(stmt: string, declared: Set) { + const code = maskNonCodeForTsql(stmt); + const definition = /^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?:FUNCTION|PROC(?:EDURE)?)\b/i.exec( + code, + ); + if (!definition) return; + + const header = code.slice(definition[0].length); + const bodyMarker = /\bAS\b/i.exec(header); + if (!bodyMarker) return; + + const signature = header.slice(0, bodyMarker.index); + for (const match of signature.matchAll(/@([A-Za-z_][A-Za-z0-9_]*)/g)) { + declared.add(match[1].toLowerCase()); + } +} + +/** Keep offsets stable while hiding comments, literals, and bracketed identifiers. */ +function maskNonCodeForTsql(sql: string): string { + const masked = sql.split(''); + for (let i = 0; i < sql.length; ) { + const end = sql[i] === '[' ? skipBracketIdentifier(sql, i) : skipNonCode(sql, i); + if (end === i) { + i += 1; + continue; + } + for (let j = i; j < end; j += 1) { + if (sql[j] !== '\n' && sql[j] !== '\r') masked[j] = ' '; + } + i = end; + } + return masked.join(''); +} + +function skipBracketIdentifier(sql: string, i: number): number { + let j = i + 1; + while (j < sql.length) { + if (sql[j] === ']') { + if (sql[j + 1] === ']') { + j += 2; + continue; + } + return j + 1; + } + j += 1; + } + return sql.length; +} + function collectDeclaredAtVarsInStatement(stmt: string, declared: Set) { const trimmed = stmt.trimStart(); if (!trimmed) return; @@ -473,21 +616,5 @@ function collectDeclaredAtVarsInStatement(stmt: string, declared: Set) { } function splitStatements(sql: string): string[] { - const stmts: string[] = []; - let start = 0; - let i = 0; - while (i < sql.length) { - const skipped = skipNonCode(sql, i); - if (skipped !== i) { - i = skipped; - continue; - } - if (sql[i] === ';') { - stmts.push(sql.slice(start, i)); - start = i + 1; - } - i += 1; - } - stmts.push(sql.slice(start)); - return stmts; + return splitStatementsWithOffsets(sql).map(({ statement }) => statement); } From de83f58242df7979c4ee2ef3bd8b4a9441e45fde Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 12:33:35 +0800 Subject: [PATCH 031/666] docs(sqlserver): describe metadata and T-SQL E2E coverage --- packages/drivers/sqlserver/e2e/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/drivers/sqlserver/e2e/README.md b/packages/drivers/sqlserver/e2e/README.md index 2f9e10723..7b4f68d5f 100644 --- a/packages/drivers/sqlserver/e2e/README.md +++ b/packages/drivers/sqlserver/e2e/README.md @@ -6,8 +6,8 @@ Optional WebdriverIO specs for the SQL Server path driver. Not included in defau |------|---------| | `sqlserver-smoke.ts` | Reachability placeholder: skips unless a SQL Server instance answers on `host:port`. | | `sqlserver-live-e2e.ts` | Live journey through the app's own IPC (`save_connection` → `connect` → `execute_query` → `get_table_data`). Asserts dialect pagination (`OFFSET … FETCH NEXT`, never `LIMIT`), batch-only DDL such as `CREATE SCHEMA`/`CREATE VIEW`, temporal rendering and filtered/sorted paging. Creates only `dz_e2e_*` objects and drops them in `after`. | -| `sqlserver-live-ui.ts` | Live journey through the **real window**: connects via the navigator card, expands ` → → Tables`, opens the scratch table in the data grid and asserts every column header, the temporal cell text (`2026-03-01`, never `Date(`/`increments`) and next-page navigation. Reuses the host harness helpers (`e2e/helpers.ts`) for tree expansion, so it also exercises the same navigator code path as the other drivers' specs. | -| `sqlserver-metadata.ts` | Live journey through `get_table_schema` IPC. Creates parent/child scratch tables with composite primary keys, a secondary index, a composite foreign key and a CHECK constraint; verifies catalog names, columns, actions and key ordering. It also checks that a non-default column collation is preserved or rejected explicitly, then drops the scratch objects. | +| `sqlserver-live-ui.ts` | Live journey through the **real window**: connects via the navigator card, expands ` → → Tables`, opens the scratch table in the data grid and asserts every column header, SQL Server temporal cell formatting and next-page navigation. It also creates and calls a scalar function with a local `@value` parameter through the SQL Editor, then verifies an invalid-object diagnostic reaches SQL Server. | +| `sqlserver-metadata.ts` | Live journey through `get_table_schema` IPC. Creates parent/child scratch tables with composite primary keys, a secondary index, a composite foreign key and a CHECK constraint; verifies catalog names, columns, actions and key ordering. It covers standard and non-default `IDENTITY`, computed, `rowversion` and temporal generated-always columns, non-default collation, filtered indexes and `INCLUDE` indexes, asserting unsupported metadata is rejected with a specific error. | | `sqlserver-schema-diff.ts` | Real-window Schema Diff from a source database to a distinct target database on the configured server. Compares one same-schema table, generates and deploys its plan, then verifies target columns and primary key. | | `sqlserver-data-sync.ts` | IPC Data Sync compare → selected INSERT preview → apply. Copies explicit identity values between same-shaped SQL Server tables, checks preview serialization and verifies the target rows. | | `sqlserver-data-transfer.ts` | IPC Data Transfer preview → execute between two scratch schemas. Copies into a target identity table with a deliberately different seed, verifies source IDs were preserved, then confirms a normal generated-ID insert still works on the target session. | From faa59ce0e802f8d2ae447ddd30c28a2cb776c2f2 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 13:02:56 +0800 Subject: [PATCH 032/666] style(sqlserver): format E2E coverage docs --- packages/drivers/sqlserver/e2e/README.md | 48 ++++++++++++------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/drivers/sqlserver/e2e/README.md b/packages/drivers/sqlserver/e2e/README.md index 7b4f68d5f..dda560a79 100644 --- a/packages/drivers/sqlserver/e2e/README.md +++ b/packages/drivers/sqlserver/e2e/README.md @@ -2,15 +2,15 @@ Optional WebdriverIO specs for the SQL Server path driver. Not included in default `pnpm e2e`. -| Spec | Purpose | -|------|---------| -| `sqlserver-smoke.ts` | Reachability placeholder: skips unless a SQL Server instance answers on `host:port`. | -| `sqlserver-live-e2e.ts` | Live journey through the app's own IPC (`save_connection` → `connect` → `execute_query` → `get_table_data`). Asserts dialect pagination (`OFFSET … FETCH NEXT`, never `LIMIT`), batch-only DDL such as `CREATE SCHEMA`/`CREATE VIEW`, temporal rendering and filtered/sorted paging. Creates only `dz_e2e_*` objects and drops them in `after`. | -| `sqlserver-live-ui.ts` | Live journey through the **real window**: connects via the navigator card, expands ` → → Tables`, opens the scratch table in the data grid and asserts every column header, SQL Server temporal cell formatting and next-page navigation. It also creates and calls a scalar function with a local `@value` parameter through the SQL Editor, then verifies an invalid-object diagnostic reaches SQL Server. | -| `sqlserver-metadata.ts` | Live journey through `get_table_schema` IPC. Creates parent/child scratch tables with composite primary keys, a secondary index, a composite foreign key and a CHECK constraint; verifies catalog names, columns, actions and key ordering. It covers standard and non-default `IDENTITY`, computed, `rowversion` and temporal generated-always columns, non-default collation, filtered indexes and `INCLUDE` indexes, asserting unsupported metadata is rejected with a specific error. | -| `sqlserver-schema-diff.ts` | Real-window Schema Diff from a source database to a distinct target database on the configured server. Compares one same-schema table, generates and deploys its plan, then verifies target columns and primary key. | -| `sqlserver-data-sync.ts` | IPC Data Sync compare → selected INSERT preview → apply. Copies explicit identity values between same-shaped SQL Server tables, checks preview serialization and verifies the target rows. | -| `sqlserver-data-transfer.ts` | IPC Data Transfer preview → execute between two scratch schemas. Copies into a target identity table with a deliberately different seed, verifies source IDs were preserved, then confirms a normal generated-ID insert still works on the target session. | +| Spec | Purpose | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sqlserver-smoke.ts` | Reachability placeholder: skips unless a SQL Server instance answers on `host:port`. | +| `sqlserver-live-e2e.ts` | Live journey through the app's own IPC (`save_connection` → `connect` → `execute_query` → `get_table_data`). Asserts dialect pagination (`OFFSET … FETCH NEXT`, never `LIMIT`), batch-only DDL such as `CREATE SCHEMA`/`CREATE VIEW`, temporal rendering and filtered/sorted paging. Creates only `dz_e2e_*` objects and drops them in `after`. | +| `sqlserver-live-ui.ts` | Live journey through the **real window**: connects via the navigator card, expands ` → → Tables`, opens the scratch table in the data grid and asserts every column header, SQL Server temporal cell formatting and next-page navigation. It also creates and calls a scalar function with a local `@value` parameter through the SQL Editor, then verifies an invalid-object diagnostic reaches SQL Server. | +| `sqlserver-metadata.ts` | Live journey through `get_table_schema` IPC. Creates parent/child scratch tables with composite primary keys, a secondary index, a composite foreign key and a CHECK constraint; verifies catalog names, columns, actions and key ordering. It covers standard and non-default `IDENTITY`, computed, `rowversion` and temporal generated-always columns, non-default collation, filtered indexes and `INCLUDE` indexes, asserting unsupported metadata is rejected with a specific error. | +| `sqlserver-schema-diff.ts` | Real-window Schema Diff from a source database to a distinct target database on the configured server. Compares one same-schema table, generates and deploys its plan, then verifies target columns and primary key. | +| `sqlserver-data-sync.ts` | IPC Data Sync compare → selected INSERT preview → apply. Copies explicit identity values between same-shaped SQL Server tables, checks preview serialization and verifies the target rows. | +| `sqlserver-data-transfer.ts` | IPC Data Transfer preview → execute between two scratch schemas. Copies into a target identity table with a deliberately different seed, verifies source IDs were preserved, then confirms a normal generated-ID insert still works on the target session. | ## Prerequisites @@ -20,7 +20,7 @@ Optional WebdriverIO specs for the SQL Server path driver. Not included in defau existing fixtures. `DATAZEN_DRIVERS=basic,sqlserver pnpm e2e -- --spec packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts` - The DMG packaging step can fail after the binary is already produced (`Built application - at: …/target/debug/datazen`); run the specs with `pnpm e2e:skip-build` in that case. +at: …/target/debug/datazen`); run the specs with `pnpm e2e:skip-build` in that case. - A reachable SQL Server / Azure SQL Database instance whose login may create and drop scratch schemas, tables and views. - The Schema Diff journey also needs two already-created, distinct databases on that @@ -29,19 +29,19 @@ Optional WebdriverIO specs for the SQL Server path driver. Not included in defau ## Environment -| Variable | Description | -|----------|-------------| -| `E2E_SQLSERVER_HOST` | Host (no default; the live spec skips without it) | -| `E2E_SQLSERVER_PORT` | Port (default `1433`) | -| `E2E_SQLSERVER_USER` | Login user | -| `E2E_SQLSERVER_PASSWORD` | Password | -| `E2E_SQLSERVER_DATABASE` | Database to connect to (optional) | -| `E2E_SQLSERVER_SOURCE_DATABASE` | Schema Diff source database (falls back to `E2E_SQLSERVER_DATABASE`) | -| `E2E_SQLSERVER_TARGET_DATABASE` | Schema Diff target database; must differ from the source | -| `E2E_SQLSERVER_SCHEMA` | Default schema used by the connection config (default `dbo`) | -| `E2E_SQLSERVER_SSL_MODE` | `disable` \| `prefer` \| `require` \| `verifyCa` \| `verifyFull` (default `require`) | -| `E2E_SQLSERVER_TRUST_CERT` | Set to `0` to validate the server certificate chain (default trusts it) | -| `E2E_SKIP_SQLSERVER` | Set to `1` to force skip | +| Variable | Description | +| ------------------------------- | ------------------------------------------------------------------------------------ | +| `E2E_SQLSERVER_HOST` | Host (no default; the live spec skips without it) | +| `E2E_SQLSERVER_PORT` | Port (default `1433`) | +| `E2E_SQLSERVER_USER` | Login user | +| `E2E_SQLSERVER_PASSWORD` | Password | +| `E2E_SQLSERVER_DATABASE` | Database to connect to (optional) | +| `E2E_SQLSERVER_SOURCE_DATABASE` | Schema Diff source database (falls back to `E2E_SQLSERVER_DATABASE`) | +| `E2E_SQLSERVER_TARGET_DATABASE` | Schema Diff target database; must differ from the source | +| `E2E_SQLSERVER_SCHEMA` | Default schema used by the connection config (default `dbo`) | +| `E2E_SQLSERVER_SSL_MODE` | `disable` \| `prefer` \| `require` \| `verifyCa` \| `verifyFull` (default `require`) | +| `E2E_SQLSERVER_TRUST_CERT` | Set to `0` to validate the server certificate chain (default trusts it) | +| `E2E_SKIP_SQLSERVER` | Set to `1` to force skip | ## Run @@ -92,7 +92,7 @@ Credential-free runs never print the password; keep it in the environment only. - **A leftover app instance hijacks the run.** `run.mjs` always starts the app on `E2E_WD_PORT` (default `4445`); if an app from another checkout or worktree is still - listening there, WebDriver attaches to *that* one and the run reports its drivers — + listening there, WebDriver attaches to _that_ one and the run reports its drivers — the classic symptom is `Driver not found for type: sqlserver` (or a driver list that does not match your build) even though the freshly built binary is correct. Check for a stray `DataZen.app/Contents/MacOS/datazen` process and/or run with a free port: From 8222ed56217c6785251ad999e95b70fcc9c8eaea Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 15:53:17 +0800 Subject: [PATCH 033/666] test(postgres): cover schema tree objects --- .../e2e/fixtures/manual-schema-tree.sql | 132 +++++++ .../e2e/install-manual-schema-tree.sh | 94 +++++ .../postgres/e2e/schema-tree-objects.ts | 354 ++++++++++++++++++ 3 files changed, 580 insertions(+) create mode 100644 packages/drivers/postgres/e2e/fixtures/manual-schema-tree.sql create mode 100755 packages/drivers/postgres/e2e/install-manual-schema-tree.sh create mode 100644 packages/drivers/postgres/e2e/schema-tree-objects.ts diff --git a/packages/drivers/postgres/e2e/fixtures/manual-schema-tree.sql b/packages/drivers/postgres/e2e/fixtures/manual-schema-tree.sql new file mode 100644 index 000000000..82f9e6db9 --- /dev/null +++ b/packages/drivers/postgres/e2e/fixtures/manual-schema-tree.sql @@ -0,0 +1,132 @@ +-- Durable PostgreSQL Schema Tree fixtures for manual testing. +-- Install with install-manual-schema-tree.sh. All objects live in their own +-- schema so the E2E teardown never touches this fixture database. + +CREATE SCHEMA IF NOT EXISTS manual_schema_tree; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'manual_schema_tree' + AND t.typname = 'manual_schema_tree_state' + ) THEN + CREATE TYPE manual_schema_tree.manual_schema_tree_state AS ENUM ('active', 'archived'); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'manual_schema_tree' + AND t.typname = 'manual_schema_tree_code' + ) THEN + CREATE DOMAIN manual_schema_tree.manual_schema_tree_code AS text + CHECK (VALUE ~ '^[A-Z0-9-]{1,24}$'); + END IF; +END $$; + +CREATE SEQUENCE IF NOT EXISTS manual_schema_tree.manual_schema_tree_group_id_seq + START WITH 1 INCREMENT BY 1; + +CREATE TABLE IF NOT EXISTS manual_schema_tree.manual_schema_tree_groups ( + group_id integer NOT NULL DEFAULT nextval('manual_schema_tree.manual_schema_tree_group_id_seq'), + group_name text NOT NULL UNIQUE, + CONSTRAINT manual_schema_tree_groups_pk PRIMARY KEY (group_id) +); + +ALTER SEQUENCE manual_schema_tree.manual_schema_tree_group_id_seq + OWNED BY manual_schema_tree.manual_schema_tree_groups.group_id; + +CREATE TABLE IF NOT EXISTS manual_schema_tree.manual_schema_tree_entries ( + entry_id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + group_id integer NOT NULL, + code manual_schema_tree.manual_schema_tree_code NOT NULL UNIQUE, + title text NOT NULL, + state manual_schema_tree.manual_schema_tree_state NOT NULL DEFAULT 'active', + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT manual_schema_tree_entries_group_fk + FOREIGN KEY (group_id) + REFERENCES manual_schema_tree.manual_schema_tree_groups (group_id) +); + +CREATE INDEX IF NOT EXISTS manual_schema_tree_entries_state_group_idx + ON manual_schema_tree.manual_schema_tree_entries (state, group_id); + +CREATE OR REPLACE VIEW manual_schema_tree.manual_schema_tree_active_entries AS +SELECT e.entry_id, g.group_name, e.code, e.title, e.state, e.updated_at +FROM manual_schema_tree.manual_schema_tree_entries AS e +JOIN manual_schema_tree.manual_schema_tree_groups AS g USING (group_id) +WHERE e.state = 'active'; + +CREATE OR REPLACE FUNCTION manual_schema_tree.manual_schema_tree_normalize_code(input_code text) +RETURNS text +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT upper(btrim(input_code)); +$$; + +CREATE OR REPLACE PROCEDURE manual_schema_tree.manual_schema_tree_archive_entry(p_entry_id integer) +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE manual_schema_tree.manual_schema_tree_entries + SET state = 'archived', updated_at = now() + WHERE entry_id = p_entry_id; +END; +$$; + +CREATE OR REPLACE FUNCTION manual_schema_tree.manual_schema_tree_touch_updated_at() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS manual_schema_tree_entries_touch_updated_at + ON manual_schema_tree.manual_schema_tree_entries; +CREATE TRIGGER manual_schema_tree_entries_touch_updated_at + BEFORE UPDATE ON manual_schema_tree.manual_schema_tree_entries + FOR EACH ROW + EXECUTE FUNCTION manual_schema_tree.manual_schema_tree_touch_updated_at(); + +INSERT INTO manual_schema_tree.manual_schema_tree_groups (group_id, group_name) +VALUES (1001, 'Manual fixtures'), (1002, 'Manual examples') +ON CONFLICT (group_id) DO NOTHING; + +DO $$ +DECLARE + last_group_id bigint; + has_groups boolean; +BEGIN + SELECT COALESCE(MAX(group_id), 1), COUNT(*) > 0 + INTO last_group_id, has_groups + FROM manual_schema_tree.manual_schema_tree_groups; + PERFORM setval('manual_schema_tree.manual_schema_tree_group_id_seq', last_group_id, has_groups); +END $$; + +INSERT INTO manual_schema_tree.manual_schema_tree_entries + (entry_id, group_id, code, title, state) +VALUES + (2001, 1001, 'MANUAL-001', 'Active manual fixture row', 'active'), + (2002, 1002, 'MANUAL-002', 'Archived manual fixture row', 'archived') +ON CONFLICT (entry_id) DO NOTHING; + +DO $$ +DECLARE + last_entry_id bigint; + has_entries boolean; +BEGIN + SELECT COALESCE(MAX(entry_id), 1), COUNT(*) > 0 + INTO last_entry_id, has_entries + FROM manual_schema_tree.manual_schema_tree_entries; + PERFORM setval( + pg_get_serial_sequence('manual_schema_tree.manual_schema_tree_entries', 'entry_id'), + last_entry_id, + has_entries + ); +END $$; diff --git a/packages/drivers/postgres/e2e/install-manual-schema-tree.sh b/packages/drivers/postgres/e2e/install-manual-schema-tree.sh new file mode 100755 index 000000000..1f20a16be --- /dev/null +++ b/packages/drivers/postgres/e2e/install-manual-schema-tree.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Create the durable PostgreSQL fixture database used for manual Schema Tree +# testing. This database is deliberately outside the E2E cleanup name patterns. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +ENV_FILE="$REPO_ROOT/e2e/.env" + +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +PGHOST="${E2E_PG_HOST:-${PG_HOST:-127.0.0.1}}" +PGPORT="${E2E_PG_PORT:-${PG_PORT:-5432}}" +PGUSER="${E2E_PG_USER:-${PG_USER:-postgres}}" +PGPASSWORD="${E2E_PG_PASSWORD:-${PG_PASSWORD:-}}" +PG_ADMIN_DB="${E2E_PG_ADMIN_DB:-postgres}" +MANUAL_DB="${E2E_PG_MANUAL_DB:-datazen_manual_schema_tree}" +export PGHOST PGPORT PGUSER PGPASSWORD + +if [[ ! "$MANUAL_DB" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + echo "E2E_PG_MANUAL_DB must be a simple PostgreSQL database identifier." >&2 + exit 2 +fi + +psql -Xq -v ON_ERROR_STOP=1 -d "$PG_ADMIN_DB" -v manual_db="$MANUAL_DB" <<'SQL' +SELECT format('CREATE DATABASE %I', :'manual_db') +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'manual_db') +\gexec +SQL + +psql -Xq -v ON_ERROR_STOP=1 -d "$MANUAL_DB" -f "$SCRIPT_DIR/fixtures/manual-schema-tree.sql" +psql -Xq -v ON_ERROR_STOP=1 -d "$MANUAL_DB" <<'SQL' +DO $$ +BEGIN + IF to_regclass('manual_schema_tree.manual_schema_tree_groups') IS NULL + OR to_regclass('manual_schema_tree.manual_schema_tree_entries') IS NULL + OR to_regclass('manual_schema_tree.manual_schema_tree_active_entries') IS NULL + OR to_regclass('manual_schema_tree.manual_schema_tree_group_id_seq') IS NULL THEN + RAISE EXCEPTION 'manual Schema Tree relation fixture is incomplete'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'manual_schema_tree' + AND p.proname = 'manual_schema_tree_normalize_code' + AND p.prokind = 'f' + ) OR NOT EXISTS ( + SELECT 1 FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'manual_schema_tree' + AND p.proname = 'manual_schema_tree_archive_entry' + AND p.prokind = 'p' + ) OR NOT EXISTS ( + SELECT 1 FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'manual_schema_tree' + AND t.tgname = 'manual_schema_tree_entries_touch_updated_at' + AND NOT t.tgisinternal + ) THEN + RAISE EXCEPTION 'manual Schema Tree routine or trigger fixture is incomplete'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'manual_schema_tree' + AND t.typname = 'manual_schema_tree_state' + AND t.typtype = 'e' + ) OR NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'manual_schema_tree' + AND t.typname = 'manual_schema_tree_code' + AND t.typtype = 'd' + ) THEN + RAISE EXCEPTION 'manual Schema Tree user-defined type fixture is incomplete'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM manual_schema_tree.manual_schema_tree_entries + WHERE code = 'MANUAL-001' + ) THEN + RAISE EXCEPTION 'manual Schema Tree fixture seed row is missing'; + END IF; +END $$; +SQL +echo "Installed durable PostgreSQL Schema Tree fixtures in database: $MANUAL_DB (schema: manual_schema_tree)" diff --git a/packages/drivers/postgres/e2e/schema-tree-objects.ts b/packages/drivers/postgres/e2e/schema-tree-objects.ts new file mode 100644 index 000000000..3b533c8db --- /dev/null +++ b/packages/drivers/postgres/e2e/schema-tree-objects.ts @@ -0,0 +1,354 @@ +/** + * PostgreSQL Schema Tree journey for tables, views, routines, triggers, + * sequences, and user-defined types. Every run owns a uniquely named schema + * and removes it even when setup or a UI assertion fails. + */ +import { expect, browser, $ } from '@wdio/globals'; +import { + clickCardConnectButton, + clickNavigatorRefresh, + closeExtraWindows, + connectConfig, + disconnectBackend, + expandConnectedConnectionInNavigator, + expandSchemaCategory, + invokeBackend, + openConnectionsWorkspace, + waitForConnectionToolbar, + withSafeModeOff, +} from '../../../../e2e/helpers.js'; + +type QueryResultPayload = { + rows?: unknown[][]; + results?: Array<{ rows?: unknown[][] }>; + data?: unknown; +}; + +type ObjectDdlRequest = { + dbSessionId: string; + kind: string; + name: string; + schema: string; + signature?: string; + targetSchema?: string; + targetName?: string; +}; + +function quoteIdent(identifier: string): string { + return `"${identifier.replace(/"/g, '""')}"`; +} + +function parseRows(payload: QueryResultPayload): unknown[][] { + if (Array.isArray(payload.rows)) return payload.rows; + const resultRows = payload.results?.[0]?.rows; + return Array.isArray(resultRows) ? resultRows : []; +} + +async function executeSql(dbSessionId: string, sql: string): Promise { + return invokeBackend('execute_query', { dbSessionId, sql }); +} + +async function readObjectDdl(request: ObjectDdlRequest): Promise { + return invokeBackend('get_object_ddl', request); +} + +async function waitForEditorText(expected: string): Promise { + await browser.waitUntil( + async () => + browser.execute((token: string) => { + const editor = document.querySelector('[data-testid="sql-editor-content"]'); + return (editor?.textContent ?? '').toLowerCase().includes(token.toLowerCase()); + }, expected), + { + timeout: 15000, + timeoutMsg: `Object definition editor did not contain ${expected}`, + }, + ); +} + +describe('PostgreSQL Schema Tree objects', () => { + let mainWindow: string; + + before(async () => { + mainWindow = await browser.getWindowHandle(); + }); + + after(async () => { + await closeExtraWindows(mainWindow); + }); + + it('creates, browses, opens definitions, and removes an isolated object schema', async () => { + const stamp = Date.now().toString(36); + const schema = `e2e_schema_tree_${stamp}`; + const connectionId = `e2e-schema-tree-${stamp}`; + const connectionName = `E2E PostgreSQL Schema Tree ${stamp}`; + const database = process.env.E2E_PG_DB || process.env.PG_DATABASE || 'postgres'; + const dbSessionIdRef: { value?: string } = {}; + let primaryFailure: unknown; + const cleanupFailures: string[] = []; + + const name = { + groups: 'tree_groups', + entries: 'tree_entries', + view: 'tree_active_entries', + function: 'tree_normalize_code', + procedure: 'tree_archive_entry', + triggerFunction: 'tree_touch_updated_at', + trigger: 'tree_entries_touch_updated_at', + sequence: 'tree_entry_id_seq', + enum: 'tree_entry_state', + domain: 'tree_entry_code', + index: 'tree_entries_state_group_idx', + }; + + try { + await invokeBackend('save_connection', { + config: { + id: connectionId, + name: connectionName, + databaseType: 'postgresql', + host: process.env.E2E_PG_HOST || process.env.PG_HOST || '127.0.0.1', + port: Number(process.env.E2E_PG_PORT || process.env.PG_PORT) || 5432, + username: process.env.E2E_PG_USER || process.env.PG_USER || 'postgres', + password: process.env.E2E_PG_PASSWORD || process.env.PG_PASSWORD || '', + database, + group: 'E2E Schema Tree', + sslMode: 'disable', + options: {}, + }, + }); + + await browser.refresh(); + await browser.pause(1000); + await openConnectionsWorkspace(mainWindow); + await clickCardConnectButton(connectionName); + await waitForConnectionToolbar(); + await expandConnectedConnectionInNavigator(connectionName); + dbSessionIdRef.value = await connectConfig(connectionId); + const dbSessionId = dbSessionIdRef.value; + + const qSchema = quoteIdent(schema); + const qName = (object: string) => `${qSchema}.${quoteIdent(object)}`; + const statements = [ + `CREATE SCHEMA ${qSchema}`, + `CREATE TYPE ${qName(name.enum)} AS ENUM ('active', 'archived')`, + `CREATE DOMAIN ${qName(name.domain)} AS text CHECK (VALUE ~ '^[A-Z0-9-]{1,24}$')`, + `CREATE SEQUENCE ${qName(name.sequence)} START WITH 1 INCREMENT BY 1`, + `CREATE TABLE ${qName(name.groups)} ( + group_id integer PRIMARY KEY, + group_name text NOT NULL UNIQUE + )`, + `CREATE TABLE ${qName(name.entries)} ( + entry_id integer PRIMARY KEY DEFAULT nextval('${schema}.${name.sequence}'), + group_id integer NOT NULL REFERENCES ${qName(name.groups)} (group_id), + code ${qName(name.domain)} NOT NULL UNIQUE, + title text NOT NULL, + state ${qName(name.enum)} NOT NULL DEFAULT 'active', + updated_at timestamptz NOT NULL DEFAULT now() + )`, + `ALTER SEQUENCE ${qName(name.sequence)} OWNED BY ${qName(name.entries)}.entry_id`, + `CREATE INDEX ${quoteIdent(name.index)} ON ${qName(name.entries)} (state, group_id)`, + `CREATE VIEW ${qName(name.view)} AS + SELECT e.entry_id, g.group_name, e.code, e.title, e.state, e.updated_at + FROM ${qName(name.entries)} AS e + JOIN ${qName(name.groups)} AS g USING (group_id) + WHERE e.state = 'active'`, + `CREATE FUNCTION ${qName(name.function)}(input_code text) + RETURNS text LANGUAGE sql IMMUTABLE + AS $$ SELECT upper(btrim(input_code)) $$`, + `CREATE PROCEDURE ${qName(name.procedure)}(p_entry_id integer) + LANGUAGE plpgsql AS $$ + BEGIN + UPDATE ${qName(name.entries)} + SET state = 'archived', updated_at = now() + WHERE entry_id = p_entry_id; + END; + $$`, + `CREATE FUNCTION ${qName(name.triggerFunction)}() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + NEW.updated_at := now(); + RETURN NEW; + END; + $$`, + `CREATE TRIGGER ${quoteIdent(name.trigger)} + BEFORE UPDATE ON ${qName(name.entries)} + FOR EACH ROW EXECUTE FUNCTION ${qName(name.triggerFunction)}()`, + `INSERT INTO ${qName(name.groups)} (group_id, group_name) + VALUES (101, 'Schema Tree group')`, + `INSERT INTO ${qName(name.entries)} (entry_id, group_id, code, title, state) + VALUES (501, 101, 'TREE-001', 'Active Schema Tree fixture', 'active'), + (502, 101, 'TREE-002', 'Archived Schema Tree fixture', 'archived')`, + `SELECT setval('${schema}.${name.sequence}', 502, true)`, + ]; + + for (const sql of statements) { + await executeSql(dbSessionId, sql); + } + + // A deliberately invalid read checks that PostgreSQL errors surface to + // the E2E caller; the finally block must still drop the partial schema. + await expect( + executeSql(dbSessionId, `SELECT * FROM ${qName('tree_missing_relation')}`), + ).rejects.toThrow(); + + await clickNavigatorRefresh(); + await expandConnectedConnectionInNavigator(connectionName); + for (const category of [ + 'tables', + 'views', + 'function', + 'procedure', + 'trigger', + 'sequence', + 'type', + ]) { + await expandSchemaCategory(category, schema, database); + } + + const treeNode = (kind: string, objectName: string) => + $( + `[data-testid="schema-tree-node"][data-tree-node="${kind}"][data-item-name="${objectName}"]`, + ); + for (const [kind, objectName] of [ + ['table', name.groups], + ['table', name.entries], + ['view', name.view], + ['function', name.function], + ['function', name.triggerFunction], + ['procedure', name.procedure], + ['trigger', name.trigger], + ['sequence', name.sequence], + ['type', name.enum], + ['type', name.domain], + ]) { + const node = treeNode(kind, objectName); + await node.waitForDisplayed({ + timeout: 15000, + timeoutMsg: `Schema Tree did not show ${kind} ${schema}.${objectName}`, + }); + await expect(node).toBeDisplayed(); + } + + const schemaVisible = await browser.execute( + (targetSchema: string) => + Array.from(document.querySelectorAll('[data-tree-node="schema"]')).some( + (node) => node.getAttribute('data-schema-name') === targetSchema, + ), + schema, + ); + expect(schemaVisible).toBe(true); + + const tableNode = treeNode('table', name.entries); + await tableNode.click(); + await $('[data-testid="sub-tab-data"]').waitForDisplayed({ timeout: 15000 }); + await browser.waitUntil(async () => (await $('body').getText()).includes('TREE-001'), { + timeout: 15000, + timeoutMsg: 'The opened PostgreSQL table did not show its seeded row', + }); + + const viewDdl = await readObjectDdl({ + dbSessionId, + kind: 'view', + name: name.view, + schema, + }); + expect(viewDdl.toLowerCase()).toContain(name.entries); + + const sequenceDdl = await readObjectDdl({ + dbSessionId, + kind: 'sequence', + name: name.sequence, + schema, + }); + expect(sequenceDdl).toContain('CREATE SEQUENCE'); + expect(sequenceDdl).toContain(name.sequence); + + const enumDdl = await readObjectDdl({ + dbSessionId, + kind: 'type', + name: name.enum, + schema, + }); + expect(enumDdl).toContain('CREATE TYPE'); + expect(enumDdl).toContain(name.enum); + expect(enumDdl).toContain("'active'"); + + const domainDdl = await readObjectDdl({ + dbSessionId, + kind: 'type', + name: name.domain, + schema, + }); + expect(domainDdl).toContain('CREATE DOMAIN'); + expect(domainDdl).toContain(name.domain); + + for (const [kind, objectName] of [ + ['function', name.function], + ['procedure', name.procedure], + ['trigger', name.trigger], + ]) { + const node = treeNode(kind, objectName); + await node.click(); + await waitForEditorText(objectName); + const editorText = await browser.execute( + () => document.querySelector('[data-testid="sql-editor-content"]')?.textContent ?? '', + ); + expect(editorText.toLowerCase()).toContain(objectName.toLowerCase()); + } + + const counts = parseRows( + await executeSql( + dbSessionId, + `SELECT + (SELECT count(*) FROM ${qName(name.entries)})::int, + (SELECT count(*) FROM ${qName(name.view)})::int`, + ), + ); + expect(counts[0]?.[0]).toBe(2); + expect(counts[0]?.[1]).toBe(1); + } catch (error) { + primaryFailure = error; + } finally { + const dbSessionId = dbSessionIdRef.value; + if (dbSessionId) { + try { + await withSafeModeOff(() => + executeSql(dbSessionId, `DROP SCHEMA IF EXISTS ${quoteIdent(schema)} CASCADE`), + ); + const remaining = parseRows( + await executeSql( + dbSessionId, + `SELECT count(*)::int FROM information_schema.schemata WHERE schema_name = '${schema}'`, + ), + ); + if (Number(remaining[0]?.[0]) !== 0) { + cleanupFailures.push(`temporary schema ${schema} remained after teardown`); + } + } catch (error) { + cleanupFailures.push( + `could not remove temporary schema ${schema}: ${error instanceof Error ? error.name : 'database error'}`, + ); + } + try { + await disconnectBackend(dbSessionId); + } catch (error) { + cleanupFailures.push( + `could not disconnect temporary PostgreSQL session: ${error instanceof Error ? error.name : 'session error'}`, + ); + } + } + + try { + await invokeBackend('delete_connection', { id: connectionId }); + } catch (error) { + cleanupFailures.push( + `could not remove temporary connection config: ${error instanceof Error ? error.name : 'config error'}`, + ); + } + } + + if (primaryFailure) throw primaryFailure; + if (cleanupFailures.length > 0) throw new Error(cleanupFailures.join('; ')); + }); +}); From 598388012c7f18c54d24231d8c9c88f4d789357d Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 15:53:49 +0800 Subject: [PATCH 034/666] test(mysql): add schema tree object journey --- .../install-manual-schema-tree-fixtures.mjs | 50 +++ .../drivers/mysql/e2e/manual-schema-tree.sql | 60 ++++ .../drivers/mysql/e2e/schema-tree-objects.ts | 331 ++++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.mjs create mode 100644 packages/drivers/mysql/e2e/manual-schema-tree.sql create mode 100644 packages/drivers/mysql/e2e/schema-tree-objects.ts diff --git a/packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.mjs b/packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.mjs new file mode 100644 index 000000000..1a4d73b66 --- /dev/null +++ b/packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Install the durable MySQL Schema Tree fixture used for manual testing. + * + * Reads connection values only from the caller's E2E_MYSQL_* environment; + * it does not load dotenv files or print connection settings. The E2E runner + * already exports those variables from its configured environment. + */ +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const host = process.env.E2E_MYSQL_HOST || '127.0.0.1'; +const port = process.env.E2E_MYSQL_PORT || '3306'; +const user = process.env.E2E_MYSQL_USER || 'root'; +const password = process.env.E2E_MYSQL_PASSWORD || ''; +const mysqlCli = process.env.MYSQL_CLI || 'mysql'; +const sqlPath = fileURLToPath(new URL('./manual-schema-tree.sql', import.meta.url)); +const sql = readFileSync(sqlPath, 'utf8'); + +const result = spawnSync( + mysqlCli, + [ + '--protocol=TCP', + '--host', + host, + '--port', + port, + '--user', + user, + '--default-character-set=utf8mb4', + ], + { + input: sql, + encoding: 'utf8', + env: { ...process.env, MYSQL_PWD: password }, + }, +); + +if (result.error || result.status !== 0) { + const reason = result.error + ? 'mysql client could not be started' + : 'mysql client returned a failure'; + console.error(`[mysql-schema-tree] ${reason}; diagnostic output suppressed`); + process.exitCode = 1; +} else { + console.log( + '[mysql-schema-tree] durable manual fixture is ready in datazen_manual_schema_tree', + ); +} diff --git a/packages/drivers/mysql/e2e/manual-schema-tree.sql b/packages/drivers/mysql/e2e/manual-schema-tree.sql new file mode 100644 index 000000000..99b65bd15 --- /dev/null +++ b/packages/drivers/mysql/e2e/manual-schema-tree.sql @@ -0,0 +1,60 @@ +-- Durable fixture for manual Schema Tree checks. +-- Re-running this file preserves extra rows and only upserts the two named samples. +CREATE DATABASE IF NOT EXISTS `datazen_manual_schema_tree` + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE `datazen_manual_schema_tree`; + +CREATE TABLE IF NOT EXISTS `manual_schema_tree_parents` ( + `id` INT NOT NULL PRIMARY KEY, + `code` VARCHAR(64) NOT NULL UNIQUE +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS `manual_schema_tree_items` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `parent_id` INT NOT NULL, + `code` VARCHAR(64) NOT NULL, + `status` VARCHAR(16) NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_manual_schema_tree_items_parent_status` (`parent_id`, `status`), + CONSTRAINT `fk_manual_schema_tree_items_parent` + FOREIGN KEY (`parent_id`) REFERENCES `manual_schema_tree_parents` (`id`) +) ENGINE=InnoDB; + +INSERT INTO `manual_schema_tree_parents` (`id`, `code`) VALUES + (1001, 'manual-parent-a'), + (1002, 'manual-parent-b') +ON DUPLICATE KEY UPDATE `code` = VALUES(`code`); + +INSERT INTO `manual_schema_tree_items` (`id`, `parent_id`, `code`, `status`) VALUES + (2001, 1001, 'manual-active-a', 'active'), + (2002, 1001, 'manual-inactive-a', 'inactive'), + (2003, 1002, 'manual-active-b', 'active') +ON DUPLICATE KEY UPDATE + `parent_id` = VALUES(`parent_id`), + `code` = VALUES(`code`), + `status` = VALUES(`status`); + +CREATE OR REPLACE VIEW `manual_schema_tree_active_items` AS +SELECT `id`, `parent_id`, `code`, `status`, `created_at`, `updated_at` +FROM `manual_schema_tree_items` +WHERE `status` = 'active'; + +DROP FUNCTION IF EXISTS `manual_schema_tree_normalize_code`; +CREATE FUNCTION `manual_schema_tree_normalize_code` (`p_value` VARCHAR(64)) +RETURNS VARCHAR(64) +DETERMINISTIC +RETURN UPPER(TRIM(`p_value`)); + +DROP PROCEDURE IF EXISTS `manual_schema_tree_list_children`; +CREATE PROCEDURE `manual_schema_tree_list_children` (IN `p_parent_id` INT) +SELECT `id`, `code`, `status` +FROM `manual_schema_tree_items` +WHERE `parent_id` = `p_parent_id` +ORDER BY `id`; + +DROP TRIGGER IF EXISTS `manual_schema_tree_touch_updated_at`; +CREATE TRIGGER `manual_schema_tree_touch_updated_at` +BEFORE UPDATE ON `manual_schema_tree_items` +FOR EACH ROW SET NEW.`updated_at` = CURRENT_TIMESTAMP; diff --git a/packages/drivers/mysql/e2e/schema-tree-objects.ts b/packages/drivers/mysql/e2e/schema-tree-objects.ts new file mode 100644 index 000000000..0548bc651 --- /dev/null +++ b/packages/drivers/mysql/e2e/schema-tree-objects.ts @@ -0,0 +1,331 @@ +/** MySQL Schema Tree journey: visible object categories, object definitions and teardown. */ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { expect, browser, $ } from '@wdio/globals'; +import { + closeExtraWindows, + connectBackend, + connectToCard, + disconnectBackend, + expandConnectedConnectionInNavigator, + expandSchemaCategory, + invokeBackend, + parseQueryRows, + queryScalar, + withSafeModeOff, + type QueryResultPayload, +} from '../../../../e2e/helpers.js'; + +const ADMIN_DATABASE = process.env.E2E_MYSQL_DB || 'datazen_test'; + +function mysqlConfig(id: string, name: string, database: string) { + return { + id, + name, + databaseType: 'mysql', + host: process.env.E2E_MYSQL_HOST || '127.0.0.1', + port: Number(process.env.E2E_MYSQL_PORT) || 3306, + username: process.env.E2E_MYSQL_USER || 'root', + password: process.env.E2E_MYSQL_PASSWORD || '', + database, + sslMode: 'disable', + }; +} + +function runDurableFixtureInstaller(): void { + const scriptPath = fileURLToPath( + new URL('./install-manual-schema-tree-fixtures.mjs', import.meta.url), + ); + const result = spawnSync(process.execPath, [scriptPath], { + env: process.env, + encoding: 'utf8', + }); + if (result.error || result.status !== 0) { + throw new Error( + 'Could not install the durable MySQL manual Schema Tree fixture; ' + + 'see the sanitized installer diagnostic.', + ); + } +} + +describe('MySQL Schema Tree object journey', function () { + this.timeout(180_000); + + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const database = `e2e_temp_schema_tree_${stamp}`; + const prefix = `e2e_tree_${stamp}`; + const parentTable = `${prefix}_parents`; + const table = `${prefix}_items`; + const view = `${prefix}_active_items`; + const functionName = `${prefix}_normalize_code`; + const procedureName = `${prefix}_list_children`; + const triggerName = `${prefix}_touch_updated_at`; + const adminId = `e2e_mysql_schema_tree_admin_${stamp}`; + const connectionId = `e2e_mysql_schema_tree_${stamp}`; + const connectionName = `E2E MySQL Schema Tree ${stamp}`; + + let mainWindow: string | undefined; + let adminConfigSaved = false; + let treeConfigSaved = false; + let adminSession: string | undefined; + let treeSession: string | undefined; + let databaseCreated = false; + + async function execute(session: string, sql: string): Promise { + return invokeBackend('execute_query', { dbSessionId: session, sql }); + } + + async function waitForNode(kind: string, name: string): Promise { + const selector = + `[data-testid="schema-tree-node"][data-tree-node="${kind}"]` + + `[data-item-name="${name}"]`; + await browser.waitUntil(async () => (await $(selector).isDisplayed().catch(() => false)), { + timeout: 20_000, + timeoutMsg: `MySQL Schema Tree did not show ${kind} ${name}`, + }); + } + + async function assertObjectDefinition( + kind: 'function' | 'procedure' | 'trigger', + name: string, + expectedText: string, + ): Promise { + const selector = + `[data-testid="schema-tree-node"][data-tree-node="${kind}"]` + + `[data-item-name="${name}"]`; + const node = await $(selector); + await node.waitForDisplayed({ timeout: 20_000 }); + await node.click(); + + let definition = ''; + await browser.waitUntil( + async () => { + definition = await browser.execute(() => { + const visibleEditors = Array.from( + document.querySelectorAll('[data-testid="sql-editor-content"]'), + ).filter((editor) => editor.getClientRects().length > 0); + return visibleEditors.at(-1)?.textContent ?? ''; + }); + return ( + definition.includes(name) && + definition.toLowerCase().includes(expectedText.toLowerCase()) + ); + }, + { + timeout: 20_000, + timeoutMsg: `Opening MySQL ${kind} ${name} did not show its expected definition`, + }, + ); + expect(definition).toContain(name); + expect(definition.toLowerCase()).toContain(expectedText.toLowerCase()); + } + + async function cleanup(): Promise { + const cleanupErrors: string[] = []; + if (treeSession) { + try { + await disconnectBackend(treeSession); + } catch { + cleanupErrors.push('could not disconnect the Schema Tree session'); + } + treeSession = undefined; + } + + try { + if (!adminSession && adminConfigSaved) adminSession = await connectBackend(adminId); + if (adminSession && databaseCreated) { + await withSafeModeOff(async () => { + await execute(adminSession!, `DROP DATABASE IF EXISTS \`${database}\``); + }); + databaseCreated = false; + const remaining = await execute( + adminSession, + `SELECT COUNT(*) AS value FROM information_schema.SCHEMATA ` + + `WHERE SCHEMA_NAME = '${database}'`, + ); + if (queryScalar(remaining, 'value') !== 0) { + cleanupErrors.push(`temporary database ${database} still exists after teardown`); + } + } + } catch { + cleanupErrors.push(`could not drop temporary database ${database}`); + } finally { + if (adminSession) { + await disconnectBackend(adminSession).catch(() => undefined); + adminSession = undefined; + } + } + + for (const [id, saved] of [ + [connectionId, treeConfigSaved], + [adminId, adminConfigSaved], + ] as const) { + if (saved) await invokeBackend('delete_connection', { id }).catch(() => undefined); + } + if (mainWindow) { + await closeExtraWindows(mainWindow).catch(() => undefined); + await browser.switchToWindow(mainWindow).catch(() => undefined); + } + + if (cleanupErrors.length > 0) throw new Error(cleanupErrors.join('; ')); + } + + before(async () => { + mainWindow = await browser.getWindowHandle(); + runDurableFixtureInstaller(); + + const adminConfig = mysqlConfig(adminId, `Schema Tree admin ${stamp}`, ADMIN_DATABASE); + await invokeBackend('save_connection', { config: adminConfig }); + adminConfigSaved = true; + adminSession = await connectBackend(adminId); + await withSafeModeOff(async () => { + databaseCreated = true; + await execute( + adminSession!, + `CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`, + ); + }); + + const treeConfig = mysqlConfig(connectionId, connectionName, database); + await invokeBackend('save_connection', { config: treeConfig }); + treeConfigSaved = true; + treeSession = await connectBackend(connectionId); + + await withSafeModeOff(async () => { + await execute( + treeSession!, + `CREATE TABLE \`${parentTable}\` (` + + '`id` INT NOT NULL PRIMARY KEY, `code` VARCHAR(64) NOT NULL UNIQUE' + + ') ENGINE=InnoDB', + ); + await execute( + treeSession!, + `CREATE TABLE \`${table}\` (` + + '`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, ' + + '`parent_id` INT NOT NULL, `code` VARCHAR(64) NOT NULL, ' + + '`status` VARCHAR(16) NOT NULL, `updated_at` TIMESTAMP NULL DEFAULT NULL, ' + + `KEY \`idx_${prefix}_parent_status\` (\`parent_id\`, \`status\`), ` + + `CONSTRAINT \`fk_${prefix}_parent\` FOREIGN KEY (\`parent_id\`) ` + + `REFERENCES \`${parentTable}\` (\`id\`)` + + ') ENGINE=InnoDB', + ); + await execute( + treeSession!, + `INSERT INTO \`${parentTable}\` (id, code) VALUES (1, 'journey-parent')`, + ); + await execute( + treeSession!, + `INSERT INTO \`${table}\` (id, parent_id, code, status) VALUES ` + + `(11, 1, 'journey-active', 'active'), (12, 1, 'journey-inactive', 'inactive')`, + ); + await execute( + treeSession!, + `CREATE VIEW \`${view}\` AS SELECT id, parent_id, code, status ` + + `FROM \`${table}\` WHERE status = 'active'`, + ); + await execute( + treeSession!, + `CREATE FUNCTION \`${functionName}\` (p_value VARCHAR(64)) ` + + `RETURNS VARCHAR(64) DETERMINISTIC RETURN UPPER(TRIM(p_value))`, + ); + await execute( + treeSession!, + `CREATE PROCEDURE \`${procedureName}\` (IN p_parent_id INT) ` + + `SELECT id, code, status FROM \`${table}\` ` + + `WHERE parent_id = p_parent_id ORDER BY id`, + ); + await execute( + treeSession!, + `CREATE TRIGGER \`${triggerName}\` BEFORE UPDATE ON \`${table}\` ` + + 'FOR EACH ROW SET NEW.`updated_at` = CURRENT_TIMESTAMP', + ); + }); + + await connectToCard(connectionName); + await expandConnectedConnectionInNavigator(connectionName); + for (const category of ['tables', 'views', 'function', 'procedure', 'trigger']) { + await expandSchemaCategory(category, database, database); + } + }); + + after(async () => { + await cleanup(); + }); + + it('shows tables, relationships, views and every supported MySQL routine category', async () => { + await waitForNode('table', parentTable); + await waitForNode('table', table); + await waitForNode('view', view); + await waitForNode('function', functionName); + await waitForNode('procedure', procedureName); + await waitForNode('trigger', triggerName); + + await $( + `[data-testid="schema-tree-node"][data-tree-node="table"][data-item-name="${table}"]`, + ).click(); + await browser.waitUntil(async () => (await $('body').getText()).includes('journey-active'), { + timeout: 20_000, + timeoutMsg: `Opening MySQL table ${table} did not show its seeded row`, + }); + + await $( + `[data-testid="schema-tree-node"][data-tree-node="view"][data-item-name="${view}"]`, + ).click(); + await browser.waitUntil(async () => (await $('body').getText()).includes('journey-active'), { + timeout: 20_000, + timeoutMsg: `Opening MySQL view ${view} did not show its active row`, + }); + + const tableCount = await execute(treeSession!, `SELECT COUNT(*) AS value FROM \`${table}\``); + expect(queryScalar(tableCount, 'value')).toBe(2); + + const foreignKeyCount = await execute( + treeSession!, + `SELECT COUNT(*) AS value FROM information_schema.KEY_COLUMN_USAGE ` + + `WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '${table}' ` + + `AND CONSTRAINT_NAME = 'fk_${prefix}_parent'`, + ); + expect(queryScalar(foreignKeyCount, 'value')).toBe(1); + + const indexCount = await execute( + treeSession!, + `SELECT COUNT(*) AS value FROM information_schema.STATISTICS ` + + `WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '${table}' ` + + `AND INDEX_NAME = 'idx_${prefix}_parent_status'`, + ); + expect(queryScalar(indexCount, 'value')).toBe(2); + + const viewRows = await execute(treeSession!, `SELECT id, code FROM \`${view}\``); + const rows = parseQueryRows(viewRows); + expect(rows).toHaveLength(1); + expect(rows[0]?.[1]).toBe('journey-active'); + + const functionRows = parseQueryRows( + await execute(treeSession!, `SELECT \`${functionName}\`(' lower ') AS value`), + ); + expect(functionRows[0]?.[0]).toBe('LOWER'); + + await execute(treeSession!, `UPDATE \`${table}\` SET code = code WHERE id = 11`); + const triggerRows = parseQueryRows( + await execute( + treeSession!, + `SELECT updated_at IS NOT NULL AS value FROM \`${table}\` WHERE id = 11`, + ), + ); + expect(Number(triggerRows[0]?.[0])).toBe(1); + + let missingTableRejected = false; + try { + await execute(treeSession!, `SELECT * FROM \`${prefix}_missing\``); + } catch { + missingTableRejected = true; + } + expect(missingTableRejected).toBe(true); + }); + + it('opens function, procedure and trigger definitions from the Schema Tree', async () => { + await assertObjectDefinition('function', functionName, 'UPPER(TRIM'); + await assertObjectDefinition('procedure', procedureName, table); + await assertObjectDefinition('trigger', triggerName, 'CURRENT_TIMESTAMP'); + }); +}); From ea00e9eba979421caa3a2a2368dff4d15ad7aeae Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 16:03:58 +0800 Subject: [PATCH 035/666] test(mysql): strengthen schema tree journey --- .../install-manual-schema-tree-fixtures.mjs | 8 +-- .../install-manual-schema-tree-fixtures.sh | 15 ++++ .../drivers/mysql/e2e/schema-tree-objects.ts | 71 ++++++++++--------- 3 files changed, 57 insertions(+), 37 deletions(-) create mode 100755 packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.sh diff --git a/packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.mjs b/packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.mjs index 1a4d73b66..2bff36f60 100644 --- a/packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.mjs +++ b/packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.mjs @@ -3,8 +3,8 @@ * Install the durable MySQL Schema Tree fixture used for manual testing. * * Reads connection values only from the caller's E2E_MYSQL_* environment; - * it does not load dotenv files or print connection settings. The E2E runner - * already exports those variables from its configured environment. + * it does not load dotenv files or print connection settings. Use + * install-manual-schema-tree-fixtures.sh to load the repository's E2E config. */ import { readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; @@ -44,7 +44,5 @@ if (result.error || result.status !== 0) { console.error(`[mysql-schema-tree] ${reason}; diagnostic output suppressed`); process.exitCode = 1; } else { - console.log( - '[mysql-schema-tree] durable manual fixture is ready in datazen_manual_schema_tree', - ); + console.log('[mysql-schema-tree] durable manual fixture is ready in datazen_manual_schema_tree'); } diff --git a/packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.sh b/packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.sh new file mode 100755 index 000000000..0eaada4b0 --- /dev/null +++ b/packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +ENV_FILE="$REPO_ROOT/e2e/.env" + +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$ENV_FILE" + set +a +fi + +exec node "$SCRIPT_DIR/install-manual-schema-tree-fixtures.mjs" diff --git a/packages/drivers/mysql/e2e/schema-tree-objects.ts b/packages/drivers/mysql/e2e/schema-tree-objects.ts index 0548bc651..56dda921a 100644 --- a/packages/drivers/mysql/e2e/schema-tree-objects.ts +++ b/packages/drivers/mysql/e2e/schema-tree-objects.ts @@ -1,6 +1,4 @@ /** MySQL Schema Tree journey: visible object categories, object definitions and teardown. */ -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; import { expect, browser, $ } from '@wdio/globals'; import { closeExtraWindows, @@ -32,22 +30,6 @@ function mysqlConfig(id: string, name: string, database: string) { }; } -function runDurableFixtureInstaller(): void { - const scriptPath = fileURLToPath( - new URL('./install-manual-schema-tree-fixtures.mjs', import.meta.url), - ); - const result = spawnSync(process.execPath, [scriptPath], { - env: process.env, - encoding: 'utf8', - }); - if (result.error || result.status !== 0) { - throw new Error( - 'Could not install the durable MySQL manual Schema Tree fixture; ' + - 'see the sanitized installer diagnostic.', - ); - } -} - describe('MySQL Schema Tree object journey', function () { this.timeout(180_000); @@ -77,12 +59,17 @@ describe('MySQL Schema Tree object journey', function () { async function waitForNode(kind: string, name: string): Promise { const selector = - `[data-testid="schema-tree-node"][data-tree-node="${kind}"]` + - `[data-item-name="${name}"]`; - await browser.waitUntil(async () => (await $(selector).isDisplayed().catch(() => false)), { - timeout: 20_000, - timeoutMsg: `MySQL Schema Tree did not show ${kind} ${name}`, - }); + `[data-testid="schema-tree-node"][data-tree-node="${kind}"]` + `[data-item-name="${name}"]`; + await browser.waitUntil( + async () => + await $(selector) + .isDisplayed() + .catch(() => false), + { + timeout: 20_000, + timeoutMsg: `MySQL Schema Tree did not show ${kind} ${name}`, + }, + ); } async function assertObjectDefinition( @@ -91,8 +78,7 @@ describe('MySQL Schema Tree object journey', function () { expectedText: string, ): Promise { const selector = - `[data-testid="schema-tree-node"][data-tree-node="${kind}"]` + - `[data-item-name="${name}"]`; + `[data-testid="schema-tree-node"][data-tree-node="${kind}"]` + `[data-item-name="${name}"]`; const node = await $(selector); await node.waitForDisplayed({ timeout: 20_000 }); await node.click(); @@ -107,8 +93,7 @@ describe('MySQL Schema Tree object journey', function () { return visibleEditors.at(-1)?.textContent ?? ''; }); return ( - definition.includes(name) && - definition.toLowerCase().includes(expectedText.toLowerCase()) + definition.includes(name) && definition.toLowerCase().includes(expectedText.toLowerCase()) ); }, { @@ -172,7 +157,6 @@ describe('MySQL Schema Tree object journey', function () { before(async () => { mainWindow = await browser.getWindowHandle(); - runDurableFixtureInstaller(); const adminConfig = mysqlConfig(adminId, `Schema Tree admin ${stamp}`, ADMIN_DATABASE); await invokeBackend('save_connection', { config: adminConfig }); @@ -231,8 +215,8 @@ describe('MySQL Schema Tree object journey', function () { await execute( treeSession!, `CREATE PROCEDURE \`${procedureName}\` (IN p_parent_id INT) ` + - `SELECT id, code, status FROM \`${table}\` ` + - `WHERE parent_id = p_parent_id ORDER BY id`, + `UPDATE \`${table}\` SET status = 'processed' ` + + `WHERE parent_id = p_parent_id AND status = 'inactive'`, ); await execute( treeSession!, @@ -305,6 +289,16 @@ describe('MySQL Schema Tree object journey', function () { ); expect(functionRows[0]?.[0]).toBe('LOWER'); + await withSafeModeOff(async () => { + await execute(treeSession!, `CALL \`${procedureName}\`(1)`); + }); + const procedureEffect = await execute( + treeSession!, + `SELECT COUNT(*) AS value FROM \`${table}\` ` + + `WHERE parent_id = 1 AND status = 'processed'`, + ); + expect(queryScalar(procedureEffect, 'value')).toBe(1); + await execute(treeSession!, `UPDATE \`${table}\` SET code = code WHERE id = 11`); const triggerRows = parseQueryRows( await execute( @@ -325,7 +319,20 @@ describe('MySQL Schema Tree object journey', function () { it('opens function, procedure and trigger definitions from the Schema Tree', async () => { await assertObjectDefinition('function', functionName, 'UPPER(TRIM'); - await assertObjectDefinition('procedure', procedureName, table); + await assertObjectDefinition('procedure', procedureName, 'UPDATE'); await assertObjectDefinition('trigger', triggerName, 'CURRENT_TIMESTAMP'); }); + + it('returns the view definition through the MySQL object DDL command', async () => { + const response = await invokeBackend<{ data: { ddl: string } }>('execute_driver_command', { + request: { + dbSessionId: treeSession, + command: 'get_object_ddl', + input: { kind: 'view', name: view, schema: database }, + }, + }); + expect(response.data.ddl).toContain(view); + expect(response.data.ddl).toContain(table); + expect(response.data.ddl.toLowerCase()).toContain("status = 'active'"); + }); }); From 5b71b74aef3a01ae7e460525d8e6113801c3ed7b Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 16:13:07 +0800 Subject: [PATCH 036/666] test(mysql): refresh saved connection before navigator journey --- packages/drivers/mysql/e2e/schema-tree-objects.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/drivers/mysql/e2e/schema-tree-objects.ts b/packages/drivers/mysql/e2e/schema-tree-objects.ts index 56dda921a..d94ff64eb 100644 --- a/packages/drivers/mysql/e2e/schema-tree-objects.ts +++ b/packages/drivers/mysql/e2e/schema-tree-objects.ts @@ -2,14 +2,17 @@ import { expect, browser, $ } from '@wdio/globals'; import { closeExtraWindows, + clickCardConnectButton, connectBackend, - connectToCard, disconnectBackend, expandConnectedConnectionInNavigator, expandSchemaCategory, + findCardByName, invokeBackend, + openConnectionsWorkspace, parseQueryRows, queryScalar, + waitForConnectionToolbar, withSafeModeOff, type QueryResultPayload, } from '../../../../e2e/helpers.js'; @@ -225,7 +228,15 @@ describe('MySQL Schema Tree object journey', function () { ); }); - await connectToCard(connectionName); + await browser.refresh(); + await browser.pause(1_500); + await openConnectionsWorkspace(mainWindow); + await browser.waitUntil(async () => Boolean(await findCardByName(connectionName)), { + timeout: 20_000, + timeoutMsg: `Saved MySQL connection card ${connectionName} did not appear after refresh`, + }); + await clickCardConnectButton(connectionName); + await waitForConnectionToolbar(); await expandConnectedConnectionInNavigator(connectionName); for (const category of ['tables', 'views', 'function', 'procedure', 'trigger']) { await expandSchemaCategory(category, database, database); From 00b147c84ec7ebe1de97afaf18074785cb3dfe4c Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 16:17:08 +0800 Subject: [PATCH 037/666] test(mysql): connect exact schema tree fixture card --- packages/drivers/mysql/e2e/schema-tree-objects.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/drivers/mysql/e2e/schema-tree-objects.ts b/packages/drivers/mysql/e2e/schema-tree-objects.ts index d94ff64eb..3c407cf6c 100644 --- a/packages/drivers/mysql/e2e/schema-tree-objects.ts +++ b/packages/drivers/mysql/e2e/schema-tree-objects.ts @@ -2,8 +2,8 @@ import { expect, browser, $ } from '@wdio/globals'; import { closeExtraWindows, - clickCardConnectButton, connectBackend, + dblclickConnByExactName, disconnectBackend, expandConnectedConnectionInNavigator, expandSchemaCategory, @@ -148,7 +148,13 @@ describe('MySQL Schema Tree object journey', function () { [connectionId, treeConfigSaved], [adminId, adminConfigSaved], ] as const) { - if (saved) await invokeBackend('delete_connection', { id }).catch(() => undefined); + if (saved) { + try { + await invokeBackend('delete_connection', { id }); + } catch { + cleanupErrors.push(`could not delete temporary connection ${id}`); + } + } } if (mainWindow) { await closeExtraWindows(mainWindow).catch(() => undefined); @@ -235,7 +241,9 @@ describe('MySQL Schema Tree object journey', function () { timeout: 20_000, timeoutMsg: `Saved MySQL connection card ${connectionName} did not appear after refresh`, }); - await clickCardConnectButton(connectionName); + if (!(await dblclickConnByExactName(connectionName))) { + throw new Error(`Saved MySQL connection card ${connectionName} could not be opened`); + } await waitForConnectionToolbar(); await expandConnectedConnectionInNavigator(connectionName); for (const category of ['tables', 'views', 'function', 'procedure', 'trigger']) { From 02903e0aad800939304b0c8b4e9500a271993726 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 16:17:14 +0800 Subject: [PATCH 038/666] test(postgres): scroll schema object leaves into view --- .../postgres/e2e/schema-tree-objects.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/drivers/postgres/e2e/schema-tree-objects.ts b/packages/drivers/postgres/e2e/schema-tree-objects.ts index 3b533c8db..8c75adda8 100644 --- a/packages/drivers/postgres/e2e/schema-tree-objects.ts +++ b/packages/drivers/postgres/e2e/schema-tree-objects.ts @@ -34,6 +34,13 @@ type ObjectDdlRequest = { targetName?: string; }; +type ListedDatabaseObject = { + kind: string; + schema?: string | null; + name: string; + signature?: string | null; +}; + function quoteIdent(identifier: string): string { return `"${identifier.replace(/"/g, '""')}"`; } @@ -66,6 +73,13 @@ async function waitForEditorText(expected: string): Promise { ); } +async function searchNavigator(query: string): Promise { + const input = await $('[data-testid="connection-search-input"]'); + await input.waitForDisplayed({ timeout: 8000 }); + await input.setValue(query); + await browser.pause(650); +} + describe('PostgreSQL Schema Tree objects', () => { let mainWindow: string; @@ -194,6 +208,24 @@ describe('PostgreSQL Schema Tree objects', () => { await clickNavigatorRefresh(); await expandConnectedConnectionInNavigator(connectionName); + + // Probe the actual driver command before asserting the rendered row. + // This keeps an empty PostgreSQL catalog result distinct from a row that + // exists but is outside the navigator's virtualized viewport. + const listedProcedures = await invokeBackend('get_database_objects', { + dbSessionId, + kind: 'procedure', + }); + const listedProcedure = listedProcedures.find( + (object) => object.name === name.procedure && object.schema === schema, + ); + if (!listedProcedure) { + throw new Error( + `PostgreSQL get_database_objects(procedure) omitted ${schema}.${name.procedure}; ` + + `returned: ${listedProcedures.map((object) => `${object.schema ?? ''}.${object.name}`).join(', ') || '(none)'}`, + ); + } + for (const category of [ 'tables', 'views', @@ -222,6 +254,9 @@ describe('PostgreSQL Schema Tree objects', () => { ['type', name.enum], ['type', name.domain], ]) { + // The navigator uses a virtual list. Filtering by a unique fixture + // name brings each real leaf into the viewport before checking it. + await searchNavigator(objectName); const node = treeNode(kind, objectName); await node.waitForDisplayed({ timeout: 15000, @@ -230,6 +265,7 @@ describe('PostgreSQL Schema Tree objects', () => { await expect(node).toBeDisplayed(); } + await searchNavigator(schema); const schemaVisible = await browser.execute( (targetSchema: string) => Array.from(document.querySelectorAll('[data-tree-node="schema"]')).some( @@ -239,6 +275,7 @@ describe('PostgreSQL Schema Tree objects', () => { ); expect(schemaVisible).toBe(true); + await searchNavigator(name.entries); const tableNode = treeNode('table', name.entries); await tableNode.click(); await $('[data-testid="sub-tab-data"]').waitForDisplayed({ timeout: 15000 }); @@ -288,6 +325,7 @@ describe('PostgreSQL Schema Tree objects', () => { ['procedure', name.procedure], ['trigger', name.trigger], ]) { + await searchNavigator(objectName); const node = treeNode(kind, objectName); await node.click(); await waitForEditorText(objectName); From 064dcf525c92926f6a8bdf1e887b442821681253 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 16:24:04 +0800 Subject: [PATCH 039/666] test(mysql): assert view definition body --- packages/drivers/mysql/e2e/schema-tree-objects.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/drivers/mysql/e2e/schema-tree-objects.ts b/packages/drivers/mysql/e2e/schema-tree-objects.ts index 3c407cf6c..facdcb072 100644 --- a/packages/drivers/mysql/e2e/schema-tree-objects.ts +++ b/packages/drivers/mysql/e2e/schema-tree-objects.ts @@ -350,7 +350,7 @@ describe('MySQL Schema Tree object journey', function () { input: { kind: 'view', name: view, schema: database }, }, }); - expect(response.data.ddl).toContain(view); + expect(response.data.ddl.trim().length).toBeGreaterThan(0); expect(response.data.ddl).toContain(table); expect(response.data.ddl.toLowerCase()).toContain("status = 'active'"); }); From 13089504262aab0fb38b278ef128846337497c61 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 16:39:31 +0800 Subject: [PATCH 040/666] fix(navigator): scope schema object rows by schema --- .../postgres/e2e/schema-tree-objects.ts | 118 +++++++++++++++++- .../ConnectionNavigatorTree.test.tsx | 50 ++++++++ .../connection/navigator/NavigatorTreeRow.tsx | 3 + .../connection/navigator/buildFlatRows.ts | 18 ++- 4 files changed, 181 insertions(+), 8 deletions(-) diff --git a/packages/drivers/postgres/e2e/schema-tree-objects.ts b/packages/drivers/postgres/e2e/schema-tree-objects.ts index 8c75adda8..34f02df4a 100644 --- a/packages/drivers/postgres/e2e/schema-tree-objects.ts +++ b/packages/drivers/postgres/e2e/schema-tree-objects.ts @@ -11,7 +11,6 @@ import { connectConfig, disconnectBackend, expandConnectedConnectionInNavigator, - expandSchemaCategory, invokeBackend, openConnectionsWorkspace, waitForConnectionToolbar, @@ -76,10 +75,120 @@ async function waitForEditorText(expected: string): Promise { async function searchNavigator(query: string): Promise { const input = await $('[data-testid="connection-search-input"]'); await input.waitForDisplayed({ timeout: 8000 }); - await input.setValue(query); + if (query) await input.setValue(query); + else await input.clearValue(); await browser.pause(650); } +async function scrollNavigatorToNode( + nodeType: 'schema' | 'category', + identity: string, +): Promise { + await browser.execute(() => { + const tree = document.querySelector('[data-testid="navigator-tree"]'); + if (tree) tree.scrollTop = 0; + }); + + await browser.waitUntil( + async () => + browser.execute( + (kind: 'schema' | 'category', value: string) => { + const tree = document.querySelector('[data-testid="navigator-tree"]'); + if (!tree) return false; + + const candidates = Array.from( + tree.querySelectorAll(`[data-tree-node="${kind}"]`), + ); + const node = candidates.find((candidate) => + kind === 'schema' + ? candidate.getAttribute('data-schema-name') === value + : candidate.getAttribute('data-cat-key') === value, + ); + if (node) { + node.scrollIntoView({ block: 'center' }); + return true; + } + + const maxScroll = Math.max(0, tree.scrollHeight - tree.clientHeight); + tree.scrollTop = Math.min( + maxScroll, + tree.scrollTop === 0 + ? 0 + : tree.scrollTop + Math.max(160, Math.floor(tree.clientHeight * 0.75)), + ); + if (tree.scrollTop === 0) { + tree.scrollTop = Math.min( + maxScroll, + Math.max(160, Math.floor(tree.clientHeight * 0.75)), + ); + } + return false; + }, + nodeType, + identity, + ), + { + timeout: 15000, + timeoutMsg: `Navigator did not mount ${nodeType} ${identity} while scrolling`, + interval: 100, + }, + ); + await browser.pause(100); +} + +async function ensureSchemaExpanded(schema: string): Promise { + const selector = `[data-testid="schema-tree-node"][data-tree-node="schema"][data-schema-name="${schema}"]`; + + // Search force-expands the row for display, but clicking it still changes + // the underlying expansion state. Retry once in case the schema was already + // expanded before the first click. + for (let attempt = 0; attempt < 2; attempt += 1) { + await searchNavigator(schema); + const schemaNode = await $(selector); + await schemaNode.waitForDisplayed({ timeout: 8000 }); + await schemaNode.click(); + await searchNavigator(''); + await scrollNavigatorToNode('schema', schema); + const currentSchemaNode = await $(selector); + if ((await currentSchemaNode.getAttribute('aria-expanded')) === 'true') return; + } + + throw new Error(`Could not expand exact PostgreSQL schema ${schema}`); +} + +async function expandSchemaObjectCategory( + connectionId: string, + database: string, + schema: string, + category: string, +): Promise { + const categoryKey = `${connectionId}::${database}::${schema}::${category}`; + await scrollNavigatorToNode('category', categoryKey); + const categoryNode = await $( + `[data-testid="schema-tree-node"][data-tree-node="category"][data-cat-key="${categoryKey}"]`, + ); + await categoryNode.waitForDisplayed({ timeout: 8000 }); + if ((await categoryNode.getAttribute('aria-expanded')) !== 'true') { + await categoryNode.click(); + } + await browser.waitUntil( + async () => + browser.execute((key: string) => { + const tree = document.querySelector('[data-testid="navigator-tree"]'); + const categoryNode = Array.from( + tree?.querySelectorAll('[data-tree-node="category"]') ?? [], + ).find((candidate) => candidate.getAttribute('data-cat-key') === key); + const count = Number(categoryNode?.lastElementChild?.textContent?.trim()); + return Number.isFinite(count) && count > 0; + }, categoryKey), + { + timeout: 15000, + timeoutMsg: `UI did not load PostgreSQL objects for category ${categoryKey}`, + interval: 100, + }, + ); +} + describe('PostgreSQL Schema Tree objects', () => { let mainWindow: string; @@ -208,6 +317,7 @@ describe('PostgreSQL Schema Tree objects', () => { await clickNavigatorRefresh(); await expandConnectedConnectionInNavigator(connectionName); + await ensureSchemaExpanded(schema); // Probe the actual driver command before asserting the rendered row. // This keeps an empty PostgreSQL catalog result distinct from a row that @@ -235,12 +345,12 @@ describe('PostgreSQL Schema Tree objects', () => { 'sequence', 'type', ]) { - await expandSchemaCategory(category, schema, database); + await expandSchemaObjectCategory(connectionId, database, schema, category); } const treeNode = (kind: string, objectName: string) => $( - `[data-testid="schema-tree-node"][data-tree-node="${kind}"][data-item-name="${objectName}"]`, + `[data-testid="schema-tree-node"][data-tree-node="${kind}"][data-item-name="${objectName}"][data-object-schema="${schema}"]`, ); for (const [kind, objectName] of [ ['table', name.groups], diff --git a/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx b/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx index 33a113934..0b464169e 100644 --- a/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx +++ b/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx @@ -99,6 +99,7 @@ vi.mock('../../../extensions/generated', () => { quoteChar: '"', sqlDialect: 'postgresql', defaultSchema: 'public', + supportedObjectKinds: ['function', 'procedure', 'trigger', 'sequence', 'type'] as const, }, mysql: { ...sqlMulti, @@ -2392,6 +2393,55 @@ async function renderPgTree( } describe('ConnectionNavigatorTree multi-db tree variants', () => { + it('keeps schema-specific object categories scoped to their owning schema', async () => { + const { container } = await renderPgTree({}, [ + { name: 't_public', tableType: 'table', schema: 'public' }, + { name: 't_audit', tableType: 'table', schema: 'audit' }, + ] as TableInfo[]); + + const procedures = [ + { name: 'refresh_public_cache', kind: 'procedure', schema: 'public' }, + { name: 'refresh_audit_cache', kind: 'procedure', schema: 'audit' }, + { name: 'refresh_unscoped_cache', kind: 'procedure', schema: null }, + ]; + mockGetDatabaseObjects.mockResolvedValue(procedures); + + const auditSchema = await waitFor(() => { + const node = container.querySelector( + '[data-tree-node="schema"][data-schema-name="audit"]', + ); + expect(node).not.toBeNull(); + return node!; + }); + fireEvent.click(auditSchema); + const auditProcedureCategory = await waitFor(() => { + const node = container.querySelector( + '[data-cat-key="cfg-pg::db_a::audit::procedure"]', + ); + expect(node).not.toBeNull(); + return node!; + }); + fireEvent.click(auditProcedureCategory); + + await waitFor(() => { + expect( + container.querySelector( + '[data-tree-node="procedure"][data-item-name="refresh_audit_cache"][data-object-schema="audit"]', + ), + ).not.toBeNull(); + }); + expect( + container.querySelector( + '[data-tree-node="procedure"][data-item-name="refresh_unscoped_cache"]', + ), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-tree-node="procedure"][data-item-name="refresh_public_cache"]', + ), + ).toBeNull(); + }); + it('sorts schemas with the driver default schema first', async () => { const { container } = await renderPgTree({}, [ { name: 't_zeta', tableType: 'table', schema: 'zeta' }, diff --git a/src/windows/connection/navigator/NavigatorTreeRow.tsx b/src/windows/connection/navigator/NavigatorTreeRow.tsx index d5384daa6..198c04b29 100644 --- a/src/windows/connection/navigator/NavigatorTreeRow.tsx +++ b/src/windows/connection/navigator/NavigatorTreeRow.tsx @@ -398,6 +398,7 @@ export function NavigatorTreeRow({ data-testid="schema-tree-node" data-tree-node="category" data-cat-id={row.cat.id} + data-cat-key={row.key} {...itemProps} className="flex w-full items-center gap-1.5 py-1 pr-2 text-left text-[13px] text-fg-secondary hover:bg-surface-raised" style={{ paddingLeft: depthPadding(row.depth) }} @@ -435,6 +436,7 @@ export function NavigatorTreeRow({ data-testid="schema-tree-node" data-tree-node={row.catId === 'views' ? 'view' : 'table'} data-item-name={row.item.name} + data-object-schema={row.item.schema ?? undefined} {...itemProps} draggable onDragStart={(e) => { @@ -497,6 +499,7 @@ export function NavigatorTreeRow({ data-testid="schema-tree-node" data-tree-node={row.catId} data-item-name={row.obj.name} + data-object-schema={row.obj.schema ?? undefined} {...itemProps} className="flex w-full items-center gap-1.5 py-1 pr-2 text-left text-[13px] text-fg-secondary hover:bg-surface-raised" style={{ paddingLeft: depthPadding(row.depth) }} diff --git a/src/windows/connection/navigator/buildFlatRows.ts b/src/windows/connection/navigator/buildFlatRows.ts index 49efba7c5..d151e5ff4 100644 --- a/src/windows/connection/navigator/buildFlatRows.ts +++ b/src/windows/connection/navigator/buildFlatRows.ts @@ -129,6 +129,7 @@ export function buildNavigatorFlatRows(params: BuildNavigatorFlatRowsParams): Un ? `${connectionId}::${dbName}::${schemaName}::${cat.id}` : `${connectionId}::${dbName}::${cat.id}`; const isExpanded = isOpen(expandedCats.has(catKey)); + let categoryObjects: DatabaseObject[] = []; let count = 0; if (cat.id === 'tables') { @@ -142,10 +143,19 @@ export function buildNavigatorFlatRows(params: BuildNavigatorFlatRowsParams): Un : viewItems; count = filtered.length; } else { - const allObjs = dbObjectsMap[catKey] ?? []; + const loadedObjects = dbObjectsMap[catKey] ?? []; + // Driver commands list a whole database's objects. When the navigator + // groups tables under schemas, each schema category has its own cache + // key, so scope that shared command result to the category's owner. + // Otherwise expanding one schema duplicates every routine in every + // other schema and makes a leaf appear under the wrong parent. Keep + // objects with no schema metadata: some drivers do not expose it. + categoryObjects = schemaName + ? loadedObjects.filter((object) => object.schema == null || object.schema === schemaName) + : loadedObjects; const filtered = query - ? allObjs.filter((o) => o.name.toLowerCase().includes(query)) - : allObjs; + ? categoryObjects.filter((o) => o.name.toLowerCase().includes(query)) + : categoryObjects; count = filtered.length; } @@ -165,7 +175,7 @@ export function buildNavigatorFlatRows(params: BuildNavigatorFlatRowsParams): Un let objs: DatabaseObject[] = []; if (cat.id === 'tables') items = tblItems; else if (cat.id === 'views') items = viewItems; - else objs = dbObjectsMap[catKey] ?? []; + else objs = categoryObjects; if ( objectFilter.hideSystemSchemas || objectFilter.tableNameInclude || From b4ee322c669543445d89f1235d776544d280b589 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 16:44:06 +0800 Subject: [PATCH 041/666] test(postgres): guard temporary schema teardown --- .../postgres/e2e/schema-tree-objects.ts | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/drivers/postgres/e2e/schema-tree-objects.ts b/packages/drivers/postgres/e2e/schema-tree-objects.ts index 34f02df4a..67939470a 100644 --- a/packages/drivers/postgres/e2e/schema-tree-objects.ts +++ b/packages/drivers/postgres/e2e/schema-tree-objects.ts @@ -201,12 +201,14 @@ describe('PostgreSQL Schema Tree objects', () => { }); it('creates, browses, opens definitions, and removes an isolated object schema', async () => { - const stamp = Date.now().toString(36); + const randomSuffix = Math.random().toString(36).slice(2, 10).padEnd(8, '0'); + const stamp = `${Date.now().toString(36)}_${randomSuffix}`; const schema = `e2e_schema_tree_${stamp}`; const connectionId = `e2e-schema-tree-${stamp}`; const connectionName = `E2E PostgreSQL Schema Tree ${stamp}`; const database = process.env.E2E_PG_DB || process.env.PG_DATABASE || 'postgres'; const dbSessionIdRef: { value?: string } = {}; + let schemaCreated = false; let primaryFailure: unknown; const cleanupFailures: string[] = []; @@ -252,8 +254,10 @@ describe('PostgreSQL Schema Tree objects', () => { const qSchema = quoteIdent(schema); const qName = (object: string) => `${qSchema}.${quoteIdent(object)}`; + await executeSql(dbSessionId, `CREATE SCHEMA ${qSchema}`); + schemaCreated = true; + const statements = [ - `CREATE SCHEMA ${qSchema}`, `CREATE TYPE ${qName(name.enum)} AS ENUM ('active', 'archived')`, `CREATE DOMAIN ${qName(name.domain)} AS text CHECK (VALUE ~ '^[A-Z0-9-]{1,24}$')`, `CREATE SEQUENCE ${qName(name.sequence)} START WITH 1 INCREMENT BY 1`, @@ -460,23 +464,25 @@ describe('PostgreSQL Schema Tree objects', () => { } finally { const dbSessionId = dbSessionIdRef.value; if (dbSessionId) { - try { - await withSafeModeOff(() => - executeSql(dbSessionId, `DROP SCHEMA IF EXISTS ${quoteIdent(schema)} CASCADE`), - ); - const remaining = parseRows( - await executeSql( - dbSessionId, - `SELECT count(*)::int FROM information_schema.schemata WHERE schema_name = '${schema}'`, - ), - ); - if (Number(remaining[0]?.[0]) !== 0) { - cleanupFailures.push(`temporary schema ${schema} remained after teardown`); + if (schemaCreated) { + try { + await withSafeModeOff(() => + executeSql(dbSessionId, `DROP SCHEMA IF EXISTS ${quoteIdent(schema)} CASCADE`), + ); + const remaining = parseRows( + await executeSql( + dbSessionId, + `SELECT count(*)::int FROM information_schema.schemata WHERE schema_name = '${schema}'`, + ), + ); + if (Number(remaining[0]?.[0]) !== 0) { + cleanupFailures.push(`temporary schema ${schema} remained after teardown`); + } + } catch (error) { + cleanupFailures.push( + `could not remove temporary schema ${schema}: ${error instanceof Error ? error.name : 'database error'}`, + ); } - } catch (error) { - cleanupFailures.push( - `could not remove temporary schema ${schema}: ${error instanceof Error ? error.name : 'database error'}`, - ); } try { await disconnectBackend(dbSessionId); From a2c820e89a6a4af45c7cf8d31178a69b00ee849e Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 16:44:19 +0800 Subject: [PATCH 042/666] test(mysql): guard temporary database cleanup --- packages/drivers/mysql/e2e/schema-tree-objects.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/drivers/mysql/e2e/schema-tree-objects.ts b/packages/drivers/mysql/e2e/schema-tree-objects.ts index facdcb072..b710d6712 100644 --- a/packages/drivers/mysql/e2e/schema-tree-objects.ts +++ b/packages/drivers/mysql/e2e/schema-tree-objects.ts @@ -171,12 +171,20 @@ describe('MySQL Schema Tree object journey', function () { await invokeBackend('save_connection', { config: adminConfig }); adminConfigSaved = true; adminSession = await connectBackend(adminId); + const existingDatabase = await execute( + adminSession, + `SELECT COUNT(*) AS value FROM information_schema.SCHEMATA ` + + `WHERE SCHEMA_NAME = '${database}'`, + ); + if (queryScalar(existingDatabase, 'value') !== 0) { + throw new Error(`Refusing to use existing temporary database ${database}`); + } await withSafeModeOff(async () => { - databaseCreated = true; await execute( adminSession!, `CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`, ); + databaseCreated = true; }); const treeConfig = mysqlConfig(connectionId, connectionName, database); From c0f6e70a41fc5869997ed908e8f040ffd7996728 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 16:48:51 +0800 Subject: [PATCH 043/666] test(mysql): match quoted view predicate --- packages/drivers/mysql/e2e/schema-tree-objects.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/drivers/mysql/e2e/schema-tree-objects.ts b/packages/drivers/mysql/e2e/schema-tree-objects.ts index b710d6712..502f5f376 100644 --- a/packages/drivers/mysql/e2e/schema-tree-objects.ts +++ b/packages/drivers/mysql/e2e/schema-tree-objects.ts @@ -360,6 +360,6 @@ describe('MySQL Schema Tree object journey', function () { }); expect(response.data.ddl.trim().length).toBeGreaterThan(0); expect(response.data.ddl).toContain(table); - expect(response.data.ddl.toLowerCase()).toContain("status = 'active'"); + expect(response.data.ddl).toMatch(/`status`\s*=\s*'active'/i); }); }); From ed7dd4d1b36022c9a07daa2ca62fc6943cb34a3b Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 16:54:45 +0800 Subject: [PATCH 044/666] test: add mysql and postgres schema tree journeys --- docs/development/driver-test-coverage.md | 108 +++++++++++++++++++++++ docs/development/e2e-testing.md | 19 ++++ e2e/tsconfig.json | 4 +- e2e/wdio.conf.ts | 6 ++ package.json | 2 + 5 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 docs/development/driver-test-coverage.md diff --git a/docs/development/driver-test-coverage.md b/docs/development/driver-test-coverage.md new file mode 100644 index 000000000..f54cf2362 --- /dev/null +++ b/docs/development/driver-test-coverage.md @@ -0,0 +1,108 @@ +# 驱动功能测试覆盖矩阵 + +> 本文盘点各个 path driver crate 中已有的测试资产,用来判断“某项驱动功能是否有测试证据”,不是代码覆盖率报告,也不代表本次已运行全部测试。驱动专属测试应落在 `packages/drivers//`;Host 通用 UI E2E 和 `driver-api` 的共享实现测试不计作某个驱动 crate 的直接覆盖。本文按 2026-09-30 当前 checkout 中的代码和测试文件整理。 + +## 功能点范围 + +| 功能面 | 检查点 | +|---|---| +| 连接与会话 | 配置解析、认证/TLS、连接失败和超时、数据库/会话选择、断线与错误恢复。 | +| 查询与结果 | 原生 SQL/命令、参数绑定、流式/分页结果、多结果集、取消、驱动类型到 DataZen 值类型的转换及错误路径。不同模型驱动按其原生命令语义评估。 | +| Schema Tree 基础目录 | 数据库/namespace、schema(若有)、表/集合/index、列或字段元数据;名称引用、空结果、缺失对象和跨 schema/database。 | +| Schema Tree 对象 | 视图、函数、存储过程、触发器、序列、用户类型的列表与 DDL;对象依赖和权限。只统计该驱动实际暴露的对象种类。 | +| 数据与结构操作 | 行/文档/键浏览、分页、读写、列类型/主键/索引/FK 元数据、表结构 DDL 与边界拒绝。Redis 按其键和值编辑器评估。 | +| 管理与运维命令 | 创建/删除 database、schema、user,权限、进程/状态等驱动命令及异常输入。普通 SQL `EXPLAIN` 不视为管理命令。 | +| Schema Diff | 驱动 migration renderer、DDL 生成/验证、危险变更和重放/回滚约束。 | +| Data Sync | 同族比较、driver source/target adapter、apply/revalidate 与执行后的回查旅程。 | +| Data Transfer | 异构 IR 类型映射、预检、DDL/数据写入、失败回滚/继续及执行后的回查旅程。 | +| 驱动 E2E | `packages/drivers//e2e/` 中有结果断言的真实用户旅程。仅有占位 smoke 文件不算覆盖;Host contract matrix 单独标注。 | + +### 标记 + +| 标记 | 含义 | +|---|---| +| **●** | crate 内有针对该功能的直接行为断言;可能是单测、内存/HTTP mock、嵌入式或 live 集成测试,不等于每种环境均已运行。 | +| **◐** | 只覆盖部分分支、辅助函数/映射/命令注册,或只有共享契约而缺少该驱动自己的完整旅程。 | +| **○** | 代码暴露或实现了该功能,但在该驱动的测试目录中未找到针对该功能的测试证据。 | +| **—** | 当前驱动不提供该功能,或该功能不适用于其数据模型。 | + +## 所有 path driver 覆盖矩阵 + +`packages/drivers/http-support` 是共享 HTTP 支持 crate,不作为数据库驱动列入。表中 E2E 只表示仓库里存在实质性旅程文件,不表示本次已执行成功。 + +| 驱动 | 连接 | 查询/结果 | 基础目录 | 对象树 | 数据/结构 | 管理/运维 | Schema Diff | Data Sync | Data Transfer | 驱动 E2E | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| ClickHouse | ◐ | ● | ● | — | ◐ | — | — | ◐ | ◐ | ○¹ | +| DuckDB | ◐ | ● | ● | ◐ | ● | — | — | ◐ | ◐ | ○¹ | +| Elasticsearch | ○ | ◐ | ◐ | — | ◐ | — | — | ◐ | ◐ | ○ | +| HBase | ○ | ● | ◐ | — | ◐ | — | — | ◐ | ◐ | ○ | +| InfluxDB | ○ | ● | ◐ | — | ◐ | — | — | ◐ | ◐ | ○ | +| MongoDB | ○ | ● | ● | — | ◐ | — | — | ◐ | ◐ | ○¹ | +| MySQL | ◐ | ● | ● | ● | ● | ● | ● | ● | ● | ● | +| PostgreSQL | ◐ | ● | ● | ● | ● | ● | ● | ● | ● | ● | +| Redis | ● | ● | ●² | — | ● | ● | — | — | — | ● | +| rqlite | ○ | ◐ | ◐ | ○ | ○ | — | — | ◐³ | ◐³ | ○ | +| SQLite | ◐ | ● | ● | ● | ● | ◐ | ● | ◐ | ● | ◐⁴ | +| SQL Server | ● | ● | ● | ◐ | ● | ● | ● | ● | ● | ● | +| Turso | ○ | ◐ | ◐ | ○ | ○ | — | — | ◐³ | ◐³ | ○ | +| Vector | ○ | ◐ | ◐ | — | ◐ | — | — | ◐ | ◐ | ○ | +| VictoriaMetrics | ○ | ◐ | ◐ | — | ◐ | — | — | ◐ | ◐ | ○ | + +1. ClickHouse、DuckDB、MongoDB 的 E2E 目录目前含 smoke 占位用例,没有实质旅程断言。 +2. Redis 的基础目录指 Redis database/key tree,不是 SQL schema/table 树;其专属 UI 和键操作有大量 crate 内测试。 +3. SQLite Sync adapter 声明支持 rqlite、Turso 方言,但这两者没有自己的 adapter journey;这里只把共享 adapter 测试记为部分证据,不等同于 rqlite/Turso 驱动已单独验证。 +4. SQLite 没有独立 driver E2E 目录;Host contract matrix 覆盖 SQLite 的连接、查询、表数据等通用 journeys,因此这里只记部分。 + +## Schema Tree 对象种类覆盖 + +这张表只看各驱动 crate 对实际支持对象的测试证据。**○** 表示实现/命令已接入但没有该驱动自己的对象操作测试;**—** 表示该方言当前未暴露此类对象。基础表/列目录见上一张表。 + +| 驱动 | View 列表/DDL | Function 列表/DDL | Procedure 列表/DDL | Trigger 列表/DDL | Sequence 列表/DDL | Type 列表/DDL | 依赖/权限 | +|---|---:|---:|---:|---:|---:|---:|---:| +| MySQL | ● | ● | ● | ● | — | — | ● | +| PostgreSQL | ● | ● | ● | ● | ● | ● | ● | +| SQLite | ● | — | — | ● | — | — | — | +| SQL Server | ● | ● | ● | ◐ | ◐ | ◐ | ◐ | +| DuckDB | — | — | — | ○ | ○ | — | — | +| rqlite | ○ | — | — | ○ | — | — | — | +| Turso | ○ | — | — | ○ | — | — | — | + +- MySQL 的序列和独立用户类型在当前对象目录实现中不提供;SQLite 的函数、存储过程、序列、权限目录也不提供。 +- PostgreSQL 的对象 journey 在真实 Schema Tree 中覆盖视图、函数、过程、触发器、序列、enum 和 domain;并对视图、序列及自定义类型 DDL、schema 归属、异常查询和临时 schema 清理做断言。驱动级依赖/权限测试仍由目录集成测试覆盖。 +- SQL Server live 测试会执行 view、function、procedure、trigger、sequence、type 的对象列表 SQL,并覆盖视图/例程目录与部分 DDL;但每种对象的 `get_object_ddl`、依赖、权限结果尚未形成对称的完整断言,因此标为部分。 +- DuckDB、rqlite、Turso 的命令定义/共享 SQL 路径已经接入,但本驱动 crate 没有覆盖各自 view/trigger/sequence 列表、DDL 返回和缺失对象边界的完整测试。 + +## 代表性测试证据 + +以下路径列出各驱动现有测试覆盖的典型功能,不是完整测试文件清单。 + +| 驱动 | 测试证据 | +|---|---| +| ClickHouse | [`src/clickhouse.rs`](../../packages/drivers/clickhouse/src/clickhouse.rs) 覆盖数据库作用域、表/列目录和 schema 参数;[`tests/http_query_wiremock.rs`](../../packages/drivers/clickhouse/tests/http_query_wiremock.rs) 覆盖 HTTP 查询/流;[`src/structure.rs`](../../packages/drivers/clickhouse/src/structure.rs) 覆盖 DDL 计划。E2E smoke 当前是占位。 | +| DuckDB | [`tests/duckdb_embedded_smoke.rs`](../../packages/drivers/duckdb/tests/duckdb_embedded_smoke.rs) 验证内存连接、查询和 schema;[`src/structure.rs`](../../packages/drivers/duckdb/src/structure.rs) 覆盖结构 DDL;Sync adapter 有单测和 smoke。E2E smoke 当前是占位。 | +| Elasticsearch | [`src/elasticsearch.rs`](../../packages/drivers/elasticsearch/src/elasticsearch.rs) 覆盖游标流、类型映射、namespace/schema 行为和命令 dispatch;[`src/sync_adapter.rs`](../../packages/drivers/elasticsearch/src/sync_adapter.rs) 覆盖传输映射。没有 driver E2E。 | +| HBase | [`src/hbase.rs`](../../packages/drivers/hbase/src/hbase.rs) 覆盖 scanner 分页、结果解码、schema 行为和类型映射;[`src/sync_adapter.rs`](../../packages/drivers/hbase/src/sync_adapter.rs) 覆盖 adapter 映射。没有 driver E2E。 | +| InfluxDB | [`src/influxdb.rs`](../../packages/drivers/influxdb/src/influxdb.rs) 覆盖 JSON 结果流、类型映射和 namespace 行为;[`src/sync_adapter.rs`](../../packages/drivers/influxdb/src/sync_adapter.rs) 覆盖 adapter。没有 driver E2E。 | +| MongoDB | [`src/mongodb.rs`](../../packages/drivers/mongodb/src/mongodb.rs) 覆盖 BSON 映射、collection metadata、JSON query/stream;[`ui/__tests__/mongodbFind.test.ts`](../../packages/drivers/mongodb/ui/__tests__/mongodbFind.test.ts) 覆盖 Find UI;Sync adapter 有 smoke。E2E smoke 当前是占位。 | +| MySQL | [`tests/schema_objects_sql.rs`](../../packages/drivers/mysql/tests/schema_objects_sql.rs)、[`tests/schema_dependency_catalog.rs`](../../packages/drivers/mysql/tests/schema_dependency_catalog.rs) 覆盖对象 SQL、view/routine/FK 依赖;[`src/structure.rs`](../../packages/drivers/mysql/src/structure.rs) 与 [`tests/migration_bound_writes.rs`](../../packages/drivers/mysql/tests/migration_bound_writes.rs) 覆盖结构与迁移写入;[`e2e/schema-tree-objects.ts`](../../packages/drivers/mysql/e2e/schema-tree-objects.ts) 覆盖表关系、视图、函数、过程、触发器、视图定义和清理,其他 `e2e/` journeys 覆盖 Sync/Transfer。 | +| PostgreSQL | [`tests/schema_objects_sql.rs`](../../packages/drivers/postgres/tests/schema_objects_sql.rs)、[`tests/schema_dependency_catalog.rs`](../../packages/drivers/postgres/tests/schema_dependency_catalog.rs)、[`tests/schema_foreign_key_introspection.rs`](../../packages/drivers/postgres/tests/schema_foreign_key_introspection.rs) 覆盖对象、依赖和 FK;[`e2e/schema-tree-objects.ts`](../../packages/drivers/postgres/e2e/schema-tree-objects.ts) 覆盖关系表、视图、函数、过程、触发器、序列、enum/domain、定义打开、异常查询和临时 schema teardown;[`tests/migration_bound_writes.rs`](../../packages/drivers/postgres/tests/migration_bound_writes.rs) 与其他 `e2e/` journeys 覆盖迁移/Sync/Transfer。 | +| Redis | [`tests/tree_contract_tester.rs`](../../packages/drivers/redis/tests/tree_contract_tester.rs)、[`tests/tree_scan_budget.rs`](../../packages/drivers/redis/tests/tree_scan_budget.rs) 覆盖 key tree;[`ui/__tests__/keyTreeJourney.test.tsx`](../../packages/drivers/redis/ui/__tests__/keyTreeJourney.test.tsx)、[`ui/__tests__/redisConsole.test.ts`](../../packages/drivers/redis/ui/__tests__/redisConsole.test.ts) 覆盖专属 UI;[`e2e/redis.ts`](../../packages/drivers/redis/e2e/redis.ts) 覆盖 Items、命令行、Monitor 和 Pub/Sub 的真实旅程。 | +| rqlite | [`src/rqlite.rs`](../../packages/drivers/rqlite/src/rqlite.rs) 目前主要覆盖 metadata SQL 选择/schema 参数拒绝和结果解码,未找到对象列表/DDL或数据修改的 crate 内测试。 | +| SQLite | [`tests/schema_object_commands.rs`](../../packages/drivers/sqlite/tests/schema_object_commands.rs)、[`tests/schema_objects_sql.rs`](../../packages/drivers/sqlite/tests/schema_objects_sql.rs) 覆盖 view/trigger;[`tests/schema_rebuild_journey.rs`](../../packages/drivers/sqlite/tests/schema_rebuild_journey.rs)、[`tests/migration_transfer_journey.rs`](../../packages/drivers/sqlite/tests/migration_transfer_journey.rs) 覆盖结构重建和传输;ADB 命令见 [`src/adb.rs`](../../packages/drivers/sqlite/src/adb.rs)。 | +| SQL Server | [`tests/live_connection.rs`](../../packages/drivers/sqlserver/tests/live_connection.rs)、[`tests/live_schema_metadata.rs`](../../packages/drivers/sqlserver/tests/live_schema_metadata.rs)、[`tests/live_admin_and_sync.rs`](../../packages/drivers/sqlserver/tests/live_admin_and_sync.rs)、[`tests/live_write_and_ddl.rs`](../../packages/drivers/sqlserver/tests/live_write_and_ddl.rs) 覆盖连接异常、目录、对象、管理命令、分页、写入和 DDL;[`e2e/sqlserver-live-ui.ts`](../../packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts)、[`e2e/sqlserver-metadata.ts`](../../packages/drivers/sqlserver/e2e/sqlserver-metadata.ts) 以及 `sqlserver-schema-diff.ts`、`sqlserver-data-sync.ts`、`sqlserver-data-transfer.ts` 覆盖应用旅程。 | +| Turso | [`src/turso.rs`](../../packages/drivers/turso/src/turso.rs) 有少量结果解码和 metadata SQL 测试;未找到本驱动的 Schema Tree 对象操作、写入、迁移旅程或 driver E2E。 | +| Vector | [`src/vector.rs`](../../packages/drivers/vector/src/vector.rs) 覆盖向量类型、namespace、结果解码和 literal;[`src/sync_adapter.rs`](../../packages/drivers/vector/src/sync_adapter.rs) 覆盖部分映射。没有 driver E2E。 | +| VictoriaMetrics | [`src/victoriametrics.rs`](../../packages/drivers/victoriametrics/src/victoriametrics.rs) 覆盖指标类型、namespace 和结果解码;[`src/sync_adapter.rs`](../../packages/drivers/victoriametrics/src/sync_adapter.rs) 有映射测试。没有 driver E2E。 | + +## 结论与解释 + +目前不能说“各驱动的测试已经覆盖了各自全部功能”。MySQL、PostgreSQL、SQLite、SQL Server 的关系型目录、结构与对象能力覆盖相对完整,但仍有类型/对象 DDL 等缺口;Redis 的专属 key tree、Workbench、命令台和主要 UI 路径覆盖最深入。多数可选驱动的测试以类型转换、SQL/命令构造、mock 或 adapter helper 为主,缺少连接到真实服务后的目录操作、异常路径和完整用户旅程。 + +特别是 Schema Tree,以下差异不能被“driver command 已注册”掩盖: + +- 通用 Host 测试只能证明宿主树组件或 IPC 编排,不证明每个驱动的目录 SQL 可执行、对象字段可解析或 DDL 正确。 +- MySQL、PostgreSQL、SQLite 和 SQL Server 有直接的驱动级对象测试,但对象种类并不对称;PostgreSQL Type、SQL Server trigger/sequence/type 的 DDL/依赖边界仍应补齐。 +- DuckDB、rqlite、Turso 暴露了部分对象命令或共享 SQLite 方言查询,但缺少各自 crate 中的对象树操作测试。跨驱动共享 SQL 生成测试不能替代这些驱动的命令执行测试。 +- ClickHouse、Elasticsearch、HBase、InfluxDB、MongoDB、Vector、VictoriaMetrics 的目录模型并非完整 SQL routine tree;应测试它们实际提供的 database/namespace、table/index/collection/measurement、字段或列元数据和特殊名称/错误处理,而不是机械补齐 SQL Server 对象种类。 + +本矩阵统计的是测试是否存在和覆盖到哪里,不表示缺口等于产品实现必然有 bug,也不以测试文件数量推算百分比。新增 driver 能力、命令或对象类型时,应同步更新对应矩阵格及代表性测试证据;具体新增测试仍放在驱动 crate 内。 diff --git a/docs/development/e2e-testing.md b/docs/development/e2e-testing.md index 33962161f..dab41129c 100644 --- a/docs/development/e2e-testing.md +++ b/docs/development/e2e-testing.md @@ -72,6 +72,7 @@ pnpm e2e:core pnpm e2e:db pnpm e2e:ai pnpm e2e:redis # 显式:packages/drivers/redis/e2e/(不进默认 pnpm e2e) +pnpm e2e:schema-tree-objects # MySQL/PostgreSQL Schema Tree 对象 journey(驱动 crate 内用例) pnpm e2e:i18n-backup pnpm e2e:path-ipc pnpm e2e:dashboard # data-dashboard*.ts(同样 skip-build) @@ -92,6 +93,24 @@ pnpm test:unit:e2e-contract:coverage # 契约纯逻辑单测 ≥80% > [`scripts/run-regression.sh`](../../scripts/run-regression.sh)(全量回归门禁)与 > [`scripts/run-e2e-minimal.sh`](../../scripts/run-e2e-minimal.sh)(minimal 集 + `E2E_ENV_FILE`/主检出 `.env` 回退解析)。 +## Schema Tree 手工 fixtures + +MySQL 和 PostgreSQL 的长期手测对象与 E2E 临时对象分开保存。安装脚本会读取现有 E2E 环境配置并确保数据库和对象存在;手工测试时连接到以下数据库: + +| 驱动 | 数据库 | 对象范围 | +|---|---|---| +| MySQL | `datazen_manual_schema_tree` | 表、外键/索引、视图、函数、存储过程和触发器;[安装器与 SQL](../../packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.sh)。 | +| PostgreSQL | `datazen_manual_schema_tree`,schema `manual_schema_tree` | 表、外键/索引、视图、函数、存储过程、触发器、序列、enum 和 domain;[安装器与 SQL](../../packages/drivers/postgres/e2e/install-manual-schema-tree.sh)。 | + +首次安装或需要重建 fixture 时,在仓库根目录运行: + +```bash +bash packages/drivers/mysql/e2e/install-manual-schema-tree-fixtures.sh +bash packages/drivers/postgres/e2e/install-manual-schema-tree.sh +``` + +`pnpm e2e:schema-tree-objects` 则在每次 journey 中创建带随机后缀的临时数据库/schema,断言对象树、对象定义和数据操作,并在 finally/after 中仅清理本次成功创建的对象;这些临时对象与上面的长期手测 fixtures 相互隔离。 + ## 2.1 CI 并行策略 为缩短 CI 墙钟时间,可将 E2E 拆为 3 个独立 job 并行运行: diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 0b6babec7..a33fea763 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -17,6 +17,8 @@ "include": [ "**/*.ts", "../packages/pro-extensions/*/e2e/**/*.ts", - "../packages/drivers/sqlserver/e2e/**/*.ts" + "../packages/drivers/sqlserver/e2e/**/*.ts", + "../packages/drivers/mysql/e2e/**/*.ts", + "../packages/drivers/postgres/e2e/**/*.ts" ] } diff --git a/e2e/wdio.conf.ts b/e2e/wdio.conf.ts index 1b459cd76..df5503269 100644 --- a/e2e/wdio.conf.ts +++ b/e2e/wdio.conf.ts @@ -323,6 +323,12 @@ export const config: WebdriverIO.Config = { contract: ['./specs/host-contract-matrix.ts'], // Redis driver's own E2E, not part of default full run (`pnpm e2e:redis`) redis: ['../packages/drivers/redis/e2e/*.ts'], + // Driver-owned MySQL/PostgreSQL schema-tree object journeys. These create + // isolated fixtures and clean them up in the driver E2E specs. + 'schema-tree-objects': [ + '../packages/drivers/mysql/e2e/schema-tree-objects.ts', + '../packages/drivers/postgres/e2e/schema-tree-objects.ts', + ], // Host-owned SQL editor gestures: Mod+D, multi-cursor, rectangular // selection (`pnpm e2e:sql-editor-prod`). Both specs are Community // behaviour, so this suite runs on any build. The Pro-only counterparts diff --git a/package.json b/package.json index d717fa976..6f9bfb72a 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,8 @@ "e2e:migration-live": "E2E_MIGRATION_LIVE=1 node e2e/run.mjs --skip-build -- --suite migration-live-workflow", "e2e:schema-diff": "bash e2e/setup-schema-diff-e2e.sh && node e2e/run.mjs --skip-build -- --suite schema-diff", "e2e:schema-diff:build": "bash e2e/setup-schema-diff-e2e.sh && node e2e/run.mjs -- --suite schema-diff", + "e2e:schema-tree-objects": "E2E_SKIP_WORKER_DATABASE=1 node e2e/run.mjs -- --suite schema-tree-objects", + "e2e:schema-tree-objects:skip-build": "E2E_SKIP_WORKER_DATABASE=1 node e2e/run.mjs --skip-build -- --suite schema-tree-objects", "e2e:demo": "bash e2e/record-demo.sh", "e2e:demo:skip-build": "bash e2e/record-demo.sh --skip-build", "pretest": "node scripts/resolve-drivers.mjs --codegen-only --drivers=basic && node scripts/generate-builtin-locales.mjs", From 8d5d163b3841f90d4026bb828fb1a7cb8f51d6ca Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 17:06:11 +0800 Subject: [PATCH 045/666] fix(sql-editor): delegate parameter syntax to drivers --- packages/driver-sdk/src/index.ts | 3 + packages/drivers/rqlite/ui/meta.ts | 7 + packages/drivers/sqlite/ui/dialect.ts | 3 +- .../sqlserver/e2e/sqlserver-data-sync.ts | 9 +- .../sqlserver/e2e/sqlserver-data-transfer.ts | 4 +- .../sqlserver/e2e/sqlserver-live-ui.ts | 25 +- packages/drivers/sqlserver/src/sqlserver.rs | 82 ++++- .../ui/__tests__/sqlParameters.test.ts | 61 ++++ packages/drivers/sqlserver/ui/meta.ts | 2 + .../drivers/sqlserver/ui/sqlParameters.ts | 237 +++++++++++++ packages/drivers/turso/ui/meta.ts | 7 + .../src/__tests__/bindParams.test.ts | 40 +-- .../src/sql-editor/bindParams.ts | 319 +++++------------- .../src/sql-editor/databaseTypes.ts | 13 +- .../src/sqlEditorEnhancedEP.ts | 3 +- .../__tests__/fixtures/sqlBindParamCases.json | 30 +- src/lib/__tests__/sqlBindParams.test.ts | 94 +----- src/lib/__tests__/sqlParameterPolicy.test.ts | 106 ++++++ src/lib/databaseMeta.ts | 11 +- src/lib/sqlBindParams.ts | 319 +++++------------- src/lib/sqlDialects/dialectProfile.ts | 50 ++- src/lib/sqlDialects/sqlParameterPolicy.ts | 40 +++ src/lib/sqlDialects/types.ts | 20 ++ src/windows/connection/QueryPanel.tsx | 20 +- .../QueryPanel.executeCancel.test.tsx | 4 +- src/windows/connection/query/contracts.ts | 3 +- .../query/useQueryExecutionGate.tsx | 19 +- 27 files changed, 876 insertions(+), 655 deletions(-) create mode 100644 packages/drivers/sqlserver/ui/__tests__/sqlParameters.test.ts create mode 100644 packages/drivers/sqlserver/ui/sqlParameters.ts create mode 100644 src/lib/__tests__/sqlParameterPolicy.test.ts create mode 100644 src/lib/sqlDialects/sqlParameterPolicy.ts diff --git a/packages/driver-sdk/src/index.ts b/packages/driver-sdk/src/index.ts index 16ebe4a78..ccb6f9014 100644 --- a/packages/driver-sdk/src/index.ts +++ b/packages/driver-sdk/src/index.ts @@ -12,6 +12,9 @@ export type { SqlDialectFamily, TableSqlDialect, GeneratedSqlType, + SqlParameterPolicy, + SqlParameterOccurrence, + SqlParameterStrategy, } from '../../../src/lib/sqlDialects/types'; // === Database metadata === diff --git a/packages/drivers/rqlite/ui/meta.ts b/packages/drivers/rqlite/ui/meta.ts index 3fc5e9b71..2d874fb14 100644 --- a/packages/drivers/rqlite/ui/meta.ts +++ b/packages/drivers/rqlite/ui/meta.ts @@ -25,6 +25,13 @@ export const rqliteMeta = { clipboardSchemes: ['rqlite'], supportsExplain: true, supportedObjectKinds: ['trigger'], + sqlParameterPolicy: { + atNamed: true, + question: true, + dollarPositional: false, + dollarNamed: true, + template: false, + }, structureEditor: { columnTypes: [ { value: 'INTEGER', label: 'INTEGER' }, diff --git a/packages/drivers/sqlite/ui/dialect.ts b/packages/drivers/sqlite/ui/dialect.ts index 766ca13d8..feb9b0b6c 100644 --- a/packages/drivers/sqlite/ui/dialect.ts +++ b/packages/drivers/sqlite/ui/dialect.ts @@ -46,9 +46,10 @@ export const sqliteDialectProfile: SqlDialectProfile = { foldCase: 'lower', projectionAliasVisibility: 'select-only', parameterPolicy: { - atNamed: false, + atNamed: true, question: true, dollarPositional: false, + dollarNamed: true, template: false, }, }; diff --git a/packages/drivers/sqlserver/e2e/sqlserver-data-sync.ts b/packages/drivers/sqlserver/e2e/sqlserver-data-sync.ts index d906e6b07..ecf753a98 100644 --- a/packages/drivers/sqlserver/e2e/sqlserver-data-sync.ts +++ b/packages/drivers/sqlserver/e2e/sqlserver-data-sync.ts @@ -202,7 +202,6 @@ describe('SQL Server Data Sync identity insert (live)', () => { const handles = await browser.getWindowHandles(); if (handles[0]) await browser.switchToWindow(handles[0]); - await browser.url('tauri://localhost/'); sourceSessionId = await saveAndConnect( SOURCE_CONNECTION_ID, @@ -331,13 +330,13 @@ describe('SQL Server Data Sync identity insert (live)', () => { IDS, ); for (const statement of preview) { - expect(Object.keys(statement)).toEqual([ - 'table', + expect(Object.keys(statement).sort()).toEqual([ 'operation', - 'sql', - 'previewSql', 'parameters', + 'previewSql', 'rowKey', + 'sql', + 'table', ]); expect(statement.identityInsert).toBeUndefined(); expect(statement.identity_insert).toBeUndefined(); diff --git a/packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts b/packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts index 8bf37d2ac..2293a9e78 100644 --- a/packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts +++ b/packages/drivers/sqlserver/e2e/sqlserver-data-transfer.ts @@ -168,6 +168,7 @@ describe('SQL Server Data Transfer (live)', () => { const handles = await browser.getWindowHandles(); if (handles[0]) await browser.switchToWindow(handles[0]); + await browser.setTimeout({ script: 180_000 }); await invoke('save_connection', { config: connectionConfig(SOURCE_CONNECTION_ID, 'E2E SQL Server transfer source'), @@ -220,7 +221,8 @@ describe('SQL Server Data Transfer (live)', () => { mode: 'data', writeMode: 'insert', tables: [{ sourceTable: TABLE, targetTable: TABLE, enabled: true }], - options: { batchSize: 2, stopOnError: true }, + // SQL Server requires an explicit choice when text collation semantics cannot be proven. + options: { batchSize: 2, stopOnError: true, useTargetDefaultCollation: true }, }, }); diff --git a/packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts b/packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts index 68fdb3e1f..e53bb866b 100644 --- a/packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts +++ b/packages/drivers/sqlserver/e2e/sqlserver-live-ui.ts @@ -162,6 +162,7 @@ describe('SQL Server driver GUI (live)', () => { */ const executeSelectedSql = async ( sql: string, + timeout = 20_000, ): Promise<{ executionAdvanced: boolean; gateMessage: string | null; @@ -187,7 +188,7 @@ describe('SQL Server driver GUI (live)', () => { (await error.isDisplayed().catch(() => false)) ); }, - { timeout: 20000, timeoutMsg: 'the SQL Editor produced no execution or diagnostic result' }, + { timeout, timeoutMsg: 'the SQL Editor produced no execution or diagnostic result' }, ); const gateButton = await $('[data-testid="result-message-ok"]'); @@ -495,14 +496,8 @@ describe('SQL Server driver GUI (live)', () => { ' RETURN UPPER(LTRIM(RTRIM(@value)));\n' + 'END;'; const createResult = await executeSelectedSql(createFunctionSql); - expect( - createResult.gateMessage, - 'T-SQL @value must not be treated as an unbound editor parameter', - ).toBeNull(); - expect( - createResult.queryError, - 'SQL Server must accept the scalar function definition', - ).toBeNull(); + expect(createResult.gateMessage).toBeNull(); + expect(createResult.queryError).toBeNull(); expect(createResult.executionAdvanced).toBe(true); const callSql = `SELECT [${scratchSchema}].[${FUNCTION_NAME}](N' Mixed Code ') AS [normalized]`; @@ -522,14 +517,14 @@ describe('SQL Server driver GUI (live)', () => { }); it('shows a SQL Server missing-object diagnostic for invalid SQL in the editor', async function () { - this.timeout(60_000); + this.timeout(120_000); const missingObject = 'dz_e2e_intentionally_missing_object'; - const result = await executeSelectedSql(`SELECT * FROM [${scratchSchema}].[${missingObject}]`); + const result = await executeSelectedSql( + `SELECT * FROM [${scratchSchema}].[${missingObject}]`, + 90_000, + ); - expect( - result.gateMessage, - 'invalid SQL must reach SQL Server instead of stopping at the bind gate', - ).toBeNull(); + expect(result.gateMessage).toBeNull(); expect(result.executionAdvanced).toBe(true); expect(result.queryError).toContain(missingObject); expect(result.queryError).toMatch(/invalid object name/i); diff --git a/packages/drivers/sqlserver/src/sqlserver.rs b/packages/drivers/sqlserver/src/sqlserver.rs index 661207f08..aa18f0fb3 100644 --- a/packages/drivers/sqlserver/src/sqlserver.rs +++ b/packages/drivers/sqlserver/src/sqlserver.rs @@ -494,8 +494,7 @@ impl SqlServerDriver { .first() .and_then(|row| row.first()) .and_then(Option::as_ref) - .map(datazen_driver_http_support::value_display) - .and_then(|value| value.parse::().ok()) + .and_then(Self::parse_transaction_count) .ok_or_else(|| { DriverError::TransactionError( "SQL Server did not return the active transaction count".into(), @@ -509,6 +508,14 @@ impl SqlServerDriver { Ok(()) } + fn parse_transaction_count(value: &Value) -> Option { + match value { + Value::Integer(count) => Some(*count), + Value::String(count) => count.parse().ok(), + _ => None, + } + } + async fn run_routed( client: &mut SqlClient, sql: &str, @@ -648,6 +655,21 @@ impl SqlServerDriver { /// aware scanner in `driver-api`. fn split_statements(sql: &str) -> Vec { use datazen_driver_api::sql_split::{is_comment_only_or_empty, split_sql_statements}; + + // A routine definition is one SQL Server batch. The shared splitter is + // dialect-neutral and treats the semicolon after a statement in a + // BEGIN/END body as a top-level separator, which sends an incomplete + // CREATE FUNCTION/PROCEDURE/TRIGGER to the server. Keep the module body + // intact; `needs_own_batch` routes it through `simple_query` below. + if is_routine_definition(sql) { + let statement = sql.trim(); + return if statement.is_empty() { + Vec::new() + } else { + vec![statement.to_string()] + }; + } + split_sql_statements(sql) .into_iter() .map(|s| s.trim().to_string()) @@ -655,6 +677,26 @@ fn split_statements(sql: &str) -> Vec { .collect() } +fn is_routine_definition(sql: &str) -> bool { + let mut words = leading_keywords(sql).into_iter(); + let Some(first) = words.next() else { + return false; + }; + if first != "CREATE" && first != "ALTER" { + return false; + } + + let mut kind = words.next(); + if kind.as_deref() == Some("OR") { + let _ = words.next(); + kind = words.next(); + } + matches!( + kind.as_deref(), + Some("FUNCTION" | "PROCEDURE" | "PROC" | "TRIGGER") + ) +} + /// T-SQL accepts a few statements **only as the first statement of a batch**: /// the programmable-object definitions (`CREATE`/`ALTER` `SCHEMA`, `VIEW`, /// `PROCEDURE`/`PROC`, `FUNCTION`, `TRIGGER`, `RULE`, `DEFAULT`). tiberius sends @@ -1815,6 +1857,27 @@ mod tests { use super::*; use tiberius::EncryptionLevel; + #[test] + fn transaction_count_accepts_native_integer_values() { + assert_eq!( + SqlServerDriver::parse_transaction_count(&Value::Integer(0)), + Some(0) + ); + assert_eq!( + SqlServerDriver::parse_transaction_count(&Value::Integer(2)), + Some(2) + ); + assert_eq!( + SqlServerDriver::parse_transaction_count(&Value::String("3".into())), + Some(3) + ); + assert_eq!( + SqlServerDriver::parse_transaction_count(&Value::String("Integer(0)".into())), + None + ); + assert_eq!(SqlServerDriver::parse_transaction_count(&Value::Null), None); + } + #[test] fn physical_database_identity_uses_server_name_and_current_catalog_database_id() { assert!(PHYSICAL_DATABASE_IDENTITY_SQL.contains("SERVERPROPERTY('ServerName')")); @@ -2228,6 +2291,21 @@ mod tests { assert!(split_statements(" ").is_empty()); } + #[test] + fn statement_splitting_keeps_routine_bodies_in_one_batch() { + let function = "CREATE FUNCTION [dbo].[normalize] (@value NVARCHAR(64))\n\ + RETURNS NVARCHAR(64)\n\ + AS\n\ + BEGIN\n\ + RETURN UPPER(LTRIM(RTRIM(@value)));\n\ + END;"; + assert_eq!(split_statements(function), vec![function]); + + let procedure = "/* migration */ CREATE OR ALTER PROCEDURE [dbo].[p] AS\n\ + BEGIN SELECT 1; END;"; + assert_eq!(split_statements(procedure), vec![procedure]); + } + #[test] fn preparable_statements_stay_on_the_rpc_path() { for stmt in [ diff --git a/packages/drivers/sqlserver/ui/__tests__/sqlParameters.test.ts b/packages/drivers/sqlserver/ui/__tests__/sqlParameters.test.ts new file mode 100644 index 000000000..7770980a8 --- /dev/null +++ b/packages/drivers/sqlserver/ui/__tests__/sqlParameters.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import type { SqlParameterOccurrence } from '@datazen/driver-sdk'; +import { findDeclaredAtVars, sqlserverSqlParameterStrategy } from '../sqlParameters'; + +function atOccurrences(sql: string, names: string[]): SqlParameterOccurrence[] { + const occurrences: SqlParameterOccurrence[] = []; + let searchFrom = 0; + for (const name of names) { + const token = `@${name}`; + const from = sql.indexOf(token, searchFrom); + if (from < 0) throw new Error(`Missing test token ${token}`); + const to = from + token.length; + occurrences.push({ + from, + to, + id: `named:${name}`, + token, + syntax: 'at', + name, + }); + searchFrom = to; + } + return occurrences; +} + +describe('SQL Server SQL parameter strategy', () => { + it('excludes T-SQL function signature parameters only within the routine', () => { + const sql = `CREATE FUNCTION [dbo].[fn_NormalizeCode] +( + @value NVARCHAR(64) +) +RETURNS NVARCHAR(64) +AS +BEGIN + RETURN UPPER(LTRIM(RTRIM(@value))); +END; +SELECT @value;`; + const candidates = atOccurrences(sql, ['value', 'value', 'value']); + + expect(findDeclaredAtVars(sql)).toEqual(new Set(['value'])); + expect(sqlserverSqlParameterStrategy.filterOccurrences?.(sql, candidates)).toEqual([ + candidates[2], + ]); + }); + + it('excludes DECLARE and SET variables while preserving unrelated bind names', () => { + const sql = 'DECLARE @limit INT; SET @count = 0; SELECT @limit, @count, @filter;'; + const candidates = atOccurrences(sql, ['limit', 'count', 'limit', 'count', 'filter']); + + expect(findDeclaredAtVars(sql)).toEqual(new Set(['limit', 'count'])); + expect(sqlserverSqlParameterStrategy.filterOccurrences?.(sql, candidates)).toEqual([ + candidates[4], + ]); + }); + + it('keeps an ordinary @name placeholder enabled', () => { + const sql = 'SELECT @value'; + const candidates = atOccurrences(sql, ['value']); + expect(sqlserverSqlParameterStrategy.filterOccurrences?.(sql, candidates)).toEqual(candidates); + }); +}); diff --git a/packages/drivers/sqlserver/ui/meta.ts b/packages/drivers/sqlserver/ui/meta.ts index ade41e8a4..6745bba62 100644 --- a/packages/drivers/sqlserver/ui/meta.ts +++ b/packages/drivers/sqlserver/ui/meta.ts @@ -1,5 +1,6 @@ import type { DatabaseTypeMeta } from '@datazen/driver-sdk'; import { sqlserverDialect, sqlserverDialectProfile } from './dialect'; +import { sqlserverSqlParameterStrategy } from './sqlParameters'; export const sqlserverMeta = { label: 'SQL Server', @@ -71,4 +72,5 @@ export const sqlserverMeta = { }, sqlDialectStrategy: sqlserverDialect, sqlDialectProfile: sqlserverDialectProfile, + sqlParameterStrategy: sqlserverSqlParameterStrategy, } satisfies DatabaseTypeMeta; diff --git a/packages/drivers/sqlserver/ui/sqlParameters.ts b/packages/drivers/sqlserver/ui/sqlParameters.ts new file mode 100644 index 000000000..80e45311e --- /dev/null +++ b/packages/drivers/sqlserver/ui/sqlParameters.ts @@ -0,0 +1,237 @@ +import type { SqlParameterStrategy } from '@datazen/driver-sdk'; + +/** T-SQL local variables and routine parameters are SQL, not editor bind placeholders. */ +export const sqlserverSqlParameterStrategy: SqlParameterStrategy = { + filterOccurrences(sql, occurrences) { + const declaredAt = findNonRoutineDeclaredAtVars(sql); + const routineAtVars = findTsqlRoutineSignatureAtVars(sql); + const routineRanges = findTsqlRoutineRanges(sql); + return occurrences.filter((occurrence) => { + if (occurrence.syntax !== 'at') return true; + const normalizedName = occurrence.name.toLowerCase(); + const isRoutineLocal = + routineAtVars.has(normalizedName) && + routineRanges.some(({ from, to }) => occurrence.from >= from && occurrence.from < to); + return !declaredAt.has(normalizedName) && !isRoutineLocal; + }); + }, +}; + +function skipNonCode(sql: string, i: number): number { + const ch = sql[i]; + if (ch === "'" || ch === '"' || ch === '`') return skipQuote(sql, i); + if (ch === '-' && sql[i + 1] === '-') { + const nl = sql.indexOf('\n', i); + return nl === -1 ? sql.length : nl + 1; + } + if (ch === '/' && sql[i + 1] === '*') { + const end = sql.indexOf('*/', i + 2); + return end === -1 ? sql.length : end + 2; + } + if (ch === '$') return skipDollarQuote(sql, i); + return i; +} + +function skipQuote(sql: string, i: number): number { + const quote = sql[i]; + let j = i + 1; + while (j < sql.length) { + if (sql[j] === quote) { + if (quote === "'" && sql[j + 1] === "'") { + j += 2; + continue; + } + return j + 1; + } + j += 1; + } + return sql.length; +} + +function skipDollarQuote(sql: string, i: number): number { + if (sql[i] !== '$') return i; + let j = i + 1; + while (j < sql.length && (isIdentChar(sql[j]) || sql[j] === '_')) j += 1; + if (j >= sql.length || sql[j] !== '$') return i; + const tag = sql.slice(i, j + 1); + const close = sql.indexOf(tag, j + 1); + return close === -1 ? sql.length : close + tag.length; +} + +function isIdentChar(ch: string): boolean { + const code = ch.charCodeAt(0); + return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57); +} + +/** Collect `@name` variables introduced by DECLARE / SET and T-SQL routine signatures. */ +export function findDeclaredAtVars(sql: string): Set { + const declared = new Set(); + for (const stmt of splitStatements(sql)) { + collectTsqlRoutineSignatureAtVars(stmt, declared); + collectDeclaredAtVarsInStatement(stmt, declared); + } + return declared; +} + +function findNonRoutineDeclaredAtVars(sql: string): Set { + const declared = new Set(); + for (const stmt of splitStatements(sql)) { + collectDeclaredAtVarsInStatement(stmt, declared); + } + return declared; +} + +function findTsqlRoutineSignatureAtVars(sql: string): Set { + const declared = new Set(); + for (const stmt of splitStatements(sql)) { + collectTsqlRoutineSignatureAtVars(stmt, declared); + } + return declared; +} + +function findTsqlRoutineRanges(sql: string): Array<{ from: number; to: number }> { + const ranges: Array<{ from: number; to: number }> = []; + for (const { statement, from } of splitStatementsWithOffsets(sql)) { + const prefix = maskNonCodeForTsql(statement); + const definition = + /^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?:FUNCTION|PROC(?:EDURE)?)\b/i.exec(prefix); + if (!definition) continue; + + const remaining = maskNonCodeForTsql(sql.slice(from)); + const header = remaining.slice(definition[0].length); + const bodyMarker = /\bAS\b/i.exec(header); + if (!bodyMarker) continue; + + const bodyStart = definition[0].length + bodyMarker.index + bodyMarker[0].length; + const body = remaining.slice(bodyStart); + const begin = /\bBEGIN\b/i.exec(body); + if (!begin) { + const semicolon = remaining.indexOf(';', bodyStart); + ranges.push({ from, to: semicolon === -1 ? sql.length : from + semicolon }); + continue; + } + + const blockStart = bodyStart + begin.index; + const keywords = /\b(BEGIN|CASE|END)\b/gi; + keywords.lastIndex = blockStart; + let depth = 0; + let end = sql.length; + let match: RegExpExecArray | null; + while ((match = keywords.exec(remaining))) { + const keyword = match[1].toUpperCase(); + if (keyword === 'BEGIN') { + const next = /^\s+(?:TRAN(?:SACTION)?|DIALOG|CONVERSATION|DISTRIBUTED)\b/i.exec( + remaining.slice(keywords.lastIndex), + ); + if (!next) depth += 1; + } else if (keyword === 'CASE') { + depth += 1; + } else { + depth -= 1; + if (depth === 0) { + end = from + keywords.lastIndex; + const terminator = /^\s*;/.exec(sql.slice(end)); + if (terminator) end += terminator[0].length; + break; + } + } + } + ranges.push({ from, to: end }); + } + return ranges; +} + +function splitStatementsWithOffsets(sql: string): Array<{ statement: string; from: number }> { + const statements: Array<{ statement: string; from: number }> = []; + let start = 0; + let i = 0; + while (i < sql.length) { + const skipped = skipNonCode(sql, i); + if (skipped !== i) { + i = skipped; + continue; + } + if (sql[i] === ';') { + statements.push({ statement: sql.slice(start, i), from: start }); + start = i + 1; + } + i += 1; + } + statements.push({ statement: sql.slice(start), from: start }); + return statements; +} + +/** A routine parameter belongs to the T-SQL body, not to the editor's bind payload. */ +function collectTsqlRoutineSignatureAtVars(stmt: string, declared: Set) { + const code = maskNonCodeForTsql(stmt); + const definition = /^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?:FUNCTION|PROC(?:EDURE)?)\b/i.exec( + code, + ); + if (!definition) return; + + const header = code.slice(definition[0].length); + const bodyMarker = /\bAS\b/i.exec(header); + if (!bodyMarker) return; + + const signature = header.slice(0, bodyMarker.index); + for (const match of signature.matchAll(/@([A-Za-z_][A-Za-z0-9_]*)/g)) { + declared.add(match[1].toLowerCase()); + } +} + +/** Keep offsets stable while hiding comments, literals, and bracketed identifiers. */ +function maskNonCodeForTsql(sql: string): string { + const masked = sql.split(''); + for (let i = 0; i < sql.length; ) { + const end = sql[i] === '[' ? skipBracketIdentifier(sql, i) : skipNonCode(sql, i); + if (end === i) { + i += 1; + continue; + } + for (let j = i; j < end; j += 1) { + if (sql[j] !== '\n' && sql[j] !== '\r') masked[j] = ' '; + } + i = end; + } + return masked.join(''); +} + +function skipBracketIdentifier(sql: string, i: number): number { + let j = i + 1; + while (j < sql.length) { + if (sql[j] === ']') { + if (sql[j + 1] === ']') { + j += 2; + continue; + } + return j + 1; + } + j += 1; + } + return sql.length; +} + +function collectDeclaredAtVarsInStatement(stmt: string, declared: Set) { + const trimmed = stmt.trimStart(); + if (!trimmed) return; + + const upper = trimmed.toUpperCase(); + if (upper.startsWith('DECLARE')) { + const body = trimmed.slice('DECLARE'.length); + for (const m of body.matchAll(/@([A-Za-z_][A-Za-z0-9_]*)/g)) { + declared.add(m[1].toLowerCase()); + } + return; + } + + if (upper.startsWith('SET')) { + const m = /^SET\s+@([A-Za-z_][A-Za-z0-9_]*)\s*=/i.exec(trimmed); + if (m) { + declared.add(m[1].toLowerCase()); + } + } +} + +function splitStatements(sql: string): string[] { + return splitStatementsWithOffsets(sql).map(({ statement }) => statement); +} diff --git a/packages/drivers/turso/ui/meta.ts b/packages/drivers/turso/ui/meta.ts index c70e19b5b..b3acd412b 100644 --- a/packages/drivers/turso/ui/meta.ts +++ b/packages/drivers/turso/ui/meta.ts @@ -25,6 +25,13 @@ export const tursoMeta = { clipboardSchemes: ['libsql', 'turso'], supportsExplain: true, supportedObjectKinds: ['trigger'], + sqlParameterPolicy: { + atNamed: true, + question: true, + dollarPositional: false, + dollarNamed: true, + template: false, + }, structureEditor: { columnTypes: [ { value: 'INTEGER', label: 'INTEGER' }, diff --git a/packages/extension-points/src/__tests__/bindParams.test.ts b/packages/extension-points/src/__tests__/bindParams.test.ts index e78ad7722..847e0801f 100644 --- a/packages/extension-points/src/__tests__/bindParams.test.ts +++ b/packages/extension-points/src/__tests__/bindParams.test.ts @@ -1,38 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { findDeclaredAtVars, parseSqlParams } from '../sql-editor/bindParams'; +import { parseSqlParamOccurrences } from '../sql-editor/bindParams'; describe('extension-point SQL bind parameter parser', () => { - it('treats T-SQL function signature parameters as local variables', () => { - const sql = `CREATE FUNCTION [dbo].[fn_NormalizeCode] -( - @value NVARCHAR(64) -) -RETURNS NVARCHAR(64) -AS -BEGIN - RETURN UPPER(LTRIM(RTRIM(@value))); -END;`; - - expect(findDeclaredAtVars(sql)).toEqual(new Set(['value'])); - expect(parseSqlParams(sql)).toEqual([]); - expect(parseSqlParams(`${sql}\nSELECT @value`)).toEqual([ - { - name: 'value', - kind: 'named', - syntax: 'at', - stableId: 'named:value', - }, - ]); - }); - - it('keeps ordinary T-SQL @name placeholders enabled', () => { - expect(parseSqlParams('SELECT @value')).toEqual([ - { - name: 'value', - kind: 'named', - syntax: 'at', - stableId: 'named:value', - }, - ]); + it('leaves dialect scope filtering to the policy strategy', () => { + expect( + parseSqlParamOccurrences('DECLARE @value INT; SELECT @value', { enableAt: true }).map( + (occurrence) => occurrence.name, + ), + ).toEqual(['value', 'value']); }); }); diff --git a/packages/extension-points/src/sql-editor/bindParams.ts b/packages/extension-points/src/sql-editor/bindParams.ts index 0f8daafaa..7c976ced9 100644 --- a/packages/extension-points/src/sql-editor/bindParams.ts +++ b/packages/extension-points/src/sql-editor/bindParams.ts @@ -1,25 +1,38 @@ +import type { SqlParameterStrategy } from '../../../../src/lib/sqlDialects/types'; + export type SqlParamKind = 'named' | 'positional'; -export type SqlParamSyntax = 'colon' | 'at' | 'dollar-positional' | 'question' | 'template'; +export type SqlParamSyntax = + | 'colon' + | 'at' + | 'dollar-positional' + | 'dollar-named' + | 'question' + | 'template'; export type SqlParamStableId = `named:${string}` | `dollar:${number}` | `question:${number}`; export interface SqlParamDialectPolicy { - /** Recognize `@name` placeholders (default false). */ + /** Recognize `@name`; driver metadata decides whether this syntax is native. */ enableAt?: boolean; - /** Recognize `?` placeholders (default false). */ + /** Recognize `?`; driver metadata decides whether this syntax is native. */ enableQuestion?: boolean; + /** Recognize `$1` placeholders (default true). */ + enableDollarPositional?: boolean; + /** Recognize `$name` placeholders (default false). */ + enableDollarNamed?: boolean; /** Recognize `${name}` template placeholders (default true). */ enableTemplate?: boolean; - /** Exclude `DECLARE` / `SET` variables and T-SQL routine parameters (default true when enableAt). */ - excludeDeclaredAtVars?: boolean; + /** Driver-owned syntax disambiguation. */ + strategy?: SqlParameterStrategy; } -export const DEFAULT_DIALECT_POLICY: Required = { +export const DEFAULT_DIALECT_POLICY: Required> = { enableAt: true, enableQuestion: true, + enableDollarPositional: true, + enableDollarNamed: false, enableTemplate: true, - excludeDeclaredAtVars: true, }; export interface SqlParam { @@ -57,8 +70,15 @@ export interface SqlBindPayloadV2 { const IDENT = /[A-Za-z_][A-Za-z0-9_]*/y; const DIGITS = /[0-9]+/y; -function resolvePolicy(policy?: SqlParamDialectPolicy): Required { - return { ...DEFAULT_DIALECT_POLICY, ...policy }; +type ResolvedSqlParamDialectPolicy = Required> & + Pick; + +function resolvePolicy(policy?: SqlParamDialectPolicy): ResolvedSqlParamDialectPolicy { + return { + ...DEFAULT_DIALECT_POLICY, + ...policy, + strategy: policy?.strategy, + }; } function namedId(name: string): SqlParamStableId { @@ -79,10 +99,6 @@ export function parseSqlParamOccurrences( policy?: SqlParamDialectPolicy, ): SqlParamOccurrence[] { const resolved = resolvePolicy(policy); - const excludeAtVars = resolved.enableAt && resolved.excludeDeclaredAtVars; - const declaredAt = excludeAtVars ? findNonRoutineDeclaredAtVars(sql) : new Set(); - const routineAtVars = excludeAtVars ? findTsqlRoutineSignatureAtVars(sql) : new Set(); - const routineRanges = excludeAtVars ? findTsqlRoutineRanges(sql) : []; const occurrences: SqlParamOccurrence[] = []; let questionOrdinal = 0; let i = 0; @@ -129,22 +145,16 @@ export function parseSqlParamOccurrences( const m = IDENT.exec(sql); if (m) { const name = m[0]; - const normalizedName = name.toLowerCase(); - const isRoutineLocal = - routineAtVars.has(normalizedName) && - routineRanges.some(({ from, to }) => i >= from && i < to); - if (!declaredAt.has(normalizedName) && !isRoutineLocal) { - const from = i; - const to = IDENT.lastIndex; - pushOccurrence(occurrences, { - from, - to, - id: namedId(name), - token: sql.slice(from, to), - syntax: 'at', - name, - }); - } + const from = i; + const to = IDENT.lastIndex; + pushOccurrence(occurrences, { + from, + to, + id: namedId(name), + token: sql.slice(from, to), + syntax: 'at', + name, + }); i = IDENT.lastIndex; continue; } @@ -170,22 +180,43 @@ export function parseSqlParamOccurrences( continue; } } - DIGITS.lastIndex = i + 1; - const m = DIGITS.exec(sql); - if (m) { - const num = Number(m[0]); - const from = i; - const to = DIGITS.lastIndex; - pushOccurrence(occurrences, { - from, - to, - id: dollarId(num), - token: sql.slice(from, to), - syntax: 'dollar-positional', - name: m[0], - }); - i = to; - continue; + if (resolved.enableDollarNamed) { + IDENT.lastIndex = i + 1; + const m = IDENT.exec(sql); + if (m) { + const name = m[0]; + const from = i; + const to = IDENT.lastIndex; + pushOccurrence(occurrences, { + from, + to, + id: namedId(name), + token: sql.slice(from, to), + syntax: 'dollar-named', + name, + }); + i = to; + continue; + } + } + if (resolved.enableDollarPositional) { + DIGITS.lastIndex = i + 1; + const m = DIGITS.exec(sql); + if (m) { + const num = Number(m[0]); + const from = i; + const to = DIGITS.lastIndex; + pushOccurrence(occurrences, { + from, + to, + id: dollarId(num), + token: sql.slice(from, to), + syntax: 'dollar-positional', + name: m[0], + }); + i = to; + continue; + } } const dq = skipDollarQuote(sql, i); if (dq !== i) { @@ -194,7 +225,7 @@ export function parseSqlParamOccurrences( } } - if (ch === '?' && resolved.enableQuestion && isQuestionBindPlaceholder(sql, i)) { + if (ch === '?' && resolved.enableQuestion) { questionOrdinal += 1; pushOccurrence(occurrences, { from: i, @@ -212,7 +243,10 @@ export function parseSqlParamOccurrences( i += 1; } - return occurrences; + if (!resolved.strategy?.filterOccurrences) return occurrences; + const accepted = resolved.strategy.filterOccurrences(sql, occurrences); + const acceptedKeys = new Set(accepted.map(({ from, to, id }) => `${from}\0${to}\0${id}`)); + return occurrences.filter(({ from, to, id }) => acceptedKeys.has(`${from}\0${to}\0${id}`)); } /** Deduped bind descriptors derived from occurrences (stable order of first appearance). */ @@ -332,6 +366,8 @@ export function getParamLabel(param: SqlParam): string { return `@${param.name}`; case 'dollar-positional': return `$${param.name}`; + case 'dollar-named': + return `$${param.name}`; case 'question': return '?'; case 'template': @@ -378,22 +414,6 @@ function pushOccurrence(list: SqlParamOccurrence[], occ: SqlParamOccurrence) { list.push(occ); } -function isQuestionBindPlaceholder(sql: string, i: number): boolean { - const next = sql[i + 1]; - if (next === '|' || next === '&' || next === '-' || next === '#') { - return false; - } - let j = i + 1; - while (j < sql.length && sql[j] === ' ') { - j += 1; - } - // PostgreSQL json key existence operator: `? 'key'` - if (sql[j] === "'") { - return false; - } - return true; -} - function skipNonCode(sql: string, i: number): number { const ch = sql[i]; if (ch === "'" || ch === '"' || ch === '`') { @@ -445,176 +465,3 @@ function isIdentChar(ch: string): boolean { const code = ch.charCodeAt(0); return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57); } - -/** Collect `@name` variables introduced by DECLARE / SET and T-SQL routine signatures. */ -export function findDeclaredAtVars(sql: string): Set { - const declared = new Set(); - for (const stmt of splitStatements(sql)) { - collectTsqlRoutineSignatureAtVars(stmt, declared); - collectDeclaredAtVarsInStatement(stmt, declared); - } - return declared; -} - -function findNonRoutineDeclaredAtVars(sql: string): Set { - const declared = new Set(); - for (const stmt of splitStatements(sql)) { - collectDeclaredAtVarsInStatement(stmt, declared); - } - return declared; -} - -function findTsqlRoutineSignatureAtVars(sql: string): Set { - const declared = new Set(); - for (const stmt of splitStatements(sql)) { - collectTsqlRoutineSignatureAtVars(stmt, declared); - } - return declared; -} - -function findTsqlRoutineRanges(sql: string): Array<{ from: number; to: number }> { - const ranges: Array<{ from: number; to: number }> = []; - for (const { statement, from } of splitStatementsWithOffsets(sql)) { - const prefix = maskNonCodeForTsql(statement); - const definition = - /^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?:FUNCTION|PROC(?:EDURE)?)\b/i.exec(prefix); - if (!definition) continue; - - const remaining = maskNonCodeForTsql(sql.slice(from)); - const header = remaining.slice(definition[0].length); - const bodyMarker = /\bAS\b/i.exec(header); - if (!bodyMarker) continue; - - const bodyStart = definition[0].length + bodyMarker.index + bodyMarker[0].length; - const body = remaining.slice(bodyStart); - const begin = /\bBEGIN\b/i.exec(body); - if (!begin) { - const semicolon = remaining.indexOf(';', bodyStart); - ranges.push({ from, to: semicolon === -1 ? sql.length : from + semicolon }); - continue; - } - - const blockStart = bodyStart + begin.index; - const keywords = /\b(BEGIN|CASE|END)\b/gi; - keywords.lastIndex = blockStart; - let depth = 0; - let end = sql.length; - let match: RegExpExecArray | null; - while ((match = keywords.exec(remaining))) { - const keyword = match[1].toUpperCase(); - if (keyword === 'BEGIN') { - const next = /^\s+(?:TRAN(?:SACTION)?|DIALOG|CONVERSATION|DISTRIBUTED)\b/i.exec( - remaining.slice(keywords.lastIndex), - ); - if (!next) depth += 1; - } else if (keyword === 'CASE') { - depth += 1; - } else { - depth -= 1; - if (depth === 0) { - end = from + keywords.lastIndex; - const terminator = /^\s*;/.exec(sql.slice(end)); - if (terminator) end += terminator[0].length; - break; - } - } - } - ranges.push({ from, to: end }); - } - return ranges; -} - -function splitStatementsWithOffsets(sql: string): Array<{ statement: string; from: number }> { - const statements: Array<{ statement: string; from: number }> = []; - let start = 0; - let i = 0; - while (i < sql.length) { - const skipped = skipNonCode(sql, i); - if (skipped !== i) { - i = skipped; - continue; - } - if (sql[i] === ';') { - statements.push({ statement: sql.slice(start, i), from: start }); - start = i + 1; - } - i += 1; - } - statements.push({ statement: sql.slice(start), from: start }); - return statements; -} - -/** A routine parameter belongs to the T-SQL body, not to the editor's bind payload. */ -function collectTsqlRoutineSignatureAtVars(stmt: string, declared: Set) { - const code = maskNonCodeForTsql(stmt); - const definition = /^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?:FUNCTION|PROC(?:EDURE)?)\b/i.exec( - code, - ); - if (!definition) return; - - const header = code.slice(definition[0].length); - const bodyMarker = /\bAS\b/i.exec(header); - if (!bodyMarker) return; - - const signature = header.slice(0, bodyMarker.index); - for (const match of signature.matchAll(/@([A-Za-z_][A-Za-z0-9_]*)/g)) { - declared.add(match[1].toLowerCase()); - } -} - -/** Keep offsets stable while hiding comments, literals, and bracketed identifiers. */ -function maskNonCodeForTsql(sql: string): string { - const masked = sql.split(''); - for (let i = 0; i < sql.length; ) { - const end = sql[i] === '[' ? skipBracketIdentifier(sql, i) : skipNonCode(sql, i); - if (end === i) { - i += 1; - continue; - } - for (let j = i; j < end; j += 1) { - if (sql[j] !== '\n' && sql[j] !== '\r') masked[j] = ' '; - } - i = end; - } - return masked.join(''); -} - -function skipBracketIdentifier(sql: string, i: number): number { - let j = i + 1; - while (j < sql.length) { - if (sql[j] === ']') { - if (sql[j + 1] === ']') { - j += 2; - continue; - } - return j + 1; - } - j += 1; - } - return sql.length; -} - -function collectDeclaredAtVarsInStatement(stmt: string, declared: Set) { - const trimmed = stmt.trimStart(); - if (!trimmed) return; - - const upper = trimmed.toUpperCase(); - if (upper.startsWith('DECLARE')) { - const body = trimmed.slice('DECLARE'.length); - for (const m of body.matchAll(/@([A-Za-z_][A-Za-z0-9_]*)/g)) { - declared.add(m[1].toLowerCase()); - } - return; - } - - if (upper.startsWith('SET')) { - const m = /^SET\s+@([A-Za-z_][A-Za-z0-9_]*)\s*=/i.exec(trimmed); - if (m) { - declared.add(m[1].toLowerCase()); - } - } -} - -function splitStatements(sql: string): string[] { - return splitStatementsWithOffsets(sql).map(({ statement }) => statement); -} diff --git a/packages/extension-points/src/sql-editor/databaseTypes.ts b/packages/extension-points/src/sql-editor/databaseTypes.ts index e36ac430a..e725fecff 100644 --- a/packages/extension-points/src/sql-editor/databaseTypes.ts +++ b/packages/extension-points/src/sql-editor/databaseTypes.ts @@ -3,6 +3,10 @@ */ import type { FunctionEntry } from './functionTypes'; import type { SqlDialectQuoteStyle, SqlProjectionAliasVisibility } from './semanticTypes'; +import type { + SqlParameterPolicy, + SqlParameterStrategy, +} from '../../../../src/lib/sqlDialects/types'; export type DatabaseType = string; @@ -48,12 +52,7 @@ export interface SqlDialectProfile { quoteStyle: SqlDialectQuoteStyle; foldCase: 'lower' | 'upper' | 'preserve'; projectionAliasVisibility: SqlProjectionAliasVisibility; - parameterPolicy: { - atNamed?: boolean; - question?: boolean; - dollarPositional?: boolean; - template?: boolean; - }; + parameterPolicy: SqlParameterPolicy; reservedKeywords?: readonly string[]; } @@ -63,6 +62,8 @@ export interface DatabaseTypeMetaLite { sqlDialect?: string; sqlFunctions?: readonly FunctionEntry[]; sqlDialectProfile?: SqlDialectProfile; + sqlParameterPolicy?: SqlParameterPolicy; + sqlParameterStrategy?: SqlParameterStrategy; [key: string]: unknown; } diff --git a/packages/extension-points/src/sqlEditorEnhancedEP.ts b/packages/extension-points/src/sqlEditorEnhancedEP.ts index 3f9064a70..590a7d770 100644 --- a/packages/extension-points/src/sqlEditorEnhancedEP.ts +++ b/packages/extension-points/src/sqlEditorEnhancedEP.ts @@ -10,6 +10,7 @@ import type { CompletionSource } from '@codemirror/autocomplete'; import type React from 'react'; import { createExtensionPoint } from './extensionPoints'; import type { QueryBuilderContribution } from './queryBuilder'; +import type { SqlParamDialectPolicy } from './sql-editor/bindParams'; /** Compartment option bags passed from host; typed loosely for cross-repo linking. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -104,7 +105,7 @@ export interface SqlEditorEnhancedFeatures { /** S5-B: Bind parameter panel renderer. */ renderBindParamPanel?: (props: SqlEditorEnhancedOptions) => any; /** S5-B: Bind parameters hook. */ - useBindParameters?: (sql: string, options?: any) => any; + useBindParameters?: (sql: string, policy?: SqlParamDialectPolicy) => any; /** * S4-E: as-you-type static diagnostics (unknown table / column squiggles). * Returns `@codemirror/lint` extensions; debounced and degraded on large documents. diff --git a/src/lib/__tests__/fixtures/sqlBindParamCases.json b/src/lib/__tests__/fixtures/sqlBindParamCases.json index b8bbf5447..2596f93c7 100644 --- a/src/lib/__tests__/fixtures/sqlBindParamCases.json +++ b/src/lib/__tests__/fixtures/sqlBindParamCases.json @@ -64,33 +64,11 @@ ] }, { - "id": "skip-pg-json-operators", + "id": "disable-question-style-for-non-bind-dialect", "sql": "SELECT data ? 'key' , col ?| arr , x ?& y , plain ?", - "policy": { "enableQuestion": true }, - "descriptors": [ - { "name": "1", "kind": "positional", "syntax": "question", "stableId": "question:1", "ordinal": 1 } - ], - "occurrences": [ - { "from": 50, "to": 51, "id": "question:1", "token": "?", "syntax": "question", "name": "1", "ordinal": 1 } - ] - }, - { - "id": "exclude-declared-at-var", - "sql": "DECLARE @limit INT; SELECT TOP (@limit) * FROM t WHERE id = @id", - "policy": { "enableAt": true, "excludeDeclaredAtVars": true }, - "descriptors": [{ "name": "id", "kind": "named", "syntax": "at", "stableId": "named:id" }], - "occurrences": [ - { "from": 60, "to": 63, "id": "named:id", "token": "@id", "syntax": "at", "name": "id" } - ] - }, - { - "id": "exclude-set-at-var", - "sql": "SET @count = 0; SELECT @count, @filter", - "policy": { "enableAt": true, "excludeDeclaredAtVars": true }, - "descriptors": [{ "name": "filter", "kind": "named", "syntax": "at", "stableId": "named:filter" }], - "occurrences": [ - { "from": 31, "to": 38, "id": "named:filter", "token": "@filter", "syntax": "at", "name": "filter" } - ] + "policy": { "enableQuestion": false }, + "descriptors": [], + "occurrences": [] }, { "id": "dollar-quote-body", diff --git a/src/lib/__tests__/sqlBindParams.test.ts b/src/lib/__tests__/sqlBindParams.test.ts index 77ece9a67..cc98709c5 100644 --- a/src/lib/__tests__/sqlBindParams.test.ts +++ b/src/lib/__tests__/sqlBindParams.test.ts @@ -3,7 +3,6 @@ import fixture from './fixtures/sqlBindParamCases.json'; import { buildBindPayloadV2, coerceParamValue, - findDeclaredAtVars, getParamLabel, paramFingerprint, parseSqlParamOccurrences, @@ -94,84 +93,28 @@ describe('parseSqlParams', () => { enableAt: true, enableQuestion: true, enableTemplate: true, + enableDollarPositional: true, }); expect(params.map((p) => p.stableId)).toEqual(['named:name', 'question:1', 'dollar:2']); }); - it('excludes declared and assigned @ variables', () => { - expect(findDeclaredAtVars('DECLARE @limit INT; SET @count = 1')).toEqual( - new Set(['limit', 'count']), - ); - const params = parseSqlParams( - 'DECLARE @limit INT; SELECT TOP (@limit) * FROM t WHERE id = @id', - { enableAt: true, excludeDeclaredAtVars: true }, - ); - expect(params).toEqual([ - { - name: 'id', - kind: 'named', - syntax: 'at', - stableId: 'named:id', - }, + it('leaves dialect-local variable decisions to the driver strategy', () => { + const sql = 'DECLARE @limit INT; SELECT TOP (@limit) * FROM t WHERE id = @id'; + expect(parseSqlParamOccurrences(sql, { enableAt: true }).map((param) => param.name)).toEqual([ + 'limit', + 'limit', + 'id', ]); }); - it('excludes T-SQL function parameters while retaining ordinary @ binds', () => { - const sql = `-- @comment is not a routine parameter -CREATE FUNCTION [dbo].[fn_Normalize_AS_Code] -( - @value NVARCHAR(64) -) -RETURNS NVARCHAR(64) -AS -BEGIN - RETURN UPPER(LTRIM(RTRIM(@value))); -END;`; - - expect(findDeclaredAtVars(sql)).toEqual(new Set(['value'])); - expect(parseSqlParams(sql)).toEqual([]); - expect(parseSqlParams(`${sql}\nSELECT @value`)).toEqual([ - { - name: 'value', - kind: 'named', - syntax: 'at', - stableId: 'named:value', - }, - ]); - expect(parseSqlParams('SELECT @value')).toEqual([ - { - name: 'value', - kind: 'named', - syntax: 'at', - stableId: 'named:value', - }, - ]); - }); - - it('excludes T-SQL procedure signature parameters without disabling unrelated @ binds', () => { - const sql = `CREATE OR ALTER PROCEDURE [dbo].[p_Filter] - @input NVARCHAR(64), @limit INT -AS -BEGIN - SELECT @input, @limit; -END; -SELECT @input, @filter;`; - - expect(findDeclaredAtVars(sql)).toEqual(new Set(['input', 'limit'])); - expect(parseSqlParams(sql)).toEqual([ - { - name: 'input', - kind: 'named', - syntax: 'at', - stableId: 'named:input', - }, - { - name: 'filter', - kind: 'named', - syntax: 'at', - stableId: 'named:filter', - }, - ]); + it('supports SQLite dollar-named placeholders when the driver enables them', () => { + expect( + parseSqlParams('SELECT $customer_id', { enableDollarNamed: true }).map((param) => ({ + name: param.name, + syntax: param.syntax, + stableId: param.stableId, + })), + ).toEqual([{ name: 'customer_id', syntax: 'dollar-named', stableId: 'named:customer_id' }]); }); }); @@ -185,11 +128,9 @@ describe('parseSqlParamOccurrences', () => { expect(sql.slice(occ[0].from, occ[0].to)).toBe('?'); }); - it('skips pg json operators for question marks', () => { + it('does not scan question operators when a driver disables question placeholders', () => { const sql = "SELECT data ? 'key' , plain ?"; - const occ = parseSqlParamOccurrences(sql, { enableQuestion: true }); - expect(occ).toHaveLength(1); - expect(sql.slice(occ[0].from, occ[0].to)).toBe('?'); + expect(parseSqlParamOccurrences(sql, { enableQuestion: false })).toEqual([]); }); }); @@ -376,6 +317,7 @@ describe('five syntax families integration', () => { enableAt: true, enableQuestion: true, enableTemplate: true, + enableDollarPositional: true, }); // :col and @col share named:col, ${col} also named:col → deduped // ? → question:1, $1 → dollar:1 diff --git a/src/lib/__tests__/sqlParameterPolicy.test.ts b/src/lib/__tests__/sqlParameterPolicy.test.ts new file mode 100644 index 000000000..b5554f346 --- /dev/null +++ b/src/lib/__tests__/sqlParameterPolicy.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { resolveSqlParameterPolicy } from '../sqlDialects/sqlParameterPolicy'; +import { parseSqlParams } from '../sqlBindParams'; +import type { SqlParameterStrategy } from '../sqlDialects/types'; + +describe('resolveSqlParameterPolicy', () => { + it('uses the selected driver policy and strategy', () => { + const strategy: SqlParameterStrategy = { + filterOccurrences: (_sql, occurrences) => occurrences, + }; + const policy = resolveSqlParameterPolicy('sqlserver', { + sqlserver: { + sqlDialectProfile: { + parameterPolicy: { + atNamed: true, + question: false, + dollarPositional: false, + template: false, + }, + }, + sqlParameterStrategy: strategy, + }, + }); + + expect(policy).toEqual({ + enableAt: true, + enableQuestion: false, + enableDollarPositional: false, + enableDollarNamed: false, + enableTemplate: false, + strategy, + }); + }); + + it('allows a focused driver policy when there is no full dialect profile', () => { + expect( + resolveSqlParameterPolicy('turso', { + turso: { + sqlParameterPolicy: { + atNamed: true, + question: true, + dollarPositional: false, + dollarNamed: true, + template: false, + }, + }, + }), + ).toEqual({ + enableAt: true, + enableQuestion: true, + enableDollarPositional: false, + enableDollarNamed: true, + enableTemplate: false, + strategy: undefined, + }); + }); + + it('uses only shared placeholder forms when a driver declares no native policy', () => { + expect(resolveSqlParameterPolicy('generic', {})).toEqual({ + enableAt: false, + enableQuestion: false, + enableDollarPositional: false, + enableDollarNamed: false, + enableTemplate: true, + strategy: undefined, + }); + }); + + it('does not interpret MySQL session variables as editor parameters', () => { + const policy = resolveSqlParameterPolicy('mysql', { + mysql: { + sqlDialectProfile: { + parameterPolicy: { + atNamed: false, + question: true, + dollarPositional: false, + template: false, + }, + }, + }, + }); + + expect(parseSqlParams('SELECT @session_var, ?', policy).map((param) => param.stableId)).toEqual( + ['question:1'], + ); + }); + + it('does not interpret PostgreSQL JSON question operators as bind markers', () => { + const policy = resolveSqlParameterPolicy('postgresql', { + postgresql: { + sqlDialectProfile: { + parameterPolicy: { + atNamed: false, + question: false, + dollarPositional: true, + template: false, + }, + }, + }, + }); + + expect( + parseSqlParams("SELECT data ? 'key', $1", policy).map((param) => param.stableId), + ).toEqual(['dollar:1']); + }); +}); diff --git a/src/lib/databaseMeta.ts b/src/lib/databaseMeta.ts index 2275cab7e..100b11eb6 100644 --- a/src/lib/databaseMeta.ts +++ b/src/lib/databaseMeta.ts @@ -6,7 +6,12 @@ import type { DatabaseObjectKind, SslMode } from '../types'; import type { StructureEditorUiConfig } from './structureEditor/types'; import type { FunctionEntry } from './sqlFunctionTypes'; -import type { SqlDialectStrategy, SqlDialectProfile } from './sqlDialects/types'; +import type { + SqlDialectStrategy, + SqlDialectProfile, + SqlParameterPolicy, + SqlParameterStrategy, +} from './sqlDialects/types'; export type ConnectionMode = 'server' | 'file' | 'url'; @@ -188,6 +193,10 @@ export interface DatabaseTypeMeta { sqlDialectStrategy?: SqlDialectStrategy; /** Semantic editor profile for identifier quoting, casing, alias visibility, and parameter policies. */ sqlDialectProfile?: SqlDialectProfile; + /** Native SQL bind syntax for drivers without a full semantic dialect profile. */ + sqlParameterPolicy?: SqlParameterPolicy; + /** Driver-owned filtering for bind syntax that is ambiguous in this dialect. */ + sqlParameterStrategy?: SqlParameterStrategy; /** * Custom SQL literal value escaper for "Copy as SQL" / export. * diff --git a/src/lib/sqlBindParams.ts b/src/lib/sqlBindParams.ts index 0f8daafaa..982e99cc5 100644 --- a/src/lib/sqlBindParams.ts +++ b/src/lib/sqlBindParams.ts @@ -1,25 +1,38 @@ +import type { SqlParameterStrategy } from './sqlDialects/types'; + export type SqlParamKind = 'named' | 'positional'; -export type SqlParamSyntax = 'colon' | 'at' | 'dollar-positional' | 'question' | 'template'; +export type SqlParamSyntax = + | 'colon' + | 'at' + | 'dollar-positional' + | 'dollar-named' + | 'question' + | 'template'; export type SqlParamStableId = `named:${string}` | `dollar:${number}` | `question:${number}`; export interface SqlParamDialectPolicy { - /** Recognize `@name` placeholders (default false). */ + /** Recognize `@name`; driver metadata decides whether this syntax is native. */ enableAt?: boolean; - /** Recognize `?` placeholders (default false). */ + /** Recognize `?`; driver metadata decides whether this syntax is native. */ enableQuestion?: boolean; + /** Recognize `$1` placeholders (default true). */ + enableDollarPositional?: boolean; + /** Recognize `$name` placeholders (default false). */ + enableDollarNamed?: boolean; /** Recognize `${name}` template placeholders (default true). */ enableTemplate?: boolean; - /** Exclude `DECLARE` / `SET` variables and T-SQL routine parameters (default true when enableAt). */ - excludeDeclaredAtVars?: boolean; + /** Driver-owned syntax disambiguation. */ + strategy?: SqlParameterStrategy; } -export const DEFAULT_DIALECT_POLICY: Required = { +export const DEFAULT_DIALECT_POLICY: Required> = { enableAt: true, enableQuestion: true, + enableDollarPositional: true, + enableDollarNamed: false, enableTemplate: true, - excludeDeclaredAtVars: true, }; export interface SqlParam { @@ -57,8 +70,15 @@ export interface SqlBindPayloadV2 { const IDENT = /[A-Za-z_][A-Za-z0-9_]*/y; const DIGITS = /[0-9]+/y; -function resolvePolicy(policy?: SqlParamDialectPolicy): Required { - return { ...DEFAULT_DIALECT_POLICY, ...policy }; +type ResolvedSqlParamDialectPolicy = Required> & + Pick; + +function resolvePolicy(policy?: SqlParamDialectPolicy): ResolvedSqlParamDialectPolicy { + return { + ...DEFAULT_DIALECT_POLICY, + ...policy, + strategy: policy?.strategy, + }; } function namedId(name: string): SqlParamStableId { @@ -79,10 +99,6 @@ export function parseSqlParamOccurrences( policy?: SqlParamDialectPolicy, ): SqlParamOccurrence[] { const resolved = resolvePolicy(policy); - const excludeAtVars = resolved.enableAt && resolved.excludeDeclaredAtVars; - const declaredAt = excludeAtVars ? findNonRoutineDeclaredAtVars(sql) : new Set(); - const routineAtVars = excludeAtVars ? findTsqlRoutineSignatureAtVars(sql) : new Set(); - const routineRanges = excludeAtVars ? findTsqlRoutineRanges(sql) : []; const occurrences: SqlParamOccurrence[] = []; let questionOrdinal = 0; let i = 0; @@ -129,22 +145,16 @@ export function parseSqlParamOccurrences( const m = IDENT.exec(sql); if (m) { const name = m[0]; - const normalizedName = name.toLowerCase(); - const isRoutineLocal = - routineAtVars.has(normalizedName) && - routineRanges.some(({ from, to }) => i >= from && i < to); - if (!declaredAt.has(normalizedName) && !isRoutineLocal) { - const from = i; - const to = IDENT.lastIndex; - pushOccurrence(occurrences, { - from, - to, - id: namedId(name), - token: sql.slice(from, to), - syntax: 'at', - name, - }); - } + const from = i; + const to = IDENT.lastIndex; + pushOccurrence(occurrences, { + from, + to, + id: namedId(name), + token: sql.slice(from, to), + syntax: 'at', + name, + }); i = IDENT.lastIndex; continue; } @@ -170,22 +180,43 @@ export function parseSqlParamOccurrences( continue; } } - DIGITS.lastIndex = i + 1; - const m = DIGITS.exec(sql); - if (m) { - const num = Number(m[0]); - const from = i; - const to = DIGITS.lastIndex; - pushOccurrence(occurrences, { - from, - to, - id: dollarId(num), - token: sql.slice(from, to), - syntax: 'dollar-positional', - name: m[0], - }); - i = to; - continue; + if (resolved.enableDollarNamed) { + IDENT.lastIndex = i + 1; + const m = IDENT.exec(sql); + if (m) { + const name = m[0]; + const from = i; + const to = IDENT.lastIndex; + pushOccurrence(occurrences, { + from, + to, + id: namedId(name), + token: sql.slice(from, to), + syntax: 'dollar-named', + name, + }); + i = to; + continue; + } + } + if (resolved.enableDollarPositional) { + DIGITS.lastIndex = i + 1; + const m = DIGITS.exec(sql); + if (m) { + const num = Number(m[0]); + const from = i; + const to = DIGITS.lastIndex; + pushOccurrence(occurrences, { + from, + to, + id: dollarId(num), + token: sql.slice(from, to), + syntax: 'dollar-positional', + name: m[0], + }); + i = to; + continue; + } } const dq = skipDollarQuote(sql, i); if (dq !== i) { @@ -194,7 +225,7 @@ export function parseSqlParamOccurrences( } } - if (ch === '?' && resolved.enableQuestion && isQuestionBindPlaceholder(sql, i)) { + if (ch === '?' && resolved.enableQuestion) { questionOrdinal += 1; pushOccurrence(occurrences, { from: i, @@ -212,7 +243,10 @@ export function parseSqlParamOccurrences( i += 1; } - return occurrences; + if (!resolved.strategy?.filterOccurrences) return occurrences; + const accepted = resolved.strategy.filterOccurrences(sql, occurrences); + const acceptedKeys = new Set(accepted.map(({ from, to, id }) => `${from}\0${to}\0${id}`)); + return occurrences.filter(({ from, to, id }) => acceptedKeys.has(`${from}\0${to}\0${id}`)); } /** Deduped bind descriptors derived from occurrences (stable order of first appearance). */ @@ -332,6 +366,8 @@ export function getParamLabel(param: SqlParam): string { return `@${param.name}`; case 'dollar-positional': return `$${param.name}`; + case 'dollar-named': + return `$${param.name}`; case 'question': return '?'; case 'template': @@ -378,22 +414,6 @@ function pushOccurrence(list: SqlParamOccurrence[], occ: SqlParamOccurrence) { list.push(occ); } -function isQuestionBindPlaceholder(sql: string, i: number): boolean { - const next = sql[i + 1]; - if (next === '|' || next === '&' || next === '-' || next === '#') { - return false; - } - let j = i + 1; - while (j < sql.length && sql[j] === ' ') { - j += 1; - } - // PostgreSQL json key existence operator: `? 'key'` - if (sql[j] === "'") { - return false; - } - return true; -} - function skipNonCode(sql: string, i: number): number { const ch = sql[i]; if (ch === "'" || ch === '"' || ch === '`') { @@ -445,176 +465,3 @@ function isIdentChar(ch: string): boolean { const code = ch.charCodeAt(0); return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57); } - -/** Collect `@name` variables introduced by DECLARE / SET and T-SQL routine signatures. */ -export function findDeclaredAtVars(sql: string): Set { - const declared = new Set(); - for (const stmt of splitStatements(sql)) { - collectTsqlRoutineSignatureAtVars(stmt, declared); - collectDeclaredAtVarsInStatement(stmt, declared); - } - return declared; -} - -function findNonRoutineDeclaredAtVars(sql: string): Set { - const declared = new Set(); - for (const stmt of splitStatements(sql)) { - collectDeclaredAtVarsInStatement(stmt, declared); - } - return declared; -} - -function findTsqlRoutineSignatureAtVars(sql: string): Set { - const declared = new Set(); - for (const stmt of splitStatements(sql)) { - collectTsqlRoutineSignatureAtVars(stmt, declared); - } - return declared; -} - -function findTsqlRoutineRanges(sql: string): Array<{ from: number; to: number }> { - const ranges: Array<{ from: number; to: number }> = []; - for (const { statement, from } of splitStatementsWithOffsets(sql)) { - const prefix = maskNonCodeForTsql(statement); - const definition = - /^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?:FUNCTION|PROC(?:EDURE)?)\b/i.exec(prefix); - if (!definition) continue; - - const remaining = maskNonCodeForTsql(sql.slice(from)); - const header = remaining.slice(definition[0].length); - const bodyMarker = /\bAS\b/i.exec(header); - if (!bodyMarker) continue; - - const bodyStart = definition[0].length + bodyMarker.index + bodyMarker[0].length; - const body = remaining.slice(bodyStart); - const begin = /\bBEGIN\b/i.exec(body); - if (!begin) { - const semicolon = remaining.indexOf(';', bodyStart); - ranges.push({ from, to: semicolon === -1 ? sql.length : from + semicolon }); - continue; - } - - const blockStart = bodyStart + begin.index; - const keywords = /\b(BEGIN|CASE|END)\b/gi; - keywords.lastIndex = blockStart; - let depth = 0; - let end = sql.length; - let match: RegExpExecArray | null; - while ((match = keywords.exec(remaining))) { - const keyword = match[1].toUpperCase(); - if (keyword === 'BEGIN') { - const next = /^\s+(?:TRAN(?:SACTION)?|DIALOG|CONVERSATION|DISTRIBUTED)\b/i.exec( - remaining.slice(keywords.lastIndex), - ); - if (!next) depth += 1; - } else if (keyword === 'CASE') { - depth += 1; - } else { - depth -= 1; - if (depth === 0) { - end = from + keywords.lastIndex; - const terminator = /^\s*;/.exec(sql.slice(end)); - if (terminator) end += terminator[0].length; - break; - } - } - } - ranges.push({ from, to: end }); - } - return ranges; -} - -function splitStatementsWithOffsets(sql: string): Array<{ statement: string; from: number }> { - const statements: Array<{ statement: string; from: number }> = []; - let start = 0; - let i = 0; - while (i < sql.length) { - const skipped = skipNonCode(sql, i); - if (skipped !== i) { - i = skipped; - continue; - } - if (sql[i] === ';') { - statements.push({ statement: sql.slice(start, i), from: start }); - start = i + 1; - } - i += 1; - } - statements.push({ statement: sql.slice(start), from: start }); - return statements; -} - -/** A routine parameter belongs to the T-SQL body, not to the editor's bind payload. */ -function collectTsqlRoutineSignatureAtVars(stmt: string, declared: Set) { - const code = maskNonCodeForTsql(stmt); - const definition = /^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?(?:FUNCTION|PROC(?:EDURE)?)\b/i.exec( - code, - ); - if (!definition) return; - - const header = code.slice(definition[0].length); - const bodyMarker = /\bAS\b/i.exec(header); - if (!bodyMarker) return; - - const signature = header.slice(0, bodyMarker.index); - for (const match of signature.matchAll(/@([A-Za-z_][A-Za-z0-9_]*)/g)) { - declared.add(match[1].toLowerCase()); - } -} - -/** Keep offsets stable while hiding comments, literals, and bracketed identifiers. */ -function maskNonCodeForTsql(sql: string): string { - const masked = sql.split(''); - for (let i = 0; i < sql.length; ) { - const end = sql[i] === '[' ? skipBracketIdentifier(sql, i) : skipNonCode(sql, i); - if (end === i) { - i += 1; - continue; - } - for (let j = i; j < end; j += 1) { - if (sql[j] !== '\n' && sql[j] !== '\r') masked[j] = ' '; - } - i = end; - } - return masked.join(''); -} - -function skipBracketIdentifier(sql: string, i: number): number { - let j = i + 1; - while (j < sql.length) { - if (sql[j] === ']') { - if (sql[j + 1] === ']') { - j += 2; - continue; - } - return j + 1; - } - j += 1; - } - return sql.length; -} - -function collectDeclaredAtVarsInStatement(stmt: string, declared: Set) { - const trimmed = stmt.trimStart(); - if (!trimmed) return; - - const upper = trimmed.toUpperCase(); - if (upper.startsWith('DECLARE')) { - const body = trimmed.slice('DECLARE'.length); - for (const m of body.matchAll(/@([A-Za-z_][A-Za-z0-9_]*)/g)) { - declared.add(m[1].toLowerCase()); - } - return; - } - - if (upper.startsWith('SET')) { - const m = /^SET\s+@([A-Za-z_][A-Za-z0-9_]*)\s*=/i.exec(trimmed); - if (m) { - declared.add(m[1].toLowerCase()); - } - } -} - -function splitStatements(sql: string): string[] { - return splitStatementsWithOffsets(sql).map(({ statement }) => statement); -} diff --git a/src/lib/sqlDialects/dialectProfile.ts b/src/lib/sqlDialects/dialectProfile.ts index db04ea39b..753d0c003 100644 --- a/src/lib/sqlDialects/dialectProfile.ts +++ b/src/lib/sqlDialects/dialectProfile.ts @@ -24,6 +24,7 @@ export const STANDARD_DIALECT_PROFILE: ResolvedSqlDialectProfile = { atNamed: false, question: false, dollarPositional: true, + dollarNamed: false, template: false, }, }; @@ -32,42 +33,78 @@ const SQLITE_PROFILE: ResolvedSqlDialectProfile = { quoteStyle: 'double', foldCase: 'lower', projectionAliasVisibility: 'select-only', - parameterPolicy: { atNamed: false, question: true, dollarPositional: false, template: false }, + parameterPolicy: { + atNamed: false, + question: true, + dollarPositional: false, + dollarNamed: false, + template: false, + }, }; const MYSQL_PROFILE: ResolvedSqlDialectProfile = { quoteStyle: 'backtick', foldCase: 'lower', projectionAliasVisibility: 'order-group', - parameterPolicy: { atNamed: false, question: true, dollarPositional: false, template: false }, + parameterPolicy: { + atNamed: false, + question: true, + dollarPositional: false, + dollarNamed: false, + template: false, + }, }; const SQLSERVER_PROFILE: ResolvedSqlDialectProfile = { quoteStyle: 'bracket', foldCase: 'preserve', projectionAliasVisibility: 'broad', - parameterPolicy: { atNamed: true, question: false, dollarPositional: false, template: false }, + parameterPolicy: { + atNamed: true, + question: false, + dollarPositional: false, + dollarNamed: false, + template: false, + }, }; const DUCKDB_PROFILE: ResolvedSqlDialectProfile = { quoteStyle: 'double', foldCase: 'lower', projectionAliasVisibility: 'select-only', - parameterPolicy: { atNamed: false, question: true, dollarPositional: true, template: false }, + parameterPolicy: { + atNamed: false, + question: true, + dollarPositional: true, + dollarNamed: false, + template: false, + }, }; const CLICKHOUSE_PROFILE: ResolvedSqlDialectProfile = { quoteStyle: 'backtick', foldCase: 'lower', projectionAliasVisibility: 'broad', - parameterPolicy: { atNamed: false, question: false, dollarPositional: false, template: false }, + parameterPolicy: { + atNamed: false, + question: false, + dollarPositional: false, + dollarNamed: false, + template: false, + }, }; const ORACLE_PROFILE: ResolvedSqlDialectProfile = { quoteStyle: 'double', foldCase: 'upper', projectionAliasVisibility: 'broad', - parameterPolicy: { atNamed: false, question: false, dollarPositional: false, template: false }, + parameterPolicy: { + atNamed: false, + question: false, + dollarPositional: false, + dollarNamed: false, + template: false, + }, }; /** @@ -109,6 +146,7 @@ function normalizeDeclaredProfile(declared: SqlDialectProfile): ResolvedSqlDiale atNamed: declared.parameterPolicy.atNamed ?? false, question: declared.parameterPolicy.question ?? false, dollarPositional: declared.parameterPolicy.dollarPositional ?? false, + dollarNamed: declared.parameterPolicy.dollarNamed ?? false, template: declared.parameterPolicy.template ?? false, }, reservedKeywords: declared.reservedKeywords, diff --git a/src/lib/sqlDialects/sqlParameterPolicy.ts b/src/lib/sqlDialects/sqlParameterPolicy.ts new file mode 100644 index 000000000..5b5dea5e4 --- /dev/null +++ b/src/lib/sqlDialects/sqlParameterPolicy.ts @@ -0,0 +1,40 @@ +import { DB_REGISTRY } from '../databaseTypes'; +import type { SqlDialectProfile, SqlParameterPolicy, SqlParameterStrategy } from './types'; +import type { SqlParamDialectPolicy } from '../sqlBindParams'; + +interface SqlParameterMeta { + sqlParameterPolicy?: SqlParameterPolicy; + sqlDialectProfile?: Pick; + sqlParameterStrategy?: SqlParameterStrategy; +} + +const GENERIC_SQL_PARAMETER_POLICY: SqlParamDialectPolicy = { + enableAt: false, + enableQuestion: false, + enableDollarPositional: false, + enableDollarNamed: false, + enableTemplate: true, +}; + +/** Resolve bind scanning from the selected driver's own metadata. */ +export function resolveSqlParameterPolicy( + databaseType?: string, + registry: Record = DB_REGISTRY, +): SqlParamDialectPolicy { + const key = databaseType?.trim().toLowerCase(); + const meta = key ? registry[key] : undefined; + const nativePolicy = meta?.sqlParameterPolicy ?? meta?.sqlDialectProfile?.parameterPolicy; + + if (!nativePolicy) { + return { ...GENERIC_SQL_PARAMETER_POLICY, strategy: meta?.sqlParameterStrategy }; + } + + return { + enableAt: nativePolicy.atNamed ?? false, + enableQuestion: nativePolicy.question ?? false, + enableDollarPositional: nativePolicy.dollarPositional ?? false, + enableDollarNamed: nativePolicy.dollarNamed ?? false, + enableTemplate: nativePolicy.template ?? false, + strategy: meta?.sqlParameterStrategy, + }; +} diff --git a/src/lib/sqlDialects/types.ts b/src/lib/sqlDialects/types.ts index 77c4e41f0..bfc47bd09 100644 --- a/src/lib/sqlDialects/types.ts +++ b/src/lib/sqlDialects/types.ts @@ -55,9 +55,29 @@ export type SqlParameterPolicy = { atNamed?: boolean; question?: boolean; dollarPositional?: boolean; + dollarNamed?: boolean; template?: boolean; }; +/** A candidate bind token found by the shared SQL scanner. */ +export interface SqlParameterOccurrence { + from: number; + to: number; + id: string; + token: string; + syntax: 'colon' | 'at' | 'dollar-positional' | 'dollar-named' | 'question' | 'template'; + name: string; + ordinal?: number; +} + +/** Optional driver-owned filtering for syntax that is ambiguous in its dialect. */ +export interface SqlParameterStrategy { + filterOccurrences?: ( + sql: string, + occurrences: readonly SqlParameterOccurrence[], + ) => readonly SqlParameterOccurrence[]; +} + /** Placeholder policy with every flag decided. */ export type ResolvedSqlParameterPolicy = Required; diff --git a/src/windows/connection/QueryPanel.tsx b/src/windows/connection/QueryPanel.tsx index 1bd350c89..d73b5fa36 100644 --- a/src/windows/connection/QueryPanel.tsx +++ b/src/windows/connection/QueryPanel.tsx @@ -18,9 +18,10 @@ import { useResizable } from '../../hooks/useResizable'; import { useCompactToolbar } from '../../hooks/useCompactToolbar'; import { queryToolbarExpandedMinWidth } from './queryToolbarWidth'; import { formatSql } from '../../lib/sqlFormat'; -import { paramsToPayload } from '../../lib/sqlBindParams'; +import { paramsToPayload, parseSqlParams } from '../../lib/sqlBindParams'; import { sqlEditorEnhancedEP, useExtension } from '@datazen/extension-points'; import { DB_REGISTRY } from '../../lib/databaseTypes'; +import { resolveSqlParameterPolicy } from '../../lib/sqlDialects/sqlParameterPolicy'; import { resolveExportScope } from '../../lib/exportCapability'; import { toQueryExecutionViewModel } from '../../lib/queryExecutionViewModel'; import { useConfirmDialog } from '../../hooks/useConfirmDialog'; @@ -112,7 +113,11 @@ export function QueryPanel({ const editorRef = useRef(null); const enhanced = useExtension(sqlEditorEnhancedEP); - const bindState = enhanced.useBindParameters?.(exec.sql) ?? { + const bindParameterPolicy = useMemo( + () => resolveSqlParameterPolicy(databaseType), + [databaseType], + ); + const bindState = enhanced.useBindParameters?.(exec.sql, bindParameterPolicy) ?? { params: [], values: {}, labels: {}, @@ -124,7 +129,16 @@ export function QueryPanel({ markSubmitted: () => {}, getHistory: () => [], }; - const sqlParams = bindState.params; + const parsedParamIds = useMemo( + () => + new Set(parseSqlParams(exec.sql, bindParameterPolicy).map((param) => param.stableId)), + [exec.sql, bindParameterPolicy], + ); + const sqlParams = useMemo( + () => + bindState.params.filter((param: { stableId: string }) => parsedParamIds.has(param.stableId)), + [bindState.params, parsedParamIds], + ); const paramValues = bindState.values; const paramLabels = bindState.labels; const paramValuesRef = useRef>({}); diff --git a/src/windows/connection/__tests__/QueryPanel.executeCancel.test.tsx b/src/windows/connection/__tests__/QueryPanel.executeCancel.test.tsx index 0cc7350d5..29b6efe15 100644 --- a/src/windows/connection/__tests__/QueryPanel.executeCancel.test.tsx +++ b/src/windows/connection/__tests__/QueryPanel.executeCancel.test.tsx @@ -144,10 +144,10 @@ extensionRegistry.register(sqlEditorEnhancedEP, { useBindParameters: (sql: string) => { const hasParam = sql.includes(':id'); return { - params: hasParam ? [{ name: 'id', stableId: 'id', syntax: 'colon' }] : [], + params: hasParam ? [{ name: 'id', stableId: 'named:id', syntax: 'colon' }] : [], values: {}, labels: hasParam ? { id: ':id' } : {}, - activeParamIds: hasParam ? ['id'] : [], + activeParamIds: hasParam ? ['named:id'] : [], paramHistory: {}, setValue: vi.fn(), applyHistoryEntry: vi.fn(), diff --git a/src/windows/connection/query/contracts.ts b/src/windows/connection/query/contracts.ts index b8c0de4c0..1a3314e40 100644 --- a/src/windows/connection/query/contracts.ts +++ b/src/windows/connection/query/contracts.ts @@ -8,6 +8,7 @@ import { useActiveConnectionStore } from '../../../stores/activeConnectionStore' import { useSchemaStore } from '../../../stores/schemaStore'; import { buildQueryDiagnosisContext, type RetryValidationInput } from '../../../lib/aiQueryActions'; import { parseSqlParams, paramsToPayload } from '../../../lib/sqlBindParams'; +import { resolveSqlParameterPolicy } from '../../../lib/sqlDialects/sqlParameterPolicy'; import type { ContentViewCallbacks } from './aiDraftBridge'; export interface QueryPanelProps { @@ -182,7 +183,7 @@ export function readCurrentQueryPanelRetryValidationInput( }); if (!latestContext.ok) return null; - const params = parseSqlParams(execution.sql); + const params = parseSqlParams(execution.sql, resolveSqlParameterPolicy(panel.databaseType)); const boundParams = params.length > 0 ? paramsToPayload(params, paramValues) : {}; return { diff --git a/src/windows/connection/query/useQueryExecutionGate.tsx b/src/windows/connection/query/useQueryExecutionGate.tsx index d9aba6454..f993d5489 100644 --- a/src/windows/connection/query/useQueryExecutionGate.tsx +++ b/src/windows/connection/query/useQueryExecutionGate.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'; import { queryCommands } from '../../../commands/query'; import { usePanelStore } from '../../../stores/panelStore'; import { paneArgs, paneKey } from '../../../stores/paneKeys'; @@ -21,6 +21,7 @@ import type { SqlEditorHandle } from '../../../components/SqlEditor'; import type { BindParams } from '../../../stores/queryExecActions'; import type { ExecuteKind, PendingExecute } from './contracts'; import { hasSuspiciousPostgresDoubleQuotedLiteral } from './contracts'; +import { resolveSqlParameterPolicy } from '../../../lib/sqlDialects/sqlParameterPolicy'; export interface ExecutionSnapshot { panelId: string; @@ -75,6 +76,10 @@ export function useQueryExecutionGate({ showMessageDialog, onExecutionComplete, }: UseQueryExecutionGateOptions) { + const bindParameterPolicy = useMemo( + () => resolveSqlParameterPolicy(databaseType), + [databaseType], + ); const { t } = useI18n(); const [confirmDangerous, confirmDangerousDialog] = useConfirmDialog(); const [txUnclosedOpen, setTxUnclosedOpen] = useState(false); @@ -193,7 +198,7 @@ export function useQueryExecutionGate({ : editorRef.current?.getSelection()?.trim() || sql; // Defense-in-depth: assert no missing parameters before substitution - const runMissing = findMissingParams(targetRawSql, rawValues); + const runMissing = findMissingParams(targetRawSql, rawValues, bindParameterPolicy); if (runMissing.length > 0) { showMessageDialog( t('query.editor.param.missingValue', { token: runMissing[0].label }), @@ -202,7 +207,11 @@ export function useQueryExecutionGate({ return; } - const targetSubstitutedSql = substituteSqlParams(targetRawSql, rawValues); + const targetSubstitutedSql = substituteSqlParams( + targetRawSql, + rawValues, + bindParameterPolicy, + ); if (kind === 'selection' && selectionSql != null) { await storeExecuteSelection( @@ -243,6 +252,7 @@ export function useQueryExecutionGate({ [ sql, databaseType, + bindParameterPolicy, panelId, paneId, autoCommit, @@ -430,7 +440,7 @@ export function useQueryExecutionGate({ // 2. Parameter validation (Host autonomous check, blocking execution on missing params) const effectiveValues = (paramValues ?? snapshotPayload ?? {}) as Record; - const missing = findMissingParams(sqlForCheck, effectiveValues); + const missing = findMissingParams(sqlForCheck, effectiveValues, bindParameterPolicy); if (missing.length > 0) { const firstMissing = missing[0]; showMessageDialog( @@ -505,6 +515,7 @@ export function useQueryExecutionGate({ boundPayload, paramValues, sqlExecutionStrategy, + bindParameterPolicy, ], ); From 9e75258ab7be9ac5dc2087e21efacf1f10437143 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 17:06:19 +0800 Subject: [PATCH 046/666] test(sqlserver): expect missing object ddl error --- .../sqlserver/tests/live_admin_and_sync.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/drivers/sqlserver/tests/live_admin_and_sync.rs b/packages/drivers/sqlserver/tests/live_admin_and_sync.rs index dab5d67a8..851b70d3b 100644 --- a/packages/drivers/sqlserver/tests/live_admin_and_sync.rs +++ b/packages/drivers/sqlserver/tests/live_admin_and_sync.rs @@ -1772,21 +1772,23 @@ async fn schema_object_commands_are_wired_and_their_sql_runs_live() { .unwrap_or_else(|| panic!("objects must be an array: {}", objects.data)); eprintln!("✅ list_objects(view) command → {} object(s)", listed.len()); - // A missing object is a well-formed empty DDL, not an error. - let ddl = driver + // A missing object must fail closed rather than returning an empty or + // ambiguous definition that the caller could mistake for usable DDL. + let error = driver .execute_command( &handle, "get_object_ddl", json!({ "kind": "procedure", "name": "dz_definitely_absent", "schema": "dbo" }), ) .await - .expect("get_object_ddl must execute for a missing object"); + .expect_err("get_object_ddl must reject a missing object"); assert!( - ddl.data["ddl"].is_string(), - "get_object_ddl must return a `ddl` string: {}", - ddl.data + error + .to_string() + .contains("Object was not found or its DDL is unavailable"), + "get_object_ddl should report a missing definition clearly: {error}" ); - eprintln!("✅ get_object_ddl(missing) → ddl={:?}", ddl.data["ddl"]); + eprintln!("✅ get_object_ddl(missing) rejected safely: {error}"); let _ = driver.disconnect(handle).await; } From 8496a3bf42f8a51b09306165d2240f379ac113af Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 17:06:26 +0800 Subject: [PATCH 047/666] docs: index driver test coverage matrix --- docs/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/README.md b/docs/README.md index b6231c5c9..0f822424d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,6 +67,7 @@ DataZen 文档只保留两类长期有效内容:**当前功能使用文档** - [E2E Testing](development/e2e-testing.md) - [E2E Coverage](development/e2e-coverage.md) +- [驱动测试覆盖矩阵](development/driver-test-coverage.md) - [E2E IPC Migration](development/e2e-ipc-migration-guide.md) - [CI Test Matrix](development/ci-test-matrix.md) - [CI Private Drivers](development/ci-private-drivers.md) From 1145b2523ffc857cc96dccc4997276c73c197f49 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 17:06:35 +0800 Subject: [PATCH 048/666] refactor: unify schema metadata access --- docs/architecture/backend/cache.md | 25 +- docs/architecture/backend/drivers.md | 13 +- docs/architecture/backend/services.md | 11 + docs/architecture/frontend/components.md | 17 +- docs/architecture/frontend/state.md | 14 + docs/development/e2e-coverage.md | 2 +- e2e/schema-metadata.ts | 33 + e2e/specs/backup-database.ts | 5 +- e2e/specs/bugfix-admin-commands.ts | 71 +- e2e/specs/editor-features-screenshots.ts | 3 +- e2e/specs/er-diagram.ts | 3 +- e2e/specs/navigator-context-menu.ts | 5 +- e2e/specs/ops-ddl-backup.ts | 2 +- e2e/specs/sql-run-mode-screenshot.ts | 3 +- e2e/specs/zz-screenshots.ts | 13 +- packages/driver-api/src/lib.rs | 1 + packages/driver-api/src/schema_metadata.rs | 84 ++ .../__tests__/pathItemsCache.test.ts | 16 +- .../driver-sdk/__tests__/schemaClient.test.ts | 47 + .../schemaStoreBridge.bound.test.tsx | 170 ++-- packages/driver-sdk/src/index.ts | 12 +- packages/driver-sdk/src/ipc/schemaClient.ts | 34 + packages/driver-sdk/src/schemaStoreBridge.ts | 78 +- .../driver-sdk/src/types/schemaMetadata.ts | 118 +++ packages/drivers/postgres/src/schema.rs | 87 +- .../ui/__tests__/columnHeaderHeight.test.tsx | 6 +- .../ui/__tests__/dirtyLeaveCoverage.test.tsx | 4 +- .../ui/__tests__/dirtyLeaveJourney.test.tsx | 4 +- .../keyTreeBreadcrumbKeyboardJourney.test.tsx | 6 +- .../ui/__tests__/keyTreeCreateKey.test.tsx | 8 +- .../keyTreeInteractionsJourney.test.tsx | 6 +- .../ui/__tests__/keyTreeJourney.test.tsx | 4 +- .../__tests__/keyTreePatternFilter.test.tsx | 4 +- .../__tests__/keyTreeTesterCoverage.test.tsx | 4 +- .../ui/__tests__/keyTreeTesterGaps.test.tsx | 4 +- .../redis/ui/__tests__/kvSlotRelay.test.tsx | 9 +- .../redis/ui/__tests__/round2Probe.test.tsx | 4 +- .../ui/__tests__/testerRound3Probe.test.tsx | 4 +- .../redis/ui/key-browser/RedisWorkbench.tsx | 11 +- src-tauri/src/bootstrap/run.rs | 6 - src-tauri/src/cache/schema_cache.rs | 222 +++-- .../src/commands/driver_command/discovery.rs | 10 +- .../src/commands/driver_command/execute.rs | 32 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/schema.rs | 887 +----------------- src-tauri/src/commands/schema/tests.rs | 617 ++++++++++++ src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/schema_metadata.rs | 262 ++++++ .../src/services/schema_metadata/columns.rs | 136 +++ .../src/services/schema_metadata/tests.rs | 239 +++++ src-tauri/src/testing/mock_driver.rs | 11 + .../__tests__/database.metadata.test.ts | 47 + src/commands/database.ts | 56 +- src/components/ai/ContextPicker.tsx | 2 +- .../ai/__tests__/ContextPicker.test.tsx | 2 +- .../sql-editor/completion/schemaCompletion.ts | 4 +- .../metadata/__tests__/metadataCache.test.ts | 50 + src/lib/__tests__/ensureNamespace.test.ts | 54 +- src/lib/__tests__/schemaCache.test.ts | 72 +- src/lib/__tests__/sqlFileExecution.test.ts | 49 +- src/lib/__tests__/tableSchemaForSql.test.ts | 180 ++-- src/lib/databaseMeta.ts | 2 +- src/lib/ensureNamespace.ts | 16 +- src/lib/relationMetadata/metadataCache.ts | 80 +- src/lib/schemaCache.ts | 200 +--- src/lib/schemaResourceCache.ts | 49 + src/lib/sqlFileExecution.ts | 6 +- src/lib/tableSchemaForSql.ts | 87 +- .../__tests__/schemaColumnLoader.test.ts | 67 ++ src/stores/__tests__/schemaStore.test.ts | 738 ++++++++------- .../__tests__/schemaStoreJourneys.test.ts | 193 ++++ src/stores/__tests__/schemaStoreState.test.ts | 69 +- src/stores/schemaColumnLoader.ts | 91 ++ src/stores/schemaRequestTracker.ts | 37 + src/stores/schemaStore.ts | 474 ++++------ src/stores/schemaStoreHelpers.ts | 2 +- src/stores/schemaStoreState.ts | 81 +- src/types/index.ts | 68 +- src/windows/backup/BackupWindow.tsx | 3 +- .../backup/__tests__/BackupWindow.test.tsx | 169 ++-- src/windows/connection/ContentView.tsx | 11 +- .../connection/DocumentConnectionView.tsx | 8 +- src/windows/connection/ExportDialog.tsx | 4 +- .../connection/PanelContentRenderer.tsx | 4 +- src/windows/connection/QueryPanel.tsx | 55 +- .../ConnectionNavigatorTree.test.tsx | 84 +- .../QueryPanel.dangerousSql.test.tsx | 8 +- .../QueryPanel.executeCancel.test.tsx | 8 +- .../__tests__/QueryPanel.paneRouting.test.tsx | 8 +- .../QueryPanel.resultMessageDialog.test.tsx | 8 +- .../__tests__/QueryPanelHistory.test.tsx | 8 +- .../__tests__/TableStructureEditor.test.tsx | 12 +- .../navigatorTreeRowAriaEquivalence.test.ts | 4 +- .../__tests__/query.modules.test.tsx | 45 +- .../navigator/useNavigatorContextMenus.ts | 39 +- .../navigator/useNavigatorDbState.ts | 49 +- .../query/QueryBuilderHostAdapter.tsx | 64 +- .../connection/query/QuerySidebarSection.tsx | 21 +- src/windows/connection/query/contracts.ts | 18 +- .../query/toolbar/RefreshCompletionButton.tsx | 12 +- .../useExpandedDbCacheRefresh.test.tsx | 4 +- .../schema-tree/useExpandedDbCacheRefresh.ts | 2 +- .../connection/useConnectionContextMenu.ts | 4 +- .../connection/useConnectionWorkspaceMeta.ts | 7 +- src/windows/connection/usePanelHandlers.ts | 2 +- src/windows/data-sync/DataSyncWindow.tsx | 4 +- .../__tests__/DataSyncWindow.test.tsx | 25 +- src/windows/schema-diff/SchemaDiffWindow.tsx | 4 +- .../__tests__/SchemaDiffProfileLoad.test.tsx | 12 +- .../__tests__/SchemaDiffWizard.test.tsx | 10 +- .../__tests__/useSchemaDiffEndpoints.test.tsx | 8 +- .../schema-diff/schemaDiffTableNames.ts | 7 +- .../schema-diff/useSchemaDiffEndpoints.ts | 4 +- .../schema-diff/useSchemaDiffSavedSetups.ts | 4 +- 114 files changed, 4073 insertions(+), 2811 deletions(-) create mode 100644 e2e/schema-metadata.ts create mode 100644 packages/driver-api/src/schema_metadata.rs create mode 100644 packages/driver-sdk/__tests__/schemaClient.test.ts create mode 100644 packages/driver-sdk/src/ipc/schemaClient.ts create mode 100644 packages/driver-sdk/src/types/schemaMetadata.ts create mode 100644 src-tauri/src/commands/schema/tests.rs create mode 100644 src-tauri/src/services/schema_metadata.rs create mode 100644 src-tauri/src/services/schema_metadata/columns.rs create mode 100644 src-tauri/src/services/schema_metadata/tests.rs create mode 100644 src/commands/__tests__/database.metadata.test.ts create mode 100644 src/lib/schemaResourceCache.ts create mode 100644 src/stores/__tests__/schemaColumnLoader.test.ts create mode 100644 src/stores/__tests__/schemaStoreJourneys.test.ts create mode 100644 src/stores/schemaColumnLoader.ts create mode 100644 src/stores/schemaRequestTracker.ts diff --git a/docs/architecture/backend/cache.md b/docs/architecture/backend/cache.md index db019c81e..ed4f8779f 100644 --- a/docs/architecture/backend/cache.md +++ b/docs/architecture/backend/cache.md @@ -765,27 +765,8 @@ impl QueryExecutor { 结论: 内存开销极小,完全可以接受 ``` -### 1.8 读 Schema 前必须 pin 会话数据库(BUG-003 复盘) +### 1.8 Schema 元数据缓存身份 -驱动契约是**分裂**的(见 [drivers.md](drivers.md) §2.1): +Schema 读取通过 `list_catalog`、`read_relation_columns` 与 `read_relation_schema` Driver Commands 进入 Host 元数据网关。关系由 `RelationRef { database, schema, name }` 精确标识;驱动的目录与列读取 API 都接收 database 和可选 schema 参数,不通过切换会话 active database 来间接指定目标。网关校验驱动的 schema 能力后,以完整关系身份读写缓存,避免跨数据库或跨 schema 的同名关系串用缓存。 -| 方法 | database 维度 | 谁负责解析 | -| --- | --- | --- | -| `get_tables(handle, database)` / `get_all_columns(handle, database)` | 显式参数 | 驱动自己 | -| `get_table_schema(handle, table)` / `get_columns(handle, table)` | **无** | **调用方必须先 pin** | - -后两者只能落在会话当前 active 的库上,所以**任何 schema 读取前都必须先 `ConnectionManager::ensure_active_database(db_session_id, database)`**(Host 命令侧包装为 `ensure_session_database`,AI schema context 直接调前者)。 - -漏掉这一步时,PG 驱动的 `information_schema.columns` 在错误的库里查不到该表,于是返回 `Ok(TableSchema { columns: [] })`——"表存在但没有列"。`SchemaCache` 再把它写进 `connection::database::table` 键,TTL(300s)内所有读该表的路径都会拿到空列: - -- 表结构视图不显示列(DDL 视图正常,它不走这个缓存); -- ER 图只有表名、没有列; -- 数据网格行数正确但单元格全空(`build_select_sql` 见列集为空会退化成 `SELECT *`,`rowsToRecords([], rows)` 把每行映射成 `{}`)。 - -典型触发顺序:选中库后**先打开 ER 图**(`ErDiagramView` 挂载即拉 `get_er_data`),此时会话还停在别的库 → 一次读取污染整库全部表。因此现象是"顺序依赖"而非"库依赖"。 - -三层防护: - -1. **pin**:所有 schema 读取入口先 pin(Host 命令 + AI schema context); -2. **驱动不静默**:PG 在 `information_schema.columns` 无行时用 `pg_class` 校验关系是否真的存在,不存在则返回 `DriverError`,而不是空 schema(`CREATE TABLE t ()` 这类真·零列表仍返回空 schema); -3. **缓存不落空**:`SchemaCache` 不写入、也不命中 `columns.is_empty()` 的条目(一律视为 miss,并 `tracing::warn!` 留痕),把任何未来的"静默空"限制为单次请求。 +`refresh_schema_metadata` 支持 session、database 和 relation 三种失效范围,并返回缓存 generation。每次读取记录启动时的 generation,刷新之后开始的读可以填充新缓存,刷新前启动的读不能把过期结果重新写回。PostgreSQL 对“关系存在但列为空”与“目标关系不存在”分别处理;Host 的列读取结果也逐关系携带成功值或错误,避免批量读取中一个关系失败丢失其他结果。接口与类型见 [services.md](services.md) 与 [drivers.md](drivers.md) §2.1。 diff --git a/docs/architecture/backend/drivers.md b/docs/architecture/backend/drivers.md index 2aa0900a4..d752ac0f7 100644 --- a/docs/architecture/backend/drivers.md +++ b/docs/architecture/backend/drivers.md @@ -22,7 +22,7 @@ DataZen 的 Driver 是**编译期集成**的数据库实现,不通过运行时 当前 API: -- `PROTOCOL_VERSION = 3` +- `PROTOCOL_VERSION = 4` - `MIN_PROTOCOL_VERSION = 1` ## 2. DatabaseDriver @@ -51,16 +51,11 @@ cleanup_query_execution 只有实际声明精确取消能力的 Driver 才会被 Host 当作 cancellable;兼容默认实现不会自动获得取消能力。 -### 2.1 database 维度的两套契约(重要) +### 2.1 Schema 元数据目标 -`database` 在两个方法族里语义不同,调用方必须区分: +`get_tables`、`get_table_schema`、`get_columns` 和 `get_all_columns` 都接收显式 `database` 与可选 `schema`,驱动按传入目标读取,不依赖会话当前选中的数据库。Host 的 `list_catalog`、`read_relation_columns`、`read_relation_schema` 和 `refresh_schema_metadata` Driver Commands 以 `RelationRef { database, schema, name }` 表达目标;前端与扩展共用 Driver Command 网关,不再需要按数据库类型选择旧 Host schema IPC。 -| 方法 | database 维度 | 谁负责解析 | -| --- | --- | --- | -| `get_tables(handle, database)` / `get_all_columns(handle, database)` | **显式参数** | 驱动自己(PG 对非 active 库临时开 pool 后关闭) | -| `get_table_schema(handle, table)` / `get_columns(handle, table)` | **无参数**,隐含依赖会话 active 库 | **调用方必须先 pin** | - -Host 侧统一用 `ConnectionManager::ensure_active_database`(命令层包装为 `ensure_session_database`)在读取前 pin 会话;漏掉就会从别的库拿到答案(详见 [cache.md](cache.md) §1.8)。彻底解法是给后两个方法补上 database 参数,但那属于 Driver API 契约变更(`PROTOCOL_VERSION` + 所有驱动同步),目前以"调用方 pin + 驱动不静默"为约定。 +有 schema 层级的驱动要求读取时提供精确 schema;schema-less 驱动使用 `null`。会话身份由 command envelope 的 `dbSessionId` 提供,与持久化连接配置 ID 分开传递。Host 缓存按 session、database、schema 和 relation 名称隔离,刷新支持 session、database 或 relation 范围。详见 [services.md](services.md) 的 Schema Metadata Gateway 与 [cache.md](cache.md) §1.8。 ### 2.2 分页契约(PaginationSyntax) diff --git a/docs/architecture/backend/services.md b/docs/architecture/backend/services.md index 73a7ea512..4ba36b7e7 100644 --- a/docs/architecture/backend/services.md +++ b/docs/architecture/backend/services.md @@ -10,6 +10,7 @@ Services 是 DataZen backend 中复用性的运行时服务层,不承担 Tauri |---|---|---| | ConnectionManager | `connection_manager.rs` | Driver 选择、连接建立、session 生命周期、引用计数、idle eviction、隧道解析(`resolve_tunnel_ref` / `maybe_start_tunnel`) | | QueryExecutor | `query_executor.rs` | 查询执行相关的参数、过滤、排序和执行辅助 | +| Schema metadata | `schema_metadata.rs` / `schema_metadata/columns.rs` | 精确关系身份读取、列批量读取、缓存刷新 | | DbTools | `db_tools.rs` | 数据库工具类复用能力 | | JobRegistry | `job_registry.rs` | 长任务/job 生命周期和取消注册 | | Transaction | `transaction.rs` | 事务相关运行时辅助 | @@ -60,3 +61,13 @@ Concrete Drivers ``` 持久化通过 Store;Services 不把 React/Zustand 状态下沉到 Rust。 + +## 6. Schema metadata + +宿主在 `execute_driver_command` 网关注册 `list_catalog`、`read_relation_columns`、`read_relation_schema` 和 `refresh_schema_metadata`。会话通过 envelope 的 `dbSessionId` 指定,database/schema/name 全部放在 input 的 `RelationRef` 中;这组命令拒绝重复的 envelope database/schema,避免目标冲突。 + +`RelationRef` 的 schema 必须显式为字符串或 null。具有 schema 层级的驱动要求精确 schema,读取不会通过 `use_database` 改变会话上下文。目录响应分开返回 schema 名称与关系条目,空 schema 不再伪装成空名称关系。 + +列读取每次最多接受 256 个目标,按完整身份去重,响应按请求顺序为各关系返回 `ok` 或 `error`。同一 database/schema 的未缓存目标可以使用驱动批量读取;驱动不支持批量读取时使用逐关系读取。完整结构缓存同时填充列缓存;失败信息经过凭据脱敏。 + +刷新支持 session、database 或精确 relation 范围,返回缓存 revision。缓存写入检查读取开始时的 generation,刷新前启动的读取不能重新填入缓存。旧的六个 Host schema IPC 已删除。查询数据和 ER 图聚合继续使用各自的 Host 命令;AI、Workflow 与驱动扩展通过 Driver Command 访问标准元数据命令。新 metadata commands 与这些入口共用 `SchemaCache`。 diff --git a/docs/architecture/frontend/components.md b/docs/architecture/frontend/components.md index 93ff483b9..9feb6402b 100644 --- a/docs/architecture/frontend/components.md +++ b/docs/architecture/frontend/components.md @@ -448,18 +448,15 @@ export const connectionCommands = { ``` ```typescript -// commands/database.ts -import { invoke } from '@tauri-apps/api/core'; - -export const databaseCommands = { - getDatabases: (dbSessionId: string) => invoke('get_databases', { dbSessionId }), +// 元数据契约:packages/driver-sdk/src/ipc/schemaClient.ts +import { schemaClient } from '@datazen/driver-sdk'; - getTables: (dbSessionId: string, database: string) => - invoke('get_tables', { dbSessionId, database }), +const scope = { database: 'app', schema: 'public' }; +const catalog = await schemaClient.listCatalog(dbSessionId, scope); +const relation = { ...scope, name: 'users' }; +const { value } = await schemaClient.readSchema(dbSessionId, relation); +// value.ref 是目标身份,value.definition 是完整结构。 - getTableSchema: (dbSessionId: string, table: string) => - invoke('get_table_schema', { dbSessionId, table }), -}; ``` ```typescript diff --git a/docs/architecture/frontend/state.md b/docs/architecture/frontend/state.md index 214764be5..961f63416 100644 --- a/docs/architecture/frontend/state.md +++ b/docs/architecture/frontend/state.md @@ -73,3 +73,17 @@ Tauri 多窗口的 WebView 不共享 Zustand 内存。需要同步的数据通 ## 6. 测试 Store tests 位于 `src/stores/__tests__/`;窗口级测试位于对应 `src/windows/**/__tests__/`。 + +## 7. Schema 身份与异步读取 + +公共元数据类型由 `packages/driver-sdk/src/types/schemaMetadata.ts` 定义,宿主 `src/types/index.ts` 重导出兼容类型。`schemaClient` 使用统一 Driver Command 网关,列读取返回带完整 `RelationRef` 的结果;列名与类型从同一份列定义派生。 + +`schemaStore` 的会话状态保存按 database 分组的 `tableCatalogs` 和按 session/database/schema/name 编码的 `relationColumns`。`relationKey` 使用 JSON tuple,名称中的分隔符不会导致身份碰撞。列名和类型只保存于 `relationColumns`,编辑器通过 `useConnectionColumnMaps` 在消费边界按上下文派生,投影使用 memo 保持引用稳定;同名关系身份不唯一时不猜测 schema。Query Builder 从绑定 database/schema 的关系缓存取值。 + +`schemaRequestTracker` 为目录请求和会话生命周期提供写入屏障。切库、断连、表目录替换后,失效请求不能改写新状态;后台目录发布只更新目标 database 快照。SDK bridge 显式传递 session,不通过切换 active session 发布数据。 + +结构与 DDL 缓存共用 `schemaResourceCache` 的 TTL、失败退避和请求去重。失效操作移除缓存与在途请求,旧 promise 的返回和清理不能覆盖或删除新请求。编辑器关系缓存另外检查 database/schema 上下文与请求身份。显式刷新补全先失效前端缓存,再调用后端刷新,并在成功后重新加载目录。 + +`schemaStore` 顶层只持有 `schemas`、`activeDbSessionId` 和 action,不复制活动会话字段,也不维护默认会话或 `dbSessionId` 别名。所有会话 action 必须显式传入 `dbSessionId`;切换活动指针独立于更新会话数据。组件通过 `useConnectionSchemaField` 读取绑定会话,缺失会话返回稳定的空快照。 + +宿主目录调用方通过 `databaseCommands.listTables` 接入 `schemaClient.listCatalog`,database 列表通过 `schemaClient.listDatabases` 获取。`TableInfo[]` 转换与空 schema sentinel 只在旧树模型适配边界生成。结构视图、索引、外键、导出、结构编辑器与 Query Builder 共用 `getCachedTableSchema`,其传输入口为 `read_relation_schema`。SQL 生成使用精确裸名称与独立 schema,完整结构不可用时读取同一关系的 typed columns;不再按数据库类型拼接名称并重试。QueryPanel 通过 `useConnectionSchemaField` 读取绑定 session 的字段。 diff --git a/docs/development/e2e-coverage.md b/docs/development/e2e-coverage.md index 77dda753b..6824c2d65 100644 --- a/docs/development/e2e-coverage.md +++ b/docs/development/e2e-coverage.md @@ -109,7 +109,7 @@ | 备份执行(IPC) | `backup-database.ts` | Covered | | 恢复:覆盖确认 + 分步进度 | `backup-database.ts` (BACKUP-012) + `BackupWindow.test.tsx` | Covered(Web ConfirmDialog;打开文件见例外) | | 新建查询不弹出对象加载补全框 | `connection-window.ts` (SQ-AC-001), `mysql.ts` (MY-AC-001) | Covered | -| SQL 补全只拉取语句中已加载完整表名的列(禁止前缀 get_columns) | `schemaStore.test.ts` / `sqlEditorDefaults.test.ts` / `buildEditorSchema.test.ts` | Covered | +| SQL 补全只拉取语句中已加载完整表名的列(禁止前缀列读取请求) | `schemaStore.test.ts` / `sqlEditorDefaults.test.ts` / `buildEditorSchema.test.ts` | Covered | | Schema Diff 窗口 + 限制说明 + 同源异库 / 自动计划 / 复制·导入·导出配置 | `schema-diff-window.ts` (SD-001~009, SD-LIM) | Covered | | 结构对比宽类型 **19 列** + 多表 **10 张** | `schema-diff-diverse-types.ts` (SD-COMP) | Covered | | 结构对比 PG↔MySQL 跨方言 deploy | `schema-diff-cross-dialect.ts` (SD-CROSS) | Covered | diff --git a/e2e/schema-metadata.ts b/e2e/schema-metadata.ts new file mode 100644 index 000000000..181fc3ee6 --- /dev/null +++ b/e2e/schema-metadata.ts @@ -0,0 +1,33 @@ +import { invokeBackend } from './helpers.js'; + +export interface CatalogMetadata { + database: string; + schemas: string[]; + relations: Array<{ + ref: { database: string; schema: string | null; name: string }; + kind: string; + rowCount: number | null; + }>; +} + +export async function listDatabases(target: { dbSessionId: string }): Promise { + const result = await invokeBackend<{ data: { databases: string[] } }>('execute_driver_command', { + request: { dbSessionId: target.dbSessionId, command: 'list_databases', input: {} }, + }); + return result.data.databases; +} + +export async function readCatalog(target: { + dbSessionId: string; + database: string; + schema?: string | null; +}): Promise { + const result = await invokeBackend<{ data: CatalogMetadata }>('execute_driver_command', { + request: { + dbSessionId: target.dbSessionId, + command: 'list_catalog', + input: { database: target.database, schema: target.schema ?? null }, + }, + }); + return result.data; +} diff --git a/e2e/specs/backup-database.ts b/e2e/specs/backup-database.ts index b7f19f2c8..b1f7c2bbf 100644 --- a/e2e/specs/backup-database.ts +++ b/e2e/specs/backup-database.ts @@ -1,3 +1,4 @@ +import { listDatabases } from '../schema-metadata.js'; import { expect, browser } from '@wdio/globals'; import path from 'node:path'; import fs from 'node:fs'; @@ -120,8 +121,8 @@ describe('数据库备份功能 (BACKUP)', () => { expect(dbSessionId.length).toBeGreaterThan(0); }); - it('BACKUP-002: get_databases returns database list', async () => { - const dbs = await invokeBackend('get_databases', { dbSessionId }); + it('BACKUP-002: list_databases returns database list', async () => { + const dbs = await listDatabases({ dbSessionId }); expect(Array.isArray(dbs)).toBe(true); expect(dbs.length).toBeGreaterThan(0); expect(dbs).toContain(PG_CONFIG.database); diff --git a/e2e/specs/bugfix-admin-commands.ts b/e2e/specs/bugfix-admin-commands.ts index 103bdf889..b931d32d2 100644 --- a/e2e/specs/bugfix-admin-commands.ts +++ b/e2e/specs/bugfix-admin-commands.ts @@ -1,9 +1,10 @@ +import { listDatabases, readCatalog } from '../schema-metadata.js'; /** * E2E tests for admin command bugfixes — IPC-based approach. * * Creates connections via save_connection IPC, connects via IPC, * and verifies admin commands work correctly. - * After each operation, re-calls get_databases / get_tables to verify + * After each operation, re-calls list_databases / list_catalog to verify * that the backend returns updated results (simulating the frontend * refresh flow: loadForConnection → getDatabases; loadTables → getTables). * @@ -104,7 +105,7 @@ describe('MySQL admin commands (IPC)', () => { it('should create a new database and list it', async function () { if (!connId) return this.skip(); - const dbsBefore = await invokeBackend('get_databases', { dbSessionId: connId }); + const dbsBefore = await listDatabases({ dbSessionId: connId }); await invokeBackend('execute_driver_command', { request: { @@ -114,7 +115,7 @@ describe('MySQL admin commands (IPC)', () => { }, }); - const dbsAfter = await invokeBackend('get_databases', { dbSessionId: connId }); + const dbsAfter = await listDatabases({ dbSessionId: connId }); expect(dbsAfter).toContain(MYSQL_TEST_DB); expect(dbsAfter.length).toBeGreaterThan(dbsBefore.length); }); @@ -139,9 +140,9 @@ describe('MySQL admin commands (IPC)', () => { it('should show new DB after simulated loadForConnection refresh', async function () { if (!connId) return this.skip(); - // Simulate the frontend refresh flow: get_databases is session-neutral + // Simulate the frontend refresh flow: list_databases is session-neutral // (F1 removed use_database) → must include the new DB. - const dbs = await invokeBackend('get_databases', { dbSessionId: connId }); + const dbs = await listDatabases({ dbSessionId: connId }); expect(dbs).toContain(MYSQL_TEST_DB); expect(dbs.length).toBeGreaterThanOrEqual(2); }); @@ -149,7 +150,7 @@ describe('MySQL admin commands (IPC)', () => { it('should show ALL databases (not locked to one) when no preferred DB', async function () { if (!connId) return this.skip(); - const dbs = await invokeBackend('get_databases', { dbSessionId: connId }); + const dbs = await listDatabases({ dbSessionId: connId }); expect(dbs.length).toBeGreaterThanOrEqual(2); expect(dbs).toContain(MYSQL_TEST_DB); }); @@ -157,14 +158,14 @@ describe('MySQL admin commands (IPC)', () => { it('should list tables for a specific database via explicit param', async function () { if (!connId) return this.skip(); - // F1: get_tables takes the database explicitly — no use_database needed. - const tables = await invokeBackend<{ name: string }[]>('get_tables', { + // F1: list_catalog takes the database explicitly — no use_database needed. + const tables = await readCatalog({ dbSessionId: connId, database: MYSQL_TEST_DB, }); // New DB has no tables yet — just verify it doesn't error expect(tables).toBeDefined(); - expect(Array.isArray(tables)).toBe(true); + expect(Array.isArray(tables.relations)).toBe(true); }); it('should drop a database via driver command', async function () { @@ -179,7 +180,7 @@ describe('MySQL admin commands (IPC)', () => { }, }); - const dbsBefore = await invokeBackend('get_databases', { dbSessionId: connId }); + const dbsBefore = await listDatabases({ dbSessionId: connId }); expect(dbsBefore).toContain(dropDb); await invokeBackend('execute_driver_command', { @@ -190,7 +191,7 @@ describe('MySQL admin commands (IPC)', () => { }, }); - const dbsAfter = await invokeBackend('get_databases', { dbSessionId: connId }); + const dbsAfter = await listDatabases({ dbSessionId: connId }); expect(dbsAfter).not.toContain(dropDb); }); @@ -257,11 +258,11 @@ describe('PostgreSQL admin commands (IPC)', () => { }, }); - const dbs = await invokeBackend('get_databases', { dbSessionId: connId }); + const dbs = await listDatabases({ dbSessionId: connId }); expect(dbs).toContain(PG_TEST_DB); }); - it('should create a schema and return it via get_tables (simulating loadTables)', async function () { + it('should create a schema and return it via list_catalog (simulating loadTables)', async function () { if (!connId) return this.skip(); await invokeBackend('execute_driver_command', { @@ -272,21 +273,16 @@ describe('PostgreSQL admin commands (IPC)', () => { }, }); - // Simulate frontend loadTables flow: get_tables with explicit database (F1) - const tablesResult = await invokeBackend<{ name: string; schema: string; tableType: string }[]>( - 'get_tables', - { dbSessionId: connId, database: PG_DB }, - ); + // Simulate frontend loadTables flow: list_catalog with explicit database (F1) + const tablesResult = await readCatalog({ dbSessionId: connId, database: PG_DB }); - // Verify new schema appears (either via SCHEMA_MARKER or table entries) - const allSchemas = [...new Set(tablesResult.map((t) => t.schema).filter(Boolean))]; + // Verify empty schemas are returned independently from relations. + const allSchemas = tablesResult.schemas; expect(allSchemas).toContain(PG_TEST_SCHEMA); expect(allSchemas).toContain('public'); - // Verify schemaNames extraction (same logic as frontend setLoadedTables) - const schemaNames = [ - ...new Set(tablesResult.map((t) => t.schema).filter((s): s is string => !!s)), - ]; + // Verify schemaNames extraction (explicit catalog schema list) + const schemaNames = tablesResult.schemas; expect(schemaNames).toContain(PG_TEST_SCHEMA); expect(schemaNames.length).toBeGreaterThanOrEqual(2); }); @@ -295,15 +291,12 @@ describe('PostgreSQL admin commands (IPC)', () => { if (!connId) return this.skip(); // Simulate full loadForConnection → getDatabases → loadTables flow - const dbs = await invokeBackend('get_databases', { dbSessionId: connId }); + const dbs = await listDatabases({ dbSessionId: connId }); expect(dbs).toContain(PG_DB); - const tablesResult = await invokeBackend<{ name: string; schema: string; tableType: string }[]>( - 'get_tables', - { dbSessionId: connId, database: PG_DB }, - ); + const tablesResult = await readCatalog({ dbSessionId: connId, database: PG_DB }); - const schemas = [...new Set(tablesResult.map((t) => t.schema).filter(Boolean))]; + const schemas = tablesResult.schemas; expect(schemas).toContain('public'); expect(schemas).toContain(PG_TEST_SCHEMA); expect(schemas.length).toBeGreaterThanOrEqual(2); @@ -376,7 +369,7 @@ describe('PostgreSQL admin commands (IPC)', () => { }, }); - const dbsBefore = await invokeBackend('get_databases', { dbSessionId: connId }); + const dbsBefore = await listDatabases({ dbSessionId: connId }); expect(dbsBefore).toContain(dropDb); await invokeBackend('execute_driver_command', { @@ -387,7 +380,7 @@ describe('PostgreSQL admin commands (IPC)', () => { }, }); - const dbsAfter = await invokeBackend('get_databases', { dbSessionId: connId }); + const dbsAfter = await listDatabases({ dbSessionId: connId }); expect(dbsAfter).not.toContain(dropDb); }); @@ -452,11 +445,11 @@ describe('PostgreSQL admin commands (IPC)', () => { }, }); - const tablesBefore = await invokeBackend<{ name: string; schema: string }[]>('get_tables', { + const tablesBefore = await readCatalog({ dbSessionId: connId, database: PG_DB, }); - const schemasBefore = [...new Set(tablesBefore.map((t) => t.schema).filter(Boolean))]; + const schemasBefore = tablesBefore.schemas; expect(schemasBefore).toContain(dropSchema); await invokeBackend('execute_driver_command', { @@ -468,11 +461,11 @@ describe('PostgreSQL admin commands (IPC)', () => { }, }); - const tablesAfter = await invokeBackend<{ name: string; schema: string }[]>('get_tables', { + const tablesAfter = await readCatalog({ dbSessionId: connId, database: PG_DB, }); - const schemasAfter = [...new Set(tablesAfter.map((t) => t.schema).filter(Boolean))]; + const schemasAfter = tablesAfter.schemas; expect(schemasAfter).not.toContain(dropSchema); }); @@ -512,11 +505,11 @@ describe('PostgreSQL admin commands (IPC)', () => { }, }); - const tablesAfter = await invokeBackend<{ name: string; schema: string }[]>('get_tables', { + const tablesAfter = await readCatalog({ dbSessionId: connId, database: PG_DB, }); - const schemasAfter = [...new Set(tablesAfter.map((t) => t.schema).filter(Boolean))]; + const schemasAfter = tablesAfter.schemas; expect(schemasAfter).not.toContain(dropSchema); }); @@ -596,7 +589,7 @@ describe('PostgreSQL admin commands (IPC)', () => { it('should show created database after full refresh cycle', async function () { if (!connId) return this.skip(); - const dbs = await invokeBackend('get_databases', { dbSessionId: connId }); + const dbs = await listDatabases({ dbSessionId: connId }); expect(dbs).toContain(PG_TEST_DB); expect(dbs).toContain(PG_DB); expect(dbs.length).toBeGreaterThanOrEqual(2); diff --git a/e2e/specs/editor-features-screenshots.ts b/e2e/specs/editor-features-screenshots.ts index 46db76040..da73e8896 100644 --- a/e2e/specs/editor-features-screenshots.ts +++ b/e2e/specs/editor-features-screenshots.ts @@ -1,3 +1,4 @@ +import { readCatalog } from '../schema-metadata.js'; /** * Editor Features Screenshot Generator. * @@ -310,7 +311,7 @@ describe('SQL Editor Latest Features Screenshots', () => { const connId = await invoke('connect', { connectionId: DEMO_PG_CONN_ID }); if (typeof connId === 'string' && !connId.startsWith('__error')) { - await invoke('get_tables', { dbSessionId: connId, database: DEMO_PG_DB }); + await readCatalog({ dbSessionId: connId, database: DEMO_PG_DB }); } // Set UI language to zh-CN, disable safeMode, and set strategy to current_statement diff --git a/e2e/specs/er-diagram.ts b/e2e/specs/er-diagram.ts index 973ef8afc..11d79fb60 100644 --- a/e2e/specs/er-diagram.ts +++ b/e2e/specs/er-diagram.ts @@ -1,3 +1,4 @@ +import { listDatabases } from '../schema-metadata.js'; /** * ER diagram full user journey — workspace home / toolbar entry, React Flow canvas, * controls, search, table/relation stats, and inferred relationships. @@ -204,7 +205,7 @@ describe('ER 图功能 E2E 测试 (ER-001~ER-013)', () => { database: null, }); try { - const databases = await invokeBackend('get_databases', { dbSessionId }); + const databases = await listDatabases({ dbSessionId }); expect(databases.length).toBeGreaterThan(0); const schemas = await invokeBackend('get_er_data', { diff --git a/e2e/specs/navigator-context-menu.ts b/e2e/specs/navigator-context-menu.ts index 4ba1cbcba..856cc5c48 100644 --- a/e2e/specs/navigator-context-menu.ts +++ b/e2e/specs/navigator-context-menu.ts @@ -1,3 +1,4 @@ +import { listDatabases } from '../schema-metadata.js'; /** * E2E tests for navigator tree context menus in ConnectionPage. * @@ -34,7 +35,7 @@ const SEEDED_CONN_ID = 'conn_e2e_pg'; * WDIO 全程复用同一个 Tauri 进程,而每个 spec 的 worker 数据库是按 spec * 创建后即删除的;Rust 侧 `connect` 对同一 connectionId 会复用仍存活的会话, * 于是本 spec 的 UI 连接拿到的是绑在已删除 worker 库上的旧会话, - * `execute_query` / `get_databases` 全部报 + * `execute_query` / `list_databases` 全部报 * `database "e2e_w..." does not exist`,before 钩子里的 * `pgTableExists(...)` 直接抛错导致整文件 before all hook 失败。 * 这里强制探测并断开残留会话,随后 UI 连接会基于本 spec 的 worker 库新建会话。 @@ -95,7 +96,7 @@ async function pgSchemaExistsInDatabase( } async function pgDatabaseExists(dbSessionId: string, database: string): Promise { - const dbs = await invokeBackend('get_databases', { dbSessionId }); + const dbs = await listDatabases({ dbSessionId }); return dbs.includes(database); } diff --git a/e2e/specs/ops-ddl-backup.ts b/e2e/specs/ops-ddl-backup.ts index a7c4c342e..746d94345 100644 --- a/e2e/specs/ops-ddl-backup.ts +++ b/e2e/specs/ops-ddl-backup.ts @@ -28,7 +28,7 @@ const SEEDED_CONN_ID = 'conn_e2e_pg'; * WDIO 全程复用同一个 Tauri 进程,而每个 spec 的 worker 数据库是按 spec * 创建后即删除的;Rust 侧 `connect` 对同一 connectionId 会复用仍存活的会话, * 于是本 spec 的 UI 连接拿到的是绑在已删除 worker 库上的旧会话, - * schema 树取数(get_databases 等)全部报 + * schema 树取数(list_databases 等)全部报 * `database "e2e_w..." does not exist`。这里强制探测并断开残留会话, * 随后 UI 连接会基于本 spec 的 worker 库新建会话。 */ diff --git a/e2e/specs/sql-run-mode-screenshot.ts b/e2e/specs/sql-run-mode-screenshot.ts index 8470235cb..eeac48814 100644 --- a/e2e/specs/sql-run-mode-screenshot.ts +++ b/e2e/specs/sql-run-mode-screenshot.ts @@ -1,3 +1,4 @@ +import { readCatalog } from '../schema-metadata.js'; /** * 34-sql-run-mode.png — the SQL editor's run mode: which statement to run. * @@ -313,7 +314,7 @@ describe('SQL Run Mode Screenshot', () => { // dead demo database downgrades this spec to a skip rather than a failure. const dbSessionId = await invoke('connect', { connectionId: DEMO_PG_CONN_ID }); if (typeof dbSessionId === 'string' && !dbSessionId.startsWith('__error')) { - await invoke('get_tables', { dbSessionId, database: DEMO_PG_DB }); + await readCatalog({ dbSessionId, database: DEMO_PG_DB }); } else { console.warn(`[run-mode] demo PostgreSQL not reachable: ${String(dbSessionId)}`); } diff --git a/e2e/specs/zz-screenshots.ts b/e2e/specs/zz-screenshots.ts index 276b5c755..d51f16ed3 100644 --- a/e2e/specs/zz-screenshots.ts +++ b/e2e/specs/zz-screenshots.ts @@ -1,3 +1,4 @@ +import { readCatalog } from '../schema-metadata.js'; /** * Site screenshot generator (temporary tooling, not a regression spec). * Drives the webdriver-enabled app and captures marketing screenshots @@ -738,7 +739,7 @@ async function selectQueryPanelDatabase(dbName: string) { ); }); } - await browser.pause(900); // allow get_tables refreshes to settle + await browser.pause(900); // allow list_catalog refreshes to settle await browser .waitUntil(async () => (await panelBoundDatabase()) === dbName, { timeout: 8000, @@ -778,7 +779,7 @@ async function selectQueryPanelDatabase(dbName: string) { /** * Verify the primary demo database is reachable so SQL / DataTable panels - * query demo_sales (F1: get_tables pins the database explicitly — the session + * query demo_sales (F1: list_catalog pins the database explicitly — the session * is switched lazily by query/stream/explain carrying `database`). */ async function pinDemoPgDatabase() { @@ -786,9 +787,9 @@ async function pinDemoPgDatabase() { if (typeof connId !== 'string' || connId.startsWith('__error')) { throw new Error(`connect(${DEMO_PG_CONN_NAME}) failed: ${JSON.stringify(connId)}`); } - const tables = await invoke('get_tables', { dbSessionId: connId, database: DEMO_PG_DB }); + const tables = await readCatalog({ dbSessionId: connId, database: DEMO_PG_DB }); if (tables && typeof tables === 'object' && '__error' in (tables as object)) { - throw new Error(`get_tables(${DEMO_PG_DB}) failed: ${JSON.stringify(tables)}`); + throw new Error(`list_catalog(${DEMO_PG_DB}) failed: ${JSON.stringify(tables)}`); } await browser.pause(300); } @@ -1363,9 +1364,9 @@ describe('site screenshots', () => { if (typeof connId !== 'string' || connId.startsWith('__error')) { throw new Error(`connect(${DEMO_PG_CONN_NAME}) failed: ${JSON.stringify(connId)}`); } - const tables = await invoke('get_tables', { dbSessionId: connId, database: DEMO_PG_DB }); + const tables = await readCatalog({ dbSessionId: connId, database: DEMO_PG_DB }); if (tables && typeof tables === 'object' && '__error' in (tables as object)) { - throw new Error(`get_tables(${DEMO_PG_DB}) failed: ${JSON.stringify(tables)}`); + throw new Error(`list_catalog(${DEMO_PG_DB}) failed: ${JSON.stringify(tables)}`); } await invoke('connect', { connectionId: DEMO_MYSQL_CONN_ID }); diff --git a/packages/driver-api/src/lib.rs b/packages/driver-api/src/lib.rs index 84376a15a..848e85692 100644 --- a/packages/driver-api/src/lib.rs +++ b/packages/driver-api/src/lib.rs @@ -14,6 +14,7 @@ mod query_stream; mod reuse; pub mod schema_catalog_commands; pub mod schema_dependencies; +pub mod schema_metadata; pub mod schema_migration; pub mod schema_object_commands; pub mod schema_objects; diff --git a/packages/driver-api/src/schema_metadata.rs b/packages/driver-api/src/schema_metadata.rs new file mode 100644 index 000000000..f6f064d86 --- /dev/null +++ b/packages/driver-api/src/schema_metadata.rs @@ -0,0 +1,84 @@ +//! Structured catalog identities shared by metadata transports. +use crate::{ColumnSchema, TableSchema, TableType}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RelationRef { + pub database: String, + pub schema: Option, + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelationSummary { + #[serde(rename = "ref")] + pub relation: RelationRef, + pub kind: TableType, + pub row_count: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelationColumns { + #[serde(rename = "ref")] + pub relation: RelationRef, + pub columns: Vec, + pub primary_keys: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelationSchema { + #[serde(rename = "ref")] + pub relation: RelationRef, + pub definition: TableSchema, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListCatalogOutput { + pub database: String, + pub schemas: Vec, + pub relations: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum MetadataReadErrorCode { + Unsupported, + ReadFailed, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MetadataReadError { + pub code: MetadataReadErrorCode, + pub message: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "lowercase")] +pub enum ColumnsReadResult { + Ok { + value: RelationColumns, + }, + Error { + #[serde(rename = "ref")] + relation: RelationRef, + error: MetadataReadError, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ReadColumnsOutput { + pub results: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] +pub enum MetadataRefreshScope { + Session, + Database { database: String }, + Relation { relation: RelationRef }, +} diff --git a/packages/driver-sdk/__tests__/pathItemsCache.test.ts b/packages/driver-sdk/__tests__/pathItemsCache.test.ts index 0d977d280..0e2458f74 100644 --- a/packages/driver-sdk/__tests__/pathItemsCache.test.ts +++ b/packages/driver-sdk/__tests__/pathItemsCache.test.ts @@ -18,19 +18,21 @@ describe('driver-sdk path item cache', () => { const items: TableInfo[] = [ { name: '1/hive', tableType: 'table', schema: 'CATALOG', rowCount: undefined }, ]; - expect(getCachedPathItems('1')).toBeUndefined(); - cachePathItems('1', items); - expect(getCachedPathItems('1')).toEqual(items); + expect(getCachedPathItems('1', 'session-1')).toBeUndefined(); + cachePathItems('1', items, 'session-1'); + expect(getCachedPathItems('1', 'session-1')).toEqual(items); }); it('notifies subscribers when autocomplete writes the cache', () => { const seen: string[] = []; const stop = subscribeSchemaPathItems((cache) => { seen.push(...Object.keys(cache)); - }); - cachePathItems('42/hive', [ - { name: 't', tableType: 'table', schema: 'snap', rowCount: undefined }, - ]); + }, 'session-1'); + cachePathItems( + '42/hive', + [{ name: 't', tableType: 'table', schema: 'snap', rowCount: undefined }], + 'session-1', + ); stop(); expect(seen).toContain('42/hive'); }); diff --git a/packages/driver-sdk/__tests__/schemaClient.test.ts b/packages/driver-sdk/__tests__/schemaClient.test.ts new file mode 100644 index 000000000..5d84f8d59 --- /dev/null +++ b/packages/driver-sdk/__tests__/schemaClient.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { schemaClient } from '../src/ipc/schemaClient'; +import { relationKey } from '../src/types/schemaMetadata'; + +const invoke = vi.hoisted(() => vi.fn()); +vi.mock('@tauri-apps/api/core', () => ({ invoke })); + +describe('schema metadata transport contract', () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it('keeps the complete target in input and unwraps the command result', async () => { + const ref = { database: 'app', schema: 'archive', name: 'users' }; + const output = { results: [{ status: 'ok', value: { ref, columns: [], primaryKeys: [] } }] }; + invoke.mockResolvedValue({ data: output }); + expect(await schemaClient.readColumns('session', [ref])).toEqual(output); + expect(invoke).toHaveBeenCalledWith('execute_driver_command', { + request: { + dbSessionId: 'session', + command: 'read_relation_columns', + input: { relations: [ref] }, + }, + }); + }); + + it('preserves null schema and exact refresh scope', async () => { + const relation = { database: 'app', schema: null, name: 'users' }; + invoke.mockResolvedValue({ data: { revision: 2 } }); + await schemaClient.refresh('session', { kind: 'relation', relation }); + expect(invoke).toHaveBeenCalledWith('execute_driver_command', { + request: { + dbSessionId: 'session', + command: 'refresh_schema_metadata', + input: { scope: { kind: 'relation', relation } }, + }, + }); + }); + + it('does not collide on delimiter-bearing identity components', () => { + const base = { dbSessionId: 'session', database: 'app', schema: null, name: 'users' }; + expect(relationKey({ ...base, database: 'app:public', name: 'users' })).not.toEqual( + relationKey({ ...base, database: 'app', name: 'public:users' }), + ); + expect(relationKey(base)).not.toEqual(relationKey({ ...base, schema: 'null' })); + }); +}); diff --git a/packages/driver-sdk/__tests__/schemaStoreBridge.bound.test.tsx b/packages/driver-sdk/__tests__/schemaStoreBridge.bound.test.tsx index b054c1368..392fb379c 100644 --- a/packages/driver-sdk/__tests__/schemaStoreBridge.bound.test.tsx +++ b/packages/driver-sdk/__tests__/schemaStoreBridge.bound.test.tsx @@ -7,7 +7,7 @@ * 2. bound selector and getState forwarding; * 3. `useBoundSchemaStore` reactive subscription inside a React component; * 4. syncSchemaTables / syncSchemaNamespace / registerPathAliases forwarding - * (setState dbSessionId branch + action delegation). + * (explicit session delegation without changing active state). */ import { act, cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -29,9 +29,7 @@ const TABLES: TableInfo[] = [{ name: 'users', tableType: 'table', schema: 'publi function makeSchemaState(overrides?: Partial): SchemaStoreState { return { - pathItems: {}, - databases: ['db1'], - loading: false, + schemas: new Map([['s-1', { pathItems: {}, databases: ['db1'], loading: false }]]), loadForConnection: vi.fn(async () => undefined), setLoadedTables: vi.fn(), mergeNamespace: vi.fn(), @@ -63,7 +61,9 @@ afterEach(() => { describe('schemaStoreBridge call signature (unbound)', () => { it('useBoundSchemaStore selector call throws with the documented message', async () => { const mod = await importFreshBridge(); - expect(() => mod.useBoundSchemaStore((s) => s.databases)).toThrow(NOT_BOUND_MESSAGE); + expect(() => mod.useBoundSchemaStore((s) => s.schemas.get('s-1')?.databases)).toThrow( + NOT_BOUND_MESSAGE, + ); }); it('useBoundSchemaStore.getState() throws with the documented message', async () => { @@ -76,8 +76,8 @@ describe('schemaStoreBridge call signature (bound forwarding)', () => { it('selector call returns the requested slice', async () => { const mod = await importFreshBridge(); mod.bindSchemaStore(makeCallableStore(makeSchemaState()).store); - expect(mod.useBoundSchemaStore((s) => s.databases)).toEqual(['db1']); - expect(mod.useBoundSchemaStore((s) => s.loading)).toBe(false); + expect(mod.useBoundSchemaStore((s) => s.schemas.get('s-1')?.databases)).toEqual(['db1']); + expect(mod.useBoundSchemaStore((s) => s.schemas.get('s-1')?.loading)).toBe(false); }); it('getState() forwards to the bound store', async () => { @@ -95,7 +95,7 @@ describe('schemaStoreBridge call signature (reactive subscription)', () => { mod.bindSchemaStore(realStore as unknown as BoundSchemaStore); function DatabaseListProbe() { - const databases = mod.useBoundSchemaStore((s) => s.databases); + const databases = mod.useBoundConnectionSchemaField('s-1', 'databases'); return {databases.join(',')}; } @@ -103,36 +103,30 @@ describe('schemaStoreBridge call signature (reactive subscription)', () => { expect(screen.getByTestId('databases').textContent).toBe('db1'); act(() => { - realStore.setState({ databases: ['db1', 'db2'] }); + realStore.setState({ + schemas: new Map([['s-1', { pathItems: {}, databases: ['db1', 'db2'], loading: false }]]), + }); }); expect(screen.getByTestId('databases').textContent).toBe('db1,db2'); // Imperative getState() reads the same live state. - expect(mod.useBoundSchemaStore.getState().databases).toEqual(['db1', 'db2']); + expect(mod.useBoundSchemaStore.getState().schemas.get('s-1')?.databases).toEqual([ + 'db1', + 'db2', + ]); }); }); describe('schemaStoreBridge sync helpers (bound)', () => { - it('syncSchemaTables sets dbSessionId then delegates to setLoadedTables', async () => { + it('syncSchemaTables delegates to its target session without activating it', async () => { const mod = await importFreshBridge(); const state = makeSchemaState(); const { store, setStateSpy } = makeCallableStore(state); mod.bindSchemaStore(store); mod.syncSchemaTables('db1', TABLES, 'session-9'); - expect(setStateSpy).toHaveBeenCalledWith({ dbSessionId: 'session-9' }); - expect(state.setLoadedTables).toHaveBeenCalledWith('db1', TABLES); - }); - - it('syncSchemaTables without dbSessionId skips setState', async () => { - const mod = await importFreshBridge(); - const state = makeSchemaState(); - const { store, setStateSpy } = makeCallableStore(state); - mod.bindSchemaStore(store); - - mod.syncSchemaTables('db1', TABLES); expect(setStateSpy).not.toHaveBeenCalled(); - expect(state.setLoadedTables).toHaveBeenCalledWith('db1', TABLES); + expect(state.setLoadedTables).toHaveBeenCalledWith('db1', TABLES, 'session-9'); }); it('syncSchemaNamespace forwards options.dbSessionId and merges namespace', async () => { @@ -142,8 +136,13 @@ describe('schemaStoreBridge sync helpers (bound)', () => { mod.bindSchemaStore(store); mod.syncSchemaNamespace(['db1', 'public'], 'tables', ['users'], { dbSessionId: 's-1' }); - expect(setStateSpy).toHaveBeenCalledWith({ dbSessionId: 's-1' }); - expect(state.mergeNamespace).toHaveBeenCalledWith(['db1', 'public'], 'tables', ['users']); + expect(setStateSpy).not.toHaveBeenCalled(); + expect(state.mergeNamespace).toHaveBeenCalledWith( + ['db1', 'public'], + 'tables', + ['users'], + 's-1', + ); }); it('registerPathAliases forwards entries with dbSessionId', async () => { @@ -154,36 +153,13 @@ describe('schemaStoreBridge sync helpers (bound)', () => { const entries = [{ name: 'My DB', id: '42' }]; mod.registerPathAliases(entries, 's-2'); - expect(setStateSpy).toHaveBeenCalledWith({ dbSessionId: 's-2' }); - expect(state.registerPathAliases).toHaveBeenCalledWith(entries); - }); - - it('syncSchemaNamespace without options still merges namespace (no setState)', async () => { - const mod = await importFreshBridge(); - const state = makeSchemaState(); - const { store, setStateSpy } = makeCallableStore(state); - mod.bindSchemaStore(store); - - mod.syncSchemaNamespace(['db1'], 'branch', ['public']); - expect(setStateSpy).not.toHaveBeenCalled(); - expect(state.mergeNamespace).toHaveBeenCalledWith(['db1'], 'branch', ['public']); - }); - - it('registerPathAliases without dbSessionId skips setState', async () => { - const mod = await importFreshBridge(); - const state = makeSchemaState(); - const { store, setStateSpy } = makeCallableStore(state); - mod.bindSchemaStore(store); - - const entries = [{ name: 'My DB', id: '42' }]; - mod.registerPathAliases(entries); expect(setStateSpy).not.toHaveBeenCalled(); - expect(state.registerPathAliases).toHaveBeenCalledWith(entries); + expect(state.registerPathAliases).toHaveBeenCalledWith(entries, 's-2'); }); it('getCachedPathItems returns undefined while unbound (optional chaining)', async () => { const mod = await importFreshBridge(); - expect(mod.getCachedPathItems('1')).toBeUndefined(); + expect(mod.getCachedPathItems('1', 's-1')).toBeUndefined(); }); it('subscribeSchemaPathItems ignores store updates that keep pathItems identical', async () => { @@ -192,26 +168,112 @@ describe('schemaStoreBridge sync helpers (bound)', () => { mod.bindSchemaStore(realStore as unknown as BoundSchemaStore); const listener = vi.fn(); - const stop = mod.subscribeSchemaPathItems(listener); + const stop = mod.subscribeSchemaPathItems(listener, 's-1'); expect(listener).toHaveBeenCalledTimes(1); // initial snapshot listener.mockClear(); act(() => { - realStore.setState({ loading: true }); // pathItems reference unchanged + realStore.setState({ + schemas: new Map(realStore.getState().schemas).set('s-1', { + ...realStore.getState().schemas.get('s-1')!, + loading: true, + }), + }); // pathItems reference unchanged }); expect(listener).not.toHaveBeenCalled(); const items: Record = { '1': TABLES }; act(() => { - realStore.setState({ pathItems: items }); + realStore.setState({ + schemas: new Map(realStore.getState().schemas).set('s-1', { + pathItems: items, + databases: ['db1'], + loading: false, + }), + }); }); expect(listener).toHaveBeenCalledWith(items); stop(); act(() => { - realStore.setState({ pathItems: {} }); + realStore.setState({ + schemas: new Map(realStore.getState().schemas).set('s-1', { + pathItems: {}, + databases: ['db1'], + loading: false, + }), + }); }); // Unsubscribed: no further notifications. expect(listener).toHaveBeenCalledTimes(1); }); + it('isolates path caches by session and clears subscribers on session removal', async () => { + const mod = await importFreshBridge(); + const realStore = create()(() => makeSchemaState()); + mod.bindSchemaStore(realStore); + const listener = vi.fn(); + const stop = mod.subscribeSchemaPathItems(listener, 's-1'); + listener.mockClear(); + realStore.setState({ + schemas: new Map(realStore.getState().schemas).set('other', { + databases: ['remote'], + loading: false, + pathItems: { '1': TABLES }, + }), + }); + expect(listener).not.toHaveBeenCalled(); + expect(mod.getCachedPathItems('1', 's-1')).toBeUndefined(); + expect(mod.getCachedPathItems('1', 'other')).toEqual(TABLES); + realStore.setState({ + schemas: new Map(realStore.getState().schemas).set('s-1', { + databases: ['db1'], + loading: false, + pathItems: { '1': TABLES }, + }), + }); + expect(listener).toHaveBeenLastCalledWith({ '1': TABLES }); + const remaining = new Map(realStore.getState().schemas); + remaining.delete('s-1'); + realStore.setState({ schemas: remaining }); + expect(listener).toHaveBeenLastCalledWith({}); + expect(mod.getCachedPathItems('1', 'other')).toEqual(TABLES); + stop(); + }); + + it('scoped selectors never fall back to another session and reset after removal', async () => { + const mod = await importFreshBridge(); + const realStore = create()(() => makeSchemaState()); + mod.bindSchemaStore(realStore); + function Probe() { + const databases = mod.useBoundConnectionSchemaField('new-session', 'databases'); + return {databases.join(',')}; + } + render(); + expect(screen.getByTestId('scoped-databases').textContent).toBe(''); + act(() => + realStore.setState({ + schemas: new Map(realStore.getState().schemas).set('new-session', { + pathItems: {}, + databases: ['target'], + loading: false, + }), + }), + ); + expect(screen.getByTestId('scoped-databases').textContent).toBe('target'); + act(() => + realStore.setState({ + schemas: new Map([ + [ + 's-1', + { + pathItems: {}, + databases: ['db1'], + loading: false, + }, + ], + ]), + }), + ); + expect(screen.getByTestId('scoped-databases').textContent).toBe(''); + }); }); diff --git a/packages/driver-sdk/src/index.ts b/packages/driver-sdk/src/index.ts index 16ebe4a78..14f05f41d 100644 --- a/packages/driver-sdk/src/index.ts +++ b/packages/driver-sdk/src/index.ts @@ -20,7 +20,9 @@ export type { ConnectionMode, KvWorkspaceCapabilities, } from '../../../src/lib/databaseMeta'; -export type { DatabaseObjectKind, TableSchema, TableInfo } from '../../../src/types'; +export type { DatabaseObjectKind } from '../../../src/types'; +export * from './types/schemaMetadata'; +export { schemaClient } from './ipc/schemaClient'; // === SQL function catalog types === export type { FunctionEntry, FunctionParam } from '../../../src/lib/sqlFunctionTypes'; @@ -145,6 +147,12 @@ export { cachePathItems, subscribeSchemaPathItems, useBoundSchemaStore, + useBoundConnectionSchemaField, bindSchemaStore, } from './schemaStoreBridge'; -export type { BoundSchemaStore, SchemaStoreState, UseBoundSchemaStore } from './schemaStoreBridge'; +export type { + BoundSchemaStore, + SchemaStoreState, + SchemaSessionState, + UseBoundSchemaStore, +} from './schemaStoreBridge'; diff --git a/packages/driver-sdk/src/ipc/schemaClient.ts b/packages/driver-sdk/src/ipc/schemaClient.ts new file mode 100644 index 000000000..54dca7e05 --- /dev/null +++ b/packages/driver-sdk/src/ipc/schemaClient.ts @@ -0,0 +1,34 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { + CatalogScope, + ListCatalogOutput, + ReadColumnsOutput, + RelationRef, + RelationSchema, + MetadataRefreshScope, +} from '../types/schemaMetadata'; + +/** Metadata targeting lives exclusively inside the command input. */ +async function execute( + dbSessionId: string, + command: string, + input: Record, +): Promise { + const result = await invoke<{ data: T }>('execute_driver_command', { + request: { dbSessionId, command, input }, + }); + return result.data; +} + +export const schemaClient = { + listDatabases: (dbSessionId: string) => + execute<{ databases: string[] }>(dbSessionId, 'list_databases', {}), + listCatalog: (dbSessionId: string, scope: CatalogScope) => + execute(dbSessionId, 'list_catalog', { ...scope }), + readColumns: (dbSessionId: string, relations: readonly RelationRef[]) => + execute(dbSessionId, 'read_relation_columns', { relations }), + readSchema: (dbSessionId: string, relation: RelationRef) => + execute<{ value: RelationSchema }>(dbSessionId, 'read_relation_schema', { relation }), + refresh: (dbSessionId: string, scope: MetadataRefreshScope) => + execute<{ revision: number }>(dbSessionId, 'refresh_schema_metadata', { scope }), +}; diff --git a/packages/driver-sdk/src/schemaStoreBridge.ts b/packages/driver-sdk/src/schemaStoreBridge.ts index a72f4fc9b..13041e809 100644 --- a/packages/driver-sdk/src/schemaStoreBridge.ts +++ b/packages/driver-sdk/src/schemaStoreBridge.ts @@ -1,21 +1,30 @@ -import type { TableInfo } from '../../../src/types'; +import type { TableInfo } from './types/schemaMetadata'; -export type SchemaStoreState = { +export type SchemaSessionState = { pathItems: Record; - /** Active-session database list (flattened from the per-session cache). */ databases: string[]; loading: boolean; +}; + +export type SchemaStoreState = { + schemas: Map; loadForConnection: (dbSessionId: string, options?: { skipLoadTables?: boolean }) => Promise; - setLoadedTables: (database: string, tables: TableInfo[]) => void; - mergeNamespace: (segments: string[], kind: 'branch' | 'tables', names: string[]) => void; - registerPathAliases: (entries: { name: string; id: string }[]) => void; - cachePathItems: (fetchPath: string, items: TableInfo[]) => void; + setLoadedTables: (database: string, tables: TableInfo[], dbSessionId: string) => void; + mergeNamespace: ( + segments: string[], + kind: 'branch' | 'tables', + names: string[], + dbSessionId: string, + ) => void; + registerPathAliases: (entries: { name: string; id: string }[], dbSessionId: string) => void; + cachePathItems: (fetchPath: string, items: TableInfo[], dbSessionId: string) => void; }; +const EMPTY_SESSION: SchemaSessionState = { pathItems: {}, databases: [], loading: false }; + export type BoundSchemaStore = { (selector: (state: SchemaStoreState) => T): T; getState: () => SchemaStoreState; - setState: (partial: Record) => void; subscribe: (listener: (state: SchemaStoreState, prev: SchemaStoreState) => void) => () => void; }; @@ -34,7 +43,7 @@ function getStore(): BoundSchemaStore { /** * Reactive accessor for the bound host schema store (zustand-hook shape). - * Driver trees read `databases` / `loading` / actions through it instead of + * Driver trees read session entries and actions through it instead of * importing the host store module. */ export type UseBoundSchemaStore = { @@ -53,29 +62,19 @@ export const useBoundSchemaStore: UseBoundSchemaStore = Object.assign( * Sync fetched tables into the host schema store (SQL editor autocomplete). * Pass `dbSessionId` for custom schema trees that never call `loadForConnection`. */ -export function syncSchemaTables( - database: string, - tables: TableInfo[], - dbSessionId?: string, -): void { +export function syncSchemaTables(database: string, tables: TableInfo[], dbSessionId: string): void { const store = getStore(); - if (dbSessionId) { - store.setState({ dbSessionId }); - } - store.getState().setLoadedTables(database, tables); + store.getState().setLoadedTables(database, tables, dbSessionId); } export function syncSchemaNamespace( segments: string[], kind: 'branch' | 'tables', names: string[], - options?: { dbSessionId?: string }, + options: { dbSessionId: string }, ): void { const store = getStore(); - if (options?.dbSessionId) { - store.setState({ dbSessionId: options.dbSessionId }); - } - store.getState().mergeNamespace(segments, kind, names); + store.getState().mergeNamespace(segments, kind, names, options.dbSessionId); } /** @@ -84,32 +83,43 @@ export function syncSchemaNamespace( */ export function registerPathAliases( entries: { name: string; id: string }[], - dbSessionId?: string, + dbSessionId: string, ): void { const store = getStore(); - if (dbSessionId) { - store.setState({ dbSessionId }); - } - store.getState().registerPathAliases(entries); + store.getState().registerPathAliases(entries, dbSessionId); } /** Cached `get_tables` rows for a fetch path (`dbId` or `dbId/catalog[/schema]`). */ -export function getCachedPathItems(fetchPath: string): TableInfo[] | undefined { - return boundStore?.getState().pathItems[fetchPath]; +export function getCachedPathItems( + fetchPath: string, + dbSessionId: string, +): TableInfo[] | undefined { + return boundStore?.getState().schemas.get(dbSessionId)?.pathItems[fetchPath]; } /** Store `get_tables` rows so autocomplete and the schema tree share one fetch. */ -export function cachePathItems(fetchPath: string, items: TableInfo[]): void { - getStore().getState().cachePathItems(fetchPath, items); +export function cachePathItems(fetchPath: string, items: TableInfo[], dbSessionId: string): void { + getStore().getState().cachePathItems(fetchPath, items, dbSessionId); } /** Subscribe to the shared path-item cache (custom trees hydrate from autocomplete). */ export function subscribeSchemaPathItems( listener: (items: Record) => void, + dbSessionId: string, ): () => void { const store = getStore(); - listener(store.getState().pathItems); + listener(store.getState().schemas.get(dbSessionId)?.pathItems ?? EMPTY_SESSION.pathItems); return store.subscribe((state, prev) => { - if (state.pathItems !== prev.pathItems) listener(state.pathItems); + const current = state.schemas.get(dbSessionId)?.pathItems ?? EMPTY_SESSION.pathItems; + const previous = prev.schemas.get(dbSessionId)?.pathItems ?? EMPTY_SESSION.pathItems; + if (current !== previous) listener(current); }); } + +/** Read only the owning session, including stable defaults before it loads. */ +export function useBoundConnectionSchemaField( + dbSessionId: string, + key: K, +): SchemaSessionState[K] { + return getStore()((state) => (state.schemas.get(dbSessionId) ?? EMPTY_SESSION)[key]); +} diff --git a/packages/driver-sdk/src/types/schemaMetadata.ts b/packages/driver-sdk/src/types/schemaMetadata.ts new file mode 100644 index 000000000..899de6708 --- /dev/null +++ b/packages/driver-sdk/src/types/schemaMetadata.ts @@ -0,0 +1,118 @@ +export type TableType = 'table' | 'view' | 'materializedView' | 'systemTable'; + +export interface TableOptionsSnapshot { + comment?: string | null; + engine?: string | null; + charset?: string | null; +} + +export interface TableInfo { + name: string; + /** `driver-api` declares both as `Option<_>` without `skip_serializing_if`, so + * serde puts `null` on the wire — not `undefined`. `?:` alone is a lie that + * silently rejects the backend's own payload. */ + schema?: string | null; + tableType: TableType; + rowCount?: number | null; +} + +export interface ColumnSchema { + name: string; + dataType: string; + nullable: boolean; + defaultValue?: string | null; + isPrimaryKey?: boolean; + isAutoIncrement?: boolean; + comment?: string; +} + +export interface IndexInfo { + name: string; + columns: string[]; + isUnique: boolean; + isPrimary: boolean; + indexType?: string; +} + +export interface ForeignKeyInfo { + name: string; + columns: string[]; + referencedTable: string; + referencedColumns: string[]; + onUpdate?: string; + onDelete?: string; + deferrability?: + | 'unknown' + | 'notDeferrable' + | 'deferrableInitiallyImmediate' + | 'deferrableInitiallyDeferred'; +} + +export interface TableSchema { + tableName: string; + columns: ColumnSchema[]; + primaryKeys: string[]; + indexes: IndexInfo[]; + foreignKeys: ForeignKeyInfo[]; + tableOptions?: TableOptionsSnapshot; +} + +/** Catalog names are exact names, not quoted SQL text. */ +export interface CatalogScope { + database: string; + schema: string | null; +} + +export interface RelationRef extends CatalogScope { + name: string; +} + +export interface SessionRelationRef extends RelationRef { + dbSessionId: string; +} + +export interface RelationSummary { + ref: RelationRef; + kind: TableType; + rowCount: number | null; +} + +export interface RelationColumns { + ref: RelationRef; + columns: ColumnSchema[]; + primaryKeys: string[]; +} + +export interface RelationSchema { + ref: RelationRef; + definition: TableSchema; +} + +export interface ListCatalogOutput { + database: string; + schemas: string[]; + relations: RelationSummary[]; +} + +export interface MetadataReadError { + code: 'unsupported' | 'read-failed'; + message: string; +} + +export type ColumnsReadResult = + | { status: 'ok'; value: RelationColumns } + | { status: 'error'; ref: RelationRef; error: MetadataReadError }; + +export interface ReadColumnsOutput { + results: ColumnsReadResult[]; +} + +export type MetadataRefreshScope = + | { kind: 'session' } + | { kind: 'database'; database: string } + | { kind: 'relation'; relation: RelationRef }; + +/** Tuple encoding preserves names containing dots, colons or slashes. */ +export function relationKey(ref: SessionRelationRef): string { + return JSON.stringify([ref.dbSessionId, ref.database, ref.schema, ref.name]); +} diff --git a/packages/drivers/postgres/src/schema.rs b/packages/drivers/postgres/src/schema.rs index 46ea795ca..f4c902231 100644 --- a/packages/drivers/postgres/src/schema.rs +++ b/packages/drivers/postgres/src/schema.rs @@ -478,22 +478,6 @@ impl PostgresDriver { let nullable: String = row.get("is_nullable"); let is_pk: bool = row.get("is_primary_key"); - // The payload is keyed by bare table name, so two same-named tables - // in different schemas cannot both be represented. Keep the first - // and say so rather than merging their columns into one table. - if let Some(existing) = owners.get(&table_name) { - if existing != &table_schema { - tracing::warn!( - table = %table_name, - kept = %existing, - skipped = %table_schema, - "get_all_columns: same-named table in another schema skipped" - ); - } - continue; - } - owners.insert(table_name.clone(), table_schema); - let column = ColumnSchema { name: col_name.clone(), data_type: row.get("data_type"), @@ -504,17 +488,38 @@ impl PostgresDriver { is_auto_increment: false, }; - let entry = result.entry(table_name).or_default(); - entry.0.push(column); - if is_pk { - entry.1.push(col_name); - } + append_catalog_column(&mut result, &mut owners, table_name, table_schema, column); } Ok(result) } } +/// Legacy batch output has bare-name keys: retain all columns of the first +/// owning schema, but never merge columns from another same-named relation. +fn append_catalog_column( + result: &mut HashMap, Vec)>, + owners: &mut HashMap, + table: String, + schema: String, + column: ColumnSchema, +) { + if let Some(owner) = owners.get(&table) { + if owner != &schema { + tracing::warn!(%table, kept = %owner, skipped = %schema, + "get_all_columns: same-named table in another schema skipped"); + return; + } + } else { + owners.insert(table.clone(), schema); + } + let entry = result.entry(table).or_default(); + if column.is_primary_key { + entry.1.push(column.name.clone()); + } + entry.0.push(column); +} + fn parse_pg_fk_deferrability( is_deferrable: &str, initially_deferred: &str, @@ -582,11 +587,47 @@ fn parse_pg_check_definition(definition: &str) -> Option { #[cfg(test)] mod schema_tests { use super::{ - normalise_fk_columns, parse_pg_check_definition, parse_pg_fk_deferrability, - qualified_pg_table_identity, relation_supports_consistent_snapshot, + append_catalog_column, normalise_fk_columns, parse_pg_check_definition, + parse_pg_fk_deferrability, qualified_pg_table_identity, + relation_supports_consistent_snapshot, }; use datazen_driver_api::ForeignKeyDeferrability; + #[test] + fn batch_columns_preserve_composite_keys_and_same_schema_columns() { + let mut result = std::collections::HashMap::new(); + let mut owners = std::collections::HashMap::new(); + for (schema, name, pk) in [ + ("public", "tenant_id", true), + ("public", "id", true), + ("public", "name", false), + ("archive", "different", false), + ("public", "created_at", false), + ] { + append_catalog_column( + &mut result, + &mut owners, + "users".into(), + schema.into(), + datazen_driver_api::ColumnSchema { + name: name.into(), + data_type: "text".into(), + nullable: false, + default_value: None, + comment: None, + is_primary_key: pk, + is_auto_increment: false, + }, + ); + } + let (columns, keys) = &result["users"]; + assert_eq!( + columns.iter().map(|c| c.name.as_str()).collect::>(), + ["tenant_id", "id", "name", "created_at"] + ); + assert_eq!(keys, &["tenant_id", "id"]); + } + fn owned(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_string()).collect() } diff --git a/packages/drivers/redis/ui/__tests__/columnHeaderHeight.test.tsx b/packages/drivers/redis/ui/__tests__/columnHeaderHeight.test.tsx index 462656cd9..ab2503a1b 100644 --- a/packages/drivers/redis/ui/__tests__/columnHeaderHeight.test.tsx +++ b/packages/drivers/redis/ui/__tests__/columnHeaderHeight.test.tsx @@ -65,9 +65,9 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - pathItems: {}, - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([ + ['sess-height', { pathItems: {}, databases: ['db0', 'db1'], loading: false }], + ]), loadForConnection: async () => {}, setLoadedTables: () => {}, mergeNamespace: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/dirtyLeaveCoverage.test.tsx b/packages/drivers/redis/ui/__tests__/dirtyLeaveCoverage.test.tsx index 6dcd47e7e..c3dee6fd0 100644 --- a/packages/drivers/redis/ui/__tests__/dirtyLeaveCoverage.test.tsx +++ b/packages/drivers/redis/ui/__tests__/dirtyLeaveCoverage.test.tsx @@ -147,9 +147,7 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - pathItems: {}, - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([['sess-i1c', { pathItems: {}, databases: ['db0', 'db1'], loading: false }]]), loadForConnection: async () => {}, setLoadedTables: () => {}, mergeNamespace: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/dirtyLeaveJourney.test.tsx b/packages/drivers/redis/ui/__tests__/dirtyLeaveJourney.test.tsx index 6e92bb197..5c6f21445 100644 --- a/packages/drivers/redis/ui/__tests__/dirtyLeaveJourney.test.tsx +++ b/packages/drivers/redis/ui/__tests__/dirtyLeaveJourney.test.tsx @@ -114,9 +114,7 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - pathItems: {}, - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([['sess-i1', { pathItems: {}, databases: ['db0', 'db1'], loading: false }]]), loadForConnection: async () => {}, setLoadedTables: () => {}, mergeNamespace: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/keyTreeBreadcrumbKeyboardJourney.test.tsx b/packages/drivers/redis/ui/__tests__/keyTreeBreadcrumbKeyboardJourney.test.tsx index a349bb193..03363d048 100644 --- a/packages/drivers/redis/ui/__tests__/keyTreeBreadcrumbKeyboardJourney.test.tsx +++ b/packages/drivers/redis/ui/__tests__/keyTreeBreadcrumbKeyboardJourney.test.tsx @@ -91,12 +91,8 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([['sess-1', { pathItems: {}, databases: ['db0', 'db1'], loading: false }]]), loadForConnection: async () => {}, - // Host-store fields this suite never exercises; bound to satisfy the bridge - // contract so the Redis tree only ever reads `databases` / `loading`. - pathItems: {}, setLoadedTables: () => {}, mergeNamespace: () => {}, registerPathAliases: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/keyTreeCreateKey.test.tsx b/packages/drivers/redis/ui/__tests__/keyTreeCreateKey.test.tsx index d9b863e27..85c63c698 100644 --- a/packages/drivers/redis/ui/__tests__/keyTreeCreateKey.test.tsx +++ b/packages/drivers/redis/ui/__tests__/keyTreeCreateKey.test.tsx @@ -88,12 +88,10 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([ + ['sess-create', { pathItems: {}, databases: ['db0', 'db1'], loading: false }], + ]), loadForConnection: async () => {}, - // Host-store fields this suite never exercises; bound to satisfy the bridge - // contract so the Redis tree only ever reads `databases` / `loading`. - pathItems: {}, setLoadedTables: () => {}, mergeNamespace: () => {}, registerPathAliases: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/keyTreeInteractionsJourney.test.tsx b/packages/drivers/redis/ui/__tests__/keyTreeInteractionsJourney.test.tsx index 38e7ee7fa..927a3f645 100644 --- a/packages/drivers/redis/ui/__tests__/keyTreeInteractionsJourney.test.tsx +++ b/packages/drivers/redis/ui/__tests__/keyTreeInteractionsJourney.test.tsx @@ -94,12 +94,8 @@ const confirmSpy = vi.fn(async (_options: ConfirmDialogOptions) => confirmAnswer bindConfirmDialog(() => [confirmSpy, null]); bindSchemaStore( create(() => ({ - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([['sess-1', { pathItems: {}, databases: ['db0', 'db1'], loading: false }]]), loadForConnection: async () => {}, - // Host-store fields this suite never exercises; bound to satisfy the bridge - // contract so the Redis tree only ever reads `databases` / `loading`. - pathItems: {}, setLoadedTables: () => {}, mergeNamespace: () => {}, registerPathAliases: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/keyTreeJourney.test.tsx b/packages/drivers/redis/ui/__tests__/keyTreeJourney.test.tsx index 451393324..4cb25d0c4 100644 --- a/packages/drivers/redis/ui/__tests__/keyTreeJourney.test.tsx +++ b/packages/drivers/redis/ui/__tests__/keyTreeJourney.test.tsx @@ -83,9 +83,7 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - pathItems: {}, - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([['sess-1', { pathItems: {}, databases: ['db0', 'db1'], loading: false }]]), loadForConnection: async () => {}, setLoadedTables: () => {}, mergeNamespace: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/keyTreePatternFilter.test.tsx b/packages/drivers/redis/ui/__tests__/keyTreePatternFilter.test.tsx index e909cc7a9..2d5c155ce 100644 --- a/packages/drivers/redis/ui/__tests__/keyTreePatternFilter.test.tsx +++ b/packages/drivers/redis/ui/__tests__/keyTreePatternFilter.test.tsx @@ -88,9 +88,7 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - pathItems: {}, - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([['sess-1', { pathItems: {}, databases: ['db0', 'db1'], loading: false }]]), loadForConnection: async () => {}, setLoadedTables: () => {}, mergeNamespace: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/keyTreeTesterCoverage.test.tsx b/packages/drivers/redis/ui/__tests__/keyTreeTesterCoverage.test.tsx index 7c0854ac8..d2d839fea 100644 --- a/packages/drivers/redis/ui/__tests__/keyTreeTesterCoverage.test.tsx +++ b/packages/drivers/redis/ui/__tests__/keyTreeTesterCoverage.test.tsx @@ -120,9 +120,7 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - pathItems: {}, - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([['sess-1', { pathItems: {}, databases: ['db0', 'db1'], loading: false }]]), loadForConnection: async () => {}, setLoadedTables: () => {}, mergeNamespace: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/keyTreeTesterGaps.test.tsx b/packages/drivers/redis/ui/__tests__/keyTreeTesterGaps.test.tsx index 82bb0b52a..6d8fdd96a 100644 --- a/packages/drivers/redis/ui/__tests__/keyTreeTesterGaps.test.tsx +++ b/packages/drivers/redis/ui/__tests__/keyTreeTesterGaps.test.tsx @@ -107,9 +107,7 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - pathItems: {}, - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([['sess-1', { pathItems: {}, databases: ['db0', 'db1'], loading: false }]]), loadForConnection: async () => {}, setLoadedTables: () => {}, mergeNamespace: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/kvSlotRelay.test.tsx b/packages/drivers/redis/ui/__tests__/kvSlotRelay.test.tsx index 7eb4d0408..f539a1274 100644 --- a/packages/drivers/redis/ui/__tests__/kvSlotRelay.test.tsx +++ b/packages/drivers/redis/ui/__tests__/kvSlotRelay.test.tsx @@ -103,12 +103,11 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([ + ['sess-1', { pathItems: {}, databases: ['db0', 'db1'], loading: false }], + ['sess-2', { pathItems: {}, databases: ['db0', 'db1'], loading: false }], + ]), loadForConnection: async () => {}, - // Host-store fields this suite never exercises; bound to satisfy the bridge - // contract so the Redis tree only ever reads `databases` / `loading`. - pathItems: {}, setLoadedTables: () => {}, mergeNamespace: () => {}, registerPathAliases: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/round2Probe.test.tsx b/packages/drivers/redis/ui/__tests__/round2Probe.test.tsx index df61e5678..1b28747d5 100644 --- a/packages/drivers/redis/ui/__tests__/round2Probe.test.tsx +++ b/packages/drivers/redis/ui/__tests__/round2Probe.test.tsx @@ -128,12 +128,10 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([['sess-i1c', { pathItems: {}, databases: ['db0', 'db1'], loading: false }]]), loadForConnection: async () => {}, // Path-tree surface this suite never drives (Redis reads the flat database // list only); the no-ops keep the fake a complete `SchemaStoreState`. - pathItems: {}, setLoadedTables: () => {}, mergeNamespace: () => {}, registerPathAliases: () => {}, diff --git a/packages/drivers/redis/ui/__tests__/testerRound3Probe.test.tsx b/packages/drivers/redis/ui/__tests__/testerRound3Probe.test.tsx index 1af72cb5a..55c7ac597 100644 --- a/packages/drivers/redis/ui/__tests__/testerRound3Probe.test.tsx +++ b/packages/drivers/redis/ui/__tests__/testerRound3Probe.test.tsx @@ -137,9 +137,7 @@ bindConnectionStore(create(() => ({ connections: [] }))); bindConfirmDialog(() => [async () => true, null]); bindSchemaStore( create(() => ({ - pathItems: {}, - databases: ['db0', 'db1'], - loading: false, + schemas: new Map([['sess-r3', { pathItems: {}, databases: ['db0', 'db1'], loading: false }]]), loadForConnection: async () => {}, setLoadedTables: () => {}, mergeNamespace: () => {}, diff --git a/packages/drivers/redis/ui/key-browser/RedisWorkbench.tsx b/packages/drivers/redis/ui/key-browser/RedisWorkbench.tsx index e51c4ca7f..6b2d49000 100644 --- a/packages/drivers/redis/ui/key-browser/RedisWorkbench.tsx +++ b/packages/drivers/redis/ui/key-browser/RedisWorkbench.tsx @@ -1,7 +1,12 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { Database } from 'lucide-react'; import { useI18n } from '@datazen/ui'; -import { useBoundSchemaStore, useBoundSettingsStore, readBooleanField } from '@datazen/driver-sdk'; +import { + useBoundSchemaStore, + useBoundConnectionSchemaField, + useBoundSettingsStore, + readBooleanField, +} from '@datazen/driver-sdk'; import { ImportExport } from './ImportExport'; import { KeyTreePane } from './KeyTreePane'; import { DetailColumn } from './DetailColumn'; @@ -61,8 +66,8 @@ export const RedisWorkbench = forwardRef s.databases); - const loading = useBoundSchemaStore((s) => s.loading); + const databasesFromStore = useBoundConnectionSchemaField(dbSessionId, 'databases'); + const loading = useBoundConnectionSchemaField(dbSessionId, 'loading'); const loadForConnection = useBoundSchemaStore((s) => s.loadForConnection); const driverSettings = useBoundSettingsStore((s) => s.settings.driverSettings); const allowFlush = readBooleanField( diff --git a/src-tauri/src/bootstrap/run.rs b/src-tauri/src/bootstrap/run.rs index 0580c76de..85ef3f8d8 100644 --- a/src-tauri/src/bootstrap/run.rs +++ b/src-tauri/src/bootstrap/run.rs @@ -296,17 +296,11 @@ pub fn run() { crate::commands::restart_app, crate::commands::save_encryption_key_with_dialog, // ── Schema 元数据 ── - crate::commands::get_all_columns, - crate::commands::get_columns, - crate::commands::get_columns_typed, crate::commands::get_database_objects, - crate::commands::get_databases, crate::commands::get_er_data, crate::commands::get_object_ddl, crate::commands::get_privileges, crate::commands::get_table_data, - crate::commands::get_table_schema, - crate::commands::get_tables, // ── 表结构编辑 ── crate::commands::get_structure_capabilities, crate::commands::plan_table_structure_changes, diff --git a/src-tauri/src/cache/schema_cache.rs b/src-tauri/src/cache/schema_cache.rs index b5f672400..783c1bc0e 100644 --- a/src-tauri/src/cache/schema_cache.rs +++ b/src-tauri/src/cache/schema_cache.rs @@ -11,7 +11,10 @@ use crate::db::registry::DriverRegistry; use crate::db::{ColumnSchema, ConnectionHandle, DatabaseDriver, DriverError, TableSchema}; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; use std::time::{Duration, Instant}; use tokio::sync::RwLock; @@ -44,6 +47,7 @@ pub struct DatabaseCache { /// Multi-level schema cache. pub struct SchemaCache { caches: Arc>>>, + generation: AtomicU64, cache_ttl: Duration, max_tables: usize, #[allow(dead_code)] @@ -54,12 +58,18 @@ impl SchemaCache { pub fn new(registry: Arc) -> Self { Self { caches: Arc::new(RwLock::new(HashMap::new())), + generation: AtomicU64::new(0), cache_ttl: Duration::from_secs(300), max_tables: 1000, registry, } } + /// Monotonic invalidation barrier for reads started before DDL/refresh. + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + /// Cache bucket for one `(database, schema)` scope. /// /// The schema is part of the key, not decoration: `public.users` and @@ -96,35 +106,13 @@ impl SchemaCache { driver: &Arc, handle: &ConnectionHandle, ) -> Result { + let generation = self.generation(); let scope = Self::scope_key(database, schema); + if let Some(cached) = self + .try_get_cached_columns(connection_id, database, schema, table) + .await { - let caches = self.caches.read().await; - if let Some(db_caches) = caches.get(connection_id) { - if let Some(db_cache) = db_caches.get(&scope) { - // An entry with no columns is never a usable answer: it means - // the read resolved against the wrong catalog (or the table is - // gone). Treat it as a miss so a single bad read cannot blank - // the structure view / ER diagram / data grid for the whole TTL. - if let Some(cached) = db_cache.tables.get(table) { - if cached.cached_at.elapsed() < self.cache_ttl - && !cached.schema.columns.is_empty() - { - return Ok(CachedColumns { - columns: cached.schema.columns.clone(), - primary_keys: cached.schema.primary_keys.clone(), - table_name: cached.schema.table_name.clone(), - cached_at: cached.cached_at, - }); - } - } - if let Some(cached) = db_cache.columns.get(table) { - if cached.cached_at.elapsed() < self.cache_ttl && !cached.columns.is_empty() - { - return Ok(cached.clone()); - } - } - } - } + return Ok(cached); } tracing::debug!("Columns cache miss: {}.{}", scope, table); @@ -136,16 +124,70 @@ impl SchemaCache { cached_at: Instant::now(), }; - { - let mut caches = self.caches.write().await; - let db_cache = Self::get_db_cache_mut(&mut caches, connection_id, &scope); - Self::evict_if_needed(&mut db_cache.columns, self.max_tables); - db_cache.columns.insert(table.to_string(), entry.clone()); - } + self.store_columns_if_current( + connection_id, + database, + schema, + table, + entry.clone(), + generation, + ) + .await; Ok(entry) } + /// Read-only cache lookup, shared by single reads and batch planning. + pub async fn try_get_cached_columns( + &self, + connection_id: &str, + database: &str, + schema: Option<&str>, + table: &str, + ) -> Option { + let scope = Self::scope_key(database, schema); + let caches = self.caches.read().await; + let db = caches.get(connection_id)?.get(&scope)?; + if let Some(cached) = db.tables.get(table) { + if cached.cached_at.elapsed() < self.cache_ttl && !cached.schema.columns.is_empty() { + return Some(CachedColumns { + columns: cached.schema.columns.clone(), + primary_keys: cached.schema.effective_primary_keys(), + table_name: cached.schema.table_name.clone(), + cached_at: cached.cached_at, + }); + } + } + db.columns + .get(table) + .filter(|cached| { + cached.cached_at.elapsed() < self.cache_ttl && !cached.columns.is_empty() + }) + .cloned() + } + + pub async fn store_columns_if_current( + &self, + connection_id: &str, + database: &str, + schema: Option<&str>, + table: &str, + columns: CachedColumns, + generation: u64, + ) { + if columns.columns.is_empty() { + return; + } + let scope = Self::scope_key(database, schema); + let mut caches = self.caches.write().await; + if self.generation() != generation { + return; + } + let db = Self::get_db_cache_mut(&mut caches, connection_id, &scope); + Self::evict_if_needed(&mut db.columns, self.max_tables); + db.columns.insert(table.to_string(), columns); + } + /// Returns a cached full schema when fresh; otherwise `None`. pub async fn try_get_cached_schema( &self, @@ -181,6 +223,26 @@ impl SchemaCache { schema_name: Option<&str>, table: &str, schema: TableSchema, + ) { + self.store_table_schema_if_current( + connection_id, + database, + schema_name, + table, + schema, + self.generation(), + ) + .await; + } + + pub async fn store_table_schema_if_current( + &self, + connection_id: &str, + database: &str, + schema_name: Option<&str>, + table: &str, + schema: TableSchema, + generation: u64, ) { if schema.columns.is_empty() { tracing::warn!( @@ -194,6 +256,9 @@ impl SchemaCache { let scope = Self::scope_key(database, schema_name); let mut caches = self.caches.write().await; + if self.generation() != generation { + return; + } let db_cache = Self::get_db_cache_mut(&mut caches, connection_id, &scope); Self::evict_if_needed(&mut db_cache.tables, self.max_tables); @@ -206,11 +271,12 @@ impl SchemaCache { }, ); + Self::evict_if_needed(&mut db_cache.columns, self.max_tables); db_cache.columns.insert( table.to_string(), CachedColumns { columns: schema.columns.clone(), - primary_keys: schema.primary_keys.clone(), + primary_keys: schema.effective_primary_keys(), table_name: schema.table_name.clone(), cached_at: Instant::now(), }, @@ -227,6 +293,7 @@ impl SchemaCache { driver: &Arc, handle: &ConnectionHandle, ) -> Result { + let generation = self.generation(); if let Some(cached) = self .try_get_cached_schema(connection_id, database, schema, table) .await @@ -238,8 +305,15 @@ impl SchemaCache { let table_schema = driver .get_table_schema(handle, table, database, schema) .await?; - self.store_table_schema(connection_id, database, schema, table, table_schema.clone()) - .await; + self.store_table_schema_if_current( + connection_id, + database, + schema, + table, + table_schema.clone(), + generation, + ) + .await; Ok(table_schema) } @@ -253,6 +327,7 @@ impl SchemaCache { pub async fn invalidate(&self, connection_id: &str, database: &str, table: Option<&str>) { let mut caches = self.caches.write().await; + self.generation.fetch_add(1, Ordering::AcqRel); // No schema argument: invalidate every schema scope of this database. if let Some(db_caches) = caches.get_mut(connection_id) { @@ -279,8 +354,27 @@ impl SchemaCache { } } + pub async fn invalidate_relation( + &self, + db_session_id: &str, + database: &str, + schema: Option<&str>, + table: &str, + ) { + let mut caches = self.caches.write().await; + self.generation.fetch_add(1, Ordering::AcqRel); + if let Some(db) = caches + .get_mut(db_session_id) + .and_then(|scopes| scopes.get_mut(&Self::scope_key(database, schema))) + { + db.columns.remove(table); + db.tables.remove(table); + } + } + pub async fn clear_connection(&self, connection_id: &str) { let mut caches = self.caches.write().await; + self.generation.fetch_add(1, Ordering::AcqRel); caches.remove(connection_id); } @@ -294,28 +388,12 @@ impl SchemaCache { handle: &ConnectionHandle, tables: &[String], ) { - let scope = Self::scope_key(database, schema); for table in tables { - if let Ok(schema) = driver - .get_table_schema(handle, table, database, schema) + if let Err(error) = self + .get_table_schema(connection_id, database, schema, table, driver, handle) .await { - if schema.columns.is_empty() { - tracing::warn!("Warmup skipped for column-less table {}", table); - continue; - } - let mut caches = self.caches.write().await; - let db_cache = Self::get_db_cache_mut(&mut caches, connection_id, &scope); - db_cache.tables.insert( - table.to_string(), - CachedSchema { - schema, - cached_at: Instant::now(), - version: 0, - }, - ); - } else { - tracing::warn!("Warmup skipped for table {}", table); + tracing::warn!(%table, %error, "Warmup skipped"); } } } @@ -568,4 +646,36 @@ mod tests { ); assert!(!cols.columns.is_empty()); } + #[tokio::test] + async fn invalidation_prevents_an_old_full_schema_read_from_repopulating_cache() { + let (cache, mock, handle) = cache_with_mock().await; + let driver = mock as Arc; + let old_generation = cache.generation(); + let old = driver + .get_table_schema(&handle, "users", "db1", None) + .await + .unwrap(); + cache.clear_connection("conn1").await; + cache + .store_table_schema_if_current( + "conn1", + "db1", + None, + "users", + old.clone(), + old_generation, + ) + .await; + assert!(cache + .try_get_cached_schema("conn1", "db1", None, "users") + .await + .is_none()); + cache + .store_table_schema_if_current("conn1", "db1", None, "users", old, cache.generation()) + .await; + assert!(cache + .try_get_cached_schema("conn1", "db1", None, "users") + .await + .is_some()); + } } diff --git a/src-tauri/src/commands/driver_command/discovery.rs b/src-tauri/src/commands/driver_command/discovery.rs index 90b43e8f6..7535a2c5b 100644 --- a/src-tauri/src/commands/driver_command/discovery.rs +++ b/src-tauri/src/commands/driver_command/discovery.rs @@ -11,7 +11,10 @@ pub(crate) async fn get_connection_commands_impl( .get_session(&db_session_id) .await .cmd_err("get_connection_commands")?; - Ok(driver.command_definitions()) + let mut definitions = driver.command_definitions(); + definitions.retain(|d| !crate::services::schema_metadata::is_metadata_command(&d.id)); + definitions.extend(crate::services::schema_metadata::command_definitions()); + Ok(definitions) } pub(crate) async fn get_driver_type_commands_impl( @@ -23,5 +26,8 @@ pub(crate) async fn get_driver_type_commands_impl( .get(&driver_type) .await .ok_or_else(|| CommandError::NotFound(format!("Driver not found: {driver_type}")))?; - Ok(driver.command_definitions()) + let mut definitions = driver.command_definitions(); + definitions.retain(|d| !crate::services::schema_metadata::is_metadata_command(&d.id)); + definitions.extend(crate::services::schema_metadata::command_definitions()); + Ok(definitions) } diff --git a/src-tauri/src/commands/driver_command/execute.rs b/src-tauri/src/commands/driver_command/execute.rs index 021b6e818..9c4ebcd36 100644 --- a/src-tauri/src/commands/driver_command/execute.rs +++ b/src-tauri/src/commands/driver_command/execute.rs @@ -35,7 +35,17 @@ pub(crate) async fn execute_driver_command_with_mode( ) .await?; + let metadata_command = crate::services::schema_metadata::is_metadata_command(&request.command); let mut definitions = driver.command_definitions(); + if metadata_command { + if request.database.is_some() || request.schema.is_some() { + return Err(CommandError::Validation( + "Metadata targets must be supplied inside input".into(), + )); + } + definitions.retain(|d| !crate::services::schema_metadata::is_metadata_command(&d.id)); + definitions.extend(crate::services::schema_metadata::command_definitions()); + } // Safety guard: if a driver forgot to extend schema_catalog_command_definitions, // inject only the missing catalog definitions so navigation/browsing is never broken. if is_schema_catalog_command(&request.command) { @@ -136,10 +146,26 @@ pub(crate) async fn execute_driver_command_with_mode( ); } - match driver - .execute_command(&handle, &request.command, request.input) + let outcome = if metadata_command { + let id = db_session_id.as_deref().ok_or_else(|| { + CommandError::Validation("Metadata commands require a session".into()) + })?; + crate::services::schema_metadata::execute( + state, + id, + &driver, + &handle, + &request.command, + request.input, + ) .await - { + } else { + driver + .execute_command(&handle, &request.command, request.input) + .await + .map_err(CommandError::from) + }; + match outcome { Ok(result) => { if is_sql_command { if let Some(sql) = sql.as_deref() { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e43984102..2ccc2b7d4 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -48,6 +48,7 @@ pub use encryption_key::*; #[cfg(feature = "webdriver")] pub use dialog::*; pub use driver_command::*; +pub(crate) use error::CommandError; pub use export::*; pub use file::*; pub use history::*; diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 6afeb7837..cebfc5d35 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -1,8 +1,7 @@ use super::error::{CmdExt, CommandError}; use super::AppState; -use crate::db::{TableDataResult, TableInfo, TableSchema}; +use crate::db::{TableDataResult, TableSchema}; use crate::services::{metadata_schema, FilterCondition, OrderBy, QueryExecutor, SortCondition}; -use std::collections::HashMap; use std::time::Instant; use tauri::State; @@ -17,264 +16,6 @@ fn embedded_schema_of(table: &str) -> Option<&str> { (!prefix.is_empty()).then_some(prefix) } -/// Resolve the `schema` argument for a per-table metadata read. -/// -/// The frontend always sends an explicit schema for schema-aware engines. This -/// is the safety net for callers that cannot know it (MCP, AI, workflow): it -/// falls back to the connection's configured schema and then to the driver's -/// own convention, and it never invents a schema for a driver without a schema -/// level — such a driver rejects one outright. -async fn resolve_metadata_schema( - state: &AppState, - db_session_id: &str, - driver: &dyn datazen_driver_api::DatabaseDriver, - table: Option<&str>, - explicit: Option<&str>, -) -> Option { - let config_schema = state - .connection_manager - .get_session_config(db_session_id) - .await - .ok() - .and_then(|c| c.schema); - metadata_schema( - driver, - explicit, - table.and_then(embedded_schema_of), - config_schema.as_deref(), - ) -} - -pub(crate) async fn get_databases_impl( - state: &AppState, - db_session_id: String, -) -> Result, CommandError> { - let start = Instant::now(); - tracing::info!(%db_session_id, "get_databases"); - - let data = run_schema_catalog_command( - state, - &db_session_id, - "list_databases", - serde_json::json!({}), - ) - .await?; - let dbs = parse_databases_from_command(&data); - tracing::info!(%db_session_id, count = dbs.len(), ms = start.elapsed().as_millis() as u64, "get_databases OK"); - Ok(dbs) -} - -pub(crate) async fn get_tables_impl( - state: &AppState, - db_session_id: String, - database: String, - schema: Option, -) -> Result, CommandError> { - let start = Instant::now(); - tracing::info!(%db_session_id, %database, schema = ?schema, "get_tables"); - let data = run_schema_catalog_command( - state, - &db_session_id, - "list_tables", - serde_json::json!({ "database": database, "schema": schema }), - ) - .await?; - let tables = parse_tables_from_command(&data); - tracing::info!(%db_session_id, %database, count = tables.len(), ms = start.elapsed().as_millis() as u64, "get_tables OK"); - Ok(tables) -} - -pub(crate) async fn get_columns_impl( - state: &AppState, - db_session_id: String, - table: String, - database: String, - schema: Option, -) -> Result, CommandError> { - let start = Instant::now(); - tracing::info!(%db_session_id, %table, "get_columns"); - let (driver, handle) = state - .connection_manager - .get_session(&db_session_id) - .await - .cmd_err("get_columns")?; - - let db = database.as_str(); - let schema = resolve_metadata_schema( - state, - &db_session_id, - driver.as_ref(), - Some(&table), - schema.as_deref(), - ) - .await; - - let cached = state - .schema_cache - .get_columns( - &db_session_id, - db, - schema.as_deref(), - &table, - &driver, - &handle, - ) - .await - .cmd_err("get_columns")?; - - tracing::info!(%db_session_id, %table, count = cached.columns.len(), ms = start.elapsed().as_millis() as u64, "get_columns OK"); - Ok(cached.columns.into_iter().map(|c| c.name).collect()) -} - -pub(crate) async fn get_columns_typed_impl( - state: &AppState, - db_session_id: String, - table: String, - database: String, - schema: Option, -) -> Result, CommandError> { - let start = Instant::now(); - tracing::info!(%db_session_id, %table, "get_columns_typed"); - let (driver, handle) = state - .connection_manager - .get_session(&db_session_id) - .await - .cmd_err("get_columns_typed")?; - - let db = database.as_str(); - let schema = resolve_metadata_schema( - state, - &db_session_id, - driver.as_ref(), - Some(&table), - schema.as_deref(), - ) - .await; - - let cached = state - .schema_cache - .get_columns( - &db_session_id, - db, - schema.as_deref(), - &table, - &driver, - &handle, - ) - .await - .cmd_err("get_columns_typed")?; - - tracing::info!(%db_session_id, %table, count = cached.columns.len(), ms = start.elapsed().as_millis() as u64, "get_columns_typed OK"); - Ok(cached.columns) -} - -pub(crate) async fn get_all_columns_impl( - state: &AppState, - db_session_id: String, - database: String, - schema: Option, -) -> Result>, CommandError> { - let start = Instant::now(); - tracing::info!(%db_session_id, "get_all_columns"); - let (driver, handle) = state - .connection_manager - .get_session(&db_session_id) - .await - .cmd_err("get_all_columns")?; - - let database = database.as_str(); - let schema = resolve_metadata_schema( - state, - &db_session_id, - driver.as_ref(), - None, - schema.as_deref(), - ) - .await; - - let raw = driver - .get_all_columns(&handle, database, schema.as_deref()) - .await - .cmd_err("get_all_columns")?; - - let result: HashMap> = raw - .into_iter() - .map(|(table, (cols, _pks))| (table, cols.into_iter().map(|c| c.name).collect())) - .collect(); - - tracing::info!(%db_session_id, %database, tables = result.len(), ms = start.elapsed().as_millis() as u64, "get_all_columns OK"); - Ok(result) -} - -pub(crate) async fn get_table_schema_impl( - state: &AppState, - db_session_id: String, - table: String, - database_pin: String, - schema: Option, -) -> Result { - let start = Instant::now(); - tracing::info!(%db_session_id, %table, "get_table_schema"); - - let database = database_pin.as_str(); - let requested_schema = schema.clone(); - let (driver, _handle) = state - .connection_manager - .get_session(&db_session_id) - .await - .cmd_err("get_table_schema")?; - let schema = resolve_metadata_schema( - state, - &db_session_id, - driver.as_ref(), - Some(&table), - schema.as_deref(), - ) - .await; - // The resolved target is what the driver will actually read; without it a - // "table does not exist" report cannot be told apart from a wrong target. - tracing::info!( - %db_session_id, - %table, - %database, - requested_schema = ?requested_schema, - resolved_schema = ?schema, - "get_table_schema target" - ); - - if let Some(schema) = state - .schema_cache - .try_get_cached_schema(&db_session_id, database, schema.as_deref(), &table) - .await - { - tracing::info!(%db_session_id, %table, cols = schema.columns.len(), indexes = schema.indexes.len(), fks = schema.foreign_keys.len(), ms = start.elapsed().as_millis() as u64, "get_table_schema OK (cache)"); - return Ok(schema); - } - - let data = run_schema_catalog_command( - state, - &db_session_id, - "get_table_schema", - serde_json::json!({ "table": table, "database": database, "schema": schema }), - ) - .await?; - let table_schema = parse_table_schema_from_command(&data).ok_or_else(|| { - CommandError::Internal("get_table_schema: missing schema in command result".into()) - })?; - state - .schema_cache - .store_table_schema( - &db_session_id, - database, - schema.as_deref(), - &table, - table_schema.clone(), - ) - .await; - tracing::info!(%db_session_id, %table, cols = table_schema.columns.len(), indexes = table_schema.indexes.len(), fks = table_schema.foreign_keys.len(), ms = start.elapsed().as_millis() as u64, "get_table_schema OK"); - Ok(table_schema) -} - pub(crate) async fn get_table_data_impl( state: &AppState, db_session_id: String, @@ -441,39 +182,6 @@ fn parse_grants_from_command( .unwrap_or_default() } -async fn run_schema_catalog_command( - state: &AppState, - db_session_id: &str, - command: &str, - input: serde_json::Value, -) -> Result { - let result = super::driver_command::execute_driver_command_impl( - state, - super::driver_command::ExecuteDriverCommandRequest { - db_session_id: Some(db_session_id.to_string()), - driver_type: None, - command: command.to_string(), - input, - database: None, - schema: None, - }, - ) - .await?; - Ok(result.data) -} - -fn parse_databases_from_command(data: &serde_json::Value) -> Vec { - datazen_driver_api::parse_databases_from_command(data) -} - -fn parse_tables_from_command(data: &serde_json::Value) -> Vec { - datazen_driver_api::parse_tables_from_command(data) -} - -fn parse_table_schema_from_command(data: &serde_json::Value) -> Option { - datazen_driver_api::parse_table_schema_from_command(data) -} - async fn run_schema_object_command( state: &AppState, db_session_id: &str, @@ -617,67 +325,6 @@ pub async fn get_privileges( get_privileges_impl(&state, db_session_id).await } -#[tauri::command] -pub async fn get_databases( - state: State<'_, AppState>, - db_session_id: String, -) -> Result, CommandError> { - get_databases_impl(&state, db_session_id).await -} - -#[tauri::command] -pub async fn get_tables( - state: State<'_, AppState>, - db_session_id: String, - database: String, - schema: Option, -) -> Result, CommandError> { - get_tables_impl(&state, db_session_id, database, schema).await -} - -#[tauri::command] -pub async fn get_columns( - state: State<'_, AppState>, - db_session_id: String, - table: String, - database: String, - schema: Option, -) -> Result, CommandError> { - get_columns_impl(&state, db_session_id, table, database, schema).await -} - -#[tauri::command] -pub async fn get_columns_typed( - state: State<'_, AppState>, - db_session_id: String, - table: String, - database: String, - schema: Option, -) -> Result, CommandError> { - get_columns_typed_impl(&state, db_session_id, table, database, schema).await -} - -#[tauri::command] -pub async fn get_all_columns( - state: State<'_, AppState>, - db_session_id: String, - database: String, - schema: Option, -) -> Result>, CommandError> { - get_all_columns_impl(&state, db_session_id, database, schema).await -} - -#[tauri::command] -pub async fn get_table_schema( - state: State<'_, AppState>, - db_session_id: String, - table: String, - database: String, - schema: Option, -) -> Result { - get_table_schema_impl(&state, db_session_id, table, database, schema).await -} - #[tauri::command] pub async fn get_table_data( state: State<'_, AppState>, @@ -719,534 +366,4 @@ pub async fn get_er_data( } #[cfg(test)] -mod tests { - use super::*; - use crate::db::Value; - use crate::services::SortCondition; - use crate::testing::app_state::TestAppState; - use crate::testing::mock_driver::MockDriverOptions; - - #[tokio::test] - async fn schema_commands_with_connected_mock() { - let test = TestAppState::with_tables().await; - let (_, conn_id) = test.save_and_connect("schema-cfg").await; - - let dbs = get_databases_impl(&test.state, conn_id.clone()) - .await - .unwrap(); - assert_eq!(dbs, vec!["app"]); - - let tables = get_tables_impl(&test.state, conn_id.clone(), "app".into(), None) - .await - .unwrap(); - assert_eq!(tables.len(), 1); - assert_eq!(tables[0].name, "users"); - - let cols = get_columns_impl( - &test.state, - conn_id.clone(), - "users".into(), - "app".into(), - None, - ) - .await - .unwrap(); - assert!(cols.contains(&"id".to_string())); - assert!(cols.contains(&"name".to_string())); - - let schema = get_table_schema_impl( - &test.state, - conn_id.clone(), - "users".into(), - "app".into(), - None, - ) - .await - .unwrap(); - assert_eq!(schema.table_name, "users"); - assert_eq!(schema.columns.len(), 2); - - let data = get_table_data_impl( - &test.state, - conn_id.clone(), - "users".into(), - 0, - 10, - None, - Some(vec![SortCondition { - column: "id".into(), - descending: false, - }]), - None, - None, - None, - None, - ) - .await - .unwrap(); - assert_eq!(data.total_rows, Some(2)); - - let er = get_er_data_impl(&test.state, conn_id, "app".into(), None) - .await - .unwrap(); - assert_eq!(er.len(), 1); - } - - #[tokio::test] - async fn get_er_data_errors_when_no_table_metadata_is_readable() { - use crate::db::{TableInfo, TableType}; - - // Schema-level driver that never supplies a schema (no explicit filter, - // table schema None, no default): every ExactSchema metadata read fails - // while `get_tables` still lists tables. - let opts = MockDriverOptions { - has_schema_level: true, - tables: vec![TableInfo { - name: "users".into(), - schema: None, - table_type: TableType::Table, - row_count: None, - }], - ..Default::default() - }; - let test = TestAppState::with_options(opts).await; - let (_, conn_id) = test.save_and_connect("er-all-fail").await; - // When tables exist but every per-table column read fails, an empty Vec - // would be rendered as "this database has no tables". Fail loudly instead. - let err = get_er_data_impl(&test.state, conn_id, "app".into(), None) - .await - .unwrap_err(); - assert!( - err.to_string() - .contains("failed to read column metadata for all"), - "expected the all-tables-failed error, got: {err}" - ); - } - - #[tokio::test] - async fn get_databases_rejects_connection_id_without_live_session() { - let test = TestAppState::with_tables().await; - test.save_connection("cfg-fallback").await; - let err = get_databases_impl(&test.state, "cfg-fallback".into()) - .await - .unwrap_err(); - assert!( - err.to_string().contains("DB session"), - "expected dbSessionId error, got: {err}" - ); - } - - #[tokio::test] - async fn schema_commands_error_when_not_connected() { - let test = TestAppState::new().await; - assert!( - get_tables_impl(&test.state, "missing".into(), "app".into(), None) - .await - .is_err() - ); - } - - #[tokio::test] - async fn get_table_data_with_query_rows() { - use crate::db::{ColumnSchema, TableInfo, TableType}; - - let opts = MockDriverOptions { - databases: vec!["app".into()], - tables: vec![TableInfo { - name: "items".into(), - schema: None, - table_type: TableType::Table, - row_count: None, - }], - columns: vec![ColumnSchema { - name: "id".into(), - data_type: "integer".into(), - nullable: false, - default_value: None, - comment: None, - is_primary_key: true, - is_auto_increment: false, - }], - primary_keys: vec!["id".into()], - count_total: 1, - query_rows: vec![vec![Some(Value::Integer(1))]], - ..Default::default() - }; - let test = TestAppState::with_options(opts).await; - let (_, conn_id) = test.save_and_connect("data-cfg").await; - let data = get_table_data_impl( - &test.state, - conn_id, - "items".into(), - 0, - 50, - None, - None, - Some(true), - None, - None, - None, - ) - .await - .unwrap(); - assert_eq!(data.rows.len(), 1); - } - - fn col(name: &str) -> crate::db::ColumnSchema { - crate::db::ColumnSchema { - name: name.into(), - data_type: "text".into(), - nullable: true, - default_value: None, - comment: None, - is_primary_key: false, - is_auto_increment: false, - } - } - - #[tokio::test] - async fn unknown_object_kind_is_validation_error() { - let test = TestAppState::new().await; - let (_, conn_id) = test.save_and_connect("obj-bad-kind").await; - let err = get_database_objects_impl(&test.state, conn_id, "invalid_kind".into()) - .await - .unwrap_err(); - assert!(err.to_string().contains("Unknown object kind")); - } - - #[tokio::test] - async fn lists_database_objects_returns_empty_when_query_has_no_rows_and_no_columns() { - let opts = MockDriverOptions { - columns: vec![], - query_rows: vec![], - ..Default::default() - }; - let test = TestAppState::with_options(opts).await; - let (_, conn_id) = test.save_and_connect("obj-empty").await; - let rows = get_database_objects_impl(&test.state, conn_id, "function".into()) - .await - .unwrap(); - assert!(rows.is_empty()); - } - - #[tokio::test] - async fn lists_database_objects_from_name_column() { - let opts = MockDriverOptions { - columns: vec![col("schema"), col("name")], - query_rows: vec![ - vec![ - Some(Value::String("public".into())), - Some(Value::String("fn_ok".into())), - ], - vec![ - Some(Value::String("public".into())), - Some(Value::String("".into())), - ], - ], - ..Default::default() - }; - let test = TestAppState::with_options(opts).await; - let (_, conn_id) = test.save_and_connect("obj-list").await; - let rows = get_database_objects_impl(&test.state, conn_id, "function".into()) - .await - .unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].name, "fn_ok"); - assert_eq!(rows[0].schema.as_deref(), Some("public")); - assert_eq!(rows[0].kind, "function"); - } - - #[tokio::test] - async fn lists_database_objects_preserves_signature_and_trigger_target() { - let opts = MockDriverOptions { - columns: vec![ - col("schema"), - col("name"), - col("signature"), - col("target_schema"), - col("target_name"), - ], - query_rows: vec![vec![ - Some(Value::String("public".into())), - Some(Value::String("audit".into())), - Some(Value::String("integer".into())), - Some(Value::String("public".into())), - Some(Value::String("orders".into())), - ]], - ..Default::default() - }; - let test = TestAppState::with_options(opts).await; - let (_, conn_id) = test.save_and_connect("obj-identity").await; - let rows = get_database_objects_impl(&test.state, conn_id, "trigger".into()) - .await - .unwrap(); - assert_eq!(rows[0].signature.as_deref(), Some("integer")); - assert_eq!(rows[0].target_schema.as_deref(), Some("public")); - assert_eq!(rows[0].target_name.as_deref(), Some("orders")); - } - - #[tokio::test] - async fn object_ddl_reads_named_or_second_column() { - let opts = MockDriverOptions { - columns: vec![col("Function"), col("Create Function")], - query_rows: vec![vec![ - Some(Value::String("fn_ok".into())), - Some(Value::String("CREATE FUNCTION fn_ok() ...".into())), - ]], - ..Default::default() - }; - let test = TestAppState::with_options(opts).await; - let (_, conn_id) = test.save_and_connect("obj-ddl").await; - let ddl = get_object_ddl_impl( - &test.state, - conn_id, - "function".into(), - "fn_ok".into(), - Some("public".into()), - ) - .await - .unwrap(); - assert!(ddl.contains("CREATE FUNCTION")); - } - - #[tokio::test] - async fn object_ddl_missing_object_fails_closed() { - let opts = MockDriverOptions { - columns: vec![col("ddl")], - query_rows: vec![], - ..Default::default() - }; - let test = TestAppState::with_options(opts).await; - let (_, conn_id) = test.save_and_connect("obj-missing-ddl").await; - let error = get_object_ddl_with_metadata_impl( - &test.state, - conn_id, - "function".into(), - "missing".into(), - Some("public".into()), - Some("integer".into()), - None, - None, - ) - .await - .unwrap_err(); - assert!(error.to_string().contains("not found")); - } - - #[tokio::test] - async fn object_catalog_preserves_permission_query_errors() { - let opts = MockDriverOptions { - query_error: Some("permission denied for relation pg_proc".into()), - ..Default::default() - }; - let test = TestAppState::with_options(opts).await; - let (_, conn_id) = test.save_and_connect("obj-permission").await; - let error = get_database_objects_impl(&test.state, conn_id, "function".into()) - .await - .unwrap_err(); - assert!(error.to_string().contains("permission denied")); - } - - #[tokio::test] - async fn privileges_map_grantee_and_skip_incomplete_rows() { - let opts = MockDriverOptions { - columns: vec![col("grantee"), col("schema"), col("name"), col("privilege")], - query_rows: vec![ - vec![ - Some(Value::String("alice".into())), - Some(Value::String("public".into())), - Some(Value::String("users".into())), - Some(Value::String("SELECT".into())), - ], - vec![None, None, None, None], - ], - ..Default::default() - }; - let test = TestAppState::with_options(opts).await; - let (_, conn_id) = test.save_and_connect("priv-list").await; - let grants = get_privileges_impl(&test.state, conn_id).await.unwrap(); - assert_eq!(grants.len(), 1); - assert_eq!(grants[0].grantee, "alice"); - assert_eq!(grants[0].object_name, "users"); - assert_eq!(grants[0].privilege, "SELECT"); - } - - #[tokio::test] - async fn get_table_data_joins_filters_with_or() { - use crate::db::{TableInfo, TableType}; - use crate::services::query_executor::FilterOperator; - use crate::services::FilterCondition; - - let opts = MockDriverOptions { - databases: vec!["app".into()], - tables: vec![TableInfo { - name: "items".into(), - schema: None, - table_type: TableType::Table, - row_count: None, - }], - columns: vec![col("id"), col("status")], - primary_keys: vec!["id".into()], - count_total: 1, - query_rows: vec![vec![ - Some(Value::Integer(1)), - Some(Value::String("a".into())), - ]], - ..Default::default() - }; - let test = TestAppState::with_options(opts).await; - let (_, conn_id) = test.save_and_connect("filter-or").await; - let data = get_table_data_impl( - &test.state, - conn_id, - "items".into(), - 0, - 50, - Some(vec![ - FilterCondition { - column: "id".into(), - operator: FilterOperator::Eq, - value: Value::Integer(1), - }, - FilterCondition { - column: "status".into(), - operator: FilterOperator::Eq, - value: Value::String("a".into()), - }, - ]), - None, - Some(true), - Some("or".into()), - None, - None, - ) - .await - .unwrap(); - assert_eq!(data.rows.len(), 1); - } - - #[tokio::test] - async fn get_table_data_reads_the_target_database_without_switching() { - let test = TestAppState::with_tables().await; - let (_, conn_id) = test.save_and_connect("tdata-pin-db").await; - // Sample config pins database = "app"; opening a table that lives in - // another database must read *that* database (BUG-002) — via the - // explicit argument, never by re-pointing the shared session. - let data = get_table_data_impl( - &test.state, - conn_id.clone(), - "users".into(), - 0, - 50, - None, - None, - Some(true), - None, - Some("analytics".into()), - None, - ) - .await - .unwrap(); - assert_eq!(data.rows.len(), 1); - assert!( - test.mock.use_database_calls().is_empty(), - "get_table_data must not switch the session's database" - ); - // The session keeps its own configured database; the request target is - // per-call. - let config = test - .state - .connection_manager - .get_session_config(&conn_id) - .await - .unwrap(); - assert_eq!(config.database.as_deref(), Some("app")); - } - - /// Regression (BUG-003): `get_er_data` listed the requested database's - /// tables but read every table's schema against whatever catalog the shared - /// session sat on. That produced a successful but column-less schema, which - /// the schema cache stored under the requested database and served for its - /// whole TTL — empty structure view, ER diagram without columns, and a data - /// grid with the right row count but blank cells. Each read now carries its - /// own `(database, schema)` target. - #[tokio::test] - async fn get_er_data_reads_each_tables_own_schema_and_never_caches_empty() { - use crate::db::{ColumnSchema, TableInfo, TableType}; - - fn column(name: &str) -> ColumnSchema { - ColumnSchema { - name: name.into(), - data_type: "text".into(), - nullable: true, - default_value: None, - comment: None, - is_primary_key: false, - is_auto_increment: false, - } - } - - let opts = MockDriverOptions { - databases: vec!["app".into(), "analytics".into()], - tables_by_database: HashMap::from([( - "analytics".to_string(), - vec![TableInfo { - name: "events".into(), - schema: None, - table_type: TableType::Table, - row_count: None, - }], - )]), - columns_by_database: HashMap::from([( - "analytics".to_string(), - HashMap::from([( - "events".to_string(), - vec![column("id"), column("name"), column("created_at")], - )]), - )]), - ..Default::default() - }; - let test = TestAppState::with_options(opts).await; - // Sample config pins database = "app"; the ER view targets "analytics". - let (_, conn_id) = test.save_and_connect("er-pin-db").await; - - let schemas = get_er_data_impl(&test.state, conn_id.clone(), "analytics".into(), None) - .await - .unwrap(); - - assert!( - test.mock.use_database_calls().is_empty(), - "get_er_data must read each table's schema explicitly, not by switching the session" - ); - assert_eq!(schemas.len(), 1); - assert_eq!( - schemas[0].columns.len(), - 3, - "columns must come from the requested database" - ); - - // The later reads of that database must see the same columns instead of - // a poisoned (empty) cache entry. - let columns = get_columns_impl( - &test.state, - conn_id.clone(), - "events".into(), - "analytics".into(), - None, - ) - .await - .unwrap(); - assert_eq!(columns, vec!["id", "name", "created_at"]); - - let schema = get_table_schema_impl( - &test.state, - conn_id, - "events".into(), - "analytics".into(), - None, - ) - .await - .unwrap(); - assert_eq!(schema.columns.len(), 3); - } -} +mod tests; diff --git a/src-tauri/src/commands/schema/tests.rs b/src-tauri/src/commands/schema/tests.rs new file mode 100644 index 000000000..4e24248f8 --- /dev/null +++ b/src-tauri/src/commands/schema/tests.rs @@ -0,0 +1,617 @@ +use super::*; +use crate::db::Value; +use crate::services::SortCondition; +use crate::testing::app_state::TestAppState; +use crate::testing::mock_driver::MockDriverOptions; +use std::collections::HashMap; + +#[tokio::test] +async fn schema_commands_with_connected_mock() { + let test = TestAppState::with_tables().await; + let (_, conn_id) = test.save_and_connect("schema-cfg").await; + + let dbs = read_databases(&test.state, conn_id.clone()).await.unwrap(); + assert_eq!(dbs, vec!["app"]); + + let tables = read_catalog(&test.state, conn_id.clone(), "app".into(), None) + .await + .unwrap(); + assert_eq!(tables.len(), 1); + assert_eq!(tables[0].relation.name, "users"); + + let cols = read_column_names( + &test.state, + conn_id.clone(), + "users".into(), + "app".into(), + None, + ) + .await + .unwrap(); + assert!(cols.contains(&"id".to_string())); + assert!(cols.contains(&"name".to_string())); + + let schema = read_definition( + &test.state, + conn_id.clone(), + "users".into(), + "app".into(), + None, + ) + .await + .unwrap(); + assert_eq!(schema.table_name, "users"); + assert_eq!(schema.columns.len(), 2); + + let data = get_table_data_impl( + &test.state, + conn_id.clone(), + "users".into(), + 0, + 10, + None, + Some(vec![SortCondition { + column: "id".into(), + descending: false, + }]), + None, + None, + None, + None, + ) + .await + .unwrap(); + assert_eq!(data.total_rows, Some(2)); + + let er = get_er_data_impl(&test.state, conn_id, "app".into(), None) + .await + .unwrap(); + assert_eq!(er.len(), 1); +} + +#[tokio::test] +async fn get_er_data_errors_when_no_table_metadata_is_readable() { + use crate::db::{TableInfo, TableType}; + + // Schema-level driver that never supplies a schema (no explicit filter, + // table schema None, no default): every ExactSchema metadata read fails + // while the driver still lists tables. + let opts = MockDriverOptions { + has_schema_level: true, + tables: vec![TableInfo { + name: "users".into(), + schema: None, + table_type: TableType::Table, + row_count: None, + }], + ..Default::default() + }; + let test = TestAppState::with_options(opts).await; + let (_, conn_id) = test.save_and_connect("er-all-fail").await; + // When tables exist but every per-table column read fails, an empty Vec + // would be rendered as "this database has no tables". Fail loudly instead. + let err = get_er_data_impl(&test.state, conn_id, "app".into(), None) + .await + .unwrap_err(); + assert!( + err.to_string() + .contains("failed to read column metadata for all"), + "expected the all-tables-failed error, got: {err}" + ); +} + +#[tokio::test] +async fn list_databases_rejects_connection_id_without_live_session() { + let test = TestAppState::with_tables().await; + test.save_connection("cfg-fallback").await; + let err = read_databases(&test.state, "cfg-fallback".into()) + .await + .unwrap_err(); + assert!( + err.to_string().contains("DB session"), + "expected dbSessionId error, got: {err}" + ); +} + +#[tokio::test] +async fn schema_commands_error_when_not_connected() { + let test = TestAppState::new().await; + assert!( + read_catalog(&test.state, "missing".into(), "app".into(), None) + .await + .is_err() + ); +} + +#[tokio::test] +async fn get_table_data_with_query_rows() { + use crate::db::{ColumnSchema, TableInfo, TableType}; + + let opts = MockDriverOptions { + databases: vec!["app".into()], + tables: vec![TableInfo { + name: "items".into(), + schema: None, + table_type: TableType::Table, + row_count: None, + }], + columns: vec![ColumnSchema { + name: "id".into(), + data_type: "integer".into(), + nullable: false, + default_value: None, + comment: None, + is_primary_key: true, + is_auto_increment: false, + }], + primary_keys: vec!["id".into()], + count_total: 1, + query_rows: vec![vec![Some(Value::Integer(1))]], + ..Default::default() + }; + let test = TestAppState::with_options(opts).await; + let (_, conn_id) = test.save_and_connect("data-cfg").await; + let data = get_table_data_impl( + &test.state, + conn_id, + "items".into(), + 0, + 50, + None, + None, + Some(true), + None, + None, + None, + ) + .await + .unwrap(); + assert_eq!(data.rows.len(), 1); +} + +fn col(name: &str) -> crate::db::ColumnSchema { + crate::db::ColumnSchema { + name: name.into(), + data_type: "text".into(), + nullable: true, + default_value: None, + comment: None, + is_primary_key: false, + is_auto_increment: false, + } +} + +#[tokio::test] +async fn unknown_object_kind_is_validation_error() { + let test = TestAppState::new().await; + let (_, conn_id) = test.save_and_connect("obj-bad-kind").await; + let err = get_database_objects_impl(&test.state, conn_id, "invalid_kind".into()) + .await + .unwrap_err(); + assert!(err.to_string().contains("Unknown object kind")); +} + +#[tokio::test] +async fn lists_database_objects_returns_empty_when_query_has_no_rows_and_no_columns() { + let opts = MockDriverOptions { + columns: vec![], + query_rows: vec![], + ..Default::default() + }; + let test = TestAppState::with_options(opts).await; + let (_, conn_id) = test.save_and_connect("obj-empty").await; + let rows = get_database_objects_impl(&test.state, conn_id, "function".into()) + .await + .unwrap(); + assert!(rows.is_empty()); +} + +#[tokio::test] +async fn lists_database_objects_from_name_column() { + let opts = MockDriverOptions { + columns: vec![col("schema"), col("name")], + query_rows: vec![ + vec![ + Some(Value::String("public".into())), + Some(Value::String("fn_ok".into())), + ], + vec![ + Some(Value::String("public".into())), + Some(Value::String("".into())), + ], + ], + ..Default::default() + }; + let test = TestAppState::with_options(opts).await; + let (_, conn_id) = test.save_and_connect("obj-list").await; + let rows = get_database_objects_impl(&test.state, conn_id, "function".into()) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name, "fn_ok"); + assert_eq!(rows[0].schema.as_deref(), Some("public")); + assert_eq!(rows[0].kind, "function"); +} + +#[tokio::test] +async fn lists_database_objects_preserves_signature_and_trigger_target() { + let opts = MockDriverOptions { + columns: vec![ + col("schema"), + col("name"), + col("signature"), + col("target_schema"), + col("target_name"), + ], + query_rows: vec![vec![ + Some(Value::String("public".into())), + Some(Value::String("audit".into())), + Some(Value::String("integer".into())), + Some(Value::String("public".into())), + Some(Value::String("orders".into())), + ]], + ..Default::default() + }; + let test = TestAppState::with_options(opts).await; + let (_, conn_id) = test.save_and_connect("obj-identity").await; + let rows = get_database_objects_impl(&test.state, conn_id, "trigger".into()) + .await + .unwrap(); + assert_eq!(rows[0].signature.as_deref(), Some("integer")); + assert_eq!(rows[0].target_schema.as_deref(), Some("public")); + assert_eq!(rows[0].target_name.as_deref(), Some("orders")); +} + +#[tokio::test] +async fn object_ddl_reads_named_or_second_column() { + let opts = MockDriverOptions { + columns: vec![col("Function"), col("Create Function")], + query_rows: vec![vec![ + Some(Value::String("fn_ok".into())), + Some(Value::String("CREATE FUNCTION fn_ok() ...".into())), + ]], + ..Default::default() + }; + let test = TestAppState::with_options(opts).await; + let (_, conn_id) = test.save_and_connect("obj-ddl").await; + let ddl = get_object_ddl_impl( + &test.state, + conn_id, + "function".into(), + "fn_ok".into(), + Some("public".into()), + ) + .await + .unwrap(); + assert!(ddl.contains("CREATE FUNCTION")); +} + +#[tokio::test] +async fn object_ddl_missing_object_fails_closed() { + let opts = MockDriverOptions { + columns: vec![col("ddl")], + query_rows: vec![], + ..Default::default() + }; + let test = TestAppState::with_options(opts).await; + let (_, conn_id) = test.save_and_connect("obj-missing-ddl").await; + let error = get_object_ddl_with_metadata_impl( + &test.state, + conn_id, + "function".into(), + "missing".into(), + Some("public".into()), + Some("integer".into()), + None, + None, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("not found")); +} + +#[tokio::test] +async fn object_catalog_preserves_permission_query_errors() { + let opts = MockDriverOptions { + query_error: Some("permission denied for relation pg_proc".into()), + ..Default::default() + }; + let test = TestAppState::with_options(opts).await; + let (_, conn_id) = test.save_and_connect("obj-permission").await; + let error = get_database_objects_impl(&test.state, conn_id, "function".into()) + .await + .unwrap_err(); + assert!(error.to_string().contains("permission denied")); +} + +#[tokio::test] +async fn privileges_map_grantee_and_skip_incomplete_rows() { + let opts = MockDriverOptions { + columns: vec![col("grantee"), col("schema"), col("name"), col("privilege")], + query_rows: vec![ + vec![ + Some(Value::String("alice".into())), + Some(Value::String("public".into())), + Some(Value::String("users".into())), + Some(Value::String("SELECT".into())), + ], + vec![None, None, None, None], + ], + ..Default::default() + }; + let test = TestAppState::with_options(opts).await; + let (_, conn_id) = test.save_and_connect("priv-list").await; + let grants = get_privileges_impl(&test.state, conn_id).await.unwrap(); + assert_eq!(grants.len(), 1); + assert_eq!(grants[0].grantee, "alice"); + assert_eq!(grants[0].object_name, "users"); + assert_eq!(grants[0].privilege, "SELECT"); +} + +#[tokio::test] +async fn get_table_data_joins_filters_with_or() { + use crate::db::{TableInfo, TableType}; + use crate::services::query_executor::FilterOperator; + use crate::services::FilterCondition; + + let opts = MockDriverOptions { + databases: vec!["app".into()], + tables: vec![TableInfo { + name: "items".into(), + schema: None, + table_type: TableType::Table, + row_count: None, + }], + columns: vec![col("id"), col("status")], + primary_keys: vec!["id".into()], + count_total: 1, + query_rows: vec![vec![ + Some(Value::Integer(1)), + Some(Value::String("a".into())), + ]], + ..Default::default() + }; + let test = TestAppState::with_options(opts).await; + let (_, conn_id) = test.save_and_connect("filter-or").await; + let data = get_table_data_impl( + &test.state, + conn_id, + "items".into(), + 0, + 50, + Some(vec![ + FilterCondition { + column: "id".into(), + operator: FilterOperator::Eq, + value: Value::Integer(1), + }, + FilterCondition { + column: "status".into(), + operator: FilterOperator::Eq, + value: Value::String("a".into()), + }, + ]), + None, + Some(true), + Some("or".into()), + None, + None, + ) + .await + .unwrap(); + assert_eq!(data.rows.len(), 1); +} + +#[tokio::test] +async fn get_table_data_reads_the_target_database_without_switching() { + let test = TestAppState::with_tables().await; + let (_, conn_id) = test.save_and_connect("tdata-pin-db").await; + // Sample config pins database = "app"; opening a table that lives in + // another database must read *that* database (BUG-002) — via the + // explicit argument, never by re-pointing the shared session. + let data = get_table_data_impl( + &test.state, + conn_id.clone(), + "users".into(), + 0, + 50, + None, + None, + Some(true), + None, + Some("analytics".into()), + None, + ) + .await + .unwrap(); + assert_eq!(data.rows.len(), 1); + assert!( + test.mock.use_database_calls().is_empty(), + "get_table_data must not switch the session's database" + ); + // The session keeps its own configured database; the request target is + // per-call. + let config = test + .state + .connection_manager + .get_session_config(&conn_id) + .await + .unwrap(); + assert_eq!(config.database.as_deref(), Some("app")); +} + +/// Regression (BUG-003): `get_er_data` listed the requested database's +/// tables but read every table's schema against whatever catalog the shared +/// session sat on. That produced a successful but column-less schema, which +/// the schema cache stored under the requested database and served for its +/// whole TTL — empty structure view, ER diagram without columns, and a data +/// grid with the right row count but blank cells. Each read now carries its +/// own `(database, schema)` target. +#[tokio::test] +async fn get_er_data_reads_each_tables_own_schema_and_never_caches_empty() { + use crate::db::{ColumnSchema, TableInfo, TableType}; + + fn column(name: &str) -> ColumnSchema { + ColumnSchema { + name: name.into(), + data_type: "text".into(), + nullable: true, + default_value: None, + comment: None, + is_primary_key: false, + is_auto_increment: false, + } + } + + let opts = MockDriverOptions { + databases: vec!["app".into(), "analytics".into()], + tables_by_database: HashMap::from([( + "analytics".to_string(), + vec![TableInfo { + name: "events".into(), + schema: None, + table_type: TableType::Table, + row_count: None, + }], + )]), + columns_by_database: HashMap::from([( + "analytics".to_string(), + HashMap::from([( + "events".to_string(), + vec![column("id"), column("name"), column("created_at")], + )]), + )]), + ..Default::default() + }; + let test = TestAppState::with_options(opts).await; + // Sample config pins database = "app"; the ER view targets "analytics". + let (_, conn_id) = test.save_and_connect("er-pin-db").await; + + let schemas = get_er_data_impl(&test.state, conn_id.clone(), "analytics".into(), None) + .await + .unwrap(); + + assert!( + test.mock.use_database_calls().is_empty(), + "get_er_data must read each table's schema explicitly, not by switching the session" + ); + assert_eq!(schemas.len(), 1); + assert_eq!( + schemas[0].columns.len(), + 3, + "columns must come from the requested database" + ); + + // The later reads of that database must see the same columns instead of + // a poisoned (empty) cache entry. + let columns = read_column_names( + &test.state, + conn_id.clone(), + "events".into(), + "analytics".into(), + None, + ) + .await + .unwrap(); + assert_eq!(columns, vec!["id", "name", "created_at"]); + + let schema = read_definition( + &test.state, + conn_id, + "events".into(), + "analytics".into(), + None, + ) + .await + .unwrap(); + assert_eq!(schema.columns.len(), 3); +} +async fn execute_metadata( + state: &AppState, + session: String, + command: &str, + input: serde_json::Value, +) -> Result { + super::super::driver_command::execute_driver_command_impl( + state, + super::super::driver_command::ExecuteDriverCommandRequest { + db_session_id: Some(session), + driver_type: None, + command: command.into(), + input, + database: None, + schema: None, + }, + ) + .await + .map(|result| result.data) +} + +async fn read_databases(state: &AppState, session: String) -> Result, CommandError> { + let data = execute_metadata(state, session, "list_databases", serde_json::json!({})).await?; + Ok(datazen_driver_api::parse_databases_from_command(&data)) +} + +async fn read_catalog( + state: &AppState, + session: String, + database: String, + schema: Option, +) -> Result, CommandError> { + let data = execute_metadata( + state, + session, + "list_catalog", + serde_json::json!({ "database": database, "schema": schema }), + ) + .await?; + let result: datazen_driver_api::schema_metadata::ListCatalogOutput = + serde_json::from_value(data)?; + Ok(result.relations) +} + +async fn read_column_names( + state: &AppState, + session: String, + name: String, + database: String, + schema: Option, +) -> Result, CommandError> { + let data = execute_metadata(state, session, "read_relation_columns", serde_json::json!({ "relations": [{ "database": database, "schema": schema, "name": name }] })).await?; + let result: datazen_driver_api::schema_metadata::ReadColumnsOutput = + serde_json::from_value(data)?; + match result + .results + .into_iter() + .next() + .expect("requested one relation") + { + datazen_driver_api::schema_metadata::ColumnsReadResult::Ok { value, .. } => Ok(value + .columns + .into_iter() + .map(|column| column.name) + .collect()), + datazen_driver_api::schema_metadata::ColumnsReadResult::Error { error, .. } => { + Err(CommandError::Internal(error.message)) + } + } +} + +async fn read_definition( + state: &AppState, + session: String, + name: String, + database: String, + schema: Option, +) -> Result { + let data = execute_metadata( + state, + session, + "read_relation_schema", + serde_json::json!({ "relation": { "database": database, "schema": schema, "name": name } }), + ) + .await?; + let result: datazen_driver_api::schema_metadata::RelationSchema = + serde_json::from_value(data["value"].clone())?; + Ok(result.definition) +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 8eca6d967..0c23f0a1f 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod connection_manager; pub mod db_tools; pub mod job_registry; pub mod query_executor; +pub mod schema_metadata; pub mod schema_scope; pub mod transaction; diff --git a/src-tauri/src/services/schema_metadata.rs b/src-tauri/src/services/schema_metadata.rs new file mode 100644 index 000000000..af3cba42b --- /dev/null +++ b/src-tauri/src/services/schema_metadata.rs @@ -0,0 +1,262 @@ +//! Host metadata policy, entered only after Driver Command access checks. +mod columns; + +use std::collections::HashSet; +use std::sync::Arc; + +use datazen_driver_api::schema_metadata::{ + ColumnsReadResult, ListCatalogOutput, MetadataReadError, MetadataReadErrorCode, + MetadataRefreshScope, ReadColumnsOutput, RelationColumns, RelationRef, RelationSchema, + RelationSummary, +}; +use datazen_driver_api::{ + validate_schema_target, CommandAccessLevel, CommandCategory, CommandResult, ConnectionHandle, + DatabaseDriver, DriverCommandDefinition, DriverCommandMetadata, DriverError, SchemaScope, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::commands::{AppState, CommandError}; + +pub fn is_metadata_command(command: &str) -> bool { + matches!( + command, + "list_catalog" + | "read_relation_columns" + | "read_relation_schema" + | "refresh_schema_metadata" + ) +} + +pub fn command_definitions() -> Vec { + let relation = json!({ + "type": "object", + "properties": { + "database": { "type": "string" }, + "schema": { "type": ["string", "null"] }, + "name": { "type": "string" } + }, + "required": ["database", "schema", "name"] + }); + [ + ( + "list_catalog", + json!({ + "type": "object", "properties": { + "database": { "type": "string" }, + "schema": { "type": ["string", "null"] } + }, "required": ["database", "schema"] + }), + ), + ( + "read_relation_columns", + json!({ + "type": "object", "properties": { + "relations": { "type": "array", "items": relation.clone() } + }, "required": ["relations"] + }), + ), + ( + "read_relation_schema", + json!({ + "type": "object", "properties": { "relation": relation }, + "required": ["relation"] + }), + ), + ( + "refresh_schema_metadata", + json!({ + "type": "object", "properties": { "scope": { "type": "object" } }, + "required": ["scope"] + }), + ), + ] + .into_iter() + .map(|(id, input_schema)| DriverCommandDefinition { + id: id.into(), + name: id.into(), + description: None, + input_schema, + output_schema: None, + permissions: vec!["driver.query".into()], + metadata: DriverCommandMetadata::new(CommandCategory::Query, CommandAccessLevel::Read) + .hide_from_workflow(), + }) + .collect() +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CatalogInput { + database: String, + schema: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ColumnsInput { + relations: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SchemaInput { + relation: RelationRef, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RefreshInput { + scope: MetadataRefreshScope, +} + +fn decode(value: Value) -> Result { + serde_json::from_value(value) + .map_err(|e| CommandError::Validation(format!("Invalid metadata request: {e}"))) +} + +fn validate_relation( + driver: &dyn DatabaseDriver, + relation: &RelationRef, +) -> Result<(), CommandError> { + if relation.database.trim().is_empty() { + return Err(CommandError::Validation("Database is required".into())); + } + if relation + .schema + .as_deref() + .is_some_and(|schema| schema.trim().is_empty()) + { + return Err(CommandError::Validation( + "Schema must be null or a non-blank name".into(), + )); + } + if relation.name.trim().is_empty() { + return Err(CommandError::Validation("Relation name is required".into())); + } + validate_schema_target( + driver, + &relation.database, + relation.schema.as_deref(), + SchemaScope::ExactSchema, + )?; + Ok(()) +} + +pub async fn execute( + state: &AppState, + db_session_id: &str, + driver: &Arc, + handle: &ConnectionHandle, + command: &str, + input: Value, +) -> Result { + let data = match command { + "refresh_schema_metadata" => { + let input: RefreshInput = decode(input)?; + match input.scope { + MetadataRefreshScope::Session => { + state.schema_cache.clear_connection(db_session_id).await + } + MetadataRefreshScope::Database { database } => { + if database.trim().is_empty() { + return Err(CommandError::Validation("Database is required".into())); + } + state + .schema_cache + .invalidate(db_session_id, &database, None) + .await; + } + MetadataRefreshScope::Relation { relation } => { + validate_relation(driver.as_ref(), &relation)?; + state + .schema_cache + .invalidate_relation( + db_session_id, + &relation.database, + relation.schema.as_deref(), + &relation.name, + ) + .await; + } + } + json!({ "revision": state.schema_cache.generation() }) + } + "list_catalog" => { + let input: CatalogInput = decode(input)?; + if input.database.trim().is_empty() { + return Err(CommandError::Validation("Database is required".into())); + } + validate_schema_target( + driver.as_ref(), + &input.database, + input.schema.as_deref(), + SchemaScope::AnySchema, + )?; + // Preserve driver-owned hierarchical catalog commands as well as + // the standard trait-backed list_tables implementation. + let result = driver + .execute_command( + handle, + "list_tables", + json!({ + "database": input.database, "schema": input.schema, + }), + ) + .await?; + let tables = datazen_driver_api::parse_tables_from_command(&result.data); + let mut schemas: Vec = tables.iter().filter_map(|t| t.schema.clone()).collect(); + schemas.sort(); + schemas.dedup(); + let relations = tables + .into_iter() + .filter(|t| !t.name.is_empty()) + .map(|t| RelationSummary { + relation: RelationRef { + database: input.database.clone(), + schema: t.schema, + name: t.name, + }, + kind: t.table_type, + row_count: t.row_count, + }) + .collect(); + serde_json::to_value(ListCatalogOutput { + database: input.database, + schemas, + relations, + })? + } + "read_relation_columns" => { + let input: ColumnsInput = decode(input)?; + serde_json::to_value( + columns::read(state, db_session_id, driver, handle, input.relations).await?, + )? + } + "read_relation_schema" => { + let input: SchemaInput = decode(input)?; + validate_relation(driver.as_ref(), &input.relation)?; + let definition = state + .schema_cache + .get_table_schema( + db_session_id, + &input.relation.database, + input.relation.schema.as_deref(), + &input.relation.name, + driver, + handle, + ) + .await?; + json!({ "value": RelationSchema { relation: input.relation, definition } }) + } + _ => { + return Err(CommandError::Validation( + "Unsupported metadata command".into(), + )) + } + }; + Ok(CommandResult::new(data)) +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/services/schema_metadata/columns.rs b/src-tauri/src/services/schema_metadata/columns.rs new file mode 100644 index 000000000..04d8f7497 --- /dev/null +++ b/src-tauri/src/services/schema_metadata/columns.rs @@ -0,0 +1,136 @@ +use super::*; +use crate::cache::schema_cache::CachedColumns; +use std::{collections::HashMap, time::Instant}; + +/// Group only exact scopes. The legacy bare-name batch payload is safe within +/// one schema, never across schemas with same-named relations. +pub(super) async fn read( + state: &AppState, + session: &str, + driver: &Arc, + handle: &ConnectionHandle, + relations: Vec, +) -> Result { + if relations.len() > 256 { + return Err(CommandError::Validation( + "At most 256 relations per metadata request".into(), + )); + } + for relation in &relations { + validate_relation(driver.as_ref(), relation)?; + } + let mut seen = HashSet::new(); + let relations: Vec<_> = relations + .into_iter() + .filter(|r| seen.insert(r.clone())) + .collect(); + let mut values = HashMap::new(); + let mut groups: HashMap<(String, Option), Vec> = HashMap::new(); + for relation in &relations { + if let Some(cached) = state + .schema_cache + .try_get_cached_columns( + session, + &relation.database, + relation.schema.as_deref(), + &relation.name, + ) + .await + { + values.insert(relation.clone(), Ok(cached)); + } else { + groups + .entry((relation.database.clone(), relation.schema.clone())) + .or_default() + .push(relation.clone()); + } + } + for ((database, schema), missing) in groups { + let generation = state.schema_cache.generation(); + let mut batch = if missing.len() > 1 { + match driver + .get_all_columns(handle, &database, schema.as_deref()) + .await + { + Ok(batch) => batch, + Err(DriverError::Unsupported(_) | DriverError::NotSupported(_)) => HashMap::new(), + Err(error) => { + let message = crate::log_redact::redact_secrets_for_log(&error.to_string()); + for relation in missing { + values.insert( + relation, + Err(MetadataReadError { + code: MetadataReadErrorCode::ReadFailed, + message: message.clone(), + }), + ); + } + continue; + } + } + } else { + HashMap::new() + }; + for relation in missing { + let columns = if let Some((columns, primary_keys)) = batch.remove(&relation.name) { + let cached = CachedColumns { + columns, + primary_keys, + table_name: relation.name.clone(), + cached_at: Instant::now(), + }; + state + .schema_cache + .store_columns_if_current( + session, + &database, + schema.as_deref(), + &relation.name, + cached.clone(), + generation, + ) + .await; + Ok(cached) + } else { + state + .schema_cache + .get_columns( + session, + &database, + schema.as_deref(), + &relation.name, + driver, + handle, + ) + .await + .map_err(|error| MetadataReadError { + code: match &error { + DriverError::Unsupported(_) | DriverError::NotSupported(_) => { + MetadataReadErrorCode::Unsupported + } + _ => MetadataReadErrorCode::ReadFailed, + }, + message: crate::log_redact::redact_secrets_for_log(&error.to_string()), + }) + }; + values.insert(relation, columns); + } + } + let mut results = Vec::with_capacity(relations.len()); + for relation in relations { + let value = values.remove(&relation).ok_or_else(|| { + CommandError::Internal("Metadata batch omitted a requested relation".into()) + })?; + results.push(match value { + Ok(columns) => ColumnsReadResult::Ok { + value: RelationColumns { + relation, + columns: columns.columns, + primary_keys: columns.primary_keys, + }, + }, + Err(error) => ColumnsReadResult::Error { relation, error }, + }); + } + Ok(ReadColumnsOutput { results }) +} diff --git a/src-tauri/src/services/schema_metadata/tests.rs b/src-tauri/src/services/schema_metadata/tests.rs new file mode 100644 index 000000000..ed8f6089d --- /dev/null +++ b/src-tauri/src/services/schema_metadata/tests.rs @@ -0,0 +1,239 @@ +use super::*; +use crate::commands::driver_command::{execute_driver_command_impl, ExecuteDriverCommandRequest}; +use crate::testing::{app_state::TestAppState, mock_driver::MockDriverOptions}; + +async fn run( + test: &TestAppState, + session: &str, + command: &str, + input: Value, +) -> Result { + execute_driver_command_impl( + &test.state, + ExecuteDriverCommandRequest { + db_session_id: Some(session.into()), + driver_type: None, + command: command.into(), + input, + database: None, + schema: None, + }, + ) + .await +} + +#[tokio::test] +async fn reads_deduplicate_exact_identities_and_cache_distinct_schemas() { + let test = TestAppState::with_options(MockDriverOptions { + has_schema_level: true, + ..Default::default() + }) + .await; + let (_, session) = test.save_and_connect("metadata-identities").await; + let input = json!({ "relations": [ + { "database": "app", "schema": "public", "name": "users" }, + { "database": "app", "schema": "archive", "name": "users" }, + { "database": "app", "schema": "public", "name": "users" } + ] }); + let result = run(&test, &session, "read_relation_columns", input.clone()) + .await + .unwrap(); + let decoded: ReadColumnsOutput = serde_json::from_value(result.data).unwrap(); + assert_eq!(decoded.results.len(), 2); + assert_eq!(test.mock.get_columns_calls(), 2); + run(&test, &session, "read_relation_columns", input) + .await + .unwrap(); + assert_eq!(test.mock.get_columns_calls(), 2); + assert!(test.mock.use_database_calls().is_empty()); +} + +#[tokio::test] +async fn invalid_target_fails_preflight_before_any_relation_read() { + let test = TestAppState::with_options(MockDriverOptions { + has_schema_level: true, + ..Default::default() + }) + .await; + let (_, session) = test.save_and_connect("metadata-preflight").await; + let error = run( + &test, + &session, + "read_relation_columns", + json!({ "relations": [ + { "database": "app", "schema": "public", "name": "users" }, + { "database": "app", "schema": null, "name": "orders" } + ] }), + ) + .await + .unwrap_err(); + assert!(matches!( + error, + CommandError::Driver(_) | CommandError::Validation(_) + )); + assert_eq!(test.mock.get_columns_calls(), 0); +} + +#[tokio::test] +async fn catalog_includes_empty_schemas_without_fake_relations() { + let test = TestAppState::with_options(MockDriverOptions { + has_schema_level: true, + tables: vec![ + datazen_driver_api::TableInfo { + name: "".into(), + schema: Some("empty".into()), + table_type: datazen_driver_api::TableType::Table, + row_count: None, + }, + datazen_driver_api::TableInfo { + name: "users".into(), + schema: Some("public".into()), + table_type: datazen_driver_api::TableType::Table, + row_count: Some(2), + }, + ], + ..Default::default() + }) + .await; + let (_, session) = test.save_and_connect("metadata-catalog").await; + let result = run( + &test, + &session, + "list_catalog", + json!({ "database": "app", "schema": null }), + ) + .await + .unwrap(); + let catalog: ListCatalogOutput = serde_json::from_value(result.data).unwrap(); + assert_eq!(catalog.schemas, ["empty", "public"]); + assert_eq!(catalog.relations.len(), 1); + assert_eq!(catalog.relations[0].relation.name, "users"); +} + +#[tokio::test] +async fn full_schema_populates_columns_tier_for_the_same_identity() { + let test = TestAppState::with_tables().await; + let (_, session) = test.save_and_connect("metadata-full").await; + let relation = json!({ "database": "app", "schema": null, "name": "users" }); + let result = run( + &test, + &session, + "read_relation_schema", + json!({ "relation": relation }), + ) + .await + .unwrap(); + assert_eq!(result.data["value"]["ref"]["database"], "app"); + run( + &test, + &session, + "read_relation_columns", + json!({ "relations": [relation] }), + ) + .await + .unwrap(); + assert_eq!(test.mock.get_columns_calls(), 0); +} + +#[tokio::test] +async fn metadata_envelope_target_is_rejected_instead_of_ignored() { + let test = TestAppState::with_tables().await; + let (_, session) = test.save_and_connect("metadata-envelope").await; + let error = execute_driver_command_impl( + &test.state, + ExecuteDriverCommandRequest { + db_session_id: Some(session), + driver_type: None, + command: "list_catalog".into(), + input: json!({ "database": "app", "schema": null }), + database: Some("other".into()), + schema: None, + }, + ) + .await + .unwrap_err(); + assert!(matches!(error, CommandError::Validation(_))); +} + +#[tokio::test] +async fn a_batch_preserves_successes_and_redacts_per_relation_failures() { + let test = TestAppState::with_options(MockDriverOptions { + column_errors_by_table: std::collections::HashMap::from([( + "denied".into(), + "postgres://root:hunter2@localhost/app".into(), + )]), + ..Default::default() + }) + .await; + let (_, session) = test.save_and_connect("metadata-partial").await; + let result = run( + &test, + &session, + "read_relation_columns", + json!({ "relations": [ + { "database": "app", "schema": null, "name": "users" }, + { "database": "app", "schema": null, "name": "denied" } + ] }), + ) + .await + .unwrap(); + assert_eq!(result.data["results"][0]["status"], "ok"); + assert_eq!(result.data["results"][1]["status"], "error"); + assert_eq!(result.data["results"][1]["ref"]["name"], "denied"); + assert_eq!(result.data["results"][1]["error"]["code"], "read-failed"); + assert!(!result.data.to_string().contains("hunter2")); +} + +#[tokio::test] +async fn relation_refresh_invalidates_only_the_exact_schema_identity() { + let test = TestAppState::with_options(MockDriverOptions { + has_schema_level: true, + ..Default::default() + }) + .await; + let (_, session) = test.save_and_connect("metadata-refresh").await; + let input = json!({ "relations": [ + { "database": "app", "schema": "public", "name": "users" }, + { "database": "app", "schema": "archive", "name": "users" } + ] }); + run(&test, &session, "read_relation_columns", input.clone()) + .await + .unwrap(); + let before = test.state.schema_cache.generation(); + let result = run( + &test, + &session, + "refresh_schema_metadata", + json!({ "scope": { + "kind": "relation", "relation": { "database": "app", "schema": "public", "name": "users" } + } }), + ) + .await + .unwrap(); + assert!(result.data["revision"].as_u64().unwrap() > before); + run(&test, &session, "read_relation_columns", input) + .await + .unwrap(); + assert_eq!(test.mock.get_columns_calls(), 3); +} + +#[tokio::test] +async fn catalog_preserves_driver_owned_list_tables_command_results() { + let test = TestAppState::with_options(MockDriverOptions { + command_results: std::collections::HashMap::from([("list_tables".into(), json!({ + "tables": [{ "name": "custom_collection", "schema": null, "tableType": "table", "rowCount": null }] + }))]), ..Default::default() + }).await; + let (_, session) = test.save_and_connect("metadata-driver-catalog").await; + let result = run( + &test, + &session, + "list_catalog", + json!({ "database": "app", "schema": null }), + ) + .await + .unwrap(); + let catalog: ListCatalogOutput = serde_json::from_value(result.data).unwrap(); + assert_eq!(catalog.relations.len(), 1); + assert_eq!(catalog.relations[0].relation.name, "custom_collection"); +} diff --git a/src-tauri/src/testing/mock_driver.rs b/src-tauri/src/testing/mock_driver.rs index b6ef7faaa..c54456a64 100644 --- a/src-tauri/src/testing/mock_driver.rs +++ b/src-tauri/src/testing/mock_driver.rs @@ -26,6 +26,8 @@ pub struct MockDriverOptions { pub category: DriverCategory, pub columns: Vec, pub primary_keys: Vec, + /// Per-relation read failures for metadata batch partial-success tests. + pub column_errors_by_table: HashMap, pub table_schema: Option, pub query_rows: Vec>>, /// Keyset pages after the first are empty for resume-executor tests. @@ -41,6 +43,7 @@ pub struct MockDriverOptions { pub explain_plan: ExplainResult, pub server_version: String, pub extra_commands: Vec, + pub command_results: HashMap, pub query_error: Option, /// When set, precise execution-handle cancellation returns a driver-level /// error for command tests. The legacy session-wide method remains a @@ -100,6 +103,7 @@ impl Default for MockDriverOptions { category: DriverCategory::Sql, columns: Vec::new(), primary_keys: Vec::new(), + column_errors_by_table: HashMap::new(), table_schema: None, query_rows: Vec::new(), empty_keyset_after_cursor: false, @@ -117,6 +121,7 @@ impl Default for MockDriverOptions { }, server_version: String::new(), extra_commands: Vec::new(), + command_results: HashMap::new(), query_error: None, cancel_error: None, execute_rows_affected: 0, @@ -479,6 +484,9 @@ impl DatabaseDriver for MockDriver { ) -> Result<(Vec, Vec), DriverError> { validate_schema_target(self, database, schema, SchemaScope::ExactSchema)?; self.get_columns_calls.fetch_add(1, Ordering::Relaxed); + if let Some(message) = self.opts.column_errors_by_table.get(table) { + return Err(DriverError::QueryFailed(message.clone())); + } if let Some(columns) = self.columns_for_database(database, table) { let primary_keys = columns .iter() @@ -783,6 +791,9 @@ impl DatabaseDriver for MockDriver { command: &str, input: serde_json::Value, ) -> Result { + if let Some(data) = self.opts.command_results.get(command) { + return Ok(CommandResult::new(data.clone())); + } if self .opts .extra_commands diff --git a/src/commands/__tests__/database.metadata.test.ts b/src/commands/__tests__/database.metadata.test.ts new file mode 100644 index 000000000..204a3f433 --- /dev/null +++ b/src/commands/__tests__/database.metadata.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { databaseCommands } from '../database'; +const invoke = vi.hoisted(() => vi.fn()); +vi.mock('@tauri-apps/api/core', () => ({ invoke })); + +describe('database metadata facade', () => { + beforeEach(() => { + invoke.mockReset(); + }); + + it('loads database names through the command gateway', async () => { + invoke.mockResolvedValue({ data: { databases: ['app'] } }); + expect(await databaseCommands.getDatabases('session')).toEqual(['app']); + expect(invoke).toHaveBeenCalledExactlyOnceWith('execute_driver_command', { + request: { dbSessionId: 'session', command: 'list_databases', input: {} }, + }); + }); + + it('preserves same-named relations and empty schemas at the tree adapter boundary', async () => { + invoke.mockResolvedValue({ + data: { + database: 'app', + schemas: ['public', 'archive', 'empty'], + relations: [ + { ref: { database: 'app', schema: 'public', name: 'users' }, kind: 'table', rowCount: 2 }, + { + ref: { database: 'app', schema: 'archive', name: 'users' }, + kind: 'view', + rowCount: null, + }, + ], + }, + }); + expect(await databaseCommands.listTables('session', 'app')).toEqual([ + { name: 'users', schema: 'public', tableType: 'table', rowCount: 2 }, + { name: 'users', schema: 'archive', tableType: 'view', rowCount: null }, + { name: '', schema: 'empty', tableType: 'table', rowCount: null }, + ]); + expect(invoke).toHaveBeenCalledExactlyOnceWith('execute_driver_command', { + request: { + dbSessionId: 'session', + command: 'list_catalog', + input: { database: 'app', schema: null }, + }, + }); + }); +}); diff --git a/src/commands/database.ts b/src/commands/database.ts index 79055395e..06bf91833 100644 --- a/src/commands/database.ts +++ b/src/commands/database.ts @@ -1,3 +1,4 @@ +import { schemaClient } from '@datazen/driver-sdk'; import { invoke } from '@tauri-apps/api/core'; import type { FilterCondition, @@ -7,7 +8,6 @@ import type { TableDataResult, TableInfo, TableSchema, - ColumnSchema, Value, } from '../types'; import type { @@ -44,44 +44,36 @@ export interface CommitPendingChangesRequest { } export const databaseCommands = { - getDatabases: (dbSessionId: string) => invoke('get_databases', { dbSessionId }), + getDatabases: async (dbSessionId: string) => + (await schemaClient.listDatabases(dbSessionId)).databases, /** * List a database's tables. `schema` is optional and only meaningful for * schema-aware engines (PostgreSQL, SQL Server); omit it to list every schema. */ - getTables: (dbSessionId: string, database: string, schema?: string | null) => - invoke('get_tables', { dbSessionId, database, schema: schema ?? null }), - - /** - * `schema` is required for schema-aware engines and must be the table's own - * schema — pass `TableInfo.schema`, never a guess. - */ - getColumns: (dbSessionId: string, table: string, database: string, schema?: string | null) => - invoke('get_columns', { dbSessionId, table, database, schema: schema ?? null }), - - getColumnsTyped: (dbSessionId: string, table: string, database: string, schema?: string | null) => - invoke('get_columns_typed', { - dbSessionId, - table, - database, - schema: schema ?? null, - }), - - getAllColumns: (dbSessionId: string, database: string, schema?: string | null) => - invoke>('get_all_columns', { - dbSessionId, - database, - schema: schema ?? null, - }), - - getTableSchema: (dbSessionId: string, table: string, database: string, schema?: string | null) => - invoke('get_table_schema', { - dbSessionId, - table, + listTables: async ( + dbSessionId: string, + database: string, + schema?: string | null, + ): Promise => { + const catalog = await schemaClient.listCatalog(dbSessionId, { database, schema: schema ?? null, - }), + }); + const tables: TableInfo[] = catalog.relations.map((relation) => ({ + name: relation.ref.name, + schema: relation.ref.schema, + tableType: relation.kind, + rowCount: relation.rowCount, + })); + // The legacy namespace tree still represents empty schemas with a sentinel. + const populated = new Set(tables.map((table) => table.schema)); + for (const schema of catalog.schemas) { + if (!populated.has(schema)) + tables.push({ name: '', schema, tableType: 'table', rowCount: null }); + } + return tables; + }, getErData: (dbSessionId: string, database: string, schema?: string | null) => invoke('get_er_data', { dbSessionId, database, schema: schema ?? null }), diff --git a/src/components/ai/ContextPicker.tsx b/src/components/ai/ContextPicker.tsx index e78e59b0f..596fefc42 100644 --- a/src/components/ai/ContextPicker.tsx +++ b/src/components/ai/ContextPicker.tsx @@ -123,7 +123,7 @@ export function ContextPicker({ let cancelled = false; setLoadingTables(true); databaseCommands - .getTables(dbSessionId, database) + .listTables(dbSessionId, database) .then((data) => { if (!cancelled) { setTables(data.map((tbl) => tableToItem(tbl, database))); diff --git a/src/components/ai/__tests__/ContextPicker.test.tsx b/src/components/ai/__tests__/ContextPicker.test.tsx index e31b4e1d0..808cdf101 100644 --- a/src/components/ai/__tests__/ContextPicker.test.tsx +++ b/src/components/ai/__tests__/ContextPicker.test.tsx @@ -25,7 +25,7 @@ vi.mock('../../../commands/context', () => ({ vi.mock('../../../commands/database', () => ({ databaseCommands: { - getTables: (...args: Parameters) => mockGetTables(...args), + listTables: (...args: Parameters) => mockGetTables(...args), }, })); diff --git a/src/components/sql-editor/completion/schemaCompletion.ts b/src/components/sql-editor/completion/schemaCompletion.ts index c83cf67db..3e130c41b 100644 --- a/src/components/sql-editor/completion/schemaCompletion.ts +++ b/src/components/sql-editor/completion/schemaCompletion.ts @@ -38,7 +38,7 @@ export interface SchemaCompletionOptions { model: SqlSemanticModel; snapshot: EditorMetadataSnapshot; adapter?: SqlDialectAdapter; - /** Full editor schema tree (from `useSchemaStore.columnMap` via `buildEditorSchema()`). */ + /** Full editor schema tree (from the session relation-column projection via `buildEditorSchema()`). */ schema?: SQLNamespace; /** Identifier quoting policy ('unquoted' | 'always' | 'both'). Default 'unquoted'. */ quotePolicy?: CompletionQuotePolicy; @@ -330,7 +330,7 @@ function allColumnsFromSnapshot( * Used as a projection fallback when no FROM clause is present AND the * metadata snapshot is empty (i.e. `allColumnsFromSnapshot` returns nothing). * - * The `schema` is the `SqlNamespace` tree built from `useSchemaStore.columnMap` + * The `schema` is the `SqlNamespace` tree built from the session relation-column projection * via `buildEditorSchema()` — it contains ALL tables and columns loaded when * the connection opens, regardless of what's referenced in the current SQL. * diff --git a/src/components/sql-editor/metadata/__tests__/metadataCache.test.ts b/src/components/sql-editor/metadata/__tests__/metadataCache.test.ts index 0d63c7833..24c650d34 100644 --- a/src/components/sql-editor/metadata/__tests__/metadataCache.test.ts +++ b/src/components/sql-editor/metadata/__tests__/metadataCache.test.ts @@ -299,4 +299,54 @@ describe('metadataCache', () => { vi.useRealTimers(); }); }); + it('switching schema during a read rejects old data and keeps the new request alive', async () => { + let resolveOld!: (value: TableSchema) => void; + let resolveNew!: (value: TableSchema) => void; + loadTableSchema + .mockReturnValueOnce( + new Promise((r) => { + resolveOld = r; + }), + ) + .mockReturnValueOnce( + new Promise((r) => { + resolveNew = r; + }), + ); + const request: EditorRelationRequest = { identity: ident([], 'users') }; + cache.ensureRelations('s1', [request], ctx); + const old = cache.flushNow(); + cache.ensureRelations('s1', [request], { ...ctx, schema: 'archive' }); + const fresh = cache.flushNow(); + resolveOld(makeSchema('old')); + await old; + expect(cache.getSnapshot('s1').relations.size).toBe(0); + cache.ensureRelations('s1', [request], { ...ctx, schema: 'archive' }); + await cache.flushNow(); + expect(loadTableSchema).toHaveBeenCalledTimes(2); + resolveNew(makeSchema('users')); + await fresh; + expect(cache.getSnapshot('s1').schema).toBe('archive'); + expect(cache.getSnapshot('s1').relations.size).toBe(1); + }); + + it('relation invalidation during a read rejects its late result', async () => { + let resolveOld!: (value: TableSchema) => void; + loadTableSchema.mockReturnValueOnce( + new Promise((r) => { + resolveOld = r; + }), + ); + const identity = ident([], 'users'); + cache.ensureRelations('s1', [{ identity }], ctx); + const old = cache.flushNow(); + cache.invalidateRelation('s1', identity, ctx.dialectId); + resolveOld(makeSchema('users')); + await old; + expect(cache.getSnapshot('s1').relations.size).toBe(0); + loadTableSchema.mockResolvedValueOnce(makeSchema('users')); + cache.ensureRelations('s1', [{ identity }], ctx); + await cache.flushNow(); + expect(cache.getSnapshot('s1').relations.size).toBe(1); + }); }); diff --git a/src/lib/__tests__/ensureNamespace.test.ts b/src/lib/__tests__/ensureNamespace.test.ts index f8d3451cb..016acd840 100644 --- a/src/lib/__tests__/ensureNamespace.test.ts +++ b/src/lib/__tests__/ensureNamespace.test.ts @@ -43,7 +43,7 @@ function makeDeps(overrides: Partial = {}): EnsureDeps { databases: [], currentDatabase: null, getDatabases: vi.fn().mockResolvedValue([]), - getTables: vi.fn().mockResolvedValue([]), + listTables: vi.fn().mockResolvedValue([]), ...overrides, pathAliases: overrides.pathAliases ?? pathAliases, loadedPaths: overrides.loadedPaths ?? loadedPaths, @@ -90,20 +90,20 @@ describe('ensureNamespacePath — path-hierarchy', () => { const deps = makeDeps({ currentDatabase: 'presto', databases: ['presto'], - getTables: vi + listTables: vi .fn() .mockResolvedValue([ { name: '558/hive', schema: 'CATALOG', tableType: 'table', rowCount: null }, ] satisfies TableInfo[]), }); await ensureNamespacePath([], deps); - expect(deps.getTables).toHaveBeenCalledWith('conn-1', '558'); + expect(deps.listTables).toHaveBeenCalledWith('conn-1', '558'); expect(deps.mergeNamespace).toHaveBeenCalledWith(['presto'], 'branch', ['hive']); }); it("['presto'] fetches catalogs with aliased root id", async () => { const deps = makeDeps({ - getTables: vi.fn().mockResolvedValue([ + listTables: vi.fn().mockResolvedValue([ { name: '558/hive', schema: 'CATALOG', tableType: 'table', rowCount: null }, { name: '558/iceberg', schema: 'CATALOG', tableType: 'table', rowCount: null }, ] satisfies TableInfo[]), @@ -111,14 +111,14 @@ describe('ensureNamespacePath — path-hierarchy', () => { await ensureNamespacePath(['presto'], deps); - expect(deps.getTables).toHaveBeenCalledWith('conn-1', '558'); + expect(deps.listTables).toHaveBeenCalledWith('conn-1', '558'); expect(deps.mergeNamespace).toHaveBeenCalledWith(['presto'], 'branch', ['hive', 'iceberg']); expect(deps.loadedPaths.has('presto')).toBe(true); }); it("['presto','hive'] fetches schemas at 558/hive", async () => { const deps = makeDeps({ - getTables: vi.fn().mockResolvedValue([ + listTables: vi.fn().mockResolvedValue([ { name: '558/hive/snap', schema: 'SCHEMA', tableType: 'table', rowCount: null }, { name: '558/hive/raw', schema: 'SCHEMA', tableType: 'table', rowCount: null }, ] satisfies TableInfo[]), @@ -126,13 +126,13 @@ describe('ensureNamespacePath — path-hierarchy', () => { await ensureNamespacePath(['presto', 'hive'], deps); - expect(deps.getTables).toHaveBeenCalledWith('conn-1', '558/hive'); + expect(deps.listTables).toHaveBeenCalledWith('conn-1', '558/hive'); expect(deps.mergeNamespace).toHaveBeenCalledWith(['presto', 'hive'], 'branch', ['snap', 'raw']); }); it("['presto','hive','snap'] merges table leaves", async () => { const deps = makeDeps({ - getTables: vi.fn().mockResolvedValue([ + listTables: vi.fn().mockResolvedValue([ { name: 't1', schema: 'snap', tableType: 'table', rowCount: null }, { name: 'v1', schema: 'snap', tableType: 'view', rowCount: null }, ] satisfies TableInfo[]), @@ -140,7 +140,7 @@ describe('ensureNamespacePath — path-hierarchy', () => { await ensureNamespacePath(['presto', 'hive', 'snap'], deps); - expect(deps.getTables).toHaveBeenCalledWith('conn-1', '558/hive/snap'); + expect(deps.listTables).toHaveBeenCalledWith('conn-1', '558/hive/snap'); expect(deps.mergeNamespace).toHaveBeenCalledWith(['presto', 'hive', 'snap'], 'tables', ['t1']); }); @@ -148,24 +148,24 @@ describe('ensureNamespacePath — path-hierarchy', () => { const deps = makeDeps(); deps.loadedPaths.add('presto'); await ensureNamespacePath(['presto'], deps); - expect(deps.getTables).not.toHaveBeenCalled(); + expect(deps.listTables).not.toHaveBeenCalled(); }); - it('reuses cached path items without calling getTables', async () => { + it('reuses cached path items without calling listTables', async () => { const items = [ { name: '558/hive', schema: 'CATALOG', tableType: 'table', rowCount: null }, ] satisfies TableInfo[]; const deps = makeDeps({ pathItems: { '558': items } }); await ensureNamespacePath(['presto'], deps); - expect(deps.getTables).not.toHaveBeenCalled(); + expect(deps.listTables).not.toHaveBeenCalled(); expect(deps.mergeNamespace).toHaveBeenCalledWith(['presto'], 'branch', ['hive']); }); - it('writes getTables results into the shared pathItems cache', async () => { + it('writes listTables results into the shared pathItems cache', async () => { const items = [ { name: '558/hive', schema: 'CATALOG', tableType: 'table', rowCount: null }, ] satisfies TableInfo[]; - const deps = makeDeps({ getTables: vi.fn().mockResolvedValue(items) }); + const deps = makeDeps({ listTables: vi.fn().mockResolvedValue(items) }); await ensureNamespacePath(['presto'], deps); expect(deps.cachePathItems).toHaveBeenCalledWith('558', items); expect(deps.pathItems['558']).toEqual(items); @@ -177,19 +177,19 @@ describe('ensureNamespacePath — path-hierarchy', () => { resolveTables = resolve; }); const deps = makeDeps({ - getTables: vi.fn().mockReturnValue(tablesPromise), + listTables: vi.fn().mockReturnValue(tablesPromise), }); const p1 = ensureNamespacePath(['presto'], deps); const p2 = ensureNamespacePath(['presto'], deps); resolveTables([{ name: '558/hive', schema: 'CATALOG', tableType: 'table', rowCount: null }]); await Promise.all([p1, p2]); - expect(deps.getTables).toHaveBeenCalledTimes(1); + expect(deps.listTables).toHaveBeenCalledTimes(1); }); it('swallows errors without marking loaded', async () => { const deps = makeDeps({ - getTables: vi.fn().mockRejectedValue(new Error('network')), + listTables: vi.fn().mockRejectedValue(new Error('network')), }); await ensureNamespacePath(['presto'], deps); expect(deps.loadedPaths.has('presto')).toBe(false); @@ -239,11 +239,11 @@ describe('ensureNamespacePath — default-sql (mysql)', () => { expect(deps.mergeNamespace).toHaveBeenCalledWith([], 'branch', ['app', 'test']); }); - it("['app'] uses getTables and excludes views", async () => { + it("['app'] uses listTables and excludes views", async () => { const deps = makeDeps({ databaseType: 'mysql', pathAliases: {}, - getTables: vi.fn().mockResolvedValue([ + listTables: vi.fn().mockResolvedValue([ { name: 'users', schema: undefined, tableType: 'table', rowCount: null }, { name: 'v_users', schema: undefined, tableType: 'view', rowCount: null }, ] satisfies TableInfo[]), @@ -251,7 +251,7 @@ describe('ensureNamespacePath — default-sql (mysql)', () => { await ensureNamespacePath(['app'], deps); - expect(deps.getTables).toHaveBeenCalledWith('conn-1', 'app'); + expect(deps.listTables).toHaveBeenCalledWith('conn-1', 'app'); expect(deps.mergeNamespace).toHaveBeenCalledWith(['app'], 'tables', ['users']); }); @@ -262,14 +262,14 @@ describe('ensureNamespacePath — default-sql (mysql)', () => { currentDatabase: 'app', databases: ['app', 'test'], getDatabases: vi.fn().mockResolvedValue(['app', 'test']), - getTables: vi + listTables: vi .fn() .mockResolvedValue([ { name: 'users', schema: undefined, tableType: 'table', rowCount: null }, ] satisfies TableInfo[]), }); await ensureNamespacePath([], deps); - expect(deps.getTables).toHaveBeenCalledWith('conn-1', 'app'); + expect(deps.listTables).toHaveBeenCalledWith('conn-1', 'app'); expect(deps.mergeNamespace).toHaveBeenCalledWith(['app'], 'tables', ['users']); }); }); @@ -293,7 +293,7 @@ describe('ensureNamespacePath — postgresql', () => { isMultiDatabase: false, databases: ['app'], currentDatabase: 'app', - getTables: vi.fn().mockResolvedValue([ + listTables: vi.fn().mockResolvedValue([ { name: 'users', schema: 'public', tableType: 'table', rowCount: null }, { name: 'v_users', schema: 'public', tableType: 'view', rowCount: null }, { name: 'logs', schema: 'audit', tableType: 'table', rowCount: null }, @@ -310,7 +310,7 @@ describe('ensureNamespacePath — postgresql', () => { databaseType: 'postgresql', pathAliases: {}, isMultiDatabase: true, - getTables: vi + listTables: vi .fn() .mockResolvedValue([ { name: 't1', schema: 'public', tableType: 'table', rowCount: null }, @@ -330,7 +330,7 @@ describe('ensureNamespacePath — postgresql', () => { databases: ['app'], }); await ensureNamespacePath(['public'], deps); - expect(deps.getTables).not.toHaveBeenCalled(); + expect(deps.listTables).not.toHaveBeenCalled(); expect(deps.mergeNamespace).toHaveBeenCalledWith(['public'], 'tables', ['users']); }); @@ -342,14 +342,14 @@ describe('ensureNamespacePath — postgresql', () => { tables: [], databases: ['app'], currentDatabase: 'app', - getTables: vi.fn().mockResolvedValue([ + listTables: vi.fn().mockResolvedValue([ { name: 'users', schema: 'public', tableType: 'table', rowCount: null }, { name: 'v_users', schema: 'public', tableType: 'view', rowCount: null }, { name: 'other', schema: 'audit', tableType: 'table', rowCount: null }, ] satisfies TableInfo[]), }); await ensureNamespacePath(['public'], deps); - expect(deps.getTables).toHaveBeenCalledWith('conn-1', 'app'); + expect(deps.listTables).toHaveBeenCalledWith('conn-1', 'app'); expect(deps.mergeNamespace).toHaveBeenCalledWith(['public'], 'tables', ['users']); }); diff --git a/src/lib/__tests__/schemaCache.test.ts b/src/lib/__tests__/schemaCache.test.ts index da43ee556..904d45139 100644 --- a/src/lib/__tests__/schemaCache.test.ts +++ b/src/lib/__tests__/schemaCache.test.ts @@ -4,9 +4,15 @@ import type { TableSchema } from '../../types'; const mockGetTableSchema = vi.fn(); const mockExecuteQuery = vi.fn(); -vi.mock('../../commands/database', () => ({ - databaseCommands: { - getTableSchema: (...args: unknown[]) => mockGetTableSchema(...args), +vi.mock('@datazen/driver-sdk', async (importOriginal) => ({ + ...(await importOriginal()), + schemaClient: { + readSchema: async (session: string, ref: import('@datazen/driver-sdk').RelationRef) => ({ + value: { + ref, + definition: await mockGetTableSchema(session, ref.name, ref.database, ref.schema), + }, + }), }, })); @@ -280,4 +286,64 @@ describe('schemaCache inflight / error / immutable', () => { invalidateSchemaCache('conn-1'); expect(listener).toHaveBeenCalledTimes(2); }); + it('refresh detaches an old request without deleting a newer in-flight read', async () => { + let resolveOld!: (value: TableSchema) => void; + let resolveNew!: (value: TableSchema) => void; + mockGetTableSchema + .mockReturnValueOnce( + new Promise((r) => { + resolveOld = r; + }), + ) + .mockReturnValueOnce( + new Promise((r) => { + resolveNew = r; + }), + ); + const old = getCachedTableSchema('conn-1', 'users', 'app', 'public'); + invalidateSchemaCache('conn-1', 'users'); + const fresh = getCachedTableSchema('conn-1', 'users', 'app', 'public'); + resolveOld({ ...fullSchema, columns: [{ name: 'old', dataType: 'int', nullable: false }] }); + await old; + const sameFresh = getCachedTableSchema('conn-1', 'users', 'app', 'public'); + expect(mockGetTableSchema).toHaveBeenCalledTimes(2); + resolveNew(fullSchema); + await Promise.all([fresh, sameFresh]); + expect(await getCachedTableSchema('conn-1', 'users', 'app', 'public')).toBe(fullSchema); + }); + + it('an invalidated failure cannot suppress a later successful read', async () => { + let rejectOld!: (error: Error) => void; + mockGetTableSchema + .mockReturnValueOnce( + new Promise((_, reject) => { + rejectOld = reject; + }), + ) + .mockResolvedValueOnce(fullSchema); + const old = getCachedTableSchema('conn-1', 'users', 'app'); + const failed = expect(old).rejects.toThrow('stale failure'); + invalidateSchemaCache('conn-1'); + rejectOld(new Error('stale failure')); + await failed; + expect(await getCachedTableSchema('conn-1', 'users', 'app')).toBe(fullSchema); + }); + + it('database-scoped invalidation preserves the same relation in other databases', async () => { + mockGetTableSchema.mockResolvedValue(fullSchema); + await getCachedTableSchema('conn-1', 'users', 'app', 'public'); + await getCachedTableSchema('conn-1', 'users', 'other', 'public'); + invalidateSchemaCache('conn-1', 'users', undefined, 'app'); + await getCachedTableSchema('conn-1', 'users', 'other', 'public'); + expect(mockGetTableSchema).toHaveBeenCalledTimes(2); + await getCachedTableSchema('conn-1', 'users', 'app', 'public'); + expect(mockGetTableSchema).toHaveBeenCalledTimes(3); + }); + + it('tuple keys distinguish delimiter-containing catalog names', async () => { + mockGetTableSchema.mockResolvedValue(fullSchema); + await getCachedTableSchema('conn-1', 'users', 'a::b', 'c'); + await getCachedTableSchema('conn-1', 'users', 'a', 'b::c'); + expect(mockGetTableSchema).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/lib/__tests__/sqlFileExecution.test.ts b/src/lib/__tests__/sqlFileExecution.test.ts index a0749d6e8..bc45ca372 100644 --- a/src/lib/__tests__/sqlFileExecution.test.ts +++ b/src/lib/__tests__/sqlFileExecution.test.ts @@ -29,7 +29,11 @@ function baseOptions() { beforeEach(() => { invokeMock.mockReset(); - invokeMock.mockImplementation(async (cmd: string) => (cmd === 'get_tables' ? [] : true)); + invokeMock.mockImplementation(async (cmd: string) => + cmd === 'execute_driver_command' + ? { data: { database: 'app', schemas: [], relations: [] } } + : true, + ); }); describe('runSqlFileExecution (decision 3+6 unified IPC)', () => { @@ -55,7 +59,21 @@ describe('runSqlFileExecution (decision 3+6 unified IPC)', () => { it('pushes the overwrite option after user confirmation on a non-empty database', async () => { invokeMock.mockImplementation(async (cmd: string) => - cmd === 'get_tables' ? [{ name: 'users' }] : true, + cmd === 'execute_driver_command' + ? { + data: { + database: 'app', + schemas: [], + relations: [ + { + ref: { database: 'app', schema: null, name: 'users' }, + kind: 'table', + rowCount: null, + }, + ], + }, + } + : true, ); const confirmOverwrite = vi.fn().mockResolvedValue(true); @@ -71,14 +89,28 @@ describe('runSqlFileExecution (decision 3+6 unified IPC)', () => { it('aborts before restore when overwrite is declined on a non-empty database', async () => { invokeMock.mockImplementation(async (cmd: string) => - cmd === 'get_tables' ? [{ name: 'users' }] : true, + cmd === 'execute_driver_command' + ? { + data: { + database: 'app', + schemas: [], + relations: [ + { + ref: { database: 'app', schema: null, name: 'users' }, + kind: 'table', + rowCount: null, + }, + ], + }, + } + : true, ); const ok = await runSqlFileExecution({ ...baseOptions(), confirmOverwrite: vi.fn().mockResolvedValue(false), }); expect(ok).toBe(false); - // get_tables probing is allowed; the restore itself must never start. + // Catalog probing is allowed; the restore itself must never start. expect(invokeMock).not.toHaveBeenCalledWith('restore_sql_file', expect.anything()); }); @@ -94,14 +126,19 @@ describe('runSqlFileExecution (decision 3+6 unified IPC)', () => { it('returns false when the dialog is dismissed (backend reports not executed)', async () => { const ok = await runSqlFileExecution(baseOptions()); expect(ok).toBe(true); - invokeMock.mockImplementation(async (cmd: string) => (cmd === 'get_tables' ? [] : false)); + invokeMock.mockImplementation(async (cmd: string) => + cmd === 'execute_driver_command' + ? { data: { database: 'app', schemas: [], relations: [] } } + : false, + ); const cancelled = await runSqlFileExecution(baseOptions()); expect(cancelled).toBe(false); }); it('surfaces backend errors through onError and rethrows', async () => { invokeMock.mockImplementation(async (cmd: string) => { - if (cmd === 'get_tables') return []; + if (cmd === 'execute_driver_command') + return { data: { database: 'app', schemas: [], relations: [] } }; throw new Error('boom'); }); const onError = vi.fn(); diff --git a/src/lib/__tests__/tableSchemaForSql.test.ts b/src/lib/__tests__/tableSchemaForSql.test.ts index ec0be599d..359de435d 100644 --- a/src/lib/__tests__/tableSchemaForSql.test.ts +++ b/src/lib/__tests__/tableSchemaForSql.test.ts @@ -1,137 +1,89 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { invalidateSchemaCache } from '../schemaCache'; import { - driverTableRefsToTry, fetchTableSchemaForSqlGeneration, generateTableSqlWithFallbacks, buildPseudoTableSchema, } from '../tableSchemaForSql'; - -const getTableSchema = vi.fn(); -const getColumns = vi.fn(); - -vi.mock('../../commands/database', () => ({ - databaseCommands: { - getTableSchema: (...args: unknown[]) => getTableSchema(...args), - getColumns: (...args: unknown[]) => getColumns(...args), - }, +const { readSchema, readColumns, capabilities } = vi.hoisted(() => ({ + readSchema: vi.fn(), + readColumns: vi.fn(), + capabilities: vi.fn(), +})); +vi.mock('@datazen/driver-sdk', async (importOriginal) => ({ + ...(await importOriginal()), + schemaClient: { readSchema, readColumns }, +})); +vi.mock('../driverCapabilities', async (importOriginal) => ({ + ...(await importOriginal()), + capabilitiesForDbSession: capabilities, })); - describe('tableSchemaForSql', () => { beforeEach(() => { - getTableSchema.mockReset(); - getColumns.mockReset(); + readSchema.mockReset(); + readColumns.mockReset(); + capabilities.mockReturnValue({ hasSchemaLevel: true }); invalidateSchemaCache('sess-1'); }); - - describe('driverTableRefsToTry', () => { - it('prefers schema.table for postgresql', () => { - expect(driverTableRefsToTry('users', 'public', 'postgresql')).toEqual([ - 'public.users', - 'users', - ]); - }); - - it('uses bare name only for mysql even when schema is set', () => { - expect(driverTableRefsToTry('users', 'mydb', 'mysql')).toEqual(['users']); + it('reads a bare exact name once, preserving schema and dotted identifiers', async () => { + const ref = { database: 'app', schema: 'archive', name: 'users.v2' }; + readSchema.mockResolvedValueOnce({ + value: { ref, definition: buildPseudoTableSchema('users.v2', ['id']) }, }); - - it('returns bare name when schema is absent', () => { - expect(driverTableRefsToTry('users', null, 'postgresql')).toEqual(['users']); + const result = await fetchTableSchemaForSqlGeneration({ + dbSessionId: 'sess-1', + tableName: 'users.v2', + schema: 'archive', + database: 'app', + databaseType: 'postgresql', }); + expect(readSchema).toHaveBeenCalledExactlyOnceWith('sess-1', ref); + expect(readColumns).not.toHaveBeenCalled(); + expect(result?.tableName).toBe('users.v2'); }); - - describe('fetchTableSchemaForSqlGeneration', () => { - it('returns schema from getTableSchema with bare tableName override', async () => { - getTableSchema.mockResolvedValueOnce({ - tableName: 'public.users', - columns: [{ name: 'id', dataType: 'int', nullable: false }], - primaryKeys: ['id'], - indexes: [], - foreignKeys: [], - }); - - const result = await fetchTableSchemaForSqlGeneration({ - dbSessionId: 'sess-1', - tableName: 'users', - schema: 'public', - database: 'app', - databaseType: 'postgresql', - }); - - expect(getTableSchema).toHaveBeenCalledWith('sess-1', 'public.users', 'app', 'public'); - expect(result?.tableName).toBe('users'); - expect(result?.columns).toHaveLength(1); + it('reads typed columns in the same identity when full structure is unavailable', async () => { + capabilities.mockReturnValue({ hasSchemaLevel: false }); + readSchema.mockRejectedValueOnce(new Error('unsupported')); + const ref = { database: 'mydb', schema: null, name: 'users' }; + const columns = [{ name: 'id', dataType: 'int', nullable: false }]; + readColumns.mockResolvedValueOnce({ + results: [{ status: 'ok', value: { ref, columns, primaryKeys: ['id'] } }], }); - - it('falls back to bare name when qualified ref returns empty columns', async () => { - getTableSchema - .mockResolvedValueOnce({ - tableName: 'public.users', - columns: [], - primaryKeys: [], - indexes: [], - foreignKeys: [], - }) - .mockResolvedValueOnce({ - tableName: 'users', - columns: [{ name: 'id', dataType: 'int', nullable: false }], - primaryKeys: ['id'], - indexes: [], - foreignKeys: [], - }); - - const result = await fetchTableSchemaForSqlGeneration({ - dbSessionId: 'sess-1', - tableName: 'users', - schema: 'public', - database: 'app', - databaseType: 'postgresql', - }); - - expect(getTableSchema).toHaveBeenCalledTimes(2); - expect(result?.columns.map((c) => c.name)).toEqual(['id']); + const result = await fetchTableSchemaForSqlGeneration({ + dbSessionId: 'sess-1', + tableName: 'users', + schema: 'mydb', + database: 'mydb', + databaseType: 'mysql', }); - - it('falls back to getColumns for mysql with bare name', async () => { - getTableSchema.mockResolvedValueOnce({ - tableName: 'users', - columns: [], - primaryKeys: [], - indexes: [], - foreignKeys: [], - }); - getColumns.mockResolvedValueOnce(['id', 'name']); - - const result = await fetchTableSchemaForSqlGeneration({ - dbSessionId: 'sess-1', - tableName: 'users', - schema: 'mydb', - database: 'mydb', - databaseType: 'mysql', - }); - - expect(getTableSchema).toHaveBeenCalledWith('sess-1', 'users', 'mydb', 'mydb'); - expect(getColumns).toHaveBeenCalledWith('sess-1', 'users', 'mydb', 'mydb'); - expect(result?.columns.map((c) => c.name)).toEqual(['id', 'name']); + expect(readSchema).toHaveBeenCalledExactlyOnceWith('sess-1', ref); + expect(readColumns).toHaveBeenCalledExactlyOnceWith('sess-1', [ref]); + expect(result?.columns).toEqual(columns); + expect(result?.primaryKeys).toEqual(['id']); + }); + it('uses the caller snapshot after metadata failure without retrying name variants', async () => { + readSchema.mockRejectedValueOnce(new Error('fail')); + readColumns.mockResolvedValueOnce({ + results: [ + { + status: 'error', + ref: { database: 'app', schema: 'public', name: 'users' }, + error: { code: 'read-failed', message: 'fail' }, + }, + ], }); - - it('uses columnMap when IPC calls fail or return empty', async () => { - getTableSchema.mockRejectedValueOnce(new Error('fail')); - getColumns.mockRejectedValueOnce(new Error('fail')); - - const result = await fetchTableSchemaForSqlGeneration({ - dbSessionId: 'sess-1', - tableName: 'users', - database: 'mydb', - databaseType: 'mysql', - columnMap: { users: ['id', 'email'] }, - }); - - expect(result).toEqual(buildPseudoTableSchema('users', ['id', 'email'])); + const result = await fetchTableSchemaForSqlGeneration({ + dbSessionId: 'sess-1', + tableName: 'users', + schema: 'public', + database: 'app', + databaseType: 'postgresql', + columnMap: { users: ['id', 'email'] }, }); + expect(result).toEqual(buildPseudoTableSchema('users', ['id', 'email'])); + expect(readSchema).toHaveBeenCalledTimes(1); + expect(readColumns).toHaveBeenCalledTimes(1); }); - describe('generateTableSqlWithFallbacks', () => { const schema = buildPseudoTableSchema('users', ['id', 'name']); diff --git a/src/lib/databaseMeta.ts b/src/lib/databaseMeta.ts index 2275cab7e..ebe13d8c5 100644 --- a/src/lib/databaseMeta.ts +++ b/src/lib/databaseMeta.ts @@ -144,7 +144,7 @@ export interface DatabaseTypeMeta { * On-demand SQL namespace completion strategy (host is wapp-agnostic). * - `default-sql`: database → tables (MySQL/MariaDB/…) * - `postgresql`: database → schema → table (or schema → table when single-db) - * - `path-hierarchy`: slash-path levels via `get_tables` + optional name→id aliases + * - `path-hierarchy`: slash-path levels via `list_catalog` + optional name→id aliases * (extensions that use catalog/schema navigation rows with schema CATALOG|SCHEMA) */ namespaceEnsure?: 'default-sql' | 'postgresql' | 'path-hierarchy'; diff --git a/src/lib/ensureNamespace.ts b/src/lib/ensureNamespace.ts index 717942398..fc6a03d8d 100644 --- a/src/lib/ensureNamespace.ts +++ b/src/lib/ensureNamespace.ts @@ -14,7 +14,7 @@ export interface EnsureDeps { databaseType: string | null; isMultiDatabase: boolean; loadedPaths: Set; - /** Raw `get_tables` payloads keyed by fetch path (shared with custom trees). */ + /** Raw `list_catalog` payloads keyed by fetch path (shared with custom trees). */ pathItems: Record; /** SQL display name → fetch path root (e.g. numeric id). Filled by extensions via SDK. */ pathAliases: Record; @@ -26,7 +26,7 @@ export interface EnsureDeps { cachePathItems: (fetchPath: string, items: TableInfo[]) => void; registerPathAliases: (entries: { name: string; id: string }[]) => void; getDatabases: (dbSessionId: string) => Promise; - getTables: (dbSessionId: string, database: string) => Promise; + listTables: (dbSessionId: string, database: string) => Promise; } const inflight = new Map>(); @@ -68,7 +68,7 @@ function resolveEnsureStrategy(databaseType: string | null): DatabaseTypeMeta['n /** * Path-hierarchy ensure: uses wapp-registered pathAliases for the root segment, - * then `get_tables(rootId[/…])`. Does not parse wapp-specific database list formats. + * then `list_catalog(rootId[/…])`. Does not parse wapp-specific database list formats. */ async function ensurePathHierarchy(segments: string[], deps: EnsureDeps): Promise { const { dbSessionId, pathAliases } = deps; @@ -87,7 +87,7 @@ async function ensurePathHierarchy(segments: string[], deps: EnsureDeps): Promis let items = deps.pathItems[fetchPath]; if (!items) { - items = await deps.getTables(dbSessionId, fetchPath); + items = await deps.listTables(dbSessionId, fetchPath); deps.cachePathItems(fetchPath, items); } @@ -121,7 +121,7 @@ async function ensurePostgresql(segments: string[], deps: EnsureDeps): Promise(); for (const item of all) { if (!isSchemaGroupingSchema(item.schema)) continue; @@ -139,7 +139,7 @@ async function ensurePostgresql(segments: string[], deps: EnsureDeps): Promise item.tableType !== 'view'); const bySchema = new Map(); for (const item of tableItems) { @@ -173,7 +173,7 @@ async function ensurePostgresql(segments: string[], deps: EnsureDeps): Promise item.tableType !== 'view' && item.schema === schema) .map((item) => item.name); @@ -192,7 +192,7 @@ async function ensureDefaultSql(segments: string[], deps: EnsureDeps): Promise session.inflight.delete(key))); + jobs.push( + loadOne(session, key, request).finally(() => { + if (session.inflight.get(key) === request) session.inflight.delete(key); + }), + ); } await Promise.allSettled(jobs); } @@ -274,22 +278,28 @@ export function createMetadataCache(deps: MetadataCacheDeps = {}): MetadataCache session.pending.set(key, request); return; } + const database = session.database; + const schemaContext = session.schema; + const dialectId = session.dialectId; + const isCurrent = () => + sessions.get(session.dbSessionId) === session && + session.database === database && + session.schema === schemaContext && + session.dialectId === dialectId && + session.inflight.get(key) === request; const table = qualifiedNameText(request.identity, session.dialectId); try { // The session's schema is part of the relation's identity: omitting it // makes the host fall back to the connection default, which resolves a // relation that lives elsewhere as missing. - const schema = await loadTableSchema( - session.dbSessionId, - table, - session.database, - session.schema, - ); + const schema = await loadTableSchema(session.dbSessionId, table, database, schemaContext); + if (!isCurrent()) return; const metadata = schemaToMetadata(key, request.identity, request.kind, schema, now()); session.relations.set(key, metadata); session.errors.delete(key); refreshSnapshot(session); } catch { + if (!isCurrent()) return; session.errors.set(key, { at: now() }); // No epoch bump — the relation simply stays absent; UI degrades on its own. } @@ -319,9 +329,21 @@ export function createMetadataCache(deps: MetadataCacheDeps = {}): MetadataCache ctx: EditorMetadataContext, ): void { const session = getOrCreateSession(dbSessionId, ctx.database); - if (ctx.dialectId) session.dialectId = ctx.dialectId; - session.database = ctx.database; - session.schema = ctx.schema ?? session.schema; + const dialectId = ctx.dialectId || 'standard'; + if ( + session.database !== ctx.database || + session.schema !== ctx.schema || + session.dialectId !== dialectId + ) { + if (session.epoch === 0 && session.relations.size === 0 && session.inflight.size === 0) { + session.database = ctx.database; + session.schema = ctx.schema; + session.dialectId = dialectId; + session.cachedSnapshot = buildSnapshot(session); + } else { + resetContext(session, ctx); + } + } for (const request of requests) enqueue(session, request); scheduleFlush(session); } @@ -337,15 +359,34 @@ export function createMetadataCache(deps: MetadataCacheDeps = {}): MetadataCache notify(); return; } - let changed = false; for (const [key, meta] of [...session.relations]) { if (relationMatchesName(session.dialectId, meta.identity, tableName)) { session.relations.delete(key); session.errors.delete(key); - changed = true; } } - if (changed) refreshSnapshot(session); + for (const entries of [session.pending, session.inflight]) { + for (const [key, request] of entries) { + if (relationMatchesName(session.dialectId, request.identity, tableName)) { + entries.delete(key); + session.errors.delete(key); + } + } + } + // Refresh also when an in-flight-only relation was invalidated. + refreshSnapshot(session); + } + + function resetContext(session: SessionState, ctx: EditorMetadataContext): void { + cancelTimer(session); + session.database = ctx.database; + session.schema = ctx.schema; + session.dialectId = ctx.dialectId || 'standard'; + session.relations.clear(); + session.errors.clear(); + session.pending.clear(); + session.inflight.clear(); + refreshSnapshot(session); } if (deps.subscribeInvalidation) { @@ -396,6 +437,7 @@ export function createMetadataCache(deps: MetadataCacheDeps = {}): MetadataCache session.relations.delete(key); session.errors.delete(key); session.pending.delete(key); + session.inflight.delete(key); refreshSnapshot(session); }, @@ -417,17 +459,7 @@ export function createMetadataCache(deps: MetadataCacheDeps = {}): MetadataCache switchContext: (dbSessionId: string, ctx: EditorMetadataContext) => { const session = getOrCreateSession(dbSessionId, ctx.database); - cancelTimer(session); - session.database = ctx.database; - session.schema = ctx.schema; - session.dialectId = ctx.dialectId || 'standard'; - session.relations.clear(); - session.errors.clear(); - session.pending.clear(); - session.inflight.clear(); - session.epoch += 1; - session.cachedSnapshot = buildSnapshot(session); - notify(); + resetContext(session, ctx); }, }; } diff --git a/src/lib/schemaCache.ts b/src/lib/schemaCache.ts index e6c52ec35..e910ecdf0 100644 --- a/src/lib/schemaCache.ts +++ b/src/lib/schemaCache.ts @@ -1,26 +1,11 @@ -import { databaseCommands } from '../commands/database'; import { queryCommands } from '../commands/query'; import type { TableSchema } from '../types'; -const CACHE_TTL = 60_000; // 60 seconds -const ERROR_TTL = 10_000; // short-lived error suppression to avoid re-request storms +import { relationKey, schemaClient } from '@datazen/driver-sdk'; +import { SchemaResourceCache } from './schemaResourceCache'; -interface CacheEntry { - data: T; - timestamp: number; -} - -interface ErrorEntry { - error: unknown; - timestamp: number; -} - -const schemaCache = new Map>(); -const ddlCache = new Map>(); -const schemaInflight = new Map>(); -const ddlInflight = new Map>(); -const schemaErrors = new Map(); -const ddlErrors = new Map(); +const schemaCache = new SchemaResourceCache(); +const ddlCache = new SchemaResourceCache(); /** Observers notified on every schema/DDL invalidation (downstream caches react). */ type InvalidationListener = (dbSessionId: string, tableName?: string) => void; @@ -44,11 +29,7 @@ function cacheKey( database: string, schema?: string | null, ): string { - // The schema is part of the identity: `public.users` and `other.users` are - // different relations, and sharing one entry is how a foreign-schema read - // could serve another schema's columns. - const scope = schema?.trim() ? schema.trim() : ''; - return `${dbSessionId}::${database}::${scope}::${tableName}`; + return relationKey({ dbSessionId, database, schema: schema ?? null, name: tableName }); } /** Optional DDL cache identity. When supplied, the key discriminates object @@ -64,17 +45,13 @@ function ddlCacheKey( database: string, identity?: DdlCacheIdentity, ): string { - const kind = identity?.objectKind ?? 'table'; - const ns = identity?.namespacePath?.length ? `${identity.namespacePath.join('.')}.` : ''; - return `${dbSessionId}::${database}::${kind}::${ns}${tableName}`; -} - -function isValid(entry: CacheEntry | undefined): entry is CacheEntry { - return !!entry && Date.now() - entry.timestamp < CACHE_TTL; -} - -function isErrorFresh(entry: ErrorEntry | undefined): boolean { - return !!entry && Date.now() - entry.timestamp < ERROR_TTL; + return JSON.stringify([ + dbSessionId, + database, + identity?.objectKind ?? 'table', + identity?.namespacePath ?? [], + tableName, + ]); } function deepFreeze(value: T): T { @@ -103,34 +80,17 @@ export async function getCachedTableSchema( ): Promise { const key = cacheKey(dbSessionId, tableName, database, schema); - const cached = schemaCache.get(key); - if (isValid(cached)) return cached.data; - - const err = schemaErrors.get(key); - if (err && isErrorFresh(err)) { - throw err.error; - } - - let inflight = schemaInflight.get(key); - if (!inflight) { - inflight = databaseCommands - .getTableSchema(dbSessionId, tableName, database, schema ?? null) - .then((data) => { - const frozen = deepFreeze(data); - schemaCache.set(key, { data: frozen, timestamp: Date.now() }); - schemaErrors.delete(key); - return frozen; - }) - .catch((error: unknown) => { - schemaErrors.set(key, { error, timestamp: Date.now() }); - throw error; - }) - .finally(() => { - schemaInflight.delete(key); - }); - schemaInflight.set(key, inflight); - } - return inflight; + return schemaCache.get(key, async () => + deepFreeze( + ( + await schemaClient.readSchema(dbSessionId, { + database, + schema: schema ?? null, + name: tableName, + }) + ).value.definition, + ), + ); } /** @@ -149,37 +109,11 @@ export async function getCachedDDL( ): Promise { const key = ddlCacheKey(dbSessionId, tableName, database, identity); - const cached = ddlCache.get(key); - if (isValid(cached)) return cached.data; - - const err = ddlErrors.get(key); - if (err && isErrorFresh(err)) { - throw err.error; - } - - let inflight = ddlInflight.get(key); - if (!inflight) { - inflight = (async () => { - // Pin the session to `database` before running (mirrors query pinning in - // queryCommands.executeQuery's F1 path) so a copy-DDL call never resolves - // against a stale/active database that may not own the relation. - const multi = await queryCommands.executeQuery(dbSessionId, sql, undefined, database, null); - const row = multi.results[0]?.rows[0]; - const data = resultExtractor(row ? [row] : []); - ddlCache.set(key, { data, timestamp: Date.now() }); - ddlErrors.delete(key); - return data; - })() - .catch((error: unknown) => { - ddlErrors.set(key, { error, timestamp: Date.now() }); - throw error; - }) - .finally(() => { - ddlInflight.delete(key); - }); - ddlInflight.set(key, inflight); - } - return inflight; + return ddlCache.get(key, async () => { + const multi = await queryCommands.executeQuery(dbSessionId, sql, undefined, database, null); + const row = multi.results[0]?.rows[0]; + return resultExtractor(row ? [row] : []); + }); } export interface DdlCacheInvalidateScope { @@ -193,58 +127,28 @@ export function invalidateSchemaCache( identity?: DdlCacheInvalidateScope, database?: string, ): void { - if (!dbSessionId) { - schemaCache.clear(); - schemaErrors.clear(); - ddlCache.clear(); - ddlErrors.clear(); - return; - } - if (tableName) { - // Cache keys are `${dbSessionId}::${database}::...`. When the caller knows - // the database, drop that cell precisely; otherwise fall back to the - // prefix/suffix scan so stale entries can never survive. - if (database) { - const key = cacheKey(dbSessionId, tableName, database); - schemaCache.delete(key); - schemaErrors.delete(key); - const dkey = ddlCacheKey(dbSessionId, tableName, database, identity); - ddlCache.delete(dkey); - ddlErrors.delete(dkey); - } - for (const k of [...schemaCache.keys()]) { - if (k.startsWith(`${dbSessionId}::`) && (k.endsWith(`::${tableName}`) || k === tableName)) { - schemaCache.delete(k); - } - } - for (const k of [...schemaErrors.keys()]) { - if (k.startsWith(`${dbSessionId}::`) && (k.endsWith(`::${tableName}`) || k === tableName)) { - schemaErrors.delete(k); - } - } - for (const k of [...ddlCache.keys()]) { - if (k.startsWith(`${dbSessionId}::`) && k.endsWith(tableName)) { - ddlCache.delete(k); - } - } - for (const k of [...ddlErrors.keys()]) { - if (k.startsWith(`${dbSessionId}::`) && k.endsWith(tableName)) { - ddlErrors.delete(k); - } - } - } else { - for (const k of [...schemaCache.keys()]) { - if (k.startsWith(`${dbSessionId}::`)) schemaCache.delete(k); - } - for (const k of [...schemaErrors.keys()]) { - if (k.startsWith(`${dbSessionId}::`)) schemaErrors.delete(k); - } - for (const k of [...ddlCache.keys()]) { - if (k.startsWith(`${dbSessionId}::`)) ddlCache.delete(k); - } - for (const k of [...ddlErrors.keys()]) { - if (k.startsWith(`${dbSessionId}::`)) ddlErrors.delete(k); - } - } - notifyInvalidation(dbSessionId, tableName); + const sessions = new Set(); + const matches = (key: string, ddl: boolean): boolean => { + const parts: unknown = JSON.parse(key); + if (!Array.isArray(parts)) return false; + const [session, db, kind, namespace, name] = parts; + if (typeof session !== 'string') return false; + if (dbSessionId && session !== dbSessionId) return false; + if (database && db !== database) return false; + const relationName = ddl ? name : namespace; + if (tableName && relationName !== tableName) return false; + if (ddl && identity?.objectKind && kind !== identity.objectKind) return false; + if ( + ddl && + identity?.namespacePath && + JSON.stringify(namespace) !== JSON.stringify(identity.namespacePath) + ) + return false; + sessions.add(session); + return true; + }; + schemaCache.invalidate((key) => matches(key, false)); + ddlCache.invalidate((key) => matches(key, true)); + if (dbSessionId) notifyInvalidation(dbSessionId, tableName); + else for (const session of sessions) notifyInvalidation(session); } diff --git a/src/lib/schemaResourceCache.ts b/src/lib/schemaResourceCache.ts new file mode 100644 index 000000000..66228e401 --- /dev/null +++ b/src/lib/schemaResourceCache.ts @@ -0,0 +1,49 @@ +/** TTL cache with a request-identity barrier across invalidation. */ +export class SchemaResourceCache { + private values = new Map(); + private errors = new Map(); + private inflight = new Map>(); + + keys(): string[] { + return [...new Set([...this.values.keys(), ...this.errors.keys(), ...this.inflight.keys()])]; + } + + invalidate(matches: (key: string) => boolean): void { + for (const key of this.keys()) { + if (!matches(key)) continue; + this.values.delete(key); + this.errors.delete(key); + // Detach old requests; their completion cannot overwrite new requests. + this.inflight.delete(key); + } + } + + async get(key: string, load: () => Promise): Promise { + const cached = this.values.get(key); + if (cached && Date.now() - cached.at < 60_000) return cached.value; + const failed = this.errors.get(key); + if (failed && Date.now() - failed.at < 10_000) throw failed.error; + const existing = this.inflight.get(key); + if (existing) return existing; + + const pending = load() + .then((value) => { + if (this.inflight.get(key) === pending) { + this.values.set(key, { value, at: Date.now() }); + this.errors.delete(key); + } + return value; + }) + .catch((error: unknown) => { + if (this.inflight.get(key) === pending) { + this.errors.set(key, { error, at: Date.now() }); + } + throw error; + }) + .finally(() => { + if (this.inflight.get(key) === pending) this.inflight.delete(key); + }); + this.inflight.set(key, pending); + return pending; + } +} diff --git a/src/lib/sqlFileExecution.ts b/src/lib/sqlFileExecution.ts index ec478b229..51201564c 100644 --- a/src/lib/sqlFileExecution.ts +++ b/src/lib/sqlFileExecution.ts @@ -6,7 +6,7 @@ import { type BackupProgressPayload, } from './backupProgress'; import type { I18nKey } from '../locales'; -import type { TableInfo } from '../types'; +import { schemaClient } from '@datazen/driver-sdk'; type TFunc = (key: I18nKey, params?: Record) => string; @@ -47,9 +47,9 @@ export async function runSqlFileExecution({ const ok = await confirmBeforeExecute(); if (!ok) return false; } else if (confirmOverwrite) { - const tables = await invoke('get_tables', { - dbSessionId, + const { relations: tables } = await schemaClient.listCatalog(dbSessionId, { database, + schema: null, }); if (tables.length > 0) { const ok = await confirmOverwrite(tables.length); diff --git a/src/lib/tableSchemaForSql.ts b/src/lib/tableSchemaForSql.ts index 59037707c..46126fd2e 100644 --- a/src/lib/tableSchemaForSql.ts +++ b/src/lib/tableSchemaForSql.ts @@ -1,30 +1,8 @@ -import { databaseCommands } from '../commands/database'; +import { schemaClient } from '@datazen/driver-sdk'; +import { capabilitiesForDbSession, relationSchemaFor } from './driverCapabilities'; import { getCachedTableSchema } from './schemaCache'; -import { DB_REGISTRY } from './databaseTypes'; import { formatTableIdentifier, generateTableSql, type GeneratedSqlType } from './sqlGenerator'; -import type { DatabaseType, TableSchema } from '../types'; - -/** Dialects whose drivers parse `schema.table` in get_table_schema / get_columns. */ -const SCHEMA_QUALIFIED_DIALECTS = new Set(['postgresql', 'sqlserver']); - -/** - * Build ordered driver table refs to try when loading column metadata. - * MySQL/SQLite bind bare TABLE_NAME — qualified refs return zero rows. - */ -export function driverTableRefsToTry( - tableName: string, - schema: string | null, - databaseType: string, -): string[] { - const dialect = DB_REGISTRY[databaseType as DatabaseType]?.sqlDialect ?? databaseType; - const schemaTrimmed = schema?.trim(); - const qualified = schemaTrimmed ? `${schemaTrimmed}.${tableName}` : null; - - if (SCHEMA_QUALIFIED_DIALECTS.has(dialect) && qualified) { - return qualified === tableName ? [tableName] : [qualified, tableName]; - } - return [tableName]; -} +import type { TableSchema } from '../types'; export function buildPseudoTableSchema(tableName: string, colNames: string[]): TableSchema { return { @@ -44,34 +22,41 @@ export async function fetchTableSchemaForSqlGeneration(args: { databaseType: string; columnMap?: Record; }): Promise { - const { dbSessionId, tableName, schema, database, databaseType, columnMap } = args; - const refs = driverTableRefsToTry(tableName, schema ?? null, databaseType); - - for (const ref of refs) { - try { - const tableSchema = await getCachedTableSchema(dbSessionId, ref, database, schema ?? null); - if (tableSchema.columns.length > 0) { - return { ...tableSchema, tableName }; - } - } catch { - // Try the next ref shape. - } + const { dbSessionId, tableName, schema, database, columnMap } = args; + const relation = { + database, + schema: relationSchemaFor(capabilitiesForDbSession(dbSessionId), schema), + name: tableName, + }; + try { + const tableSchema = await getCachedTableSchema( + dbSessionId, + tableName, + database, + relation.schema, + ); + if (tableSchema.columns.length > 0) return { ...tableSchema, tableName }; + } catch { + // Drivers may support columns without full structure metadata. } - - for (const ref of refs) { - try { - const colNames = await databaseCommands.getColumns( - dbSessionId, - ref, - database, - schema ?? null, - ); - if (colNames.length > 0) { - return buildPseudoTableSchema(tableName, colNames); - } - } catch { - // Try the next ref shape. + try { + const response = await schemaClient.readColumns(dbSessionId, [relation]); + const row = response.results.find( + (row) => + row.status === 'ok' && + row.value.ref.database === database && + row.value.ref.schema === relation.schema && + row.value.ref.name === tableName, + ); + if (row?.status === 'ok' && row.value.columns.length > 0) { + return { + ...buildPseudoTableSchema(tableName, []), + columns: row.value.columns, + primaryKeys: row.value.primaryKeys, + }; } + } catch { + // Keep the caller's already loaded columns available on transport failure. } const cached = columnMap?.[tableName]; diff --git a/src/stores/__tests__/schemaColumnLoader.test.ts b/src/stores/__tests__/schemaColumnLoader.test.ts new file mode 100644 index 000000000..8de9af01b --- /dev/null +++ b/src/stores/__tests__/schemaColumnLoader.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadRelationColumns, resolveColumnRelations } from '../schemaColumnLoader'; +import { createEmptyConnectionSchema } from '../schemaStoreState'; +import type { RelationRef } from '@datazen/driver-sdk'; + +const readColumns = vi.hoisted(() => vi.fn()); +vi.mock('@datazen/driver-sdk', async (importOriginal) => ({ + ...(await importOriginal()), + schemaClient: { readColumns }, +})); +vi.mock('../../lib/driverCapabilities', () => ({ + capabilitiesForDbSession: () => ({ hasSchemaLevel: true }), + relationSchemaFor: (_capabilities: unknown, schema: string | null | undefined) => schema ?? null, +})); + +describe('relation column loading', () => { + beforeEach(() => { + readColumns.mockReset(); + }); + + it('uses the requested catalog without importing another database path cache', () => { + const state = createEmptyConnectionSchema(); + state.currentDatabase = 'active'; + state.tableCatalogs.target = [ + { name: 'users', schema: 'public', tableType: 'table', rowCount: null }, + ]; + state.pathItems.foreign = [ + { name: 'users', schema: 'archive', tableType: 'table', rowCount: null }, + ]; + expect(resolveColumnRelations(state, ['users'], 'session', 'target')).toEqual([ + { database: 'target', schema: 'public', name: 'users' }, + ]); + state.tableCatalogs.target.push({ + name: 'users', + schema: 'archive', + tableType: 'table', + rowCount: null, + }); + expect(resolveColumnRelations(state, ['users'], 'session', 'target')).toEqual([]); + expect(resolveColumnRelations(state, ['users'], 'session', 'target', 'archive')).toEqual([ + { database: 'target', schema: 'archive', name: 'users' }, + ]); + }); + + it('bounds transport batches and accepts only successful requested identities', async () => { + const refs: RelationRef[] = Array.from({ length: 257 }, (_, index) => ({ + database: 'app', + schema: 'public', + name: `table_${index}`, + })); + readColumns.mockImplementation(async (_session: string, relations: RelationRef[]) => ({ + results: [ + { status: 'ok', value: { ref: relations[0], columns: [], primaryKeys: [] } }, + { status: 'error', ref: relations[1], error: { code: 'read-failed', message: 'denied' } }, + { + status: 'ok', + value: { ref: { ...relations[0], schema: 'foreign' }, columns: [], primaryKeys: [] }, + }, + ], + })); + const values = await loadRelationColumns('session', refs); + expect(readColumns.mock.calls.map((call) => (call[1] as RelationRef[]).length)).toEqual([ + 256, 1, + ]); + expect(values.map((value) => value.ref)).toEqual([refs[0], refs[256]]); + }); +}); diff --git a/src/stores/__tests__/schemaStore.test.ts b/src/stores/__tests__/schemaStore.test.ts index c731d7193..dde5e296c 100644 --- a/src/stores/__tests__/schemaStore.test.ts +++ b/src/stores/__tests__/schemaStore.test.ts @@ -1,19 +1,73 @@ +import { createEmptyConnectionSchema, type ConnectionSchemaState } from '../schemaStoreState'; +import { projectRelationColumns } from '../schemaColumnLoader'; +import { relationKey } from '@datazen/driver-sdk'; +import type { SchemaStore } from '../schemaStore'; import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import type { TableInfo } from '../../types'; +import type { RelationRef } from '@datazen/driver-sdk'; + +type TestSchemaStore = { getState(): SchemaStore; setState(patch: Partial): void }; +function seedSchema( + store: TestSchemaStore, + session: string, + patch: Partial, +) { + const schemas = new Map(store.getState().schemas); + schemas.set(session, { ...createEmptyConnectionSchema(), ...schemas.get(session), ...patch }); + store.setState({ schemas, activeDbSessionId: session }); +} +function schemaSnapshot(store: TestSchemaStore, session: string) { + const entry = store.getState().schemas.get(session) ?? createEmptyConnectionSchema(); + return { + ...entry, + ...projectRelationColumns( + entry, + session, + entry.currentDatabase ?? '', + entry.currentSchema ?? undefined, + ), + }; +} +function cachedRelation(session: string, database: string, name: string, names: string[]) { + const ref = { database, schema: null, name }; + return { + [relationKey({ ...ref, dbSessionId: session })]: { + ref, + primaryKeys: [], + columns: names.map((name) => ({ name, dataType: 'text', nullable: true })), + }, + }; +} + +const { mockReadColumns } = vi.hoisted(() => ({ + mockReadColumns: vi.fn(async (_session: string, refs: readonly RelationRef[]) => ({ + results: refs.map((ref) => ({ + status: 'ok' as const, + value: { + ref, + columns: [ + { name: 'id', dataType: 'integer', nullable: false }, + { name: 'name', dataType: 'text', nullable: true }, + ], + primaryKeys: ['id'], + }, + })), + })), +})); + +vi.mock('@datazen/driver-sdk', async (importOriginal) => ({ + ...(await importOriginal()), + schemaClient: { readColumns: mockReadColumns }, +})); vi.mock('../../commands/database', () => ({ databaseCommands: { getDatabases: vi.fn().mockResolvedValue(['testdb']), - getTables: vi.fn().mockResolvedValue([ + listTables: vi.fn().mockResolvedValue([ { name: 'users', tableType: 'TABLE', schema: 'public', rowCount: null }, { name: 'products', tableType: 'TABLE', schema: 'public', rowCount: null }, { name: 'orders', tableType: 'TABLE', schema: 'public', rowCount: null }, ]), - getColumns: vi.fn().mockResolvedValue(['id', 'name']), - // Default: typed endpoint unavailable → per-table path falls back to - // getColumns. Tests that exercise type loading mock this explicitly. - getColumnsTyped: vi.fn().mockRejectedValue(new Error('typed not available')), - getAllColumns: vi.fn().mockResolvedValue({}), }, })); @@ -81,11 +135,11 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { preferredDatabase: 'db_b', }); - const state = useSchemaStore.getState(); + const state = schemaSnapshot(useSchemaStore, 'conn-1'); expect(state.isMultiDatabase).toBe(false); expect(state.databases).toEqual(['db_b']); expect(state.currentDatabase).toBe('db_b'); - expect(databaseCommands.getTables).not.toHaveBeenCalled(); + expect(databaseCommands.listTables).not.toHaveBeenCalled(); }); it('lists all databases when none configured (mysql)', async () => { @@ -96,7 +150,7 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { skipLoadTables: true, }); - const state = useSchemaStore.getState(); + const state = schemaSnapshot(useSchemaStore, 'conn-1'); expect(state.isMultiDatabase).toBe(true); expect(state.databases).toEqual(['db_a', 'db_b', 'db_c']); expect(state.currentDatabase).toBe('db_a'); @@ -110,8 +164,8 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { skipLoadTables: true, }); - expect(useSchemaStore.getState().isMultiDatabase).toBe(false); - expect(useSchemaStore.getState().currentDatabase).toBe('only_db'); + expect(schemaSnapshot(useSchemaStore, 'conn-1').isMultiDatabase).toBe(false); + expect(schemaSnapshot(useSchemaStore, 'conn-1').currentDatabase).toBe('only_db'); }); it('sets isMultiDatabase true for postgresql with multiple databases', async () => { @@ -122,9 +176,9 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { skipLoadTables: true, }); - expect(useSchemaStore.getState().isMultiDatabase).toBe(true); - expect(useSchemaStore.getState().databases).toEqual(['db1', 'db2']); - expect(useSchemaStore.getState().currentDatabase).toBe('db1'); + expect(schemaSnapshot(useSchemaStore, 'conn-1').isMultiDatabase).toBe(true); + expect(schemaSnapshot(useSchemaStore, 'conn-1').databases).toEqual(['db1', 'db2']); + expect(schemaSnapshot(useSchemaStore, 'conn-1').currentDatabase).toBe('db1'); }); it('falls back to listing all when preferred is empty string', async () => { @@ -136,9 +190,9 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { skipLoadTables: true, }); - expect(useSchemaStore.getState().databases).toEqual(['alpha', 'beta']); - expect(useSchemaStore.getState().currentDatabase).toBe('alpha'); - expect(useSchemaStore.getState().isMultiDatabase).toBe(true); + expect(schemaSnapshot(useSchemaStore, 'conn-1').databases).toEqual(['alpha', 'beta']); + expect(schemaSnapshot(useSchemaStore, 'conn-1').currentDatabase).toBe('alpha'); + expect(schemaSnapshot(useSchemaStore, 'conn-1').isMultiDatabase).toBe(true); }); it('does not lock when configured database is absent from server list (e.g. Kiwi domain)', async () => { @@ -150,9 +204,9 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { skipLoadTables: true, }); - expect(useSchemaStore.getState().databases).toEqual(['alpha', 'beta']); - expect(useSchemaStore.getState().currentDatabase).toBe('alpha'); - expect(useSchemaStore.getState().isMultiDatabase).toBe(true); + expect(schemaSnapshot(useSchemaStore, 'conn-1').databases).toEqual(['alpha', 'beta']); + expect(schemaSnapshot(useSchemaStore, 'conn-1').currentDatabase).toBe('alpha'); + expect(schemaSnapshot(useSchemaStore, 'conn-1').isMultiDatabase).toBe(true); }); it('keeps a user-selected non-first database across reload instead of reverting to the first', async () => { @@ -163,12 +217,12 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { skipLoadTables: true, }); - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); + expect(schemaSnapshot(useSchemaStore, 'conn-1').currentDatabase).toBe('db_a'); // User selects a non-first database from the multi-db dropdown. - vi.mocked(databaseCommands.getTables).mockResolvedValueOnce([]); - await useSchemaStore.getState().switchDatabase('db_b'); - expect(useSchemaStore.getState().currentDatabase).toBe('db_b'); + vi.mocked(databaseCommands.listTables).mockResolvedValueOnce([]); + await useSchemaStore.getState().switchDatabase('db_b', 'conn-1'); + expect(schemaSnapshot(useSchemaStore, 'conn-1').currentDatabase).toBe('db_b'); // Re-mounting ContentView (e.g. switching to Settings and back) re-runs // loadForConnection. It must keep db_b instead of reverting to db_a. @@ -179,8 +233,8 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { skipLoadTables: true, }); - expect(useSchemaStore.getState().databases).toEqual(['db_a', 'db_b', 'db_c']); - expect(useSchemaStore.getState().currentDatabase).toBe('db_b'); + expect(schemaSnapshot(useSchemaStore, 'conn-1').databases).toEqual(['db_a', 'db_b', 'db_c']); + expect(schemaSnapshot(useSchemaStore, 'conn-1').currentDatabase).toBe('db_b'); }); it('refresh after creating a new DB preserves locked database when preferred is passed', async () => { @@ -192,9 +246,9 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { preferredDatabase: 'db_a', }); - expect(useSchemaStore.getState().databases).toEqual(['db_a']); - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); - expect(useSchemaStore.getState().isMultiDatabase).toBe(false); + expect(schemaSnapshot(useSchemaStore, 'conn-1').databases).toEqual(['db_a']); + expect(schemaSnapshot(useSchemaStore, 'conn-1').currentDatabase).toBe('db_a'); + expect(schemaSnapshot(useSchemaStore, 'conn-1').isMultiDatabase).toBe(false); vi.mocked(databaseCommands.getDatabases).mockResolvedValueOnce(['db_a', 'db_b', 'db_new']); @@ -204,9 +258,9 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { preferredDatabase: 'db_a', }); - expect(useSchemaStore.getState().databases).toEqual(['db_a']); - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); - expect(useSchemaStore.getState().isMultiDatabase).toBe(false); + expect(schemaSnapshot(useSchemaStore, 'conn-1').databases).toEqual(['db_a']); + expect(schemaSnapshot(useSchemaStore, 'conn-1').currentDatabase).toBe('db_a'); + expect(schemaSnapshot(useSchemaStore, 'conn-1').isMultiDatabase).toBe(false); }); it('refresh after creating a new DB shows all DBs when no preferred', async () => { @@ -217,8 +271,8 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { skipLoadTables: true, }); - expect(useSchemaStore.getState().databases).toEqual(['db_a', 'db_b']); - expect(useSchemaStore.getState().isMultiDatabase).toBe(true); + expect(schemaSnapshot(useSchemaStore, 'conn-1').databases).toEqual(['db_a', 'db_b']); + expect(schemaSnapshot(useSchemaStore, 'conn-1').isMultiDatabase).toBe(true); vi.mocked(databaseCommands.getDatabases).mockResolvedValueOnce(['db_a', 'db_b', 'db_new']); @@ -227,8 +281,8 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { skipLoadTables: true, }); - expect(useSchemaStore.getState().databases).toEqual(['db_a', 'db_b', 'db_new']); - expect(useSchemaStore.getState().isMultiDatabase).toBe(true); + expect(schemaSnapshot(useSchemaStore, 'conn-1').databases).toEqual(['db_a', 'db_b', 'db_new']); + expect(schemaSnapshot(useSchemaStore, 'conn-1').isMultiDatabase).toBe(true); }); it('seeds top-level database branches only when multi-db', async () => { @@ -239,8 +293,8 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { skipLoadTables: true, }); - expect(useSchemaStore.getState().isMultiDatabase).toBe(true); - expect(useSchemaStore.getState().namespaceTree).toEqual({ db1: {}, db2: {} }); + expect(schemaSnapshot(useSchemaStore, 'conn-1').isMultiDatabase).toBe(true); + expect(schemaSnapshot(useSchemaStore, 'conn-1').namespaceTree).toEqual({ db1: {}, db2: {} }); }); it('does not seed database names as top-level branches for single-db', async () => { @@ -251,8 +305,8 @@ describe('schemaStore.loadForConnection isMultiDatabase', () => { skipLoadTables: true, }); - expect(useSchemaStore.getState().isMultiDatabase).toBe(false); - expect(useSchemaStore.getState().namespaceTree).toEqual({}); + expect(schemaSnapshot(useSchemaStore, 'conn-1').isMultiDatabase).toBe(false); + expect(schemaSnapshot(useSchemaStore, 'conn-1').namespaceTree).toEqual({}); }); }); describe('schemaStore.loadTables', () => { @@ -266,7 +320,7 @@ describe('schemaStore.loadTables', () => { useSchemaStore = storeMod.useSchemaStore; const cmdMod = await import('../../commands/database'); databaseCommands = cmdMod.databaseCommands; - useSchemaStore.setState({ dbSessionId: 'test-conn' }); + seedSchema(useSchemaStore, 'test-conn', {}); }); afterEach(() => { @@ -274,55 +328,60 @@ describe('schemaStore.loadTables', () => { }); it('does not call getColumns during loadTables', async () => { - await useSchemaStore.getState().loadTables('testdb'); + await useSchemaStore.getState().loadTables('testdb', 'test-conn'); - expect(databaseCommands.getTables).toHaveBeenCalledOnce(); - expect(databaseCommands.getColumns).not.toHaveBeenCalled(); + expect(databaseCommands.listTables).toHaveBeenCalledOnce(); + expect(mockReadColumns).not.toHaveBeenCalled(); }); it('loads tables for the pinned database without a use_database IPC (F1)', async () => { - await useSchemaStore.getState().loadTables('testdb'); + await useSchemaStore.getState().loadTables('testdb', 'test-conn'); - expect(databaseCommands.getTables).toHaveBeenCalledWith('test-conn', 'testdb'); + expect(databaseCommands.listTables).toHaveBeenCalledWith('test-conn', 'testdb'); }); it('populates tables but leaves columnMap empty after loadTables', async () => { - await useSchemaStore.getState().loadTables('testdb'); + await useSchemaStore.getState().loadTables('testdb', 'test-conn'); - const state = useSchemaStore.getState(); + const state = schemaSnapshot(useSchemaStore, 'test-conn'); expect(state.tables).toHaveLength(3); expect(Object.keys(state.columnMap)).toHaveLength(0); }); it('setLoadedTables partitions views and clears columnMap', async () => { - useSchemaStore.setState({ - columnMap: { old: ['a'] }, + seedSchema(useSchemaStore, 'test-conn', { + relationColumns: cachedRelation('test-conn', 'db1', 'old', ['a']), currentDatabase: 'other', }); - useSchemaStore.getState().setLoadedTables('db1', [ - { name: 't1', tableType: 'table', schema: null, rowCount: null }, - { name: 'v1', tableType: 'view', schema: null, rowCount: null }, - ]); - const state = useSchemaStore.getState(); + useSchemaStore.getState().setLoadedTables( + 'db1', + [ + { name: 't1', tableType: 'table', schema: null, rowCount: null }, + { name: 'v1', tableType: 'view', schema: null, rowCount: null }, + ], + 'test-conn', + ); + const state = schemaSnapshot(useSchemaStore, 'test-conn'); expect(state.currentDatabase).toBe('db1'); expect(state.tables.map((t) => t.name)).toEqual(['t1']); expect(state.views.map((t) => t.name)).toEqual(['v1']); expect(state.columnMap).toEqual({}); }); - it('setLoadedTables with pinCurrentDatabase:false fills tables but keeps the pointer', () => { - useSchemaStore.setState({ currentDatabase: 'other' }); + it('background fills preserve the active directory and cache the target database', () => { + seedSchema(useSchemaStore, 'test-conn', { currentDatabase: 'other' }); useSchemaStore .getState() .setLoadedTables( 'db1', [{ name: 't1', tableType: 'table', schema: null, rowCount: null }], - undefined, + 'test-conn', { pinCurrentDatabase: false }, ); - const state = useSchemaStore.getState(); + const state = schemaSnapshot(useSchemaStore, 'test-conn'); expect(state.currentDatabase).toBe('other'); - expect(state.tables.map((t) => t.name)).toEqual(['t1']); + expect(state.tables).toEqual([]); + expect(state.tableCatalogs.db1.map((t) => t.name)).toEqual(['t1']); }); }); @@ -337,7 +396,7 @@ describe('schemaStore.switchDatabase', () => { useSchemaStore = storeMod.useSchemaStore; const cmdMod = await import('../../commands/database'); databaseCommands = cmdMod.databaseCommands; - useSchemaStore.setState({ dbSessionId: 'test-conn' }); + seedSchema(useSchemaStore, 'test-conn', {}); }); afterEach(() => { @@ -345,19 +404,19 @@ describe('schemaStore.switchDatabase', () => { }); it('switches the local database context via setLoadedTables (no use_database IPC)', async () => { - await useSchemaStore.getState().switchDatabase('otherdb'); + await useSchemaStore.getState().switchDatabase('otherdb', 'test-conn'); - expect(databaseCommands.getTables).toHaveBeenCalledWith('test-conn', 'otherdb'); - const state = useSchemaStore.getState(); + expect(databaseCommands.listTables).toHaveBeenCalledWith('test-conn', 'otherdb'); + const state = schemaSnapshot(useSchemaStore, 'test-conn'); expect(state.currentDatabase).toBe('otherdb'); expect(state.tables.map((t) => t.name)).toEqual(['users', 'products', 'orders']); }); it('does not bump schemaEpoch on a lightweight context switch', async () => { - expect(useSchemaStore.getState().schemaEpoch).toBe(0); - await useSchemaStore.getState().switchDatabase('otherdb'); + expect(schemaSnapshot(useSchemaStore, 'test-conn').schemaEpoch).toBe(0); + await useSchemaStore.getState().switchDatabase('otherdb', 'test-conn'); // Identical to loadTables except for the invalidation bump — epoch stays flat. - expect(useSchemaStore.getState().schemaEpoch).toBe(0); + expect(schemaSnapshot(useSchemaStore, 'test-conn').schemaEpoch).toBe(0); }); }); @@ -371,12 +430,12 @@ describe('schemaStore.setCurrentDatabase', () => { useSchemaStore = storeMod.useSchemaStore; const stateMod = await import('../../stores/schemaStoreState'); createEmptyConnectionSchema = stateMod.createEmptyConnectionSchema; - useSchemaStore.setState({ dbSessionId: 'test-conn' }); + seedSchema(useSchemaStore, 'test-conn', {}); }); it('adopts the panel database as the session currentDatabase', () => { useSchemaStore.getState().setCurrentDatabase('tradingdb', 'test-conn'); - expect(useSchemaStore.getState().currentDatabase).toBe('tradingdb'); + expect(schemaSnapshot(useSchemaStore, 'test-conn').currentDatabase).toBe('tradingdb'); }); it('follows a bound non-first database even when current is the first one', () => { @@ -390,7 +449,7 @@ describe('schemaStore.setCurrentDatabase', () => { // The active query tab is bound to tradingdb — currentDatabase must follow. useSchemaStore.getState().setCurrentDatabase('tradingdb', 'test-conn'); - expect(useSchemaStore.getState().currentDatabase).toBe('tradingdb'); + expect(schemaSnapshot(useSchemaStore, 'test-conn').currentDatabase).toBe('tradingdb'); }); it('ignores a database the session does not know about', () => { @@ -403,32 +462,34 @@ describe('schemaStore.setCurrentDatabase', () => { useSchemaStore.setState({ schemas }); useSchemaStore.getState().setCurrentDatabase('ghost_db', 'test-conn'); - expect(useSchemaStore.getState().currentDatabase).toBe('channeling_dock_db'); + expect(schemaSnapshot(useSchemaStore, 'test-conn').currentDatabase).toBe('channeling_dock_db'); }); }); -describe('schemaStore.loadColumnMap', () => { +describe('schemaStore.ensureDatabaseColumns', () => { let useSchemaStore: typeof import('../../stores/schemaStore').useSchemaStore; - let databaseCommands: typeof import('../../commands/database').databaseCommands; beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); const storeMod = await import('../../stores/schemaStore'); useSchemaStore = storeMod.useSchemaStore; - const cmdMod = await import('../../commands/database'); - databaseCommands = cmdMod.databaseCommands; - useSchemaStore.setState({ dbSessionId: 'test-conn' }); + seedSchema(useSchemaStore, 'test-conn', {}); }); - it('loads columns for all tables sequentially when called', async () => { - await useSchemaStore.getState().loadTables('testdb'); - expect(databaseCommands.getColumns).not.toHaveBeenCalled(); + it('loads all requested typed columns in one transport batch', async () => { + await useSchemaStore.getState().loadTables('testdb', 'test-conn'); + expect(mockReadColumns).not.toHaveBeenCalled(); - await useSchemaStore.getState().loadColumnMap('test-conn', 'testdb'); + await useSchemaStore.getState().ensureDatabaseColumns('test-conn', 'testdb'); - const state = useSchemaStore.getState(); - expect(databaseCommands.getColumns).toHaveBeenCalledTimes(3); + const state = schemaSnapshot(useSchemaStore, 'test-conn'); + expect(mockReadColumns).toHaveBeenCalledTimes(1); + expect(mockReadColumns).toHaveBeenCalledWith('test-conn', [ + { database: 'testdb', schema: 'public', name: 'users' }, + { database: 'testdb', schema: 'public', name: 'products' }, + { database: 'testdb', schema: 'public', name: 'orders' }, + ]); expect(state.columnMap).toEqual({ users: ['id', 'name'], products: ['id', 'name'], @@ -437,223 +498,146 @@ describe('schemaStore.loadColumnMap', () => { }); it('does nothing when dbSessionId is null', async () => { - useSchemaStore.setState({ dbSessionId: null }); - await useSchemaStore.getState().loadColumnMap('', 'testdb'); - expect(databaseCommands.getColumns).not.toHaveBeenCalled(); + seedSchema(useSchemaStore, 'test-conn', {}); + await useSchemaStore.getState().ensureDatabaseColumns('', 'testdb'); + expect(mockReadColumns).not.toHaveBeenCalled(); }); it('does nothing when database is empty', async () => { - await useSchemaStore.getState().loadTables('testdb'); - vi.mocked(databaseCommands.getColumns).mockClear(); - vi.mocked(databaseCommands.getAllColumns).mockClear(); - await useSchemaStore.getState().loadColumnMap('test-conn', ''); - expect(databaseCommands.getColumns).not.toHaveBeenCalled(); - expect(databaseCommands.getAllColumns).not.toHaveBeenCalled(); + await useSchemaStore.getState().loadTables('testdb', 'test-conn'); + mockReadColumns.mockClear(); + await useSchemaStore.getState().ensureDatabaseColumns('test-conn', ''); + expect(mockReadColumns).not.toHaveBeenCalled(); }); }); describe('schemaStore.ensureColumns', () => { let useSchemaStore: typeof import('../../stores/schemaStore').useSchemaStore; - let databaseCommands: typeof import('../../commands/database').databaseCommands; beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); - const storeMod = await import('../../stores/schemaStore'); - useSchemaStore = storeMod.useSchemaStore; - const cmdMod = await import('../../commands/database'); - databaseCommands = cmdMod.databaseCommands; + useSchemaStore = (await import('../schemaStore')).useSchemaStore; useSchemaStore.getState().reset(); - useSchemaStore.setState({ dbSessionId: 'test-conn', columnMap: {} }); + seedSchema(useSchemaStore, 'test-conn', {}); }); - it('fetches only the requested tables', async () => { - await useSchemaStore.getState().loadTables('testdb'); - vi.mocked(databaseCommands.getColumns).mockClear(); - + it('reads only requested full identities and derives names and types together', async () => { + await useSchemaStore.getState().loadTables('testdb', 'test-conn'); await useSchemaStore.getState().ensureColumns(['users'], 'test-conn', 'testdb'); - - expect(databaseCommands.getColumns).toHaveBeenCalledTimes(1); - expect(databaseCommands.getColumns).toHaveBeenCalledWith( - 'test-conn', - 'users', - 'testdb', - 'public', - ); - expect(useSchemaStore.getState().columnMap).toEqual({ users: ['id', 'name'] }); + expect(mockReadColumns).toHaveBeenCalledWith('test-conn', [ + { database: 'testdb', schema: 'public', name: 'users' }, + ]); + expect(schemaSnapshot(useSchemaStore, 'test-conn').columnMap).toEqual({ + users: ['id', 'name'], + }); + expect(schemaSnapshot(useSchemaStore, 'test-conn').typedColumnMap).toEqual({ + users: { id: 'integer', name: 'text' }, + }); }); - it('skips tables already present in columnMap', async () => { - await useSchemaStore.getState().loadTables('testdb'); - vi.mocked(databaseCommands.getColumns).mockClear(); - useSchemaStore.setState({ columnMap: { users: ['id'] } }); + it('reuses typed results and fetches only newly requested relations', async () => { + await useSchemaStore.getState().loadTables('testdb', 'test-conn'); + await useSchemaStore.getState().ensureColumns(['users'], 'test-conn', 'testdb'); + mockReadColumns.mockClear(); await useSchemaStore.getState().ensureColumns(['users', 'orders'], 'test-conn', 'testdb'); - expect(databaseCommands.getColumns).toHaveBeenCalledTimes(1); - expect(databaseCommands.getColumns).toHaveBeenCalledWith( - 'test-conn', - 'orders', - 'testdb', - 'public', - ); - expect(useSchemaStore.getState().columnMap).toEqual({ - users: ['id'], - orders: ['id', 'name'], - }); + expect(mockReadColumns).toHaveBeenCalledOnce(); + expect(mockReadColumns).toHaveBeenCalledWith('test-conn', [ + { database: 'testdb', schema: 'public', name: 'orders' }, + ]); }); - it('does not call getColumns for unknown or partial table names', async () => { - useSchemaStore.setState({ + it('does not fetch incomplete names or namespace branches', async () => { + seedSchema(useSchemaStore, 'test-conn', { namespaceTree: { hive: { snap: { wb_daily_orders: [] } } }, }); await useSchemaStore .getState() .ensureColumns(['wb_d', 'wb_daily', 'snap', 'hive'], 'test-conn', 'hive'); - expect(databaseCommands.getColumns).not.toHaveBeenCalled(); - expect(useSchemaStore.getState().columnMap).toEqual({}); + expect(mockReadColumns).not.toHaveBeenCalled(); }); - it('fetches a path-hierarchy leaf once the full name is known', async () => { - useSchemaStore.setState({ + it('supports published path-hierarchy leaf names', async () => { + seedSchema(useSchemaStore, 'test-conn', { namespaceTree: { hive: { snap: { wb_daily_orders: [] } } }, }); await useSchemaStore.getState().ensureColumns(['wb_d', 'wb_daily_orders'], 'test-conn', 'hive'); - expect(databaseCommands.getColumns).toHaveBeenCalledTimes(1); - expect(databaseCommands.getColumns).toHaveBeenCalledWith( - 'test-conn', - 'wb_daily_orders', - 'hive', - null, - ); - expect(useSchemaStore.getState().columnMap).toEqual({ - wb_daily_orders: ['id', 'name'], - }); + expect(mockReadColumns).toHaveBeenCalledWith('test-conn', [ + { database: 'hive', schema: null, name: 'wb_daily_orders' }, + ]); }); - it('does not cache a failed getColumns so a later retry can succeed', async () => { - await useSchemaStore.getState().loadTables('testdb'); - vi.mocked(databaseCommands.getColumns).mockClear(); - vi.mocked(databaseCommands.getColumns).mockRejectedValueOnce(new Error('500')); + it('failed reads remain retryable and clear their in-flight markers', async () => { + await useSchemaStore.getState().loadTables('testdb', 'test-conn'); + mockReadColumns.mockRejectedValueOnce(new Error('500')); await useSchemaStore.getState().ensureColumns(['users'], 'test-conn', 'testdb'); - expect(useSchemaStore.getState().columnMap).toEqual({}); - - vi.mocked(databaseCommands.getColumns).mockResolvedValueOnce(['id', 'name']); + expect(schemaSnapshot(useSchemaStore, 'test-conn').columnMap).toEqual({}); + expect(schemaSnapshot(useSchemaStore, 'test-conn').columnInflight.size).toBe(0); await useSchemaStore.getState().ensureColumns(['users'], 'test-conn', 'testdb'); - expect(useSchemaStore.getState().columnMap).toEqual({ users: ['id', 'name'] }); + expect(schemaSnapshot(useSchemaStore, 'test-conn').columnMap).toEqual({ + users: ['id', 'name'], + }); }); - it('does nothing when dbSessionId is null', async () => { - useSchemaStore.setState({ dbSessionId: null }); + it('does nothing without a session or database', async () => { await useSchemaStore.getState().ensureColumns(['users'], '', 'testdb'); - expect(databaseCommands.getColumns).not.toHaveBeenCalled(); - }); - - it('does nothing when database is empty', async () => { - await useSchemaStore.getState().loadTables('testdb'); - vi.mocked(databaseCommands.getColumns).mockClear(); - vi.mocked(databaseCommands.getAllColumns).mockClear(); await useSchemaStore.getState().ensureColumns(['users'], 'test-conn', ''); - expect(databaseCommands.getColumns).not.toHaveBeenCalled(); - expect(databaseCommands.getAllColumns).not.toHaveBeenCalled(); + expect(mockReadColumns).not.toHaveBeenCalled(); }); - it('passes the tab-bound database to batch and per-table column reads', async () => { - await useSchemaStore.getState().loadTables('testdb'); - vi.mocked(databaseCommands.getAllColumns).mockClear(); - vi.mocked(databaseCommands.getColumns).mockClear(); + it('does not resolve a different database against the active catalog', async () => { + await useSchemaStore.getState().loadTables('testdb', 'test-conn'); + await useSchemaStore.getState().ensureColumns(['users'], 'test-conn', 'otherdb'); + expect(mockReadColumns).not.toHaveBeenCalled(); + }); + it('uses the catalog belonging to the tab-bound database', async () => { + await useSchemaStore.getState().loadTables('testdb', 'test-conn'); + useSchemaStore + .getState() + .setLoadedTables( + 'tab_db', + [{ name: 'users', tableType: 'table', schema: 'archive' }], + 'test-conn', + { pinCurrentDatabase: false }, + ); await useSchemaStore.getState().ensureColumns(['users'], 'test-conn', 'tab_db'); - - // The batch endpoint is deliberately schema-agnostic (AnySchema): it - // returns every schema's columns for the database in one round trip. - expect(databaseCommands.getAllColumns).toHaveBeenCalledWith('test-conn', 'tab_db'); - expect(databaseCommands.getColumns).toHaveBeenCalledWith( - 'test-conn', - 'users', - 'tab_db', - 'public', - ); + expect(mockReadColumns).toHaveBeenCalledWith('test-conn', [ + { database: 'tab_db', schema: 'archive', name: 'users' }, + ]); + expect(schemaSnapshot(useSchemaStore, 'test-conn').currentDatabase).toBe('testdb'); }); - it('falls back to per-table reads for tables missing from the batch result', async () => { - await useSchemaStore.getState().loadTables('testdb'); - vi.mocked(databaseCommands.getAllColumns).mockClear(); - vi.mocked(databaseCommands.getAllColumns).mockResolvedValueOnce({ users: ['id'] }); - vi.mocked(databaseCommands.getColumns).mockClear(); - - await useSchemaStore.getState().ensureColumns(['users', 'orders'], 'test-conn', 'tab_db'); - - expect(databaseCommands.getColumns).toHaveBeenCalledTimes(1); - expect(databaseCommands.getColumns).toHaveBeenCalledWith( + it('ambiguous bare names are not resolved to the first or last schema', async () => { + useSchemaStore.getState().setLoadedTables( + 'db', + [ + { name: 'users', tableType: 'table', schema: 'public' }, + { name: 'users', tableType: 'table', schema: 'archive' }, + ], 'test-conn', - 'orders', - 'tab_db', - 'public', ); - expect(useSchemaStore.getState().columnMap).toEqual({ - users: ['id'], - orders: ['id', 'name'], - }); - }); - - it('requireTypes fetches typed columns even when the batch already filled columnMap', async () => { - await useSchemaStore.getState().loadTables('testdb'); - vi.mocked(databaseCommands.getAllColumns).mockResolvedValueOnce({ users: ['id', 'name'] }); - vi.mocked(databaseCommands.getColumnsTyped).mockResolvedValueOnce([ - { name: 'id', dataType: 'integer' }, - { name: 'ordered_at', dataType: 'timestamp without time zone' }, - ] as never); - + expect(useSchemaStore.getState().schemaOfRelation('users', 'test-conn')).toBeNull(); + await useSchemaStore.getState().ensureColumns(['users'], 'test-conn', 'db'); + expect(mockReadColumns).not.toHaveBeenCalled(); await useSchemaStore .getState() - .ensureColumns(['users'], 'test-conn', 'testdb', { requireTypes: true }); - - expect(databaseCommands.getColumnsTyped).toHaveBeenCalledWith( - 'test-conn', - 'users', - 'testdb', - 'public', - ); - expect(useSchemaStore.getState().typedColumnMap).toEqual({ - users: { id: 'integer', ordered_at: 'timestamp without time zone' }, - }); - }); - - it('without requireTypes the batch path leaves typedColumnMap empty', async () => { - await useSchemaStore.getState().loadTables('testdb'); - vi.mocked(databaseCommands.getAllColumns).mockResolvedValueOnce({ users: ['id', 'name'] }); - - await useSchemaStore.getState().ensureColumns(['users'], 'test-conn', 'testdb'); - - expect(databaseCommands.getColumnsTyped).not.toHaveBeenCalled(); - expect(useSchemaStore.getState().typedColumnMap).toEqual({}); - expect(useSchemaStore.getState().columnMap).toEqual({ users: ['id', 'name'] }); - }); - - it('requireTypes only re-fetches tables that still lack types', async () => { - await useSchemaStore.getState().loadTables('testdb'); - useSchemaStore.setState({ - columnMap: { users: ['id'], orders: ['id'] }, - typedColumnMap: { users: { id: 'integer' } }, - }); - vi.mocked(databaseCommands.getColumnsTyped).mockResolvedValueOnce([ - { name: 'id', dataType: 'bigint' }, - ] as never); - + .ensureColumns(['users'], 'test-conn', 'db', { schema: 'archive' }); await useSchemaStore .getState() - .ensureColumns(['users', 'orders'], 'test-conn', 'testdb', { requireTypes: true }); + .ensureColumns(['users'], 'test-conn', 'db', { schema: 'public' }); + expect(mockReadColumns).toHaveBeenCalledTimes(2); + const values = Object.values(schemaSnapshot(useSchemaStore, 'test-conn').relationColumns); + expect(values.map((value) => value.ref.schema).sort()).toEqual(['archive', 'public']); + }); - expect(databaseCommands.getColumnsTyped).toHaveBeenCalledTimes(1); - expect(databaseCommands.getColumnsTyped).toHaveBeenCalledWith( - 'test-conn', - 'orders', - 'testdb', - 'public', - ); - expect(useSchemaStore.getState().typedColumnMap).toEqual({ - users: { id: 'integer' }, - orders: { id: 'bigint' }, - }); + it('concurrent callers share an in-flight relation', async () => { + await useSchemaStore.getState().loadTables('testdb', 'test-conn'); + await Promise.all([ + useSchemaStore.getState().ensureColumns(['users'], 'test-conn', 'testdb'), + useSchemaStore.getState().ensureColumns(['users'], 'test-conn', 'testdb'), + ]); + expect(mockReadColumns).toHaveBeenCalledOnce(); }); }); @@ -668,106 +652,150 @@ describe('schemaStore namespace merge APIs', () => { }); it('mergeNamespace updates namespaceTree and loadedPaths', async () => { - useSchemaStore.getState().mergeNamespace(['db'], 'branch', ['hive']); - expect(useSchemaStore.getState().namespaceTree).toEqual({ db: { hive: {} } }); - expect(useSchemaStore.getState().loadedPaths.has('db')).toBe(true); + useSchemaStore.getState().mergeNamespace(['db'], 'branch', ['hive'], 'test-conn'); + expect(schemaSnapshot(useSchemaStore, 'test-conn').namespaceTree).toEqual({ db: { hive: {} } }); + expect(schemaSnapshot(useSchemaStore, 'test-conn').loadedPaths.has('db')).toBe(true); }); it('cachePathItems stores get_tables rows by fetch path', async () => { const items: TableInfo[] = [ { name: '558/hive', tableType: 'table', schema: 'CATALOG', rowCount: null }, ]; - useSchemaStore.getState().cachePathItems('558', items); - expect(useSchemaStore.getState().pathItems['558']).toEqual(items); + useSchemaStore.getState().cachePathItems('558', items, 'test-conn'); + expect(schemaSnapshot(useSchemaStore, 'test-conn').pathItems['558']).toEqual(items); }); it('registerPathAliases maps name to id', async () => { - useSchemaStore.getState().registerPathAliases([{ name: 'presto_afi_data', id: '558' }]); - expect(useSchemaStore.getState().pathAliases).toEqual({ presto_afi_data: '558' }); - expect(useSchemaStore.getState().namespaceTree).toEqual({ presto_afi_data: {} }); - expect(useSchemaStore.getState().namespaceOwnedByPlugin).toBe(true); + useSchemaStore + .getState() + .registerPathAliases([{ name: 'presto_afi_data', id: '558' }], 'test-conn'); + expect(schemaSnapshot(useSchemaStore, 'test-conn').pathAliases).toEqual({ + presto_afi_data: '558', + }); + expect(schemaSnapshot(useSchemaStore, 'test-conn').namespaceTree).toEqual({ + presto_afi_data: {}, + }); + expect(schemaSnapshot(useSchemaStore, 'test-conn').namespaceOwnedByPlugin).toBe(true); }); it('setLoadedTables does not flatten namespace after registerPathAliases', async () => { - useSchemaStore.getState().registerPathAliases([{ name: 'presto', id: '558' }]); - useSchemaStore.getState().mergeNamespace(['presto', 'hive', 'snap'], 'tables', ['t1']); - const before = structuredClone(useSchemaStore.getState().namespaceTree); + useSchemaStore.getState().registerPathAliases([{ name: 'presto', id: '558' }], 'test-conn'); useSchemaStore .getState() - .setLoadedTables('558/hive/snap', [ - { name: 't1', tableType: 'table', schema: 'snap', rowCount: null }, - ]); - expect(useSchemaStore.getState().namespaceOwnedByPlugin).toBe(true); - expect(useSchemaStore.getState().namespaceTree).toEqual(before); - expect(useSchemaStore.getState().tables.map((t) => t.name)).toEqual(['t1']); + .mergeNamespace(['presto', 'hive', 'snap'], 'tables', ['t1'], 'test-conn'); + const before = structuredClone(schemaSnapshot(useSchemaStore, 'test-conn').namespaceTree); + useSchemaStore + .getState() + .setLoadedTables( + '558/hive/snap', + [{ name: 't1', tableType: 'table', schema: 'snap', rowCount: null }], + 'test-conn', + ); + expect(schemaSnapshot(useSchemaStore, 'test-conn').namespaceOwnedByPlugin).toBe(true); + expect(schemaSnapshot(useSchemaStore, 'test-conn').namespaceTree).toEqual(before); + expect(schemaSnapshot(useSchemaStore, 'test-conn').tables.map((t) => t.name)).toEqual(['t1']); }); it('setLoadedTables merges mysql-style database.table namespace', async () => { - useSchemaStore.setState({ isMultiDatabase: true }); + seedSchema(useSchemaStore, 'test-conn', { isMultiDatabase: true }); useSchemaStore .getState() - .setLoadedTables('app', [ - { name: 'users', tableType: 'table', schema: null, rowCount: null }, - ]); - expect(useSchemaStore.getState().namespaceTree).toEqual({ app: { users: [] } }); + .setLoadedTables( + 'app', + [{ name: 'users', tableType: 'table', schema: null, rowCount: null }], + 'test-conn', + ); + expect(schemaSnapshot(useSchemaStore, 'test-conn').namespaceTree).toEqual({ + app: { users: [] }, + }); }); it('setLoadedTables groups postgresql schemas under database when multi-db', async () => { - useSchemaStore.setState({ isMultiDatabase: true }); + seedSchema(useSchemaStore, 'test-conn', { isMultiDatabase: true }); useSchemaStore .getState() - .setLoadedTables('warehouse', [ - { name: 't', tableType: 'table', schema: 'public', rowCount: null }, - ]); - expect(useSchemaStore.getState().namespaceTree).toEqual({ + .setLoadedTables( + 'warehouse', + [{ name: 't', tableType: 'table', schema: 'public', rowCount: null }], + 'test-conn', + ); + expect(schemaSnapshot(useSchemaStore, 'test-conn').namespaceTree).toEqual({ warehouse: { public: { t: [] } }, }); }); it('setLoadedTables uses schema.table when single-db postgresql', async () => { - useSchemaStore.setState({ isMultiDatabase: false }); + seedSchema(useSchemaStore, 'test-conn', { isMultiDatabase: false }); useSchemaStore .getState() - .setLoadedTables('warehouse', [ - { name: 't', tableType: 'table', schema: 'public', rowCount: null }, - ]); - expect(useSchemaStore.getState().namespaceTree).toEqual({ public: { t: [] } }); + .setLoadedTables( + 'warehouse', + [{ name: 't', tableType: 'table', schema: 'public', rowCount: null }], + 'test-conn', + ); + expect(schemaSnapshot(useSchemaStore, 'test-conn').namespaceTree).toEqual({ + public: { t: [] }, + }); }); it('setLoadedTables includes views in namespace table leaves', async () => { - useSchemaStore.setState({ isMultiDatabase: false }); - useSchemaStore.getState().setLoadedTables('warehouse', [ - { name: 't', tableType: 'table', schema: 'public', rowCount: null }, - { name: 'v', tableType: 'view', schema: 'public', rowCount: null }, - ]); - expect(useSchemaStore.getState().namespaceTree).toEqual({ public: { t: [], v: [] } }); - expect(useSchemaStore.getState().views.map((v) => v.name)).toEqual(['v']); + seedSchema(useSchemaStore, 'test-conn', { isMultiDatabase: false }); + useSchemaStore.getState().setLoadedTables( + 'warehouse', + [ + { name: 't', tableType: 'table', schema: 'public', rowCount: null }, + { name: 'v', tableType: 'view', schema: 'public', rowCount: null }, + ], + 'test-conn', + ); + expect(schemaSnapshot(useSchemaStore, 'test-conn').namespaceTree).toEqual({ + public: { t: [], v: [] }, + }); + expect(schemaSnapshot(useSchemaStore, 'test-conn').views.map((v) => v.name)).toEqual(['v']); }); it('setLoadedTables replaces dropped tables instead of merging them back', async () => { - useSchemaStore.setState({ isMultiDatabase: true }); - useSchemaStore.getState().setLoadedTables('app', [ - { name: 'users', tableType: 'table', schema: null, rowCount: null }, - { name: 'orders', tableType: 'table' }, - ]); + seedSchema(useSchemaStore, 'test-conn', { isMultiDatabase: true }); + useSchemaStore.getState().setLoadedTables( + 'app', + [ + { name: 'users', tableType: 'table', schema: null, rowCount: null }, + { name: 'orders', tableType: 'table' }, + ], + 'test-conn', + ); useSchemaStore .getState() - .setLoadedTables('app', [ - { name: 'users', tableType: 'table', schema: null, rowCount: null }, - ]); - expect(useSchemaStore.getState().tables.map((t) => t.name)).toEqual(['users']); - expect(useSchemaStore.getState().namespaceTree).toEqual({ app: { users: [] } }); + .setLoadedTables( + 'app', + [{ name: 'users', tableType: 'table', schema: null, rowCount: null }], + 'test-conn', + ); + expect(schemaSnapshot(useSchemaStore, 'test-conn').tables.map((t) => t.name)).toEqual([ + 'users', + ]); + expect(schemaSnapshot(useSchemaStore, 'test-conn').namespaceTree).toEqual({ + app: { users: [] }, + }); }); it('removeRelation drops the table from lists and namespace immediately', async () => { - useSchemaStore.setState({ isMultiDatabase: true }); - useSchemaStore.getState().setLoadedTables('app', [ - { name: 'users', tableType: 'table', schema: null, rowCount: null }, - { name: 'orders', tableType: 'table' }, + seedSchema(useSchemaStore, 'test-conn', { isMultiDatabase: true }); + useSchemaStore.getState().setLoadedTables( + 'app', + [ + { name: 'users', tableType: 'table', schema: null, rowCount: null }, + { name: 'orders', tableType: 'table' }, + ], + 'test-conn', + ); + useSchemaStore.getState().removeRelation('orders', 'test-conn'); + expect(schemaSnapshot(useSchemaStore, 'test-conn').tables.map((t) => t.name)).toEqual([ + 'users', ]); - useSchemaStore.getState().removeRelation('orders'); - expect(useSchemaStore.getState().tables.map((t) => t.name)).toEqual(['users']); - expect(useSchemaStore.getState().namespaceTree).toEqual({ app: { users: [] } }); + expect(schemaSnapshot(useSchemaStore, 'test-conn').namespaceTree).toEqual({ + app: { users: [] }, + }); }); }); @@ -787,15 +815,14 @@ describe('schemaStore.ensureNamespacePath ensuringCount', () => { it('increments while a namespace fetch is in flight', async () => { let release!: (value: TableInfo[]) => void; - vi.mocked(databaseCommands.getTables).mockImplementation( + vi.mocked(databaseCommands.listTables).mockImplementation( () => new Promise((resolve) => { release = resolve; }), ); - useSchemaStore.setState({ - dbSessionId: 'c1', + seedSchema(useSchemaStore, 'c1', { databaseType: 'mysql', currentDatabase: 'app', databases: ['app'], @@ -804,20 +831,19 @@ describe('schemaStore.ensureNamespacePath ensuringCount', () => { ensuringCount: 0, }); - const pending = useSchemaStore.getState().ensureNamespacePath(['app']); + const pending = useSchemaStore.getState().ensureNamespacePath(['app'], 'c1'); await vi.waitFor(() => { expect(typeof release).toBe('function'); }); - expect(useSchemaStore.getState().ensuringCount).toBe(1); + expect(schemaSnapshot(useSchemaStore, 'c1').ensuringCount).toBe(1); release([]); await pending; - expect(useSchemaStore.getState().ensuringCount).toBe(0); + expect(schemaSnapshot(useSchemaStore, 'c1').ensuringCount).toBe(0); }); it('does not increment when the path is already loaded', async () => { - useSchemaStore.setState({ - dbSessionId: 'c1', + seedSchema(useSchemaStore, 'c1', { databaseType: 'mysql', currentDatabase: 'app', databases: ['app'], @@ -826,9 +852,9 @@ describe('schemaStore.ensureNamespacePath ensuringCount', () => { ensuringCount: 0, }); - await useSchemaStore.getState().ensureNamespacePath(['app']); - expect(useSchemaStore.getState().ensuringCount).toBe(0); - expect(databaseCommands.getTables).not.toHaveBeenCalled(); + await useSchemaStore.getState().ensureNamespacePath(['app'], 'c1'); + expect(schemaSnapshot(useSchemaStore, 'c1').ensuringCount).toBe(0); + expect(databaseCommands.listTables).not.toHaveBeenCalled(); }); }); @@ -851,7 +877,7 @@ describe('schemaStore keyed multi-connection', () => { if (conn === 'conn-a') return ['db_a']; return ['db_b']; }); - vi.mocked(databaseCommands.getTables).mockImplementation(async (_conn, db) => { + vi.mocked(databaseCommands.listTables).mockImplementation(async (_conn, db) => { if (db === 'db_a') { return [{ name: 'users_a', tableType: 'table' }]; } @@ -865,13 +891,13 @@ describe('schemaStore keyed multi-connection', () => { databaseType: 'sqlite', }); - expect(useSchemaStore.getState().dbSessionId).toBe('conn-b'); - expect(useSchemaStore.getState().tables.map((t) => t.name)).toEqual(['users_b']); + expect(useSchemaStore.getState().activeDbSessionId).toBe('conn-b'); + expect(schemaSnapshot(useSchemaStore, 'conn-b').tables.map((t) => t.name)).toEqual(['users_b']); useSchemaStore.getState().setActiveConnection('conn-a'); - expect(useSchemaStore.getState().dbSessionId).toBe('conn-a'); - expect(useSchemaStore.getState().tables.map((t) => t.name)).toEqual(['users_a']); - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); + expect(useSchemaStore.getState().activeDbSessionId).toBe('conn-a'); + expect(schemaSnapshot(useSchemaStore, 'conn-a').tables.map((t) => t.name)).toEqual(['users_a']); + expect(schemaSnapshot(useSchemaStore, 'conn-a').currentDatabase).toBe('db_a'); }); it('getConnectionSchema returns cached entry without switching active', async () => { @@ -892,7 +918,7 @@ describe('schemaStore keyed multi-connection', () => { const schemaA = useSchemaStore.getState().getConnectionSchema('conn-a'); expect(schemaA?.currentDatabase).toBe('db_a'); - expect(useSchemaStore.getState().dbSessionId).toBe('conn-b'); + expect(useSchemaStore.getState().activeDbSessionId).toBe('conn-b'); }); it('removeConnection drops cached schema and clears active when removed', async () => { @@ -903,54 +929,50 @@ describe('schemaStore keyed multi-connection', () => { useSchemaStore.getState().removeConnection('conn-a'); expect(useSchemaStore.getState().getConnectionSchema('conn-a')).toBeUndefined(); - expect(useSchemaStore.getState().dbSessionId).toBeNull(); - expect(useSchemaStore.getState().tables).toEqual([]); + expect(useSchemaStore.getState().activeDbSessionId).toBeNull(); + expect(useSchemaStore.getState().schemas.has('conn-a')).toBe(false); }); - it('setState with dbSessionId switches active and preserves per-connection fields', async () => { - vi.mocked(databaseCommands.getDatabases).mockImplementation(async (conn) => { - if (conn === 'conn-a') return ['db_a']; - return ['db_b']; - }); - vi.mocked(databaseCommands.getTables).mockImplementation(async (_conn, db) => [ - { name: `t_${db}`, tableType: 'table' }, - ]); - - await useSchemaStore.getState().loadForConnection('conn-a', { - databaseType: 'sqlite', - }); - await useSchemaStore.getState().loadForConnection('conn-b', { - databaseType: 'sqlite', - }); - - useSchemaStore.setState({ dbSessionId: 'conn-a' }); - expect(useSchemaStore.getState().tables.map((t) => t.name)).toEqual(['t_db_a']); - - useSchemaStore.setState({ columnMap: { t_db_a: ['id'] } }); - expect(useSchemaStore.getState().columnMap).toEqual({ t_db_a: ['id'] }); - expect(useSchemaStore.getState().getConnectionSchema('conn-b')?.columnMap).toEqual({}); - - useSchemaStore.setState({ dbSessionId: 'conn-b' }); - expect(useSchemaStore.getState().columnMap).toEqual({}); + it('requires the target session even when another session is active', async () => { + useSchemaStore.getState().setLoadedTables('db', [{ name: 'a', tableType: 'table' }], 'conn-a'); + useSchemaStore.getState().setLoadedTables('db', [{ name: 'b', tableType: 'table' }], 'conn-b'); + useSchemaStore.getState().setActiveConnection('conn-b'); + useSchemaStore.getState().setSelected('table:a', 'conn-a'); + useSchemaStore.getState().toggleExpand('db:db', 'conn-a'); + expect(schemaSnapshot(useSchemaStore, 'conn-a').selectedId).toBe('table:a'); + expect(schemaSnapshot(useSchemaStore, 'conn-a').expanded.has('db:db')).toBe(true); + expect(schemaSnapshot(useSchemaStore, 'conn-b').selectedId).toBeNull(); + expect(useSchemaStore.getState().activeDbSessionId).toBe('conn-b'); + expect(useSchemaStore.getState()).not.toHaveProperty('dbSessionId'); + expect(useSchemaStore.getState()).not.toHaveProperty('tables'); + expect(useSchemaStore.getState()).not.toHaveProperty('columnMap'); }); it('ensureColumns uses per-connection columnInflight', async () => { - useSchemaStore.setState({ dbSessionId: 'conn-a', columnMap: {} }); - useSchemaStore.getState().setLoadedTables('db', [{ name: 'users', tableType: 'table' }]); + seedSchema(useSchemaStore, 'conn-a', {}); + useSchemaStore + .getState() + .setLoadedTables('db', [{ name: 'users', tableType: 'table' }], 'conn-a'); - useSchemaStore.setState({ dbSessionId: 'conn-b', columnMap: {} }); - useSchemaStore.getState().setLoadedTables('db', [{ name: 'orders', tableType: 'table' }]); + seedSchema(useSchemaStore, 'conn-b', {}); + useSchemaStore + .getState() + .setLoadedTables('db', [{ name: 'orders', tableType: 'table' }], 'conn-b'); - vi.mocked(databaseCommands.getColumns).mockClear(); + vi.mocked(mockReadColumns).mockClear(); await useSchemaStore.getState().ensureColumns(['users'], 'conn-a', 'db'); await useSchemaStore.getState().ensureColumns(['orders'], 'conn-b', 'db'); - expect(databaseCommands.getColumns).toHaveBeenCalledWith('conn-a', 'users', 'db', null); - expect(databaseCommands.getColumns).toHaveBeenCalledWith('conn-b', 'orders', 'db', null); - expect(useSchemaStore.getState().getConnectionSchema('conn-a')?.columnMap).toEqual({ + expect(mockReadColumns).toHaveBeenCalledWith('conn-a', [ + { name: 'users', database: 'db', schema: null }, + ]); + expect(mockReadColumns).toHaveBeenCalledWith('conn-b', [ + { name: 'orders', database: 'db', schema: null }, + ]); + expect(schemaSnapshot(useSchemaStore, 'conn-a').columnMap).toEqual({ users: ['id', 'name'], }); - expect(useSchemaStore.getState().getConnectionSchema('conn-b')?.columnMap).toEqual({ + expect(schemaSnapshot(useSchemaStore, 'conn-b').columnMap).toEqual({ orders: ['id', 'name'], }); }); diff --git a/src/stores/__tests__/schemaStoreJourneys.test.ts b/src/stores/__tests__/schemaStoreJourneys.test.ts new file mode 100644 index 000000000..2e24a3576 --- /dev/null +++ b/src/stores/__tests__/schemaStoreJourneys.test.ts @@ -0,0 +1,193 @@ +import { projectRelationColumns } from '../schemaColumnLoader'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReadColumnsOutput, TableInfo } from '@datazen/driver-sdk'; + +const mocks = vi.hoisted(() => ({ + databases: vi.fn<() => Promise>(), + tables: vi.fn<() => Promise>(), + columns: vi.fn<() => Promise>(), +})); +vi.mock('../../commands/database', () => ({ + databaseCommands: { + getDatabases: mocks.databases, + listTables: mocks.tables, + }, +})); +vi.mock('@datazen/driver-sdk', async (importOriginal) => ({ + ...(await importOriginal()), + schemaClient: { readColumns: mocks.columns }, +})); + +import { renderHook, cleanup } from '@testing-library/react'; +import { createEmptyConnectionSchema } from '../schemaStoreState'; +import { useConnectionSchemaField, useSchemaStore } from '../schemaStore'; +import { relationKey, syncSchemaTables } from '@datazen/driver-sdk'; + +function snapshot() { + const state = useSchemaStore.getState().schemas.get('session') ?? createEmptyConnectionSchema(); + return { + ...state, + ...projectRelationColumns( + state, + 'session', + state.currentDatabase ?? '', + state.currentSchema ?? undefined, + ), + }; +} +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +const tables = (name: string): TableInfo[] => [{ name, schema: null, tableType: 'table' }]; + +describe('schema directory and metadata journeys', () => { + beforeEach(() => { + vi.resetAllMocks(); + useSchemaStore.getState().reset(); + useSchemaStore.setState({ + activeDbSessionId: 'session', + schemas: new Map([['session', createEmptyConnectionSchema()]]), + }); + }); + + it.each(['old-first', 'new-first'])( + 'A → B keeps B regardless of completion order: %s', + async (order) => { + const a = deferred(); + const b = deferred(); + mocks.tables.mockReturnValueOnce(a.promise).mockReturnValueOnce(b.promise); + const loadA = useSchemaStore.getState().switchDatabase('a', 'session'); + const loadB = useSchemaStore.getState().switchDatabase('b', 'session'); + expect(snapshot().loading).toBe(true); + if (order === 'old-first') { + a.resolve(tables('a_table')); + await loadA; + expect(snapshot().loading).toBe(true); + b.resolve(tables('b_table')); + } else { + b.resolve(tables('b_table')); + await loadB; + a.resolve(tables('a_table')); + } + await Promise.all([loadA, loadB]); + expect(snapshot().currentDatabase).toBe('b'); + expect(snapshot().tables).toEqual(tables('b_table')); + expect(snapshot().loading).toBe(false); + }, + ); + + it('a stale failure cannot replace the selected database with an error', async () => { + const a = deferred(); + mocks.tables.mockReturnValueOnce(a.promise).mockResolvedValueOnce(tables('b_table')); + const loadA = useSchemaStore.getState().switchDatabase('a', 'session'); + await useSchemaStore.getState().switchDatabase('b', 'session'); + a.reject(new Error('old database unavailable')); + await loadA; + expect(snapshot().error).toBeNull(); + expect(snapshot().currentDatabase).toBe('b'); + }); + + it('background B refresh preserves A tables and columns, then B can be activated', () => { + useSchemaStore.getState().setLoadedTables('a', tables('a_table'), 'session'); + const entry = useSchemaStore.getState().schemas.get('session')!; + const ref = { database: 'a', schema: null, name: 'a_table' }; + useSchemaStore.setState({ + schemas: new Map([ + [ + 'session', + { + ...entry, + relationColumns: { + [relationKey({ ...ref, dbSessionId: 'session' })]: { + ref, + columns: [{ name: 'id', dataType: 'integer', nullable: false }], + primaryKeys: [], + }, + }, + }, + ], + ]), + }); + useSchemaStore.getState().setLoadedTables('b', tables('b_table'), 'session', { + pinCurrentDatabase: false, + }); + expect(snapshot().currentDatabase).toBe('a'); + expect(snapshot().tables).toEqual(tables('a_table')); + expect(snapshot().columnMap).toEqual({ a_table: ['id'] }); + const cachedB = snapshot().tableCatalogs.b; + useSchemaStore.getState().setLoadedTables('b', cachedB, 'session'); + expect(snapshot().tables).toEqual(tables('b_table')); + expect(snapshot().columnMap).toEqual({}); + }); + + it('column responses from a replaced directory cannot repopulate its maps', async () => { + const columns = deferred(); + mocks.columns.mockReturnValueOnce(columns.promise); + useSchemaStore.getState().setLoadedTables('a', tables('users'), 'session'); + const load = useSchemaStore.getState().ensureColumns(['users'], 'session', 'a'); + useSchemaStore.getState().setLoadedTables('b', tables('users'), 'session'); + columns.resolve({ + results: [ + { + status: 'ok', + value: { + ref: { database: 'a', schema: null, name: 'users' }, + columns: [{ name: 'old_column', dataType: 'text', nullable: true }], + primaryKeys: [], + }, + }, + ], + }); + await load; + expect(snapshot().columnMap).toEqual({}); + expect(snapshot().relationColumns).toEqual({}); + expect(snapshot().columnInflight.size).toBe(0); + }); + + it('disconnect while listing databases never resurrects the session', async () => { + const databases = deferred(); + mocks.databases.mockReturnValueOnce(databases.promise); + const load = useSchemaStore.getState().loadForConnection('session', { skipLoadTables: true }); + useSchemaStore.getState().removeConnection('session'); + databases.resolve(['a']); + await load; + expect(useSchemaStore.getState().schemas.has('session')).toBe(false); + expect(useSchemaStore.getState().activeDbSessionId).toBeNull(); + }); + + it('SDK publication to background session leaves the active session unchanged', () => { + useSchemaStore.getState().setLoadedTables('a', tables('active'), 'session'); + syncSchemaTables('b', tables('background'), 'other-session'); + expect(useSchemaStore.getState().activeDbSessionId).toBe('session'); + expect(snapshot().tables).toEqual(tables('active')); + expect(useSchemaStore.getState().schemas.get('other-session')?.tables).toEqual( + tables('background'), + ); + }); +}); + +afterEach(cleanup); + +it('keeps bound session metadata independent of the active session and missing sessions empty', () => { + const a = { ...createEmptyConnectionSchema(), tables: tables('active_table') }; + const b = { ...createEmptyConnectionSchema(), tables: tables('bound_table') }; + useSchemaStore.setState({ + schemas: new Map([ + ['active', a], + ['bound', b], + ]), + activeDbSessionId: 'active', + }); + const bound = renderHook(() => useConnectionSchemaField('bound', 'tables')); + const missing = renderHook(() => useConnectionSchemaField('missing', 'tables')); + expect(bound.result.current).toEqual(tables('bound_table')); + expect(missing.result.current).toEqual([]); + expect(useSchemaStore.getState().activeDbSessionId).toBe('active'); +}); diff --git a/src/stores/__tests__/schemaStoreState.test.ts b/src/stores/__tests__/schemaStoreState.test.ts index 49da7ef94..63c724937 100644 --- a/src/stores/__tests__/schemaStoreState.test.ts +++ b/src/stores/__tests__/schemaStoreState.test.ts @@ -1,53 +1,42 @@ import { describe, expect, it } from 'vitest'; -import { - activeFlatten, - createEmptyConnectionSchema, - DEFAULT_SCHEMA_KEY, - extractSchemaPatch, - patchConnectionSchema, - resolveRealConnectionId, - resolveTargetConnectionId, -} from '../schemaStoreState'; +import { createEmptyConnectionSchema, patchConnectionSchema } from '../schemaStoreState'; describe('[tester] schemaStoreState', () => { - it('activeFlatten reads active session or default key', () => { - const schemas = new Map>(); + it('creates a session snapshot without legacy column projections', () => { const entry = createEmptyConnectionSchema(); - entry.currentDatabase = 'app'; - schemas.set('sess-1', entry); - - expect(activeFlatten(schemas, 'sess-1').currentDatabase).toBe('app'); - expect(activeFlatten(schemas, null).currentDatabase).toBeNull(); - - schemas.set(DEFAULT_SCHEMA_KEY, { ...entry, currentDatabase: 'default-db' }); - expect(activeFlatten(schemas, null).currentDatabase).toBe('default-db'); + expect(entry.relationColumns).toEqual({}); + expect(entry.tableCatalogs).toEqual({}); + expect(entry).not.toHaveProperty('columnMap'); + expect(entry).not.toHaveProperty('typedColumnMap'); }); - it('extractSchemaPatch picks only known connection keys', () => { - const patch = extractSchemaPatch({ - currentDatabase: 'app', - unknownField: 'ignored', - loading: true, - }); - expect(patch).toEqual({ currentDatabase: 'app', loading: true }); - expect(patch).not.toHaveProperty('unknownField'); + it('patches the explicit session without mutating other sessions or the original map', () => { + const a = { ...createEmptyConnectionSchema(), currentDatabase: 'a' }; + const b = { ...createEmptyConnectionSchema(), currentDatabase: 'b' }; + const schemas = new Map([ + ['session-a', a], + ['session-b', b], + ]); + const next = patchConnectionSchema(schemas, 'session-a', { currentDatabase: 'updated' }); + expect(next.get('session-a')?.currentDatabase).toBe('updated'); + expect(schemas.get('session-a')).toBe(a); + expect(next.get('session-b')).toBe(b); }); - it('patchConnectionSchema merges into map immutably', () => { + it('creates only the requested session when the map is empty', () => { const schemas = new Map>(); - const next = patchConnectionSchema(schemas, 'sess-1', { currentDatabase: 'app' }); - expect(next.get('sess-1')?.currentDatabase).toBe('app'); - expect(schemas.has('sess-1')).toBe(false); - }); - - it('resolveTargetConnectionId falls back to default schema key', () => { - expect(resolveTargetConnectionId({ activeDbSessionId: 'sess-1' })).toBe('sess-1'); - expect(resolveTargetConnectionId({ activeDbSessionId: null })).toBe(DEFAULT_SCHEMA_KEY); - expect(resolveTargetConnectionId({ activeDbSessionId: null }, 'explicit')).toBe('explicit'); + const next = patchConnectionSchema(schemas, 'session', { currentDatabase: 'app' }); + expect([...next.keys()]).toEqual(['session']); + expect(schemas.size).toBe(0); }); - it('resolveRealConnectionId never uses default schema key', () => { - expect(resolveRealConnectionId({ activeDbSessionId: 'sess-1' })).toBe('sess-1'); - expect(resolveRealConnectionId({ activeDbSessionId: null })).toBeNull(); + it('keeps mutable collections isolated between freshly created sessions', () => { + const a = createEmptyConnectionSchema(); + const b = createEmptyConnectionSchema(); + a.expanded.add('db:app'); + a.loadedPaths.add('app'); + expect(b.expanded.size).toBe(0); + expect(b.loadedPaths.size).toBe(0); + expect(a.relationColumns).not.toBe(b.relationColumns); }); }); diff --git a/src/stores/schemaColumnLoader.ts b/src/stores/schemaColumnLoader.ts new file mode 100644 index 000000000..781ffd491 --- /dev/null +++ b/src/stores/schemaColumnLoader.ts @@ -0,0 +1,91 @@ +import { relationKey, type RelationRef, type RelationColumns } from '@datazen/driver-sdk'; +import { schemaClient } from '@datazen/driver-sdk'; +import { capabilitiesForDbSession, relationSchemaFor } from '../lib/driverCapabilities'; +import { knownTableNames } from './schemaStoreHelpers'; +import type { ConnectionSchemaState } from './schemaStoreState'; + +/** Legacy name requests resolve only when their catalog identity is unique. */ +export function resolveColumnRelations( + state: ConnectionSchemaState, + names: readonly string[], + dbSessionId: string, + database: string, + schemaFilter?: string | null, +): RelationRef[] { + const capabilities = capabilitiesForDbSession(dbSessionId); + const catalog = + state.tableCatalogs[database] ?? + (state.currentDatabase === database || state.currentDatabase === null + ? [...state.tables, ...state.views] + : []); + const pathItems = + state.currentDatabase === database || state.currentDatabase === null ? state.pathItems : {}; + const items = [...catalog, ...Object.values(pathItems).flat()]; + const known = knownTableNames(state.namespaceTree, catalog, [], pathItems); + const refs: RelationRef[] = []; + for (const name of new Set(names.map((n) => n.trim()).filter(Boolean))) { + const candidates = new Map(); + for (const item of items) { + if (item.name !== name) continue; + const schema = relationSchemaFor(capabilities, item.schema); + if (schemaFilter !== undefined && schema !== schemaFilter) continue; + const ref = { database, schema, name: item.name }; + candidates.set(relationKey({ ...ref, dbSessionId }), ref); + } + if (candidates.size === 1) refs.push([...candidates.values()][0]); + // Path trees may expose names before publishing their metadata rows. + else if ( + candidates.size === 0 && + known.has(name) && + capabilities?.hasSchemaLevel !== true && + schemaFilter === undefined && + (state.namespaceOwnedByPlugin || + Object.keys(state.pathAliases).length > 0 || + (catalog.length === 0 && state.currentDatabase === null)) + ) { + refs.push({ database, schema: null, name }); + } + } + return refs; +} + +/** One typed read path; transport batching is bounded independently of drivers. */ +export async function loadRelationColumns( + dbSessionId: string, + relations: readonly RelationRef[], +): Promise { + const values: RelationColumns[] = []; + for (let offset = 0; offset < relations.length; offset += 256) { + const batch = relations.slice(offset, offset + 256); + const expected = new Set(batch.map((ref) => relationKey({ ...ref, dbSessionId }))); + const response = await schemaClient.readColumns(dbSessionId, batch); + for (const row of response.results) { + if (row.status === 'ok' && expected.has(relationKey({ ...row.value.ref, dbSessionId }))) { + values.push(row.value); + } + } + } + return values; +} + +/** Bare-name consumers see only uniquely resolved relations in their context. */ +export function projectRelationColumns( + state: ConnectionSchemaState, + dbSessionId: string, + database: string, + schemaFilter?: string | null, +): { columnMap: Record; typedColumnMap: Record> } { + const names = Object.values(state.relationColumns).map((value) => value.ref.name); + const refs = resolveColumnRelations(state, names, dbSessionId, database, schemaFilter); + const columnMap: Record = {}; + const typedColumnMap: Record> = {}; + for (const ref of refs) { + const value = state.relationColumns[relationKey({ ...ref, dbSessionId })]; + if (!value) continue; + columnMap[ref.name] = value.columns.map((column) => column.name); + typedColumnMap[ref.name] = Object.fromEntries( + value.columns.map((column) => [column.name, column.dataType]), + ); + } + return { columnMap, typedColumnMap }; +} diff --git a/src/stores/schemaRequestTracker.ts b/src/stores/schemaRequestTracker.ts new file mode 100644 index 000000000..b5e78d08f --- /dev/null +++ b/src/stores/schemaRequestTracker.ts @@ -0,0 +1,37 @@ +/** Request identity and session lifecycle barriers; no UI state lives here. */ +export class SchemaRequestTracker { + private sessions = new Map>(); + + capture(dbSessionId: string): () => boolean { + let session = this.sessions.get(dbSessionId); + if (!session) { + session = new Map(); + this.sessions.set(dbSessionId, session); + } + return () => this.sessions.get(dbSessionId) === session; + } + + begin(dbSessionId: string, slot: string): () => boolean { + const alive = this.capture(dbSessionId); + const session = this.sessions.get(dbSessionId); + const request = {}; + session?.set(slot, request); + return () => alive() && session?.get(slot) === request; + } + + captureSlot(dbSessionId: string, slot: string): () => boolean { + const alive = this.capture(dbSessionId); + const session = this.sessions.get(dbSessionId); + if (!session?.has(slot)) return this.begin(dbSessionId, slot); + const request = session.get(slot); + return () => alive() && session.get(slot) === request; + } + + invalidate(dbSessionId: string): void { + this.sessions.delete(dbSessionId); + } + + reset(): void { + this.sessions.clear(); + } +} diff --git a/src/stores/schemaStore.ts b/src/stores/schemaStore.ts index e5b2691f1..caf0d958a 100644 --- a/src/stores/schemaStore.ts +++ b/src/stores/schemaStore.ts @@ -1,4 +1,8 @@ import { create } from 'zustand'; +import { useMemo } from 'react'; +import { projectRelationColumns } from './schemaColumnLoader'; +import { SchemaRequestTracker } from './schemaRequestTracker'; +import { loadRelationColumns, resolveColumnRelations } from './schemaColumnLoader'; import { databaseCommands } from '../commands/database'; import { DB_REGISTRY } from '../lib/databaseTypes'; import { @@ -14,8 +18,7 @@ import { } from '../lib/sqlNamespace'; import { t } from '../locales/t'; import type { DatabaseType, TableInfo } from '../types'; -import { bindSchemaStore } from '@datazen/driver-sdk'; -import { capabilitiesForDbSession, relationSchemaFor } from '../lib/driverCapabilities'; +import { bindSchemaStore, relationKey } from '@datazen/driver-sdk'; import { computeIsMultiDatabase, knownTableNames, @@ -25,14 +28,9 @@ import { type LoadForConnectionOptions, } from './schemaStoreHelpers'; import { - activeFlatten, createEmptyConnectionSchema, - DEFAULT_SCHEMA_KEY, EMPTY_NAMESPACE, - extractSchemaPatch, patchConnectionSchema, - resolveRealConnectionId, - resolveTargetConnectionId, type ConnectionSchemaState, } from './schemaStoreState'; @@ -46,16 +44,14 @@ export { } from './schemaStoreHelpers'; export type { ConnectionSchemaState } from './schemaStoreState'; -interface SchemaStore extends ConnectionSchemaState { +export interface SchemaStore { /** Keyed per-connection schema cache. */ schemas: Map; /** Runtime DB session id of the active session. */ activeDbSessionId: string | null; - /** Alias for `activeDbSessionId` (backward compatible). */ - dbSessionId: string | null; loadForConnection: (dbSessionId: string, options?: LoadForConnectionOptions) => Promise; - loadTables: (database: string, dbSessionId?: string) => Promise; + loadTables: (database: string, dbSessionId: string) => Promise; /** * Switch the session to a different logical database and refresh the editor * context (tables/namespace/currentDatabase) for it. Unlike `loadTables`, @@ -64,39 +60,39 @@ interface SchemaStore extends ConnectionSchemaState { * cache. It is meant for lightweight context switches (e.g. the Query Panel * database dropdown) where the connection schema itself did not change. */ - switchDatabase: (database: string, dbSessionId?: string) => Promise; + switchDatabase: (database: string, dbSessionId: string) => Promise; /** F7: pin/clear the PG-family current schema (local UI state; sent as the * `schema` envelope field on query executions). */ - setCurrentSchema: (schema: string | null, dbSessionId?: string) => void; + setCurrentSchema: (schema: string | null, dbSessionId: string) => void; /** Sync the session `currentDatabase` to the ACTIVE panel's bound database so * the store reflects the tab the user is on (prevents drift to the first * database after a reload, e.g. Settings round-trip). */ - setCurrentDatabase: (database: string | null, dbSessionId?: string) => void; + setCurrentDatabase: (database: string | null, dbSessionId: string) => void; setLoadedTables: ( database: string, all: TableInfo[], - dbSessionId?: string, + dbSessionId: string, options?: { pinCurrentDatabase?: boolean }, ) => void; - removeRelation: (name: string, dbSessionId?: string) => void; + removeRelation: (name: string, dbSessionId: string) => void; mergeNamespace: ( segments: string[], kind: NamespaceMergeKind, names: string[], - dbSessionId?: string, + dbSessionId: string, ) => void; - cachePathItems: (fetchPath: string, items: TableInfo[], dbSessionId?: string) => void; - registerPathAliases: (entries: { name: string; id: string }[], dbSessionId?: string) => void; - ensureNamespacePath: (segments: string[], dbSessionId?: string) => Promise; + cachePathItems: (fetchPath: string, items: TableInfo[], dbSessionId: string) => void; + registerPathAliases: (entries: { name: string; id: string }[], dbSessionId: string) => void; + ensureNamespacePath: (segments: string[], dbSessionId: string) => Promise; ensureColumns: ( tableNames: string[], dbSessionId: string, database: string, - options?: { requireTypes?: boolean }, + options?: { schema?: string | null }, ) => Promise; - loadColumnMap: (dbSessionId: string, database: string) => Promise; - toggleExpand: (id: string, dbSessionId?: string) => void; - setSelected: (id: string | null, dbSessionId?: string) => void; + ensureDatabaseColumns: (dbSessionId: string, database: string) => Promise; + toggleExpand: (id: string, dbSessionId: string) => void; + setSelected: (id: string | null, dbSessionId: string) => void; reset: () => void; setActiveConnection: (dbSessionId: string | null) => void; removeConnection: (dbSessionId: string) => void; @@ -107,92 +103,60 @@ interface SchemaStore extends ConnectionSchemaState { * metadata read; `null` means "unknown/not applicable" and is what * schema-less engines expect. */ - schemaOfRelation: (name: string, dbSessionId?: string) => string | null; -} - -function mergePartialIntoStore( - current: { schemas: Map; activeDbSessionId: string | null }, - partial: Partial, -): Pick & - ConnectionSchemaState & { dbSessionId: string | null } { - let schemas = partial.schemas ?? current.schemas; - let activeDbSessionId = current.activeDbSessionId; - - if ('activeDbSessionId' in partial) { - activeDbSessionId = partial.activeDbSessionId ?? null; - } else if ('dbSessionId' in partial) { - activeDbSessionId = partial.dbSessionId ?? null; - } - - if (activeDbSessionId && !schemas.has(activeDbSessionId)) { - schemas = new Map(schemas); - schemas.set(activeDbSessionId, createEmptyConnectionSchema()); - } - - const schemaPatch = extractSchemaPatch(partial); - - const mutationKey = - activeDbSessionId ?? (Object.keys(schemaPatch).length > 0 ? DEFAULT_SCHEMA_KEY : null); - - if (mutationKey && Object.keys(schemaPatch).length > 0) { - schemas = new Map(schemas); - const prev = schemas.get(mutationKey) ?? createEmptyConnectionSchema(); - schemas.set(mutationKey, { ...prev, ...schemaPatch }); - } - - return { - schemas, - activeDbSessionId, - ...activeFlatten(schemas, activeDbSessionId), - }; + schemaOfRelation: (name: string, dbSessionId: string) => string | null; } export const useSchemaStore = create((set, get) => { - type SchemaUpdater = Partial | ((state: SchemaStore) => Partial); - - const setSynced = (partial: SchemaUpdater) => { - if (typeof partial === 'function') { - set( - (state) => ({ ...state, ...mergePartialIntoStore(state, partial(state)) }) as SchemaStore, - ); - } else { - set((state) => ({ ...state, ...mergePartialIntoStore(state, partial) }) as SchemaStore); - } - }; - const commitConnectionPatch = ( dbSessionId: string, patch: Partial, options?: { activate?: boolean }, ) => { - setSynced((state) => { + if (!dbSessionId.trim()) return; + set((state) => { const schemas = patchConnectionSchema(state.schemas, dbSessionId, patch); const activeDbSessionId = options?.activate ? dbSessionId : state.activeDbSessionId; return { schemas, activeDbSessionId, - ...activeFlatten(schemas, activeDbSessionId), }; }); }; - const empty = createEmptyConnectionSchema(); + const requests = new SchemaRequestTracker(); + const reloadTables = async (database: string, dbSessionId: string, refresh: boolean) => { + if (!dbSessionId) return; + const isCurrent = requests.begin(dbSessionId, 'tables'); + commitConnectionPatch(dbSessionId, { loading: true, error: null }); + try { + const all = await databaseCommands.listTables(dbSessionId, database); + if (!isCurrent()) return; + get().setLoadedTables(database, all, dbSessionId); + const state = get().schemas.get(dbSessionId); + commitConnectionPatch(dbSessionId, { + loading: false, + ...(refresh ? { schemaEpoch: (state?.schemaEpoch ?? 0) + 1 } : {}), + }); + } catch (e) { + if (!isCurrent()) return; + commitConnectionPatch(dbSessionId, { + loading: false, + error: e instanceof Error ? e.message : t('schema.loadTablesFailed'), + }); + } + }; return { schemas: new Map(), activeDbSessionId: null, - dbSessionId: null, - ...empty, setActiveConnection: (dbSessionId) => { - setSynced((state) => ({ - activeDbSessionId: dbSessionId, - ...activeFlatten(state.schemas, dbSessionId), - })); + set({ activeDbSessionId: dbSessionId }); }, removeConnection: (dbSessionId) => { - setSynced((state) => { + requests.invalidate(dbSessionId); + set((state) => { const schemas = new Map(state.schemas); schemas.delete(dbSessionId); const activeDbSessionId = @@ -200,15 +164,13 @@ export const useSchemaStore = create((set, get) => { return { schemas, activeDbSessionId, - ...activeFlatten(schemas, activeDbSessionId), }; }); }, getConnectionSchema: (dbSessionId) => get().schemas.get(dbSessionId), - schemaOfRelation: (name, dbSessionIdOverride) => { - const dbSessionId = resolveTargetConnectionId(get(), dbSessionIdOverride); + schemaOfRelation: (name, dbSessionId) => { const state = get().schemas.get(dbSessionId); if (!state) return null; const target = name.trim(); @@ -218,11 +180,15 @@ export const useSchemaStore = create((set, get) => { ...state.views, ...Object.values(state.pathItems).flat(), ]; - const hit = candidates.find((item) => item.name === target); - return hit?.schema?.trim() ? hit.schema : null; + const owners = new Set( + candidates.filter((item) => item.name === target).map((item) => item.schema ?? null), + ); + return owners.size === 1 ? ([...owners][0] ?? null) : null; }, loadForConnection: async (dbSessionId, options) => { + requests.invalidate(dbSessionId); + const isCurrent = requests.begin(dbSessionId, 'connection'); commitConnectionPatch( dbSessionId, { @@ -240,6 +206,7 @@ export const useSchemaStore = create((set, get) => { ); try { const allDatabases = await databaseCommands.getDatabases(dbSessionId); + if (!isCurrent()) return; const meta = options?.databaseType ? DB_REGISTRY[options.databaseType as DatabaseType] : undefined; @@ -292,7 +259,7 @@ export const useSchemaStore = create((set, get) => { commitConnectionPatch( dbSessionId, { databases, isMultiDatabase, loading: false, currentDatabase }, - { activate: options?.activate !== false }, + { activate: false }, ); if (usesPluginDbList) { const aliasEntries = allDatabases.map(parsePathHierarchyDatabaseEntry); @@ -303,9 +270,10 @@ export const useSchemaStore = create((set, get) => { if (options?.skipLoadTables) return; if (currentDatabase) { await get().loadTables(currentDatabase, dbSessionId); - get().setSelected(`db:${currentDatabase}`, dbSessionId); + if (isCurrent()) get().setSelected(`db:${currentDatabase}`, dbSessionId); } } catch (e) { + if (!isCurrent()) return; commitConnectionPatch( dbSessionId, { @@ -313,48 +281,13 @@ export const useSchemaStore = create((set, get) => { error: e instanceof Error ? e.message : t('schema.loadDbFailed'), isMultiDatabase: false, }, - { activate: options?.activate !== false }, + { activate: false }, ); } }, - loadTables: async (database, dbSessionIdOverride) => { - const dbSessionId = resolveRealConnectionId(get(), dbSessionIdOverride); - if (!dbSessionId) return; - commitConnectionPatch(dbSessionId, { loading: true, error: null }); - try { - const all = await databaseCommands.getTables(dbSessionId, database); - get().setLoadedTables(database, all, dbSessionId); - const schema = get().schemas.get(dbSessionId); - commitConnectionPatch(dbSessionId, { - loading: false, - schemaEpoch: (schema?.schemaEpoch ?? 0) + 1, - }); - } catch (e) { - commitConnectionPatch(dbSessionId, { - loading: false, - error: e instanceof Error ? e.message : t('schema.loadTablesFailed'), - }); - } - }, - - switchDatabase: async (database, dbSessionIdOverride) => { - const dbSessionId = resolveRealConnectionId(get(), dbSessionIdOverride); - if (!dbSessionId) return; - commitConnectionPatch(dbSessionId, { loading: true, error: null }); - try { - // F1: no use_database IPC — currentDatabase is local UI state; query - // commands pin the database explicitly and the backend switches lazily. - const all = await databaseCommands.getTables(dbSessionId, database); - get().setLoadedTables(database, all, dbSessionId); - commitConnectionPatch(dbSessionId, { loading: false }); - } catch (e) { - commitConnectionPatch(dbSessionId, { - loading: false, - error: e instanceof Error ? e.message : t('schema.loadTablesFailed'), - }); - } - }, + loadTables: (database, dbSessionId) => reloadTables(database, dbSessionId, true), + switchDatabase: (database, dbSessionId) => reloadTables(database, dbSessionId, false), /** * F7: set the PG-family current schema (pure local UI state, like @@ -362,11 +295,12 @@ export const useSchemaStore = create((set, get) => { * `schema` envelope field; rewrite-capable drivers inline it as * `"schema"."t"`. `null` clears the pin. */ - setCurrentSchema: (schema, dbSessionIdOverride) => { - const dbSessionId = resolveTargetConnectionId(get(), dbSessionIdOverride); + setCurrentSchema: (schema, dbSessionId) => { if (!dbSessionId) return; const normalized = schema?.trim() ? schema.trim() : null; - commitConnectionPatch(dbSessionId, { currentSchema: normalized }); + commitConnectionPatch(dbSessionId, { + currentSchema: normalized, + }); }, // Session-local database pointer. When the active (query/table) panel is @@ -375,8 +309,7 @@ export const useSchemaStore = create((set, get) => { // user is on — otherwise loadForConnection/schema-tree defaults can // re-pin it to the first database after a reload (e.g. Settings round-trip) // even though the panel is still targeting `tradingdb`. - setCurrentDatabase: (database, dbSessionIdOverride) => { - const dbSessionId = resolveTargetConnectionId(get(), dbSessionIdOverride); + setCurrentDatabase: (database, dbSessionId) => { if (!dbSessionId) return; const normalized = database?.trim() || null; if (!normalized) return; @@ -385,11 +318,10 @@ export const useSchemaStore = create((set, get) => { // Only adopt a database the session actually knows about; before the // database list is loaded (empty) we still allow the panel pointer. if (databases.length > 0 && !databases.includes(normalized)) return; - commitConnectionPatch(dbSessionId, { currentDatabase: normalized }, { activate: true }); + commitConnectionPatch(dbSessionId, { currentDatabase: normalized }); }, - mergeNamespace: (segments, kind, names, dbSessionIdOverride) => { - const dbSessionId = resolveTargetConnectionId(get(), dbSessionIdOverride); + mergeNamespace: (segments, kind, names, dbSessionId) => { const schema = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); commitConnectionPatch(dbSessionId, { namespaceTree: mergeNamespacePath(schema.namespaceTree, segments, kind, names), @@ -397,17 +329,15 @@ export const useSchemaStore = create((set, get) => { }); }, - cachePathItems: (fetchPath, items, dbSessionIdOverride) => { + cachePathItems: (fetchPath, items, dbSessionId) => { if (!fetchPath) return; - const dbSessionId = resolveTargetConnectionId(get(), dbSessionIdOverride); const schema = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); commitConnectionPatch(dbSessionId, { pathItems: { ...schema.pathItems, [fetchPath]: items }, }); }, - registerPathAliases: (entries, dbSessionIdOverride) => { - const dbSessionId = resolveTargetConnectionId(get(), dbSessionIdOverride); + registerPathAliases: (entries, dbSessionId) => { const schema = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); const nextIds = { ...schema.pathAliases }; const names: string[] = []; @@ -422,10 +352,10 @@ export const useSchemaStore = create((set, get) => { get().mergeNamespace([], 'branch', names, dbSessionId); }, - ensureNamespacePath: async (segments, dbSessionIdOverride) => { - const dbSessionId = resolveRealConnectionId(get(), dbSessionIdOverride); + ensureNamespacePath: async (segments, dbSessionId) => { if (!dbSessionId) return; const schema = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); + const isCurrent = requests.capture(dbSessionId); const deps = { dbSessionId, databaseType: schema.databaseType, @@ -437,14 +367,17 @@ export const useSchemaStore = create((set, get) => { tables: schema.tables, databases: schema.databases, currentDatabase: schema.currentDatabase, - mergeNamespace: (segs: string[], kind: NamespaceMergeKind, names: string[]) => - get().mergeNamespace(segs, kind, names, dbSessionId), - cachePathItems: (fetchPath: string, items: TableInfo[]) => - get().cachePathItems(fetchPath, items, dbSessionId), - registerPathAliases: (entries: { name: string; id: string }[]) => - get().registerPathAliases(entries, dbSessionId), + mergeNamespace: (segs: string[], kind: NamespaceMergeKind, names: string[]) => { + if (isCurrent()) get().mergeNamespace(segs, kind, names, dbSessionId); + }, + cachePathItems: (fetchPath: string, items: TableInfo[]) => { + if (isCurrent()) get().cachePathItems(fetchPath, items, dbSessionId); + }, + registerPathAliases: (entries: { name: string; id: string }[]) => { + if (isCurrent()) get().registerPathAliases(entries, dbSessionId); + }, getDatabases: databaseCommands.getDatabases, - getTables: databaseCommands.getTables, + listTables: databaseCommands.listTables, }; const pending = namespaceEnsurePending(segments, deps); if (pending) { @@ -453,7 +386,7 @@ export const useSchemaStore = create((set, get) => { try { await ensureNamespacePathImpl(segments, deps); } finally { - if (pending) { + if (pending && isCurrent()) { const latest = get().schemas.get(dbSessionId); commitConnectionPatch(dbSessionId, { ensuringCount: Math.max(0, (latest?.ensuringCount ?? 1) - 1), @@ -462,9 +395,15 @@ export const useSchemaStore = create((set, get) => { } }, - setLoadedTables: (database, all, dbSessionIdOverride, options) => { - const dbSessionId = resolveTargetConnectionId(get(), dbSessionIdOverride); + setLoadedTables: (database, all, dbSessionId, options) => { const schema = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); + const tableCatalogs = { ...schema.tableCatalogs, [database]: all }; + if (options?.pinCurrentDatabase === false && schema.currentDatabase !== database) { + commitConnectionPatch(dbSessionId, { tableCatalogs }); + return; + } + // Detach every earlier column load when this directory is replaced. + requests.begin(dbSessionId, 'columns'); // Background cache refreshes (expanded-db fan-out, session refresh) pass // { pinCurrentDatabase: false } so a late-completing reload cannot // overwrite whichever database the user last selected in the tree — @@ -515,18 +454,22 @@ export const useSchemaStore = create((set, get) => { commitConnectionPatch(dbSessionId, { tables, + tableCatalogs, views, schemaNames, + columnInflight: new Set(), + relationColumns: Object.fromEntries( + Object.entries(schema.relationColumns).filter( + ([, value]) => value.ref.database !== database, + ), + ), ...(pinCurrentDatabase ? { currentDatabase: database } : {}), - columnMap: {}, - typedColumnMap: {}, namespaceTree: nextTree, loadedPaths: nextLoadedPaths, }); }, - removeRelation: (name, dbSessionIdOverride) => { - const dbSessionId = resolveTargetConnectionId(get(), dbSessionIdOverride); + removeRelation: (name, dbSessionId) => { const schema = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); const nextSelected = schema.selectedId === `table:${name}` || schema.selectedId === `view:${name}` @@ -541,136 +484,55 @@ export const useSchemaStore = create((set, get) => { }, ensureColumns: async (tableNames, dbSessionId, database, options) => { - if (!dbSessionId || !database?.trim()) return; - const schema = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); - const { columnMap, typedColumnMap, namespaceTree, tables, views, pathItems, columnInflight } = - schema; - const known = knownTableNames(namespaceTree, tables, views, pathItems); - if (known.size === 0) return; - // Every read below carries the table's *own* schema (from `TableInfo`). - // Schema-aware engines (PostgreSQL, SQL Server) reject a schema-less - // metadata read, and guessing would be the same class of bug this - // replaced: resolving a table against a namespace it does not live in. - const capabilities = capabilitiesForDbSession(dbSessionId); - const schemaOf = new Map(); - for (const item of [...tables, ...views, ...Object.values(pathItems).flat()]) { - const relationSchema = relationSchemaFor(capabilities, item.schema); - if (relationSchema) schemaOf.set(item.name, relationSchema); - } - const wanted = [ - ...new Set( - tableNames - .map((name) => name.trim()) - .filter((name) => name.length > 0 && known.has(name)), - ), - ]; - const missing = wanted.filter((name) => !(name in columnMap) && !columnInflight.has(name)); - // The batch endpoint returns names only — consumers that need dataTypes - // (the visual query builder) pass `requireTypes` so tables already in - // `columnMap` still get the per-table typed endpoint. - const typedMissing = options?.requireTypes - ? wanted.filter((name) => !(name in typedColumnMap) && !columnInflight.has(name)) - : []; - if (missing.length === 0 && typedMissing.length === 0) return; - - // Try batch loading first. Only satisfied tables are recorded; any table - // missing from the batch result falls through to per-table loading below - // (e.g. batch hit another database, or the driver returned a subset). - let stillMissing = missing; - if (missing.length > 0) { - try { - const batchResult = await databaseCommands.getAllColumns(dbSessionId, database); - if (batchResult && Object.keys(batchResult).length > 0) { - const latest = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); - const nextColumnMap = { ...latest.columnMap }; - let changed = false; - for (const name of missing) { - if (name in batchResult) { - nextColumnMap[name] = batchResult[name]; - changed = true; - } - } - if (changed) { - commitConnectionPatch(dbSessionId, { columnMap: nextColumnMap }); - } - stillMissing = missing.filter((name) => !(name in batchResult)); - } - } catch { - // Batch not supported or failed — fall through to per-table - } - } - const toFetch = [...new Set([...stillMissing, ...typedMissing])]; - if (toFetch.length === 0) return; - - // Fallback: per-table loading (uses typed endpoint for dataType info) - const latest = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); - const nextInflight = new Set(latest.columnInflight); - for (const name of toFetch) nextInflight.add(name); - commitConnectionPatch(dbSessionId, { columnInflight: nextInflight }); - + if (!dbSessionId || !database.trim()) return; + const schema = get().schemas.get(dbSessionId); + if (!schema) return; + const isCurrent = requests.captureSlot(dbSessionId, 'columns'); + const refs = resolveColumnRelations( + schema, + tableNames, + dbSessionId, + database, + options?.schema, + ).filter((ref) => { + const key = relationKey({ ...ref, dbSessionId }); + return !schema.columnInflight.has(key) && !(key in schema.relationColumns); + }); + if (refs.length === 0) return; + commitConnectionPatch(dbSessionId, { + columnInflight: new Set([ + ...schema.columnInflight, + ...refs.map((ref) => relationKey({ ...ref, dbSessionId })), + ]), + }); try { - const settled = await Promise.all( - toFetch.map(async (name) => { - try { - const typedCols = await databaseCommands.getColumnsTyped( - dbSessionId, - name, - database, - schemaOf.get(name) ?? null, - ); - return { - name, - cols: typedCols.map((c) => c.name), - typeMap: Object.fromEntries(typedCols.map((c) => [c.name, c.dataType])), - }; - } catch { - try { - // Fallback to untyped endpoint if typed not available - const cols = await databaseCommands.getColumns( - dbSessionId, - name, - database, - schemaOf.get(name) ?? null, - ); - return { name, cols, typeMap: null }; - } catch { - return { name, cols: null, typeMap: null }; - } - } - }), - ); - const latest = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); - const nextColumnMap = { ...latest.columnMap }; - const nextTypedColumnMap = { ...latest.typedColumnMap }; - let changed = false; - for (const row of settled) { - if (row.cols == null) continue; - nextColumnMap[row.name] = row.cols; - if (row.typeMap) { - nextTypedColumnMap[row.name] = row.typeMap; - } - changed = true; - } - const clearedInflight = new Set(latest.columnInflight); - for (const name of toFetch) clearedInflight.delete(name); - if (changed) { - commitConnectionPatch(dbSessionId, { - columnMap: nextColumnMap, - typedColumnMap: nextTypedColumnMap, - columnInflight: clearedInflight, - }); - } else { - commitConnectionPatch(dbSessionId, { columnInflight: clearedInflight }); + const values = await loadRelationColumns(dbSessionId, refs); + if (!isCurrent()) return; + const latest = get().schemas.get(dbSessionId); + if (!latest) return; + const relationColumns = { ...latest.relationColumns }; + for (const value of values) { + relationColumns[relationKey({ ...value.ref, dbSessionId })] = value; } + commitConnectionPatch(dbSessionId, { relationColumns }); } catch { - const latest = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); - const clearedInflight = new Set(latest.columnInflight); - for (const name of toFetch) clearedInflight.delete(name); - commitConnectionPatch(dbSessionId, { columnInflight: clearedInflight }); + // No successful result is fabricated; later calls may retry. + } finally { + if (isCurrent()) { + const latest = get().schemas.get(dbSessionId); + if (latest) + commitConnectionPatch(dbSessionId, { + columnInflight: new Set( + [...latest.columnInflight].filter( + (key) => !refs.some((ref) => relationKey({ ...ref, dbSessionId }) === key), + ), + ), + }); + } } }, - loadColumnMap: async (dbSessionId, database) => { + ensureDatabaseColumns: async (dbSessionId, database) => { if (!dbSessionId || !database?.trim()) return; const schema = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); // Collect table names from ALL sources: tables array (normal drivers), @@ -681,8 +543,7 @@ export const useSchemaStore = create((set, get) => { await get().ensureColumns(allNames, dbSessionId, database); }, - toggleExpand: (id, dbSessionIdOverride) => { - const dbSessionId = resolveTargetConnectionId(get(), dbSessionIdOverride); + toggleExpand: (id, dbSessionId) => { const schema = get().schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); const next = new Set(schema.expanded); if (next.has(id)) next.delete(id); @@ -690,45 +551,25 @@ export const useSchemaStore = create((set, get) => { commitConnectionPatch(dbSessionId, { expanded: next }); }, - setSelected: (id, dbSessionIdOverride) => { - const dbSessionId = resolveTargetConnectionId(get(), dbSessionIdOverride); + setSelected: (id, dbSessionId) => { commitConnectionPatch(dbSessionId, { selectedId: id }); }, reset: () => { - const fresh = createEmptyConnectionSchema(); - setSynced({ + requests.reset(); + set({ schemas: new Map(), activeDbSessionId: null, - dbSessionId: null, - ...fresh, }); }, }; }); -/** Route external `setState` partials into the keyed schema map (backward compatible). */ -const nativeSetState = useSchemaStore.setState.bind(useSchemaStore); -useSchemaStore.setState = ((partial, replace) => { - const merge = (state: SchemaStore): SchemaStore => - ({ - ...state, - ...(typeof partial === 'function' - ? mergePartialIntoStore(state, partial(state)) - : mergePartialIntoStore(state, partial)), - }) as SchemaStore; - if (replace) { - nativeSetState(merge as Parameters[0], true); - } else { - nativeSetState((state) => merge(state)); - } -}) as typeof useSchemaStore.setState; +const emptyConnectionSchema = createEmptyConnectionSchema(); /** * Read a field from the keyed per-session schema store. - * Falls back to the global (active-session) field when the keyed entry - * is not yet populated, keeping backward compatibility for callers that - * only have a single DB session open. + * Missing sessions read an empty snapshot, never another session's data. */ export function useConnectionSchemaField( dbSessionId: string, @@ -737,10 +578,25 @@ export function useConnectionSchemaField( return useSchemaStore((s) => { const entry = s.schemas.get(dbSessionId); if (entry) return entry[field]; - return (s as unknown as ConnectionSchemaState)[field]; + return emptyConnectionSchema[field]; }); } +/** Derive editor columns from the canonical relation cache for one session/context. */ +export function useConnectionColumnMaps(dbSessionId: string) { + const entry = useSchemaStore((state) => state.schemas.get(dbSessionId)); + return useMemo( + () => + projectRelationColumns( + entry ?? emptyConnectionSchema, + dbSessionId, + entry?.currentDatabase ?? '', + entry?.currentSchema ?? undefined, + ), + [entry, dbSessionId], + ); +} + if (import.meta.env.DEV) { (window as unknown as Record).__schemaStore = useSchemaStore; } diff --git a/src/stores/schemaStoreHelpers.ts b/src/stores/schemaStoreHelpers.ts index 7fa5fd03e..05b0510b5 100644 --- a/src/stores/schemaStoreHelpers.ts +++ b/src/stores/schemaStoreHelpers.ts @@ -62,7 +62,7 @@ export function parsePathHierarchyDatabaseEntry(entry: string): { id: string; na return { id, name: rest }; } -/** Names that are safe to pass to `get_columns` (complete loaded tables only). */ +/** Names that are safe to pass to `read_relation_columns` (complete loaded tables only). */ export function knownTableNames( namespaceTree: SqlNamespace, tables: TableInfo[], diff --git a/src/stores/schemaStoreState.ts b/src/stores/schemaStoreState.ts index d68f8e799..72de39f46 100644 --- a/src/stores/schemaStoreState.ts +++ b/src/stores/schemaStoreState.ts @@ -1,5 +1,5 @@ import type { SqlNamespace } from '../lib/sqlNamespace'; -import type { TableInfo } from '../types'; +import type { TableInfo, RelationColumns } from '@datazen/driver-sdk'; /** Per-session schema cache entry (map key = runtime DB session id). */ export interface ConnectionSchemaState { @@ -10,12 +10,12 @@ export interface ConnectionSchemaState { databaseType: string | null; isMultiDatabase: boolean; tables: TableInfo[]; + /** Directory snapshots are owned by their database, not the active pointer. */ + tableCatalogs: Record; views: TableInfo[]; /** All schema names including those with no tables (e.g. from PG schemata). */ schemaNames: string[]; - columnMap: Record; - /** Per-table column type map (table → column → raw dataType). */ - typedColumnMap: Record>; + relationColumns: Record; namespaceTree: SqlNamespace; loadedPaths: Set; pathItems: Record; @@ -32,34 +32,6 @@ export interface ConnectionSchemaState { export const EMPTY_NAMESPACE: SqlNamespace = {}; -/** Fallback key when mutating schema without an active DB session (singleton compat). */ -export const DEFAULT_SCHEMA_KEY = '__default__'; - -export const CONNECTION_STATE_KEYS = [ - 'currentDatabase', - 'currentSchema', - 'databases', - 'databaseType', - 'isMultiDatabase', - 'tables', - 'views', - 'schemaNames', - 'columnMap', - 'typedColumnMap', - 'namespaceTree', - 'loadedPaths', - 'pathItems', - 'pathAliases', - 'namespaceOwnedByPlugin', - 'schemaEpoch', - 'expanded', - 'selectedId', - 'loading', - 'ensuringCount', - 'error', - 'columnInflight', -] as const satisfies readonly (keyof ConnectionSchemaState)[]; - export function createEmptyConnectionSchema(): ConnectionSchemaState { return { currentDatabase: null, @@ -68,10 +40,10 @@ export function createEmptyConnectionSchema(): ConnectionSchemaState { databaseType: null, isMultiDatabase: false, tables: [], + tableCatalogs: {}, views: [], schemaNames: [], - columnMap: {}, - typedColumnMap: {}, + relationColumns: {}, namespaceTree: EMPTY_NAMESPACE, loadedPaths: new Set(), pathItems: {}, @@ -87,33 +59,6 @@ export function createEmptyConnectionSchema(): ConnectionSchemaState { }; } -export function activeFlatten( - schemas: Map, - activeDbSessionId: string | null, -): ConnectionSchemaState & { dbSessionId: string | null } { - const readKey = - activeDbSessionId ?? (schemas.has(DEFAULT_SCHEMA_KEY) ? DEFAULT_SCHEMA_KEY : null); - if (!readKey) { - return { ...createEmptyConnectionSchema(), dbSessionId: null }; - } - const schema = schemas.get(readKey); - if (!schema) { - return { ...createEmptyConnectionSchema(), dbSessionId: activeDbSessionId }; - } - return { ...schema, dbSessionId: activeDbSessionId }; -} - -export function extractSchemaPatch( - partial: Record, -): Partial { - const result: Partial = {}; - for (const key of CONNECTION_STATE_KEYS) { - if (!(key in partial)) continue; - (result as Record)[key] = partial[key as keyof ConnectionSchemaState]; - } - return result; -} - export function patchConnectionSchema( schemas: Map, dbSessionId: string, @@ -124,17 +69,3 @@ export function patchConnectionSchema( next.set(dbSessionId, { ...prev, ...patch }); return next; } - -export function resolveTargetConnectionId( - state: { activeDbSessionId: string | null }, - dbSessionId?: string, -): string { - return dbSessionId ?? state.activeDbSessionId ?? DEFAULT_SCHEMA_KEY; -} - -export function resolveRealConnectionId( - state: { activeDbSessionId: string | null }, - dbSessionId?: string, -): string | null { - return dbSessionId ?? state.activeDbSessionId; -} diff --git a/src/types/index.ts b/src/types/index.ts index ced86a600..4bc598056 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -81,8 +81,6 @@ export interface DriverCapabilities { hasSchemaLevel: boolean; } -export type TableType = 'table' | 'view' | 'materializedView' | 'systemTable'; - export type DatabaseObjectKind = 'function' | 'procedure' | 'trigger' | 'sequence' | 'type'; export interface DatabaseObject { @@ -103,56 +101,16 @@ export interface PrivilegeGrant { privilege: string; } -export interface TableInfo { - name: string; - /** `driver-api` declares both as `Option<_>` without `skip_serializing_if`, so - * serde puts `null` on the wire — not `undefined`. `?:` alone is a lie that - * silently rejects the backend's own payload. */ - schema?: string | null; - tableType: TableType; - rowCount?: number | null; -} - -export interface ColumnSchema { - name: string; - dataType: string; - nullable: boolean; - defaultValue?: string | null; - isPrimaryKey?: boolean; - isAutoIncrement?: boolean; - comment?: string; -} - -export interface IndexInfo { - name: string; - columns: string[]; - isUnique: boolean; - isPrimary: boolean; - indexType?: string; -} - -export interface ForeignKeyInfo { - name: string; - columns: string[]; - referencedTable: string; - referencedColumns: string[]; - onUpdate?: string; - onDelete?: string; - deferrability?: - | 'unknown' - | 'notDeferrable' - | 'deferrableInitiallyImmediate' - | 'deferrableInitiallyDeferred'; -} - -export interface TableSchema { - tableName: string; - columns: ColumnSchema[]; - primaryKeys: string[]; - indexes: IndexInfo[]; - foreignKeys: ForeignKeyInfo[]; - tableOptions?: TableOptionsSnapshot; -} +export type { + TableType, + TableInfo, + ColumnSchema, + IndexInfo, + ForeignKeyInfo, + TableSchema, + TableOptionsSnapshot, +} from '@datazen/driver-sdk'; +import type { ColumnSchema, TableOptionsSnapshot } from '@datazen/driver-sdk'; export type Value = string | number | boolean | null | Record | unknown[]; @@ -833,12 +791,6 @@ export interface CheckConstraintDiffEntry { expression: string; } -export interface TableOptionsSnapshot { - comment?: string | null; - engine?: string | null; - charset?: string | null; -} - export interface TableOptionsDiff { source: TableOptionsSnapshot; target: TableOptionsSnapshot; diff --git a/src/windows/backup/BackupWindow.tsx b/src/windows/backup/BackupWindow.tsx index 447878945..2221816c8 100644 --- a/src/windows/backup/BackupWindow.tsx +++ b/src/windows/backup/BackupWindow.tsx @@ -1,3 +1,4 @@ +import { schemaClient } from '@datazen/driver-sdk'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChevronDown, ChevronRight, Database, HardDrive } from 'lucide-react'; import { TitleBar } from '../../components/TitleBar'; @@ -135,7 +136,7 @@ export function BackupWindow() { /* server version is optional */ } - const dbs = await invoke('get_databases', { dbSessionId: sessionId }); + const { databases: dbs } = await schemaClient.listDatabases(sessionId); setDatabases(dbs.map((name) => ({ name }))); const dbPick = preferredDatabase ?? conn.database; diff --git a/src/windows/backup/__tests__/BackupWindow.test.tsx b/src/windows/backup/__tests__/BackupWindow.test.tsx index 72e0c5778..9db42580a 100644 --- a/src/windows/backup/__tests__/BackupWindow.test.tsx +++ b/src/windows/backup/__tests__/BackupWindow.test.tsx @@ -2,7 +2,21 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render, cleanup, waitFor, fireEvent, screen } from '@testing-library/react'; import { BackupWindow } from '../BackupWindow'; import { PRESET_GROUPS } from '../../../lib/connectionGroups'; -import type { ConnectionConfig } from '../../../types'; +import type { TableInfo, ConnectionConfig } from '../../../types'; + +function catalogResult(tables: TableInfo[]) { + return { + data: { + database: 'app', + schemas: [], + relations: tables.map((table) => ({ + ref: { database: 'app', schema: table.schema ?? null, name: table.name }, + kind: table.tableType, + rowCount: table.rowCount ?? null, + })), + }, + }; +} const { invokeMock, @@ -182,29 +196,32 @@ describe('BackupWindow connection list', () => { expect(invokeMock).not.toHaveBeenCalledWith('connect', expect.anything()); }); - function mockRestoreCommands(tables: unknown[] = []) { + function mockRestoreCommands(tables: TableInfo[] = []) { urlParamMock.mockImplementation((name: string) => (name === 'mode' ? 'restore' : null)); - invokeMock.mockImplementation(async (cmd: string) => { - if (cmd === 'get_connections') { - return [ - conn({ - id: 'pg-1', - name: 'Local PG', - databaseType: 'postgresql', - group: PRESET_GROUPS.development, - host: 'localhost', - database: 'app', - }), - ]; - } - if (cmd === 'get_groups') return [PRESET_GROUPS.development]; - if (cmd === 'connect') return 'live-1'; - if (cmd === 'get_connection_info') return { serverVersion: '16' }; - if (cmd === 'get_databases') return ['app', 'postgres']; - if (cmd === 'get_tables') return tables; - if (cmd === 'restore_sql_file') return true; - return null; - }); + invokeMock.mockImplementation( + async (cmd: string, args?: { request?: { command?: string } }) => { + if (cmd === 'get_connections') { + return [ + conn({ + id: 'pg-1', + name: 'Local PG', + databaseType: 'postgresql', + group: PRESET_GROUPS.development, + host: 'localhost', + database: 'app', + }), + ]; + } + if (cmd === 'get_groups') return [PRESET_GROUPS.development]; + if (cmd === 'connect') return 'live-1'; + if (cmd === 'get_connection_info') return { serverVersion: '16' }; + if (args?.request?.command === 'list_databases') + return { data: { databases: ['app', 'postgres'] } }; + if (args?.request?.command === 'list_catalog') return catalogResult(tables); + if (cmd === 'restore_sql_file') return true; + return null; + }, + ); } async function selectRestoreTarget() { @@ -249,35 +266,38 @@ describe('BackupWindow connection list', () => { return () => {}; }, ); - invokeMock.mockImplementation(async (cmd: string) => { - if (cmd === 'get_connections') { - return [ - conn({ - id: 'pg-1', - name: 'Local PG', - databaseType: 'postgresql', - group: PRESET_GROUPS.development, - host: 'localhost', - database: 'app', - }), - ]; - } - if (cmd === 'get_groups') return [PRESET_GROUPS.development]; - if (cmd === 'connect') return 'live-1'; - if (cmd === 'get_connection_info') return { serverVersion: '16' }; - if (cmd === 'get_databases') return ['app', 'postgres']; - if (cmd === 'get_tables') return []; - if (cmd === 'restore_sql_file') { - onProgress?.({ - payload: { current: 1, total: 2, objectName: 'CREATE TABLE users', phase: 'object' }, - }); - onProgress?.({ - payload: { current: 2, total: 2, objectName: 'INSERT INTO users', phase: 'object' }, - }); - return true; - } - return null; - }); + invokeMock.mockImplementation( + async (cmd: string, args?: { request?: { command?: string } }) => { + if (cmd === 'get_connections') { + return [ + conn({ + id: 'pg-1', + name: 'Local PG', + databaseType: 'postgresql', + group: PRESET_GROUPS.development, + host: 'localhost', + database: 'app', + }), + ]; + } + if (cmd === 'get_groups') return [PRESET_GROUPS.development]; + if (cmd === 'connect') return 'live-1'; + if (cmd === 'get_connection_info') return { serverVersion: '16' }; + if (args?.request?.command === 'list_databases') + return { data: { databases: ['app', 'postgres'] } }; + if (args?.request?.command === 'list_catalog') return catalogResult([]); + if (cmd === 'restore_sql_file') { + onProgress?.({ + payload: { current: 1, total: 2, objectName: 'CREATE TABLE users', phase: 'object' }, + }); + onProgress?.({ + payload: { current: 2, total: 2, objectName: 'INSERT INTO users', phase: 'object' }, + }); + return true; + } + return null; + }, + ); await selectRestoreTarget(); fireEvent.click(screen.getByTestId('backup-start-restore')); @@ -350,15 +370,18 @@ describe('BackupWindow backup flow (F3-BUG-002 coverage)', () => { } function setupInvoke(backupDatabaseImpl: () => Promise) { - invokeMock.mockImplementation(async (cmd: string) => { - if (cmd === 'get_connections') return pgOnlyConnections(); - if (cmd === 'get_groups') return [PRESET_GROUPS.development]; - if (cmd === 'connect') return 'live-1'; - if (cmd === 'get_connection_info') return { serverVersion: '16' }; - if (cmd === 'get_databases') return ['app', 'postgres']; - if (cmd === 'backup_database') return backupDatabaseImpl(); - return null; - }); + invokeMock.mockImplementation( + async (cmd: string, args?: { request?: { command?: string } }) => { + if (cmd === 'get_connections') return pgOnlyConnections(); + if (cmd === 'get_groups') return [PRESET_GROUPS.development]; + if (cmd === 'connect') return 'live-1'; + if (cmd === 'get_connection_info') return { serverVersion: '16' }; + if (args?.request?.command === 'list_databases') + return { data: { databases: ['app', 'postgres'] } }; + if (cmd === 'backup_database') return backupDatabaseImpl(); + return null; + }, + ); } async function selectBackupTarget() { @@ -500,16 +523,20 @@ describe('BackupWindow backup flow (F3-BUG-002 coverage)', () => { it('restore backend error surfaces in status and progress log', async () => { urlParamMock.mockImplementation((name: string) => (name === 'mode' ? 'restore' : null)); - invokeMock.mockImplementation(async (cmd: string) => { - if (cmd === 'get_connections') return pgOnlyConnections(); - if (cmd === 'get_groups') return [PRESET_GROUPS.development]; - if (cmd === 'connect') return 'live-1'; - if (cmd === 'get_connection_info') return { serverVersion: '16' }; - if (cmd === 'get_databases') return ['app', 'postgres']; - if (cmd === 'get_tables') return [{ name: 'users', tableType: 'table' }]; - if (cmd === 'restore_sql_file') throw new Error('restore boom'); - return null; - }); + invokeMock.mockImplementation( + async (cmd: string, args?: { request?: { command?: string } }) => { + if (cmd === 'get_connections') return pgOnlyConnections(); + if (cmd === 'get_groups') return [PRESET_GROUPS.development]; + if (cmd === 'connect') return 'live-1'; + if (cmd === 'get_connection_info') return { serverVersion: '16' }; + if (args?.request?.command === 'list_databases') + return { data: { databases: ['app', 'postgres'] } }; + if (args?.request?.command === 'list_catalog') + return catalogResult([{ name: 'users', tableType: 'table' }]); + if (cmd === 'restore_sql_file') throw new Error('restore boom'); + return null; + }, + ); render(); await waitFor(() => screen.getByText('Local PG')); diff --git a/src/windows/connection/ContentView.tsx b/src/windows/connection/ContentView.tsx index df370140a..356ac241e 100644 --- a/src/windows/connection/ContentView.tsx +++ b/src/windows/connection/ContentView.tsx @@ -9,7 +9,7 @@ import { } from 'react'; import { useI18n } from '../../hooks/useI18n'; import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'; -import { useSchemaStore } from '../../stores/schemaStore'; +import { useSchemaStore, useConnectionSchemaField } from '../../stores/schemaStore'; import { useTableDataStore } from '../../stores/tableDataStore'; import { useSettingsStore } from '../../stores/settingsStore'; import { useConnectionStore } from '../../stores/connectionStore'; @@ -94,9 +94,12 @@ export function ContentView({ const activePanel = allPanels.find((p) => p.id === activePanelId) ?? null; const savedConnections = useConnectionStore((s) => s.connections); - const currentDatabase = useSchemaStore((s) => s.currentDatabase); - const schemaTables = useSchemaStore((s) => s.tables); - const schemaViews = useSchemaStore((s) => s.views); + const currentDatabase = useConnectionSchemaField( + activePanel?.dbSessionId ?? '', + 'currentDatabase', + ); + const schemaTables = useConnectionSchemaField(activePanel?.dbSessionId ?? '', 'tables'); + const schemaViews = useConnectionSchemaField(activePanel?.dbSessionId ?? '', 'views'); const loadForConnection = useSchemaStore((s) => s.loadForConnection); const isTablePanel = activePanel?.type === 'table' || activePanel?.type === 'view'; diff --git a/src/windows/connection/DocumentConnectionView.tsx b/src/windows/connection/DocumentConnectionView.tsx index 096207ef7..d55423b92 100644 --- a/src/windows/connection/DocumentConnectionView.tsx +++ b/src/windows/connection/DocumentConnectionView.tsx @@ -5,7 +5,7 @@ import { Button } from '../../components/ui/Button'; import { CopyableError } from '../../components/ui/CopyableError'; import { Input } from '../../components/ui/Input'; import { SqlEditor } from '../../components/SqlEditor'; -import { useSchemaStore } from '../../stores/schemaStore'; +import { useSchemaStore, useConnectionSchemaField } from '../../stores/schemaStore'; import { usePanelStore, nextPanelId } from '../../stores/panelStore'; import { useQueryExec } from '../../hooks/useQueryExec'; import { useColumnResize } from '../../hooks/useColumnResize'; @@ -40,8 +40,8 @@ export function DocumentConnectionView({ initialDatabase, }: ConnectionViewProps) { const { t } = useI18n(); - const databases = useSchemaStore((s) => s.databases); - const loading = useSchemaStore((s) => s.loading); + const databases = useConnectionSchemaField(dbSessionId, 'databases'); + const loading = useConnectionSchemaField(dbSessionId, 'loading'); const loadForConnection = useSchemaStore((s) => s.loadForConnection); const [activeTab, setActiveTab] = useState('documents'); @@ -76,7 +76,7 @@ export function DocumentConnectionView({ async (db: string) => { setCollectionsLoading(true); try { - const tables = await databaseCommands.getTables(dbSessionId, db); + const tables = await databaseCommands.listTables(dbSessionId, db); setCollections(tables); } catch (e) { console.error('load collections failed:', e); diff --git a/src/windows/connection/ExportDialog.tsx b/src/windows/connection/ExportDialog.tsx index 00a3909b1..e3f1006f3 100644 --- a/src/windows/connection/ExportDialog.tsx +++ b/src/windows/connection/ExportDialog.tsx @@ -57,7 +57,9 @@ export function ExportDialog({ dataExportCapability = 'full_table', }: ExportDialogProps) { const { t } = useI18n(); - const tableSchema = useSchemaStore((s) => s.schemaOfRelation(tableName, dbSessionId)); + const tableSchema = useSchemaStore((s) => + dbSessionId ? s.schemaOfRelation(tableName, dbSessionId) : null, + ); const exportLocked = dataExportCapability === 'none'; const allowEntire = !exportLocked && supportsFullTableExport(dataExportCapability) && Boolean(dbSessionId); diff --git a/src/windows/connection/PanelContentRenderer.tsx b/src/windows/connection/PanelContentRenderer.tsx index 8fae6a176..398c2aa1b 100644 --- a/src/windows/connection/PanelContentRenderer.tsx +++ b/src/windows/connection/PanelContentRenderer.tsx @@ -8,7 +8,7 @@ import { resolveExportScope } from '../../lib/exportCapability'; import { resolveCreateTableSchema } from '../../lib/structureEditor/resolveCreateTableSchema'; import { invalidateSchemaCache } from '../../lib/schemaCache'; import { getConnectionView } from '../../lib/connectionViews'; -import { useSchemaStore } from '../../stores/schemaStore'; +import { useConnectionSchemaField } from '../../stores/schemaStore'; import { onPanelClosed } from '../../stores/panelStore'; import { type Panel, @@ -479,7 +479,7 @@ function CreateTablePanelContent({ onCancel, }: CreateTablePanelContentProps) { const { t } = useI18n(); - const isMultiDb = useSchemaStore((s) => s.isMultiDatabase); + const isMultiDb = useConnectionSchemaField(dbSessionId, 'isMultiDatabase'); // F1: no use_database IPC gate — the editor's queries pin `database` // explicitly and the backend switches the session lazily. if (isMultiDb && !database) { diff --git a/src/windows/connection/QueryPanel.tsx b/src/windows/connection/QueryPanel.tsx index 1bd350c89..e31410ea5 100644 --- a/src/windows/connection/QueryPanel.tsx +++ b/src/windows/connection/QueryPanel.tsx @@ -11,7 +11,11 @@ import { import { paneArgs, paneKey, resolveFocusedPaneId, usePanelStore } from '../../stores/panelStore'; import { useActiveConnectionStore } from '../../stores/activeConnectionStore'; import { useQueryExec } from '../../hooks/useQueryExec'; -import { useSchemaStore } from '../../stores/schemaStore'; +import { + useSchemaStore, + useConnectionSchemaField, + useConnectionColumnMaps, +} from '../../stores/schemaStore'; import { useSettingsStore } from '../../stores/settingsStore'; import { useI18n } from '../../hooks/useI18n'; import { useResizable } from '../../hooks/useResizable'; @@ -174,19 +178,19 @@ export function QueryPanel({ getStartSize: () => editorViewportRef.current?.offsetHeight, }); - const tables = useSchemaStore((s) => s.tables); - const views = useSchemaStore((s) => s.views); - const columnMap = useSchemaStore((s) => s.columnMap); - const namespaceTree = useSchemaStore((s) => s.namespaceTree); - const pathAliases = useSchemaStore((s) => s.pathAliases); - const databases = useSchemaStore((s) => s.databases); - const currentDatabase = useSchemaStore((s) => s.currentDatabase); - const currentSchema = useSchemaStore((s) => s.currentSchema); - const isMultiDb = useSchemaStore((s) => s.isMultiDatabase); + const tables = useConnectionSchemaField(dbSessionId, 'tables'); + const views = useConnectionSchemaField(dbSessionId, 'views'); + const { columnMap } = useConnectionColumnMaps(dbSessionId); + const namespaceTree = useConnectionSchemaField(dbSessionId, 'namespaceTree'); + const pathAliases = useConnectionSchemaField(dbSessionId, 'pathAliases'); + const databases = useConnectionSchemaField(dbSessionId, 'databases'); + const currentDatabase = useConnectionSchemaField(dbSessionId, 'currentDatabase'); + const currentSchema = useConnectionSchemaField(dbSessionId, 'currentSchema'); + const isMultiDb = useConnectionSchemaField(dbSessionId, 'isMultiDatabase'); const ensureColumns = useSchemaStore((s) => s.ensureColumns); - const loadColumnMap = useSchemaStore((s) => s.loadColumnMap); - const namespaceLoading = useSchemaStore((s) => s.ensuringCount > 0); - const schemaEpoch = useSchemaStore((s) => s.schemaEpoch); + const ensureDatabaseColumns = useSchemaStore((s) => s.ensureDatabaseColumns); + const namespaceLoading = useConnectionSchemaField(dbSessionId, 'ensuringCount') > 0; + const schemaEpoch = useConnectionSchemaField(dbSessionId, 'schemaEpoch'); const metadataSnapshot = useMetadataSnapshot(dbSessionId); @@ -472,10 +476,7 @@ export function QueryPanel({ // Eagerly load column metadata so SQL autocomplete can suggest columns // even without a FROM clause (e.g. typing "SELECT na" shows matching columns). - // - // Phase 1: For multi-DB / path-hierarchy drivers, loadForConnection skips - // loadTables, so `tables` stays empty. Trigger loadTables for the current - // database so that namespaceTree gets table entries and columnMap can be built. + // Load an initially skipped catalog before deriving editor columns. const loadTablesFn = useSchemaStore((s) => s.loadTables); useEffect(() => { if (!dbSessionId || !isMultiDb || !currentDatabase) return; @@ -487,30 +488,28 @@ export function QueryPanel({ // Trigger on schemaEpoch (bumped by loadTables) and namespaceTree structural // changes (bumped via namespaceFingerprint). Also depends on tables.length so // it re-fires after Phase 1 populates tables. - const namespaceFingerprint = useSchemaStore((s) => { - const tree = s.namespaceTree; - if (Array.isArray(tree)) return String(tree.length); - return Object.keys(tree).sort().join(','); - }); + const namespaceFingerprint = Array.isArray(namespaceTree) + ? String(namespaceTree.length) + : Object.keys(namespaceTree).sort().join(','); useEffect(() => { if (!dbSessionId || !selectedDatabase) return; - void loadColumnMap(dbSessionId, selectedDatabase); + void ensureDatabaseColumns(dbSessionId, selectedDatabase); }, [ dbSessionId, - loadColumnMap, + ensureDatabaseColumns, selectedDatabase, schemaEpoch, namespaceFingerprint, tables.length, ]); - // Keep the editor metadata cache pinned to the tab's database. Only - // switch when the bound database actually changes — switchContext drops - // all loaded relations, so calling it per keystroke would thrash the cache. + // Reload metadata only when the tab's database/schema context changes. useEffect(() => { if (!dbSessionId || !selectedDatabase) return; const snapshot = metadataCache.getSnapshot(dbSessionId); - if (snapshot.database !== selectedDatabase) { + const contextChanged = + snapshot.database !== selectedDatabase || snapshot.schema !== (selectedSchema ?? undefined); + if (contextChanged) { metadataCache.switchContext(dbSessionId, { database: selectedDatabase, schema: selectedSchema ?? undefined, diff --git a/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx b/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx index 33a113934..59919f98c 100644 --- a/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx +++ b/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx @@ -7,6 +7,7 @@ import { type ConnectionNavigatorTreeProps, } from '../ConnectionNavigatorTree'; import { useSchemaStore } from '../../../stores/schemaStore'; +import { createEmptyConnectionSchema } from '../../../stores/schemaStoreState'; import type { ConnectionConfig, TableInfo } from '../../../types'; import { showWebContextMenu } from '../../../stores/contextMenuStore'; import { getUnifiedRowKey } from '../navigator/utils'; @@ -210,7 +211,7 @@ const mockExecuteQuery = vi.fn(); vi.mock('../../../commands/database', () => ({ databaseCommands: { getDatabases: (...args: unknown[]) => mockGetDatabases(...args), - getTables: (...args: unknown[]) => mockGetTables(...args), + listTables: (...args: unknown[]) => mockGetTables(...args), useDatabase: (...args: unknown[]) => mockUseDatabase(...args), getDatabaseObjects: (...args: unknown[]) => mockGetDatabaseObjects(...args), }, @@ -362,7 +363,7 @@ async function activateDatabaseContext( ) { fireEvent.click((await findByText(tableName)).closest('button')!); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe(dbName); + expect(activeSchema()?.currentDatabase).toBe(dbName); }); } @@ -488,40 +489,15 @@ type SessionSchemaPatch = { schemaEpoch?: number; }; -const EMPTY_SESSION_SCHEMA = { - currentDatabase: null, - currentSchema: null, - databases: [], - databaseType: null, - isMultiDatabase: false, - tables: [], - views: [], - schemaNames: [], - columnMap: {}, - typedColumnMap: {}, - namespaceTree: {}, - loadedPaths: new Set(), - pathItems: {}, - pathAliases: {}, - namespaceOwnedByPlugin: false, - schemaEpoch: 0, - expanded: new Set(), - selectedId: null, - loading: false, - ensuringCount: 0, - error: null, - columnInflight: new Set(), -}; +function activeSchema() { + const state = useSchemaStore.getState(); + return state.activeDbSessionId ? state.schemas.get(state.activeDbSessionId) : undefined; +} /** Patch (or create) one session's schema-cache entry in the real store. */ function seedSessionSchema(dbSessionId: string, patch: SessionSchemaPatch): void { useSchemaStore.setState((state) => { - const base = state.schemas.get(dbSessionId) ?? { - ...EMPTY_SESSION_SCHEMA, - loadedPaths: new Set(), - expanded: new Set(), - columnInflight: new Set(), - }; + const base = state.schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); const next = new Map(state.schemas); next.set(dbSessionId, { ...base, ...patch }); return { schemas: next }; @@ -704,14 +680,14 @@ describe('ConnectionNavigatorTree multi-db table selection', () => { await ensureDbTableVisible(findByText, queryAllByText, 'db_b', 'orders'); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe('db_b'); + expect(activeSchema()?.currentDatabase).toBe('db_b'); }); fireEvent.click((await findByText('users')).closest('button')!); await waitFor(() => { // F1: no use_database IPC — activation only moves the local context. - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); + expect(activeSchema()?.currentDatabase).toBe('db_a'); }); expect(onSelectTable).toHaveBeenCalledWith('users', null, 'db_a'); }); @@ -722,19 +698,19 @@ describe('ConnectionNavigatorTree multi-db table selection', () => { await ensureDbTableVisible(findByText, queryAllByText, 'db_a', 'users'); await ensureDbTableVisible(findByText, queryAllByText, 'db_b', 'orders'); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe('db_b'); + expect(activeSchema()?.currentDatabase).toBe('db_b'); }); mockGetTables.mockClear(); // Collapse db_a, then re-expand it: the tables are already cached, so no - // getTables call fires — yet the pointer must follow the click. The + // listTables call fires — yet the pointer must follow the click. The // original bug pinned only as a side effect of the fetch, so cache hits // left currentDatabase behind and "查看 ER" opened a stale database. fireEvent.click((await findByText('db_a')).closest('button')!); fireEvent.click((await findByText('db_a')).closest('button')!); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); + expect(activeSchema()?.currentDatabase).toBe('db_a'); }); await findByText('users'); expect(mockGetTables).not.toHaveBeenCalledWith('conn-1', 'db_a'); @@ -958,13 +934,13 @@ describe('ConnectionNavigatorTree drop database', () => { await ensureDbTableVisible(findByText, queryAllByText, 'db_a', 'users'); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); + expect(activeSchema()?.currentDatabase).toBe('db_a'); }); await triggerDropDatabase(findByText, 'db_a'); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe('postgres'); + expect(activeSchema()?.currentDatabase).toBe('postgres'); expect(mockDriverExecute).toHaveBeenCalledWith({ dbSessionId: 'conn-1', command: 'drop_database', @@ -983,7 +959,7 @@ describe('ConnectionNavigatorTree drop database', () => { await ensureDbTableVisible(findByText, queryAllByText, 'db_a', 'users'); await ensureDbTableVisible(findByText, queryAllByText, 'db_b', 'orders'); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe('db_b'); + expect(activeSchema()?.currentDatabase).toBe('db_b'); }); mockGetTables.mockClear(); @@ -1021,7 +997,7 @@ describe('ConnectionNavigatorTree drop database', () => { await ensureDbTableVisible(findByText, queryAllByText, 'db_a', 'users'); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); + expect(activeSchema()?.currentDatabase).toBe('db_a'); }); await triggerDropDatabase(findByText, 'db_a'); @@ -1064,7 +1040,7 @@ describe('ConnectionNavigatorTree close database connection', () => { await ensureDbTableVisible(findByText, queryAllByText, 'db_a', 'users'); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); + expect(activeSchema()?.currentDatabase).toBe('db_a'); }); await triggerCloseDatabase(findByText, 'db_a'); @@ -1105,12 +1081,12 @@ describe('ConnectionNavigatorTree context menu new query', () => { await ensureDbTableVisible(findByText, queryAllByText, 'db_b', 'orders'); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe('db_b'); + expect(activeSchema()?.currentDatabase).toBe('db_b'); }); await triggerContextMenuAction((await findByText('db_a')).closest('button')!, 'new-query'); - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); + expect(activeSchema()?.currentDatabase).toBe('db_a'); expect(newQuery).toHaveBeenCalled(); }); @@ -1150,15 +1126,15 @@ describe('ConnectionNavigatorTree context menu new query', () => { await waitFor(() => expect(mockGetTables).toHaveBeenCalledWith('conn-1', 'db_a')); fireEvent.click((await findByText('public')).closest('button')!); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); + expect(activeSchema()?.currentDatabase).toBe('db_a'); }); // Set a different active database to verify the fix - useSchemaStore.setState({ currentDatabase: 'db_b' }); + seedSessionSchema('conn-1', { currentDatabase: 'db_b' }); await triggerContextMenuAction((await findByText('public')).closest('button')!, 'new-query'); - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); + expect(activeSchema()?.currentDatabase).toBe('db_a'); expect(newQuery).toHaveBeenCalled(); connectionsState.connections = [MYSQL_CONN]; @@ -2527,7 +2503,7 @@ describe('ConnectionNavigatorTree multi-db tree variants', () => { await waitFor(() => { expect(useSchemaStore.getState().schemas.get('conn-1')?.currentDatabase).toBe('postgres'); }); - expect(useSchemaStore.getState().currentDatabase).toBe('postgres'); + expect(activeSchema()?.currentDatabase).toBe('postgres'); expect(onShowMessage).not.toHaveBeenCalled(); }); @@ -2563,7 +2539,7 @@ describe('ConnectionNavigatorTree multi-db tree variants', () => { command: 'drop_database', input: { name: 'first' }, }); - expect(useSchemaStore.getState().currentDatabase).toBe('second'); + expect(activeSchema()?.currentDatabase).toBe('second'); }); }); @@ -2638,7 +2614,7 @@ describe('ConnectionNavigatorTree multi-db tree variants', () => { const dbButton = (await findByText('db_a')).closest('button')!; await openMenuAndPick(dbButton, 'create-schema'); expect(openCreateSchema).toHaveBeenCalled(); - expect(useSchemaStore.getState().currentDatabase).toBe('db_a'); + expect(activeSchema()?.currentDatabase).toBe('db_a'); }); }); @@ -2744,7 +2720,7 @@ describe('ConnectionNavigatorTree schema context menu', () => { await ensureDbTableVisible(findByText, queryAllByText, 'db_a', 'users'); await ensureDbTableVisible(findByText, queryAllByText, 'db_b', 'orders'); await waitFor(() => { - expect(useSchemaStore.getState().currentDatabase).toBe('db_b'); + expect(activeSchema()?.currentDatabase).toBe('db_b'); }); await activateDatabaseContext(findByText, 'db_a', 'users'); mockExecuteQuery.mockClear(); @@ -2792,7 +2768,7 @@ describe('ConnectionNavigatorTree schema context menu', () => { currentDatabase: 'db_a', databases: ['db_a', 'db_b'], }); - useSchemaStore.setState({ currentDatabase: 'db_a' }); + seedSessionSchema('conn-pg', { currentDatabase: 'db_a' }); fireEvent.click((await findByText('db_b')).closest('button')!); await waitFor(() => { @@ -3218,7 +3194,7 @@ describe('ConnectionNavigatorTree path-hierarchy namespace trees', () => { await waitFor(() => { expect(container.querySelector('[data-item-name="users"]')).not.toBeNull(); }); - expect(useSchemaStore.getState().currentDatabase).toBe('db_x'); + expect(activeSchema()?.currentDatabase).toBe('db_x'); const leafButton = container.querySelector('[data-item-name="users"]')!.closest('button')!; await triggerContextMenuAction(leafButton, 'new-query'); @@ -3227,7 +3203,7 @@ describe('ConnectionNavigatorTree path-hierarchy namespace trees', () => { // The leaf's `database` is the namespace fetch path (`public`), not a real // database: the store pointer must stay on db_x so ensureNamespacePath // keeps prefixing fetches with the right database root. - expect(useSchemaStore.getState().currentDatabase).toBe('db_x'); + expect(activeSchema()?.currentDatabase).toBe('db_x'); }); it('search prunes unmatched namespaces and force-expands matches', async () => { diff --git a/src/windows/connection/__tests__/QueryPanel.dangerousSql.test.tsx b/src/windows/connection/__tests__/QueryPanel.dangerousSql.test.tsx index 7ffde7cc7..241fbf9bb 100644 --- a/src/windows/connection/__tests__/QueryPanel.dangerousSql.test.tsx +++ b/src/windows/connection/__tests__/QueryPanel.dangerousSql.test.tsx @@ -57,7 +57,7 @@ const schemaStoreState = vi.hoisted(() => ({ isMultiDatabase: false, ensuringCount: 0, ensureColumns: vi.fn(), - loadColumnMap: vi.fn(), + ensureDatabaseColumns: vi.fn(), loadTables: vi.fn(), ensureNamespacePath: vi.fn(), })); @@ -101,6 +101,12 @@ vi.mock('../../../stores/connectionStore', () => ({ })); vi.mock('../../../stores/schemaStore', () => ({ + useConnectionColumnMaps: (_session: string) => ({ + columnMap: schemaStoreState.columnMap, + typedColumnMap: {}, + }), + useConnectionSchemaField: (_session: string, field: keyof typeof schemaStoreState) => + schemaStoreState[field], useSchemaStore: Object.assign( (sel: (s: typeof schemaStoreState) => unknown) => sel(schemaStoreState), { getState: () => schemaStoreState }, diff --git a/src/windows/connection/__tests__/QueryPanel.executeCancel.test.tsx b/src/windows/connection/__tests__/QueryPanel.executeCancel.test.tsx index 0cc7350d5..371e62aa5 100644 --- a/src/windows/connection/__tests__/QueryPanel.executeCancel.test.tsx +++ b/src/windows/connection/__tests__/QueryPanel.executeCancel.test.tsx @@ -66,7 +66,7 @@ const schemaStoreState = vi.hoisted(() => ({ isMultiDatabase: false, ensuringCount: 0, ensureColumns: vi.fn(), - loadColumnMap: vi.fn(), + ensureDatabaseColumns: vi.fn(), loadTables: vi.fn(), ensureNamespacePath: vi.fn(), })); @@ -92,6 +92,12 @@ vi.mock('../../../stores/activeConnectionStore', () => ({ })); vi.mock('../../../stores/schemaStore', () => ({ + useConnectionColumnMaps: (_session: string) => ({ + columnMap: schemaStoreState.columnMap, + typedColumnMap: {}, + }), + useConnectionSchemaField: (_session: string, field: keyof typeof schemaStoreState) => + schemaStoreState[field], useSchemaStore: Object.assign( (sel: (s: typeof schemaStoreState) => unknown) => sel(schemaStoreState), { getState: () => schemaStoreState }, diff --git a/src/windows/connection/__tests__/QueryPanel.paneRouting.test.tsx b/src/windows/connection/__tests__/QueryPanel.paneRouting.test.tsx index a10a4a901..a5645d5de 100644 --- a/src/windows/connection/__tests__/QueryPanel.paneRouting.test.tsx +++ b/src/windows/connection/__tests__/QueryPanel.paneRouting.test.tsx @@ -79,7 +79,7 @@ const schemaStoreState = vi.hoisted(() => ({ isMultiDatabase: false, ensuringCount: 0, ensureColumns: vi.fn(), - loadColumnMap: vi.fn(), + ensureDatabaseColumns: vi.fn(), loadTables: vi.fn(), ensureNamespacePath: vi.fn(), })); @@ -104,6 +104,12 @@ vi.mock('../../../stores/activeConnectionStore', () => ({ })); vi.mock('../../../stores/schemaStore', () => ({ + useConnectionColumnMaps: (_session: string) => ({ + columnMap: schemaStoreState.columnMap, + typedColumnMap: {}, + }), + useConnectionSchemaField: (_session: string, field: keyof typeof schemaStoreState) => + schemaStoreState[field], useSchemaStore: Object.assign( (sel: (s: typeof schemaStoreState) => unknown) => sel(schemaStoreState), { getState: () => schemaStoreState }, diff --git a/src/windows/connection/__tests__/QueryPanel.resultMessageDialog.test.tsx b/src/windows/connection/__tests__/QueryPanel.resultMessageDialog.test.tsx index b66cd3ffa..4391cd088 100644 --- a/src/windows/connection/__tests__/QueryPanel.resultMessageDialog.test.tsx +++ b/src/windows/connection/__tests__/QueryPanel.resultMessageDialog.test.tsx @@ -57,7 +57,7 @@ const schemaStoreState = vi.hoisted(() => ({ isMultiDatabase: false, ensuringCount: 0, ensureColumns: vi.fn(), - loadColumnMap: vi.fn(), + ensureDatabaseColumns: vi.fn(), loadTables: vi.fn(), ensureNamespacePath: vi.fn(), })); @@ -82,6 +82,12 @@ vi.mock('../../../stores/activeConnectionStore', () => ({ })); vi.mock('../../../stores/schemaStore', () => ({ + useConnectionColumnMaps: (_session: string) => ({ + columnMap: schemaStoreState.columnMap, + typedColumnMap: {}, + }), + useConnectionSchemaField: (_session: string, field: keyof typeof schemaStoreState) => + schemaStoreState[field], useSchemaStore: Object.assign( (sel: (s: typeof schemaStoreState) => unknown) => sel(schemaStoreState), { getState: () => schemaStoreState }, diff --git a/src/windows/connection/__tests__/QueryPanelHistory.test.tsx b/src/windows/connection/__tests__/QueryPanelHistory.test.tsx index 1714ccf26..7ed92bbc9 100644 --- a/src/windows/connection/__tests__/QueryPanelHistory.test.tsx +++ b/src/windows/connection/__tests__/QueryPanelHistory.test.tsx @@ -44,12 +44,18 @@ const schemaSnapshot = { isMultiDatabase: true, ensuringCount: 0, ensureColumns: vi.fn(), - loadColumnMap: vi.fn(), + ensureDatabaseColumns: vi.fn(), loadTables: vi.fn(), ensureNamespacePath: vi.fn(), }; vi.mock('../../../stores/schemaStore', () => ({ + useConnectionColumnMaps: (_session: string) => ({ + columnMap: schemaSnapshot.columnMap, + typedColumnMap: {}, + }), + useConnectionSchemaField: (_session: string, field: keyof typeof schemaSnapshot) => + schemaSnapshot[field], useSchemaStore: (sel: (s: typeof schemaSnapshot) => unknown) => sel(schemaSnapshot), })); diff --git a/src/windows/connection/__tests__/TableStructureEditor.test.tsx b/src/windows/connection/__tests__/TableStructureEditor.test.tsx index 9ad7348dc..609815330 100644 --- a/src/windows/connection/__tests__/TableStructureEditor.test.tsx +++ b/src/windows/connection/__tests__/TableStructureEditor.test.tsx @@ -30,9 +30,15 @@ const { mockExportTableStructureToFile: vi.fn().mockResolvedValue('ok'), })); -vi.mock('../../../commands/database', () => ({ - databaseCommands: { - getTableSchema: (...args: unknown[]) => mockGetTableSchema(...args), +vi.mock('@datazen/driver-sdk', async (importOriginal) => ({ + ...(await importOriginal()), + schemaClient: { + readSchema: async (session: string, ref: import('@datazen/driver-sdk').RelationRef) => ({ + value: { + ref, + definition: await mockGetTableSchema(session, ref.name, ref.database, ref.schema), + }, + }), }, })); diff --git a/src/windows/connection/__tests__/navigatorTreeRowAriaEquivalence.test.ts b/src/windows/connection/__tests__/navigatorTreeRowAriaEquivalence.test.ts index aabf2c667..09f36dc26 100644 --- a/src/windows/connection/__tests__/navigatorTreeRowAriaEquivalence.test.ts +++ b/src/windows/connection/__tests__/navigatorTreeRowAriaEquivalence.test.ts @@ -436,8 +436,8 @@ function schemaState(): ConnectionSchemaState { tables: [{ name: 'users', schema: 'public', tableType: 'table' } as TableInfo], views: [], schemaNames: ['public'], - columnMap: {}, - typedColumnMap: {}, + tableCatalogs: {}, + relationColumns: {}, namespaceTree: {}, loadedPaths: new Set(), pathItems: {}, diff --git a/src/windows/connection/__tests__/query.modules.test.tsx b/src/windows/connection/__tests__/query.modules.test.tsx index fa869854b..17743a05e 100644 --- a/src/windows/connection/__tests__/query.modules.test.tsx +++ b/src/windows/connection/__tests__/query.modules.test.tsx @@ -31,6 +31,31 @@ import { EMPTY_QUERY_EXEC } from '../../../stores/queryExecActions'; import { extensionRegistry, sqlEditorEnhancedEP } from '@datazen/extension-points'; import { toQueryExecutionViewModel } from '../../../lib/queryExecutionViewModel'; +const refreshSchemaMetadata = vi.hoisted(() => vi.fn().mockResolvedValue({ revision: 1 })); +vi.mock('@datazen/driver-sdk', async (importOriginal) => ({ + ...(await importOriginal()), + schemaClient: { + refresh: refreshSchemaMetadata, + readSchema: async (session: string, ref: import('@datazen/driver-sdk').RelationRef) => ({ + value: { ref, definition: await getTableSchema(session, ref.name, ref.database, ref.schema) }, + }), + readColumns: async (session: string, refs: import('@datazen/driver-sdk').RelationRef[]) => ({ + results: await Promise.all( + refs.map(async (ref) => ({ + status: 'ok', + value: { + ref, + columns: ( + (await getColumns(session, ref.name, ref.database, ref.schema)) as string[] + ).map((name) => ({ name, dataType: '', nullable: true })), + primaryKeys: [], + }, + })), + ), + }), + }, +})); + vi.mock('../../../hooks/useI18n', () => ({ useI18n: () => ({ t: (key: string) => key }), })); @@ -384,7 +409,7 @@ describe('[tester] query/queryDropHandler', () => { }); await handler({ tables: [{ tableName: 'users', schema: 'public' }] }, 10); - expect(getTableSchema).toHaveBeenCalledWith('sess-1', 'public.users', 'app', 'public'); + expect(getTableSchema).toHaveBeenCalledWith('sess-1', 'users', 'app', 'public'); expect(insertAt).toHaveBeenCalled(); }); @@ -1056,6 +1081,21 @@ describe('[tester] query/QueryEditorSection', () => { expect(screen.getByText('query.historyRows')).toBeInTheDocument(); }); + it('keeps refresh failures visible and does not report success', async () => { + refreshSchemaMetadata.mockRejectedValueOnce(new Error('metadata refresh failed')); + const onCompletionRefreshed = vi.fn(); + renderSection({ onCompletionRefreshed }); + fireEvent.click(screen.getByTestId('editor-refresh-completion-button')); + await waitFor(() => + expect(screen.getByTestId('editor-refresh-completion-button')).toHaveAttribute( + 'title', + 'metadata refresh failed', + ), + ); + expect(onCompletionRefreshed).not.toHaveBeenCalled(); + expect(schemaStoreState.loadTables).not.toHaveBeenCalled(); + }); + it('guards refresh completion against double clicks', async () => { let resolveLoad: (() => void) | undefined; schemaStoreState.loadTables.mockImplementation( @@ -1070,7 +1110,8 @@ describe('[tester] query/QueryEditorSection', () => { fireEvent.click(screen.getByTestId('editor-refresh-completion-button')); fireEvent.click(screen.getByTestId('editor-refresh-completion-button')); - expect(schemaStoreState.loadTables).toHaveBeenCalledTimes(1); + await waitFor(() => expect(schemaStoreState.loadTables).toHaveBeenCalledTimes(1)); + expect(refreshSchemaMetadata).toHaveBeenCalledWith('sess-1', { kind: 'session' }); expect(onCompletionRefreshed).not.toHaveBeenCalled(); await act(async () => { diff --git a/src/windows/connection/navigator/useNavigatorContextMenus.ts b/src/windows/connection/navigator/useNavigatorContextMenus.ts index ab0344e7b..f06f41f40 100644 --- a/src/windows/connection/navigator/useNavigatorContextMenus.ts +++ b/src/windows/connection/navigator/useNavigatorContextMenus.ts @@ -467,7 +467,7 @@ export function useNavigatorContextMenus(deps: NavigatorContextMenuDeps) { : undefined, onNewQuery: () => { onSelectConnection(connectionId); - useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); + if (dbSessionId) useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); viewActions?.newQuery?.(undefined, { database: dbName, schema: null }); }, onQueryHistory: () => { @@ -483,7 +483,7 @@ export function useNavigatorContextMenus(deps: NavigatorContextMenuDeps) { // explicitly so a reused ER tab re-binds even if this closure // holds props from a previous render (contextmenu fires without // a preceding click, so no fresh render is guaranteed). - useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); + if (dbSessionId) useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); viewActions?.openErDiagram?.(undefined, dbName); }, onExecuteSqlFile: @@ -502,7 +502,8 @@ export function useNavigatorContextMenus(deps: NavigatorContextMenuDeps) { onCreateSchema: dbMeta?.supportsCreateSchema ? () => { onSelectConnection(connectionId); - useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); + if (dbSessionId) + useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); viewActions?.openCreateSchema?.(); } : undefined, @@ -562,25 +563,7 @@ export function useNavigatorContextMenus(deps: NavigatorContextMenuDeps) { .getState() .setLoadedTables(fallback, cached, dbSessionId); } else { - useSchemaStore.setState((state) => { - const schemaEntry = state.schemas.get(dbSessionId); - let schemas = state.schemas; - if (schemaEntry) { - schemas = new Map(state.schemas); - schemas.set(dbSessionId, { - ...schemaEntry, - currentDatabase: fallback, - }); - } - return { - ...state, - schemas, - currentDatabase: - state.currentDatabase === dbName - ? fallback - : state.currentDatabase, - }; - }); + useSchemaStore.getState().setCurrentDatabase(fallback, dbSessionId); } } clearDbLocalCache(connectionId, dbSessionId, dbName); @@ -659,7 +642,7 @@ export function useNavigatorContextMenus(deps: NavigatorContextMenuDeps) { }, onNewQuery: () => { onSelectConnection(connectionId); - useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); + if (dbSessionId) useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); viewActions?.newQuery?.(undefined, { database: dbName, schema: null }); }, onQueryHistory: () => { @@ -685,7 +668,7 @@ export function useNavigatorContextMenus(deps: NavigatorContextMenuDeps) { onViewErDiagram: () => { onSelectConnection(connectionId); // Same contract as the database menu: pin + explicit target. - useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); + if (dbSessionId) useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); viewActions?.openErDiagram?.(undefined, dbName); }, onDropSchema: @@ -786,17 +769,15 @@ export function useNavigatorContextMenus(deps: NavigatorContextMenuDeps) { const handleGenerateTableSql = async (type: GeneratedSqlType) => { onSelectConnection(connectionId); - useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); + if (dbSessionId) useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); await activateDatabase(dbSessionId, dbName); const dbType = conn?.databaseType ?? 'postgresql'; - const schemaState = useSchemaStore.getState().schemas.get(dbSessionId); const tableSchema = await fetchTableSchemaForSqlGeneration({ dbSessionId, tableName: name, schema, database: dbName, databaseType: dbType, - columnMap: schemaState?.columnMap, }); const tableRef = schema ? `${schema}.${name}` : name; const sql = generateTableSqlWithFallbacks(tableSchema, type, dbType, { @@ -809,7 +790,7 @@ export function useNavigatorContextMenus(deps: NavigatorContextMenuDeps) { const handleGenerateDdl = async () => { onSelectConnection(connectionId); - useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); + if (dbSessionId) useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); try { let ddl = ''; try { @@ -900,7 +881,7 @@ export function useNavigatorContextMenus(deps: NavigatorContextMenuDeps) { : undefined, onNewQuery: () => { onSelectConnection(connectionId); - useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); + if (dbSessionId) useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); if (kind === 'table') { const query = buildQueryOpenContext( { diff --git a/src/windows/connection/navigator/useNavigatorDbState.ts b/src/windows/connection/navigator/useNavigatorDbState.ts index fd207f2ee..0907e3efa 100644 --- a/src/windows/connection/navigator/useNavigatorDbState.ts +++ b/src/windows/connection/navigator/useNavigatorDbState.ts @@ -9,41 +9,6 @@ import type { ConnectionEntry } from '../../../stores/activeConnectionStore'; import { shouldUseMultiDatabaseTree } from './utils'; import { useExpandedDbCacheRefresh } from '../schema-tree/useExpandedDbCacheRefresh'; -/** - * Pin `currentDatabase` on ONE session entry without touching any other - * session. The updater returns ONLY `{ schemas }`: spreading the whole state - * back would re-inject the stale flattened top-level fields, and - * `mergePartialIntoStore` would then re-apply that stale `currentDatabase` to - * the ACTIVE session's entry — silently clobbering this pin whenever the - * target row's session === active session (the old raw-setState path had - * exactly this latent bug). - */ -function pinCurrentDatabase( - dbSessionId: string, - dbName: string, - options?: { onlyKnownDatabases?: boolean }, -): void { - useSchemaStore.setState((state) => { - const entry = state.schemas.get(dbSessionId); - if (!entry || entry.currentDatabase === dbName) return { schemas: state.schemas }; - // Path-hierarchy namespace leaves call activateDatabase with their fetch - // PATH ('public', '558/hive/snap'), not a real database. Pinning that as - // currentDatabase would corrupt the root prefix that ensureNamespacePath - // builds fetch paths from. Only a database the session actually knows - // moves the pointer; an unloaded list trusts the caller. - if ( - options?.onlyKnownDatabases && - entry.databases.length > 0 && - !entry.databases.includes(dbName) - ) { - return { schemas: state.schemas }; - } - const next = new Map(state.schemas); - next.set(dbSessionId, { ...entry, currentDatabase: dbName }); - return { schemas: next }; - }); -} - export function useNavigatorDbState( activeConnections: Record, connections: ConnectionConfig[], @@ -82,7 +47,7 @@ export function useNavigatorDbState( async (dbSessionId: string, dbName: string) => { const tableKey = `${dbSessionId}::${dbName}`; try { - const all = await databaseCommands.getTables(dbSessionId, dbName); + const all = await databaseCommands.listTables(dbSessionId, dbName); setDbTablesMap((prev) => ({ ...prev, [tableKey]: all })); // Background cache fills stay neutral: the pointer is owned by the // user's last tree click, not by whichever fan-out finishes last. @@ -109,8 +74,8 @@ export function useNavigatorDbState( } // Cache miss: nothing fetched yet, so the pointer still has to move — // but only for a database this session actually knows (namespace leaves - // pass fetch PATHs here; see pinCurrentDatabase). - pinCurrentDatabase(dbSessionId, dbName, { onlyKnownDatabases: true }); + // pass fetch PATHs here; setCurrentDatabase validates known databases). + useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); }, [dbTablesMap], ); @@ -326,16 +291,14 @@ export function useNavigatorDbState( // The click itself owns the selection: pin synchronously so the pointer // moves even on cache hits, in-flight fetches, and before the IPC // resolves. Previously currentDatabase only changed as an async side - // effect of getTables completing inside setLoadedTables — skipped on + // effect of listTables completing inside setLoadedTables — skipped on // every early-return below — which is why clicking a database did not // move the pointer (and "查看 ER" then showed a stale database) in a // non-reproducible subset of interactions. - pinCurrentDatabase(dbSessionId, dbName); + useSchemaStore.getState().setCurrentDatabase(dbName, dbSessionId); const tableKey = `${dbSessionId}::${dbName}`; if (dbTablesMap[tableKey]) { - // Cache hit: still refresh the flat session tables so legacy - // flat-field consumers see the clicked database's list (the pin - // above already happened; this re-pins the same value). + // Publish the cached catalog into the explicitly selected session. useSchemaStore.getState().setLoadedTables(dbName, dbTablesMap[tableKey], dbSessionId); return; } diff --git a/src/windows/connection/query/QueryBuilderHostAdapter.tsx b/src/windows/connection/query/QueryBuilderHostAdapter.tsx index 5532d48cb..a1584b2c7 100644 --- a/src/windows/connection/query/QueryBuilderHostAdapter.tsx +++ b/src/windows/connection/query/QueryBuilderHostAdapter.tsx @@ -48,27 +48,45 @@ export function QueryBuilderHostAdapter({ ); const catalog = useMemo(() => { + const catalogItems = + sessionSchema?.tableCatalogs[database] ?? + (sessionSchema?.currentDatabase === database + ? [...sessionSchema.tables, ...sessionSchema.views] + : []); const relations = [ - ...(sessionSchema?.tables ?? []), - ...(sessionSchema?.views ?? []), - ...Object.values(sessionSchema?.pathItems ?? {}).flat(), - ].filter((relation) => !schema || (relation.schema ?? null) === schema); - const hasMatchingDatabase = - !sessionSchema?.currentDatabase || sessionSchema.currentDatabase === database; + ...catalogItems, + ...Object.values( + sessionSchema?.currentDatabase === database ? sessionSchema.pathItems : {}, + ).flat(), + ].filter((relation) => !schema || relation.schema === schema); + const unique = new Map(); + for (const relation of relations) { + unique.set(JSON.stringify([relation.schema ?? null, relation.name]), relation); + } const counts = new Map(); - if (hasMatchingDatabase) { - for (const relation of relations) { - counts.set(relation.name, (counts.get(relation.name) ?? 0) + 1); - } + for (const relation of unique.values()) + counts.set(relation.name, (counts.get(relation.name) ?? 0) + 1); + const columnMap: Record = {}; + const typedColumnMap: Record> = {}; + for (const value of Object.values(sessionSchema?.relationColumns ?? {})) { + if ( + value.ref.database !== database || + (schema && value.ref.schema !== schema) || + counts.get(value.ref.name) !== 1 || + !unique.has(JSON.stringify([value.ref.schema, value.ref.name])) + ) + continue; + columnMap[value.ref.name] = value.columns.map((column) => column.name); + typedColumnMap[value.ref.name] = Object.fromEntries( + value.columns.map((column) => [column.name, column.dataType]), + ); } return { - tables: hasMatchingDatabase - ? relations - .filter((relation) => counts.get(relation.name) === 1) - .map((relation) => ({ name: relation.name, schema: relation.schema ?? null })) - : [], - columnMap: hasMatchingDatabase ? (sessionSchema?.columnMap ?? {}) : {}, - typedColumnMap: hasMatchingDatabase ? (sessionSchema?.typedColumnMap ?? {}) : {}, + tables: [...unique.values()] + .filter((relation) => counts.get(relation.name) === 1) + .map((relation) => ({ name: relation.name, schema: relation.schema ?? null })), + columnMap, + typedColumnMap, }; }, [sessionSchema, schema, database]); @@ -76,24 +94,24 @@ export function QueryBuilderHostAdapter({ async (names: readonly string[]) => { if (!dbSessionId || !database.trim() || names.length === 0) return; await useSchemaStore.getState().ensureColumns([...names], dbSessionId, database, { - requireTypes: true, + ...(schema ? { schema } : {}), }); }, - [dbSessionId, database], + [dbSessionId, database, schema], ); const loadTableSchema = useCallback( async (name: string): Promise => { if (!dbSessionId || !database.trim() || !name.trim()) return null; try { - const relationSchema = - useSchemaStore.getState().schemaOfRelation(name, dbSessionId) ?? schema; - return await getCachedTableSchema(dbSessionId, name, database, relationSchema); + const relation = catalog.tables.find((item) => item.name === name); + if (!relation) return null; + return await getCachedTableSchema(dbSessionId, relation.name, database, relation.schema); } catch { return null; } }, - [dbSessionId, database, schema], + [dbSessionId, database, catalog.tables], ); const predict = useCallback( diff --git a/src/windows/connection/query/QuerySidebarSection.tsx b/src/windows/connection/query/QuerySidebarSection.tsx index c3038b89b..125dc15f1 100644 --- a/src/windows/connection/query/QuerySidebarSection.tsx +++ b/src/windows/connection/query/QuerySidebarSection.tsx @@ -65,7 +65,7 @@ export function useQueryContextPath({ ); useEffect(() => { - void ensureNamespacePath([]); + void ensureNamespacePath([], dbSessionId); }, [dbSessionId, selectedDatabase, ensureNamespacePath]); useEffect(() => { @@ -78,23 +78,24 @@ export function useQueryContextPath({ setContextPath(next); if (isPathHierarchy) { updatePanel(panelId, { namespacePath: next.length > 0 ? next : undefined }); - if (next.length > 0) await ensureNamespacePath(next); + if (next.length > 0) await ensureNamespacePath(next, dbSessionId); return; } const db = next[0]; // Guard: in single-database mode the context-path root can be a *schema* // (e.g. `public`) rather than a database — never hand a schema name to - // switchDatabase, which would run get_tables('public') and pin the + // switchDatabase, which would run listTables('public') and pin the // session's currentDatabase to a non-existent database. if (db && databases.includes(db) && db !== currentDatabase) { // Persist the database to the panel so that `selectedDatabase` // (= database ?? currentDatabase) reflects the switch even when the // panel already has a bound database from creation time. updatePanel(panelId, { database: db }); - await switchDatabase(db); + await switchDatabase(db, dbSessionId); } }, [ + dbSessionId, currentDatabase, databases, ensureNamespacePath, @@ -133,14 +134,22 @@ export function useQueryContextPath({ (parents: string[]) => { if (ensureTimer.current) clearTimeout(ensureTimer.current); ensureTimer.current = setTimeout(() => { - void ensureNamespacePath(parents); + void ensureNamespacePath(parents, dbSessionId); }, 120); const roots = new Set(namespaceRootsFrom(namespaceTree, pathAliases, databases)); if (parents[0] && roots.has(parents[0]) && !pathsEqual(parents, contextPath)) { void applyContextPath(parents); } }, - [applyContextPath, contextPath, databases, ensureNamespacePath, namespaceTree, pathAliases], + [ + applyContextPath, + contextPath, + databases, + dbSessionId, + ensureNamespacePath, + namespaceTree, + pathAliases, + ], ); const syncContextFromSql = useCallback( diff --git a/src/windows/connection/query/contracts.ts b/src/windows/connection/query/contracts.ts index b8c0de4c0..2a112b3d2 100644 --- a/src/windows/connection/query/contracts.ts +++ b/src/windows/connection/query/contracts.ts @@ -1,3 +1,5 @@ +import { createEmptyConnectionSchema } from '../../../stores/schemaStoreState'; +import { projectRelationColumns } from '../../../stores/schemaColumnLoader'; import type { ConnectionSchemaState } from '../../../stores/schemaStore'; import type { QueryExecState } from '../../../stores/queryExecActions'; import type { SqlEditorHandle } from '../../../components/SqlEditor'; @@ -33,8 +35,8 @@ export interface QueryPanelProps { export type QueryDiagnosisSchemaState = Pick< ConnectionSchemaState, - 'currentDatabase' | 'currentSchema' | 'tables' | 'views' | 'columnMap' ->; + 'currentDatabase' | 'currentSchema' | 'tables' | 'views' +> & { columnMap: Record }; export type QueryDiagnosisExecution = Pick; @@ -164,7 +166,7 @@ export function readCurrentQueryPanelRetryValidationInput( if (!activeConnectionMatchesPanel) return null; const schemaStoreState = useSchemaStore.getState(); - const schemaState = schemaStoreState.schemas.get(dbSessionId) ?? schemaStoreState; + const schemaState = schemaStoreState.schemas.get(dbSessionId) ?? createEmptyConnectionSchema(); const latestContext = buildQueryPanelDiagnosisContext({ execution, connectionId: panel.connectionId, @@ -178,7 +180,15 @@ export function readCurrentQueryPanelRetryValidationInput( undefined, schema: panel.schema, serverVersion: activeConnection?.serverInfo?.serverVersion, - schemaState, + schemaState: { + ...schemaState, + ...projectRelationColumns( + schemaState, + dbSessionId, + panel.database ?? schemaState.currentDatabase ?? '', + panel.schema ?? undefined, + ), + }, }); if (!latestContext.ok) return null; diff --git a/src/windows/connection/query/toolbar/RefreshCompletionButton.tsx b/src/windows/connection/query/toolbar/RefreshCompletionButton.tsx index 7f56195f1..0bf11d8e7 100644 --- a/src/windows/connection/query/toolbar/RefreshCompletionButton.tsx +++ b/src/windows/connection/query/toolbar/RefreshCompletionButton.tsx @@ -5,9 +5,9 @@ * previously required switching databases to force a re-read. */ import { useCallback, useState } from 'react'; +import { schemaClient } from '@datazen/driver-sdk'; import { RefreshCw } from 'lucide-react'; import { ToolbarButton } from '../../../../components/ui/ToolbarButton'; -import { metadataCache } from '../../../../components/sql-editor/metadata/metadataCache'; import { invalidateSchemaCache } from '../../../../lib/schemaCache'; import { useSchemaStore } from '../../../../stores/schemaStore'; import { useI18n } from '../../../../hooks/useI18n'; @@ -31,19 +31,25 @@ export function RefreshCompletionButton({ }: RefreshCompletionButtonProps) { const { t } = useI18n(); const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); const handleRefresh = useCallback(async () => { if (!dbSessionId || busy) return; setBusy(true); + setError(null); try { // Drop the underlying table-schema/DDL layer first: clearing only the // editor snapshot would let it repopulate from the same stale entries. invalidateSchemaCache(dbSessionId); - metadataCache.invalidateSession(dbSessionId); + await schemaClient.refresh(dbSessionId, { kind: 'session' }); + // Reads started while the backend was refreshing are stale as well. + invalidateSchemaCache(dbSessionId); if (database) { await useSchemaStore.getState().loadTables(database, dbSessionId); } onRefreshed(t('query.refreshCompletionDone')); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); } finally { setBusy(false); } @@ -54,7 +60,7 @@ export function RefreshCompletionButton({ iconOnly={iconOnly} variant="ghost" label={t('query.refreshCompletion')} - title={t('query.refreshCompletionTitle')} + title={error ?? t('query.refreshCompletionTitle')} icon={} onClick={() => void handleRefresh()} disabled={disabled || busy || !dbSessionId} diff --git a/src/windows/connection/schema-tree/__tests__/useExpandedDbCacheRefresh.test.tsx b/src/windows/connection/schema-tree/__tests__/useExpandedDbCacheRefresh.test.tsx index c02dcbf27..5e98d054e 100644 --- a/src/windows/connection/schema-tree/__tests__/useExpandedDbCacheRefresh.test.tsx +++ b/src/windows/connection/schema-tree/__tests__/useExpandedDbCacheRefresh.test.tsx @@ -18,8 +18,8 @@ function schemaEntry(databases: string[], epoch: number): ConnectionSchemaState tables: [], views: [], schemaNames: [], - columnMap: {}, - typedColumnMap: {}, + tableCatalogs: {}, + relationColumns: {}, namespaceTree: {}, loadedPaths: new Set(), pathItems: {}, diff --git a/src/windows/connection/schema-tree/useExpandedDbCacheRefresh.ts b/src/windows/connection/schema-tree/useExpandedDbCacheRefresh.ts index 071d779f8..92bbad745 100644 --- a/src/windows/connection/schema-tree/useExpandedDbCacheRefresh.ts +++ b/src/windows/connection/schema-tree/useExpandedDbCacheRefresh.ts @@ -56,7 +56,7 @@ export interface ExpandedDbCacheRefreshOptions { * * Root-cause fix for "sidebar refresh flips my SQL session": the previous * inline implementations reloaded each expanded database through - * `useDatabase` + `getTables`, so the LAST reloaded database silently became + * `useDatabase` + `listTables`, so the LAST reloaded database silently became * the session's active database. Drivers now expose session-neutral table * reads, so this hook never touches `useDatabase`. * diff --git a/src/windows/connection/useConnectionContextMenu.ts b/src/windows/connection/useConnectionContextMenu.ts index 9626dc05c..1a869225a 100644 --- a/src/windows/connection/useConnectionContextMenu.ts +++ b/src/windows/connection/useConnectionContextMenu.ts @@ -151,14 +151,12 @@ export function useConnectionContextMenu({ const handleGenerateTableSql = (type: GeneratedSqlType) => { void (async () => { - const schemaState = useSchemaStore.getState().schemas.get(ctx.dbSessionId); const tableSchema = await fetchTableSchemaForSqlGeneration({ dbSessionId: ctx.dbSessionId, tableName: name, schema, database, databaseType: ctxDbType, - columnMap: schemaState?.columnMap, }); const tableRef = schema ? `${schema}.${name}` : name; const sql = generateTableSqlWithFallbacks(tableSchema, type, ctxDbType, { @@ -310,7 +308,7 @@ export function useConnectionContextMenu({ sql, () => { invalidateSchemaCache(ctx.dbSessionId, name); - removeRelation(name); + removeRelation(name, ctx.dbSessionId); handlers.handleRefresh(); closePanelsForTable(name); }, diff --git a/src/windows/connection/useConnectionWorkspaceMeta.ts b/src/windows/connection/useConnectionWorkspaceMeta.ts index 8c77e42dc..9825ede40 100644 --- a/src/windows/connection/useConnectionWorkspaceMeta.ts +++ b/src/windows/connection/useConnectionWorkspaceMeta.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useConnectionStore } from '../../stores/connectionStore'; import { useActiveConnectionStore } from '../../stores/activeConnectionStore'; -import { useSchemaStore } from '../../stores/schemaStore'; +import { useSchemaStore, useConnectionSchemaField } from '../../stores/schemaStore'; import { usePanelStore, type ConnectionContext, type Panel } from '../../stores/panelStore'; import { DB_REGISTRY } from '../../lib/databaseTypes'; import { canOpenStructureEditor } from '../../lib/structureEditor/canOpenStructureEditor'; @@ -48,7 +48,10 @@ export function useConnectionWorkspaceMeta(activePanel: Panel | null): Connectio const savedConnections = useConnectionStore((s) => s.connections); const activeConnections = useActiveConnectionStore((s) => s.connections); const storeActiveDbSessionId = useSchemaStore((s) => s.activeDbSessionId); - const currentDatabase = useSchemaStore((s) => s.currentDatabase); + const currentDatabase = useConnectionSchemaField( + activePanel?.dbSessionId ?? '', + 'currentDatabase', + ); const allPanels = usePanelStore((s) => s.panels); const databaseType = activePanel?.databaseType as DatabaseType | undefined; diff --git a/src/windows/connection/usePanelHandlers.ts b/src/windows/connection/usePanelHandlers.ts index 1e783eab7..126dec353 100644 --- a/src/windows/connection/usePanelHandlers.ts +++ b/src/windows/connection/usePanelHandlers.ts @@ -635,7 +635,7 @@ export function usePanelHandlers({ if (!sidebarConnCtx?.dbSessionId) return; useTableDataStore.getState().invalidateCachedData(sidebarConnCtx.dbSessionId); if (currentDatabase) { - void loadTables(currentDatabase); + void loadTables(currentDatabase, sidebarConnCtx.dbSessionId); } else { void loadForConnection(sidebarConnCtx.dbSessionId, { databaseType: sidebarConnCtx.databaseType, diff --git a/src/windows/data-sync/DataSyncWindow.tsx b/src/windows/data-sync/DataSyncWindow.tsx index 5e4cafe08..15fa3a887 100644 --- a/src/windows/data-sync/DataSyncWindow.tsx +++ b/src/windows/data-sync/DataSyncWindow.tsx @@ -495,7 +495,7 @@ export function DataSyncWindow() { let cancelled = false; (async () => { try { - const tables = await databaseCommands.getTables(connId, sourceDatabase); + const tables = await databaseCommands.listTables(connId, sourceDatabase); if (cancelled) return; const schemas = uniqueSchemasFromTables(tables); setSourceSchemas(schemas); @@ -543,7 +543,7 @@ export function DataSyncWindow() { let cancelled = false; (async () => { try { - const tables = await databaseCommands.getTables(connId, targetDatabase); + const tables = await databaseCommands.listTables(connId, targetDatabase); if (cancelled) return; const schemas = uniqueSchemasFromTables(tables); setTargetSchemas(schemas); diff --git a/src/windows/data-sync/__tests__/DataSyncWindow.test.tsx b/src/windows/data-sync/__tests__/DataSyncWindow.test.tsx index 2b33ee4e4..616a102ff 100644 --- a/src/windows/data-sync/__tests__/DataSyncWindow.test.tsx +++ b/src/windows/data-sync/__tests__/DataSyncWindow.test.tsx @@ -16,7 +16,7 @@ const { saveSyncProfileMock, deleteSyncProfileMock, getDatabasesMock, - getTablesMock, + listTablesMock, aiChatMock, stableT, aiConfiguredRef, @@ -38,7 +38,7 @@ const { saveSyncProfileMock: vi.fn(), deleteSyncProfileMock: vi.fn(), getDatabasesMock: vi.fn(), - getTablesMock: vi.fn(), + listTablesMock: vi.fn(), aiChatMock: vi.fn(), stableT, aiConfiguredRef, @@ -92,7 +92,7 @@ vi.mock('../../../commands/sync', () => ({ vi.mock('../../../commands/database', () => ({ databaseCommands: { getDatabases: (...args: unknown[]) => getDatabasesMock(...args), - getTables: (...args: unknown[]) => getTablesMock(...args), + listTables: (...args: unknown[]) => listTablesMock(...args), }, })); @@ -360,8 +360,8 @@ describe('DataSyncWindow wizard', () => { getDatabasesMock.mockImplementation(async (connId: string) => connId.includes('pg-src') || connId.includes('my') ? ['src', 'other'] : ['tgt'], ); - getTablesMock.mockReset(); - getTablesMock.mockResolvedValue([{ name: 'users', tableType: 'table' }]); + listTablesMock.mockReset(); + listTablesMock.mockResolvedValue([{ name: 'users', tableType: 'table' }]); invokeMock.mockImplementation( async ( cmd: string, @@ -369,6 +369,7 @@ describe('DataSyncWindow wizard', () => { connectionId?: string; database?: string | null; dbSessionId?: string; + request?: { command?: string; dbSessionId?: string }; sourceDatabaseType?: string; targetDatabaseType?: string; }, @@ -382,8 +383,12 @@ describe('DataSyncWindow wizard', () => { const db = args?.database ?? 'default'; return `dedicated-${conn}-${db}`; } - if (cmd === 'get_databases') { - return args?.dbSessionId?.includes('pg-src') ? ['src', 'other'] : ['tgt']; + if (cmd === 'execute_driver_command' && args?.request?.command === 'list_databases') { + return { + data: { + databases: args.request.dbSessionId?.includes('pg-src') ? ['src', 'other'] : ['tgt'], + }, + }; } if (cmd === 'release_connection') return false; if (cmd === 'connect') return `live-${args?.connectionId}`; @@ -673,7 +678,7 @@ describe('DataSyncWindow wizard', () => { }); it('shows schema pickers for PostgreSQL when get_tables returns schemas', async () => { - getTablesMock.mockResolvedValue([ + listTablesMock.mockResolvedValue([ { name: 'users', schema: 'public', tableType: 'table' }, { name: 'users', schema: 'app', tableType: 'table' }, ]); @@ -687,7 +692,7 @@ describe('DataSyncWindow wizard', () => { }); it('discovers schemas for any SQL driver that supports table metadata', async () => { - getTablesMock.mockResolvedValue([ + listTablesMock.mockResolvedValue([ { name: 'users', schema: 'public', tableType: 'table' }, { name: 'users', schema: 'app', tableType: 'table' }, ]); @@ -696,7 +701,7 @@ describe('DataSyncWindow wizard', () => { await pickSelect('data-sync-source', 'My Tgt'); await waitFor(() => expect(screen.getByTestId('data-sync-source-schema')).toBeTruthy()); expect(screen.getByTestId('data-sync-source-schema')).toHaveTextContent('public'); - expect(getTablesMock).toHaveBeenCalledWith('dedicated-my-tgt-src', 'src'); + expect(listTablesMock).toHaveBeenCalledWith('dedicated-my-tgt-src', 'src'); }); it('marks heterogeneous targets as unsupported in the picker', async () => { diff --git a/src/windows/schema-diff/SchemaDiffWindow.tsx b/src/windows/schema-diff/SchemaDiffWindow.tsx index eef664cb2..8796dcfdc 100644 --- a/src/windows/schema-diff/SchemaDiffWindow.tsx +++ b/src/windows/schema-diff/SchemaDiffWindow.tsx @@ -213,8 +213,8 @@ export function SchemaDiffWindow() { const tgtConnId = await endpoints.ensureConnected('target'); if (!srcConnId || !tgtConnId) return; const [sourceRows, targetRows] = await Promise.all([ - databaseCommands.getTables(srcConnId, endpoints.sourceDatabase), - databaseCommands.getTables(tgtConnId, endpoints.targetDatabase), + databaseCommands.listTables(srcConnId, endpoints.sourceDatabase), + databaseCommands.listTables(tgtConnId, endpoints.targetDatabase), ]); const catalog = await unifiedObjects.load( srcConnId, diff --git a/src/windows/schema-diff/__tests__/SchemaDiffProfileLoad.test.tsx b/src/windows/schema-diff/__tests__/SchemaDiffProfileLoad.test.tsx index 73ab4dc14..7cfc7b971 100644 --- a/src/windows/schema-diff/__tests__/SchemaDiffProfileLoad.test.tsx +++ b/src/windows/schema-diff/__tests__/SchemaDiffProfileLoad.test.tsx @@ -76,7 +76,7 @@ const { endpointState, profile, schemaDiffCommands, databaseCommands } = vi.hois executeDeploy: vi.fn(), }, databaseCommands: { - getTables: vi + listTables: vi .fn() .mockResolvedValue([{ name: 'users', schema: 'public', tableType: 'table' }]), getDatabaseObjects: vi.fn().mockResolvedValue([]), @@ -216,7 +216,7 @@ describe('SchemaDiffWindow profile loading', () => { rollbackCompleteness: { complete: true, missing: [] }, typeSuggestions: [], }); - databaseCommands.getTables.mockResolvedValue([ + databaseCommands.listTables.mockResolvedValue([ { name: 'users', schema: 'public', tableType: 'table' }, ]); databaseCommands.getDatabaseObjects.mockResolvedValue([]); @@ -249,7 +249,7 @@ describe('SchemaDiffWindow profile loading', () => { await waitFor(() => expect(screen.getByTestId('schema-diff-objects-panel')).toBeInTheDocument(), ); - expect(databaseCommands.getTables).toHaveBeenCalledWith('source-session', 'profile-db'); + expect(databaseCommands.listTables).toHaveBeenCalledWith('source-session', 'profile-db'); expect(screen.getByTestId('schema-diff-table-row')).toHaveAttribute( 'data-table-name', 'public.users', @@ -273,7 +273,7 @@ describe('SchemaDiffWindow profile loading', () => { it('[tester] restores a target-only profile row and forwards separate selectors', async () => { profile.targetOnlyTables = ['public.archive']; - databaseCommands.getTables.mockImplementation(async (sessionId: string) => + databaseCommands.listTables.mockImplementation(async (sessionId: string) => sessionId === 'source-session' ? [{ name: 'users', schema: 'public', tableType: 'table' }] : [ @@ -345,7 +345,7 @@ describe('SchemaDiffWindow profile loading', () => { targetSchema: 'public', targetName: 'orders', }; - databaseCommands.getTables.mockResolvedValue([]); + databaseCommands.listTables.mockResolvedValue([]); databaseCommands.getDatabaseObjects.mockImplementation( async (sessionId: string, kind: string) => (sessionId === 'source-session' ? [sourceRoutine] : [targetTrigger]).filter( @@ -442,7 +442,7 @@ describe('SchemaDiffWindow profile loading', () => { : [otherTrigger, { ...desiredTarget }] ).filter((object) => object.kind === kind), ); - databaseCommands.getTables.mockResolvedValue([]); + databaseCommands.listTables.mockResolvedValue([]); render(); fireEvent.click(screen.getByTestId('schema-diff-next')); diff --git a/src/windows/schema-diff/__tests__/SchemaDiffWizard.test.tsx b/src/windows/schema-diff/__tests__/SchemaDiffWizard.test.tsx index 5081619c9..7c394a9fe 100644 --- a/src/windows/schema-diff/__tests__/SchemaDiffWizard.test.tsx +++ b/src/windows/schema-diff/__tests__/SchemaDiffWizard.test.tsx @@ -51,7 +51,7 @@ vi.mock('../../../lib/schemaDiffLimitationsPrefs', () => ({ setSchemaDiffLimitationsDismissed: vi.fn(), })); vi.mock('../../../commands/database', () => ({ - databaseCommands: { getTables: vi.fn(), getDatabaseObjects: vi.fn() }, + databaseCommands: { listTables: vi.fn(), getDatabaseObjects: vi.fn() }, })); vi.mock('../../../commands/file', () => ({ fileCommands: { saveTextWithDialog: vi.fn() } })); vi.mock('../../../commands/schemaDiff', async (original) => ({ @@ -124,7 +124,7 @@ beforeEach(() => { state.endpoints.isCrossDialect = false; state.endpoints.validateEndpoints.mockReturnValue(true); state.endpoints.ensureConnected.mockImplementation(async (side) => `${side}-session`); - vi.mocked(databaseCommands.getTables).mockResolvedValue([{ name: 'users', tableType: 'table' }]); + vi.mocked(databaseCommands.listTables).mockResolvedValue([{ name: 'users', tableType: 'table' }]); vi.mocked(databaseCommands.getDatabaseObjects).mockResolvedValue([]); vi.mocked(schemaDiffCommands.compareTableSchemas).mockResolvedValue({ table: 'users', @@ -261,7 +261,7 @@ describe('complete schema migration wizard journeys', () => { }); it('selects a target-only table without sending it through source comparison', async () => { - vi.mocked(databaseCommands.getTables).mockImplementation(async (sessionId) => + vi.mocked(databaseCommands.listTables).mockImplementation(async (sessionId) => sessionId === 'source-session' ? [{ name: 'users', tableType: 'table' }] : [ @@ -304,7 +304,7 @@ describe('complete schema migration wizard journeys', () => { }); it('exports and saves target-only selections as destructive profile state', async () => { - vi.mocked(databaseCommands.getTables).mockImplementation(async (sessionId) => + vi.mocked(databaseCommands.listTables).mockImplementation(async (sessionId) => sessionId === 'source-session' ? [{ name: 'users', tableType: 'table' }] : [ @@ -565,7 +565,7 @@ describe('complete schema migration wizard journeys', () => { targetSchema: 'public', targetName: 'orders', }; - vi.mocked(databaseCommands.getTables).mockResolvedValue([]); + vi.mocked(databaseCommands.listTables).mockResolvedValue([]); vi.mocked(databaseCommands.getDatabaseObjects).mockImplementation(async (sessionId, kind) => (sessionId === 'source-session' ? [routine] : [trigger]).filter( (object) => object.kind === kind, diff --git a/src/windows/schema-diff/__tests__/useSchemaDiffEndpoints.test.tsx b/src/windows/schema-diff/__tests__/useSchemaDiffEndpoints.test.tsx index a7a1dd520..1fede9510 100644 --- a/src/windows/schema-diff/__tests__/useSchemaDiffEndpoints.test.tsx +++ b/src/windows/schema-diff/__tests__/useSchemaDiffEndpoints.test.tsx @@ -36,7 +36,7 @@ vi.mock('../../../lib/dedicatedDbSession', () => ({ vi.mock('../../../commands/database', () => ({ databaseCommands: { - getTables: vi.fn().mockResolvedValue([]), + listTables: vi.fn().mockResolvedValue([]), }, })); @@ -276,7 +276,7 @@ describe('useSchemaDiffEndpoints', () => { }); it('discovers schemas through table metadata for non-PostgreSQL SQL drivers', async () => { - vi.mocked(databaseCommands.getTables).mockResolvedValue([ + vi.mocked(databaseCommands.listTables).mockResolvedValue([ { name: 'users', schema: 'public', tableType: 'table' }, { name: 'users', schema: 'app', tableType: 'table' }, ]); @@ -292,7 +292,7 @@ describe('useSchemaDiffEndpoints', () => { await waitFor(() => { expect(result.current.sourceSchemas).toEqual(['app', 'public']); }); - expect(databaseCommands.getTables).toHaveBeenCalledWith( + expect(databaseCommands.listTables).toHaveBeenCalledWith( 'session-mysql-tgt', 'datazen_sync_src', ); @@ -303,7 +303,7 @@ describe('useSchemaDiffEndpoints', () => { pgConn('pg-src', 'PG Src', 'worker_db', 'e2e_worker_0'), pgConn('pg-tgt', 'PG Tgt', 'datazen_sync_tgt'), ]); - vi.mocked(databaseCommands.getTables).mockResolvedValue([ + vi.mocked(databaseCommands.listTables).mockResolvedValue([ { name: '', schema: 'public', tableType: 'systemTable' }, { name: 'e2e_contract_conn', schema: 'e2e_worker_0', tableType: 'table' }, ]); diff --git a/src/windows/schema-diff/schemaDiffTableNames.ts b/src/windows/schema-diff/schemaDiffTableNames.ts index ec6327079..7758d0c94 100644 --- a/src/windows/schema-diff/schemaDiffTableNames.ts +++ b/src/windows/schema-diff/schemaDiffTableNames.ts @@ -77,11 +77,8 @@ export function mergeSchemaDiffTablePicks( const targetName = targetTable ? qualifySchemaDiffTableName(targetTable, targetSchema) : undefined; - const origin = sourceTable && targetTable - ? 'both' - : sourceTable - ? 'source-only' - : 'target-only'; + const origin = + sourceTable && targetTable ? 'both' : sourceTable ? 'source-only' : 'target-only'; return { name: sourceName ?? targetName ?? relation, enabled: origin !== 'target-only', diff --git a/src/windows/schema-diff/useSchemaDiffEndpoints.ts b/src/windows/schema-diff/useSchemaDiffEndpoints.ts index 6d8bfee35..e41690bc2 100644 --- a/src/windows/schema-diff/useSchemaDiffEndpoints.ts +++ b/src/windows/schema-diff/useSchemaDiffEndpoints.ts @@ -271,7 +271,7 @@ export function useSchemaDiffEndpoints(options: UseSchemaDiffEndpointsOptions = let cancelled = false; (async () => { try { - const tables = await databaseCommands.getTables(connId, sourceDatabase); + const tables = await databaseCommands.listTables(connId, sourceDatabase); if (cancelled) return; const schemas = uniqueSchemasFromTables(tables); setSourceSchemas(schemas); @@ -324,7 +324,7 @@ export function useSchemaDiffEndpoints(options: UseSchemaDiffEndpointsOptions = let cancelled = false; (async () => { try { - const tables = await databaseCommands.getTables(connId, targetDatabase); + const tables = await databaseCommands.listTables(connId, targetDatabase); if (cancelled) return; const schemas = uniqueSchemasFromTables(tables); setTargetSchemas(schemas); diff --git a/src/windows/schema-diff/useSchemaDiffSavedSetups.ts b/src/windows/schema-diff/useSchemaDiffSavedSetups.ts index baedad1ce..d09534fb4 100644 --- a/src/windows/schema-diff/useSchemaDiffSavedSetups.ts +++ b/src/windows/schema-diff/useSchemaDiffSavedSetups.ts @@ -339,8 +339,8 @@ export function useSchemaDiffSavedSetups({ const tgtConnId = await endpoints.ensureConnected('target'); if (!srcConnId || !tgtConnId) return; const [sourceRows, targetRows] = await Promise.all([ - databaseCommands.getTables(srcConnId, profile.sourceDatabase), - databaseCommands.getTables(tgtConnId, profile.targetDatabase), + databaseCommands.listTables(srcConnId, profile.sourceDatabase), + databaseCommands.listTables(tgtConnId, profile.targetDatabase), ]); const catalog = await unifiedObjects.load( srcConnId, From 8592b0fe1087be67a498e738e367016514ac2fcc Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 18:07:50 +0800 Subject: [PATCH 049/666] fix(schema-tree): route PG and MySQL objects by database --- .../driver-api/src/schema_object_commands.rs | 42 +++++-- .../tests/dependency_route.rs | 117 +++++++++++++++++- packages/driver-api/src/schema_objects.rs | 34 +++++ .../driver-sdk/src/types/connection-view.ts | 1 + src-tauri/src/commands/schema.rs | 24 +++- src-tauri/src/commands/schema/tests.rs | 11 +- src/commands/database.ts | 10 +- src/lib/__tests__/fetchRelationDdl.test.ts | 8 ++ src/lib/fetchRelationDdl.ts | 11 +- src/stores/panelTypes.ts | 1 + .../connection/ConnectionNavigatorTree.tsx | 4 +- src/windows/connection/DatabaseObjectView.tsx | 2 + src/windows/connection/ObjectBrowser.tsx | 18 ++- .../connection/PanelContentRenderer.tsx | 6 +- .../ConnectionNavigatorTree.test.tsx | 104 ++++++++++++++-- .../__tests__/ObjectBrowser.test.tsx | 40 +++++- .../navigatorTreeRowAriaEquivalence.test.ts | 1 + .../__tests__/usePanelHandlers.test.tsx | 20 ++- .../connection/navigator/NavigatorTreeRow.tsx | 19 ++- .../connection/navigator/buildFlatRows.ts | 1 + src/windows/connection/navigator/types.ts | 2 + .../navigator/useNavigatorContextMenus.ts | 20 ++- .../navigator/useNavigatorDbState.ts | 38 ++++-- .../useExpandedDbCacheRefresh.test.tsx | 31 +++++ .../schema-tree/useExpandedDbCacheRefresh.ts | 20 ++- src/windows/connection/usePanelHandlers.ts | 9 +- 26 files changed, 534 insertions(+), 60 deletions(-) diff --git a/packages/driver-api/src/schema_object_commands.rs b/packages/driver-api/src/schema_object_commands.rs index d4abab6be..ce11930a2 100644 --- a/packages/driver-api/src/schema_object_commands.rs +++ b/packages/driver-api/src/schema_object_commands.rs @@ -25,9 +25,10 @@ use crate::schema_dependencies::{ MYSQL_UDF_CATALOG_SQL, }; use crate::schema_objects::{ - list_objects_sql, list_privileges_sql, object_ddl_sql_with_metadata, DatabaseObject, - ObjectKind, PrivilegeGrant, + list_objects_sql_for_database, list_privileges_sql, object_ddl_sql_with_metadata, + DatabaseObject, ObjectKind, PrivilegeGrant, }; +use crate::sql_target::SqlTarget; use crate::traits::DatabaseDriver; use crate::types::{ColumnInfo, DriverError, QueryResult, Value}; use crate::ConnectionHandle; @@ -63,7 +64,8 @@ pub fn schema_object_command_definitions() -> Vec { "type": "string", "enum": ["table", "view", "function", "procedure", "trigger", "sequence", "type"], "description": "Object kind to list" - } + }, + "database": { "type": ["string", "null"], "description": "Database that owns the objects (optional)" } }, "required": ["kind"] }), @@ -106,6 +108,7 @@ pub fn schema_object_command_definitions() -> Vec { }, "name": { "type": "string", "description": "Object name" }, "schema": { "type": ["string", "null"], "description": "Schema name (optional)" }, + "database": { "type": ["string", "null"], "description": "Database that owns the object (optional)" }, "signature": { "type": ["string", "null"], "description": "Routine identity arguments (PostgreSQL overload disambiguation)" }, "targetSchema": { "type": ["string", "null"], "description": "Trigger target schema (optional)" }, "targetName": { "type": ["string", "null"], "description": "Trigger target relation (optional)" } @@ -267,10 +270,11 @@ pub async fn execute_schema_object_command( let parsed = ObjectKind::parse(kind_raw).ok_or_else(|| { DriverError::InvalidConfig(format!("Unknown object kind: {kind_raw}")) })?; - let Some(sql) = list_objects_sql(db_type, parsed) else { + let database = input.get("database").and_then(JsonValue::as_str); + let Some(sql) = list_objects_sql_for_database(db_type, parsed, database) else { return Ok(CommandResult::new(json!({ "objects": [] }))); }; - let result = driver.query(handle, &sql).await?; + let result = query_in_target_database(driver, db_type, handle, &sql, database).await?; let objects = parse_object_list(&result, parsed.as_str())?; Ok(CommandResult::new(json!({ "objects": objects }))) } @@ -291,11 +295,17 @@ pub async fn execute_schema_object_command( let signature = input.get("signature").and_then(|v| v.as_str()); let target_schema = input.get("targetSchema").and_then(|v| v.as_str()); let target_name = input.get("targetName").and_then(|v| v.as_str()); + let database = input.get("database").and_then(JsonValue::as_str); + let object_schema = if crate::schema_objects::dialect_family(db_type) == "mysql" { + schema.or(database) + } else { + schema + }; let Some(sql) = object_ddl_sql_with_metadata( db_type, parsed, name, - schema, + object_schema, signature, target_schema, target_name, @@ -304,13 +314,13 @@ pub async fn execute_schema_object_command( "This database type does not expose object DDL".into(), )); }; - let result = driver.query(handle, &sql).await?; + let result = query_in_target_database(driver, db_type, handle, &sql, database).await?; let ddl = extract_object_ddl_checked(&result)?; let view_metadata = if parsed == ObjectKind::View && crate::schema_objects::dialect_family(db_type) == "mysql" { let show_create_sql = - crate::schema_objects::mysql_show_create_view_sql(name, schema); + crate::schema_objects::mysql_show_create_view_sql(name, object_schema); let show_create = driver.query(handle, &show_create_sql).await?; Some(extract_mysql_view_metadata(&result, &show_create)?) } else { @@ -344,6 +354,22 @@ pub async fn execute_schema_object_command( } } +async fn query_in_target_database( + driver: &D, + db_type: &str, + handle: &ConnectionHandle, + sql: &str, + database: Option<&str>, +) -> Result { + if crate::schema_objects::dialect_family(db_type) == "postgresql" { + driver + .query_at(handle, sql, SqlTarget::new(database, None)) + .await + } else { + driver.query(handle, sql).await + } +} + async fn execute_object_dependencies( driver: &D, db_type: &str, diff --git a/packages/driver-api/src/schema_object_commands/tests/dependency_route.rs b/packages/driver-api/src/schema_object_commands/tests/dependency_route.rs index 6e1b75606..ba824949c 100644 --- a/packages/driver-api/src/schema_object_commands/tests/dependency_route.rs +++ b/packages/driver-api/src/schema_object_commands/tests/dependency_route.rs @@ -1,7 +1,7 @@ use crate::schema_object_commands::execute_schema_object_command; use crate::{ async_trait, ConnectionConfig, ConnectionHandle, DatabaseDriver, DatabaseType, DriverError, - MultiQueryResult, QueryResult, ServerInfo, TableInfo, TableSchema, Value, + MultiQueryResult, QueryResult, ServerInfo, SqlTarget, TableInfo, TableSchema, Value, }; use serde_json::{json, Value as JsonValue}; use std::collections::VecDeque; @@ -21,6 +21,7 @@ struct TrackingDriver { db_type: String, queries: AtomicUsize, queried_sql: Mutex>, + query_databases: Mutex>>, replies: Mutex>, } @@ -34,6 +35,7 @@ impl TrackingDriver { db_type: db_type.into(), queries: AtomicUsize::new(0), queried_sql: Mutex::new(Vec::new()), + query_databases: Mutex::new(Vec::new()), replies: Mutex::new(replies.into_iter().collect()), } } @@ -49,6 +51,10 @@ impl TrackingDriver { fn queried_sql(&self) -> Vec { self.queried_sql.lock().unwrap().clone() } + + fn query_databases(&self) -> Vec> { + self.query_databases.lock().unwrap().clone() + } } #[async_trait] @@ -160,6 +166,19 @@ impl DatabaseDriver for TrackingDriver { } } + async fn query_at( + &self, + handle: &ConnectionHandle, + sql: &str, + target: SqlTarget<'_>, + ) -> Result { + self.query_databases + .lock() + .unwrap() + .push(target.database().map(str::to_string)); + self.query(handle, sql).await + } + async fn query_multi( &self, _: &ConnectionHandle, @@ -225,6 +244,102 @@ fn assert_incomplete_without_edges(result: &JsonValue) { assert_eq!(result["dependencies"], json!([])); } +#[tokio::test] +async fn postgres_object_catalog_query_uses_database_selected_in_tree() { + let catalog = QueryResult { + columns: ["schema", "name", "signature"] + .into_iter() + .map(super::col) + .collect(), + rows: Vec::new(), + rows_affected: None, + execution_time_ms: 0, + }; + let driver = TrackingDriver::for_type("postgresql", [QueryReply::Catalog(catalog)]); + + let result = execute_schema_object_command( + &driver, + "postgresql", + &handle(), + "list_objects", + json!({"kind":"function", "database":"manual_fixture_db"}), + ) + .await + .unwrap(); + + assert_eq!(result.data["objects"], json!([])); + assert_eq!( + driver.query_databases(), + vec![Some("manual_fixture_db".into())] + ); +} + +#[tokio::test] +async fn postgres_object_ddl_query_uses_database_selected_in_tree() { + let ddl = "CREATE FUNCTION public.lookup_code(integer) RETURNS text"; + let result = QueryResult { + columns: vec![super::col("ddl")], + rows: vec![vec![Some(Value::String(ddl.into()))]], + rows_affected: None, + execution_time_ms: 0, + }; + let driver = TrackingDriver::for_type("postgresql", [QueryReply::Catalog(result)]); + + let result = execute_schema_object_command( + &driver, + "postgresql", + &handle(), + "get_object_ddl", + json!({ + "kind":"function", + "name":"lookup_code", + "schema":"public", + "signature":"integer", + "database":"manual_fixture_db" + }), + ) + .await + .unwrap(); + + assert_eq!(result.data["ddl"], json!(ddl)); + assert_eq!( + driver.query_databases(), + vec![Some("manual_fixture_db".into())] + ); +} + +#[tokio::test] +async fn mysql_object_ddl_uses_database_when_schema_metadata_is_absent() { + let result = QueryResult { + columns: vec![super::col("Create Procedure")], + rows: vec![vec![Some(Value::String( + "CREATE PROCEDURE `target_db`.`refresh_cache`() SELECT 1".into(), + ))]], + rows_affected: None, + execution_time_ms: 0, + }; + let driver = TrackingDriver::for_type("mysql", [QueryReply::Catalog(result)]); + + execute_schema_object_command( + &driver, + "mysql", + &handle(), + "get_object_ddl", + json!({ + "kind":"procedure", + "name":"refresh_cache", + "database":"target_db" + }), + ) + .await + .unwrap(); + + assert_eq!( + driver.queried_sql(), + vec!["SHOW CREATE PROCEDURE `target_db`.`refresh_cache`"] + ); +} + #[tokio::test] async fn unsupported_and_invalid_dependency_inputs_return_incomplete_without_querying() { let driver = TrackingDriver::new(QueryReply::MalformedCatalog); diff --git a/packages/driver-api/src/schema_objects.rs b/packages/driver-api/src/schema_objects.rs index dbd60f229..9364d22cf 100644 --- a/packages/driver-api/src/schema_objects.rs +++ b/packages/driver-api/src/schema_objects.rs @@ -223,6 +223,27 @@ pub fn list_objects_sql(db_type: &str, kind: ObjectKind) -> Option { } } +/// Build an object-list query for a specific database when the dialect needs +/// an explicit catalog filter. PostgreSQL chooses the database through +/// `query_at`; MySQL exposes all catalogs through `information_schema`, so its +/// routine/trigger predicates must name the selected database instead of +/// relying on the connection's default `DATABASE()` value. +pub fn list_objects_sql_for_database( + db_type: &str, + kind: ObjectKind, + database: Option<&str>, +) -> Option { + let sql = list_objects_sql(db_type, kind)?; + let Some(database) = database.filter(|value| !value.trim().is_empty()) else { + return Some(sql); + }; + if dialect_family(db_type) != "mysql" { + return Some(sql); + } + + Some(sql.replace("DATABASE()", &sql_string(database))) +} + pub fn object_ddl_sql( db_type: &str, kind: ObjectKind, @@ -636,6 +657,19 @@ mod tests { assert!(ddl.contains("p.prokind = 'f'")); } + #[test] + fn mysql_object_catalog_can_target_a_database_without_using_connection_default() { + let sql = + list_objects_sql_for_database("mysql", ObjectKind::Procedure, Some("manual'catalog")) + .unwrap(); + assert!(sql.contains("ROUTINE_SCHEMA = 'manual''catalog'")); + assert!(!sql.contains("DATABASE()")); + + let default_sql = + list_objects_sql_for_database("mysql", ObjectKind::Function, None).unwrap(); + assert!(default_sql.contains("ROUTINE_SCHEMA = DATABASE()")); + } + #[test] fn postgres_type_catalog_excludes_table_row_types_and_preserves_type_options() { let list = list_objects_sql("postgresql", ObjectKind::Type).unwrap(); diff --git a/packages/driver-sdk/src/types/connection-view.ts b/packages/driver-sdk/src/types/connection-view.ts index 8975d3a94..5913ab25e 100644 --- a/packages/driver-sdk/src/types/connection-view.ts +++ b/packages/driver-sdk/src/types/connection-view.ts @@ -59,6 +59,7 @@ export interface ConnectionViewActions { signature?: string, targetSchema?: string, targetName?: string, + database?: string, ) => void; openQueryHistory?: () => void; /** 打开目标连接的服务器仪表盘;ctx 由右键菜单显式传入被点击的连接。 */ diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index cebfc5d35..588bc1a60 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -207,6 +207,7 @@ pub(crate) async fn get_database_objects_impl( state: &AppState, db_session_id: String, kind: String, + database: Option, ) -> Result, CommandError> { if crate::schema_objects::ObjectKind::parse(&kind).is_none() { return Err(CommandError::Validation(format!( @@ -217,7 +218,7 @@ pub(crate) async fn get_database_objects_impl( state, &db_session_id, "list_objects", - serde_json::json!({ "kind": kind }), + serde_json::json!({ "kind": kind, "database": database }), ) .await?; Ok(parse_objects_from_command(&data)) @@ -230,8 +231,18 @@ pub(crate) async fn get_object_ddl_impl( name: String, schema: Option, ) -> Result { - get_object_ddl_with_metadata_impl(state, db_session_id, kind, name, schema, None, None, None) - .await + get_object_ddl_with_metadata_impl( + state, + db_session_id, + kind, + name, + schema, + None, + None, + None, + None, + ) + .await } pub(crate) async fn get_object_ddl_with_metadata_impl( @@ -243,6 +254,7 @@ pub(crate) async fn get_object_ddl_with_metadata_impl( signature: Option, target_schema: Option, target_name: Option, + database: Option, ) -> Result { if crate::schema_objects::ObjectKind::parse(&kind).is_none() { return Err(CommandError::Validation(format!( @@ -260,6 +272,7 @@ pub(crate) async fn get_object_ddl_with_metadata_impl( "signature": signature, "targetSchema": target_schema, "targetName": target_name, + "database": database, }), ) .await?; @@ -289,8 +302,9 @@ pub async fn get_database_objects( state: State<'_, AppState>, db_session_id: String, kind: String, + database: Option, ) -> Result, CommandError> { - get_database_objects_impl(&state, db_session_id, kind).await + get_database_objects_impl(&state, db_session_id, kind, database).await } #[tauri::command] @@ -303,6 +317,7 @@ pub async fn get_object_ddl( signature: Option, target_schema: Option, target_name: Option, + database: Option, ) -> Result { get_object_ddl_with_metadata_impl( &state, @@ -313,6 +328,7 @@ pub async fn get_object_ddl( signature, target_schema, target_name, + database, ) .await } diff --git a/src-tauri/src/commands/schema/tests.rs b/src-tauri/src/commands/schema/tests.rs index 4e24248f8..4df38be8e 100644 --- a/src-tauri/src/commands/schema/tests.rs +++ b/src-tauri/src/commands/schema/tests.rs @@ -185,7 +185,7 @@ fn col(name: &str) -> crate::db::ColumnSchema { async fn unknown_object_kind_is_validation_error() { let test = TestAppState::new().await; let (_, conn_id) = test.save_and_connect("obj-bad-kind").await; - let err = get_database_objects_impl(&test.state, conn_id, "invalid_kind".into()) + let err = get_database_objects_impl(&test.state, conn_id, "invalid_kind".into(), None) .await .unwrap_err(); assert!(err.to_string().contains("Unknown object kind")); @@ -200,7 +200,7 @@ async fn lists_database_objects_returns_empty_when_query_has_no_rows_and_no_colu }; let test = TestAppState::with_options(opts).await; let (_, conn_id) = test.save_and_connect("obj-empty").await; - let rows = get_database_objects_impl(&test.state, conn_id, "function".into()) + let rows = get_database_objects_impl(&test.state, conn_id, "function".into(), None) .await .unwrap(); assert!(rows.is_empty()); @@ -224,7 +224,7 @@ async fn lists_database_objects_from_name_column() { }; let test = TestAppState::with_options(opts).await; let (_, conn_id) = test.save_and_connect("obj-list").await; - let rows = get_database_objects_impl(&test.state, conn_id, "function".into()) + let rows = get_database_objects_impl(&test.state, conn_id, "function".into(), None) .await .unwrap(); assert_eq!(rows.len(), 1); @@ -254,7 +254,7 @@ async fn lists_database_objects_preserves_signature_and_trigger_target() { }; let test = TestAppState::with_options(opts).await; let (_, conn_id) = test.save_and_connect("obj-identity").await; - let rows = get_database_objects_impl(&test.state, conn_id, "trigger".into()) + let rows = get_database_objects_impl(&test.state, conn_id, "trigger".into(), None) .await .unwrap(); assert_eq!(rows[0].signature.as_deref(), Some("integer")); @@ -304,6 +304,7 @@ async fn object_ddl_missing_object_fails_closed() { Some("integer".into()), None, None, + None, ) .await .unwrap_err(); @@ -318,7 +319,7 @@ async fn object_catalog_preserves_permission_query_errors() { }; let test = TestAppState::with_options(opts).await; let (_, conn_id) = test.save_and_connect("obj-permission").await; - let error = get_database_objects_impl(&test.state, conn_id, "function".into()) + let error = get_database_objects_impl(&test.state, conn_id, "function".into(), None) .await .unwrap_err(); assert!(error.to_string().contains("permission denied")); diff --git a/src/commands/database.ts b/src/commands/database.ts index 06bf91833..98c97ce4f 100644 --- a/src/commands/database.ts +++ b/src/commands/database.ts @@ -128,8 +128,12 @@ export const databaseCommands = { fingerprint: params.fingerprint, }), - getDatabaseObjects: (dbSessionId: string, kind: string) => - invoke('get_database_objects', { dbSessionId, kind }), + getDatabaseObjects: (dbSessionId: string, kind: string, database?: string | null) => + invoke('get_database_objects', { + dbSessionId, + kind, + database: database ?? null, + }), getObjectDdl: ( dbSessionId: string, @@ -139,6 +143,7 @@ export const databaseCommands = { signature?: string | null, targetSchema?: string | null, targetName?: string | null, + database?: string | null, ) => invoke('get_object_ddl', { dbSessionId, @@ -148,6 +153,7 @@ export const databaseCommands = { signature: signature ?? null, targetSchema: targetSchema ?? null, targetName: targetName ?? null, + database: database ?? null, }), getPrivileges: (dbSessionId: string) => diff --git a/src/lib/__tests__/fetchRelationDdl.test.ts b/src/lib/__tests__/fetchRelationDdl.test.ts index 1fee6bf4d..19b2ea7aa 100644 --- a/src/lib/__tests__/fetchRelationDdl.test.ts +++ b/src/lib/__tests__/fetchRelationDdl.test.ts @@ -72,6 +72,10 @@ describe('fetchRelationDdl', () => { 'table', 'fallback', 'app', + null, + null, + null, + 'app', ); }); it('passes database to getCachedDDL namespacePath and getObjectDdl schema fallback', async () => { @@ -97,6 +101,10 @@ describe('fetchRelationDdl', () => { 'table', 'users', 'tenant_db', + null, + null, + null, + 'tenant_db', ); }); }); diff --git a/src/lib/fetchRelationDdl.ts b/src/lib/fetchRelationDdl.ts index eee97fda2..24e4c5e4e 100644 --- a/src/lib/fetchRelationDdl.ts +++ b/src/lib/fetchRelationDdl.ts @@ -46,7 +46,16 @@ export async function fetchRelationDdl( try { const kind = isView ? 'view' : 'table'; const effectiveSchema = schema ?? database ?? null; - const ddl = await databaseCommands.getObjectDdl(dbSessionId, kind, tableName, effectiveSchema); + const ddl = await databaseCommands.getObjectDdl( + dbSessionId, + kind, + tableName, + effectiveSchema, + null, + null, + null, + database, + ); if (ddl && ddl.trim()) return ddl; } catch { // ignore diff --git a/src/stores/panelTypes.ts b/src/stores/panelTypes.ts index 4b182ab03..e557e59d5 100644 --- a/src/stores/panelTypes.ts +++ b/src/stores/panelTypes.ts @@ -93,6 +93,7 @@ export interface DatabaseObjectPanel extends PanelBase { objectKind: 'function' | 'procedure' | 'trigger' | 'sequence' | 'type'; objectName: string; objectSchema: string | null; + objectDatabase?: string; objectSignature?: string | null; objectTargetSchema?: string | null; objectTargetName?: string | null; diff --git a/src/windows/connection/ConnectionNavigatorTree.tsx b/src/windows/connection/ConnectionNavigatorTree.tsx index f5f80e213..f6aaf24ee 100644 --- a/src/windows/connection/ConnectionNavigatorTree.tsx +++ b/src/windows/connection/ConnectionNavigatorTree.tsx @@ -432,7 +432,7 @@ export const ConnectionNavigatorTree = forwardRef< }, []); const toggleCategory = useCallback( - async (catKey: string, catId: string, dbSessionId: string) => { + async (catKey: string, catId: string, dbSessionId: string, dbName: string) => { const wasExpanded = expandedCats.has(catKey); setExpandedCats((prev) => { @@ -443,7 +443,7 @@ export const ConnectionNavigatorTree = forwardRef< }); if (wasExpanded) return; - await dbState.toggleCategoryLoad(catKey, catId, dbSessionId); + await dbState.toggleCategoryLoad(catKey, catId, dbSessionId, dbName); }, [expandedCats, dbState], ); diff --git a/src/windows/connection/DatabaseObjectView.tsx b/src/windows/connection/DatabaseObjectView.tsx index d836dd48c..85cdcd4f0 100644 --- a/src/windows/connection/DatabaseObjectView.tsx +++ b/src/windows/connection/DatabaseObjectView.tsx @@ -50,6 +50,7 @@ export function DatabaseObjectView({ objectSignature, objectTargetSchema, objectTargetName, + database, ); setDdl(text); } catch (e) { @@ -65,6 +66,7 @@ export function DatabaseObjectView({ objectSignature, objectTargetSchema, objectTargetName, + database, ]); useEffect(() => { diff --git a/src/windows/connection/ObjectBrowser.tsx b/src/windows/connection/ObjectBrowser.tsx index 821473b1c..9d4db9d94 100644 --- a/src/windows/connection/ObjectBrowser.tsx +++ b/src/windows/connection/ObjectBrowser.tsx @@ -47,7 +47,16 @@ export function ObjectBrowser({ dbSessionId, databaseType, database }: ObjectBro const hasMetadata = obj.signature != null || obj.targetSchema != null || obj.targetName != null; if (!hasMetadata) { - return databaseCommands.getObjectDdl(dbSessionId, obj.kind, obj.name, obj.schema); + return databaseCommands.getObjectDdl( + dbSessionId, + obj.kind, + obj.name, + obj.schema, + null, + null, + null, + database, + ); } return databaseCommands.getObjectDdl( dbSessionId, @@ -57,9 +66,10 @@ export function ObjectBrowser({ dbSessionId, databaseType, database }: ObjectBro obj.signature, obj.targetSchema, obj.targetName, + database, ); }, - [dbSessionId], + [database, dbSessionId], ); const load = useCallback( @@ -67,7 +77,7 @@ export function ObjectBrowser({ dbSessionId, databaseType, database }: ObjectBro setLoading(true); setError(null); try { - const rows = await databaseCommands.getDatabaseObjects(dbSessionId, nextKind); + const rows = await databaseCommands.getDatabaseObjects(dbSessionId, nextKind, database); setObjects(rows); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -76,7 +86,7 @@ export function ObjectBrowser({ dbSessionId, databaseType, database }: ObjectBro setLoading(false); } }, - [dbSessionId], + [database, dbSessionId], ); useEffect(() => { diff --git a/src/windows/connection/PanelContentRenderer.tsx b/src/windows/connection/PanelContentRenderer.tsx index 398c2aa1b..189038bab 100644 --- a/src/windows/connection/PanelContentRenderer.tsx +++ b/src/windows/connection/PanelContentRenderer.tsx @@ -440,7 +440,11 @@ function SqlPanelContent({ { sqlDialect: 'mysql', defaultPort: 3306, clipboardSchemes: ['mysql'], + supportedObjectKinds: ['function', 'procedure', 'trigger', 'sequence', 'type'] as const, }, sqlite: { ...sqlMulti, @@ -1820,7 +1821,15 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { fireEvent.click(container.querySelector('[data-item-name="fn_calc"]')!); await waitFor(() => { - expect(openObject).toHaveBeenCalledWith('function', 'fn_calc', undefined); + expect(openObject).toHaveBeenCalledWith( + 'function', + 'fn_calc', + undefined, + undefined, + undefined, + undefined, + '/data/app.db', + ); }); fireEvent.click(categoryButton(container, 'trigger')); @@ -1829,7 +1838,15 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { }); fireEvent.click(container.querySelector('[data-item-name="tg_del"]')!); await waitFor(() => { - expect(openObject).toHaveBeenCalledWith('trigger', 'tg_del', undefined); + expect(openObject).toHaveBeenCalledWith( + 'trigger', + 'tg_del', + undefined, + undefined, + undefined, + undefined, + '/data/app.db', + ); }); fireEvent.click(categoryButton(container, 'sequence')); @@ -1886,9 +1903,25 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { }); const routines = container.querySelectorAll('[data-item-name="lookup"]'); fireEvent.click(routines[0]!); - expect(openObject).toHaveBeenCalledWith('function', 'lookup', 'public', 'integer'); + expect(openObject).toHaveBeenCalledWith( + 'function', + 'lookup', + 'public', + 'integer', + undefined, + undefined, + '/data/app.db', + ); fireEvent.click(routines[1]!); - expect(openObject).toHaveBeenCalledWith('function', 'lookup', 'public', 'text'); + expect(openObject).toHaveBeenCalledWith( + 'function', + 'lookup', + 'public', + 'text', + undefined, + undefined, + '/data/app.db', + ); fireEvent.click(categoryButton(container, 'trigger')); await waitFor(() => { @@ -1902,6 +1935,7 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { undefined, undefined, 'orders', + '/data/app.db', ); }); @@ -1968,7 +2002,7 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { mockGetTables.mockClear(); await openMenuAndPick(categoryButton(container, 'procedure'), 'refresh'); await waitFor(() => { - expect(mockGetDatabaseObjects).toHaveBeenCalledWith('conn-sql', 'procedure'); + expect(mockGetDatabaseObjects).toHaveBeenCalledWith('conn-sql', 'procedure', '/data/app.db'); }); expect(container.querySelector('[data-item-name="pr_y"]')).not.toBeNull(); @@ -2012,7 +2046,7 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { await triggerConnectionRefresh(findByText, 'SQLite Conn'); await waitFor(() => { - expect(mockGetDatabaseObjects).toHaveBeenCalledWith('conn-sql', 'procedure'); + expect(mockGetDatabaseObjects).toHaveBeenCalledWith('conn-sql', 'procedure', '/data/app.db'); }); await waitFor(() => { expect(container.querySelector('[data-item-name="pr_x"]')).not.toBeNull(); @@ -2040,7 +2074,7 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { await triggerDatabaseRefresh(findByText, '/data/app.db'); await waitFor(() => { - expect(mockGetDatabaseObjects).toHaveBeenCalledWith('conn-sql', 'function'); + expect(mockGetDatabaseObjects).toHaveBeenCalledWith('conn-sql', 'function', '/data/app.db'); }); await waitFor(() => { expect(container.querySelector('[data-item-name="fn_y"]')).not.toBeNull(); @@ -2100,7 +2134,7 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { mockGetDatabaseObjects.mockClear(); await openMenuAndPick(categoryButton(container, 'function'), 'refresh'); await waitFor(() => { - expect(mockGetDatabaseObjects).toHaveBeenCalledWith('conn-sql', 'function'); + expect(mockGetDatabaseObjects).toHaveBeenCalledWith('conn-sql', 'function', '/data/app.db'); }); await waitFor(() => { expect(container.querySelector('[data-item-name="fn_z"]')).not.toBeNull(); @@ -2369,6 +2403,60 @@ async function renderPgTree( } describe('ConnectionNavigatorTree multi-db tree variants', () => { + it('loads schema objects from the database node being expanded', async () => { + connectionsState.connections = [ + makeConn({ + id: 'cfg-mysql', + name: 'MySQL Conn', + databaseType: 'mysql', + }), + ]; + activeConnectionsState.connections = { + 'cfg-mysql': { status: 'connected', dbSessionId: 'conn-mysql', connectionId: 'cfg-mysql' }, + }; + mockGetDatabases.mockResolvedValue(['connection_default', 'manual_fixture_db']); + mockGetTables.mockResolvedValue([]); + mockGetDatabaseObjects.mockImplementation( + (_sessionId: string, kind: string, database: string) => + Promise.resolve( + kind === 'procedure' && database === 'manual_fixture_db' + ? [{ kind: 'procedure', schema: 'manual_fixture_db', name: 'pr_manual_fixture' }] + : [], + ), + ); + expect(connectionsState.connections[0]?.database).toBeUndefined(); + + const { container, findByText } = render( + , + ); + await waitFor(() => { + expect(useSchemaStore.getState().schemas.get('conn-mysql')?.databases).toEqual([ + 'connection_default', + 'manual_fixture_db', + ]); + }); + const targetDatabase = await findByText('manual_fixture_db'); + fireEvent.click(targetDatabase.closest('button')!); + + const procedureCategory = await waitFor(() => { + const row = container.querySelector( + '[data-cat-key="cfg-mysql::manual_fixture_db::procedure"]', + ); + expect(row).not.toBeNull(); + return row!; + }); + fireEvent.click(procedureCategory); + + await waitFor(() => { + expect(container.querySelector('[data-item-name="pr_manual_fixture"]')).not.toBeNull(); + }); + expect(mockGetDatabaseObjects).toHaveBeenCalledWith( + 'conn-mysql', + 'procedure', + 'manual_fixture_db', + ); + }); + it('keeps schema-specific object categories scoped to their owning schema', async () => { const { container } = await renderPgTree({}, [ { name: 't_public', tableType: 'table', schema: 'public' }, diff --git a/src/windows/connection/__tests__/ObjectBrowser.test.tsx b/src/windows/connection/__tests__/ObjectBrowser.test.tsx index 51eff56a1..8d6133fdc 100644 --- a/src/windows/connection/__tests__/ObjectBrowser.test.tsx +++ b/src/windows/connection/__tests__/ObjectBrowser.test.tsx @@ -72,11 +72,20 @@ describe('ObjectBrowser', () => { it('lists objects, opens DDL, and executes it', async () => { render(); await screen.findByText('fn_ok'); - expect(getDatabaseObjects).toHaveBeenCalledWith('c1', 'function'); + expect(getDatabaseObjects).toHaveBeenCalledWith('c1', 'function', 'db_a'); fireEvent.click(screen.getByText('fn_ok')); await waitFor(() => { - expect(getObjectDdl).toHaveBeenCalledWith('c1', 'function', 'fn_ok', 'public'); + expect(getObjectDdl).toHaveBeenCalledWith( + 'c1', + 'function', + 'fn_ok', + 'public', + null, + null, + null, + 'db_a', + ); }); expect(screen.getByDisplayValue(/CREATE FUNCTION/)).toBeInTheDocument(); @@ -102,7 +111,16 @@ describe('ObjectBrowser', () => { await screen.findByText('fn_ok'); fireEvent.click(screen.getByText('fn_ok')); await waitFor(() => { - expect(getObjectDdl).toHaveBeenCalledWith('c1', 'function', 'fn_ok', 'public'); + expect(getObjectDdl).toHaveBeenCalledWith( + 'c1', + 'function', + 'fn_ok', + 'public', + null, + null, + null, + 'goecoride', + ); }); fireEvent.click(screen.getByText('query.execute')); @@ -146,11 +164,20 @@ describe('ObjectBrowser', () => { fireEvent.click(screen.getByTestId('object-browser-type')); const type = await screen.findByText('mood'); - expect(getDatabaseObjects).toHaveBeenLastCalledWith('c1', 'type'); + expect(getDatabaseObjects).toHaveBeenLastCalledWith('c1', 'type', 'db_a'); fireEvent.click(type); await waitFor(() => { - expect(getObjectDdl).toHaveBeenCalledWith('c1', 'type', 'mood', 'public'); + expect(getObjectDdl).toHaveBeenCalledWith( + 'c1', + 'type', + 'mood', + 'public', + null, + null, + null, + 'db_a', + ); }); expect(screen.getByDisplayValue(/CREATE TYPE public\.mood/)).toBeInTheDocument(); }); @@ -181,6 +208,7 @@ describe('ObjectBrowser', () => { 'integer', undefined, undefined, + 'db_a', ); }); @@ -205,6 +233,7 @@ describe('ObjectBrowser', () => { undefined, null, 'orders', + 'db_a', ); }); }); @@ -266,6 +295,7 @@ describe('ObjectBrowser', () => { 'text', undefined, undefined, + 'db_a', ); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('lookup(text)')); }); diff --git a/src/windows/connection/__tests__/navigatorTreeRowAriaEquivalence.test.ts b/src/windows/connection/__tests__/navigatorTreeRowAriaEquivalence.test.ts index 09f36dc26..2d54e114f 100644 --- a/src/windows/connection/__tests__/navigatorTreeRowAriaEquivalence.test.ts +++ b/src/windows/connection/__tests__/navigatorTreeRowAriaEquivalence.test.ts @@ -152,6 +152,7 @@ const VARIANTS: UnifiedRow[] = [ { type: 'category', key: 'c1::db1::public::tables', + dbName: 'db1', cat: { id: 'tables' } as never, count: 1, expanded: true, diff --git a/src/windows/connection/__tests__/usePanelHandlers.test.tsx b/src/windows/connection/__tests__/usePanelHandlers.test.tsx index 6a8f0c61c..88f902a16 100644 --- a/src/windows/connection/__tests__/usePanelHandlers.test.tsx +++ b/src/windows/connection/__tests__/usePanelHandlers.test.tsx @@ -156,21 +156,39 @@ describe('usePanelHandlers.handleNewQuery binds a database to the query tab', () 'public', 'users', ); + result.current.handleOpenDbObject( + 'function', + 'lookup', + 'public', + 'integer', + undefined, + undefined, + 'db_b', + ); }); const panels = usePanelStore.getState().panels.filter((panel) => panel.type === 'db-object'); - expect(panels).toHaveLength(4); + expect(panels).toHaveLength(5); + expect(panels.map((panel) => panel.type === 'db-object' && panel.objectDatabase)).toEqual([ + 'db_a', + 'db_a', + 'db_a', + 'db_a', + 'db_b', + ]); expect(panels.map((panel) => panel.type === 'db-object' && panel.objectSignature)).toEqual([ 'integer', 'text', null, null, + 'integer', ]); expect(panels.map((panel) => panel.type === 'db-object' && panel.objectTargetName)).toEqual([ null, null, 'orders', 'users', + null, ]); }); }); diff --git a/src/windows/connection/navigator/NavigatorTreeRow.tsx b/src/windows/connection/navigator/NavigatorTreeRow.tsx index 198c04b29..80eb35693 100644 --- a/src/windows/connection/navigator/NavigatorTreeRow.tsx +++ b/src/windows/connection/navigator/NavigatorTreeRow.tsx @@ -54,7 +54,7 @@ export interface NavigatorTreeRowProps { toggleConnection: (connectionId: string, sectionGroup: string) => void; toggleDb: (connectionId: string, dbSessionId: string, dbName: string) => void; toggleSchema: (schemaKey: string) => void; - toggleCategory: (catKey: string, catId: string, dbSessionId: string) => void; + toggleCategory: (catKey: string, catId: string, dbSessionId: string, dbName: string) => void; activateDatabase: (dbSessionId: string, dbName: string) => Promise; ensureNamespacePath: (segments: string[], dbSessionId: string) => Promise; setExpandedDbs: React.Dispatch>>; @@ -115,6 +115,7 @@ export interface NavigatorTreeRowProps { signature?: string, targetSchema?: string, targetName?: string, + database?: string, ) => void; }; } @@ -405,7 +406,7 @@ export function NavigatorTreeRow({ onClick={() => { const conn = connections.find((c) => c.id === catConnectionId); const dbSessionId = conn ? (activeConnections[conn.id]?.dbSessionId ?? '') : ''; - void toggleCategory(row.key, row.cat.id, dbSessionId); + void toggleCategory(row.key, row.cat.id, dbSessionId, row.dbName); }} onContextMenu={(e) => handleCategoryContextMenu(e, row.key, row.cat.id, catConnectionId)} > @@ -519,6 +520,7 @@ export function NavigatorTreeRow({ signature, targetSchema, targetName, + row.dbName, ); } else if (hasSignature) { viewActions?.openObject?.( @@ -526,9 +528,20 @@ export function NavigatorTreeRow({ row.obj.name, row.obj.schema ?? undefined, signature, + undefined, + undefined, + row.dbName, ); } else { - viewActions?.openObject?.(kind, row.obj.name, row.obj.schema ?? undefined); + viewActions?.openObject?.( + kind, + row.obj.name, + row.obj.schema ?? undefined, + undefined, + undefined, + undefined, + row.dbName, + ); } } }} diff --git a/src/windows/connection/navigator/buildFlatRows.ts b/src/windows/connection/navigator/buildFlatRows.ts index d151e5ff4..c4932086b 100644 --- a/src/windows/connection/navigator/buildFlatRows.ts +++ b/src/windows/connection/navigator/buildFlatRows.ts @@ -164,6 +164,7 @@ export function buildNavigatorFlatRows(params: BuildNavigatorFlatRowsParams): Un rows.push({ type: 'category', key: catKey, + dbName, cat, count, expanded: isExpanded, diff --git a/src/windows/connection/navigator/types.ts b/src/windows/connection/navigator/types.ts index f0334cc15..5415d24fa 100644 --- a/src/windows/connection/navigator/types.ts +++ b/src/windows/connection/navigator/types.ts @@ -62,6 +62,7 @@ type DepthBearing = | { type: 'category'; key: string; + dbName: string; cat: SchemaTreeCategoryDef; count: number; expanded: boolean; @@ -214,6 +215,7 @@ export interface ConnectionNavigatorTreeProps { signature?: string, targetSchema?: string, targetName?: string, + database?: string, ) => void; openQueryHistory?: () => void; openServerStatus?: (ctx?: ConnectionOpenTarget) => void; diff --git a/src/windows/connection/navigator/useNavigatorContextMenus.ts b/src/windows/connection/navigator/useNavigatorContextMenus.ts index f06f41f40..8fc6e4350 100644 --- a/src/windows/connection/navigator/useNavigatorContextMenus.ts +++ b/src/windows/connection/navigator/useNavigatorContextMenus.ts @@ -74,7 +74,12 @@ export interface NavigatorContextMenuDeps { refreshDatabase: (connectionId: string, dbName: string) => Promise; refreshSchema: (connectionId: string, dbName: string, schemaName: string) => Promise; reloadDbTables: (dbSessionId: string, dbName: string) => Promise; - reloadDbObjectCategory: (dbSessionId: string, catKey: string, catId: string) => Promise; + reloadDbObjectCategory: ( + dbSessionId: string, + catKey: string, + catId: string, + dbName: string, + ) => Promise; loadForConnection: ( dbSessionId: string, opts: { @@ -794,7 +799,16 @@ export function useNavigatorContextMenus(deps: NavigatorContextMenuDeps) { try { let ddl = ''; try { - ddl = await databaseCommands.getObjectDdl(dbSessionId, 'table', name, schema); + ddl = await databaseCommands.getObjectDdl( + dbSessionId, + 'table', + name, + schema, + null, + null, + null, + dbName, + ); } catch { const dialect = getSqlDialect(conn?.databaseType ?? 'postgresql'); if (dialect) { @@ -1031,7 +1045,7 @@ export function useNavigatorContextMenus(deps: NavigatorContextMenuDeps) { }); } } else { - void reloadDbObjectCategory(entry.dbSessionId, catKey, catId); + void reloadDbObjectCategory(entry.dbSessionId, catKey, catId, dbName); } }, onNewTable: diff --git a/src/windows/connection/navigator/useNavigatorDbState.ts b/src/windows/connection/navigator/useNavigatorDbState.ts index 0907e3efa..5ce6d30c7 100644 --- a/src/windows/connection/navigator/useNavigatorDbState.ts +++ b/src/windows/connection/navigator/useNavigatorDbState.ts @@ -9,6 +9,25 @@ import type { ConnectionEntry } from '../../../stores/activeConnectionStore'; import { shouldUseMultiDatabaseTree } from './utils'; import { useExpandedDbCacheRefresh } from '../schema-tree/useExpandedDbCacheRefresh'; +function databaseNameForCategory( + connectionId: string, + catKey: string, + expandedDbs: ReadonlySet, + knownDatabases: readonly string[], +): string | undefined { + const prefix = `${connectionId}::`; + const databaseNames = new Set([ + ...knownDatabases, + ...[...expandedDbs] + .filter((dbKey) => dbKey.startsWith(prefix)) + .map((dbKey) => dbKey.slice(prefix.length)), + ]); + const match = [...databaseNames] + .filter((database) => catKey.startsWith(`${prefix}${database}::`)) + .sort((left, right) => right.length - left.length)[0]; + return match; +} + export function useNavigatorDbState( activeConnections: Record, connections: ConnectionConfig[], @@ -113,10 +132,10 @@ export function useNavigatorDbState( ); const reloadDbObjectCategory = useCallback( - async (dbSessionId: string, catKey: string, catId: string) => { + async (dbSessionId: string, catKey: string, catId: string, dbName: string) => { if (catId === 'tables' || catId === 'views') return; try { - const objs = await databaseCommands.getDatabaseObjects(dbSessionId, catId); + const objs = await databaseCommands.getDatabaseObjects(dbSessionId, catId, dbName); setDbObjectsMap((prev) => ({ ...prev, [catKey]: objs })); } catch { setDbObjectsMap((prev) => ({ ...prev, [catKey]: [] })); @@ -157,10 +176,13 @@ export function useNavigatorDbState( if (!catKey.startsWith(`${connectionId}::`)) continue; const catId = catKey.split('::').pop(); if (!catId || catId === 'tables' || catId === 'views') continue; - await reloadDbObjectCategory(dbSessionId, catKey, catId); + const knownDatabases = useSchemaStore.getState().schemas.get(dbSessionId)?.databases ?? []; + const dbName = databaseNameForCategory(connectionId, catKey, expandedDbs, knownDatabases); + if (!dbName) continue; + await reloadDbObjectCategory(dbSessionId, catKey, catId, dbName); } }, - [expandedCats, reloadDbObjectCategory], + [expandedCats, expandedDbs, reloadDbObjectCategory], ); const refreshConnection = useCallback( @@ -254,7 +276,7 @@ export function useNavigatorDbState( if (!catKey.startsWith(prefix)) continue; const catId = catKey.split('::').pop(); if (!catId || catId === 'tables' || catId === 'views') continue; - await reloadDbObjectCategory(entry.dbSessionId, catKey, catId); + await reloadDbObjectCategory(entry.dbSessionId, catKey, catId, dbName); } }, [ @@ -280,7 +302,7 @@ export function useNavigatorDbState( if (!catKey.startsWith(prefix)) continue; const catId = catKey.split('::').pop(); if (!catId || catId === 'tables' || catId === 'views') continue; - await reloadDbObjectCategory(entry.dbSessionId, catKey, catId); + await reloadDbObjectCategory(entry.dbSessionId, catKey, catId, dbName); } }, [activeConnections, expandedCats, reloadDbObjectCategory, reloadDbTables], @@ -323,12 +345,12 @@ export function useNavigatorDbState( ); const toggleCategoryLoad = useCallback( - async (catKey: string, catId: string, dbSessionId: string) => { + async (catKey: string, catId: string, dbSessionId: string, dbName: string) => { if (catId === 'tables' || catId === 'views') return; if (dbObjectsMap[catKey]) return; try { - const objs = await databaseCommands.getDatabaseObjects(dbSessionId, catId); + const objs = await databaseCommands.getDatabaseObjects(dbSessionId, catId, dbName); setDbObjectsMap((prev) => ({ ...prev, [catKey]: objs })); } catch { setDbObjectsMap((prev) => ({ ...prev, [catKey]: [] })); diff --git a/src/windows/connection/schema-tree/__tests__/useExpandedDbCacheRefresh.test.tsx b/src/windows/connection/schema-tree/__tests__/useExpandedDbCacheRefresh.test.tsx index 5e98d054e..1d0c5c11a 100644 --- a/src/windows/connection/schema-tree/__tests__/useExpandedDbCacheRefresh.test.tsx +++ b/src/windows/connection/schema-tree/__tests__/useExpandedDbCacheRefresh.test.tsx @@ -148,21 +148,25 @@ describe('useExpandedDbCacheRefresh', () => { 'conn-sql', 'cfg-sql::/data/app.db::procedure', 'procedure', + '/data/app.db', ); expect(handlers.loadObjectsForCat).toHaveBeenCalledWith( 'conn-sql', 'cfg-sql::/data/app.db::main::function', 'function', + '/data/app.db', ); expect(handlers.loadObjectsForCat).not.toHaveBeenCalledWith( 'conn-sql', 'cfg-sql::/data/app.db::tables', 'tables', + '/data/app.db', ); expect(handlers.loadObjectsForCat).not.toHaveBeenCalledWith( 'conn-sql', 'cfg-other::/data/other.db::procedure', 'procedure', + '/data/other.db', ); expect(handlers.loadTablesForDb).toHaveBeenCalledWith('conn-sql', '/data/app.db'); @@ -179,6 +183,33 @@ describe('useExpandedDbCacheRefresh', () => { } }); + it('routes an expanded category by its known database even when that db row is collapsed', async () => { + useSchemaStore.setState((s) => ({ + schemas: new Map(s.schemas).set('conn-1', schemaEntry(['db::with-delimiter'], 0)), + })); + const handlers = makeHandlers(); + const opts: ExpandedDbCacheRefreshOptions = { + ...baseOpts(handlers), + expandedDbs: new Set(), + expandedCats: new Set(['cfg-1::db::with-delimiter::app::procedure']), + }; + const { rerun } = renderHookWithDeps(opts); + + useSchemaStore.setState((s) => ({ + schemas: new Map(s.schemas).set('conn-1', schemaEntry(['db::with-delimiter'], 1)), + })); + rerun(); + + await vi.waitFor(() => { + expect(handlers.loadObjectsForCat).toHaveBeenCalledWith( + 'conn-1', + 'cfg-1::db::with-delimiter::app::procedure', + 'procedure', + 'db::with-delimiter', + ); + }); + }); + function renderHookWithDeps(opts: ExpandedDbCacheRefreshOptions) { const view = renderHook((p: ExpandedDbCacheRefreshOptions) => useExpandedDbCacheRefresh(p), { initialProps: opts, diff --git a/src/windows/connection/schema-tree/useExpandedDbCacheRefresh.ts b/src/windows/connection/schema-tree/useExpandedDbCacheRefresh.ts index 92bbad745..1b8aabb16 100644 --- a/src/windows/connection/schema-tree/useExpandedDbCacheRefresh.ts +++ b/src/windows/connection/schema-tree/useExpandedDbCacheRefresh.ts @@ -40,7 +40,12 @@ export interface ExpandedDbCacheRefreshOptions { * Reload one object category into the caller's cache. `catId` is the trailing * segment of `catKey`. Must be session-neutral (no useDatabase). */ - loadObjectsForCat: (dbSessionId: string, catKey: string, catId: string) => Promise; + loadObjectsForCat: ( + dbSessionId: string, + catKey: string, + catId: string, + dbName: string, + ) => Promise; /** * Drop every cached entry belonging to the given runtime db session id. * `connectionId` is the persistent config id the session belongs to — table @@ -120,7 +125,18 @@ export function useExpandedDbCacheRefresh({ if (!catKey.startsWith(prefix)) continue; const catId = catKey.split('::').pop(); if (!catId || catId === 'tables' || catId === 'views') continue; - void handlersRef.current.loadObjectsForCat(dbSessionId, catKey, catId); + const knownDatabases = schemas.get(dbSessionId)?.databases ?? []; + const databaseNames = new Set([ + ...knownDatabases, + ...[...expandedDbs] + .filter((key) => key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)), + ]); + const database = [...databaseNames] + .filter((name) => catKey.startsWith(`${prefix}${name}::`)) + .sort((left, right) => right.length - left.length)[0]; + if (!database) continue; + void handlersRef.current.loadObjectsForCat(dbSessionId, catKey, catId, database); } } // Re-run only when the store surface or the expanded sets change; the diff --git a/src/windows/connection/usePanelHandlers.ts b/src/windows/connection/usePanelHandlers.ts index 126dec353..92cb2d3cc 100644 --- a/src/windows/connection/usePanelHandlers.ts +++ b/src/windows/connection/usePanelHandlers.ts @@ -50,6 +50,7 @@ export interface PanelHandlers { signature?: string, targetSchema?: string, targetName?: string, + database?: string, ) => void; handleOpenPrivileges: () => void; handleOpenServerStatus: (ctx?: ConnectionOpenTarget) => void; @@ -376,8 +377,10 @@ export function usePanelHandlers({ signature?: string, targetSchema?: string, targetName?: string, + database?: string, ) => { if (!sidebarConnCtx) return; + const targetDatabase = database ?? currentDatabase ?? initialDatabase ?? ''; const existing = connPanels.find( (p) => p.type === 'db-object' && @@ -386,7 +389,8 @@ export function usePanelHandlers({ (p as DatabaseObjectPanel).objectSchema === (schema ?? null) && (p as DatabaseObjectPanel).objectSignature === (signature ?? null) && (p as DatabaseObjectPanel).objectTargetSchema === (targetSchema ?? null) && - (p as DatabaseObjectPanel).objectTargetName === (targetName ?? null), + (p as DatabaseObjectPanel).objectTargetName === (targetName ?? null) && + (p as DatabaseObjectPanel).objectDatabase === targetDatabase, ); if (existing) { setActivePanel(existing.id); @@ -399,13 +403,14 @@ export function usePanelHandlers({ objectKind: kind, objectName: name, objectSchema: schema ?? null, + objectDatabase: targetDatabase, objectSignature: signature ?? null, objectTargetSchema: targetSchema ?? null, objectTargetName: targetName ?? null, }; addPanel(panel); }, - [sidebarConnCtx, connPanels, addPanel, setActivePanel], + [sidebarConnCtx, connPanels, currentDatabase, initialDatabase, addPanel, setActivePanel], ); const handleOpenPrivileges = useCallback(() => { From 5956d1b5447189325701214f3ad74e3a562226a7 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 20:43:47 +0800 Subject: [PATCH 050/666] docs: add platform and connection architecture --- AGENTS.md | 1 + docs/README.md | 8 + docs/architecture/README.md | 12 +- .../platform/connection-management.md | 1248 +++++++++++++++++ docs/architecture/platform/system-overview.md | 315 +++++ docs/development/platform-development-plan.md | 303 ++++ 6 files changed, 1885 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/platform/connection-management.md create mode 100644 docs/architecture/platform/system-overview.md create mode 100644 docs/development/platform-development-plan.md diff --git a/AGENTS.md b/AGENTS.md index 83b3ac519..dff1e0e6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,7 @@ cargo test -p datazen-driver-postgres # 示例:某个驱动 crate 的 Rust - 仓库只维护三类文档:功能使用(`docs/features/`)、代码架构(`docs/architecture/`)、开发流程(`docs/development/`)。索引见 [docs/README.md](docs/README.md)。 - **不新增** PRD、设计稿、原型图、实施规范、开发进度、Bug List、评审记录类文档;这些是一次性过程产物,已交付即失效。 +- 用户明确要求并已入库的 [桌面 / Web 平台演进设计](docs/README.md) 是本纪律的窄范围例外;其中标为「目标设计」的概要、详细设计和开发计划只在该明确授权范围内维护,落地后逐节改写为已实现事实。该例外不授权新增其他方案、评审记录或进度文档。 - 需要长期留存的设计结论,**改写为「已实现」的事实**并入对应架构文档,而不是保留方案原文。 - 引用文件路径、命令、IPC 名称、驱动数量等事实前先核对代码;删除或重构代码时同步删除失效引用。 - 写完文档自查链接可达:`pnpm exec` 下的脚本与 CI 不会校验 Markdown 链接,失效引用只能靠人工/代理检查。 diff --git a/docs/README.md b/docs/README.md index 0f822424d..f2c447ad7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,6 +63,14 @@ DataZen 文档只保留两类长期有效内容:**当前功能使用文档** - [Windows](architecture/windows.md) - [Testing](architecture/testing.md) +### 桌面 / Web 平台演进设计(待实现) + +以下三份文档按用户明确要求保存,是本次设计交付的范围例外。它们标明基线和目标状态,不代表当前 main 已支持团队 Web;落地后应改写为实现事实与开发流程。 + +- [系统概要设计](architecture/platform/system-overview.md):三种运行方式、模块职责、交互与接口。 +- [连接管理详细设计](architecture/platform/connection-management.md):数据契约、状态机、算法、验收标准与 73 个测试用例。 +- [分阶段开发计划](development/platform-development-plan.md):连接重构、多应用形态、团队服务和多 worker 的依赖与交付门槛。 + ## 开发与发布 - [E2E Testing](development/e2e-testing.md) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 76664aed4..1e3a88262 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,6 +1,6 @@ # DataZen 系统架构 -> 本文档描述 **main 分支当前实现**,不是未来版本规划。具体实现以 `src-tauri/`、`packages/driver-api/` 和 `src/` 为准。 +> 本文正文描述 **main 分支当前实现**。下方单独标明的 `platform/` 设计是用户明确要求保存的待实现交付,不属于当前功能;具体实现以代码为准。 ## 架构文档索引 @@ -36,7 +36,15 @@ - [Windows](windows.md) - [Testing](testing.md) -> 本目录只描述 **已落地** 的实现。方案草稿、RFC 与实施计划不长期留在 `architecture/`: +### 平台演进目标设计(待实现) + +- [桌面 / Web 系统概要设计](platform/system-overview.md) +- [连接管理详细设计与验收用例](platform/connection-management.md) +- [分阶段开发计划](../development/platform-development-plan.md) + +这三份文档是本次明确请求的范围例外,状态与当前实现分开标注;实施后逐节改写为事实,不扩展为通用提案/台账目录。 + +> 除上述明确请求的待实现设计外,本目录只描述 **已落地** 的实现。方案草稿、RFC 与实施计划不长期留在 `architecture/`: > 结论落地后改写为本目录的事实条目,未落地的提案不入库。演进方向的公开叙述见 [blogs/](../blogs/README.md)。 ## 1. 总体结构 diff --git a/docs/architecture/platform/connection-management.md b/docs/architecture/platform/connection-management.md new file mode 100644 index 000000000..56e64f6b4 --- /dev/null +++ b/docs/architecture/platform/connection-management.md @@ -0,0 +1,1248 @@ +# DataZen 连接管理详细设计 + +> 状态:待实现的开发契约。基线:2026-09-30,`8592b0fe1`。本次文档交付不代表已完成重构。 +> 读者:第一次参与 DataZen 的开发者。按“类型 → fake driver → 资源 → 会话 → 网关 → UI → Job → Web”顺序实现,不从修改 UI 当前库开始。 +> 配套:[系统概要](system-overview.md)、[开发计划](../../development/platform-development-plan.md)。 + +## 1. 开发前先理解的六个对象 + +| 对象 | 保存什么 | 谁拥有 | 是否落盘 | +| --- | --- | --- | --- | +| ConnectionProfile | 地址、驱动配置、初始目标、secretRef、版本 | 组织/本地用户 | 是 | +| ExecutionTarget | connectionId + 命名空间 + 可选对象 | 用例/任务 | 是 | +| DbSession | 连续上下文、事务状态、资源绑定 | 编辑器/显式会话块/客户端 | 否 | +| ResourceLease | 对物理资源的独占、预算与清理 | Session 或 Job 阶段或一次操作 | 否 | +| Execution | 一次执行、取消句柄、来源和结果 | 请求调用者/Job | 执行记录可落盘 | +| Job | 计划、阶段、提交边界、恢复条件 | 后端 JobRuntime | 是 | + +`connectionId` 不能作为 session key。`dbSessionId` 不能写进 profile、Workflow 定义或恢复检查点。共享配置不共享事务。tab 只是视图,连接使用范围由操作语义决定。 + +## 2. 当前代码定位与替换规则 + +| 当前文件 | 阅读重点 | 新实现中的去向 | +| --- | --- | --- | +| [connection_manager.rs](../../../src-tauri/src/services/connection_manager.rs) | ActiveSession、owner map、refs | SessionRegistry + ResourceManager | +| [connections.rs](../../../src-tauri/src/services/connection_manager/connections.rs) | 配置、隧道、driver.connect | 版本化配置解析 + 受预算约束建连 | +| [sessions.rs](../../../src-tauri/src/services/connection_manager/sessions.rs) | 按配置复用、release、reconnect | 明确 owner 的 session API、显式失效 | +| [driver_command](../../../src-tauri/src/commands/driver_command/mod.rs) | Command 网关 | adapter 进入统一 ExecutionGateway | +| [transaction.rs](../../../src-tauri/src/services/transaction.rs) | DDL 原子性与事务句柄 | 在同一 Lease 上管理事务;回滚失败必须上报 | +| [driver traits](../../../packages/driver-api/src/traits.rs) | connect、query、Command、事务 | 保留领域能力,补 opaque 固定资源契约 | +| [activeConnectionStore](../../../src/stores/activeConnectionStore.ts) | connectionId 索引的连接状态 | 连接可达性与编辑器 session 状态分开 | +| [QuerySidebarSection](../../../src/windows/connection/query/QuerySidebarSection.tsx) | SQL 推测上下文 | 仅做补全/浏览推测,实际上下文由后端更新 | +| [dedicatedDbSession](../../../src/lib/dedicatedDbSession.ts) | 三件套窗口准备/释放 | 明确目标,执行 Lease 归 Job | +| [workflow command_runtime](../../../src-tauri/src/workflow/command_runtime.rs) | 默认目标与 Command | 按 step/block 范围申请资源 | + +旧 `ConnectionHandle` 有 `id` 与 `pool_id`,不能从名字推断固定物理连接。先做 driver 契约测试,再将 consumer 移到新接口。旧 API 过渡适配禁止把未知连接假称为有状态 session。 + +## 3. 不可破坏的不变量 + +| 编号 | 必须成立 | +| --- | --- | +| INV-01 | 身份来自 adapter,组织与 owner 不由请求体授予 | +| INV-02 | 同一固定 Lease 的物理会话在整个范围内连续,driver 不内部换连接 | +| INV-03 | 同一 session 最多一个普通执行;取消使用独立控制路径 | +| INV-04 | 实际上下文由 driver 确认;SQL 文本推断不能修改 observedContext | +| INV-05 | target 操作不改变任何其他 session 的默认库或事务 | +| INV-06 | 未消费完结果、仍执行、回滚失败或状态未知的资源不归池 | +| INV-07 | runtimeEpoch/dbSessionId 不匹配的执行与取消被拒绝 | +| INV-08 | 物理会话失效后返回 SessionLost,禁止透明重建;显式新建会话必须使用新 ID | +| INV-09 | 所有实际连接占用预算,包含 idle、cluster 和 cancel 连接 | +| INV-10 | lease 清理/关闭幂等,预算只释放一次 | +| INV-11 | 结果操作使用执行时来源,不用当前 UI 数据库 | +| INV-12 | Job 独立于窗口;提交未知不自动重试 | + +## 4. DTO 与字段定义 + +本节给出可直接转为 TypeScript 契约的定义;Rust 使用 serde 的 camelCase 映射。所有 string ID 在 Rust 内部使用不同 newtype,禁止互换。64 位计数在 JSON 中编码为十进制字符串,避免 JavaScript 精度丢失。 + +```typescript +type Id = string; +type Counter = string; +type Timestamp = string; +type Availability = 'supported' | 'unsupported' | 'unknown'; + +interface NamespaceTarget { + database: string | null; + catalog: string | null; + schema: string | null; + path: readonly string[]; +} + +interface ObjectTarget { + kind: string; + name: string; + signature: string | null; +} + +interface ExecutionTarget { + connectionId: Id; + namespace: NamespaceTarget; + object: ObjectTarget | null; +} + +type OwnerRef = + | { kind: 'editor'; clientInstanceId: Id; editorSessionId: Id } + | { kind: 'job'; jobId: Id; stageId: Id } + | { kind: 'workflowBlock'; jobId: Id; blockId: Id } + | { kind: 'clientSession'; clientInstanceId: Id; purpose: string }; + +interface SessionHandle { + dbSessionId: Id; + runtimeEpoch: Id; +} + +type TransactionState = + | 'none' + | 'active' + | 'aborted' + | 'unknown' + | 'unsupported'; + +interface SessionContext { + namespace: NamespaceTarget; + searchPath: readonly string[] | null; + effectiveIdentity: string | null; + transactionState: TransactionState; + autocommit: boolean | null; + confidence: 'confirmed' | 'partial' | 'unknown'; +} + +type AttachmentState = 'attached' | 'detached' | 'expired'; + +type SessionState = + | 'new' + | 'opening' + | 'ready' + | 'executing' + | 'reconfiguring' + | 'closing' + | 'closed' + | 'lost'; + +interface SessionView { + handle: SessionHandle; + connectionId: Id; + configRevision: Counter; + owner: OwnerRef; + initialTarget: ExecutionTarget; + observedContext: SessionContext; + contextRevision: Counter; + state: SessionState; + attachmentState: AttachmentState; + activeExecutionId: Id | null; + expiresAt: Timestamp | null; +} + +interface OpenSessionRequest { + initialTarget: ExecutionTarget; + owner: OwnerRef; + idempotencyKey: string; +} + +interface CommandCall { + command: string; + input: unknown; +} + +interface ExecuteInSessionRequest { + handle: SessionHandle; + expectedContextRevision: Counter | null; + call: CommandCall; + idempotencyKey: string; +} + +interface ExecuteAtTargetRequest { + target: ExecutionTarget; + expectedConfigRevision: Counter; + call: CommandCall; + idempotencyKey: string; +} + +interface SetSessionContextRequest { + handle: SessionHandle; + expectedContextRevision: Counter; + desired: NamespaceTarget; + idempotencyKey: string; +} + +type CloseMode = 'requireNoTransaction' | 'rollbackAndClose'; + +interface CloseSessionRequest { + handle: SessionHandle; + mode: CloseMode; +} + +type EffectOutcome = + | 'notStarted' + | 'completed' + | 'rolledBack' + | 'partiallyApplied' + | 'unknown'; + +type ExecutionState = + | 'queued' + | 'running' + | 'cancelRequested' + | 'succeeded' + | 'failed' + | 'cancelled'; + +interface ExecutionReceipt { + executionId: Id; + streamId: Id; + state: ExecutionState; +} + +interface ResultProvenance { + organizationId: Id; + principalId: Id; + connectionId: Id; + configRevision: Counter; + contextBefore: SessionContext; + contextAfter: SessionContext; + requestedTarget: ExecutionTarget; + capabilitySnapshot: CapabilitySnapshot; + executedAt: Timestamp; +} + +interface CapabilitySnapshot { + driverId: string; + driverVersion: string; + protocolVersion: number; + capabilityRevision: Counter; + confirmed: Readonly>; +} + +interface RuntimeResultBinding { + handle: SessionHandle; + resourceBindingId: Id; + executionId: Id; +} + +interface ExecutionView { + executionId: Id; + state: ExecutionState; + effectOutcome: EffectOutcome; + provenance: ResultProvenance | null; + artifactIds: readonly Id[]; + resultCompleteness: 'pending' | 'complete' | 'truncated'; + truncationReason: string | null; + errorCode: string | null; + runtimeBinding: RuntimeResultBinding | null; +} + +interface EventEnvelope { + streamId: Id; + sequence: Counter; + runtimeEpoch: Id; + executionId: Id | null; + jobId: Id | null; + sessionHandle: SessionHandle | null; + contextRevision: Counter | null; + payload: T; +} +``` + +字段校验:所有 NamespaceTarget 字段必传,空字符串非法;层级含义与 `null` 规则统一见 4.3。对象操作必须传完整身份,不能把未指定 schema 猜成 public/dbo。`path` 是驱动命名空间 ID,不是文件路径;重复表达同一层级必须一致,否则 TargetConflict。 + +OwnerRef 是请求的归属意图。后端确认 editor 属于当前 client,job/block 属于已授权 Job;不能允许用户声称任意 job owner。组织和用户绑定存于服务端记录。 + +客户端另有 `{ backendId, handle }` 包装。后台数据库执行身份为服务端生成的不可伪造 `executionIdentityKey`;PoolKey 使用该 key,不使用客户端传入用户名。 + +### 4.1 服务接口与补充响应 + +以下声明与上节类型一起构成连接模块的客户端接口,不依赖 Tauri Channel。流传输适配为 AsyncIterable;客户端结束迭代只取消订阅,用户主动取消 SQL 必须调用 cancelExecution;runtime 可按 7.7 的明确放弃消费政策超期取消。 + +```typescript +interface ProfileView { + connectionId: Id; + name: string; + driverId: string; + configRevision: Counter; + credentialRevision: Counter; + initialNamespace: NamespaceTarget; + publicOptions: Readonly>; + credentialConfigured: boolean; + enabled: boolean; +} + +interface ContextChangeReceipt { + session: SessionView; + replacedSessionId: Id | null; + attachmentToken: string | null; +} + +interface CloseReceipt { + dbSessionId: Id; + state: 'closing' | 'closed' | 'lost'; + effectOutcome: EffectOutcome; + resourceRelease: 'pending' | 'confirmed' | 'quarantined'; +} + +interface CancelReceipt { + executionId: Id; + disposition: 'requested' | 'unsupported' | 'alreadyFinished'; + state: ExecutionState; +} + +interface StatementResultSource { + executionId: Id; + statementIndex: number; + context: SessionContext; + relation: ExecutionTarget | null; + writableMapping: 'verified' | 'readOnly'; +} + +type ConnectionEvent = + | { kind: 'sessionChanged'; session: SessionView } + | { kind: 'executionChanged'; execution: ExecutionView } + | { kind: 'resultChunk'; artifactId: Id; chunkIndex: Counter; source: StatementResultSource } + | { kind: 'streamResetRequired'; reason: string }; + +type IdempotentOperation = 'openSession' | 'executeInSession' | 'executeAtTarget' | 'setSessionContext' | 'startJob'; + +interface SubmissionToken { + idempotencyKey: string; + expiresAt: Timestamp; +} + +interface OpenSessionReceipt { + session: SessionView; + attachmentToken: string; +} + +interface AttachmentRequest { + handle: SessionHandle; + attachmentToken: string; +} + +interface ConnectionService { + listConnections(): Promise; + issueSubmissionToken(operation: IdempotentOperation, handle: SessionHandle | null): Promise; + openSession(request: OpenSessionRequest): Promise; + attachSession(request: AttachmentRequest): Promise; + detachSession(request: AttachmentRequest): Promise; + getSession(handle: SessionHandle): Promise; + executeInSession(request: ExecuteInSessionRequest): Promise; + executeAtTarget(request: ExecuteAtTargetRequest): Promise; + setSessionContext(request: SetSessionContextRequest): Promise; + closeSession(request: CloseSessionRequest): Promise; + getExecution(executionId: Id): Promise; + cancelExecution(executionId: Id): Promise; + subscribeEvents(streamId: Id, afterSequence: Counter | null): AsyncIterable>; +} +``` + +内部 ConnectionProfile 的完整字段为:organizationId、connectionId、name、driverId、configRevision、initialNamespace、driverOptions、secretRef、credentialRevision、networkRouteRef/revision、readOnly、policyRef、enabled、createdAt、updatedAt。driverOptions 按 schema 分离敏感字段,秘密内容存 SecretProvider。ProfileView.publicOptions 只含可见且非敏感字段;列表不返回 password、token、TLS private key 或可直接解密材料。 + +执行来源分两层:ExecutionView 的 provenance 记录整个调用前后状态;每个 StatementResultSource 记录该结果产生时的上下文。同一脚本 A→B 的两个结果不能都贴最终 B。driver 无法观察批次内准确上下文时返回 partial/unknown 且 readOnly,不猜测 relation 或提供可写映射。 + +### 4.2 内部记录 + +| 记录 | 完整必要字段 | +| --- | --- | +| SessionRecord | SessionView、organizationId、principalId、workerId、executionIdentityKey、credentialRevision、actor queue、leaseId、lastBusinessActivity、attachmentState、attachmentTokenHash、detachedAt、resourceBindingId、单调 deadline | +| LeaseRecord | leaseId、providerId、opaqueResource、owner、scope、state、budgetPermit、tunnelLease、baseline、cleanupDeadline | +| PoolKey | organizationId、connectionId、configRevision、credentialRevision、executionIdentityKey、policyIsolationKey、driverResourceKey、networkRouteRevision | +| ExecutionRecord | 请求指纹、身份、target、runtimeEpoch、resourceBindingId、状态、effectOutcome、cancelHandle、结果来源、时间、事件 sequence | +| JobCheckpoint | 稳定目标/版本/映射指纹、已提交边界、核验证据、恢复策略;不保存 live session/lease/cursor | + +PoolKey 不含密码原文。`driverResourceKey` 由驱动描述 database-bound 资源及初始化基线;不同 database 是否可共享 pool 由驱动证明,不由宿主猜测。 + +### 4.3 命名空间规范化契约 + +驱动 ResourceDescriptor 注册 namespaceShape:声明 database/catalog/schema 是否存在、pathSegments 的顺序与语义、名称大小写及 canonical ID 规则、可接受的别名映射。Command definition 另注册 targetRequirements:每个层级 required/optional/forbidden、是否允许对象、是否允许使用该 session 已确认默认值。两者必须同时校验,不能用一个全驱动 required 列表代替操作需求。 + +处理顺序:校验 DTO 所有字段存在 → 按 namespaceShape 拒绝不存在层级的非 null 值 → 合并字段/path 别名并拒绝冲突 → driver 规范化 ID → 校验本操作 targetRequirements → 输出 CanonicalTarget。不存在的层级只能 null;存在但未指定的层级为 null,仅 optional 允许。executeAtTarget 不从 UI 或其他 session 补值;executeInSession 仅在 Command 明确允许时使用该 session 已确认值。无法确认仍返回 TargetRequired。 + +| 用途示例 | 规则 | +| --- | --- | +| PostgreSQL session 打开 | database required,schema 可 optional;观察 searchPath 独立于对象身份 | +| PostgreSQL 对象操作 | database/schema/object required,不猜 public | +| SQLite | 文件由 profile 配置解析;path 不承载文件系统路径 | +| Redis | db index 的映射、编码与范围由 driver schema 定义,不把它通用定义成 path | + +path 默认只承载未被 database/catalog/schema 表达的扩展层级;若 driver 兼容 schema 明确声明别名,双方同时提交必须规范化后相等。ResourceManager、权限检查、缓存和执行都使用同一 CanonicalTarget;前端展示名称不参与身份比较。 + +### 4.4 可落盘来源与运行时绑定 + +ResultProvenance、StatementResultSource、结果数据/映射可以持久化;SessionHandle、RuntimeResultBinding、EventEnvelope.sessionHandle、取消句柄、lease/cursor **永不落盘**,也不能进入持久化日志、审计或 Job checkpoint。ExecutionView 是实时响应;仓储使用独立 DurableExecutionRecord DTO,不直接序列化它。事件归档只写显式白名单投影,不能直接序列化 EventEnvelope。 + +DurableExecutionRecord 保存 executionId、身份、state/effectOutcome、provenance、statementSources、artifactIds、resultCompleteness/truncationReason、errorCode 和时间;不包含 runtimeBinding。能力快照保存驱动/协议/能力版本及本次确认的非敏感能力,作为计划核验与审计证据,不保存凭据。RuntimeResultBinding 仅由 execution registry 在内存附加,资源丢失、关闭或替换立即失效。 + +恢复普通表结果可以重新授权完整对象目标,验证结构/可写映射和 PK/version 后短租约写回。临时对象结果可恢复数据供阅读,但恢复后不能写回;禁止通过连接配置或上下文指纹寻找“等价”session。configRevision/contextRevision 用于版本与上下文冲突,不能证明物理资源连续性。 + +## 5. 驱动资源契约 + +### 5.1 新增契约的落点 + +在 `packages/driver-api` 增加传输无关的 opaque resource、能力、错误和 trait;以下文件名为拟新增,不是现有文件:`resource.rs`、`session.rs`、`capabilities.rs`。Host runtime 的具体池、Tokio actor、channel 和 semaphore 不暴露到 Driver API。 + +| 操作 | 输入 | 返回 | 强制语义 | +| --- | --- | --- | --- | +| describeResource | 经验证配置、身份范围、目标、用途 | ResourceDescriptor | 是否固定/可复用、资源 key、所需能力 | +| acquireResource | 建连材料、目标、scope、BudgetPort | opaque ResourceHandle | 固定范围内底层会话不改变;所有建连经预算 | +| executeOnResource | ResourceHandle、executionId、CommandCall、ResultSink | ExecutionCompletion | 不自行再从随机 pool 取连接 | +| observeSession | ResourceHandle | SessionObservation | 在同一物理资源读取;不可观察明确返回 Unknown | +| changeContext | ResourceHandle、desired | Confirmed/RequiresReplacement/Unsupported | 不改宿主配置、不默默重连 | +| begin/commit/rollback | ResourceHandle、事务选项 | TransactionObservation | 必须作用于同一资源;提交不确定返回 Unknown | +| requestCancel | 精确 execution cancelHandle | Requested/Unsupported/AlreadyFinished | 不调用 session-wide cancel 作为隐式回退 | +| resetResource | ResourceHandle、baseline | Clean/Discard | 成功必须满足全部清理条件 | +| closeResource | ResourceHandle | Closed/CloseUnconfirmed | 幂等;未证实关闭不能伪称预算已回收 | + +资源句柄仅在对应 provider/worker 有效,不在客户端传递。能力接口使用标准库类型、API 自有类型和 serde;async-trait 方式遵守现有契约。 + +ResourceDescriptor 必须包含 providerId、resourceKey、sessionContinuity、reusePolicy、initializationRequirements、connectionCostPolicy 和 namespaceShape;Command definition 提供操作级 targetRequirements。ResourceHandle 是 API 定义的不可伪造 opaque ID,由 provider 校验所有权和 epoch。 + +ExecutionCompletion 必须包含 completionStatus、effectOutcome、statementResults、contextBefore/after、transactionObservation、protocolDrained、resourceHealth。SessionObservation 的 unknown 字段不能填入 initialTarget 假充确认。ResultSink 提供带字节大小的异步写入/完成/失败方法,写入等待表示背压;不暴露 Tokio channel 类型。 + +`BudgetPort` 返回 opaque 许可,并支持按实际物理连接申请/释放。SQLx 驱动必须约束内部 pool 的真实建连;仅在 acquire 时计数会漏掉 idle 连接。第三方 SDK 无法统计实际 socket 时声明保守资源成本/硬上限,或在严格服务端预算模式拒绝该能力,不能报告精确值。 + +### 5.2 能力粒度 + +| 能力 | 值/范围 | +| --- | --- | +| statefulSession | supported/unsupported/unknown | +| namespaceSwitch | inPlace/requiresReplacement/unsupported/unknown | +| contextObservation | full/partial/unsupported | +| transactionObservation | full/partial/unsupported | +| resetForReuse | verified/unsupported | +| preciseCancel | supported/unsupported/unknown | +| snapshots | perTable/perDatabase/coordinated/unsupported | +| transactions | 支持的隔离级别、savepoint、对象/引擎限制 | +| ddlAtomicity | 按计划操作确认,可包含非事务操作 | + +描述阶段和实际连接握手都校验能力。Unknown 不开启需要保证的功能。遗留驱动可以支持独立受控操作,但不能因为“池大小为 1”就声明固定会话:底层重建和不同 consumer 仍会破坏语义。 + +## 6. 状态机与并发 + +### 6.1 Session 状态机 + +| 当前状态 | 输入/条件 | 下一状态 | 动作 | +| --- | --- | --- | --- | +| New | 首次执行/显式连接 | Opening | 申请预算、建立固定 Lease | +| Opening | 建立和初始化成功 | Ready | 绑定 resourceBindingId,发布确认状态 | +| Opening | 超时/取消/失败 | New | 清理部分资源、发布错误;未执行 SQL | +| Ready | 开始查询 | Executing | 原子登记 activeExecution | +| Executing | 完成且资源健康 | Ready | 观察状态、确认结果、释放 active 标记 | +| Ready | context change | Reconfiguring | 校验 revision、执行切换 | +| Reconfiguring | 原地成功/失败且旧状态确认 | Ready | 成功更新 revision;失败保留实际状态 | +| Ready/Executing/Reconfiguring | 协议损坏或连接丢失 | Lost | 禁止后续执行、终结相关执行 | +| New/Ready | 关闭允许 | Closing | 停止接受新请求、处理事务与清理 | +| Executing | 强制关闭 | Closing | 发取消;等待完成,不立即归池 | +| Closing | 资源确认释放 | Closed | 终态 tombstone、预算核销 | +| Lost | 清理关闭 | Closed | 幂等;恢复另建新 session | + +Lost/Closed 不能直接转 Ready。用户“重新连接”调用 openSession 获得新 ID。需要重连的切库采用两阶段替换:先建立新 session,成功后 UI 原子替换绑定,旧 session 再关闭;不是复用旧 ID。 + +### 6.2 Lease 状态机 + +`Reserved → Opening → InUse → Cleaning → Returned/Closed`;未知或清理失败进入 `Quarantined → Closed`。Quarantined 不能再次 acquire。连接关闭未确认时保留预算占用或转为待核验占用,不能立即归零。 + +### 6.3 锁与队列 + +每个 session 一个 actor,普通消息 FIFO 串行。execution cancel registry 和 shutdown control 独立,避免取消被长查询堵在后面。运行中的 driver future 与控制信号由 actor 协调;执行任务不能绕开 actor 持有同一个资源。 + +注册表的读写锁只用于定位 actor 或插入记录,取 Arc 后释放锁,再 await 网络、预算或 driver。禁止持全局锁等待外部服务。多端预算先统一预留,或按稳定 endpoint key 排序且等待失败释放已持许可,避免 AB/BA 死锁。 + +客户端 dbSessionId 和 runtimeEpoch 必须精确匹配。下拉切库要求 expectedContextRevision;有新状态时返回 ContextConflict,重新读取后由用户操作重发。普通编辑器执行也默认携带 revision,阻止旧界面在新上下文执行;仅显式 session 脚本内部按队列自然延续。 + +### 6.4 Attachment 与超期处理 + +Attachment 是独立维度:attached/detached/expired,与 SessionState 组合;不新增混入 SessionState 的 Detached。attached 的 session 仍可能 Lost;detached 的 session 仍可能 Executing。Job 不使用交互 attachment TTL。 + +ConnectionService 的 attachSession/detachSession 接受 AttachmentRequest,返回 SessionView。openSession 返回 OpenSessionReceipt,通过独立敏感字段返回 attachmentToken,仅发给 owner,不能进入事件、URL、日志或磁盘。前端只保存在内存;令牌丢失不能仅凭同 principal 恢复,需显式关闭/新建会话。服务端保存内存哈希,认证组织/principal、原 clientInstanceId/editorSessionId 及令牌后才允许重附着;重复 detach 不重置 detachedAt,重复 attach 幂等。返回失败使用 PermissionDenied 的不可见资源策略。 + +| 条件 | 到期点/动作 | +| --- | --- | +| attached,无事务,非执行中 | lastBusinessActivity + 30 分钟,关闭 | +| attached,active/aborted/unknown 事务,非执行中 | lastBusinessActivity + 5 分钟,回滚并关闭 | +| detached | detachedAt + 60 秒;与已适用 idle deadline 取最早 | +| grace 内 attach | 取消掉线 deadline,不刷新 lastBusinessActivity/事务期限 | +| 执行中 | 暂停 idle 检查,不暂停掉线 grace;grace 到期请求取消并关闭 | +| 关闭/取消进行中 | 独立 cleanup deadline,超时隔离,记录真实 effectOutcome | + +lastBusinessActivity 在已接受的实际业务操作开始和终结时更新;登录心跳、读取状态、订阅和 attach 不刷新。执行中没有 detached deadline 时由执行 timeout/管理员政策管理。每个 TTL 起点在服务端用单调时钟保存;expiresAt 是最早适用 deadline 的 UTC 投影,执行中无适用期限为 null。超期动作由同 actor 串行裁决,expired 不可重附着;竞态中已进入 Closing 就不能恢复 Ready。 + +## 7. 方法处理顺序 + +### 7.1 openSession + +1. adapter 认证,构造 RequestContext。 +2. 校验 owner 属于调用者,授权 connection/use + session/open。 +3. 读取 profile 和版本,解析初始目标;必需 database 缺失返回 TargetRequired。 +4. 校验驱动支持该会话语义和执行环境;配置 schema 校验通过。 +5. 按 `(组织, principal, client, operation, idempotencyKey)` 查询去重。相同键不同输入返回 IdempotencyConflict。 +6. 创建 New session,生成唯一 dbSessionId,contextRevision=0,observedContext=unknown。 +7. 写注册表/目录和去重记录,再返回 OpenSessionReceipt;此时物理连接数量不变。 + +首次 execution 进入 actor 才建立固定 Lease,建连成功时生成内部 resourceBindingId;公开句柄只有 runtimeEpoch + dbSessionId,首版不设置恒为 1 的 generation。建连失败且未执行 SQL 可以在同一 New session 重试。已经建立的物理会话丢失则进入 Lost 并返回 SessionLost,禁止用原句柄透明替换资源;用户显式新建会话时分配新 ID。后续排队请求仍要重新校验 contextRevision。 + +### 7.2 executeInSession + +1. 检查 authentication、organization、owner、runtimeEpoch/dbSessionId、当前权限。 +2. 校验 Command definition、input schema、访问级别、预算/队列上限,规范化输入。 +3. 原子登记幂等请求与 executionId。接受后返回 receipt,不等 SQL 完成才生成 ID。 +4. 进入 actor;实际执行前再次检查权限、handle 和 expectedContextRevision。 +5. New session 按 7.1 建立 Lease;Ready 使用已有 Lease;Lost/Closed 明确失败。 +6. 保存 contextBefore,将精确取消句柄绑定 executionId + runtimeEpoch + resourceBindingId + opaque resource。 +7. 在同一 Lease 调用 driver;任意用户 SQL 保留原始顺序,不预扫描最后 USE 后先切库。 +8. driver 按方言正确处理批次/分隔符,返回语句或批次结果;Host 不用分号正则拆 SQL。 +9. 消费结果/完成协议;即使 SQL 报错也尝试观察实际上下文和事务状态。 +10. 更新 contextRevision(任何可见观察快照变化,包括 confirmed→unknown,都增加;确认无变化不增加)、保存结果来源和 effectOutcome。 +11. 注销取消句柄,发布终态。确认资源健康后进入 Ready,否则 Lost/Quarantined。 + +若 observe 失败但连接健康,只把对应字段标记 Unknown,禁止依赖未知事务状态的切库、清理复用或保证原子性的操作;允许用户显式结束会话。不能把所有未知情况强行显示成无事务。 + +### 7.3 executeAtTarget + +1. 验证 profile 版本、完整对象目标、权限和 Command schema。 +2. 任意 SQL 在该操作范围内使用独立固定 Lease;它不获得跨请求会话连续性。 +3. 受控对象 Command 可借共享干净资源,初始化目标或使用 driver 完整限定引用。 +4. 不支持目标定位则 TargetUnsupported,不回退默认库。 +5. 执行、观察、生成来源;未结束事务必须回滚。 +6. 结果完全消费后 reset;只有 Clean 才归池,其余关闭。 +7. 同一调用中的 SQL 状态变更仅影响该 Lease;下一个独立调用重新初始化目标。 + +### 7.4 setSessionContext + +1. 验证 handle、owner、权限和 revision,将操作放入 actor。 +2. New 且从未建立资源时,只更新经验证的 initialTarget、增加 contextRevision,observedContext 保持 Unknown,不建连;不存在事务,无需走替换流程。 +3. Executing 时 UI 禁用切库;API 排队时仍在实际处理前检查 revision。 +4. 已建立资源且 Unknown/Active/Aborted 事务时,首版 UI 切库返回 TransactionResolutionRequired;先显式提交/回滚。用户原生 SQL 是否允许 USE 由数据库语义决定,不改写 SQL。 +5. inPlace:driver 切换并观察;成功后发布已确认上下文。 +6. requiresReplacement:保留旧 session,建立仅内部可见的候选资源与候选记录。候选不登记到公共 SessionRegistry/SessionDirectory,不接受执行和订阅。确认候选成功后,以内存替换操作记录为提交依据,在旧 actor 内停止旧 session 发放新执行,通过 12 节的内存目录提交协议原子发布新 session 与 replacement receipt、将旧 session 标记 Closing,再把旧资源移交 cleanup。目录发布失败不得返回成功,保持提交屏障直到恢复发布;提交前失败销毁候选,提交后失败只能恢复同一 receipt,不能重新建立候选。返回新 session 后 UI 原子切换绑定。预算不足或建连失败,销毁候选并保留旧 session。 +7. unsupported/unknown:明确错误,不创建假成功状态。 + +`setSessionContext` 返回 `ContextChangeReceipt { session: SessionView, replacedSessionId: string | null, attachmentToken: string | null }`,BackendClient 对应方法据此原子更新绑定。替换时返回新 session 的 attachmentToken,原地切换为 null;旧 token 不授予新 session 附着权,token 只进入调用者内存,不能进入事件。 + +切换操作按 idempotencyKey 登记:网络丢失后同键重试返回原 receipt,不再建立候选连接。旧 session 的授权状态读取可返回 replacement ID,帮助客户端恢复绑定;未授权调用者看不到该 ID。两阶段是“建立候选 → 服务端提交替换”,不等待浏览器确认才释放旧资源;客户端离线不会造成无限保留。 + +### 7.5 closeSession + +1. 验证归属,幂等查 tombstone;关闭状态下不再接受新请求。 +2. New 且从未建连可以直接关闭。已建连 requireNoTransaction 在 Active/Aborted/Unknown 时返回 TransactionResolutionRequired,不关闭。 +3. rollbackAndClose 请求取消运行中 execution,等待停止/取消超时。 +4. 能确认事务时显式 rollback;失败记录 RollbackFailed/Unknown,不能返回“已回滚”。 +5. 任意用户 SQL session 首版直接关闭物理资源;不复用未知会话状态。 +6. 关闭未确认进入隔离,稍后核验;释放隧道引用、核销预算只执行一次。 +7. 记录 Closed/Lost 和实际效果;短期保留 tombstone 供重复请求查询。 + +Drop 仅做同步兜底标记并把资源移交 runtime cleanup queue;异步 rollback/close 必须被显式 await。cleanup queue 在应用退出时有界 drain,不能依赖被丢弃的 unawaited future。 + +### 7.6 cancelExecution + +查 execution 与当前权限 → 验证内部取消绑定的 executionId/runtimeEpoch/resourceBindingId → 对活动执行调用 driver 独立控制路径 → 按请求结果返回 CancelReceipt。driver 确认 Requested 后标记 cancelRequested;unsupported 保留当前执行状态;已经终结返回 alreadyFinished。取消与完成竞态由 execution registry 原子处理;终态优先,不重新激活取消句柄。用户可以显式关闭自己的会话。 + +取消请求接受后返回 `{ executionId, disposition: 'requested', state: 'cancelRequested' }`;只有 terminal event/getExecution 的终态才能显示“已取消”。超时可以隔离/关闭资源,写入结果未知时仍为 Unknown,不能伪称 CancelledRolledBack。 + +### 7.7 结果订阅与放弃消费 + +订阅仅观察已接受执行。结束 AsyncIterable、关闭结果视图或网络断线都不自动 cancelExecution,也不立即释放固定编辑器资源。ResultSink 将数据写入有界内存/ArtifactStore,事件订阅慢不能把无界数据留在内存。每订阅事件队列最大 256 条/1 MiB,先触及任一上限即停止订阅并发 streamResetRequired(无法发送则断开);客户端读快照,不继续积压。 + +首版每 execution 未消费缓冲上限 8 MiB、无消费者等待 30 秒、放弃后的协议 drain deadline 10 秒,均可配置。ArtifactStore 的每 execution 字节上限必填,桌面默认 256 MiB,团队默认 1 GiB,仍受组织总产物额度限制;达到上限停止写入并按下面流程处理,不能无限 spool。订阅端收到结果截断原因,ExecutionView 记录产物完整性,不把截断产物宣称完整导出。 + +处理流程:无订阅者时继续在额度内持久化 → 等待期限或产物额度耗尽后,普通查询切换到 bounded discard/drain → 能完成则保留 SQL 终态,结果标为 truncated → 不能在 drain deadline 内结束则请求精确取消 → 等协议终结、观察事务/健康 → 仍未知则关闭/隔离资源。bounded discard 按固定块读取并丢弃,不累计整个结果。流水线导出/迁移由 Job 的背压与任务策略控制,不适用普通结果放弃策略。 + +对固定 session:协议与事务确认健康时可回 Ready,取消不必然丢失临时状态;有活动事务保留明确状态等待用户处理。对短 Lease:还需 9.4 全部检查和 reset Clean 才归池。用户显式 closeSession 则始终结束该 session;关闭结果视图不能隐式触发它。unsubscribe 后仍可 getExecution/重新订阅;事件过期用快照,已截断行不能假装可重放。 + +## 8. multidb 与用户 SQL + +命名空间枚举、原地切库、连接绑定数据库、跨库引用是四种独立能力。浏览树位置、补全推测位置和 observedContext 分开存储。 + +MySQL USE 改变当前会话默认数据库;完整限定对象名不等于切库。[MySQL USE](https://dev.mysql.com/doc/refman/8.0/en/use.html)。PostgreSQL 连接绑定一个数据库,切 database 需要新连接;search_path 属于库内上下文。[PostgreSQL 数据库边界](https://www.postgresql.org/docs/current/manage-ag-overview.html)。SQL Server/Azure SQL 差异由 driver 运行时能力确认,[USE 官方说明](https://learn.microsoft.com/en-us/sql/t-sql/language-elements/use-transact-sql?view=sql-server-ver17)。 + +用户执行 `A 查询 → USE B → B 查询失败` 时,默认停止后续语句,实际当前库保留 B。执行 `USE 无权限库` 失败时保留确认的旧库;切换成功但观察/网络状态无法证明时显示 Unknown,不推断成功或失败。 + +禁止为了目标定位通用改写任意 SQL 的所有关系名。受控对象 SQL 可以由 driver 渲染限定名;任意 SQL 用真实会话目标。变量、过程、动态 SQL 和用户直接 BEGIN/COMMIT 都要求实际状态观察。 + +## 9. 连接池、预算与清理 + +### 9.1 默认资源政策 + +| consumer | 默认策略 | 长持有条件 | +| --- | --- | --- | +| QueryPanel | 懒固定 session | 第一次执行至关闭/失效 | +| TablePanel | 短操作 Lease | 显式事务/快照/游标 | +| 表编辑 | 本地 ChangeSet,保存时短事务 | 手工事务模式才长期持有 | +| 结构/ER/补全 | 元数据资源 | driver 需要一致读取范围 | +| 仪表盘/监控 | 受限短租约 | 驱动声明专用连续资源 | +| 导出 | Job 阶段固定 | 流/游标/快照结束 | +| 三件套 | Job 声明多端阶段资源 | 事务、快照、session mode | +| Workflow | step/block 声明范围 | session/transaction block | +| AI/MCP/Wapp | 目标操作 | 显式受控 session | + +### 9.2 可调初始值 + +这是首版配置起点,不是当前默认值或性能结论。所有值通过 settings/admin 配置,测试注入更小值。 + +| 项 | 初始值 | 行为 | +| --- | --- | --- | +| 短操作 pool | min=0、max=2/PoolKey | 还受服务级预算约束 | +| 每用户已连接编辑器 | 5 | Opening 时预留名额;资源仍存活的 Ready/Executing/Closing/Lost 占用至确认释放 | +| 桌面总目标连接 | 16 | idle、任务和控制资源都计入 | +| 团队单数据库服务额度 | 20 | 管理员按目标 max connections 调整 | +| acquire timeout | 10 秒 | 返回 ResourceBusy,可取消等待 | +| session 普通队列 | 32 请求 | 超过拒绝,不无界积压 | +| 每用户逻辑 session / 每组织逻辑 session | 100 / 1000 | New 也计数;超限 SessionQuotaExceeded;Job 不冒充编辑器 session | +| client 掉线保留 | 60 秒 | 原 owner + attachment 凭据重附着,规则见 6.4 | +| 无事务会话业务空闲 | 30 分钟 | 明确过期关闭,不默默重建 | +| 空闲事务 | 5 分钟 | 请求回滚关闭,记录实际结果 | +| cancel/cleanup deadline | 10 秒 | 不能确认则隔离并核验 | +| 数据缓冲 | 每 pipeline 8 MiB | 按字节背压;超大单值走单独上限/分块 | + +额度和 timeout 使用单调时钟判断持续时间;对外 expiresAt 使用 UTC。登录心跳不能刷新 lastBusinessActivity;执行期间不以 idle 判定回收。事务状态 Unknown 按保守事务上限处理。 + +### 9.3 预算会计 + +预算覆盖组织、用户、数据库服务、worker 和 Job。占用集合包含 Opening、InUse、Cleaning、idle pool、Quarantined 和 control sockets。归还到 idle pool不释放物理连接预算,只有实际 close 才释放。 + +cancel 所需控制连接预留在同一预算内,不能在满池时无限额外建连。多 profile 指向同一服务需 driver 服务身份与管理员资源组辅助聚合;无法可靠识别时使用显式配置组,不能声称自动精确识别。 + +多 worker 使用节点额度或全局 permit。失去协调器续约停止新建连接;旧额度在确认 worker 隔离/连接关闭之前不重新分配,以免网络分区超额。 + +### 9.4 归池前检查 + +必须全部成立:执行结束、结果协议完全消费、事务终结、锁释放、默认命名空间恢复、角色/会话变量/编码恢复、临时对象/预处理状态按 driver 契约处理、连接健康。 + +归池条件是宿主检查全部通过 AND driver 返回 Clean。宿主检查执行终态、无活跃消费者/取消句柄、已收到 protocolDrained、预算和 owner 合法;driver 负责协议、事务、初始化基线与健康检查。任一失败都关闭,即使 driver 返回 Clean 也不能绕过宿主检查。reset 不支持、失败或超时直接关闭。不能把 ping 成功等同于 Clean。任意 SQL 编辑器首版关闭销毁,短操作只复用工具控制的可清理资源。 + +### 9.5 资源类别与调度 + +每个数据库服务预算分为 control、interactive、metadata、job;分类由服务端用例决定,客户端不能自报优先级。默认团队额度 20 中 control 保留 2、metadata 保留 1、interactive 保留 2,其余 15 为共享额度;桌面 16 中分别保留 1/1/2,共享 12。额度低于保留总和则拒绝配置;所有保留都在总额度内。首版保留不可被其他类借用,control 只能用于取消/健康恢复,不用于普通 SQL。 + +固定编辑器和 TablePanel 使用 interactive 类;元数据、补全使用 metadata;迁移/导出阶段使用 job。类内按 principal 轮转 FIFO,共享额度按 interactive:metadata:job=4:2:1 加权轮转,空队列跳过;非空队列不得无限饥饿。每类每服务排队上限 32、每用户 32;actor 队列另计,任一满返回 QueueFull。acquire 等待可取消并受 10 秒期限约束。 + +已连接固定 session、活动事务、游标不为优先请求抢占。idle pool 保留原类别与实际预算,可按 LRU 关闭并在确认后释放额度,再向其他类别发放;不能先记账转移仍存活的 socket。Job 的多端申请必须一次预留全部许可或失败释放,不持有半边无限等待。每用户 5 个编辑器名额不代替物理额度;Job 另受组织最大并发阶段数(默认 4)及全局连接额度约束。 + +### 9.6 PoolKey、版本与缓存的生产者 + +| 字段 | 权威生产者 | 变化处理 | +| --- | --- | --- | +| organizationId/connectionId/configRevision | RequestContext + ProfileRepository 的 CAS 版本 | 新 key;旧 idle 停发并关闭 | +| credentialRevision | SecretProvider 的不透明版本 | 新 key;不使用密码散列作为 key | +| executionIdentityKey | IdentityResolver:DB 登录身份、委托角色、有效权限范围 | 重新解析并隔离池与缓存 | +| policyIsolationKey | AuthorizationService:组织/用户/策略版本及只读限制 | 权限撤销立即停发,排队重新鉴权 | +| driverResourceKey | describeResource 对 CanonicalTarget/基线的规范化结果 | 数据库绑定资源按 database 分 key | +| networkRouteRevision | NetworkProvider 的路由/隧道/TLS 配置版本 | 新 key,旧路由不发放新租约 | + +PoolKey 使用类型化结构等值比较,不能用随意字符串拼接。policyIsolationKey 在共享 DB 账号但权限不同的用户之间必须不同。已建立 session 保存建连时版本,不因配置更新改变底层身份;管理员按 drain/force 明确处理旧资源。effectiveIdentity 变化须先重新授权,不得在未匹配的池内直接切换角色。 + +SchemaCache key 为组织、executionIdentityKey、policyIsolationKey、connectionId、config/credential/capabilityRevision、CanonicalTarget、Command/参数指纹;临时元数据只进绑定 runtime handle 的内存缓存。缓存 TTL 默认 60 秒,配置/授权/DDL 事件即时失效;刷新使用独立 cacheRevision 防止慢结果回填,它不是 session 代次。不能仅靠 TTL 延迟权限撤销。 + +浏览多个 database 不必各保留连接,但 database-bound driver 可产生多个 pool key;每 pool min=0、max=2 仍受服务总预算,idle TTL 默认 60 秒、最多保留 32 个空 pool 元数据条目。空 pool LRU 可删除;有资源的 pool 先关闭再删除。跨数据库可安全复用只能由 driver 契约证明,不能为降低 pool 数量强行合并。 + +## 10. 三件套与 Job 实现细则 + +### 10.1 公共处理 + +准备阶段使用明确 target + 短 Lease;审阅等待释放快照。计划冻结 target、config/credential/capability 版本、对象结构指纹、映射和事务要求。执行前重新验证,变化则 PlanStale。 + +Job 接受时先持久化记录,再获取资源。Job 资源 owner 是 jobId/stageId,关闭窗口只取消订阅。完成/失败/取消均走 cleanup;应用进程关闭仍会停止任务,只有独立 worker 才能跨桌面进程继续。 + +源与目标角色独立。相同 connectionId 可以不同 database;不同 connectionId 可以同一真实对象。结合服务身份、命名空间、对象映射检查重叠;身份无法证明时提示并禁止危险自覆盖模式。 + +### 10.2 Schema Diff + +compare 短租约读取结构 → 计划 → review → 重新校验目标 → 目标专用阶段资源 → 按依赖执行 DDL。原子性按计划确认,Unknown 不承诺全部回滚。MySQL 多种 DDL 会隐式提交,[官方说明](https://dev.mysql.com/doc/refman/8.0/en/implicit-commit.html)。失败记录已生效 DDL;反向 DDL 是补偿,不等于 rollback。 + +### 10.3 Data Sync + +先过同族/结构/PK 门闸,比较时声明实时/单表/全库/协调快照范围。源目标各自快照不等于跨服务器同一时刻快照。review 默认释放快照;ChangeSet 保存目标旧值/版本证据。apply 再过门闸、结构检查和行冲突验证。 + +首版默认按批次事务;整表/整任务仅在计划支持时开放。session mode 开启、写入、关闭必须同一 Lease,恢复失败销毁。取消只回滚未提交范围,已提交批次保留。 + +### 10.4 Data Transfer + +source reader → 有界缓冲 → IR 转换 → target writer。游标/快照固定读取资源,写入按提交范围固定资源。首版单 reader/writer;并行需稳定分片、快照协调及约束顺序证明。PostgreSQL 可导入导出快照,但不能假定所有 driver 支持,[官方说明](https://www.postgresql.org/docs/current/sql-set-transaction.html)。 + +checkpoint 记录稳定键、已确认提交、源一致性证据和映射指纹。目标 commit 与本地 checkpoint 之间存在崩溃窗口,恢复依赖目标事务内批次记录/幂等或重新核验,不能仅保存 offset。SQL 文件输出不建目标数据库连接。 + +## 11. Workflow / AI / MCP / Wapp + +Workflow 目标优先级:step 显式目标 → block target → workflow 默认目标 → profile 初始目标。独立 step 每次初始化,USE 只在 step 内生效。session block 同一资源串行、跨 step 保留状态;transaction block runtime 控制 begin/commit/rollback,禁止步骤破坏提交边界。需重连的 database 变更不得进入现有 transaction block。 + +并行分支各自资源;同 session block 不并行。事务失败重试整个已证实回滚的块。任意 session block 默认不能从中间 checkpoint 恢复。跨端事务不承诺原子性,按幂等/补偿处理。 + +transaction block 执行前由 driver 校验整个计划是否能保证提交边界。首版优先受控 DML Command;任意 SQL 若无法证明不含手工 COMMIT、autocommit 修改、隐式提交或其他破坏边界的行为,则返回 UnsupportedPlan。不能只用关键词正则宣称安全。session block 可执行原生任意 SQL,但不提供 runtime 管理的整块原子性承诺。 + +AI 默认独立目标操作;用户显式授权当前编辑器 session 后,进入同队列并共享真实状态。MCP 显式 session 归客户端/身份、受 TTL 配额控制;Wapp session 归实例或 Job。后台调度用服务身份,不依赖 GUI 当前库或长期保留用户登录。 + +## 12. Web、多实例、权限与结果 + +SessionDirectory 保存组织/owner/worker/runtimeEpoch,不保存连接。dbSessionId 使用随机 128 位以上 ID,runtimeEpoch 每次 worker 启动重新生成;目录以 dbSessionId 唯一约束原子登记 owner/epoch,碰撞重生成,登记失败不返回成功。单进程实现同一目录 port;多 worker 用共享内存目录与 CAS,不需要中央 ID 分配器。替换提交必须对目录中的 old/new/operation 状态提供原子事务:prepared 候选不可路由;committed 指向新 owner/epoch,并把旧条目改为关闭路由。旧 actor 在提交期间不派发;提交成功后完成本地新记录发布再返回回执。目录提交失败且确认未提交则恢复旧 Ready;提交结果未知则保持 Reconfiguring 屏障,按 operation key 查询提交状态,不同时允许 old/new 执行。worker 崩溃使两者失效;不从内存目录恢复物理状态。目录禁止磁盘持久化、快照和 append-only 日志;仅保存带 TTL 的运行时路由,丢失目录则使相关会话明确失效,不据此恢复物理会话。任意 API 实例完成授权后转发到 owner worker。worker 故障返回 SessionLost,不能换机器保留 ID。sticky session 仅是优化。 + +浏览器网络断开不是数据库断开。允许 grace 内按 6.4 认证原 attachment 后重附着;前端恢复订阅/getExecution,不重新发送未知写入。超期关闭;有事务的实际回滚结果单独记录。租约到期的 Job 接管必须先验证旧执行与目标提交,fencing token 无法自动撤回外部数据库语句。 + +池隔离由执行身份而非显示用户名决定。共享账号的应用 ACL 不能代替数据库权限;任意 SQL 的限定名/过程/角色可能绕过当前库范围。结果/缓存/event 都按组织和有效权限范围检查。配置禁用/删除拒绝新操作,已有资源按显式 drain 或强制结束策略处理。 + +旧结果编辑:受控对象结果有完整 relation identity + PK/version,可重新授权并乐观写回原目标;任意 SQL 多表/表达式结果默认只读,driver 未证明可写映射时不猜测。临时表结果写回要求原 runtimeBinding 精确有效,否则 SessionLost;相同配置、指纹或用户不能代替原物理会话。 + +## 13. 错误、重试和事件 + +| code | 触发 | UI/调用者动作 | +| --- | --- | --- | +| InvalidArgument | DTO/Command schema 不合法 | 修正请求,不调用 driver | +| TargetRequired / TargetConflict / TargetUnsupported | 目标缺失/重复冲突/无法定位 | 修正请求,不默认切库 | +| SessionNotFound / SessionLost | 失效或不存在 | 读终态,显式建立新会话 | +| RuntimeEpochMismatch / ContextConflict | 旧运行时/上下文请求 | 读取新状态,不自动执行旧写入 | +| PermissionDenied | 当前授权拒绝 | 停止;敏感资源映射为 404 | +| ResourceBusy / QueueFull | 预算/队列超限 | 等待或用户取消;不得创建额外连接 | +| TransactionResolutionRequired | 事务阻止切换/关闭 | 用户选择处理事务 | +| CapabilityUnsupported / UnsupportedPlan | 无所需能力/无法证明事务边界 | 拒绝执行或重新制定计划 | +| EndpointOverlap | 源目标对象危险重叠或无法排除自覆盖 | 修改目标或选用允许的计划模式 | +| SessionQuotaExceeded | 逻辑会话/编辑器额度超限 | 关闭会话或管理员调整额度 | +| IdempotencyExpired | 幂等键已过提交有效期 | 查询原执行并核验,不自动用新键重投 | +| RollbackFailed / CleanupFailed | 无法证明清理 | 隔离连接、报告真实结果 | +| OutcomeUnknown | 写入/提交结果未知 | 核验执行,不自动重试 | +| PlanStale / SourceChanged / TargetConflictRows | 计划/数据变化 | 重比对或处理冲突 | +| IdempotencyConflict | 相同键不同输入 | 修正请求键,不能覆盖记录 | + +每条事件带 stream sequence;重复事件忽略,缺口重新读取状态。session context 按 runtimeEpoch/dbSessionId/contextRevision 更新;比较 Counter 使用 BigInt 或十进制字符串比较,不能 Number 转换。 + +幂等记录原子保存请求指纹和 receipt,键的有效期协议见 13.1。首版执行/Job 的完整 durable receipt 保留至少 24 小时,会话回执仅内存保留;不依赖已删除记录来识别旧请求。幂等提交只保证创建 execution 一次,不保证外部 SQL exactly-once。执行记录不可用时,客户端不能根据没有事件推断未执行。 + +### 13.1 幂等键期限与响应分类 + +幂等键不是客户端随意 UUID。BackendClient 先从应用服务获取签名提交令牌(本地也使用相同 port);令牌包含随机 nonce、operation、组织/principal/client、issuedAt、expiresAt、keyVersion;运行时会话操作还绑定 owner runtimeEpoch,默认有效期 24 小时,不含秘密。issueSubmissionToken 对 openSession 选定 owner,对已有 session 操作验证并绑定其 owner;返回的 idempotencyKey 是不透明签名令牌。服务器校验签名与作用域,再以令牌摘要为去重 key;不相信客户端时间。未过期且同指纹返回同 receipt,不同指纹 IdempotencyConflict。 + +超过 expiresAt:仍有记录时允许只读查询原 receipt;任何重新提交返回 IdempotencyExpired,不再次执行。完整记录至少保留至 expiresAt + 24 小时,然后可按审计策略删除;删除后过期签名令牌仍被拒绝。令牌验证密钥至少保留至已发令牌全部过期,未知 keyVersion 一律拒绝。长期离线请求需重新核验业务状态,由用户重新提交,不自动换新令牌。执行与 Job 接受记录持久化时只保存请求摘要、executionId/jobId、稳定目标/owner、状态和 receipt 的 durable 投影,禁止保存原请求里的 SessionHandle。接受记录须在 SQL/Job 派发前提交;进程重启后的非终态进入核验/OutcomeUnknown,不自动再执行。 + +openSession/context replacement 的完整指纹、SessionView/attachmentToken/receipt 只在 owner 内存保存至令牌过期或 runtime 终止。对应令牌绑定 runtimeEpoch,owner 丢失即使令牌未过期也返回 SessionLost,不能在新 owner 当作首次请求再次创建。响应丢失时,在原 runtime 内同键返回同 session/token/候选提交结果;不延长 attachment/idle TTL。会话已到期则返回其终态或 SessionLost,不恢复资源。close 的 tombstone 同样只在内存保留(默认 24 小时),逻辑额度在终态退出活动注册表时回收,隔离物理占用持续计数。 + +RequestError(ApiError.code)表示请求被拒绝;已接受 ExecutionReceipt 不等于 SQL 成功;CancelReceipt.disposition 表示控制请求结果,不用异常 CancellationUnsupported 表示正常 unsupported。执行终态 errorCode 使用版本化错误枚举,effectOutcome 独立表达数据库生效范围。所有大小写使用 DTO 字面值,如 cancelled/rolledBack/partiallyApplied;文中 Active/Unknown 等状态展示简称不作为协议值。driver 的内部错误由网关脱敏映射,不原样暴露。 + +## 14. 开发步骤与文件职责 + +拟新增模块放在 `packages/runtime/src/connection/`,每文件按职责拆分,不写巨型 manager: + +| 顺序 | 模块 | 开发任务 | 完成证据 | +| --- | --- | --- | --- | +| 1 | types/error | newtype、DTO、enum、schema 校验 | 类型编译、序列化往返、错误码 | +| 2 | testing/fake_resource | 假资源、状态、屏障、故障注入 | 可观察创建/关闭/执行次数和 resourceId | +| 3 | budget | 单机多维许可、可取消等待、幂等核销 | 并发与失败会计测试 | +| 4 | resource | acquire/cleanup/quarantine、隧道引用 | 每阶段故障无泄漏 | +| 5 | registry/actor | session 生命周期、队列、epoch | 连续旅程与取消竞态 | +| 6 | execution | 幂等、授权、来源、事件 | 重复提交、旧事件、结果 ACL | +| 7 | adapters | IPC/BackendClient、旧接口窄适配 | 桌面路径行为一致 | +| 8 | consumers | Query/Table/metadata、三件套、Workflow | 功能契约旅程 | +| 9 | routing | worker 目录、总预算、drain | 多实例与分区测试 | + +fake driver 模拟一般会话语义,不包含 MySQL/PostgreSQL 方言实现。真实方言测试严格放对应驱动。旧 API 每迁移一个 consumer 即删除它的旧路径;同一请求不允许同时经过新旧管理器。注册目录、依赖和脚本在实施阶段添加,本次仅交付文档。 + +## 15. 测试夹具、落点和执行规则 + +### 15.1 基础夹具 + +Host fake 提供:两个组织 O1/O2、用户 U1/U2、profile P、不同个人执行身份、A/B 命名空间、资源创建 journal、命令屏障、fake clock、可注入 observe/reset/rollback/close 失败。测试断言 driver 收到的 opaqueResourceId、实际目标、调用顺序和预算计数,不只断言 UI 文本。 + +真实 driver fixture:创建独立测试 database/schema,A/B 的同名 `dz_target_marker` 返回不同标记;有状态驱动提供临时对象、事务和上下文探测脚本。SQL 和测试数据由对应 driver 测试包定义。测试前验证目标属于专用测试环境,结束删除夹具。 + +不打开、读取、解析、source 或打印 `.env` / `.env.test`,不运行隐式加载它们的程序。真实测试凭据由已配置 CI secret 或获授权的进程环境注入;开发前检查测试启动器不会加载受保护文件。使用 fake 可完成大多数宿主测试。 + +### 15.2 测试分层 + +| 层 | 落点 | 覆盖 | +| --- | --- | --- | +| runtime 单元/集成 | 新 runtime crate 内 | fake 资源、actor、预算、生命周期 | +| Host 集成 | `src-tauri` 通用测试 | IPC 与用例、身份/Job consumer | +| driver Rust | `packages/drivers//tests/` 或同文件 | 真实协议、USE、事务、清理、快照 | +| driver UI | `packages/drivers//ui/__tests__/` | driver 专属界面 | +| 前端 Vitest | Host/SDK 对应测试目录 | backend 选择、事件投影、tab 旅程 | +| Host WDIO | `e2e/specs/`、`e2e/contract/` | 通用旅程与契约矩阵 | +| driver E2E | `packages/drivers//e2e/` | 方言场景 | +| Web 服务集成 | 拟新增 server crate 内 | HTTP、认证、CSRF、SSE、worker 路由 | + +现有可用命令:`pnpm typecheck`、`pnpm test:unit`、`pnpm test:unit:drivers`、`cargo test -p datazen --lib`、`cargo test -p datazen-driver-api`、`cargo test -p datazen-driver-postgres`、`pnpm test:layers`、`pnpm test:boundaries`。新 crate 测试命令待其 Cargo package 创建后添加,不能现在声称可运行。 + +WDIO 使用 `pnpm tauri:build:webdriver` 或经检查的 E2E wrapper,不能裸 cargo build。测试文件参加 typecheck,不用 any。三件套已生成可运行 app 后仅 DMG 打包失败,不阻塞其 WDIO 功能验收。 + +### 15.3 测试范围与基准 harness + +H 是部署无关 runtime 契约,F 是前端契约,D 是真实驱动协议;W 再分 W1(单实例 HTTP/SSE)与 WN(多 worker)。CM-57/58 的故障路由和 CM-60 多 worker 部分属于 WN/P9;CM-54/55/56 的 H/F 部分在 P3/P4 必须验收,不能推迟到 Web。单进程 SessionDirectory 实现同一 port,P9 替换路由基础设施。 + +CM-60 使用 release 构建,4 vCPU/8 GiB、无数据库网络、单进程固定 fake command 10 ms,以并发 8 预热 1000 请求;每轮采集 10000 个获准且未排队的请求,运行 5 轮。附加耗时从 gateway 完成鉴权/参数校验开始到派发 driver,以及 driver completion 到 receipt/event 状态登记完成的两段单调时间之和,不包含 fake SQL、预算/actor 排队、网络传输。采用 nearest-rank p95(排序后第 ceil(0.95*N) 项),每轮均须 ≤10 ms;排队请求另报等待分位数,不删失败样本,失败数单列。 + +事件断言限单次存活订阅、缓冲未溢出的 sequence 连续性;重连允许重放重复原始事件,但投影后无重复行/状态遗漏。journal 在每次建连/关闭/permit 变化时断言额度,无随机采样盲区。注入连接持有、慢 consumer 和 drain 的压力部分另运行,不混入非排队延迟样本;保存环境、构建参数、原始计时与 journal 作为 CI artifact,不新增仓库评审记录。 + +## 16. 详细测试用例 + +每个用例实现时保留编号。H=Host fake/runtime,D=driver,F=前端,W=Web/worker。涉及真实方言的 D 断言放驱动目录,Host 只运行统一 contract。 + +### 16.1 类型、身份、目标与懒连接 + +**CM-01 ID 类型与序列化(H/F)** + +- 前置:所有 DTO 已定义;Counter 使用大于 `2^53` 的十进制值。 +- 步骤:Rust/TS 往返序列化;编译时把 connectionId newtype 传入 session API 的负例;传空 namespace 字符串。 +- 断言:计数不丢精度;Rust 负例编译失败;空值/缺字段请求返回参数错误;null 按 namespaceShape 与操作需求解释。 + +**CM-02 打开空白编辑器不建连(H/F)** + +- 前置:profile P、预算使用 0,driver creation journal 为空。 +- 步骤:创建 20 个 New session;只在其中 2 个执行读取。 +- 断言:创建时物理连接为 0;执行后为 2 条固定资源;其他 18 个仍为 New;未占用事务。 + +**CM-03 session 创建幂等(H/W)** + +- 前置:同一用户、client、owner 和 key K。 +- 步骤:并发提交 10 次相同 open;再用 K 提交不同 target。 +- 断言:相同 dbSessionId/attachmentToken、一个注册记录;不同输入 IdempotencyConflict;没有额外连接。 + +**CM-04 禁止配置 ID 回退(H/W)** + +- 前置:存在 profileId,不存在同字符串 session。 +- 步骤:向 executeInSession 传 profileId;向 executeAtTarget 传未知 profile。 +- 断言:分别 SessionNotFound、不可见配置错误;driver execute 次数为 0。 + +**CM-05 跨用户/组织资源访问(H/W)** + +- 前置:O1/U1 有 session、execution、job、artifact;O1/U2 和 O2 用户无授权。 +- 步骤:分别读取、执行、关闭、取消、订阅和下载这些 ID。 +- 断言:每个接口均拒绝;不能观察资源存在性/数据;不能调用 driver cancel/execute。 + +**CM-06 前端伪造 owner(H/W)** + +- 前置:U1 与 U2 的 editor/job 各自存在。 +- 步骤:U1 请求 owner 指向 U2 editor 或 job,或在 body 伪造 organizationId/principalId。 +- 断言:拒绝 owner;请求身份字段无法覆盖 RequestContext;不创建会话。 + +**CM-07 目标缺失与冲突(H/D)** + +- 前置:声明必须 database/schema 的 driver;Command 同时接受 envelope 与 input 目标的过渡适配。 +- 步骤:省略必需层;传 schema=null;传两个不同 database;传无能力目标。 +- 断言:TargetRequired/TargetConflict/TargetUnsupported;不在默认库执行;D 测试验证真实 driver。 + +### 16.2 会话连续性、切库和事务 + +**CM-08 两个编辑器隔离(H/D/F)** + +- 前置:A/B 同名 marker,两个独立 session。 +- 步骤:S1 切 B,S2 保持 A;交错执行 100 次读取。 +- 断言:S1 始终读 B,S2 始终读 A;各自 resourceId 固定且不同;UI 上下文独立。 + +**CM-09 跨调用临时状态(D/F)** + +- 前置:driver 声明有状态会话。 +- 步骤:第一次调用创建临时表并设置会话变量;第二次插入;第三次读取;另一 session 尝试访问。 +- 断言:原会话可读取值,另一会话不可访问该临时状态;原会话底层身份连续。 + +**CM-10 原始 SQL 顺序(D)** + +- 前置:初始 A;A/B marker 值不同。 +- 步骤:同一脚本执行 A 查询、USE B、B 查询。 +- 断言:结果依次 A/B,各结果来源分别 A/B,最终上下文 B;执行前没有提前切 B;语句顺序保留。 + +**CM-11 USE 失败(D/F)** + +- 前置:A 会话;B 不存在或无权限。 +- 步骤:执行 USE B,再在下一次请求读取当前库。 +- 断言:错误明确;实际与 UI 保留 A;contextRevision 不虚增为 B。 + +**CM-12 USE 成功后查询失败(D/F)** + +- 前置:A 会话,B 存在,B 内 missing 表不存在。 +- 步骤:USE B 后查询 missing,再读取当前库。 +- 断言:查询失败但当前库 B;不能按整体错误恢复 UI 为 A;之后合法读取在 B。 + +**CM-13 限定名与补全不切库(D/F)** + +- 前置:A 会话;补全能加载 B。 +- 步骤:输入残缺 `B.`、补全对象、执行 B 限定名查询,再执行无前缀 A marker 查询。 +- 断言:推测/补全能变化;observedContext 仍 A;后续读取 A;无 changeContext 调用。 + +**CM-14 字符串、注释、过程与批次(D)** + +- 前置:driver fixture 提供该方言合法字符串、注释、过程/批次分隔语法。 +- 步骤:执行含文本 USE 和嵌入分号的 SQL;实际支持的动态 SQL改变上下文;读取状态。 +- 断言:文本不触发切库,语法不被 Host 分号正则破坏;动态变化由实际观察确认或明确 Unknown。 + +**CM-15 PostgreSQL 切 database 替换(D/F)** + +- 前置:A/B、旧 session 已建立且无事务、预算允许候选连接。 +- 步骤:UI 切 B;记录候选建立、绑定替换、旧连接关闭顺序。 +- 断言:新 dbSessionId,真实 current_database 为 B;先成功后替换,旧 ID不能执行。 + +**CM-16 替换失败保留旧连接(H/D/F)** + +- 前置:旧 A 正常,候选 B 建立失败或预算不足。 +- 步骤:请求切 B,再在 A 执行读取。 +- 断言:旧 session 和 A 仍有效,UI 不显示 B;候选资源和许可释放;提示失败。 + +**CM-17 手写事务旅程(D/F)** + +- 前置:事务型表及固定 session。 +- 步骤:用户 SQL BEGIN、INSERT、读取;另一会话读取;原 session ROLLBACK;再次读取。 +- 断言:真实可见性符合所选隔离级别;状态 Active→None;UI 同步;数据回滚。 + +**CM-18 aborted/unknown 事务(H/D/F)** + +- 前置:可观察 aborted 的 driver;另设 observe 失败 fake。 +- 步骤:事务内制造错误;请求切库/requireNoTransaction 关闭;执行显式回滚或关闭。 +- 断言:不能把 aborted/unknown 显示为 none;切库被阻止;处理后下一合法操作顺畅。 + +**CM-19 driver 无状态能力(H/D)** + +- 前置:statefulSession 为 unsupported/unknown。 +- 步骤:请求固定 SQL session;执行支持的受控目标操作。 +- 断言:前者 CapabilityUnsupported,后者按能力可成功;不把单连接池假装成固定资源。 + +### 16.3 并发、预算、资源清理与取消 + +**CM-20 会话串行且跨会话并行(H)** + +- 前置:S1/S2、fake command barrier。 +- 步骤:阻塞 S1/E1,提交 S1/E2 与 S2/E3;释放 E1。 +- 断言:E2 不早于 E1 完成,E3 可先完成;同 resource 并发计数最大为 1。 + +**CM-21 切库与执行竞态(H/F)** + +- 前置:revision R 的慢查询;UI 切库被禁用。 +- 步骤:直接 API 排队 context change;再提交携带旧 R 的执行;完成查询和切换。 +- 断言:切换按序;旧 revision 执行 ContextConflict,不在新库默默执行。 + +**CM-22 精确取消不阻塞(H/D)** + +- 前置:长查询 E1、另一个 session E2;driver 支持精确取消。 +- 步骤:E1 阻塞时发 cancel,并继续 E2。 +- 断言:控制路径实际收到 cancel,不等待 E1 队列结束;E2 不受影响;终态与事务观察一致。 + +**CM-23 旧取消与已完成取消(H/D)** + +- 前置:E1 已结束/旧 runtimeEpoch 或旧 resourceBindingId;新执行 E2 正在运行。 +- 步骤:重复取消 E1,伪造旧 cancelHandle 指向 E2。 +- 断言:CancelReceipt.disposition=alreadyFinished;内部伪造绑定被拒绝;不会取消 E2;registry 不残留 E1。 + +**CM-24 无取消能力(H/D/F)** + +- 前置:preciseCancel unsupported 的长执行。 +- 步骤:请求 cancel,再显式选择关闭自己的 session。 +- 断言:先返回 CancelReceipt.disposition=unsupported;不调用 legacy session-wide cancel;显式关闭按实际效果终结。 + +**CM-25 结果未消费不能归池(H/D)** + +- 前置:流结果屏障,短资源池容量 1。 +- 步骤:E1 已产出部分数据但未完成协议,提交 E2;结束/取消 E1 并清理。 +- 断言:E2 之前不能取得同资源;清理确认后可复用或拿新资源;无残留数据混入。 + +**CM-26 reset/rollback/close 故障矩阵(H/D)** + +- 前置:三个阶段分别注入失败/超时;记录资源和 budget journal。 +- 步骤:每种故障执行短操作并释放,随后申请另一操作。 +- 断言:旧资源不复用;回滚失败不报告 RolledBack;close 未确认继续占预算;确认关闭后只核销一次。 + +**CM-27 部分建连与初始化失败(H)** + +- 前置:在 permit、隧道、socket、握手、初始化、注册阶段分别注入失败。 +- 步骤:逐阶段尝试首次执行,再关闭/重试。 +- 断言:没有已执行 SQL;可确认关闭的资源许可归零;隧道引用正确;未确认关闭进入隔离。 + +**CM-28 重复 release/close(H)** + +- 前置:同 Lease、多个关闭请求;cleanup 与 timeout 竞态。 +- 步骤:并发释放 20 次,再重复查询 tombstone。 +- 断言:driver close 至多一次有效关闭;预算不负数;隧道不多减引用;重复响应一致。 + +**CM-29 多维预算与队列取消(H)** + +- 前置:目标总额度 4、控制预留 1、用户额度 2。 +- 步骤:多个用户同时请求 20 个资源;取消等待者;释放一个资源。 +- 断言:总物理数不超过 4、普通占用不侵占控制预留、用户不超过 2;取消者不建连;等待者公平取得资源。 + +**CM-30 idle 连接计入预算(H/D)** + +- 前置:pool 容量 2,预算 2。 +- 步骤:两次短操作归池;请求独立固定 session;关闭一个 idle 连接。 +- 断言:归池后预算仍占 2;固定 session 先等待;确认 idle close 后才有额度。 + +**CM-31 多端 AB/BA 申请(H)** + +- 前置:端点 A/B 各容量 1,两任务需要两端。 +- 步骤:同时申请 A→B 与 B→A,取消一个任务或推进调度。 +- 断言:无循环等待;一个任务可取得完整资源并结束;超时者无半边资源泄漏。 + +**CM-32 隧道引用与并行任务(H)** + +- 前置:两个 session/Job 共用同版本隧道。 +- 步骤:关闭第一个,继续第二个;第二个结束。 +- 断言:第一步不关闭隧道;最后一个引用释放才关闭;隧道失败传播给依赖资源。 + +### 16.4 表格、缓存、结果与配置 + +**CM-33 多表 tab 共用短操作池(H/F)** + +- 前置:20 个 TablePanel,pool max=2,两个已连接 QueryPanel。 +- 步骤:并发刷新表格,查询编辑器当前库和临时状态。 +- 断言:表格并发资源不超过 2,不为每 tab 建连接;编辑器资源/上下文不变。 + +**CM-34 表编辑事务范围(H/D/F)** + +- 前置:表有 PK/version,driver 支持该表事务;界面累计两条修改。 +- 步骤:编辑但不保存;保存成功;再制造第二条写入冲突/错误。 +- 断言:编辑阶段无长期事务;保存同 Lease 短事务;第二条失败则两条修改均回滚;没有事务保证的目标须预先拒绝原子保存,或由用户显式选择非原子模式并报告部分结果。 + +**CM-35 旧结果正确归属(H/D/F)** + +- 前置:A 表结果,之后编辑器切到 B。 +- 步骤:对旧结果导出/诊断;对可写映射提交 PK 修改。 +- 断言:仍使用 A 的来源与重新授权;不写 B;没有可写映射的任意 SQL 结果保持只读。 + +**CM-36 临时对象结果失效(D/F)** + +- 前置:结果来自临时表和 session S。 +- 步骤:关闭 S,再尝试编辑旧结果。 +- 断言:SessionLost;不在新 session 建同名对象或写普通表;结果本身按策略仍可阅读。 + +**CM-37 缓存竞态与权限隔离(H)** + +- 前置:A 元数据慢读取;刷新 revision;U1/U2 权限范围不同。 +- 步骤:刷新后返回旧读取;分别读取缓存;配置版本变化后再读取。 +- 断言:旧读取不能重新填充新 cacheRevision;不同权限/版本不串缓存;临时对象不进入全局缓存。 + +**CM-38 配置修改与凭据轮换(H/W)** + +- 前置:旧 session 与 idle pool,configRevision=1。 +- 步骤:CAS 更新为 2、轮换凭据;提交旧 expectedRevision 请求;建立新 session。 +- 断言:旧请求冲突/按明确政策拒绝;新资源用新材料;旧 idle 不再发放;旧 session 不被悄悄改配置。 + +**CM-39 配置禁用/删除(H/W)** + +- 前置:已有 session、排队请求与 Job。 +- 步骤:管理员禁用,分别测试 drain 与 force 策略。 +- 断言:新/排队请求不执行;已运行操作按实际取消结果记录;审计与 Job 历史仍可定位原配置版本。 + +### 16.5 三件套与 Workflow + +**CM-40 任务关闭窗口继续(H/F/W)** + +- 前置:运行中的迁移 Job,源目标 Lease 均归 Job。 +- 步骤:关闭窗口/浏览器,重新打开并按 jobId 订阅。 +- 断言:driver 未被 UI release;任务继续;重订阅不重新创建 Job;权限仍检查。 + +**CM-41 计划过期与自覆盖(H/D)** + +- 前置:两个 profile 指向同一对象;另有 review 完成的计划。 +- 步骤:执行危险自覆盖;修改目标结构或配置版本后应用旧计划。 +- 断言:阻止自覆盖;旧计划 PlanStale;没有首条目标写入。 + +**CM-42 Schema Diff 非事务 DDL 失败(D)** + +- 前置:真实 driver 声明计划含不可回滚 DDL。 +- 步骤:第一条 DDL 成功,第二条失败,调用回滚/取消。 +- 断言:PartiallyApplied,列出已生效对象;不能报告完整 rollback;缓存失效范围正确。 + +**CM-43 Data Sync review 后冲突(D/F)** + +- 前置:比较生成 ChangeSet,审阅阶段不占快照;另一连接修改目标行。 +- 步骤:应用原 ChangeSet,再处理冲突重比对。 +- 断言:检测旧值/version 冲突,不无提示覆盖;新比对可继续;门闸和结构再次验证。 + +**CM-44 Data Sync 批次取消(D)** + +- 前置:3 批写入,第一批已提交、第二批事务内阻塞。 +- 步骤:取消并释放阻塞,查询目标和 Job 结果。 +- 断言:第一批保留,第二批回滚,第三批 NotStarted;终态明确部分提交。 + +**CM-45 session mode 生命周期(D)** + +- 前置:需要 identity/约束会话模式的 driver fixture。 +- 步骤:开启、写入、关闭;再注入关闭失败。 +- 断言:整个流程同资源;失败资源销毁;下一 consumer 不继承模式。 + +**CM-46 Transfer 背压与超大值(H/D)** + +- 前置:源高速、目标 barrier,缓冲上限 8 MiB;有超大单字段。 +- 步骤:持续读取后阻塞目标;恢复;触发取消。 +- 断言:已缓冲字节不越配置,超大值按分块/上限明确处理;取消唤醒两端,无悬挂 reader。 + +**CM-47 提交与检查点崩溃窗口(H/D)** + +- 前置:目标批次事务内记录幂等 batchId。 +- 步骤:目标 commit 成功、持久化本地 checkpoint 前模拟进程崩溃,再恢复。 +- 断言:查询目标记录确认提交,不重复插入;无证据时 OutcomeUnknown,不按旧 offset 重跑。 + +**CM-48 源变化与快照并行(D)** + +- 前置:读取中源发生更新;分别开启支持/不支持协调快照的能力。 +- 步骤:并行 reader 或断点恢复。 +- 断言:不支持时拒绝严格一致并行;支持时 reader 使用同一已证明快照;源证据变化拒绝无条件恢复。 + +**CM-49 SQL 文件传输(H/D)** + +- 前置:source→SQL artifact Job,无 database target。 +- 步骤:生成文件、取消中途文件、重新下载成功文件。 +- 断言:目标连接创建数为 0;未完成产物不伪装成功;下载按 ACL 校验。 + +**CM-50 Workflow 独立 step(H/D)** + +- 前置:workflow 默认 A;step1 在自己的 Lease USE B。 +- 步骤:step2 不指定目标;step3 显式目标 C。 +- 断言:step2 使用 A,step3 使用 C;step1 状态不泄漏;资源全部清理。 + +**CM-51 Workflow session block(H/D)** + +- 前置:3 个 step 共用 block,第一步创建临时对象。 +- 步骤:后续写读临时对象;尝试同 block 并行;再启动另一 block。 +- 断言:原 block 同资源连续;并行配置被拒绝;另一 block 独立;block 结束关闭。 + +**CM-52 Workflow transaction block(H/D)** + +- 前置:事务型目标,2 个写 step;runtime 控制事务。 +- 步骤:第二步失败;尝试用户 COMMIT/跨 connection/隐式提交 DDL;经完整回滚后重试块。 +- 断言:不支持计划执行前被拒绝;合法失败完整回滚;重试整个块而非中间 step;未知 commit 不自动重试。 + +**CM-53 AI/MCP/Wapp 归属(H/W)** + +- 前置:用户 editor、MCP 客户端、Wapp 各自 owner。 +- 步骤:默认 AI 读取临时对象失败;用户授权绑定 editor 后读取;其他客户端伪造 session。 +- 断言:授权后同队列可见;未授权拒绝;客户端断开只清理自己的资源。 + +### 16.6 Web、多实例、断线与性能 + +**CM-54 请求超时与幂等写入(H/W)** + +- 前置:请求已接受并执行写入,响应丢失。 +- 步骤:同 key 重发、getExecution、恢复事件订阅。 +- 断言:同 executionId、数据库写入一次;无法读取记录时要求核验,不用新 key 自动再写。 + +**CM-55 事件重复/乱序/超大 revision(F/W)** + +- 前置:旧 epoch、旧 session ID、乱序 sequence 和大 Counter 事件。 +- 步骤:依次投递旧 context、重复 chunk、新 context、缺口。 +- 断言:旧事件不覆盖状态、不重复行;大计数精确;缺口读取快照或补订阅。 + +**CM-56 网络掉线与事务 TTL(H/F/W)** + +- 前置:session 已建立、有事务;fake clock。 +- 步骤:断开客户端,grace 内重附着;再次断开并超过事务空闲/保留期限;持续发登录心跳。 +- 断言:grace 内原资源连续;到期显式关闭并记录回滚;心跳不延长事务业务空闲;下一执行要求新 session。 + +**CM-57 多 API 实例会话路由(W)** + +- 前置:API A/B、worker W1/W2;session 归 W1。 +- 步骤:通过 A/B 交错执行、切库、取消;不启用 sticky session。 +- 断言:均到 W1,同物理资源串行;W2 不创建替代连接;每次鉴权。 + +**CM-58 worker 故障与租约分区(H/W)** + +- 前置:W1 有 session 和执行中的写 Job,协调器租约可失效。 +- 步骤:断开 W1 与协调器;尝试 W2 接管和申请旧额度;恢复 W1。 +- 断言:W1 停止新派发;旧 session SessionLost;旧额度未确认隔离前不复用;Job 核验/Unknown,不盲重写。 + +**CM-59 登出/权限撤销/CSRF(W)** + +- 前置:cookie 登录、SSE、排队执行和运行 Job。 +- 步骤:伪造跨站修改请求;撤销权限/登出;继续订阅、下载和提交请求。 +- 断言:CSRF 拒绝;旧订阅关闭/停止数据;排队不执行;运行操作按权限取消政策记录真实结果。 + +**CM-60 资源压力与 drain(H/W)** + +- 前置:总额度 20、控制预留 2;100 用户逻辑 session、短请求 fake 固定耗时 10 ms、队列上限 32。 +- 步骤:提交 1000 次操作与取消,两个 worker 分配额度;将一个 worker drain;全部完成后关池。 +- 断言:每次资源 journal 变化点真实资源不超过总额度;普通资源不侵占预留;拒绝/等待有界;drain 不接新资源;结束后非保留资源、任务、取消句柄和许可均归零。 +- 性能门槛:单机 fake 基准(4 vCPU/8 GiB、预热后 5 轮)非排队网关附加耗时 p95≤10 ms,事件重复/丢失数为 0;记录实测环境。它是 runtime 回归指标,不是远程数据库 SQL 延迟 SLA。 + +### 16.7 补充契约与边界用例 + +**CM-61 持久化白名单与临时结果恢复(H/F)** + +- 前置:普通表/临时表结果,包含 runtimeBinding 的实时 ExecutionView。 +- 步骤:保存执行、产物、事件审计和 checkpoint;销毁 runtime 后恢复;尝试写回两种结果。 +- 断言:落盘结构无 dbSessionId/SessionHandle/resourceBindingId/lease/cancel 句柄;普通表重新授权并验证映射后可写,临时结果只读;相同配置新 session 不能接替原绑定。 + +**CM-62 命名空间规范化矩阵(H/D)** + +- 前置:driver 提供 namespaceShape、session/object 两类 targetRequirements、path 别名规则。 +- 步骤:分别提交缺字段、不存在层级非 null、required null、optional null、同义 path、冲突 path、大小写别名。 +- 断言:前三种执行前拒绝,optional 按操作允许;同义 ID 规范化一致;冲突 TargetConflict;缓存/权限/driver 使用相同 CanonicalTarget,无文件路径误用。 + +**CM-63 Attachment、TTL 与竞态(H/F/W1)** + +- 前置:fake clock、事务 session、原 owner token 与同 principal 另一 client。 +- 步骤:detach 后重复 detach;同 principal 无 token attach;原 owner grace 内 attach;推进事务 TTL 并同时 attach/close。 +- 断言:重复 detach 不延期限,无凭据拒绝;合法 attach 不刷新事务 idle;最早期限生效;Closing/expired 不重附着;heartbeat 不改变期限。 + +**CM-64 无消费者、截断与有界 drain(H/D/F)** + +- 前置:8 MiB buffer、低 artifact quota、无消费者,分别设可完成/不可完成协议 barrier。 +- 步骤:unsubscribe/关闭结果视图;超时或耗尽额度;执行 drain、取消与故障清理。 +- 断言:unsubscribe 不立即取消 SQL;缓冲/产物有界,截断可见;健康固定资源可 Ready;短资源满足宿主检查与 Clean 才复用;未知资源隔离,不误伤同 session 的健康临时状态。 + +**CM-65 类别保留与无饥饿(H)** + +- 前置:服务总额 8,control/metadata/interactive 各保留 1,共享 5;固定编辑器和长 Job 占共享额度。 +- 步骤:持续注入交互请求并发元数据/控制取消;再释放共享额度,多个用户排队;取消部分等待者。 +- 断言:类保留不被侵占,控制可用;持续队列按 4:2:1 获派发;用户轮转无饥饿;不抢占固定资源;队列取消无许可泄漏。 + +**CM-66 逻辑与物理额度(H)** + +- 前置:用户逻辑上限 20、已连接编辑器上限 2,组织逻辑上限 30,总物理上限 4。 +- 步骤:打开 20 个 New,再开第 21 个;并发连接 3 个;关闭一已连接编辑器后重试;另一用户尝试超过组织上限。 +- 断言:New 无 socket 但计逻辑数;超限 SessionQuotaExceeded;连接名额原子预留,失败回收;物理、用户、组织额度各自正确。 + +**CM-67 PoolKey 版本和多 database(H/D)** + +- 前置:database-bound driver,两个 database、共享 DB 账号但不同 policy 的用户;低总预算。 +- 步骤:轮流浏览 A/B;轮换凭据、路由和 ACL;慢缓存结果晚到;推进 idle TTL。 +- 断言:按 database/policy 分 key;总 socket 不越预算;旧 idle 停发并关闭;旧缓存不能回填,撤权即时生效;空 pool 元数据受 LRU 上限约束。 + +**CM-68 候选替换发布与响应丢失(H/W1/WN)** + +- 前置:可替换 driver、旧 session;目录与替换提交可注入故障。 +- 步骤:候选建连时尝试 get/execute;提交前失败;提交成功但目录发布/响应丢失,再同 key 重试。 +- 断言:候选不可公开使用;提交前旧 session 有效且候选清理;提交后旧 ID 不执行,只恢复同一新 session/receipt;无重复候选或已成功却无目录的回复。 + +**CM-69 Clean 不能绕过宿主检查(H)** + +- 前置:fake driver 返回 Clean,但分别存在 active execution、未完成协议或取消句柄。 +- 步骤:请求归池,随后申请另一短 Lease。 +- 断言:每种情况均拒绝复用并走关闭/隔离;全部宿主条件及 driver Clean 通过时才可 Returned。 + +**CM-70 过期幂等键与记录删除(H/W1)** + +- 前置:签名令牌、fake clock,写入已接受且响应丢失。 +- 步骤:有效期内重发同/不同输入;过期重发;超过记录保留期删除记录,再重放令牌;伪造 issuedAt/keyVersion。 +- 断言:同 receipt、不同输入冲突;过期和删除后均不再执行;伪造拒绝;客户端不能用新键自动重试未知写入;open/context 的 owner 重启后旧令牌 SessionLost,运行时 receipt/token 不落盘。 + +**CM-71 worker ID 唯一性与原子登记(H/WN)** + +- 前置:两个 worker 使用随机 runtimeEpoch/dbSessionId,目录支持唯一约束和 CAS;fake 强制 ID 碰撞。 +- 步骤:并发创建,碰撞后重生成;目录丢失/owner epoch 失效后发送旧请求和取消。 +- 断言:一个 ID 只登记一个 owner;未登记不返回成功;旧 handle 明确失效;不借配置找替代 session;不需要中央 ID 分配器。 + +**CM-72 错误、控制回执与能力审计(H/F/W1)** + +- 前置:取消支持/不支持/已结束三种执行;UnsupportedPlan、EndpointOverlap 和旧 epoch 请求。 +- 步骤:逐项调用,序列化 IPC/HTTP,重放前端状态;读取 durable provenance。 +- 断言:CancelReceipt 三种 disposition 与终态分离,无 accepted 字段漂移;错误 code/大小写一致;effectOutcome 不被请求成功覆盖;审计包含非敏感能力版本,不含 live handle 或凭据。 + +**CM-73 空闲淘汰与活动事务句柄连续旅程(H)** + +- 前置:同一 session/resource 上通过 `begin_session_transaction` 创建真实 fake TransactionHandle;事务句柄在命令状态映射中,session 资源已空闲且引用计数为 0;fake clock 可驱动 idle timeout。 +- 步骤:开始事务并写入未提交 marker;推进到空闲淘汰期限并运行 cleanup;查询事务状态、尝试 commit/rollback,再用旧 dbSessionId 发起查询;记录物理 resourceId、事务 map、回滚/关闭调用。 +- 断言:淘汰不得在活动事务仍映射时静默关掉物理资源后保留可提交句柄。目标行为是在回收时先在原 resource 回滚并移除/终结事务映射,再关闭资源;回滚结果未知则转 OutcomeUnknown/SessionLost,不重新连接后复用旧 dbSessionId 或在新 resource 提交旧 handle。事务状态不得在物理 session 丢失后继续报告 Active。失败后的显式新 session 使用新 ID,marker 不提交。 +- 基线说明:旧实现应先记录复现证据:`cleanup_idle_connections` 保留 owner map,`get_session` 通过该 map 同 ID 重连,而 `session_transactions` 由命令层单独管理。此用例在连接管理重构完成前可作为已知失败,完成后必须转绿;不可删掉断言或用仅检查 UI 文案替代。 + +## 17. 验收标准与证据 + +| 门槛 | 必须通过的用例 | 必须提交的证据 | +| --- | --- | --- | +| 类型/身份/目标 | CM-01~07 | 类型与 schema 测试、越权入口矩阵 | +| 会话正确性 | CM-08~19 | driver 真实状态 journal、连续 UI 旅程 | +| 并发/清理/预算 | CM-20~32 | 故障注入、许可会计、竞态测试 | +| 表格/来源/缓存 | CM-33~39 | 目标/权限/版本断言 | +| 三件套/Workflow | CM-40~53 | 提交边界、恢复核验、Job owner 断言 | +| 断线/事件/幂等 | CM-54~56、70、72 | runtime/前端与 W1 适配一致性 | +| Web/多实例/压力 | CM-57~60、71 | 按 W1/WN 阶段提交路由、分区与基准证据 | +| 事务失效回收 | CM-73 | idle cleanup、事务 map、物理 resource 与后续显式重连的连续旅程 | +| 补充契约 | CM-61~69 | 持久化、规范化、TTL、结果流、调度、额度与故障 journal | + +阶段性验收按开发计划选择对应门槛;最终验收覆盖全部适用用例,包括 CM-73;基线阶段可暂时记录该遗留缺陷,P3 连接运行时阶段必须转绿。能力 Unsupported 的 driver 测试断言“正确拒绝”,不能静默 skip 后宣称能力已验证。真实测试环境不可用必须报告未验证范围,不能用 fake 代替真实协议结论。 + +结束时还必须满足:生产无裸 unwrap/expect、公共 API 无实现库类型、测试 typecheck 干净、生成文件未提交、无新增宿主数据库名称分支、无敏感日志。新驱动实现现有能力时,修改范围仅驱动包/选型/注册/元数据和测试。 + +每个功能同时验证:原缺陷解决、合法同类操作未误伤、失败后的下一步可继续。比如 USE 失败后仍能查 A,取消后能明确重新执行,计划过期后能重新比对,worker 丢失后能显式创建新会话。 + +## 18. 文档与交付维护 + +实现者按测试编号提交代码和正式架构说明,不新增进度台账/缺陷清单文件。协议变化同步所有 driver 实现;外部 Git driver 在其独立仓库更新。开发仅改 en.ts,发布前补齐翻译。迁移完 consumer 后删除旧路径与失效引用,最终将本文目标设计改写为已实现事实。 diff --git a/docs/architecture/platform/system-overview.md b/docs/architecture/platform/system-overview.md new file mode 100644 index 000000000..cc7617807 --- /dev/null +++ b/docs/architecture/platform/system-overview.md @@ -0,0 +1,315 @@ +# DataZen 桌面与团队 Web 系统概要设计 + +> 状态:目标设计,尚未整体实现。基线:2026-09-30,代码提交 `8592b0fe1`。 +> 本文按用户明确要求保存设计文档,不把规划表述为当前功能。实现后逐节改写为事实,删除已失效的过渡约定。基线复核:§2 引用文件的连接/调用语义变化、`PROTOCOL_VERSION` 变化、任一阶段开始落地时,必须重新核对相关事实与跨文档契约。 +> 配套:[连接管理详细设计](connection-management.md)、[分阶段开发计划](../../development/platform-development-plan.md)。 + +## 1. 目标与范围 + +同一套 React 产品和 Rust 业务内核支持三种方式: + +| 方式 | 前端到后端 | 执行位置 | 凭据位置 | +| --- | --- | --- | --- | +| 桌面本地 | Tauri IPC | 用户电脑的本地 Runtime | 本地 SecretProvider | +| 浏览器团队 | HTTPS API + SSE | 团队服务 Runtime | 服务端 SecretProvider | +| 桌面团队 | HTTPS API + SSE | 团队服务 Runtime | 服务端 SecretProvider | + +共享范围包括 Driver Command、元数据、SQL 执行、连接资源、事务、Schema Diff、Data Sync、Data Transfer、Workflow、AI、MCP 和任务生命周期。平台差异集中于传输、身份、持久化、文件、窗口和网络部署。 + +首个团队版本采用模块化单体:HTTP API、管理服务和执行 worker 可以运行在同一进程。逻辑边界允许后续拆为多个 worker,不提前引入微服务、分布式事务或原生驱动热加载。 + +首版不支持:任意 SQL 会话无损故障转移、跨服务器原子事务、浏览器代理数据库流量、不同 backend 之间直接迁移、团队成员任意上传不可信原生驱动。私网部署通过把 Runtime 放入可达网络解决;远程 agent 是后续扩展。 + +## 2. 当前实现与改造入口 + +以下路径已在基线代码核对,新增包和接口则属于目标设计。 + +| 当前入口 | 已确认事实 | 目标改造 | +| --- | --- | --- | +| [AppState](../../../src-tauri/src/commands/mod.rs) | 聚合连接、事务、任务、AI、Workflow 等服务 | 组装留在 host,应用服务只接收必要依赖 | +| [ConnectionManager](../../../src-tauri/src/services/connection_manager.rs) | 管理 handle、配置、隧道、引用计数 | 拆分 Profile、Session、Lease、Budget | +| [session 实现](../../../src-tauri/src/services/connection_manager/sessions.rs) | 按 connectionId 复用 session;物理连接缺失时 `get_session` 会以同一 `dbSessionId` 重连。空闲清理保留 owner 映射;它没有同步清理 `AppState.session_transactions`,可能留下指向已关闭物理会话的事务句柄,详见 [tunnels.rs](../../../src-tauri/src/services/connection_manager/tunnels.rs)、[query.rs](../../../src-tauri/src/commands/query.rs) 与 [AppState](../../../src-tauri/src/commands/mod.rs)。 | 按明确生命周期使失效会话返回 `SessionLost`,禁止透明重建;活动事务回收须先回滚并清理事务状态,结果不确定时报告 `OutcomeUnknown`。用 CM-73 覆盖该连续旅程 | +| [Driver API](../../../packages/driver-api/src/traits.rs) | 提供 DatabaseDriver、Command、迁移等能力 | 增加固定执行资源契约、可选能力接口 | +| [Factory](../../../packages/driver-api/src/factory.rs) | inventory 编译期注册 | 桌面与 server 使用同一注册入口 | +| [前端 Command SDK](../../../packages/driver-sdk/src/ipc/driverCommands.ts) | 直接导入 Tauri invoke/Channel | 注入传输无关 BackendClient | +| [Workflow Command runtime](../../../src-tauri/src/workflow/command_runtime.rs) | 解析 connectionId,传递目标 database/schema | 明确 step/session/transaction 三种资源范围 | +| [dedicatedDbSession](../../../src/lib/dedicatedDbSession.ts) | 三件套准备会话由前端创建、释放 | 执行资源由后端 Job 持有 | + +Driver 公共类型继续遵守[驱动依赖边界](../../development/driver-api-dependency-boundary.md)。基线 `packages/driver-api/src/lib.rs` 的 `PROTOCOL_VERSION` 为 4、最低版本为 1;实现时重新核对,不能从旧架构说明复制版本数字。 + +## 3. 系统架构 + +```mermaid +flowchart TD + Desktop[桌面 React] --> IPC[Tauri adapter] + Desktop --> API[HTTP API adapter] + Browser[浏览器 React] --> API + IPC --> App[Application services] + API --> Auth[认证与 RequestContext] + Auth --> App + App --> Policy[授权与审计] + Policy --> Runtime[Execution Gateway / Job Runtime] + Runtime --> Resources[Session Registry / Resource Manager] + Resources --> Drivers[Driver API / Registry] + Drivers --> DB[目标数据库] + App --> Repos[配置 / 权限 / 任务 repositories] + Runtime --> Artifacts[结果与文件 ArtifactStore] +``` + +部署拆分后,API 负责入口和路由;worker 持有真实连接、事务、游标和驱动实例。SessionDirectory 记录 owner worker;JobRepository 保存任务状态。共享存储不保存可迁移的数据库连接。 + +## 4. 模块职责与边界 + +| 模块 | 职责和拥有的数据 | 输入/输出 | 禁止依赖或行为 | +| --- | --- | --- | --- | +| Product UI / Stores | tab、编辑草稿、浏览位置、已确认状态投影 | BackendClient、PlatformServices | 不决定物理连接、不保存团队密码、不自行确认事务状态 | +| BackendClient | 类型化业务调用、流订阅、backend 路由 | DTO、事件、ApiError | 不包含业务 SQL 或数据库类型分支 | +| PlatformServices | 文件选择/上传/下载、窗口、剪贴板 | artifactId、用户操作 | 不执行数据库业务 | +| Tauri adapter | IPC 反序列化、本地身份、事件映射 | 应用服务请求 | 不持有第二份执行逻辑 | +| HTTP adapter | 认证、CSRF、请求限制、响应映射 | RequestContext、DTO | 不直接调用具体驱动 | +| Application services | 用例、目标解析、策略编排 | 业务请求、Execution/Job 引用 | 不引用 Tauri、HTTP 框架或 React | +| PolicyService | connection/session/job/result 授权 | AuthorizationDecision | 不通过前端角色标记授予权限 | +| ProfileRepository | 连接配置、版本、共享范围 | ConnectionProfile | 不保存 runtime dbSessionId | +| SecretProvider | 解密/取凭据、轮换、短期执行材料 | secretRef + 执行身份 | 不把密码返回到普通查询响应 | +| DriverRegistry | 描述、协议检查、factory 注册 | descriptor、provider | 不按数据库名称拼 SQL | +| ExecutionGateway | 参数/能力/权限校验、执行登记、取消、来源 | ExecutionReceipt、事件 | 不创建隐式共享会话 | +| SessionRegistry | owner、状态机、串行队列、上下文 revision | SessionView | 不用 connectionId 代替 session 身份 | +| ResourceManager | pool、lease、预算、隧道引用、清理 | ResourceLease | 不把清理失败资源归池 | +| MetadataService | 明确对象身份、批量读取、缓存失效 | 元数据、revision | 不改变编辑器当前库 | +| JobRuntime | 长任务、阶段、子执行、提交边界、恢复 | JobView、检查点 | 不把 UI 生命周期当任务生命周期 | +| 迁移领域引擎 | 结构 IR、ChangeSet、转换与计划;由对应领域包拥有 | 计划、执行需求、结果 | 不自行绕过预算建连接、不依赖桌面窗口 | +| MCP adapters | Tool/Resource/Prompt、MCP client identity 与授权委托 | Job/Execution DTO、MCP 协议 | 不向 MCP client 暴露数据库凭据或 runtime handle | +| Wapp host bridge | 桌面/Web iframe 沙箱、能力声明、来源与消息校验 | 受限 BackendClient Command、Artifact | 不让 Wapp 直连 Driver/HTTP API,不传 session/secret | +| EP host | 桌面进程内受信特权扩展与版本校验 | extension-points 契约 | Web server 不加载桌面 native EP;同等团队能力走服务端授权 API | +| Theme delivery | 校验并分发静态主题资源 | manifest、静态 assets | 无可执行脚本能力,不访问数据库或服务端密钥 | +| Workflow Runtime | 目标继承、step/block、DAG、重试 | run、子任务 | 不继承 GUI 当前数据库 | +| Driver capabilities | 协议、方言、实际会话语义、能力探测 | opaque resource、CommandResult | 不 import host、不弹原生对话框 | +| RuntimeRouter | 会话路由、能力/网络匹配、worker drain | owner worker、执行响应 | 不把旧 session 绑定到替代 worker | +| ArtifactStore | 结果分块、上传、导出、TTL、访问授权 | artifactId、受控数据 | 不接受任意服务器绝对路径 | + +### 4.1 目标代码组织 + +以下目录尚未创建;实施阶段才加入 workspace/build 配置。 + +```text +packages/application/ Rust 用例、DTO、错误、授权编排 +packages/runtime/ Rust session、lease、资源调度、execution、job +packages/platform-api/ Rust SessionDirectory/BudgetCoordinator 等 ports 与环境适配 +packages/schema-diff/ 共享 Schema Diff 领域引擎(从 src-tauri 分阶段抽取) +packages/data-sync/ 共享 Data Sync 领域引擎(从 src-tauri 分阶段抽取) +packages/data-transfer/ 共享 Data Transfer 领域引擎(从 src-tauri 分阶段抽取) +packages/driver-api/ 现有 Driver API 与可选能力 +packages/backend-client/ TypeScript DTO/client/transport 契约 +packages/wapp-sdk/ Wapp 受控桥 SDK +packages/extension-points/ 桌面特权 EP 契约 +packages/themes/ 静态主题资源与 schema +server/ Rust HTTP host 与服务端 composition root +src-tauri/ 桌面 host、IPC/文件/窗口 adapter 与遗留命令 +src/ 共享 React 产品与平台桥 +``` + +依赖方向:host → application/runtime/领域包;application/runtime → platform-api/driver-api;driver → driver-api。`SessionDirectory` 与 `BudgetCoordinator` 的接口归 `platform-api`,SessionRegistry/ResourceManager 只依赖这些 port;桌面/单进程先用内存适配,多 worker 再替换目录和全局预算适配。领域引擎归 `packages/schema-diff`、`packages/data-sync`、`packages/data-transfer`;Tauri 命令、窗口与文件选择留在 `src-tauri` adapter。领域包不得反向 import host。JobRuntime 通过注册的 JobHandler 调用领域引擎,避免 runtime 与领域 crate 循环依赖。 + +## 5. 统一领域对象 + +| 对象 | ID/归属 | 持久化 | 核心含义 | +| --- | --- | --- | --- | +| ConnectionProfile | organizationId + connectionId | 是 | 配置、初始目标、secretRef、configRevision | +| ExecutionTarget | connectionId + NamespaceTarget + 可选对象;请求包装携带 backendId | 是,持久化目标字段;backendId 单独保存为调用/工作区绑定,不属于数据库目标 DTO | 明确执行目标;执行前 backend 绑定必须匹配且重新鉴权 | +| DbSession | dbSessionId + owner + worker | 否 | 连续会话状态,不等于 pool;含 SessionContext 与 contextRevision | +| Transaction state | Session actor 内的事务观察与句柄 | 否 | 不分配独立业务 ID;关闭/失效时与 Lease 同步回滚、核验和清理 | +| ResourceLease | leaseId + owner + scope | 否 | 物理资源占用、清理与预算 | +| Execution | executionId + owner | 可保存记录 | 一次执行及实际来源;持久化投影排除运行时绑定 | +| Job | jobId + organizationId + principal | 是 | 长任务、阶段、提交结果、恢复契约 | +| Artifact | artifactId + ACL | 是 | 结果或文件,不等于服务器路径 | + +登录会话、数据库会话、编辑器视图和共享 SQL 文件各有独立 ID。共享 SQL 文件不共享事务;知道 ID 不构成授权。 + +## 6. 接口定义 + +### 6.1 应用上下文 + +`RequestContext` 由 adapter 构造,不允许请求体覆盖: + +| 字段 | 类型 | 规则 | +| --- | --- | --- | +| organizationId | string | 本地模式为固定本地组织;团队模式来自认证与 membership | +| principalId | string | 用户、服务账号或委托身份 | +| authenticationSessionId | string/null | 后台任务使用服务身份,不永久复用浏览器登录 | +| clientInstanceId | string | 已绑定到认证身份的客户端实例;不是授权依据 | +| requestId | string | 日志关联,不包含凭据 | +| delegationId | string/null | 子任务的显式权限委托 | + +应用服务在执行前检查当前权限;权限快照只用于审计。权限撤销使排队请求失效,已执行操作按取消能力停止并记录实际结果,不能假设已经回滚。 + +### 6.2 BackendClient 与领域客户端 + +BackendClient 是绑定单个 backend 的传输门面;`backendId` 用于选择门面,不作为每个业务方法的参数,也不放入 ExecutionTarget。连接资源方法及其 DTO 以[连接管理详细设计 §4.1](connection-management.md#41-服务接口与补充响应)为权威;本表同时标明配置、Job 与 Artifact 方法的归属。 + +| 服务 | 方法 | 输入 | 输出 | 语义 | +| --- | --- | --- | --- | --- | +| ConnectionClient | listConnections | 无 | ProfileView[] | 当前 backend 下列出授权配置;分页另行版本化,本版不传分页参数 | +| ProfileClient | createConnection | profile draft、write-only credentials、idempotencyKey | ProfileView | 创建配置;凭据仅交 SecretProvider | +| ProfileClient | updateConnection | connectionId、expectedRevision、patch | ProfileView | CAS 更新;不改变已建会话 | +| ProfileClient | disableConnection | connectionId、expectedRevision | ProfileView | 禁止新执行,已有资源按 drain/force 政策处理 | +| ConnectionClient | openSession | OpenSessionRequest | OpenSessionReceipt | 懒建逻辑会话,返回仅 owner 持有的 attachmentToken | +| ConnectionClient | getSession | SessionHandle | SessionView | 读取当前 owner 的会话状态 | +| ConnectionClient | executeInSession / executeAtTarget | 对应请求与签名 SubmissionToken | ExecutionReceipt | 返回接受回执,不表示 SQL 已成功 | +| ConnectionClient | setSessionContext | SetSessionContextRequest 与签名 token | ContextChangeReceipt | 原地确认或返回新 session | +| ConnectionClient | attachSession / detachSession | AttachmentRequest | SessionView | 原 owner 身份与 token 校验;不延长业务空闲期 | +| ConnectionClient | closeSession | handle、`mode: CloseMode` | CloseReceipt | 幂等关闭,不默认提交事务 | +| ConnectionClient | getExecution / cancelExecution | executionId、取消操作 token | ExecutionView / CancelReceipt | 查询终态或请求精确取消 | +| ConnectionClient | subscribeEvents | streamId、afterSequence | EventEnvelope 流 | 授权后续订;断开订阅不直接取消 SQL | +| JobClient | startJob / getJob / cancelJob | 定义、计划、jobId 与签名 token | JobView / CancelReceipt | 持久化接受;Job 生命周期独立于窗口 | +| ArtifactClient | readArtifact | artifactId、块范围 | ArtifactChunk | 每次授权、限大小并服从产物 TTL | +| SubmissionTokenClient | issueSubmissionToken | operation、可选 SessionHandle | SubmissionToken | 签名限定调用身份、操作和有效期 | + +Profile、Job、Artifact 的 DTO 和服务归属在本系统概要中定义;它们不属于连接管理 `ConnectionService`。IPC 与 HTTP adapter 都实现这些接口并共享 ApiError。 + +`ContextChangeReceipt` 包含 `session: SessionView`、`replacedSessionId: string | null` 和 `attachmentToken: string | null`;替换时 token 只返回原 owner,原地切换为 null。客户端原子切换到返回 session;失效旧句柄不得继续执行。 + +### 6.3 首版 HTTP 映射 + +以下为待实现路由,不能当作当前可调用 API。 + +| 路由 | 用例 | +| --- | --- | +| GET `/api/v1/connections` | listConnections | +| POST `/api/v1/connections` | ProfileClient.createConnection | +| PATCH `/api/v1/connections/{id}` | expectedRevision 更新或设置 `enabled=false`;分别对应 updateConnection / disableConnection | +| POST `/api/v1/submission-tokens` | 获取签名幂等令牌 | +| POST `/api/v1/sessions` | openSession | +| POST `/api/v1/sessions/{id}/attach` | 认证原 attachment | +| POST `/api/v1/sessions/{id}/detach` | 显式取消 attachment | +| GET `/api/v1/sessions/{id}` | 状态读取 | +| POST `/api/v1/sessions/{id}/executions` | executeInSession | +| PUT `/api/v1/sessions/{id}/context` | setSessionContext | +| POST `/api/v1/sessions/{id}/close` | closeSession | +| POST `/api/v1/executions` | executeAtTarget | +| GET `/api/v1/executions/{id}` | 执行状态 | +| POST `/api/v1/executions/{id}/cancel` | 精确取消 | +| POST/GET `/api/v1/jobs` | 创建/授权列表 | +| GET `/api/v1/jobs/{id}` | 任务状态 | +| POST `/api/v1/jobs/{id}/cancel` | 任务取消 | +| GET `/api/v1/events/{streamId}` | SSE,支持 Last-Event-ID | +| POST `/api/v1/artifacts/uploads` | 配额内上传 | +| GET `/api/v1/artifacts/{id}` | 受控结果/下载 | + +异步接受返回 202 + ID;参数错误 400、未认证 401、无权限 403、不可见资源 404、版本/运行时冲突 409、预算或速率超限 429、暂时无可用 worker 503。无法区分“无权限”和“不存在”的资源统一 404,避免 ID 枚举。 + +`ApiError` 包含 `code`、脱敏 `message`、`requestId`、`retryDisposition`;它是机器可读的重试政策,详细设计错误表里的 UI 动作是用户指引。映射规则:`never` 用于权限、参数、能力和上下文冲突(先修复或刷新);`safeRead` 仅用于确认无副作用的读取;`checkExecution` 用于已接受但结果未知的提交,先 getExecution/核验,不盲目重发。 + +### 6.4 repositories 与环境 ports + +| 接口 | 必须提供的方法语义 | +| --- | --- | +| ProfileRepository | get/list、按 expectedRevision 保存、禁用;以组织限定查询 | +| PolicyService | authorize action/resource、委托校验、权限版本变更通知 | +| SecretProvider | resolve execution identity、读取版本化材料、轮换;材料不序列化到 API | +| JobRepository | create、状态 CAS、claim/renew、检查点、恢复待核验任务 | +| SessionDirectory | 内存 register/get、owner epoch CAS、原子替换提交、invalidate;禁止落盘 | +| SubmissionTokenIssuer | 签名、验证作用域/有效期/owner epoch;未知版本拒绝 | +| ArtifactStore | create writer、按块读取、finalize、TTL 删除、ACL 校验 | +| EventSink | 发布带 sequence 的事件、受控订阅;终态可从 repository 重建 | +| NetworkProvider | 按路由策略建立直连/隧道、端点校验、共享引用 | +| BudgetCoordinator | 申请节点额度、续期、drain;失联时停止新增资源 | + +Desktop adapters 使用现有 Store/keychain/file 管理能力逐步适配;Server adapters 使用服务端元数据数据库、SecretProvider 与 ArtifactStore。第一版推荐 PostgreSQL 管理组织、配置版本、任务、审计和额度,不与用户业务数据库混用。SSE 用于单向状态通知,取消走 HTTP;有双向交互需要时再引入 WebSocket。 + +## 7. 核心交互 + +### 7.1 QueryPanel + +```mermaid +sequenceDiagram + participant UI + participant Adapter + participant Gateway + participant Session + participant Driver + UI->>Adapter: openSession(initialTarget) + Adapter->>Gateway: 已认证 RequestContext + Gateway->>Session: 创建 New session(不建连) + Session-->>UI: SessionView + UI->>Adapter: executeInSession(handle, expectedContextRevision) + Adapter->>Gateway: 校验权限与幂等 + Gateway->>Session: 排队并建立固定资源 + Session->>Driver: 在指定资源上执行 + Driver-->>Session: 结果、实际上下文、事务状态 + Session-->>UI: 带 runtimeEpoch/dbSessionId/revision 的事件 +``` + +SQL 解析用于提示,不提前修改已生效上下文。会话操作串行;取消通过独立控制路径进入,不能排在待取消查询之后。 + +### 7.2 TablePanel 与元数据 + +UI 发送完整对象目标 → 应用服务授权 → 网关获取短租约 → driver 在明确目标执行 → 完成并清理 → 返回结果来源。元数据缓存按组织、权限范围、配置版本与对象身份隔离,临时对象仅绑定原 runtime handle。 + +### 7.3 三件套与 Workflow + +JobRuntime 保存计划、目标、版本和 owner → 执行前验证 → 申请源/目标阶段资源 → 调用领域 handler → 记录已确认提交边界 → 清理 → 发布终态。窗口关闭不释放 Job 资源;审阅等待不长期持有快照。Workflow 显式定义独立 step、共享 session block、transaction block。 + +## 8. 驱动扩展与开闭原则 + +DriverFactory 注册 descriptor、配置 schema、Command schema、资源 provider 和可选能力。能力描述与实际能力接口必须对应;运行时探测可降低静态声明,不能无依据提升权限。 + +稳定底座负责 factory、opaque resource、Command、结果、错误与协议。可选能力包括 namespace/session/transaction/catalog/migration/snapshot/data-read/data-write/backup。能力接口不暴露 SQLx、Tokio、HTTP 客户端或 Tauri 类型。 + +宿主控制 owner、预算、权限和调度;driver 控制协议、方言、连接初始化、固定资源执行、状态观察和安全清理。所有 driver 实际建连必须经过预算 port,包含 cluster、控制连接与 SDK 隐藏连接;不能仅统计顶层 handle 数。 + +普通新增驱动只改变驱动包、选型/注册、schema、UI、文案与该驱动测试。新语义确实超出既有能力时才扩展公共契约,更新版本并迁移所有实现;不通过宿主数据库名称分支绕过。 + +保留 inventory 编译期注册。原生同进程驱动属于可信代码。管理员升级发布包/镜像,worker drain 后切换;独立 driver runner 是后续隔离方案,需版本化 RPC,不能把 Rust trait 当作稳定动态 ABI。 + +## 9. 多实例部署与故障 + +固定 session 路由到持有真实资源的 worker;客户端无需知道 worker。请求附带 runtimeEpoch/dbSessionId。owner 失效返回 SessionLost,不在其他 worker 上复用旧 session ID。替代会话得到新 ID,旧事务和临时状态不能恢复。 + +Job 使用持久化租约和执行 epoch。旧 worker 失去续约后停止派发;fencing token 不会自动阻止已发给外部数据库的写入。接管前核验目标提交记录、幂等标识或数据库锁,无法证明时进入 OutcomeUnknown。不能承诺通用 exactly-once。 + +worker drain 停止新资源与任务,允许现有执行到安全边界。交互会话按通知和期限结束。节点额度失效不能立即重新分配仍可能占用的连接;必须确认旧节点隔离/资源关闭后再回收。 + +### 9.1 Wapp、EP 与 Theme 的平台边界 + +桌面与 Web 均可展示 Wapp iframe,但桥接必须调用当前 backend 的 BackendClient,经 host 校验 `origin`、`source`、一次性 nonce、消息 schema/大小、Wapp 安装身份和能力授权;禁止使用 `*` target origin。桥接只提供声明过的 Command 和 Artifact 操作,不传 dbSessionId、attachmentToken、取消句柄或数据库凭据。Web 部署使用独立受限 origin 与 sandbox/CSP;Wapp 不能从 iframe 自行取得用户 cookie 或调用任意服务端 URL。用户身份与每次操作授权由父应用和服务端重新验证,卸载/撤权即关闭桥接订阅。 + +特权 EP 是桌面 host 内的可信扩展,Web server 不加载 native EP,也不把 EP 包下发到浏览器执行。团队版若需要同等功能,必须走显式授权的服务 API。Theme 只分发 schema 校验后的静态资源,Web CSP 禁止脚本执行;主题不能调用 backend 或访问 secret。Driver/Workspace App/EP/Theme 的扩展面相互独立。 + +## 10. 安全和环境差异 + +团队共享 profile 不共享 session。个人数据库账号按身份隔离池;共享账号下仅允许经过授权和安全清理的短操作复用。任意 SQL 对象访问依赖数据库端账号/角色权限,SQL guard 只是额外防护。 + +Web 认证首版采用 OIDC 登录及服务端登录会话,浏览器使用 Secure/HttpOnly/SameSite cookie;修改操作校验 CSRF。服务调用使用受限服务身份。SSE/未来 WebSocket 持续检查权限失效、登录过期和资源归属;不能只鉴权一次握手。 + +服务端连接地址受出站网络策略控制,包括 DNS 解析后的地址、重定向与隧道目标。`localhost` 指执行节点。文件、TLS 证书和备份工具路径改为经过授权的 artifact/配置引用,禁止普通用户任意读写服务器路径。 + +共享业务结果默认私有;共享 SQL 文件不授予结果读取权。审计记录组织、应用用户、数据库执行身份、目标、执行结果与配置版本;敏感 SQL/参数是否保留由策略控制,默认不打印密码、令牌、原始数据库错误或用户绝对路径。 + +## 11. 系统级验收 + +1. CI 对 server normal/build 依赖图运行架构边界检查,失败于任何 Tauri crate 或 UI runtime 进入依赖闭包;server 可独立构建,同一用例通过 IPC 和 HTTP 的结果与错误语义一致。 +2. 桌面本地可离线运行;桌面团队模式不下载团队数据库凭据。 +3. 用户/组织之间不能读取、执行、取消或订阅对方未授权资源。 +4. 编辑器 `USE`、事务、临时状态连续;TablePanel 和后台任务不污染编辑器。 +5. 多 API 实例能路由同一固定 session;worker 丢失后明确失效,不伪恢复。 +6. 任务在窗口/网络断开后按政策继续,重订阅不会重复提交任务。 +7. 新增已有能力的驱动无需修改宿主业务分支,并通过 driver 契约矩阵。 +8. 组织/数据库/节点预算共同生效,节点扩容不使连接数成倍越界。 +9. 写入提交未知不自动重试;任务恢复核验已提交边界。 +10. 结果、文件、缓存和事件均执行组织及身份范围授权。 + +## 12. 连接契约摘要 + +- 会话、物理绑定和可持久化执行来源分开;`dbSessionId`、SessionHandle、lease/cursor、取消绑定与 attachment token 均不落盘,详见[连接管理 §4.4](connection-management.md#44-可落盘来源与运行时绑定)。 +- Driver 提供 namespaceShape 与操作级 targetRequirements;网关用同一 CanonicalTarget 做授权、缓存和执行,详见[§4.3](connection-management.md#43-命名空间规范化契约)及[§9.6](connection-management.md#96-poolkey版本与缓存的生产者)。 +- SessionDirectory/预算、会话失效、worker 路由和候选替换归属见[§12](connection-management.md#12-web多实例权限与结果);丢失物理资源后返回 SessionLost,禁止透明重建。 +- Attachment、TTL、结果放弃消费与清理分别见[§6.4](connection-management.md#64-attachment-与超期处理)、[§7.7](connection-management.md#77-结果订阅与放弃消费)和[§9.4–9.5](connection-management.md#94-归池前检查)。 +- 取消回执、错误与签名幂等期限以[§13](connection-management.md#13-错误重试和事件)为权威;完整验收用例 CM-01~73 和开发阶段映射见详细设计、[开发计划](../../development/platform-development-plan.md)。 + +## 13. 参考与维护 + +- [Tauri Process Model](https://tauri.app/concept/process-model/):desktop host 的进程边界。 +- [OWASP WebSocket Security](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html):双向长连接的认证、来源与消息权限。 +- [当前架构](../README.md)、[当前服务](../backend/services.md)、[ID 命名](../naming.md)。 + +按阶段落地后,同步当前架构文档、公开 API 和测试,不让设计约定与实现长期并列冲突。平台特定 API 只能出现在适配层;本设计不授权绕过现有驱动边界与凭据保护。 diff --git a/docs/development/platform-development-plan.md b/docs/development/platform-development-plan.md new file mode 100644 index 000000000..77b6d9a07 --- /dev/null +++ b/docs/development/platform-development-plan.md @@ -0,0 +1,303 @@ +# 连接管理重构与桌面 / Web 多形态开发计划 + +> 状态:待执行计划;基线:2026-09-30,`8592b0fe1`。本文按用户明确要求入库,不记录每日进度。 +> 设计依据:[系统概要](../architecture/platform/system-overview.md)、[连接管理详细设计](../architecture/platform/connection-management.md)。 + +## 1. 交付目标与实施原则 + +最终交付桌面本地、浏览器团队、桌面团队三种方式,共享 Rust 业务内核和 React 产品。连接管理对用户、会话、物理资源和任务分别建模;新增已有能力的 driver 不要求修改宿主业务流程。 + +先验证资源语义,再迁移 consumer,再增加团队入口和多实例。初期采用模块化单体;远程 agent、独立 driver runner、跨 backend 迁移是独立后续版本,不阻塞首版。 + +代码变更按阶段拆为可验收的小 PR。每个 PR 包含实现、相关测试和已落地事实文档;不创建进度台账、Bug List 或新方案副本。本计划是阶段顺序与交付门槛,完成的阶段应改为实施事实或合并到开发流程,不无限保留历史方案。 + +不能把后端重构、Driver API 协议迁移、所有 UI 和 Web 服务一次性切换。旧路径只做有截止点的窄兼容;同一 consumer 同一请求只使用一种资源管理器。 + +## 2. 阶段依赖 + +```mermaid +flowchart TD + P0[P0 基线与契约夹具] --> P1[P1 共享边界与 DTO] + P1 --> P2[P2 Driver 资源与能力] + P2 --> P3[P3 Session / Lease / Budget / Gateway] + P3 --> P4[P4 桌面 tab 与元数据迁移] + P3 --> P5[P5 Job 与迁移三件套] + P5 --> P6[P6 Workflow / AI / MCP / Wapp] + P4 --> P7[P7 单实例团队 Web] + P6 --> P7 + P7 --> P8[P8 桌面团队客户端与发布] + P8 --> P9[P9 多 worker 与全局调度] + P9 --> P10[P10 驱动扩展验收与旧路径清理] +``` + +P4 与 P5 代码边界独立后可以分别推进,但必须共享已稳定的 P3 契约;图表示依赖,不要求自动启用多个代理。没有用户明确授权时不派发其他聊天任务。 + +## 3. 每个阶段的统一完成定义 + +1. 所需 API、DTO、错误和生命周期都有实现,不能只交接口骨架。 +2. 详细设计对应测试编号通过;Unsupported 能力有拒绝断言。 +3. 生产路径不新增裸 unwrap/expect;测试文件通过 typecheck,无 any 绕过。 +4. driver 方言实现及测试在 driver 目录;host 无数据库名称分支。 +5. 新协议兼容声明真实,所有参与构建的 path/Git driver 已迁移或明确禁用对应新能力。 +6. 不读取 `.env` / `.env.test`,不提交凭据、生成文件或外部子仓库内容。 +7. 当前架构文档同步描述已经实现的部分;未实现功能仍标记目标设计。 +8. 评审看到清楚的启用条件、关闭/失败行为和回退边界。 + +## 4. P0:现状基线与契约测试夹具 + +**目的**:在移动代码前识别逻辑 session、pool handle、transaction handle 的真实语义,建立可比较的行为基线。 + +输入为当前 ConnectionManager、Driver API、Query/Table consumer、三件套和 Workflow。重点代码链接见详细设计第 2 节。 + +交付: + +- 清点各 consumer 的连接创建、复用、关闭、取消和配置目标路径;结论放进代码测试与当前服务文档,不写调查台账。 +- 建立 transport-neutral fake resource,支持 resourceId、owner、命令 journal、屏障、fake clock 和每阶段失败注入。 +- 在 driver 目录建立真实固定会话/事务/目标/清理契约测试;确认不能保证的能力。 +- 确定测试环境注入方式,检查启动器不读取受保护 env 文件。 +- 记录现有 DTO 与持久化格式兼容范围;旧 session ID 不作为迁移数据。 +- 用 CM-73 复现并留存“空闲淘汰关闭物理连接、同 ID 重连、事务 map 未清理”的基线;测试作为 P3 强制回归门槛。 + +负责边界:runtime 负责人定义 fake 契约,driver 维护者验证真实协议,前端负责人验证 tab 行为。 + +退出门槛:CM-01、04、07 的基线测试,以及 CM-73 对当前事务句柄生命周期的可复现证据;真实 driver CM-08~19 的能力验证能区分已支持和缺失。已知失败作为本轮后续阶段要修复的测试,不能删除断言。 + +回退:仅增加夹具和测试;不改变生产执行路径。 + +## 5. P1:抽取共享应用边界和前端传输契约 + +**目的**:业务可在没有 Tauri 的测试进程中调用。 + +交付: + +- 创建并注册拟新增 application/runtime/platform-api/backend-client 包,明确依赖方向。 +- 定义 RequestContext、ID newtype、CanonicalTarget、namespaceShape/操作级 targetRequirements、会话、执行、Job、Artifact DTO 和 ApiError。 +- 分开 DurableExecutionRecord 与实时 runtimeBinding,定义能力快照、CancelReceipt 和签名幂等令牌 port;所有持久化路径排除运行时句柄。 +- 定义 repositories、SecretProvider、NetworkProvider、ArtifactStore、EventSink ports。 +- Tauri adapter 组装本地身份,调用应用服务;保留现有 IPC 外观的窄适配。 +- BackendClient 和 PlatformServices 注入,迁移 SDK 中 invoke/Channel,存量调用用薄再导出过渡。 +- 加入依赖护栏:core 不引用 Tauri/HTTP,driver 不引用 host,前端领域不直接绑定平台传输。 +- 加入 CI 架构检查:对 server crate 的 normal/build 依赖闭包验证不含 Tauri crate 和 UI runtime,并验证 server 独立构建;依赖检查失败即阻断合并。 + +退出门槛:CM-01、03~07,DTO 往返与内核无 Tauri 编译、CI 依赖图检查通过;旧桌面基本连接/查询流程保持可用。 + +回退:Tauri adapter 可仍调用原用例实现,但不得把新 owner 和权限语义伪映射为旧共享 session。新 frontend bridge 在未注入时明确报错。 + +## 6. P2:Driver 固定资源与可选能力契约 + +**目的**:把“执行在同一真实资源上”变成可测试保证。 + +交付: + +- opaque resource、ResourceProvider、固定执行、状态观察、reset、close 和精确 cancel 契约。 +- namespace/session/transaction/snapshot/data/backup 可选能力注册与运行时能力降低机制。 +- 所有实际建连的预算 port,包括 driver pool、集群节点和控制资源。 +- 现有 Command 和迁移接口保持领域语义;资源获得方式通过 adapter 演进。 +- 先迁移具有真实会话与事务的代表 driver,再迁移其余参与构建驱动;不支持能力明确拒绝。 +- 修改公共契约时核对 crate SemVer、PROTOCOL_VERSION 和 minimum compatible;同步 path/Git driver、factory、ReuseDriver、生成选型路径。 + +退出门槛:D 层 CM-07~19、22~26、30、45、48;不同 driver 不共享实现库类型;新增能力缺失不会 no-op 成功。 + +回退:过渡 adapter 可以支持旧 driver 的独立受控 Command,但不能开放未证明的 statefulSession。协议升级与驱动发布是原子兼容门槛,不允许用下调最低版本掩盖 breaking change。 + +## 7. P3:统一 Session / Lease / Budget / ExecutionGateway + +**目的**:建立桌面与 server 都能用的资源运行时。 + +交付: + +- SessionRegistry actor、状态机、owner/runtimeEpoch/contextRevision、独立 attachment 与内部 resourceBindingId。 +- ResourceManager lease、pool、cleanup/quarantine、隧道引用。 +- 多维单机预算、control/interactive/metadata/job 保留与公平队列、逻辑 session/已连接编辑器额度、真实物理资源计数。 +- attachment 令牌与 TTL 最早期限裁决;无消费者有界产物、discard/drain、取消与协议恢复;PoolKey 版本生产者与缓存失效。 +- 单进程 SessionDirectory、候选替换提交屏障、过期令牌拒绝与 Clean 双重检查。 +- ExecutionGateway:授权、目标校验、schema、幂等 receipt、状态、来源、事件。 +- 独立取消路径,取消和关闭先等待执行/协议结束;所有失败阶段 cleanup。 +- 显式 session 失效和新 session 恢复;禁止原 ID 透明重连。 +- 指标:物理资源数、lease state、预算等待、执行队列、取消耗时、cleanup 失败、Unknown outcome。 + +退出门槛:H 层 CM-02~07、20~32、37~39、54~56、60 单机部分及 CM-61~73 的 H 断言;CM-73 必须转绿,证明事务状态、物理资源和 session ID 同步失效。race 测试使用 barrier/fake clock,不靠任意 sleep 猜顺序。 + +回退:新 runtime 仅给已迁移 consumer 使用;不同时维护一条资源的旧 refs 和新 lease。服务暂停/重启前关闭新 session,不能将活事务移回旧 manager。 + +## 8. P4:桌面 QueryPanel、TablePanel、元数据迁移 + +**目的**:先在现有产品中完成连接正确性,不等待 Web。 + +交付: + +- QueryPanel 每编辑器独立懒 session,复制 tab 不复制 runtime ID。 +- 删除执行前从 SQL 推测已生效上下文的行为,保留补全与浏览推测。 +- 下拉切库支持确认、revision 冲突、两阶段替换和事务处理。 +- TablePanel 使用明确对象 target;ChangeSet 保存时短事务,手工事务显式开关。 +- 元数据批量与缓存按身份/配置/目标隔离;临时对象绑定原 runtime handle。 +- 旧结果 provenance、读写映射与临时结果失效规则。 +- 关闭 tab、断线、错误后的继续操作和用户提示;开发只新增/修改 en.ts。 + +退出门槛:CM-08~18、21、33~39、55~56、61~64、72 的 F 断言;Host 通用 WDIO + driver 专属旅程;桌面 Community 与 Pro consumer 兼容验证。 + +回退:切换以 consumer 为单位,启用前 session 全部重新建立;不能在活动事务中切 feature path。存在数据写入未知时先核验,回退 UI 不等于数据库回滚。 + +## 9. P5:JobRuntime 与数据迁移三件套 + +**目的**:连接资源脱离 UI,任务结果真实反映提交边界。 + +交付: + +- JobRepository、JobHandler 注册、资源阶段、进度、取消和结果。 +- 持久化接受后返回 jobId,窗口只订阅;任务中心可重新附着。 +- 将 `src-tauri/src/schema_diff`、`data_sync`、`data_transfer` 中与 Tauri 无关的领域实现按依赖分阶段抽取到概要 §4.1 的领域包;Tauri commands、窗口、文件选择保留在 adapter。不得把当前目录位置误当成最终共享边界。 +- Schema Diff 比较/审阅/应用阶段分开、目标指纹复验、DDL 原子性按计划判断。 +- Data Sync 同族/结构/PK 门闸、快照范围、review 释放、冲突检测与批次事务。 +- Data Transfer 有界数据管道、IR、源一致性、目标批次记录与检查点核验。 +- endpoint 重叠识别、源目标多端预算申请、防 AB/BA 死锁。 +- 明确 successful/failed/cancelled 与 effectOutcome,保留部分提交和未知信息。 + +退出门槛:CM-31、40~49、54;Job 执行期 UI unmount 不调用资源 release;已提交/未提交/未知边界通过真实数据库断言。 + +回退:新 Job 暂停派发,已有任务到安全边界后结束;计划/检查点格式升级使用版本,新旧引擎不能接管不兼容计划。已经写入的任务只按核验与补偿处理。 + +## 10. P6:Workflow、AI、MCP、Wapp 与辅助任务 + +**目的**:消除 GUI 外入口的隐式目标和共享状态。 + +交付: + +- Workflow 独立 step、session block、transaction block 的模型、校验和执行。 +- 明确 connection/namespace 继承、并行分支资源、重试范围、checkpoint 边界。 +- AI 默认独立操作,显式授权当前 editor session 时同队列。 +- MCP client/session owner 与 TTL,Wapp 实例和任务归属。 +- Dashboard/Monitor 独立资源;导出、备份、恢复归 Job/Artifact;原生工具由环境 port 管理。 +- 服务身份/委托权限用于调度,所有入口走 ExecutionGateway。 + +退出门槛:CM-50~53;辅助任务共享预算、不污染编辑器;原 Workflow 的 query/command 默认目标契约仍通过。 + +回退:旧 Workflow 定义按版本解析,默认独立 step 语义;新 block 格式不交旧 executor。后台任务取消与运行状态通过 Job 查证,不用 GUI 关闭推断。 + +## 11. P7:单实例团队 Web 服务 + +**目的**:先交付可部署、可授权的统一团队入口。 + +交付: + +- server host,无 Tauri 依赖,初期同进程管理与执行。 +- OIDC、membership、服务端登录会话、RBAC/资源 ACL、CSRF、同源 HTTPS 部署。 +- ProfileRepository/JobRepository/Audit/ArtifactStore 的服务端实现和 schema migration。 +- secretRef/轮换、个人/团队执行身份、出站网络政策。 +- 概要设计 v1 HTTP API,幂等提交、SSE 重订阅、分页/大小限制、受控上传下载。 +- React Web shell:窗口替换为页面/面板,文件和剪贴板使用 Web 平台实现。 +- 配额、任务中心、结果 TTL、配置版本 CAS、权限撤销传播。 +- API schema/client 生成或契约一致性门禁,桌面 IPC 与 HTTP 同用例测试。 + +退出门槛:CM-05~07、38~40、53~56、59、60 单实例部分及 CM-63、68、70、72 的 W1 断言;浏览器无需数据库凭据;组织隔离、CSRF、产物与事件越权全部拒绝。 + +发布门槛:部署文档明确管理数据库、SecretProvider、网络可达性、备份、迁移、TLS 和资源额度;管理员能恢复服务元数据,活动数据库会话不声称可恢复。 + +回退:保留上一版本镜像和向后兼容 schema;数据库 schema 采用 expand/contract,不先删字段。drain 活任务后回退;发生外部数据库写入不能通过回退镜像撤销。 + +## 12. P8:桌面团队客户端与首版发布 + +**目的**:同一客户端可选择本地或团队 backend,避免句柄和凭据混用。 + +交付: + +- backendId、连接来源、版本握手与 per-backend client。 +- 桌面登录团队服务、HTTPS/SSE 生命周期;团队 session 不经本地 Driver 执行。 +- 本地 profile/session 与团队 profile/session 独立索引;服务退出不删除本地配置。 +- API major/minor 兼容提示;不兼容阻止写操作,不能默默降级。 +- 第一版同 backend 迁移限制,选择跨 backend endpoint 明确拒绝。 +- Community/Pro 桌面及团队发行 capability 清单;高级扩展仍经原有 EP 契约。 +- 发布前 en.ts source key 检查与翻译补齐、功能使用和部署文档。 + +退出门槛:桌面离线本地可用;桌面/浏览器团队同用例一致;本地 ID 发往团队服务拒绝;团队秘密不存入本地普通 profile;CM-55/56 与 backend 隔离旅程通过。 + +回退:移除远程 backend 绑定前显式关闭/保留远程任务;本地状态不受影响。不能把 remote SQL 转为本地执行来“降级”。 + +## 13. P9:多 worker、网络分区与全局预算 + +**目的**:扩容不破坏会话连续性和总连接上限。 + +交付: + +- RuntimeRouter、SessionDirectory、owner worker epoch、认证内部调用。 +- worker 能力/版本/网络区域匹配,固定 session 路由,替代 session 新 ID。 +- 总预算节点分配/许可机制,idle/控制/cluster 资源统计与分区保守回收。 +- Job claim/renew/fencing、失租停止派发、接管前目标核验。 +- worker drain、滚动升级、任务恢复和元数据/权限事件失效。 +- 压力、故障注入、协调器失联、worker 暂停后恢复测试。 + +退出门槛:CM-57~60 的 WN 部分、CM-68/71 多 worker 断言;关闭 sticky 后仍正确;worker 故障不伪恢复事务;旧 worker 隔离未证明前不复用额度;提交未知不盲目接管。 + +回退:停止新任务/会话路由并 drain 至单 worker;不迁移 live session。多实例元数据可保留,但执行 owner 必须明确,不能把目录清空后继续使用旧 ID。 + +## 14. P10:扩展性验收、旧路径删除与稳定化 + +**目的**:验证架构目标,而不只验证代表驱动能工作。 + +交付: + +- 选择一个实现既有能力的新增/适配 driver,限定改动在 driver 包、注册选型、schema/UI/文案和 driver 测试。 +- 运行通用 contract × driver 矩阵,确认 unsupported 路径明确拒绝。 +- 删除 consumer 的旧共享 session/reconnect/双执行路径,删除 SDK 重复实现。 +- 更新 Driver 公共协议、独立驱动指南和已实现架构;本三份文档逐节转为事实/流程。 +- 采集 fake runtime 基准、真实典型负载和资源计数,按实测调整默认预算,不承诺跨环境 SQL 延迟。 + +退出门槛:详细设计 CM-01~73 全部适用门槛,新增 driver 不改宿主数据库分支;未能执行的真实环境验证明确列入发布限制,不能称全部通过。 + +回退:只回退新增驱动选型/包;已删除旧路径不为了兼容缺失能力重新引入。公共契约确有新语义需求时走版本化演进。 + +## 15. 验证命令与 CI 规划 + +### 15.1 当前已有命令 + +以下命令已核对 `package.json`/workspace;执行测试前确认其启动链不读取受保护 env 文件。 + +```bash +pnpm typecheck +pnpm test:unit +pnpm test:unit:drivers +pnpm test:layers +pnpm test:boundaries +cargo test -p datazen --lib +cargo test -p datazen-driver-api +cargo test -p datazen-driver-postgres +pnpm tauri:build:webdriver +pnpm e2e:skip-build +pnpm e2e:contract:matrix +``` + +WDIO 必须使用已注入 driver 的构建;三件套可运行 app 已生成而仅 DMG 失败时继续功能验证。driver 专属 E2E 不放 Host。新 runtime/application/server crate 创建后,其 package 名和测试命令同步写入 CI;不把未创建命令列为现有脚本。 + +### 15.2 新增 CI 门禁 + +- application/runtime 无 Tauri/server/UI 依赖构建。 +- Driver API 无实现类型泄漏,driver 不 import host。 +- DTO 与 API schema 的生成漂移检查。 +- runtime fake 故障/竞态/预算测试。 +- IPC/HTTP 适配一致性测试。 +- Web 身份/权限/CSRF/SSE/Artifact 集成。 +- 真实 driver 契约矩阵(各 driver 内部测试)。 +- 多 worker 分区/drain/接管测试。 +- 本地 Community、Pro、Web 与桌面 remote 的核心旅程。 + +## 16. 工作拆分和交接 + +每阶段可按“公共契约 → runtime → driver → consumer → 验证”拆 PR;有依赖的实现基于已合并契约,不自行复制 DTO。公共能力变更由 API/runtime 负责人评审,driver 维护者评审真实协议,应用负责人评审用户下一步流程,团队权限由 server 安全负责人评审。 + +实习生先完成 fake/runtime 中不含凭据的类型、会计、actor 和状态投影任务,再在维护者指导下实现 driver 协议与真实数据写入路径。工作交接必须包含测试编号、执行命令、实际结果、未验证能力和回退边界;不需要新增交接文件,写入 PR 与正式代码文档。 + +不承诺未经估算的日期。P0/P1 完成后根据 driver 缺口、消费者数量和团队规模估算后续工期;任何阶段不能因赶进度跳过会话、权限或提交未知的验收门槛。 + +## 17. 补充契约的阶段归属 + +| 阶段 | 必须交付的补充契约 | 验收范围 | +| --- | --- | --- | +| P0/P1 | DTO、持久化白名单、目标规范化、错误/回执、能力快照、令牌 port | CM-61/62/72 的类型与 schema 断言 | +| P2 | namespaceShape、targetRequirements、规范化资源 key、driver 清理与协议 drain | CM-62/64/67/69 的 D 断言 | +| P3 | attachment、结果放弃、类别调度、逻辑配额、PoolKey/cache、替换发布、幂等过期、内存目录与事务失效回收 | CM-61~73 的 H 断言,CM-60 基准 harness | +| P4 | 恢复结果只读/写回、TTL 与切库错误投影、关闭结果视图行为 | CM-61~64/72 的 F 断言 | +| P5/P6 | 能力快照计划核验、Job 多端预算、独立消费者政策 | CM-40~53 与 CM-65/67 的任务断言 | +| P7/P8 | 单实例认证重附着、HTTP 回执与持久化投影、签名令牌和 SSE | CM-63/68/70/72 的 W1 断言 | +| P9 | 原子 owner 登记、碰撞/失效、跨节点替换发布、分区和全局预算 | CM-57/58/60/68/71 的 WN 断言 | + +测试标签代表执行层,不代表整条用例只属于某阶段。P3/P4 必须完成 CM-54~56 的 runtime/前端部分;P7 完成单实例适配部分;只有 owner 路由、worker 分区及跨节点预算推迟到 P9。P10 逐层汇总全部 73 条适用用例,不能把未执行的 D/WN 断言计为已通过。 From bbb185fa3869fb21c1e2cc4bb65bf17e4d30c416 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 20:55:33 +0800 Subject: [PATCH 051/666] docs(connection): define ExecutionErrorCode and session handle registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CM-73 asserted the right target behavior but the design had no rule that makes it fire. Transactions and cursors are Driver Command handles that outlive the execution that returned them, and the 13 ConnectionService methods contain no transaction call, so the session actor never learned a live handle existed. That left three rules silently inert: the 5-minute idle TTL in 6.4, "事务终结" in the 9.4 pool-return preconditions (assigned to the driver, which by construction cannot see a handle it already handed out), and CM-73's own assertion. - add 6.5 会话级资源句柄登记: handles must be registered with the actor before the execution reaches a terminal state; a command-layer-owned map does not count as registration - reassign transaction/cursor ownership from driver to host in 9.4, and state why the driver's Clean cannot stand in for termination - type ExecutionView.errorCode as ExecutionErrorCode instead of string, add the enum and an ExecutionFailed row so the "versioned enum" promise in 13.1 becomes checkable; pin errorCode/effectOutcome orthogonality so a cancellation cannot be reported as rolledBack - add CM-74 for handle registration and release order across eviction, pool return, replacement and close - give 6.5 a home in the step 5 module table and fold CM-73/74 into the acceptance gates - extend the P3 exit gate to CM-61~74 so the new case has an owning phase --- .../platform/connection-management.md | 61 ++++++++++++++++--- docs/development/platform-development-plan.md | 4 +- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/docs/architecture/platform/connection-management.md b/docs/architecture/platform/connection-management.md index 56e64f6b4..df5b5c7f6 100644 --- a/docs/architecture/platform/connection-management.md +++ b/docs/architecture/platform/connection-management.md @@ -187,6 +187,24 @@ type ExecutionState = | 'failed' | 'cancelled'; +// 执行终态 errorCode。与 ApiError.code 是两个独立命名空间,不得互相塞入对方取值。 +// 网关按此枚举脱敏映射 driver 原始错误;driver 的错误文本、SQL 片段与绝对路径只进日志。 +type ExecutionErrorCode = + // 数据库返回错误;方言差异由网关归一,宿主与 UI 只见本枚举 + | 'sqlError' + // 编解码/协议层失败,语句可能已送达 + | 'protocolError' + // 精确取消已生效 + | 'cancelled' + // 执行超过 timeout 且无法判定数据库生效范围 + | 'timeout' + // 执行中物理资源丢失 + | 'resourceLost' + // 放弃消费或背压导致的截断中止 + | 'pipelineAborted' + // 派发前宿主重校验拒绝:权限撤销、额度、队列、sql_guard + | 'hostRejected'; + interface ExecutionReceipt { executionId: Id; streamId: Id; @@ -227,7 +245,7 @@ interface ExecutionView { artifactIds: readonly Id[]; resultCompleteness: 'pending' | 'complete' | 'truncated'; truncationReason: string | null; - errorCode: string | null; + errorCode: ExecutionErrorCode | null; runtimeBinding: RuntimeResultBinding | null; } @@ -456,7 +474,7 @@ ConnectionService 的 attachSession/detachSession 接受 AttachmentRequest,返 | 条件 | 到期点/动作 | | --- | --- | | attached,无事务,非执行中 | lastBusinessActivity + 30 分钟,关闭 | -| attached,active/aborted/unknown 事务,非执行中 | lastBusinessActivity + 5 分钟,回滚并关闭 | +| attached,active/aborted/unknown 事务或游标(§6.5 已登记句柄),非执行中 | lastBusinessActivity + 5 分钟,在原资源上回滚/关闭句柄后再关闭 | | detached | detachedAt + 60 秒;与已适用 idle deadline 取最早 | | grace 内 attach | 取消掉线 deadline,不刷新 lastBusinessActivity/事务期限 | | 执行中 | 暂停 idle 检查,不暂停掉线 grace;grace 到期请求取消并关闭 | @@ -464,6 +482,21 @@ ConnectionService 的 attachSession/detachSession 接受 AttachmentRequest,返 lastBusinessActivity 在已接受的实际业务操作开始和终结时更新;登录心跳、读取状态、订阅和 attach 不刷新。执行中没有 detached deadline 时由执行 timeout/管理员政策管理。每个 TTL 起点在服务端用单调时钟保存;expiresAt 是最早适用 deadline 的 UTC 投影,执行中无适用期限为 null。超期动作由同 actor 串行裁决,expired 不可重附着;竞态中已进入 Closing 就不能恢复 Ready。 +### 6.5 会话级资源句柄登记 + +Driver Command 可能返回超出该次 execution 生命周期的会话级句柄:事务、游标、服务端预处理对象。句柄一经返回,必须在返回 execution 终态之前向 session actor 登记;未登记的句柄视为非法,runtime 拒绝把它交给宿主。 + +execution 终态只表示该次语句结束,不表示句柄失效。命令层自管的句柄映射不算登记。 + +actor 对已登记句柄承担与 Lease 相同的引用责任,并据此驱动: + +- §6.4 的 idle 期限:存在未结束事务或游标时按 5 分钟规则,不进入 30 分钟关闭 +- §7.5 closeSession 的回滚与终结顺序 +- §9.4 归池前置中的"事务终结":宿主侧判定的是已登记句柄为空 +- 淘汰、替换或隔离时,先在原 resource 上回滚/关闭句柄并从 actor 注销,确认后才释放资源;不得在新 resource 上复用旧句柄 + +句柄登记只存在于内存,随 actor 终止而失效。恢复流程不得重建句柄,只允许以新 dbSessionId 显式重建。 + ## 7. 方法处理顺序 ### 7.1 openSession @@ -605,7 +638,7 @@ cancel 所需控制连接预留在同一预算内,不能在满池时无限额 必须全部成立:执行结束、结果协议完全消费、事务终结、锁释放、默认命名空间恢复、角色/会话变量/编码恢复、临时对象/预处理状态按 driver 契约处理、连接健康。 -归池条件是宿主检查全部通过 AND driver 返回 Clean。宿主检查执行终态、无活跃消费者/取消句柄、已收到 protocolDrained、预算和 owner 合法;driver 负责协议、事务、初始化基线与健康检查。任一失败都关闭,即使 driver 返回 Clean 也不能绕过宿主检查。reset 不支持、失败或超时直接关闭。不能把 ping 成功等同于 Clean。任意 SQL 编辑器首版关闭销毁,短操作只复用工具控制的可清理资源。 +归池条件是宿主检查全部通过 AND driver 返回 Clean。宿主检查执行终态、无活跃消费者/取消句柄、已收到 protocolDrained、预算和 owner 合法、以及 §6.5 已登记句柄为空;driver 负责协议、初始化基线与健康检查。事务与游标由宿主按 §6.5 的登记记录判定,driver 不重复负责:driver 对已交出的句柄没有可见性,其 Clean 不构成事务终结的证据。任一失败都关闭,即使 driver 返回 Clean 也不能绕过宿主检查。reset 不支持、失败或超时直接关闭。不能把 ping 成功等同于 Clean。任意 SQL 编辑器首版关闭销毁,短操作只复用工具控制的可清理资源。 ### 9.5 资源类别与调度 @@ -697,6 +730,7 @@ SessionDirectory 保存组织/owner/worker/runtimeEpoch,不保存连接。dbSe | OutcomeUnknown | 写入/提交结果未知 | 核验执行,不自动重试 | | PlanStale / SourceChanged / TargetConflictRows | 计划/数据变化 | 重比对或处理冲突 | | IdempotencyConflict | 相同键不同输入 | 修正请求键,不能覆盖记录 | +| ExecutionFailed | 执行已派发但终态失败 | 读 ExecutionView.errorCode 与 effectOutcome 决定下一步,不以 code 推断生效范围 | 每条事件带 stream sequence;重复事件忽略,缺口重新读取状态。session context 按 runtimeEpoch/dbSessionId/contextRevision 更新;比较 Counter 使用 BigInt 或十进制字符串比较,不能 Number 转换。 @@ -710,7 +744,9 @@ SessionDirectory 保存组织/owner/worker/runtimeEpoch,不保存连接。dbSe openSession/context replacement 的完整指纹、SessionView/attachmentToken/receipt 只在 owner 内存保存至令牌过期或 runtime 终止。对应令牌绑定 runtimeEpoch,owner 丢失即使令牌未过期也返回 SessionLost,不能在新 owner 当作首次请求再次创建。响应丢失时,在原 runtime 内同键返回同 session/token/候选提交结果;不延长 attachment/idle TTL。会话已到期则返回其终态或 SessionLost,不恢复资源。close 的 tombstone 同样只在内存保留(默认 24 小时),逻辑额度在终态退出活动注册表时回收,隔离物理占用持续计数。 -RequestError(ApiError.code)表示请求被拒绝;已接受 ExecutionReceipt 不等于 SQL 成功;CancelReceipt.disposition 表示控制请求结果,不用异常 CancellationUnsupported 表示正常 unsupported。执行终态 errorCode 使用版本化错误枚举,effectOutcome 独立表达数据库生效范围。所有大小写使用 DTO 字面值,如 cancelled/rolledBack/partiallyApplied;文中 Active/Unknown 等状态展示简称不作为协议值。driver 的内部错误由网关脱敏映射,不原样暴露。 +RequestError(ApiError.code)表示请求被拒绝;已接受 ExecutionReceipt 不等于 SQL 成功;CancelReceipt.disposition 表示控制请求结果,不用异常 CancellationUnsupported 表示正常 unsupported。执行终态 errorCode 使用 §4 定义的版本化枚举 ExecutionErrorCode,与 ApiError.code 是两个命名空间;effectOutcome 独立表达数据库生效范围。所有大小写使用 DTO 字面值,如 cancelled/rolledBack/partiallyApplied;文中 Active/Unknown 等状态展示简称不作为协议值。driver 的内部错误由网关脱敏映射,不原样暴露。 + +errorCode 与 effectOutcome 正交:sqlError 可对应 completed/rolledBack/partiallyApplied/unknown 任一。超时、取消、连接丢失导致生效范围无法判定时,effectOutcome 必须为 unknown,禁止因为"看到取消"就写 rolledBack——CM-44、CM-47 的断言依赖此规则。新增取值需要提升枚举版本并同时更新全部消费者,宿主、前端与 driver 不得各自扩展。 ## 14. 开发步骤与文件职责 @@ -719,10 +755,10 @@ RequestError(ApiError.code)表示请求被拒绝;已接受 ExecutionReceip | 顺序 | 模块 | 开发任务 | 完成证据 | | --- | --- | --- | --- | | 1 | types/error | newtype、DTO、enum、schema 校验 | 类型编译、序列化往返、错误码 | -| 2 | testing/fake_resource | 假资源、状态、屏障、故障注入 | 可观察创建/关闭/执行次数和 resourceId | +| 2 | testing/fake_resource | 假资源、状态、屏障、故障注入;可返回事务/游标句柄的 fake 命令 | 可观察创建/关闭/执行次数、resourceId 与句柄登记状态 | | 3 | budget | 单机多维许可、可取消等待、幂等核销 | 并发与失败会计测试 | | 4 | resource | acquire/cleanup/quarantine、隧道引用 | 每阶段故障无泄漏 | -| 5 | registry/actor | session 生命周期、队列、epoch | 连续旅程与取消竞态 | +| 5 | registry/actor | session 生命周期、队列、epoch、§6.5 会话级句柄登记 | 连续旅程与取消竞态、句柄释放顺序(CM-73/74) | | 6 | execution | 幂等、授权、来源、事件 | 重复提交、旧事件、结果 ACL | | 7 | adapters | IPC/BackendClient、旧接口窄适配 | 桌面路径行为一致 | | 8 | consumers | Query/Table/metadata、三件套、Workflow | 功能契约旅程 | @@ -1218,10 +1254,17 @@ CM-60 使用 release 构建,4 vCPU/8 GiB、无数据库网络、单进程固 **CM-73 空闲淘汰与活动事务句柄连续旅程(H)** -- 前置:同一 session/resource 上通过 `begin_session_transaction` 创建真实 fake TransactionHandle;事务句柄在命令状态映射中,session 资源已空闲且引用计数为 0;fake clock 可驱动 idle timeout。 +- 前置:同一 session/resource 上通过 `begin_session_transaction` 创建真实 fake TransactionHandle 并按 §6.5 完成登记;宿主侧检查全部通过(无活动 execution、无消费者、已收到 protocolDrained、预算与 owner 合法);fake clock 可驱动 idle timeout。 - 步骤:开始事务并写入未提交 marker;推进到空闲淘汰期限并运行 cleanup;查询事务状态、尝试 commit/rollback,再用旧 dbSessionId 发起查询;记录物理 resourceId、事务 map、回滚/关闭调用。 - 断言:淘汰不得在活动事务仍映射时静默关掉物理资源后保留可提交句柄。目标行为是在回收时先在原 resource 回滚并移除/终结事务映射,再关闭资源;回滚结果未知则转 OutcomeUnknown/SessionLost,不重新连接后复用旧 dbSessionId 或在新 resource 提交旧 handle。事务状态不得在物理 session 丢失后继续报告 Active。失败后的显式新 session 使用新 ID,marker 不提交。 - 基线说明:旧实现应先记录复现证据:`cleanup_idle_connections` 保留 owner map,`get_session` 通过该 map 同 ID 重连,而 `session_transactions` 由命令层单独管理。此用例在连接管理重构完成前可作为已知失败,完成后必须转绿;不可删掉断言或用仅检查 UI 文案替代。 +- 保留声明:以上仅为缺陷来源记录。本用例的断言只依赖行为(存在已登记活动句柄时不得淘汰并同 ID 重建),不依赖上述任何函数名或命令层映射结构。§14 逐步删除旧管理器与旧接口时必须保留本用例与 CM-74,不得按死代码一并清理。 + +**CM-74 会话级句柄登记与释放顺序(H/F)** + +- 前置:fake driver 的命令分别返回事务句柄与游标句柄各一个,均已按 §6.5 登记;另备一个未登记的句柄。 +- 步骤:推进到 idle 期限触发淘汰,在淘汰中途发起 commit;再分别走归池、setSessionContext 替换、closeSession 三条路径;最后提交未登记句柄。 +- 断言:句柄在物理资源关闭前已在原 resource 上回滚/关闭并从 actor 注销;commit 落在旧 resource 或明确失败,不出现在新 resource;driver 返回 Clean 时若宿主仍有已登记句柄,宿主检查必须失败(§9.4);未登记句柄被拒绝返回;actor 终止后不重建任何句柄,恢复只能以新 dbSessionId 显式重建。 ## 17. 验收标准与证据 @@ -1234,10 +1277,10 @@ CM-60 使用 release 构建,4 vCPU/8 GiB、无数据库网络、单进程固 | 三件套/Workflow | CM-40~53 | 提交边界、恢复核验、Job owner 断言 | | 断线/事件/幂等 | CM-54~56、70、72 | runtime/前端与 W1 适配一致性 | | Web/多实例/压力 | CM-57~60、71 | 按 W1/WN 阶段提交路由、分区与基准证据 | -| 事务失效回收 | CM-73 | idle cleanup、事务 map、物理 resource 与后续显式重连的连续旅程 | +| 事务失效回收 | CM-73、74 | idle cleanup、句柄登记与释放顺序、物理 resource 与后续显式重连的连续旅程 | | 补充契约 | CM-61~69 | 持久化、规范化、TTL、结果流、调度、额度与故障 journal | -阶段性验收按开发计划选择对应门槛;最终验收覆盖全部适用用例,包括 CM-73;基线阶段可暂时记录该遗留缺陷,P3 连接运行时阶段必须转绿。能力 Unsupported 的 driver 测试断言“正确拒绝”,不能静默 skip 后宣称能力已验证。真实测试环境不可用必须报告未验证范围,不能用 fake 代替真实协议结论。 +阶段性验收按开发计划选择对应门槛;最终验收覆盖全部适用用例,包括 CM-73、CM-74;基线阶段可暂时记录该遗留缺陷,P3 连接运行时阶段必须转绿。能力 Unsupported 的 driver 测试断言“正确拒绝”,不能静默 skip 后宣称能力已验证。真实测试环境不可用必须报告未验证范围,不能用 fake 代替真实协议结论。 结束时还必须满足:生产无裸 unwrap/expect、公共 API 无实现库类型、测试 typecheck 干净、生成文件未提交、无新增宿主数据库名称分支、无敏感日志。新驱动实现现有能力时,修改范围仅驱动包/选型/注册/元数据和测试。 diff --git a/docs/development/platform-development-plan.md b/docs/development/platform-development-plan.md index 77b6d9a07..3f0e12762 100644 --- a/docs/development/platform-development-plan.md +++ b/docs/development/platform-development-plan.md @@ -56,7 +56,7 @@ P4 与 P5 代码边界独立后可以分别推进,但必须共享已稳定的 - 在 driver 目录建立真实固定会话/事务/目标/清理契约测试;确认不能保证的能力。 - 确定测试环境注入方式,检查启动器不读取受保护 env 文件。 - 记录现有 DTO 与持久化格式兼容范围;旧 session ID 不作为迁移数据。 -- 用 CM-73 复现并留存“空闲淘汰关闭物理连接、同 ID 重连、事务 map 未清理”的基线;测试作为 P3 强制回归门槛。 +- 用 CM-73 复现并留存“空闲淘汰关闭物理连接、同 ID 重连、事务 map 未清理”的基线;测试作为 P3 强制回归门槛,其断言只依赖行为,不得随旧管理器删除。 负责边界:runtime 负责人定义 fake 契约,driver 维护者验证真实协议,前端负责人验证 tab 行为。 @@ -116,7 +116,7 @@ P4 与 P5 代码边界独立后可以分别推进,但必须共享已稳定的 - 显式 session 失效和新 session 恢复;禁止原 ID 透明重连。 - 指标:物理资源数、lease state、预算等待、执行队列、取消耗时、cleanup 失败、Unknown outcome。 -退出门槛:H 层 CM-02~07、20~32、37~39、54~56、60 单机部分及 CM-61~73 的 H 断言;CM-73 必须转绿,证明事务状态、物理资源和 session ID 同步失效。race 测试使用 barrier/fake clock,不靠任意 sleep 猜顺序。 +退出门槛:H 层 CM-02~07、20~32、37~39、54~56、60 单机部分及 CM-61~74 的 H 断言;CM-73、CM-74 必须转绿,证明会话级句柄(事务/游标)在物理资源释放前已注销,事务状态、物理资源和 session ID 同步失效。race 测试使用 barrier/fake clock,不靠任意 sleep 猜顺序。 回退:新 runtime 仅给已迁移 consumer 使用;不同时维护一条资源的旧 refs 和新 lease。服务暂停/重启前关闭新 session,不能将活事务移回旧 manager。 From 9cee477ae2bebeb428bb51dc359c533f5c26c377 Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 21:00:53 +0800 Subject: [PATCH 052/666] refactor(navigator): drop the count badge from category rows Category rows no longer render row.count next to the label; section and group rows keep theirs. buildFlatRows still computes count for every row type, so the field stays populated for other consumers. Tests move from asserting the count value to asserting the badge is absent, covering the refresh-failure path where the category ends up with no children. --- .../__tests__/ConnectionNavigatorTree.test.tsx | 16 ++++++++-------- .../connection/navigator/NavigatorTreeRow.tsx | 1 - 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx b/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx index fe2a00054..1b248262c 100644 --- a/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx +++ b/src/windows/connection/__tests__/ConnectionNavigatorTree.test.tsx @@ -1817,7 +1817,7 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { await waitFor(() => { expect(container.querySelector('[data-item-name="fn_calc"]')).not.toBeNull(); }); - expect(categoryButton(container, 'function').textContent).toContain('2'); + expect(categoryButton(container, 'function').textContent).not.toMatch(/\d/); fireEvent.click(container.querySelector('[data-item-name="fn_calc"]')!); await waitFor(() => { @@ -1951,15 +1951,15 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { await waitFor(() => { expect(container.querySelector('[data-item-name="pr_x"]')).not.toBeNull(); }); - expect(categoryButton(container, 'procedure').textContent).toContain('1'); + expect(categoryButton(container, 'procedure').textContent).not.toMatch(/\d/); // Category context menu → refresh → backend failure falls back to []. mockGetDatabaseObjects.mockRejectedValueOnce(new Error('boom')); await openMenuAndPick(categoryButton(container, 'procedure'), 'refresh'); await waitFor(() => { - expect(categoryButton(container, 'procedure').textContent).toContain('0'); + expect(container.querySelector('[data-item-name="pr_x"]')).toBeNull(); }); - expect(container.querySelector('[data-item-name="pr_x"]')).toBeNull(); + expect(categoryButton(container, 'procedure').textContent).not.toMatch(/\d/); }); it('activates the clicked database in local state when nothing is cached', async () => { @@ -2037,11 +2037,11 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { await waitFor(() => { expect(container.querySelector('[data-item-name="pr_x"]')).not.toBeNull(); }); - expect(categoryButton(container, 'procedure').textContent).toContain('1'); + expect(categoryButton(container, 'procedure').textContent).not.toMatch(/\d/); // Connection-level refresh bumps schemaEpoch → epoch-triggered cache // invalidation must not leave the expanded category empty. The recovery - // wave re-fetches it and the row keeps its entries + count. + // wave re-fetches it and the row keeps its entries. mockGetDatabaseObjects.mockClear(); await triggerConnectionRefresh(findByText, 'SQLite Conn'); @@ -2051,7 +2051,7 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { await waitFor(() => { expect(container.querySelector('[data-item-name="pr_x"]')).not.toBeNull(); }); - expect(categoryButton(container, 'procedure').textContent).toContain('1'); + expect(categoryButton(container, 'procedure').textContent).not.toMatch(/\d/); }); it('F1-BUG-005: single-db database-node refresh restores expanded categories', async () => { @@ -2079,7 +2079,7 @@ describe('ConnectionNavigatorTree standard single-db trees', () => { await waitFor(() => { expect(container.querySelector('[data-item-name="fn_y"]')).not.toBeNull(); }); - expect(categoryButton(container, 'function').textContent).toContain('1'); + expect(categoryButton(container, 'function').textContent).not.toMatch(/\d/); }); it('schema-level refresh reloads expanded schema-scoped categories', async () => { diff --git a/src/windows/connection/navigator/NavigatorTreeRow.tsx b/src/windows/connection/navigator/NavigatorTreeRow.tsx index 80eb35693..6351a5881 100644 --- a/src/windows/connection/navigator/NavigatorTreeRow.tsx +++ b/src/windows/connection/navigator/NavigatorTreeRow.tsx @@ -421,7 +421,6 @@ export function NavigatorTreeRow({ fallback={row.cat.icon} /> {t(row.cat.labelKey as Parameters[0])} - {row.count} ); } From e544e0699de0642a13d44693601048886273812f Mon Sep 17 00:00:00 2001 From: probe Date: Wed, 30 Sep 2026 21:08:08 +0800 Subject: [PATCH 053/666] docs(platform): pin handle shapes, token scope and cancelled semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the session handle registration rule: the design said a handle must be registered with the actor but never said where it comes from or what shape it has, so §6.5 could not be implemented or tested. - give §6.5 a concrete SessionHandleRef carrying resourceId and runtimeEpoch, so a handle cannot be replayed onto a replacement resource or outlive its owner - add ExecutionCompletion.sessionHandles and the sessionScopedHandles capability axis; runtime owns only registered items after terminal state and the host never consumes handles directly - correct `cancelled` to mean the request reached the driver — it is not evidence that a write rolled back — and require partial or unknown for pipelineAborted - separate RequestError from post-dispatch failures: only rejected requests raise ApiError; dispatched-but-failed executions surface as ExecutionState='failed' plus ExecutionErrorCode - add createProfile to IdempotentOperation, bound to identity and organization but not to a session epoch; state that startJob is likewise not owner-bound - widen the package inventory in system-overview to mark what already exists versus what this design creates --- docs/README.md | 2 +- .../platform/connection-management.md | 31 ++++++-- docs/architecture/platform/system-overview.md | 76 ++++++++++++++----- docs/development/platform-development-plan.md | 6 +- 4 files changed, 86 insertions(+), 29 deletions(-) diff --git a/docs/README.md b/docs/README.md index f2c447ad7..52f3ffe6b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -68,7 +68,7 @@ DataZen 文档只保留两类长期有效内容:**当前功能使用文档** 以下三份文档按用户明确要求保存,是本次设计交付的范围例外。它们标明基线和目标状态,不代表当前 main 已支持团队 Web;落地后应改写为实现事实与开发流程。 - [系统概要设计](architecture/platform/system-overview.md):三种运行方式、模块职责、交互与接口。 -- [连接管理详细设计](architecture/platform/connection-management.md):数据契约、状态机、算法、验收标准与 73 个测试用例。 +- [连接管理详细设计](architecture/platform/connection-management.md):数据契约、状态机、算法、验收标准与 74 个测试用例。 - [分阶段开发计划](development/platform-development-plan.md):连接重构、多应用形态、团队服务和多 worker 的依赖与交付门槛。 ## 开发与发布 diff --git a/docs/architecture/platform/connection-management.md b/docs/architecture/platform/connection-management.md index df5b5c7f6..7df4b2804 100644 --- a/docs/architecture/platform/connection-management.md +++ b/docs/architecture/platform/connection-management.md @@ -194,7 +194,7 @@ type ExecutionErrorCode = | 'sqlError' // 编解码/协议层失败,语句可能已送达 | 'protocolError' - // 精确取消已生效 + // 精确取消已送达驱动;数据库生效范围仍由 effectOutcome 表达,可能为 unknown | 'cancelled' // 执行超过 timeout 且无法判定数据库生效范围 | 'timeout' @@ -317,7 +317,8 @@ type ConnectionEvent = | { kind: 'resultChunk'; artifactId: Id; chunkIndex: Counter; source: StatementResultSource } | { kind: 'streamResetRequired'; reason: string }; -type IdempotentOperation = 'openSession' | 'executeInSession' | 'executeAtTarget' | 'setSessionContext' | 'startJob'; +// createProfile 属配置写入,不绑定会话;其余运行时会话操作绑定 owner runtimeEpoch。 +type IdempotentOperation = 'createProfile' | 'openSession' | 'executeInSession' | 'executeAtTarget' | 'setSessionContext' | 'startJob'; interface SubmissionToken { idempotencyKey: string; @@ -405,14 +406,14 @@ DurableExecutionRecord 保存 executionId、身份、state/effectOutcome、prove | changeContext | ResourceHandle、desired | Confirmed/RequiresReplacement/Unsupported | 不改宿主配置、不默默重连 | | begin/commit/rollback | ResourceHandle、事务选项 | TransactionObservation | 必须作用于同一资源;提交不确定返回 Unknown | | requestCancel | 精确 execution cancelHandle | Requested/Unsupported/AlreadyFinished | 不调用 session-wide cancel 作为隐式回退 | -| resetResource | ResourceHandle、baseline | Clean/Discard | 成功必须满足全部清理条件 | +| resetResource | ResourceHandle、baseline | Clean/Discard | 仅覆盖协议层与初始化基线,不含 §6.5 已登记句柄;成功必须满足全部清理条件 | | closeResource | ResourceHandle | Closed/CloseUnconfirmed | 幂等;未证实关闭不能伪称预算已回收 | 资源句柄仅在对应 provider/worker 有效,不在客户端传递。能力接口使用标准库类型、API 自有类型和 serde;async-trait 方式遵守现有契约。 ResourceDescriptor 必须包含 providerId、resourceKey、sessionContinuity、reusePolicy、initializationRequirements、connectionCostPolicy 和 namespaceShape;Command definition 提供操作级 targetRequirements。ResourceHandle 是 API 定义的不可伪造 opaque ID,由 provider 校验所有权和 epoch。 -ExecutionCompletion 必须包含 completionStatus、effectOutcome、statementResults、contextBefore/after、transactionObservation、protocolDrained、resourceHealth。SessionObservation 的 unknown 字段不能填入 initialTarget 假充确认。ResultSink 提供带字节大小的异步写入/完成/失败方法,写入等待表示背压;不暴露 Tokio channel 类型。 +ExecutionCompletion 必须包含 completionStatus、effectOutcome、statementResults、contextBefore/after、transactionObservation、sessionHandles、protocolDrained、resourceHealth。`sessionHandles` 是本次执行交出的会话级句柄(§6.5):driver 必须如实返回,没有则为空数组,runtime 只对已登记项负责终态后的句柄生命周期,宿主不直接消费句柄。SessionObservation 的 unknown 字段不能填入 initialTarget 假充确认。ResultSink 提供带字节大小的异步写入/完成/失败方法,写入等待表示背压;不暴露 Tokio channel 类型。 `BudgetPort` 返回 opaque 许可,并支持按实际物理连接申请/释放。SQLx 驱动必须约束内部 pool 的真实建连;仅在 acquire 时计数会漏掉 idle 连接。第三方 SDK 无法统计实际 socket 时声明保守资源成本/硬上限,或在严格服务端预算模式拒绝该能力,不能报告精确值。 @@ -424,6 +425,7 @@ ExecutionCompletion 必须包含 completionStatus、effectOutcome、statementRes | namespaceSwitch | inPlace/requiresReplacement/unsupported/unknown | | contextObservation | full/partial/unsupported | | transactionObservation | full/partial/unsupported | +| sessionScopedHandles | supported/unsupported/unknown(是否返回事务、游标、服务端预处理句柄) | | resetForReuse | verified/unsupported | | preciseCancel | supported/unsupported/unknown | | snapshots | perTable/perDatabase/coordinated/unsupported | @@ -484,7 +486,19 @@ lastBusinessActivity 在已接受的实际业务操作开始和终结时更新 ### 6.5 会话级资源句柄登记 -Driver Command 可能返回超出该次 execution 生命周期的会话级句柄:事务、游标、服务端预处理对象。句柄一经返回,必须在返回 execution 终态之前向 session actor 登记;未登记的句柄视为非法,runtime 拒绝把它交给宿主。 +Driver Command 可能返回超出该次 execution 生命周期的会话级句柄:事务、游标、服务端预处理对象。句柄由 §5.1 的 `ExecutionCompletion.sessionHandles` 显式交出,形状为 `SessionHandleRef`: + +```typescript +interface SessionHandleRef { + handleId: Id; + kind: 'transaction' | 'cursor' | 'serverPrepared'; + resourceId: Id; // 所属物理资源;换资源后失效,不得在新 resource 上复用 + runtimeEpoch: Counter; // 归属 epoch,owner 更换后旧句柄一律拒绝 + closed: boolean; +} +``` + +句柄一经返回,必须在返回 execution 终态之前向 session actor 登记;未登记的句柄视为非法,runtime 拒绝把它交给宿主。 execution 终态只表示该次语句结束,不表示句柄失效。命令层自管的句柄映射不算登记。 @@ -730,7 +744,6 @@ SessionDirectory 保存组织/owner/worker/runtimeEpoch,不保存连接。dbSe | OutcomeUnknown | 写入/提交结果未知 | 核验执行,不自动重试 | | PlanStale / SourceChanged / TargetConflictRows | 计划/数据变化 | 重比对或处理冲突 | | IdempotencyConflict | 相同键不同输入 | 修正请求键,不能覆盖记录 | -| ExecutionFailed | 执行已派发但终态失败 | 读 ExecutionView.errorCode 与 effectOutcome 决定下一步,不以 code 推断生效范围 | 每条事件带 stream sequence;重复事件忽略,缺口重新读取状态。session context 按 runtimeEpoch/dbSessionId/contextRevision 更新;比较 Counter 使用 BigInt 或十进制字符串比较,不能 Number 转换。 @@ -738,7 +751,7 @@ SessionDirectory 保存组织/owner/worker/runtimeEpoch,不保存连接。dbSe ### 13.1 幂等键期限与响应分类 -幂等键不是客户端随意 UUID。BackendClient 先从应用服务获取签名提交令牌(本地也使用相同 port);令牌包含随机 nonce、operation、组织/principal/client、issuedAt、expiresAt、keyVersion;运行时会话操作还绑定 owner runtimeEpoch,默认有效期 24 小时,不含秘密。issueSubmissionToken 对 openSession 选定 owner,对已有 session 操作验证并绑定其 owner;返回的 idempotencyKey 是不透明签名令牌。服务器校验签名与作用域,再以令牌摘要为去重 key;不相信客户端时间。未过期且同指纹返回同 receipt,不同指纹 IdempotencyConflict。 +幂等键不是客户端随意 UUID。BackendClient 先从应用服务获取签名提交令牌(本地也使用相同 port);令牌包含随机 nonce、operation、组织/principal/client、issuedAt、expiresAt、keyVersion;运行时会话操作还绑定 owner runtimeEpoch,默认有效期 24 小时,不含秘密。issueSubmissionToken 对 openSession 选定 owner,对已有 session 操作验证并绑定其 owner;`createProfile` 是配置写入,不绑定会话与 runtimeEpoch,签名只限定身份、组织与操作。返回的 idempotencyKey 是不透明签名令牌。服务器校验签名与作用域,再以令牌摘要为去重 key;不相信客户端时间。未过期且同指纹返回同 receipt,不同指纹 IdempotencyConflict。 超过 expiresAt:仍有记录时允许只读查询原 receipt;任何重新提交返回 IdempotencyExpired,不再次执行。完整记录至少保留至 expiresAt + 24 小时,然后可按审计策略删除;删除后过期签名令牌仍被拒绝。令牌验证密钥至少保留至已发令牌全部过期,未知 keyVersion 一律拒绝。长期离线请求需重新核验业务状态,由用户重新提交,不自动换新令牌。执行与 Job 接受记录持久化时只保存请求摘要、executionId/jobId、稳定目标/owner、状态和 receipt 的 durable 投影,禁止保存原请求里的 SessionHandle。接受记录须在 SQL/Job 派发前提交;进程重启后的非终态进入核验/OutcomeUnknown,不自动再执行。 @@ -746,7 +759,9 @@ openSession/context replacement 的完整指纹、SessionView/attachmentToken/re RequestError(ApiError.code)表示请求被拒绝;已接受 ExecutionReceipt 不等于 SQL 成功;CancelReceipt.disposition 表示控制请求结果,不用异常 CancellationUnsupported 表示正常 unsupported。执行终态 errorCode 使用 §4 定义的版本化枚举 ExecutionErrorCode,与 ApiError.code 是两个命名空间;effectOutcome 独立表达数据库生效范围。所有大小写使用 DTO 字面值,如 cancelled/rolledBack/partiallyApplied;文中 Active/Unknown 等状态展示简称不作为协议值。driver 的内部错误由网关脱敏映射,不原样暴露。 -errorCode 与 effectOutcome 正交:sqlError 可对应 completed/rolledBack/partiallyApplied/unknown 任一。超时、取消、连接丢失导致生效范围无法判定时,effectOutcome 必须为 unknown,禁止因为"看到取消"就写 rolledBack——CM-44、CM-47 的断言依赖此规则。新增取值需要提升枚举版本并同时更新全部消费者,宿主、前端与 driver 不得各自扩展。 +errorCode 与 effectOutcome 正交:sqlError 可对应 completed/rolledBack/partiallyApplied/unknown 任一。超时、protocolError、取消、连接丢失导致生效范围无法判定时,effectOutcome 必须为 unknown,禁止因为"看到取消"就写 rolledBack——CM-44、CM-47 的断言依赖此规则。`cancelled` 只表示取消请求已送达驱动,不构成"写入已回滚"的证据;pipelineAborted 至少为 partial 或 unknown。新增取值需要提升枚举版本并同时更新全部消费者,宿主、前端与 driver 不得各自扩展。 + +派发后的执行终态失败只由 `ExecutionState='failed'` + ExecutionErrorCode + 事件表达,不产生 ApiError,也不出现在 RequestError 表中;该表只收"请求被拒绝"的场景(未认证、无权限、参数错误、版本或运行时冲突、预算或速率超限、暂时无可用 worker)。已建立执行记录后由宿主二次校验拒绝的记 `hostRejected`,尚未建立记录时直接返回 ApiError。 ## 14. 开发步骤与文件职责 diff --git a/docs/architecture/platform/system-overview.md b/docs/architecture/platform/system-overview.md index cc7617807..3a4d30191 100644 --- a/docs/architecture/platform/system-overview.md +++ b/docs/architecture/platform/system-overview.md @@ -73,7 +73,7 @@ flowchart TD | SecretProvider | 解密/取凭据、轮换、短期执行材料 | secretRef + 执行身份 | 不把密码返回到普通查询响应 | | DriverRegistry | 描述、协议检查、factory 注册 | descriptor、provider | 不按数据库名称拼 SQL | | ExecutionGateway | 参数/能力/权限校验、执行登记、取消、来源 | ExecutionReceipt、事件 | 不创建隐式共享会话 | -| SessionRegistry | owner、状态机、串行队列、上下文 revision | SessionView | 不用 connectionId 代替 session 身份 | +| SessionRegistry | owner、状态机、串行队列、上下文 revision、会话级句柄登记 | SessionView | 不用 connectionId 代替 session 身份 | | ResourceManager | pool、lease、预算、隧道引用、清理 | ResourceLease | 不把清理失败资源归池 | | MetadataService | 明确对象身份、批量读取、缓存失效 | 元数据、revision | 不改变编辑器当前库 | | JobRuntime | 长任务、阶段、子执行、提交边界、恢复 | JobView、检查点 | 不把 UI 生命周期当任务生命周期 | @@ -96,18 +96,24 @@ packages/application/ Rust 用例、DTO、错误、授权编排 packages/runtime/ Rust session、lease、资源调度、execution、job packages/platform-api/ Rust SessionDirectory/BudgetCoordinator 等 ports 与环境适配 packages/schema-diff/ 共享 Schema Diff 领域引擎(从 src-tauri 分阶段抽取) -packages/data-sync/ 共享 Data Sync 领域引擎(从 src-tauri 分阶段抽取) -packages/data-transfer/ 共享 Data Transfer 领域引擎(从 src-tauri 分阶段抽取) +packages/data-sync/ 共享 Data Sync 领域引擎(从 src-tauri 分阶段抽取) +packages/data-transfer/ 共享 Data Transfer 领域引擎(从 src-tauri 分阶段抽取) packages/driver-api/ 现有 Driver API 与可选能力 +packages/driver-sdk/ 现有驱动前端元数据/方言/Command SDK +packages/ai-api/ 现有 AI Provider trait、工厂与调用协议 +packages/drivers/ 现有驱动 crate 与驱动 UI 源码(随驱动选型注入) packages/backend-client/ TypeScript DTO/client/transport 契约 -packages/wapp-sdk/ Wapp 受控桥 SDK -packages/extension-points/ 桌面特权 EP 契约 -packages/themes/ 静态主题资源与 schema +packages/wapp-sdk/ 现有 Wapp 受控桥 SDK +packages/extension-points/ 现有桌面特权 EP 契约 +packages/ui/ 现有 @datazen/ui 基础视图组件 +packages/themes/ 目标静态主题资源与 schema server/ Rust HTTP host 与服务端 composition root src-tauri/ 桌面 host、IPC/文件/窗口 adapter 与遗留命令 src/ 共享 React 产品与平台桥 ``` +标“现有”的包不在本次新建范围,按现有构建与注入方式复用;`packages/themes` 当前目录仍是旧 v1 ThemePack 存档,目标主题资源与其并存,迁移前不改旧目录。 + 依赖方向:host → application/runtime/领域包;application/runtime → platform-api/driver-api;driver → driver-api。`SessionDirectory` 与 `BudgetCoordinator` 的接口归 `platform-api`,SessionRegistry/ResourceManager 只依赖这些 port;桌面/单进程先用内存适配,多 worker 再替换目录和全局预算适配。领域引擎归 `packages/schema-diff`、`packages/data-sync`、`packages/data-transfer`;Tauri 命令、窗口与文件选择留在 `src-tauri` adapter。领域包不得反向 import host。JobRuntime 通过注册的 JobHandler 调用领域引擎,避免 runtime 与领域 crate 循环依赖。 ## 5. 统一领域对象 @@ -119,7 +125,7 @@ src/ 共享 React 产品与平台桥 | DbSession | dbSessionId + owner + worker | 否 | 连续会话状态,不等于 pool;含 SessionContext 与 contextRevision | | Transaction state | Session actor 内的事务观察与句柄 | 否 | 不分配独立业务 ID;关闭/失效时与 Lease 同步回滚、核验和清理 | | ResourceLease | leaseId + owner + scope | 否 | 物理资源占用、清理与预算 | -| Execution | executionId + owner | 可保存记录 | 一次执行及实际来源;持久化投影排除运行时绑定 | +| Execution | executionId + owner | 是 | 一次执行及实际来源;持久化投影排除运行时绑定与 session 句柄 | | Job | jobId + organizationId + principal | 是 | 长任务、阶段、提交结果、恢复契约 | | Artifact | artifactId + ACL | 是 | 结果或文件,不等于服务器路径 | @@ -149,7 +155,7 @@ BackendClient 是绑定单个 backend 的传输门面;`backendId` 用于选择 | 服务 | 方法 | 输入 | 输出 | 语义 | | --- | --- | --- | --- | --- | | ConnectionClient | listConnections | 无 | ProfileView[] | 当前 backend 下列出授权配置;分页另行版本化,本版不传分页参数 | -| ProfileClient | createConnection | profile draft、write-only credentials、idempotencyKey | ProfileView | 创建配置;凭据仅交 SecretProvider | +| ProfileClient | createConnection | ProfileDraft、write-only credentials、`createProfile` 签名令牌给出的 idempotencyKey | ProfileView | 创建配置;凭据仅交 SecretProvider,不回显 | | ProfileClient | updateConnection | connectionId、expectedRevision、patch | ProfileView | CAS 更新;不改变已建会话 | | ProfileClient | disableConnection | connectionId、expectedRevision | ProfileView | 禁止新执行,已有资源按 drain/force 政策处理 | | ConnectionClient | openSession | OpenSessionRequest | OpenSessionReceipt | 懒建逻辑会话,返回仅 owner 持有的 attachmentToken | @@ -160,11 +166,47 @@ BackendClient 是绑定单个 backend 的传输门面;`backendId` 用于选择 | ConnectionClient | closeSession | handle、`mode: CloseMode` | CloseReceipt | 幂等关闭,不默认提交事务 | | ConnectionClient | getExecution / cancelExecution | executionId、取消操作 token | ExecutionView / CancelReceipt | 查询终态或请求精确取消 | | ConnectionClient | subscribeEvents | streamId、afterSequence | EventEnvelope 流 | 授权后续订;断开订阅不直接取消 SQL | -| JobClient | startJob / getJob / cancelJob | 定义、计划、jobId 与签名 token | JobView / CancelReceipt | 持久化接受;Job 生命周期独立于窗口 | -| ArtifactClient | readArtifact | artifactId、块范围 | ArtifactChunk | 每次授权、限大小并服从产物 TTL | +| JobClient | startJob / listJobs / getJob / cancelJob | 定义、计划、jobId 与签名 token | JobView / CancelReceipt | 持久化接受;listJobs 只返回当前 principal 授权的 Job;Job 生命周期独立于窗口 | +| ArtifactClient | readArtifact | artifactId、chunkIndex 或字节范围 offset/limit | ArtifactChunk | 每次授权、限大小并服从产物 TTL;越界报错,不返回服务器路径 | | SubmissionTokenClient | issueSubmissionToken | operation、可选 SessionHandle | SubmissionToken | 签名限定调用身份、操作和有效期 | -Profile、Job、Artifact 的 DTO 和服务归属在本系统概要中定义;它们不属于连接管理 `ConnectionService`。IPC 与 HTTP adapter 都实现这些接口并共享 ApiError。 +Profile、Job、Artifact 的 DTO 和服务归属在本系统概要中定义;它们不属于连接管理 `ConnectionService`。IPC 与 HTTP adapter 都实现这些接口并共享 ApiError。连接资源 DTO(`ProfileView`、`SessionView`、`ExecutionView` 等)以[详细设计 §4.1](connection-management.md#41-服务接口与补充响应)为权威;写模型与产物 DTO 在此定义,`Id`/`Counter`/`Timestamp`/`NamespaceTarget` 沿用同一套基础类型: + +```typescript +interface ProfileDraft { + name: string; + driverId: string; + initialNamespace: NamespaceTarget; + publicOptions: Readonly>; + credentials?: Readonly>; // write-only,只交 SecretProvider +} + +type ProfilePatch = Partial>; + +type JobState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; + +interface JobView { + jobId: Id; + kind: string; + state: JobState; + stage: string | null; + executionIds: readonly Id[]; + artifactIds: readonly Id[]; + createdAt: Timestamp; + updatedAt: Timestamp; +} + +interface ArtifactChunk { + artifactId: Id; + chunkIndex: Counter; + totalChunks: Counter | null; // 流式结果未收齐时为 null + offset: Counter; + bytes: Uint8Array; // 只含结果字节,不含服务器路径与凭据 + resultCompleteness: 'pending' | 'complete' | 'truncated'; +} +``` + +`credentials` 只出现在请求侧,`ProfileView` 只回 `credentialConfigured`;`driverId` 属目标身份,变更走新建配置而非 patch。`JobView` 由 JobRuntime 拥有,`ArtifactChunk` 由 ArtifactStore 拥有,二者都不承载 connectionId 以外的授权信息,授权仍以 PolicyService 判定为准。 `ContextChangeReceipt` 包含 `session: SessionView`、`replacedSessionId: string | null` 和 `attachmentToken: string | null`;替换时 token 只返回原 owner,原地切换为 null。客户端原子切换到返回 session;失效旧句柄不得继续执行。 @@ -188,12 +230,12 @@ Profile、Job、Artifact 的 DTO 和服务归属在本系统概要中定义; | POST `/api/v1/executions` | executeAtTarget | | GET `/api/v1/executions/{id}` | 执行状态 | | POST `/api/v1/executions/{id}/cancel` | 精确取消 | -| POST/GET `/api/v1/jobs` | 创建/授权列表 | +| POST/GET `/api/v1/jobs` | 创建 / 授权列表(listJobs) | | GET `/api/v1/jobs/{id}` | 任务状态 | | POST `/api/v1/jobs/{id}/cancel` | 任务取消 | | GET `/api/v1/events/{streamId}` | SSE,支持 Last-Event-ID | | POST `/api/v1/artifacts/uploads` | 配额内上传 | -| GET `/api/v1/artifacts/{id}` | 受控结果/下载 | +| GET `/api/v1/artifacts/{id}` | 受控结果/下载;`chunkIndex` 或 `offset`+`limit` 取块 | 异步接受返回 202 + ID;参数错误 400、未认证 401、无权限 403、不可见资源 404、版本/运行时冲突 409、预算或速率超限 429、暂时无可用 worker 503。无法区分“无权限”和“不存在”的资源统一 404,避免 ID 枚举。 @@ -230,7 +272,7 @@ sequenceDiagram UI->>Adapter: openSession(initialTarget) Adapter->>Gateway: 已认证 RequestContext Gateway->>Session: 创建 New session(不建连) - Session-->>UI: SessionView + Session-->>UI: OpenSessionReceipt(session, attachmentToken) UI->>Adapter: executeInSession(handle, expectedContextRevision) Adapter->>Gateway: 校验权限与幂等 Gateway->>Session: 排队并建立固定资源 @@ -239,7 +281,7 @@ sequenceDiagram Session-->>UI: 带 runtimeEpoch/dbSessionId/revision 的事件 ``` -SQL 解析用于提示,不提前修改已生效上下文。会话操作串行;取消通过独立控制路径进入,不能排在待取消查询之后。 +SQL 解析用于提示,不提前修改已生效上下文。会话操作串行;取消通过独立控制路径进入,不能排在待取消查询之后。`attachmentToken` 只属于原 owner 且不落盘,handoff 必须走 attach/detach;图中的“事务状态”若指向会话级句柄(事务/游标),必须按[§6.5](connection-management.md#65-会话级资源句柄登记)在终态前登记到 session actor。 ### 7.2 TablePanel 与元数据 @@ -303,8 +345,8 @@ Web 认证首版采用 OIDC 登录及服务端登录会话,浏览器使用 Sec - 会话、物理绑定和可持久化执行来源分开;`dbSessionId`、SessionHandle、lease/cursor、取消绑定与 attachment token 均不落盘,详见[连接管理 §4.4](connection-management.md#44-可落盘来源与运行时绑定)。 - Driver 提供 namespaceShape 与操作级 targetRequirements;网关用同一 CanonicalTarget 做授权、缓存和执行,详见[§4.3](connection-management.md#43-命名空间规范化契约)及[§9.6](connection-management.md#96-poolkey版本与缓存的生产者)。 - SessionDirectory/预算、会话失效、worker 路由和候选替换归属见[§12](connection-management.md#12-web多实例权限与结果);丢失物理资源后返回 SessionLost,禁止透明重建。 -- Attachment、TTL、结果放弃消费与清理分别见[§6.4](connection-management.md#64-attachment-与超期处理)、[§7.7](connection-management.md#77-结果订阅与放弃消费)和[§9.4–9.5](connection-management.md#94-归池前检查)。 -- 取消回执、错误与签名幂等期限以[§13](connection-management.md#13-错误重试和事件)为权威;完整验收用例 CM-01~73 和开发阶段映射见详细设计、[开发计划](../../development/platform-development-plan.md)。 +- Attachment、TTL、结果放弃消费与清理分别见[§6.4](connection-management.md#64-attachment-与超期处理)、[§7.7](connection-management.md#77-结果订阅与放弃消费)和[§9.4–9.5](connection-management.md#94-归池前检查);事务/游标等会话级句柄必须在终态前登记到 session actor 才允许归池,见[§6.5](connection-management.md#65-会话级资源句柄登记)。 +- 取消回执、错误与签名幂等期限以[§13](connection-management.md#13-错误重试和事件)为权威;完整验收用例 CM-01~74 和开发阶段映射见详细设计、[开发计划](../../development/platform-development-plan.md)。 ## 13. 参考与维护 diff --git a/docs/development/platform-development-plan.md b/docs/development/platform-development-plan.md index 3f0e12762..273596ff8 100644 --- a/docs/development/platform-development-plan.md +++ b/docs/development/platform-development-plan.md @@ -242,7 +242,7 @@ P4 与 P5 代码边界独立后可以分别推进,但必须共享已稳定的 - 更新 Driver 公共协议、独立驱动指南和已实现架构;本三份文档逐节转为事实/流程。 - 采集 fake runtime 基准、真实典型负载和资源计数,按实测调整默认预算,不承诺跨环境 SQL 延迟。 -退出门槛:详细设计 CM-01~73 全部适用门槛,新增 driver 不改宿主数据库分支;未能执行的真实环境验证明确列入发布限制,不能称全部通过。 +退出门槛:详细设计 CM-01~74 全部适用门槛,新增 driver 不改宿主数据库分支;未能执行的真实环境验证明确列入发布限制,不能称全部通过。 回退:只回退新增驱动选型/包;已删除旧路径不为了兼容缺失能力重新引入。公共契约确有新语义需求时走版本化演进。 @@ -294,10 +294,10 @@ WDIO 必须使用已注入 driver 的构建;三件套可运行 app 已生成 | --- | --- | --- | | P0/P1 | DTO、持久化白名单、目标规范化、错误/回执、能力快照、令牌 port | CM-61/62/72 的类型与 schema 断言 | | P2 | namespaceShape、targetRequirements、规范化资源 key、driver 清理与协议 drain | CM-62/64/67/69 的 D 断言 | -| P3 | attachment、结果放弃、类别调度、逻辑配额、PoolKey/cache、替换发布、幂等过期、内存目录与事务失效回收 | CM-61~73 的 H 断言,CM-60 基准 harness | +| P3 | attachment、结果放弃、类别调度、逻辑配额、PoolKey/cache、替换发布、幂等过期、内存目录、事务失效回收与会话级句柄登记 | CM-61~74 的 H 断言,CM-60 基准 harness | | P4 | 恢复结果只读/写回、TTL 与切库错误投影、关闭结果视图行为 | CM-61~64/72 的 F 断言 | | P5/P6 | 能力快照计划核验、Job 多端预算、独立消费者政策 | CM-40~53 与 CM-65/67 的任务断言 | | P7/P8 | 单实例认证重附着、HTTP 回执与持久化投影、签名令牌和 SSE | CM-63/68/70/72 的 W1 断言 | | P9 | 原子 owner 登记、碰撞/失效、跨节点替换发布、分区和全局预算 | CM-57/58/60/68/71 的 WN 断言 | -测试标签代表执行层,不代表整条用例只属于某阶段。P3/P4 必须完成 CM-54~56 的 runtime/前端部分;P7 完成单实例适配部分;只有 owner 路由、worker 分区及跨节点预算推迟到 P9。P10 逐层汇总全部 73 条适用用例,不能把未执行的 D/WN 断言计为已通过。 +测试标签代表执行层,不代表整条用例只属于某阶段。P3/P4 必须完成 CM-54~56 的 runtime/前端部分;P7 完成单实例适配部分;只有 owner 路由、worker 分区及跨节点预算推迟到 P9。P10 逐层汇总全部 74 条适用用例,不能把未执行的 D/WN 断言计为已通过。 From 931a53d6466339748b6fa6502ae956e72e652ce3 Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 07:48:57 +0800 Subject: [PATCH 054/666] =?UTF-8?q?docs(platform):=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=BC=94=E8=BF=9B=E8=AF=A6=E7=BB=86=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=EF=BC=8C=E6=96=B0=E5=A2=9E=206=20=E7=AF=87=E5=B9=B6?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E6=9D=83=E5=A8=81=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 persistence-model / team-server-and-auth / shared-boundaries-and-ports / fake-runtime-fixtures / driver-capability-migration / workflow-resource-model - connection-management §13 登记 31 个 code;plan §17 修正 P2 的 CM 集合 - system-overview §4 补 IdentityResolver 行,§6.4 保持 10 行 - 清除过期限定语与裸行号引用,全仓 1138 条链接零新增失效 --- docs/README.md | 20 +- .../platform/connection-management.md | 17 +- .../platform/driver-capability-migration.md | 550 ++++++++++++ .../platform/fake-runtime-fixtures.md | 673 +++++++++++++++ .../platform/persistence-model.md | 800 ++++++++++++++++++ .../platform/shared-boundaries-and-ports.md | 689 +++++++++++++++ docs/architecture/platform/system-overview.md | 7 +- .../platform/team-server-and-auth.md | 796 +++++++++++++++++ .../platform/workflow-resource-model.md | 799 +++++++++++++++++ docs/development/platform-development-plan.md | 3 +- 10 files changed, 4344 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/platform/driver-capability-migration.md create mode 100644 docs/architecture/platform/fake-runtime-fixtures.md create mode 100644 docs/architecture/platform/persistence-model.md create mode 100644 docs/architecture/platform/shared-boundaries-and-ports.md create mode 100644 docs/architecture/platform/team-server-and-auth.md create mode 100644 docs/architecture/platform/workflow-resource-model.md diff --git a/docs/README.md b/docs/README.md index 52f3ffe6b..797b6a2da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,11 +65,25 @@ DataZen 文档只保留两类长期有效内容:**当前功能使用文档** ### 桌面 / Web 平台演进设计(待实现) -以下三份文档按用户明确要求保存,是本次设计交付的范围例外。它们标明基线和目标状态,不代表当前 main 已支持团队 Web;落地后应改写为实现事实与开发流程。 +以下九份文档按用户明确要求保存,是本次设计交付的范围例外。它们标明基线和目标状态,不代表当前 main 已支持团队 Web;落地后应改写为实现事实与开发流程。 + +概要、契约与计划: - [系统概要设计](architecture/platform/system-overview.md):三种运行方式、模块职责、交互与接口。 -- [连接管理详细设计](architecture/platform/connection-management.md):数据契约、状态机、算法、验收标准与 74 个测试用例。 -- [分阶段开发计划](development/platform-development-plan.md):连接重构、多应用形态、团队服务和多 worker 的依赖与交付门槛。 +- [连接管理详细设计](architecture/platform/connection-management.md):数据契约、状态机、算法、验收标准与 74 个测试用例,是接口契约、错误码与验收的唯一权威。 +- [分阶段开发计划](development/platform-development-plan.md):连接重构、多应用形态、团队服务和多 worker 的依赖与交付门槛,是阶段→补充契约归属的唯一权威。 + +补充详细设计(P0–P2): + +- [共享应用边界与端口详细设计](architecture/platform/shared-boundaries-and-ports.md):包边界与依赖矩阵、runtime 内部模块、端口 trait 签名、请求上下文三来源与 Tauri 组装根。 +- [P0 假资源夹具与基准 harness 详细设计](architecture/platform/fake-runtime-fixtures.md):transport-neutral fake 的资源契约、F1~F12 故障注入矩阵、CommandJournal、Barrier/DrainBarrier 与 FakeClock。 +- [持久化模型详细设计](architecture/platform/persistence-model.md):可落盘白名单与禁落盘清单、管理库表的 DDL 与 CHECK、本地 Store ↔ 服务端 DB 映射、schema 版本与 expand/contract。 +- [驱动能力迁移详细设计](architecture/platform/driver-capability-migration.md):path 驱动的能力现状矩阵、迁移批次与依赖图、协议版本演进门槛、过渡 adapter 的退役条件。 + +补充详细设计(P6–P7): + +- [Workflow 资源模型详细设计](architecture/platform/workflow-resource-model.md):step / session block / transaction block 三种执行单元、目标继承链、Session/Lease/Budget 集成与版本兼容边界。 +- [团队 Web 服务与认证详细设计](architecture/platform/team-server-and-auth.md):server crate 形态、中间件链、OIDC/CSRF/RBAC、SSE 回放与错误到 HTTP 的映射。 ## 开发与发布 diff --git a/docs/architecture/platform/connection-management.md b/docs/architecture/platform/connection-management.md index 7df4b2804..c3d13b737 100644 --- a/docs/architecture/platform/connection-management.md +++ b/docs/architecture/platform/connection-management.md @@ -202,7 +202,7 @@ type ExecutionErrorCode = | 'resourceLost' // 放弃消费或背压导致的截断中止 | 'pipelineAborted' - // 派发前宿主重校验拒绝:权限撤销、额度、队列、sql_guard + // 已建立执行记录后由宿主二次校验拒绝:权限撤销、额度、队列、sql_guard;尚未建立记录时直接返回 ApiError,不产生本枚举取值 | 'hostRejected'; interface ExecutionReceipt { @@ -626,6 +626,8 @@ MySQL USE 改变当前会话默认数据库;完整限定对象名不等于切 | 项 | 初始值 | 行为 | | --- | --- | --- | | 短操作 pool | min=0、max=2/PoolKey | 还受服务级预算约束 | +| pool idle TTL | 60 秒 | 空 pool 元数据按 LRU 可删除;有资源的 pool 先关闭再删除 | +| 空 pool 元数据条目上限 | 32 个 | 每 pool min=0、max=2,仍受服务总预算约束 | | 每用户已连接编辑器 | 5 | Opening 时预留名额;资源仍存活的 Ready/Executing/Closing/Lost 占用至确认释放 | | 桌面总目标连接 | 16 | idle、任务和控制资源都计入 | | 团队单数据库服务额度 | 20 | 管理员按目标 max connections 调整 | @@ -669,7 +671,7 @@ cancel 所需控制连接预留在同一预算内,不能在满池时无限额 | organizationId/connectionId/configRevision | RequestContext + ProfileRepository 的 CAS 版本 | 新 key;旧 idle 停发并关闭 | | credentialRevision | SecretProvider 的不透明版本 | 新 key;不使用密码散列作为 key | | executionIdentityKey | IdentityResolver:DB 登录身份、委托角色、有效权限范围 | 重新解析并隔离池与缓存 | -| policyIsolationKey | AuthorizationService:组织/用户/策略版本及只读限制 | 权限撤销立即停发,排队重新鉴权 | +| policyIsolationKey | PolicyService:组织/用户/策略版本及只读限制 | 权限撤销立即停发,排队重新鉴权 | | driverResourceKey | describeResource 对 CanonicalTarget/基线的规范化结果 | 数据库绑定资源按 database 分 key | | networkRouteRevision | NetworkProvider 的路由/隧道/TLS 配置版本 | 新 key,旧路由不发放新租约 | @@ -744,10 +746,17 @@ SessionDirectory 保存组织/owner/worker/runtimeEpoch,不保存连接。dbSe | OutcomeUnknown | 写入/提交结果未知 | 核验执行,不自动重试 | | PlanStale / SourceChanged / TargetConflictRows | 计划/数据变化 | 重比对或处理冲突 | | IdempotencyConflict | 相同键不同输入 | 修正请求键,不能覆盖记录 | +| Unauthenticated | 未认证 | 跳转登录;不自动重放原请求 | +| NotFound | 不可见资源、不存在资源或二者不可区分 | 不重试,不据此推断资源存在性 | +| PayloadTooLarge | 请求体超过物理上限 | 缩减请求 | +| QuotaExceeded | 组织/用户/数据库额度超限 | 等待或申请配额 | +| RateLimited | 令牌桶限流拒绝 | 按 Retry-After 退避 | +| ServiceUnavailable | 暂无可用 worker、drain 中、管理库不可达或迁移未完成 | 退避后重发;已接受的幂等提交必须复用同一 idempotencyKey | +| ConfigRevisionMismatch | ProfileRepository::compare_and_set 的 expectedRevision CAS 失败(命名空间目标本身合法,区别于目标冲突的 TargetConflict) | 重读最新 configRevision 后由用户决定,不自动覆盖 | 每条事件带 stream sequence;重复事件忽略,缺口重新读取状态。session context 按 runtimeEpoch/dbSessionId/contextRevision 更新;比较 Counter 使用 BigInt 或十进制字符串比较,不能 Number 转换。 -幂等记录原子保存请求指纹和 receipt,键的有效期协议见 13.1。首版执行/Job 的完整 durable receipt 保留至少 24 小时,会话回执仅内存保留;不依赖已删除记录来识别旧请求。幂等提交只保证创建 execution 一次,不保证外部 SQL exactly-once。执行记录不可用时,客户端不能根据没有事件推断未执行。 +幂等记录原子保存请求指纹和 receipt,键的有效期协议见 13.1。首版执行/Job 的完整 durable receipt 保留至少至 `expiresAt + 24 小时`,会话回执仅内存保留;不依赖已删除记录来识别旧请求。幂等提交只保证创建 execution 一次,不保证外部 SQL exactly-once。执行记录不可用时,客户端不能根据没有事件推断未执行。 ### 13.1 幂等键期限与响应分类 @@ -818,7 +827,7 @@ CM-60 使用 release 构建,4 vCPU/8 GiB、无数据库网络、单进程固 ## 16. 详细测试用例 -每个用例实现时保留编号。H=Host fake/runtime,D=driver,F=前端,W=Web/worker。涉及真实方言的 D 断言放驱动目录,Host 只运行统一 contract。 +每个用例实现时保留编号。H=Host fake/runtime,D=driver,F=前端,W=Web/worker 泛称(§15.3 再分 W1=单实例 HTTP/SSE、WN=多 worker);标题写 (W) 表示该 Web 断言横跨两种部署形态,阶段性落点按 §15.3 与 §17 判定,与标题直接写 (W1)/(WN) 的用例同属一类层标签。涉及真实方言的 D 断言放驱动目录,Host 只运行统一 contract。 ### 16.1 类型、身份、目标与懒连接 diff --git a/docs/architecture/platform/driver-capability-migration.md b/docs/architecture/platform/driver-capability-migration.md new file mode 100644 index 000000000..20bc5d40e --- /dev/null +++ b/docs/architecture/platform/driver-capability-migration.md @@ -0,0 +1,550 @@ +# P2 阶段:path 驱动的资源能力现状清点、迁移批次与协议版本演进 + +> **状态:目标设计,尚未实现。** +> 本文清点的是当前代码事实,描述的是 P2 应当如何迁移到目标契约;两者之间尚未落地的部分全部标注为待办,不得当作已实现能力。 +> +> - 基线:`8592b0fe1`(2026-09-30)。 +> - 代码核对时的工作区 HEAD 为 `e544e0699`(2026-09-30 21:08)。`8592b0fe1` 是 HEAD 的祖先,两者相差 4 个提交;`git diff --stat 8592b0fe1..HEAD -- packages/drivers packages/driver-api` 输出为空,因此本文的全部代码结论对两个基线一致。 +> - 配套文档:[连接与资源运行时设计](connection-management.md)(下称「连接管理」)、[平台开发计划](../../development/platform-development-plan.md)、[Driver API 依赖边界](../../development/driver-api-dependency-boundary.md)。 +> - 独立开发与发布的约束另见 [可选驱动](../../development/optional-drivers.md) 与 [独立驱动开发指南](../../development/independent-driver-development.zh-CN.md)。 + +## 0. 阅读须知:本文「当前」列的证据来源 + +本文的每一格「当前」结论都来自对代码的逐项核对,不是推断。核对范围是 `packages/driver-api/**` 与 `packages/drivers//**` 在上述基线下的实现,宿主侧只读 `src-tauri/src/db/registry.rs`、`src-tauri/src/ai/registry.rs` 与 `data_transfer` 的版本闸门。 + +三条硬规则: + +1. **不猜测。** 只要某个能力无法从已读代码直接推出,就在矩阵中写「待 P0 验证」,并在 [§9](#9-待-p0-验证清单) 集中列出,绝不用「大概率支持」「应当可以」代替。 +2. **能力取驱动当前返回的行为,不是引擎上限。** §5.2 的 `sessionScopedHandles` 问的是 driver 是否返回事务/游标/服务端预处理句柄;Redis 引擎支持 MULTI、DuckDB 引擎支持快照,都不改变「驱动当前返回 `unsupported`」这个事实。引擎能力与驱动能力必须分别记录。 +3. **枚举值必须与连接管理 §5.2 逐字一致**(`supported` / `unsupported` / `unknown` / `inPlace` / `requiresReplacement` / `full` / `partial` / `verified` / `perTable` / `perDatabase` / `coordinated`)。本文不新增、不合并、不改名任何取值。 + +矩阵列数说明:连接管理 §5.2 的表格列出 10 条具名能力,第 11 列 `namespaceShape` 来自 §5.1 对 `ResourceDescriptor` 的必填字段要求(`ResourceDescriptor` 必须包含 `namespaceShape`)。因此矩阵是 **15 个驱动 × 11 列 = 165 格**。§5.1 的 9 个契约操作与 §5.2 的能力枚举在 `packages/driver-api` 中**都还不存在**(`resource.rs` / `session.rs` / `capabilities.rs` 是拟新增文件),本文的所有「当前」值都是从现有 trait 方法的具体实现反推出来的,这也是 P2 的起点形态。 + +## 1. 驱动清单 + +### 1.1 path 驱动逐个清点 + +`drivers-registry.json` 中 `source: "path"` 的条目共 15 个,全部对应 `packages/drivers/` 下的 crate,全部是根 `Cargo.toml` 的 workspace member(`members` 含 `packages/drivers/*`),全部参与 `--drivers=basic|all|stub` 的编译期选型,生成产物 `generated.ts` / `driver_init.rs` / `.driver-features.json` 均 gitignored。 + +| # | 目录 | crate 名 | registry 条目 | 默认 basic 构建 | 链接期注入 | +| --- | --- | --- | --- | --- | --- | +| 1 | `postgres` | `datazen-driver-postgres` | `postgres` | 是 | inventory | +| 2 | `mysql` | `datazen-driver-mysql` | `mysql` | 是 | inventory | +| 3 | `sqlite` | `datazen-driver-sqlite` | `sqlite` | 是 | inventory | +| 4 | `redis` | `datazen-driver-redis` | `redis`(`tauriPlugin`) | 是 | inventory | +| 5 | `mongodb` | `datazen-driver-mongodb` | `mongodb` | 否(Akulaku SKU) | inventory | +| 6 | `sqlserver` | `datazen-driver-sqlserver` | `sqlserver` | 否 | inventory | +| 7 | `clickhouse` | `datazen-driver-clickhouse` | `clickhouse` | 否 | inventory | +| 8 | `duckdb` | `datazen-driver-duckdb` | `duckdb` | 否 | inventory | +| 9 | `elasticsearch` | `datazen-driver-elasticsearch` | `elasticsearch` | 否 | inventory | +| 10 | `rqlite` | `datazen-driver-rqlite` | `rqlite` | 否 | inventory | +| 11 | `turso` | `datazen-driver-turso` | `turso` | 否 | inventory | +| 12 | `influxdb` | `datazen-driver-influxdb` | `influxdb` | 否 | inventory | +| 13 | `victoriametrics` | `datazen-driver-victoriametrics` | `victoriametrics` | 否 | inventory | +| 14 | `hbase` | `datazen-driver-hbase` | `hbase` | 否 | inventory | +| 15 | `vector` | `datazen-driver-vector` | `vector` | 否 | inventory | + +`scripts/resolve-drivers.mjs` 的 `BASIC_DRIVERS` 为 `['postgres', 'mysql', 'sqlite', 'redis']`,默认 `--drivers=basic`;`all` 取遍 registry 里**所有 path 条目**,不取 git 条目。因此 `--drivers=all` 构建出的驱动集合就是上表 15 个,与 §1.3 的出入讨论无关。 + +`register_driver!` 宏在每个 crate 的 `lib.rs` 里被调用,`inventory::collect!` 在 `packages/driver-api/src/factory.rs` 里收集 `DatabaseDriverFactory`;宿主 `DriverRegistry` 只经 factory 创建,不按驱动名分支。 + +### 1.2 `http-support`:共享 helper crate,不是驱动 + +`packages/drivers/http-support` 的 crate 名是 `datazen-driver-http-support`(路径依赖 `../http-support`),依赖 `datazen-driver-api`、`reqwest 0.13.3`、`base64`、`serde_json`,`src/lib.rs` 共 146 行。 + +- **它没有 `register_driver!`,没有 `DatabaseDriverFactory`,不出现在 `drivers-registry.json` 里**,因此不是驱动,也不参与选型、不产生 `driver_id`。 +- 它被 10 个驱动消费:clickhouse、duckdb、elasticsearch、hbase、influxdb、vector、victoriametrics、rqlite、turso、sqlserver。它提供的是 HTTP 客户端构造、基础 URL 拼接、认证头与响应解码一类管道。 +- **关于 P2 退出门槛「不同 driver 不共享实现库类型」**:本文采纳的读法是「驱动之间不得共享后端实现库类型」。`http-support` 只提供 `reqwest` 管道,不暴露、也不依赖任何数据库后端类型(ClickHouse 类型、ES document、TDS 协议类型、Influx line protocol 都在各自 crate 内);`sqlserver` 消费它并不意味着它和 `turso` 共享 SQLite 类型——`turso` 用的是自己的 HTTP 路径。因此 `http-support` 满足该门槛。**但这条读法必须在 P0 写成明确规则并加边界测试**,否则「共享实现库」这条门槛无法判定,见 [§8](#8-依赖边界约束)。 + +### 1.3 「17 个 path 驱动」这一表述与代码事实的出入 + +任务口径说「17 个 path 驱动」,代码核对结果是 **15 个**。差的 2 个来源是: + +- `packages/drivers/kiwi`(crate `datazen-plugin-kiwi`)与 `packages/drivers/superset`(`register_driver!`,`DriverCategory::Sql`)在工作区里存在,但 registry 里是 `source: "git"`,不是 path;它们被根 `Cargo.toml` 的 `exclude` 排除在 workspace 之外,是 `resolve-drivers.mjs` 克隆后注入的。 +- `packages/drivers/olap` 同为 `source: "git"`(registry 钉 `ref` = `7096c8737dbe25ab4529e5c3997c9f67309154d9`),但**当前工作区里没有该目录**,未克隆。 + +所以「17」= 15 个 path 驱动 + 2 个恰好在本地可见的 git 驱动。本文后续所有矩阵、批次、协议章节都只覆盖 15 个 path 驱动;git 驱动在 [§5](#5-协议版本演进) 单独说明升级机制,但不在能力矩阵内——它们的 `describeResource` 只能在自己的仓库里实现。 + +## 2. 能力现状总矩阵 + +### 2.1 矩阵 + +下表是 P2 的起点形态。取值全部按连接管理 §5.2 的枚举字面量书写;「待 P0 验证」的格在 [§9](#9-待-p0-验证清单) 有对应条目。`transactions` 与 `ddlAtomicity` 在 §5.2 是自由文本范围,这里填的是从实现中读到的实际语义。 + +| 驱动 | statefulSession | namespaceSwitch | contextObservation | transactionObservation | sessionScopedHandles | resetForReuse | preciseCancel | snapshots | transactions(实际语义) | ddlAtomicity | namespaceShape | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| postgres | `unsupported` | `requiresReplacement` | `partial` | `partial` | `unsupported` | `unsupported` | `supported` | `perDatabase` | `BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY` 快照;无 savepoint;事务独占一个 `PoolConnection`,跨库语句直接拒绝 | `Transactional` | database + schema(默认 `public`) | +| mysql | `unsupported` | `inPlace` | `partial` | `partial` | `unsupported` | `unsupported` | `supported` | `perDatabase` | `SET TRANSACTION ISOLATION LEVEL REPEATABLE READ` + `START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY`;无 savepoint;DDL 隐式提交 | `AutoCommitPerStatement` | database(无 schema 层) | +| sqlite | `unsupported` | `unsupported` | `partial` | `partial` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 文本执行 `BEGIN`/`COMMIT`/`ROLLBACK`;无 savepoint;`begin_read_snapshot` 未实现 | `Transactional` | 单文件 = 单库(`ATTACH` 仅出现在 `#[cfg(test)]`) | +| sqlserver | `unknown` | `unsupported` | `partial` | `partial` | `unsupported` | `unsupported` | `unsupported` | `perDatabase` | `BEGIN TRANSACTION`/`COMMIT`/`ROLLBACK`;提交回滚后按 `restore_isolation` 复原隔离级别;失败即移除客户端;无 savepoint | `Unknown` | database + schema(默认 `dbo`) | +| redis | `unknown` | 待 P0 验证 | 待 P0 验证 | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 无(三个事务方法走 trait 默认 `TransactionError`) | `Unknown` | db index(`db0`..`dbN-1`,由 `CONFIG GET databases` 决定) | +| mongodb | `unsupported` | 待 P0 验证 | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 无(走 trait 默认 `TransactionError`) | `Unknown` | database 即命名空间(无 schema) | +| clickhouse | `unsupported` | 待 P0 验证 | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 无(走 trait 默认 `TransactionError`) | `Unknown` | database(ClickHouse 的 "schema" 就是 database) | +| duckdb | `unknown` | 待 P0 验证 | 待 P0 验证 | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 驱动层未暴露;引擎自身事务能力待 P0 验证 | `Unknown` | 单文件 = 单 catalog;`get_databases` 返回的 `main` / `temp` 是库内 **schema** 名而非 catalog 名(`duckdb.rs:80-98` 的 `catalog_selector` 注释已明确) | +| rqlite | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 无 | `Unknown` | 固定为 `main` | +| turso | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 无 | `Unknown` | 固定为 `main` | +| elasticsearch | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 无 | `Unknown` | 固定为 `default` | +| hbase | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 无 | `Unknown` | 固定为 `default` | +| influxdb | `unsupported` | 待 P0 验证 | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 无 | `Unknown` | database(`SHOW DATABASES` 实际列举) | +| victoriametrics | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 无 | `Unknown` | 固定为 `default` | +| vector | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | `unsupported` | 无 | `Unknown` | 固定为 `default` | + +统计:**165 格中 158 格已核实,7 格待 P0 验证**。另有 3 格填的是枚举自带的 `unknown`(sqlserver / duckdb / redis 的 `statefulSession`),它们是有效取值,含义是「不得据此开启需要保证的功能」,P0 需要给出把它收紧成 `supported` 或 `unsupported` 的证据。 + +### 2.2 读表之前必须知道的三条结构事实 + +这三条决定了整个矩阵的形状,也决定了 P2 的工作量集中在哪。 + +**事实一:trait 默认值是 fail-closed 的,但有一个例外。** `packages/driver-api/src/traits.rs` 的默认实现里,`has_schema_level` = `false`(:123)、`has_multi_database` = `false`(:133)、`default_schema` = `None`(:147)、`ddl_atomicity` = `DdlAtomicity::Unknown`(:157)、`begin_transaction`/`commit`/`rollback` 返回 `TransactionError`(:602/:743/:749)、`begin_read_snapshot` 返回 `Unsupported("stable read snapshots are not supported by this driver")`(:734)、`discard_connection` 返回 `Unsupported`(:653)、`cancel_query_with_execution` 返回 `Unsupported("precise query cancellation is not supported for execution {}")`(:794)。**唯一破坏 fail-closed 的是 `cancel_query`**:它没有默认体,是必实现方法,而 15 个 path 驱动里有 13 个把它实现成 `Ok(())`。这是 P2 退出门槛「新增能力缺失不会 no-op 成功」最直接的违反点,见 [§7](#7-unsupported-的拒绝断言要求)。 + +**事实二:当前没有任何驱动拥有一等固定资源会话。** 逐个读 `connect` 得到的连接模型是: + +- postgres:`sqlx` 连接池按 handle 建,每库一个池(`MAX_DATABASE_POOLS_PER_HANDLE = 8`,`DATABASE_POOL_MAX_CONNECTIONS = 2`),外加一个独立的 `control_pool`(max 1 / min 0)供取消用;每次操作 `pool.acquire()`。 +- mysql:池化,且**每条语句执行前**都要对刚 acquire 到的连接重跑 `apply_active_database`。 +- sqlserver:每个 `pool_id` 恰好一个 `tiberius::Client`,`handle.id == handle.pool_id`,是真实物理 TCP 会话。 +- sqlite:`SqlitePoolOptions::max_connections(1).min_connections(1)`,单文件。 +- duckdb:`::duckdb::Connection` 包在 `Arc>` 里,一个 handle 一个。 +- redis:handle 存一条 `RedisConn { plan, live }`;**大多数面向库的操作直接在这条常驻连接上发 `SELECT` 再执行**(`with_live_op!` / `with_live_op_topo!`,见 `mod.rs:33-64`,调用点如 `mod.rs:208`、`:242`、`:264`、`:357`、`:387`),只有元数据读取另开一次性连接(`open_pinned_conn`:`database.rs:122` 的 `get_tables`、`database.rs:146` 的 `get_table_schema`;`mod.rs:313-316` 的全库 `DBSIZE` 统计也另开 `open_live_conn`),以免扰动共享会话。因此「切换 db」在同一驱动里**就地切换与换连接两种机制并存**,矩阵取值待 P0 定案([§9](#9-待-p0-验证清单) 第 7 条)。 +- 其余 9 个(mongodb、clickhouse、rqlite、turso、elasticsearch、hbase、influxdb、victoriametrics、vector):存 `(client, base)` 元组,**完全无会话**。 + +事务只在事务存续期间临时占用一条连接(postgres 是 `pool.acquire()` 后把 `PoolConnection` 存进 `self.transactions`;mysql 同理;sqlite 通过 `tx.connection_id` 重建 handle;sqlserver 直接就是那条常驻 client)。所以**不能**把任何驱动标成 `statefulSession: supported`。sqlserver / duckdb / redis 三格写 `unknown` 而不是 `unsupported`,是因为它们确实存在「物理连接在 handle 生命周期内不变」的事实,只是没有任何声明或校验机制把它变成可依赖的保证——见 [§6.1](#61-允许与禁止)。 + +**事实三:`unsupported` 的驱动大多不是「不支持」,而是「根本没接」。** 8 个驱动(duckdb、elasticsearch、hbase、influxdb、rqlite、turso、vector、victoriametrics)**一个能力方法都没覆盖**,全走 trait 默认。这在 P2 里要分两种处理:真正没有该能力的(HTTP 文档库的 `snapshots`、`transactions`)保留 `unsupported` 并补拒绝断言;引擎支持但驱动没接的(SQLite 快照、DuckDB 事务、Redis 事务)要进批次 1/2 补实现,否则 Host 会把「驱动没接」和「后端不支持」混成同一个错误,无法给出可执行的下一步。 + +## 3. 分驱动说明 + +每节只写已读代码能证明的语义;与连接管理 §5.1 的 9 个契约操作(`describeResource` / `acquireResource` / `executeOnResource` / `observeSession` / `changeContext` / `begin|commit|rollback` / `requestCancel` / `resetResource` / `closeResource)逐条的差距在每节末尾的「与目标契约的差距」中给出。 + +### 3.1 PostgreSQL(含 questdb / cloudberry 复用) + +`packages/drivers/postgres` 一个 crate 注册 3 个 factory:`PostgresFactory`(`driver_id = "postgresql"`)、`QuestDbFactory`(`"questdb"`)、`CloudberryFactory`(`"cloudberry"`)。后两个的 `create()` 返回 `ReuseDriver::new_with_precise_cancel(PostgresDriver, "questdb"|"cloudberry", true)`。三个 factory 的 `supports_explain()` 与 `supports_query_execution_cancel()` **都**返回 `true`(`lib.rs:36,42` / `:60,66` / `:86,92`),但 `supports_cancel_query()` **只有 `postgresql` 返回 `true`**(`lib.rs:39`),`questdb` / `cloudberry` 返回 `false`(`:63` / `:89`)。 + +- **多库/多 schema**:库级由多池承担,每库一个池,单 handle 最多 8 个;schema 级由 `search_path` 承担。`build_pg_options()` 在**建连时**写入 `.database(resolve_connect_database(config))`,并且只在 `config.schema` 非空时追加 `opts.options([("search_path", format!("\"{clean}\",public"))])`。`fetch_tables_from_pool` 读取时也固定补 `'public'`。这意味着 schema 是**连接建立时固化**的,驱动里没有对活会话发 `SET search_path` 的路径。 +- **事务隔离**:`begin_transaction_impl` 为每个 `handle.id` 记一条事务,从该 handle 自己的池里 `pool.acquire()` 一条专用连接执行 `BEGIN`,并把这条连接存进 `self.transactions`。源码注释明确写:事务占用本库池中的一条连接,指向另一个库的语句被拒绝,绝不静默重定向。`begin_read_snapshot_impl` 发 `BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY`。 +- **会话作用域句柄**:`prepare_query_execution` 存在,但 `QueryExecutionId` 在 API 文档里被要求保持 opaque,禁止暴露后端 PID/线程号。 +- **取消**:`cancel_query` 返回 `Err(DriverError::Unsupported("legacy session-wide query cancellation is disabled; use an execution handle"))`;`cancel_query_with_execution` 走独立的 `control_pool`。这是 15 个驱动里**两个**正确拒绝 legacy cancel 的之一。 +- **重置/丢弃**:`discard_connection` 未覆盖,走 trait 默认 `Err(Unsupported(...))`(fail-closed)。 +- **多库标识**:`is_active_database` 是只读元数据查询,源码注释写明它**从不被改成用来切换会话**;`close_database_pool` 拒绝关闭 handle 自己的库。 + +**与目标契约的差距**:`changeContext` 必须返回 `RequiresReplacement` 而不是 `Unsupported`——库与 schema 都能换,只是要换一条连接,这是 `requiresReplacement` 的教科书场景。`resetResource` 目前只能整体丢弃(且本驱动连 `discard_connection` 都没实现),要补一个「回滚未决事务 + 恢复 `search_path` 到 baseline」的实现才能给 `resetForReuse: verified`。`executeOnResource` 要求的「不自行再从随机 pool 取连接」目前不成立:`begin_transaction` 之外的路径都是每次 `pool.acquire()`。 + +### 3.2 MySQL(含 mariadb / doris / starrocks / manticore / ob_oracle 复用) + +`packages/drivers/mysql` 一个 crate 注册 **6 个 factory**:`mysql`、`mariadb`、`doris`、`starrocks`、`manticore`、`ob_oracle`(驱动 id 用的是下划线,见 `lib.rs:142`),全部 `supports_query_execution_cancel() -> true`。 + +- **切库语义(本矩阵里唯一的 `namespaceSwitch: inPlace`)**:`apply_active_database(handle, &mut conn)` 在**每条语句执行前**对新 acquire 到的连接执行 `USE \`db\``(`build_use_database_sql` 做标识符引用,拒绝空名与含 NUL 的名字),随后调用 `conn.clear_cached_statements()`——因为 MySQL 在 PREPARE 时就解析非限定名,不清缓存会打到错的库。这条路径的定义在 `connection.rs:130`(内部经 `execute_use_on_conn`,在 `connection.rs:103` 清 sqlx 语句缓存),在 `mysql.rs` 里有 9 个调用点(`1300`、`1410`、`1509`、`1603`、`1654`、`1680`、`1708`、`1740`、`1816`),另有一处在 `execution.rs:159`,结论是:**MySQL 的库上下文是每操作重放的,不是会话粘滞的**。 +- **上下文观察**:`current_database_on_conn` 用**文本协议**发 `SELECT DATABASE()`,源码注释解释:走 prepared 会返回 PREPARE 时刻的 schema,因此必须文本协议。 +- **协议限制**:`execute_text_on_conn` 注释记录 MySQL 会在 prepared 协议上拒绝 `BEGIN`/`COMMIT`/`ROLLBACK`/`USE`/`SET`/DDL/`CREATE PROCEDURE`,错误码 1295 (HY000)。`execute_use_on_conn` 因此固定走文本路径。 +- **事务隔离**:`begin_transaction` 先 `apply_active_database` 再 `BEGIN`;`begin_read_snapshot` 发 `SET TRANSACTION ISOLATION LEVEL REPEATABLE READ` + `START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY`,且**失败时关闭该连接**,避免一次性隔离级别泄漏。 +- **DDL 原子性**:`ddl_atomicity` = `DdlAtomicity::AutoCommitPerStatement`,与 PostgreSQL 的 `Transactional` 正好构成一对,宿主据此决定 schema-diff 部署是否包事务。 +- **取消**:`cancel_query` 返回 `Err(DriverError::Unsupported(...))`(与 postgres 同文案);`cancel_query_with_execution` 锁住注册表条目直到 `KILL QUERY ` 完成,并处理了 thread id 在过早清理后被复用的风险;`cleanup_query_execution` 直接移除注册表条目(`execution.rs:25-38`),`execution.cancel_requested` 的复位则发生在 `cancel_query_with_execution` 的各条失败分支。这是 15 个驱动里**第二个**正确拒绝 legacy cancel 的。 +- **重置/丢弃**:`discard_connection` 未覆盖,走默认 `Err(Unsupported(...))`。 + +**与目标契约的差距**:`inPlace` 只在「同一个活连接上」成立,而 `executeOnResource` 每次都可能拿到池里另一条连接。P2 要么让 `changeContext` 在 MySQL 上也返回 `requiresReplacement`(并把 `inPlace` 收窄到「同一 `ResourceHandle` 已固定的那条连接」),要么引入显式的资源固定。这一取舍必须在 P0 定案并写进契约测试,不能靠 `apply_active_database` 的存在就宣称 `inPlace` 成立。 + +### 3.3 SQLite + +- **连接模型**:`SqlitePoolOptions::max_connections(1).min_connections(1)` 建在 `sqlite:{path}` 上,随后 `PRAGMA journal_mode=WAL`;`ConnectionHandle` 的 `id` 与 `pool_id` 取同一个值。 +- **库即文件**:`get_databases` 恒返回 `vec!["main"]`。`physical_database_identity` 读 `PRAGMA database_list` 的 `file` 列得到 `sqlite_file_identity`。迁移路径里出现过 `ATTACH DATABASE '...' AS aux` 与 `"aux".sqlite_master` 限定名,但**该 `ATTACH` 只出现在 `#[cfg(test)]`**(`sqlite.rs:1177`),生产路径没有跨文件附加。因此矩阵里是「引擎能力存在,驱动不支持」。 +- **DDL 原子性**:`DdlAtomicity::Transactional`。 +- **事务**:`begin_transaction` 走 `execute(handle, "BEGIN")`,产出 `TransactionHandle { id: "sqlite_tx_", connection_id: handle.id }`;`commit`/`rollback` 由 `tx.connection_id` 重建 `ConnectionHandle` 后续行,源码注释说明这就是因为 `connect()` 让 `handle.id` 与 `handle.pool_id` 取了同值。 +- **未覆盖的方法**:`begin_read_snapshot`、`prepare_query_execution`、`discard_connection` 均未覆盖,分别落到 trait 默认的 `Unsupported`(fail-closed)。 +- **取消**:`cancel_query` 只打一条 `tracing::debug!("sqlite: cancel_query is a no-op (single-connection, in-process)")` 然后返回 `Ok(())`。 + +**与目标契约的差距**:池大小为 1 是本文档最需要守住的一条红线。连接管理 §5.2 明确写「不能因为『池大小为 1』就声明固定会话:底层重建和不同 consumer 仍会破坏语义」。SQLite 的 `max_connections(1)` 完全符合这个反例——底层仍然是 sqlx pool,`handle` 仍然可以被 `disconnect` 之外的路径重建,Host 侧也没有任何基线校验。因此 `statefulSession` 必须保持 `unsupported`。`preciseCancel` 应从现在的「no-op 成功」改为显式 `Unsupported`(见 §7)。 + +### 3.4 SQL Server + +- **连接模型**:`connect_client` 直接用 `TcpStream` + `tiberius::Client::connect`(超时包裹),不走 SQLx。`connect` 每个 `pool_id` 保存**恰好一个** `Client`,`handle.id == handle.pool_id`。 +- **命名空间**:对象名一律全限定 `[db].[dbo].[obj]`。单测(`sqlserver.rs:2086`)显式断言没有任何读路径内嵌 `USE [`,并断言空库名时 SQL 保持本地。注释还记录了 T-SQL 没有 `LIMIT`、分页必须写 `OFFSET … ROWS FETCH NEXT … ROWS ONLY` 因而强制 `ORDER BY`。因此**驱动从不发 `USE`**,切库是寻址问题不是会话问题,`namespaceSwitch` 是 `unsupported`(就当前实现而言)。 +- **会话级开关**:`explicit_identity_insert_requires_session_toggle` = `true`,`set_identity_insert` 的作用域是物理 SQL Server 会话——这是矩阵之外的一个真实会话级副作用,P0 需要决定它归入 `sessionScopedHandles` 还是 `initializationRequirements`。 +- **事务**:`begin_transaction` 先 `ensure_no_open_transaction` 再 `BEGIN TRANSACTION`,失败时移除 client;`begin_read_snapshot` 读取并保存当前隔离级别(`restore_isolation`);`commit`/`rollback` 校验 `tx.connection_id` 匹配后执行,必要时附 `SET TRANSACTION ISOLATION LEVEL {level}` 复原,**执行失败即 `clients.remove()`**(fail-closed 丢弃)。 +- **丢弃**:`discard_connection` 是 15 个驱动里**唯一**实现了的:移除事务条目 + 丢弃 client,返回 `Ok`。 +- **取消**:`cancel_query` 返回 `Ok(())` 空实现,且没有覆盖 `cancel_query_with_execution` / `supports_query_execution_cancel`(即默认 `false` + 默认 `Err(Unsupported(...))`)。这是最刺眼的一格:**`preciseCancel` 明确 `unsupported`,而 legacy cancel 却静默「成功」**。 +- **DDL 原子性**:未覆盖 → `DdlAtomicity::Unknown`。 +- **绑定参数**:`max_bound_parameters` = 2100。 +- **测试基座**:`tests/` 下共 9 个 `.rs`——7 个 `live_*.rs`(`live_admin_and_sync`、`live_connection`、`live_pagination`、`live_query_types`、`live_schema_metadata`、`live_streaming`、`live_write_and_ddl`)+ `sync_adapter_smoke.rs` + `command_definitions.rs`,另有 `common/` 辅助模块。它是当前 15 个 path 驱动里真实服务端测试覆盖最厚的。 + +**与目标契约的差距**:这是最接近目标资源模型、也最需要谨慎的一个驱动。它有真实的单物理会话,有 `discard_connection`,有会话级开关,但**没有声明、没有基线校验、没有 reset 复原**。`resetForReuse` 的枚举只有 `verified` / `unsupported` 两值,本驱动拿不到 `verified`——`discard` 是丢弃不是复原。P2 的工作是把「回滚未决事务 → 复原 `restore_isolation` → 复原 `SHOWPLAN_TEXT`/IDENTITY_INSERT 等会话开关」做成显式 `resetResource`,再由 CM-26/CM-69 验证之后才能填 `verified`。 + +### 3.5 Redis + +- **库即 db index**:`get_databases` 读 `CONFIG GET databases`(失败回退 16),返回 `(0..db_count).map(|i| format!("db{i}"))`。 +- **切库**:`parse_db_name` 去掉可选的 `db` 前缀后 `u32::parse`,空串或不可解析直接 `DriverError::QueryFailed("invalid database name (expected e.g. db0)")`;`open_pinned_conn` 克隆 handle 的 plan,**重开**一条 `open_live_conn(&plan)`,再 `select_db(&mut live, db_index)`——这条路径用于元数据枚举,`mod.rs:140-146` 的注释写明这样做是为了让浏览某个 db 不会改动共享会话。但同一个驱动在 `with_live_op!` / `with_live_op_topo!` 里对常驻 `rc.live` 直接 `select_db`(`mod.rs:208`、`:242`、`:264`、`:357`、`:387`),即**就地切换**。两种机制并存,所以本文既不记 `inPlace` 也不记 `requiresReplacement`,改记「待 P0 验证」([§9](#9-待-p0-验证清单) 第 7 条)。 +- **单测**:`get_table_schema_rejects_unparsable_database` 覆盖了拒绝路径。 +- **事务**:三个事务方法都未覆盖,走 trait 默认 `TransactionError`。注意 `ops/value_search.rs:264` 有一个 `commit`,它是任务/作业累计器(把完成的批次折进任务总数,单测 `commit_accumulates_until_task_replaced`),**不是数据库事务**,不要在迁移时误当成事务实现。 +- **取消**:`cancel_query` 返回 `Ok(())`。 +- **依赖**:`redis 0.27`(tokio-comp / aio / cluster-async / sentinel / tls)、`rmpv`、`flate2`、`urlencoding`、`futures-util`,可选 `tauri 2`;不依赖 `http-support`。 + +**与目标契约的差距**:`contextObservation` 与 `namespaceSwitch` 两格待 P0 验证([§9](#9-待-p0-验证清单) 第 1、7 条)。`statefulSession` 写 `unknown` 是因为 handle 里确实常驻一条 `live` 连接(面向库的操作就在它上面发 `SELECT`),但没有任何声明或校验机制把「句柄生命周期内连接不变」变成可依赖的保证;P0 需要用 CM-09 判定这条常驻连接在并发下是否可被复用为固定资源。 + +### 3.6 MongoDB + +- `connect` 存 `(client, config.database)`;`get_databases` 调 `client.list_database_names()`。 +- `has_multi_database` = `true`;`driver_category` = `DriverCategory::Document`(15 个 path 驱动里只有它与 redis 显式声明分类:`Document` 与 `KeyValue`,其余 13 个走默认 `Sql`;git 驱动 kiwi / superset 另显式声明为 `Sql`)。 +- `get_tables` 注释写明 MongoDB 没有 schema 层、**database 就是命名空间**,随后 `validate_schema_target(self, database, schema, SchemaScope::AnySchema)` 再 `client.database(database).list_collection_names()`。 +- 寻址方式是 `client.database(name)`——**每次操作按名寻址,没有会话**。 + +**与目标契约的差距**:`namespaceSwitch` 待 P0 验证([§9](#9-待-p0-验证清单) 第 2 条)。根因是 §5.2 的 `namespaceSwitch` 四个取值里**没有「无会话、按请求寻址」这一档**:`inPlace` 需要一条会被切换的会话,`requiresReplacement` 需要先有一条会被替换的会话,而 MongoDB 两者都没有。同样的缺口也出现在 clickhouse(库随每个 HTTP 查询下发)和 influxdb(`SHOW DATABASES` 真实枚举、库随请求下发)。**P0 必须先扩展枚举或明确定义映射规则**,否则这 3 个驱动无法给出合法的 `namespaceSwitch` 取值。 + +### 3.7 ClickHouse + +- `PoolEntry { client, base, database }`:库在建连时取自 `config.database` 并随每次查询下发,因此**库不是会话状态**。 +- `has_multi_database` = `true`;`get_databases` 查 `SELECT name FROM system.databases ORDER BY name`(真实枚举)。 +- `get_tables` 注释:ClickHouse 的 "schema" **就是** database,所以 `namespaceShape` 只有一层。 +- `cancel_query` 返回 `Ok(())`;事务三方法未覆盖;`ddl_atomicity` 未覆盖。 + +**与目标契约的差距**:同 §3.6 的枚举缺口。额外一条:ClickHouse 的 engine 变量、临时表、session 设置都是 HTTP query 参数级的,不构成会话上下文,`contextObservation: unsupported` 是准确的。 + +### 3.8 DuckDB + +- `connect` 用 `::duckdb::Connection::open(path or ":memory:")` 包在 `Arc>` 里按 `pool_id` 存,`ConnectionHandle` 的 `id` 与 `pool_id` 同值。一个 handle 一条真连接,这一点与 sqlserver 同构。 +- `get_databases` 返回 `vec!["main","temp"]`,但这两个名字**是 schema 名不是 catalog 名**——`duckdb.rs:80-98` 的 `catalog_selector` 注释明确写了这一点,所以 `namespaceShape` 不能按「单文件 = 单库(`main`/`temp`)」记。`ATTACH ':memory:' AS aux` 出现在 `duckdb.rs:831`,同样位于 `#[cfg(test)]` 内;生产路径无跨 catalog 附加。 +- 已知方言细节:注释记录 DuckDB 的 `PRAGMA` 解析器拒绝 catalog 前缀(`PRAGMA aux.table_info`),必须写成 `PRAGMA table_info('aux.t')`。 +- `cancel_query` 返回 `Ok(())`;事务三方法、`begin_read_snapshot`、`discard_connection`、`ddl_atomicity` 全部未覆盖。 + +**与目标契约的差距**:`namespaceSwitch` 与 `contextObservation` 两格待 P0 验证([§9](#9-待-p0-验证清单) 第 3、4 条)。DuckDB 引擎本身的事务与快照能力驱动完全没有暴露,属于「引擎支持、驱动没接」,批次 2 要决定是接还是不接;不接就必须保证 `Unsupported` 是显式拒绝而非静默。 + +### 3.9 rqlite / turso + +两者结构相同:`(client, base)` 无会话元组,`get_databases` 恒返回 `vec!["main"]`,事务/快照/重置全走 trait 默认,`cancel_query` 返回 `Ok(())`。两个 crate **都没有 `sync_adapter` 模块,也没有实现 `SyncAdapter`**;sqlite 兼容的元数据读取留在各自驱动文件内(`rqlite.rs:82-89` / `turso.rs:84-91` 的 `table_info_sql`,拼 `PRAGMA {db}.table_info('t')`)。这是 P2 里**最低成本的一档**:全部能力取 `unsupported`,工作量在于把 `cancel_query` 改成显式拒绝并补契约测试。 + +### 3.10 Elasticsearch / HBase + +同样是无会话 `(client, base)`,`get_databases` 恒返回 `vec!["default"]`,`cancel_query` 返回 `Ok(())`。HBase 另有 namespace 概念(列族/表限定名),但驱动当前不暴露成可切换的命名空间。**elasticsearch 没有 `tests/` 集成测试目录**(真实服务端基线为 0),只有 `src/elasticsearch.rs:516` 与 `src/sync_adapter.rs:128` 两个 `#[cfg(test)]` 模块共 10 个单元测试。**没有 `tests/` 目录的 path 驱动共 7 个**:rqlite / turso / elasticsearch / hbase / influxdb / victoriametrics / vector——批次 3 必须先补最小可运行基线,否则这 7 个驱动的任何能力结论都没有真实协议回归保护。 + +### 3.11 InfluxDB / VictoriaMetrics + +- influxdb:`get_databases` 解析 `SHOW DATABASES` 的 results/series/values,返回真实库列表;库随请求下发 → `namespaceSwitch` 待 P0 验证(§9 第 5 条)。`cancel_query` 返回 `Ok(())`。 +- victoriametrics:`get_databases` 恒返回 `vec!["default"]`,`cancel_query` 返回 `Ok(())`。 + +### 3.12 Vector + +`(client, base)` 无会话,`get_databases` 恒返回 `vec!["default"]`,`cancel_query` 返回 `Ok(())`,事务/快照/重置全走默认。与 §3.9 同一档处理。 + +## 4. 迁移批次 + +批次划分原则:**先用最少的驱动把契约测试模板跑通并冻结,再按语义相近度扩面**。每个批次内所有驱动的 D 层用例同时转绿才准进下一批;任一驱动未转绿则整批停在原地,不允许「大部分通过」。 + +**所有 D 层测试的落点固定在驱动 crate 内**(`AGENTS.md` 驱动测试落点表 + 独立驱动开发指南 §8):Rust 单元写在 `packages/drivers//src/*.rs` 的 `#[cfg(test)]`,集成测试写在 `packages/drivers//tests/`,`cargo test -p datazen-driver-`;驱动专属 UI 单测写在 `packages/drivers//ui/__tests__/`,`pnpm test:unit:drivers`;驱动专属 E2E 写在 `packages/drivers//e2e/`。**禁止**把这些用例加到 `src-tauri/`、`src/` 或 `e2e/specs/`。 + +### 批次 0:契约测试模板(不接任何真实驱动) + +目标:把「能力断言 + 拒绝断言」写成一份可复制的模板,让后续每个驱动只需填表。 + +- **范围**:`packages/driver-api` 新增 `capabilities.rs`(能力枚举与 `describeResource` 的返回结构)、`session.rs`、`resource.rs` 三个模块(拟新增),以及驱动侧的能力自检 harness。 +- **必做断言**: + - 每个枚举取值都有一正一反两个用例:合法值原样返回;非法/未覆盖时返回 `Unknown` 而不是猜一个 `supported`。 + - 能力缺失必须走 `DriverError::Unsupported`,**不得**返回 `Ok(())`;已建立执行记录时记 `ExecutionErrorCode::hostRejected`,尚未建立记录时直接返回 `ApiError.code=CapabilityUnsupported`(`CapabilityUnsupported` 是 §13 的 `ApiError.code`,不是 `ExecutionErrorCode` 取值;这是把当前 13 处 no-op 变成回归网的关键断言)。 + - `describeResource` 的 `ResourceDescriptor` 必含 `providerId` / `resourceKey` / `sessionContinuity` / `reusePolicy` / `initializationRequirements` / `connectionCostPolicy` / `namespaceShape`(连接管理 §5.1)。 + - `SessionObservation` 的 unknown 字段不得填入 `initialTarget` 假充确认(§5.1)。 + - **CM-08(两个编辑器隔离,H/D/F)与 CM-30(idle 连接计入预算,H/D)必须进模板**:这两条描述的是宿主侧「同一个 driver 实例同时服务两个编辑器、上下文互不破坏」与「预算必须统计真实 idle 连接」,不是某个方言的语义,因此对全部 15 个驱动成立。模板里先把两条的用例骨架写出来(驱动只需提供自己的 `ResourceDescriptor` 与连接统计钩子),后续驱动行就不必再重复列它们。 +- **进出门槛**:模板本身是一个可运行的 hostless harness(`cargo test -p datazen-driver-api`),且在至少一个「全部能力 unsupported」的桩驱动上能全绿。 + +### 批次 1:代表驱动(每种语义模型各一个) + +选这 4 个是因为它们把 §2.2 的三条结构事实全部覆盖了一遍,且彼此没有共享实现库。 + +| 驱动 | 选它的原因 | 必做 D 层断言 | +| --- | --- | --- | +| postgres | `Transactional`(postgres 与 sqlite 同为 `Transactional`)+ 多池 + per-DB 控制通道 + 两个正确拒绝 legacy cancel 的驱动之一(另一个是 mysql) | CM-07、CM-08、CM-09~CM-14、CM-15、CM-16、CM-17~CM-19、CM-22~CM-24、CM-26、CM-30、CM-62、CM-67 | +| mysql | 唯一 `namespaceSwitch: inPlace`、唯一 `AutoCommitPerStatement`、DDL 隐式提交 | CM-07、CM-10~CM-13、CM-16、CM-17、CM-19、CM-22~CM-24、CM-26、CM-45、CM-48、CM-62 | +| sqlserver | 唯一真·单物理会话、唯一 `discard_connection`、唯一会话级开关 | CM-07、CM-09、CM-14、CM-17~CM-19、CM-22~CM-24、CM-26、CM-45;另需跑纯 H 的 `CM-69` 与 H/F 的 `CM-74`(二者无 D 层标记,**不能算作 D 层断言**) | +| sqlite | 唯一的硬编码单连接池反例(`sqlite.rs:288-291`;mysql 只有在 `max_pool_size` 配成 1 时才会同样降到 1,见 `types.rs:170-172` 的 `clamp(1, 100)`)、最小 dialect 面 | CM-07、CM-13、CM-14、CM-17~CM-19、CM-24、CM-26、CM-62 | + +- **CM-19(driver 无状态能力)** 是批次 1 的核心用例:它直接检验「不把单连接池假装成固定资源」,sqlite 这一格就是它的标准靶子。 +- **CM-15** 只对 postgres 成立(切 database 必须替换连接,是 `requiresReplacement` 的教科书实现);**CM-16**(替换失败保留旧连接)适用于**一切** `requiresReplacement` 驱动——mysql 当前矩阵填 `inPlace`,若 §3.2 的 P0 定案改为 `requiresReplacement`,CM-16 立即对它必做;**CM-11 / CM-12** 只对 mysql 的 `USE` 路径成立。这三条都不允许被推广成「所有驱动都要发 `USE`」的断言。 +- **进出门槛**:这 4 个驱动的 `cargo test -p datazen-driver-` 全绿;`pnpm test:unit:drivers` 干净;`pnpm typecheck` 干净;`pnpm e2e:contract:matrix` 中这 4 行的 D 层用例转绿。任一项不过则批次 1 不完成。 + +### 批次 2:其余关系库与嵌入式数据库 + +| 驱动 | 必做 D 层断言 | 备注 | +| --- | --- | --- | +| clickhouse | CM-07、CM-10、CM-14、CM-17、CM-19、CM-24、CM-26、CM-62、CM-67 | 先解决 §5.2 的 `namespaceSwitch` 枚举缺口 | +| duckdb | CM-07、CM-13、CM-14、CM-17、CM-19、CM-24、CM-26、CM-62 | 需定案:接不接引擎事务 | +| rqlite | CM-07、CM-14、CM-17、CM-19、CM-24、CM-26 | 最低成本档 | +| turso | CM-07、CM-14、CM-17、CM-19、CM-24、CM-26 | 同上 | +| mongodb | CM-07、CM-09、CM-14、CM-17、CM-19、CM-24、CM-26、CM-62、CM-67 | 需先解决 `namespaceSwitch` 枚举缺口 | + +- **批次 2 的前置条件**:§5.2 的 `namespaceSwitch` 必须先补齐「无会话按请求寻址」这一情形的表达方式(扩展枚举,或在 `ResourceDescriptor.namespaceShape` 里把它显式建模并由契约测试固定映射)。clickhouse / mongodb / influxdb 三者共用这一前置。 +- **进出门门槛**:本批 5 个驱动 D 层全绿,且批次 1 的 4 个驱动仍然全绿(防止新增能力改坏旧驱动)。`pnpm test:boundaries` 的生产侧基线必须仍为 0 违规。 + +### 批次 3:非关系库与明确 unsupported 的固化 + +| 驱动 | 必做 D 层断言 | 备注 | +| --- | --- | --- | +| redis | CM-07、CM-09、CM-19、CM-24、CM-26、CM-62 | 补 `contextObservation` 与 `namespaceSwitch` 的 P0 结论;`value_search` 的 `commit` 不得被误认成事务 | +| influxdb | CM-07、CM-14、CM-19、CM-24、CM-26 | 需先解决 `namespaceSwitch` 枚举缺口 | +| victoriametrics | CM-07、CM-14、CM-19、CM-24、CM-26 | | +| hbase | CM-07、CM-14、CM-19、CM-24、CM-26 | | +| vector | CM-07、CM-14、CM-19、CM-24、CM-26 | | +| elasticsearch | CM-07、CM-14、CM-19、CM-24、CM-26 | **先补最小 Rust 集成测试基线**(当前无 `tests/` 目录,只有 `src/` 内 10 个单元测试) | + +- 批次 3 的重点不是接能力,而是**把「不支持」变成可执行的显式拒绝**:`requestCancel` 返回 `Unsupported`、`begin` 返回 `Unsupported`、`resetResource` 返回 `Unsupported`,且每一条都有 CM-24(无取消能力)与 §7 的对应断言。 +- **进出门槛**:13 处 `cancel_query -> Ok(())` 全部改为显式拒绝(见 §7);这 6 个驱动的 D 层全绿;`pnpm e2e:contract:matrix` 全矩阵转绿。 + +### 4.5 批次顺序的硬性依赖 + +```text +批次 0(契约模板) + └─> 批次 1(postgres / mysql / sqlserver / sqlite) + └─> 批次 2(clickhouse / duckdb / mongodb / rqlite / turso) + │ 需要:§5.2 namespaceSwitch 枚举缺口先补齐 + └─> 批次 3(redis / influxdb / victoriametrics / hbase / vector / elasticsearch) + 需要:13 处 no-op cancel 先改显式拒绝 +``` + +## 5. 协议版本演进 + +### 5.1 当前版本与最低兼容版本 + +`packages/driver-api/src/lib.rs` 尾部定义两个常量: + +```rust +pub const PROTOCOL_VERSION: u32 = 4; // lib.rs:92 +pub const MIN_PROTOCOL_VERSION: u32 = 1; // lib.rs:98 +``` + +同处文档说明:`version < MIN` 的驱动被拒绝加载;`MIN <= version < PROTOCOL_VERSION` 的驱动以**降级模式**运行,缺失的能力一律按 `false` 处理。**这是 `driver-api` 侧写下的契约承诺;宿主代码当前只打印降级警告,并没有真的把任何能力改写为 `false`(见下方闸门现状)。** + +宿主侧闸门有两处,结构相同,但**只拒绝对老版本,不拒绝更新版本**: + +- `src-tauri/src/db/registry.rs:126-151`:`pv < MIN_PROTOCOL_VERSION` → `return Err(...)`,拒绝加载(:126-133);`pv > PROTOCOL_VERSION` → **只有 `tracing::warn!`(:134-140),并不拒绝**,随后照常 `factory.create()` 加载(:154);`MIN <= pv < PROTOCOL_VERSION` → `warn!` 提示降级(:141-151)后同样照常加载,代码里**没有**把任何 `supports_*` 强制改写为 `false`——降级模式只是打日志,驱动仍按 factory 自己声明的能力运行。 +- `src-tauri/src/ai/registry.rs:170-198`:`pv < MIN_AI_PROTOCOL_VERSION` → `tracing::error!` + `continue`,跳过注册;`pv > AI_PROTOCOL_VERSION` → 同样只 `warn!` 后照常 `registry.register()`;`pv < AI_PROTOCOL_VERSION` → `warn!` 降级提示。 + +数据迁移另有独立校验:plan 里携带 `plan.source_driver_protocol` / `plan.target_driver_protocol`,执行时再次核对(`src-tauri/src/commands/data_transfer/exec.rs:125,150,487-488`,取值辅助函数 `plans::driver_protocol_version` 在 `plans.rs:156`)。 + +**闸门现状与 §5.4 目标之间的缺口**:`pv > PROTOCOL_VERSION` 分支今天是「警告后照常加载」,也就是说宿主面对一个声明了更高协议版本的驱动时会静默进入未知兼容态,而 §5.4 要求的是「不静默降级」。P2 必须先在**硬拒绝(返回 `Err`、不 `create()`)**与**加载但标记为不可用并禁止 `execute_driver_command`**之间**二选一**(无论选哪个都不得以「下调 `MIN` 让它落进降级分支」的方式绕过),再把它落成门禁;定案见 §5.4 第 5 条与 [§9](#9-待-p0-验证清单) 待定义 H。**在定案并落成门禁前,不能声称「版本更高的驱动会被拒绝」。** + +### 5.2 版本是怎么声明的 + +`DatabaseDriverFactory` 的 `protocol_version() -> u32` 有默认体,返回 `crate::PROTOCOL_VERSION`。**全部 15 个 path 驱动都没有覆盖它**;唯一覆盖者是 git 驱动 `packages/drivers/superset/src/lib.rs:1197`。 + +这意味着:**驱动只要不显式声明就自动跟随 `driver-api` 的版本**,只要升级 `PROTOCOL_VERSION`,所有 path 驱动同步升版、闸门恒走「当前」分支。代价是**驱动无法为旧宿主做兼容**——这正是必须由 `MIN_PROTOCOL_VERSION` 承接的部分。 + +### 5.3 哪些改动是 breaking + +| 改动 | 是否 breaking | 处理 | +| --- | --- | --- | +| 新增 trait 方法且带 fail-closed 默认体 | 否 | 升 `MIN` 不变;驱动不改也能编 | +| 新增 trait 方法**没有**默认体 | 是 | 升 `MIN`,要求所有驱动显式实现后才算合规 | +| 改能力枚举取值或语义 | 是 | 升 `MIN` + 升 `PROTOCOL_VERSION` | +| 改 DTO 字段的必填性 | 是 | 升 `MIN` + 升 `PROTOCOL_VERSION` | +| 放宽已声明 `unsupported` 的行为 | 否 | 降级模式下按 `false` 处理即安全 | +| 收紧已声明 `supported` 的行为 | 是 | 视为 breaking,需按 breaking 发布 | + +判定原则是**「老驱动在新宿主上是否仍能安全运行」**。`capabilities.rs` 里所有新枚举都按 fail-closed 默认(`unknown` / `unsupported`)设计,正是为了让「新增能力」本身不成为 breaking。 + +### 5.4 「协议升级与驱动发布是原子兼容门槛」 + +这条门槛的落地机制如下。 + +**禁止的做法**:宿主遇到驱动行为不兼容时,下调 `MIN_PROTOCOL_VERSION` 让旧驱动继续加载,并在降级模式下把缺失能力当 `false` 用。这会把兼容性问题变成运行期静默降级——正是「新增能力缺失不会 no-op 成功」要禁止的那类问题。当前 `MIN_PROTOCOL_VERSION = 1` 是在 `PROTOCOL_VERSION = 4` 之前三次演进中留下的兼容窗口,它的存在本身不代表可以随时下调。 + +**正确的做法**:任何 breaking 改动必须让**版本号、驱动实现、驱动发布**三者同一次落地完成,具体为: + +1. 在 `packages/driver-api` 改 trait / 枚举 / DTO,同时把 `PROTOCOL_VERSION` 从 N 提到 N+1。 +2. **同一个提交内**把所有受影响的 path 驱动改到能通过新契约的形态(缺失能力显式 `Unsupported`,不得 `Ok(())`)。 +3. path 驱动在**本仓库内**改完即发布,不存在跨仓库同步窗口。 +4. git 驱动在**自己的仓库**里跟进,在 DataZen 侧以 `drivers-registry.json` 的**钉死 `ref`**(`source: "git"` + 指定 `ref`,参见 `olap` 条目 `7096c8737dbe25ab4529e5c3997c9f67309154d9`)提交 DataZen registry PR。钉死 ref 是可复现构建的前提:registry 不指向浮动分支,也不指向未打 tag 的提交。 +5. 宿主升级后,`PROTOCOL_VERSION` 必须 ≥ 全部已发布驱动的 `protocol_version()`。**注意这里的现状是缺口而不是保障**:`registry.rs:134-140` 的 `pv > PROTOCOL_VERSION` 分支目前只是 `tracing::warn!` 然后照常加载,既没有拒绝、也没有把它挡在降级分支之外。要让这条真正成立,P2 必须先在**硬拒绝(返回 `Err`、不 `create()`)**与**加载但标记为不可用并禁止 `execute_driver_command`**之间**二选一并落成门禁**(定案见 [§9](#9-待-p0-验证清单) 待定义 H;闸门现状见 §5.1)——**在定案并落成门禁前,「版本更高的 git 驱动会被拒绝、不静默降级」这句话是不成立的**。 + +**冲突时的判据**:如果某个 breaking 改动无法在本仓库内一次性覆盖全部 path 驱动(例如需要 3 个 git 驱动分别发版),那它**不能**靠下调 `MIN` 蒙混过关,必须先让 DataZen 侧以新 `PROTOCOL_VERSION` 构建、通过 registry 钉 ref 升级全部 git 驱动,最后再打开闸门。在中间状态,宿主应保持 `PROTOCOL_VERSION` 不变、拒绝加载尚未跟进的新能力,而不是接受一个半兼容的组合。 + +**AI 侧同理**:`AI_PROTOCOL_VERSION` 变更时需要同步更新所有 AI Provider 插件,这条规则在 `AGENTS.md` 中已写明,与本文的驱动协议规则是同一套原子门槛的两次应用。 + +## 6. 过渡 adapter:遗留驱动的独立受控 Command + +### 6.1 允许与禁止 + +连接管理 §5.2 的收尾约束是:**「遗留驱动可以支持独立受控操作,但不能因为『池大小为 1』就声明固定会话:底层重建和不同 consumer 仍会破坏语义。」** 拆成可执行的两侧: + +**允许(保留独立受控 Command,不必立刻实现资源契约)**: + +- 遗留 trait 上已有的独立操作继续可用,只要它自身携带完整语义(例:sqlserver 的 `set_identity_insert` 本身就是会话级开关,其作用域由驱动自己负责;sqlserver 的 `discard_connection` 本身就是一次性丢弃)。 +- 宿主可以对 `unsupported` 的能力继续提供**明确标注降级**的旧路径,但该路径必须在 UI 与 `ExecutionCompletion.effectOutcome` 上标出 `partial` 或 `unknown`,不得呈现为成功。 +- 迁移期的驱动可以同时实现新旧两套接口,新接口为准,旧接口作为 adapter 转发。 + +**必须永不开放**: + +- **不得因为「池大小为 1」或「每个 `pool_id` 只有一条连接」就声明 `statefulSession: supported`。** 具体到本文的三个驱动:sqlite 的 `max_connections(1)` 是 sqlx pool;duckdb 的 `Arc>` 是互斥访问而非基线校验;redis 的常驻 `live` 连接承载了大多数操作,但同样没有基线校验、没有独占声明。§5.2 的反例原话就是这一条。 +- **不得因为「驱动没实现」就把 `Unsupported` 翻译成成功。** trait 默认体已经是 fail-closed 的(`discard_connection`、`begin_read_snapshot`、`cancel_query_with_execution` 都返回 `Unsupported`),adapter 若把它们捕获后返回 `Ok(())`,等于抹掉默认体的保护。 +- **不得让 adapter 跨 consumer 复用会话级句柄。** §6.5 的句柄登记机制还不存在,adapter 若在没有登记的情况下交出句柄,宿主将无法对句柄终态负责。 + +### 6.2 退役条件与删除时机 + +每个驱动从「adapter 双轨」切换到「只走资源契约」的条件是全部满足: + +1. 该驱动在 §2.1 矩阵中的每一格都已是 §5.2 的合法取值,不再有「待 P0 验证」。 +2. §5.1 的 9 个操作全部实现,且 `executeOnResource` 不再自行从随机池取连接。 +3. 所属批次的 D 层用例全绿,且 `CM-19` 明确断言「没有把单连接池假装成固定资源」。 +4. `resetResource` 要么返回 `Clean`(通过 CM-26 / CM-69)要么返回 `Discard`,没有第三条静默路径。 +5. 宿主对该驱动的所有 namespace API 调用点已迁移。当前调用点包括 `src-tauri/src/data_transfer/execute/dispatcher.rs:696`(`tgt_driver.discard_connection(tgt_handle)`)、`src-tauri/src/data_sync/execute.rs:196-199`(`executor.discard_connection()`,仅在 `cleanup_error` 已发生时)与 `src-tauri/src/commands/sync/exec.rs:88-93`(同一 executor 的转发实现)、`src-tauri/src/workflow/executor.rs:60`(持有 `driver_has_multi_database: bool`)、`src-tauri/src/bootstrap/run.rs:250,258`(注册 `close_database` / `get_open_databases`)、`src-tauri/src/testing/mock_driver.rs:361-374`(伪造 `close_database` / `open_databases`)。这几处正是 P2 要收敛的宿主侧驱动分支。 + +**删除时机**:adapter 的删除与**宿主侧对应分支的删除**必须同一个提交。只删驱动侧 adapter 而留宿主分支,宿主就会退回到 no-op;只删宿主分支而留驱动侧,则驱动仍可能被旧路径调用。这个原子性与 §5.4 的协议发布门槛是同一条原则的两次应用。 + +## 7. unsupported 的拒绝断言要求 + +连接管理 §17 的收尾约束是:**「能力 Unsupported 的 driver 测试断言『正确拒绝』,不能静默 skip 后宣称能力已验证。真实测试环境不可用必须报告未验证范围,不能用 fake 代替真实协议结论。」** + +### 7.1 头号违反点:13 处 `cancel_query` 的 no-op 成功 + +15 个 path 驱动的 `cancel_query` 实现体逐个读过,结果是 **13 个返回 `Ok(())`**,即「用户请求取消,驱动回一句成功,实际什么都没做」: + +| 驱动 | 位置 | 当前行为 | +| --- | --- | --- | +| mongodb | `mongodb.rs:679` | `Ok(())` | +| duckdb | `duckdb.rs:570` | `Ok(())` | +| clickhouse | `clickhouse.rs:552` | `Ok(())` | +| rqlite | `rqlite.rs:389` | `Ok(())` | +| turso | `turso.rs:419` | `Ok(())` | +| elasticsearch | `elasticsearch.rs:511` | `Ok(())` | +| influxdb | `influxdb.rs:396` | `Ok(())` | +| victoriametrics | `victoriametrics.rs:402` | `Ok(())` | +| hbase | `hbase.rs:484` | `Ok(())` | +| vector | `vector.rs:429` | `Ok(())` | +| redis | `database.rs:319` | `Ok(())` | +| sqlite | `sqlite.rs:696` | `debug!` 日志 + `Ok(())` | +| sqlserver | `sqlserver.rs:1759` | `Ok(())` | + +只有 **postgres**(`postgres.rs:467`)和 **mysql**(`mysql.rs:1886`)返回 `Err(DriverError::Unsupported("legacy session-wide query cancellation is disabled; use an execution handle"))`。 + +其中 **sqlserver 最严重**:`supports_query_execution_cancel` 未覆盖(默认 `false`),`cancel_query_with_execution` 未覆盖(默认 `Err(Unsupported(...))`)。也就是说它的 `preciseCancel` 在矩阵里是明确的 `unsupported`,而 legacy 取消却静默返回成功——宿主据此显示「已取消」,用户以为写事务被回滚了,实际没有。这正是 §13 说的「不能因为『看到取消』就写 rolledBack」。 + +**P2 必须做的改造**:这 13 处全部改为显式 `Unsupported`(或按 §5.1 的 `requestCancel` 语义返回 `CancelReceipt.disposition = unsupported`),并让宿主把 `unsupported` 呈现为「该连接不支持取消,请显式中止会话」,而不是「已取消」。 + +### 7.2 逐能力的拒绝断言要求 + +对每一个标 `unsupported` 的能力,M 层(驱动内)必须有一条断言,D 层(宿主 × 驱动)也要有一条;具体落在哪一层以「对应用例」列标注的层标记为准。**当前唯一的缺口是 `sessionScopedHandles`:连接管理 §16 里与 `sessionHandles` 相关的只有 CM-74,标记为(H/F),没有 D 层用例**(§9 待定义 G)——在该 D 层断言补齐前,这一行不得声称「M 层与 D 层各有一条断言」。 + +| 能力 | 触发的契约操作 | 必须断言的行为 | 对应用例 | +| --- | --- | --- | --- | +| `statefulSession` | `describeResource` | `sessionContinuity` 不得声明为固定;CM-19 断言「池大小为 1」不被当作固定会话 | CM-19 | +| `namespaceSwitch` | `changeContext` | 返回 `Unsupported` 而非静默留在旧命名空间;失败后仍可查原目标(AGENTS.md 交互原则) | CM-16(H/D/F,适用于一切 `requiresReplacement` 驱动);CM-11(D/F,**仅** mysql 的 `USE` 路径) | +| `contextObservation` | `observeSession` | 返回 `Unknown`,且 unknown 字段**不得**填 `initialTarget` 假充确认(§5.1) | CM-13、CM-18 | +| `transactionObservation` | `begin/commit/rollback` | 返回 `Unsupported` / `TransactionError`;不得回退成 autocommit 继续跑 | CM-18、CM-45 | +| `sessionScopedHandles` | `executeOnResource` | `sessionHandles` 返回空数组,**不得**返回未登记的句柄 | CM-74(H/F 层,**无 D 层用例**——P2 需为该能力补一条 D 层断言,见 §9 待定义 G) | +| `resetForReuse` | `resetResource` | 不得返回 `Clean`;不满足全部清理条件时直接走关闭路径(§9.4) | CM-26(H/D)、CM-69(纯 H) | +| `preciseCancel` | `requestCancel` | `disposition` 返回 `unsupported`;**不得**调用 session-wide cancel 作为隐式回退(§5.1 明文禁止) | CM-22、CM-23、CM-24 | +| `snapshots` | `begin` | 返回 `Unsupported("stable read snapshots are not supported by this driver")` 同义错误 | CM-48 | +| `transactions` | `begin` | 返回 `TransactionError`;不得静默以非事务方式执行 | CM-17、CM-18 | +| `ddlAtomicity` | `describeResource` | `Unknown` 时宿主不得包事务,直接按「非事务 DDL」执行 | CM-42 | + +三条贯穿性要求: + +1. **错误码与生效结论正交。** `errorCode` 是 `sqlError`,`effectOutcome` 可能是 `notStarted`/`completed`/`rolledBack`/`partiallyApplied`/`unknown` 任一(§13)。取消未被支持时 `CancelReceipt.disposition=unsupported`(**正常返回值,不是异常 code**),`effectOutcome` 必须是 `unknown`,且不得写 `rolledBack`。 +2. **不得静默 skip。** D 层用例遇到 `unsupported` 时,判定结果必须区分「断言了正确拒绝」与「跳过」两种状态,前者绿、后者红。测试报告要能回答「这个能力是被验证过的拒绝,还是没测」。 +3. **不得用 fake 代替真实结论。** 真实测试环境不可用时,报告未验证范围。批次 3 里 7 个驱动(rqlite / turso / elasticsearch / hbase / influxdb / victoriametrics / vector)没有任何 `tests/` 集成测试目录、sqlserver 依赖 `tests/live_*.rs` 的真实服务端,正是这条的典型场景。 + +## 8. 依赖边界约束 + +### 8.1 允许与禁止的划分 + +`packages/driver-api` 的公共接口**只允许**使用标准库类型、API 自有类型和 serde(连接管理 §5.1 末段)。据此: + +| 类别 | 允许出现在驱动公共 API 表面 | 允许出现在驱动内部实现 | 禁止 | +| --- | --- | --- | --- | +| 宿主类型 | — | — | ❌ 任何 `datazen`(宿主 crate)的类型 | +| Tokio | — | ✅ 15 个 path 驱动**全部**依赖 `tokio 1` | ❌ 出现在 `driver-api` 公共签名或 DTO 里 | +| HTTP 客户端 | — | ✅ `reqwest`(经 `http-support` 或直连) | ❌ `reqwest::Response` 等出现在公共签名里 | +| SQLx | — | ✅ postgres / mysql / sqlite 依赖 `sqlx 0.8` | ❌ `sqlx::Pool` / `sqlx::Row` 出现在 `driver-api` 公共签名里 | +| 第三方后端类型 | — | ✅ `tiberius::Client`(sqlserver)、`mongodb::Client`、`::duckdb::Connection`、`redis::aio::Connection` | ❌ 出现在 `driver-api` 公共签名里 | + +**关键区分**:依赖 tokio / reqwest / sqlx / tiberius 作为**私有实现依赖**是正常的、也是现状;禁止的是它们出现在**公共 API 表面**。§5.1 明确要求「结果写入等待表示背压;不暴露 Tokio channel 类型」「`ResultSink` 提供带字节大小的异步写入/完成/失败方法」——即背压语义要表达,Tokio 的具体类型不能表达。 + +P2 新增的 `capabilities.rs` / `resource.rs` / `session.rs` 必须逐条过这条:`ResourceHandle` 是 API 定义的**不可伪造 opaque ID**,由 provider 校验所有权和 epoch(§5.1);`SessionObservation` / `TransactionState` / `ExecutionCompletion` 全部用 API 自有结构表达;`snapshots`、`transactions`、`ddlAtomicity` 三项的自由文本范围若用结构体表达,其中的隔离级别列表应当是 API 自有枚举,不得直接复用 `sqlx::TransactionIsolation` 或后端枚举。 + +### 8.2 资源成本与预算 + +`BudgetPort` 返回 opaque 许可并按**实际物理连接**申请/释放(§5.1 末段)。对本文的驱动: + +- SQLx 驱动(postgres / mysql / sqlite)必须约束内部 pool 的真实建连数;**只在 acquire 时计数会漏掉 idle 连接**。对照 §9.4 归池前检查与 CM-30(idle 连接计入预算),postgres 的 `control_pool`(max 1 / min 0)就是一个典型的「不计入主池统计的常驻连接」,P2 必须把它纳入预算。 +- 第三方 SDK 无法统计实际 socket 时(`redis` 的 cluster-async、`mongodb` 的 `Client` 内部池、duckdb 的进程内连接),声明**保守资源成本/硬上限**,或在严格服务端预算模式下**直接拒绝该能力**,不得报告精确值。 + +### 8.3 改动 `packages/driver-api` 后必跑的门禁 + +| 门禁 | 命令 | 基线 | +| --- | --- | --- | +| 依赖边界 | `pnpm test:boundaries` | 生产代码 0 违规;fixture 2 处已知违规(不是 0 目标值) | +| 类型 | `pnpm typecheck` | 干净,**含测试文件**(`tsconfig.json` 已不排除 `__tests__/` 与 `*.test.ts(x)`) | +| 驱动 UI 单测 | `pnpm test:unit:drivers` | 干净 | +| 驱动 Rust | `cargo test -p datazen-driver-` | 全绿 | +| 契约矩阵 E2E | `pnpm e2e:contract:matrix` | 走 `pnpm tauri:build:webdriver` 构建,**禁止**裸 `cargo build` | + +另外,§17 结尾的总要求同样适用于本阶段:生产无裸 `unwrap`/`expect`、公共 API 无实现库类型、测试 typecheck 干净、生成文件(`generated.ts` / `driver_init.rs` / `.driver-features.json` / `capabilities/default.json`)未提交、**无新增宿主数据库名称分支**、无敏感日志。最后一条「无新增宿主数据库名称分支」是 P2 的自我约束:上面 §6.2 列出的 `driver_has_multi_database`、`close_database` / `get_open_databases` 等宿主分支只能被删除或替换,**不得新增**按数据库名判断的分支。 + +## 9. 待 P0 验证清单 + +以下是 §2.1 中写「待 P0 验证」的 7 格,以及若干需要 P0 / P2 **定义**(而非验证)的项。每一项都注明了具体该测什么、怎么算验证通过。 + +| # | 驱动 | 能力 | 现状 | P0 需要的证据 | +| --- | --- | --- | --- | --- | +| 1 | redis | `contextObservation` | `open_pinned_conn` 会 `select_db`,但没有读到任何「从活连接读当前 db index」的路径 | 找到一个能在同一活连接上读回当前 db index 的调用点;找不到则填 `unsupported`。用例落 `packages/drivers/redis/tests/`,CM-09 | +| 2 | mongodb | `namespaceSwitch` | 按 `client.database(name)` 逐请求寻址,无会话可切 | §5.2 枚举缺「无会话按请求寻址」档;P0 须先扩展枚举或定义 `namespaceShape` 映射,再回填取值。CM-62 | +| 3 | duckdb | `namespaceSwitch` | `get_databases` = `["main","temp"]`(这两个名字是 **schema** 名不是 catalog 名,见 [§3.8](#38-duckdb));`ATTACH` 仅在 `#[cfg(test)]`;无 `USE` | 决定生产路径是否接 `ATTACH`/`USE`;不接则 `unsupported` + 拒绝断言。CM-62 | +| 4 | duckdb | `contextObservation` | 未读到任何读当前 catalog / schema 的实现 | 找或补一个 `SELECT current_database()` 等价调用点;找不到则 `unsupported` | +| 5 | clickhouse | `namespaceSwitch` | 库在建连时取自 `config.database` 并随每次查询下发 | 同 #2 的枚举缺口。CM-62 | +| 6 | influxdb | `namespaceSwitch` | `SHOW DATABASES` 真实枚举,但库随请求下发 | 同 #2 的枚举缺口。CM-62 | +| 7 | redis | `namespaceSwitch` | 同一驱动内两种机制并存:`with_live_op!` 在常驻 `live` 连接上 `select_db`(就地切换),`open_pinned_conn` 另开一条连接再 `SELECT`(换连接) | P0 须定案这一格记 `inPlace` 还是 `requiresReplacement`(或先给 §5.2 补第三种表达),再回填取值。CM-11 / CM-12 针对 `USE`,不适用于 redis 的 `SELECT` 路径 | + +另需 P0 **定义**(不填矩阵,先立规则): + +- **A. `statefulSession` 的 `unknown` 三格如何收紧。** sqlserver / duckdb / redis 各要给出把 `unknown` 变成 `supported` 所需的全部保证(基线校验、重建后的可观察性、并发下的独占性),或直接证明不成立改填 `unsupported`。 +- **B. `namespaceSwitch` 枚举缺口。** 至少影响 mongodb、clickhouse、influxdb、duckdb 四个驱动;不定规则这四格永远填不出合法值。redis 是另一种情形——同一驱动内就地切换与换连接两种机制并存(§9 第 7 条),同样需要 P0 给出判定规则。 +- **C. sqlserver 会话级开关的归类。** `explicit_identity_insert_requires_session_toggle` / `set_identity_insert` 归入 `sessionScopedHandles` 还是 `initializationRequirements`。 +- **D. `contextObservation: partial` 的充分性。** postgres 只观察到 `current_database()`、未观察 `search_path`;mysql 只观察库、未观察其它上下文;sqlserver 只观察隔离级别。P0 要定义 `partial` 允许缺哪些维度,否则三格会长期停在含义不清的 `partial`。 +- **E. DuckDB 引擎事务能力接不接。** 引擎支持、驱动未暴露;不接就必须保证拒绝是显式的。 +- **F. `http-support` 是否满足「不共享实现库类型」。** 本文 §1.2 给出了「共享 HTTP 管道不构成共享后端类型」的读法,但这必须写成明确规则并加边界测试,否则批次 0 的 `pnpm test:boundaries` 判不出结果。 +- **G. `sessionScopedHandles` 的 D 层断言缺位。** 连接管理 §16 里涉及 `sessionHandles` 的只有 CM-74,标记是(H/F),**没有 D 层用例**;§7.2 因此无法兑现「M 层与 D 层各有一条断言」。P2 需先定这条 D 层用例的形态(示例:在真实服务端执行一条会返回服务端预处理语句或游标的语句,断言 `ExecutionCompletion.sessionHandles` 要么是空数组、要么是已登记句柄),再落到驱动 crate 内。**在此之前该格不得声称「D 层已验证」。** +- **H. 高于宿主的协议版本当前不会被拒绝。** `src-tauri/src/db/registry.rs:134-140` 与 `src-tauri/src/ai/registry.rs:179-186` 都是「`warn!` 后照常加载」,而 §5.4 要求的是显式拒绝。P0/P2 需要定:改成硬拒绝(返回 `Err`、不 `create()`),还是保留加载但把驱动标记为不可用并禁止 `execute_driver_command`;同时要确定「驱动版本 > 宿主版本」与「降级模式」是否互斥(若不互斥,就存在一条能同时进入未知兼容态和降级提示的路径)。**在规则定案前,§5.4 第 5 条只能当作目标,不能当作现状。** + +## 10. 验收映射表 + +### 10.1 P2 退出门槛 → 本文章节 + +开发计划 §6 的 P2 退出门槛原文只有三条,与连接管理 §17 的 D 层用例合并后映射到本文: + +| P2 退出门槛(开发计划 §6 原文) | 展开后的 D 层用例 | 本文对应章节 | +| --- | --- | --- | +| D 层 CM-07~19、22~26、30、45、48 | 会话连续性与切库:CM-07、CM-08、CM-09~CM-14、CM-15、CM-16、CM-17、CM-18、CM-19;并发 / 清理 / 取消:CM-22、CM-23、CM-24、CM-25、CM-26;预算:CM-30;事务失效可回收:CM-45、CM-48 | [§2.2](#22-读表之前必须知道的三条结构事实)、[§3.1](#31-postgresql含-questdb--cloudberry-复用)、[§3.2](#32-mysql含-mariadb--doris--starrocks--manticore--ob_oracle-复用)、[§3.4](#34-sql-server)、[§4.1](#批次-0契约测试模板不接任何真实驱动)、[§4.2](#批次-1代表驱动每种语义模型各一个)、[§7.1](#71-头号违反点13-处-cancel_query-的-no-op-成功)、[§7.2](#72-逐能力的拒绝断言要求)、[§4.5](#45-批次顺序的硬性依赖) | +| 不同 driver 不共享实现库类型 | (边界门禁,非 CM 用例) | [§1.2](#12-http-support共享-helper-crate不是驱动)、[§8.1](#81-允许与禁止的划分) | +| 新增能力缺失不会 no-op 成功 | CM-24 + §7.2 逐能力拒绝断言 | [§2.2](#22-读表之前必须知道的三条结构事实)(事实一)、[§7](#7-unsupported-的拒绝断言要求) 全章 | + +三点必须说清楚,否则这张表会被当成比原文更强的承诺: + +- **CM-08(两个编辑器隔离,H/D/F)此前在本文完全没有出现过**,而它落在门槛区间 `CM-07~19` 内。批次 0 的模板现在显式承接它,见 [§4.1](#批次-0契约测试模板不接任何真实驱动) 的必做断言。 +- **CM-14(字符串、注释、过程与批次,D)与 CM-16(替换失败保留旧连接,H/D/F)同样在门槛区间内**。它们只在部分驱动上被触发,但不能因此省掉对应驱动的 D 层用例:CM-14 是 D 层单值判定,必须逐驱动跑;CM-16 是**一切** `requiresReplacement` 驱动的必做项,当前矩阵里就是 postgres。 +- **「驱动测试只写在驱动 crate 内」不是 P2 退出门槛**,它是 `AGENTS.md` 的常驻规则。本文 [§4](#4-迁移批次) 开头的四种落点表是执行该规则的产物,属于门槛的达成手段,不属于门槛本身,因此不列在上表。 + +### 10.2 P10 目标:通用 contract × driver 矩阵 + +开发计划 P10 要求产出「通用 contract × driver 矩阵」。本文的 [§2.1](#21-矩阵) 就是这张矩阵的**当前**列,[§4](#4-迁移批次) 是它的**目标**列,两者一一对应: + +- 每一行是一个 path 驱动,行为固定不变(`postgres` 不会在后续新增或合并)。 +- 每一列是 §5.2 的一条能力 + `namespaceShape`,行为固定不变:能力集合只增不改,取值集合只按 §5.3 的规则演进。 +- 矩阵的填充规则固定:**已核实格由代码推出、待验证格必须走 P0、禁止猜测**。 +- 矩阵的变更规则固定:任何一格的改动只能发生在其所属批次的进出门槛通过之后,且必须附 D 层用例证据。 + +因此 P10 读这张表时可以直接回答「某个能力在哪些驱动上可用、在哪些驱动上必须拒绝」——前者是 `supported`/`verified`/`inPlace` 的格,后者必须配 §7.2 的对应拒绝断言。 + +### 10.3 阶段完成的判据 + +P2 完成时,上表所有章节都应转为「已实现」:§2.1 不再有「待 P0 验证」格、§9 清单清空、§7.1 的 13 处 no-op 全部改为显式拒绝、§6.2 列出的宿主侧驱动分支(`driver_has_multi_database`、`close_database` / `get_open_databases`、dispatcher 的 `discard_connection` 调用)全部删除或替换为契约调用。按 `AGENTS.md` 文档纪律,届时应把本文的「当前」内容改写为已实现事实并入 [连接管理](connection-management.md),本文转为协议与矩阵的长期维护说明。 + +## 与既有设计的关系 + +- **[连接与资源运行时设计](connection-management.md)** 是本文的上游契约:§3 借用它的 `ResourceDescriptor` 七项必填字段与 `namespaceShape`,§4 的批次以它的 §16 用例编号为门槛,§6 沿用 §5.2 的「遗留驱动可以支持独立受控操作」约束与 §9.4 的归池前检查,§7 的「不得静默 skip」引自 §17。本文**不重新定义**它的任何枚举取值、任何 trait 方法、任何用例编号。 +- **[平台开发计划](../../development/platform-development-plan.md)** 是本文的阶段归属:本文的批次划分对应 P2 的交付与退出门槛,§10.2 对应 P10 的「通用 contract × driver 矩阵」。本文只解释「怎么清点、怎么分批、怎么升版本」,不重复计划里已有的排期与里程碑。 +- **[Driver API 依赖边界](../../development/driver-api-dependency-boundary.md)** 是 §8 的规范来源:公共接口只用标准库类型、API 自有类型和 serde,宿主类型、Tokio、HTTP 客户端、SQLx 的禁止范围由该文档定义,本章只补充「私有实现依赖 vs 公共 API 表面」的区分与改动后的门禁命令。 +- **[可选驱动](../../development/optional-drivers.md)** 与 **[独立驱动开发指南](../../development/independent-driver-development.zh-CN.md)** 是 §1.3 与 §4 测试落点的来源:git 驱动钉 ref 升级、驱动专属测试只写在 `packages/drivers//` 之下、禁止把驱动专属测试加到 Host,均引自这两份文档。 +- **代码事实**(而非上述任何文档)是 §2 矩阵与 §7.1 清单的唯一证据来源。凡本文与代码不一致,以代码为准并回头修订本文。 + +## 本文不覆盖什么 + +- **不定义 `capabilities.rs` / `resource.rs` / `session.rs` 的具体 API 形状。** §5.1 的 9 个操作、§4 的 DTO 字段、§5.2 的能力枚举都由连接管理定义;本文只说明它们对现有 trait 的映射关系,不新增方法、不新增类型、不重命名。 +- **不设计 Host 侧运行时。** 连接池、Tokio actor、channel、semaphore、`PoolKey`、`BudgetPort` 的实现、Lease/Attachment 状态机、结果流与背压策略属于 Host 连接运行时(P3 及之后),不在本文范围。本文只在 §8.2 说明驱动如何配合预算计数。 +- **不覆盖 git 驱动(kiwi / olap / superset)的能力矩阵。** 它们在 `drivers-registry.json` 里是 `source: "git"`,不在 workspace 内,`describeResource` 只能由各自仓库实现。本文只在 §1.3 记录它们的存在与计数差异,在 §5.4 记录它们的版本升级机制(钉 ref 升级)。 +- **不覆盖前端 UI 与 i18n 的驱动能力呈现。** 能力如何在 UI 上呈现、降级路径如何标注,属于前端设计与宿主绑定层(见独立驱动开发指南 §6),本文只规定「必须标注降级、不得呈现为成功」这条行为要求。 +- **不排期、不列进度、不列缺陷清单。** 按 `AGENTS.md` 文档纪律,本文是一次性设计产物;落地后改写为已实现事实并入对应架构文档。 +- **不重新评估 `AGENTS.md` 中已有的驱动选型、双版本构建、四维扩展体系与测试落点规则**,只引用。 +- **不代替真实测试环境。** §9 的「待 P0 验证」项必须由真实服务端测试环境下的 D 层用例判定;按 §17,测试环境不可用时必须报告未验证范围,不能用 fake 代替真实协议结论。 diff --git a/docs/architecture/platform/fake-runtime-fixtures.md b/docs/architecture/platform/fake-runtime-fixtures.md new file mode 100644 index 000000000..93071a1a8 --- /dev/null +++ b/docs/architecture/platform/fake-runtime-fixtures.md @@ -0,0 +1,673 @@ +# DataZen P0 transport-neutral fake resource 夹具与基准 harness 详细设计 + +> 状态:目标设计,尚未实现。基线:2026-09-30,`8592b0fe1`。本文只交付夹具、故障注入、真实驱动契约夹具与基准 harness 的实现级设计,不代表连接管理重构已完成。 +> 读者:负责 [连接管理详细设计](connection-management.md) §14 开发步骤第 2 项(`testing/fake_resource`)、§6.5 句柄登记与 §15.3 基准 harness 的实现者。 +> 配套:[连接管理详细设计](connection-management.md)、[平台开发计划](../../development/platform-development-plan.md)、[系统概要](system-overview.md)、[测试架构](../testing.md)、[E2E 测试指南](../../development/e2e-testing.md)。 + +## 1. 范围与前置约定 + +| 本文覆盖 | 本文不覆盖 | +| --- | --- | +| transport-neutral fake resource provider 的接口、可编程状态与默认行为 | §4 的 DTO 定义(以 connection-management.md 为准,本文只引用) | +| 逐阶段故障与延迟注入点矩阵,以及「注入后无泄漏」的可验证断言 | 各阶段内部实现算法 | +| CommandJournal、Barrier、FakeClock 三件夹具 | 真实数据库协议实现 | +| 可返回会话级句柄的 fake 命令清单(服务 CM-73/CM-74) | 真实方言差异(严格落驱动 crate) | +| 真实驱动契约夹具模板与专用测试环境前置检查 | 驱动专属 Command 的语义测试 | +| CM-60 基准 harness 的可执行规格与产物 | 基准结果结论(门槛判定方式见 §11.6) | + +三条前置约定,实现时不得偏离: + +1. **fake 位于资源层,不位于驱动层。** fake provider 实现 connection-management.md §5.1 的资源级端口,一个 fake resource 等价于真实驱动的一个底层会话(池大小 1)。它不实现 `packages/driver-api` 的 `DatabaseDriver` trait,因此**不能**用它验证任何方言语义。 +2. **§4 的 DTO 不在本文重定义。** `SessionView`、`ExecutionReceipt`、`SessionHandleRef`(§6.5)、`ExecutionState`、`ExecutionErrorCode` 等一律按 connection-management.md §4/§6.5 引用,本文只给夹具侧的构造与断言。 +3. **fake 的可编程状态是唯一输入。** 任何行为差异只能来自 `FakeScript`(故障与延迟)与 `FakeClock`(时间),不得在夹具内部写随机数、真实 `Instant::now()` 或真实 socket。 + +**Rust 变体名与协议字面量的映射(全文只在此声明一次)**:本文为可读性混用 Rust 变体名与 connection-management.md §4 的协议字面量,两者指向同一取值,正文不再逐处重复标注。 + +| Rust 变体 | 协议字面量(connection-management.md §4) | +| --- | --- | +| `ExecutionErrorCode::SqlError` | `sqlError` | +| `ExecutionErrorCode::ProtocolError` | `protocolError` | +| `ExecutionErrorCode::Cancelled` | `cancelled` | +| `ExecutionErrorCode::Timeout` | `timeout` | +| `ExecutionErrorCode::ResourceLost` | `resourceLost` | +| `ExecutionErrorCode::PipelineAborted` | `pipelineAborted` | +| `ExecutionErrorCode::HostRejected` | `hostRejected` | +| `EffectOutcome::NotStarted` | `notStarted` | +| `EffectOutcome::Completed` | `completed` | +| `EffectOutcome::RolledBack` | `rolledBack` | +| `EffectOutcome::PartiallyApplied` | `partiallyApplied` | +| `EffectOutcome::Unknown` | `unknown` | +| `CancelReceipt` 的 `Disposition::Requested` / `Unsupported` / `AlreadyFinished` | `requested` / `unsupported` / `alreadyFinished` | + +`errorCode` 与 `effectOutcome` 是两个独立字段,不得互塞;`ApiError.code`(connection-management.md §13:`UnsupportedPlan`、`CapabilityUnsupported`、`OutcomeUnknown`、`TransactionResolutionRequired`、`RollbackFailed` 等)是第三个命名空间,只在「请求被拒绝」处出现(含提交结果未知时的核验请求),其取值不得写进 `errorCode`/`effectOutcome`。派发前拒绝的路径(如 §4.2 的 F1)上不存在 execution 记录,因此也不存在 `ExecutionState`/`errorCode`/`effectOutcome`。 + +### 1.1 与现有 Host MockDriver 的边界 + +| 维度 | 现有 `MockDriver` | 本文 fake resource provider | +| --- | --- | --- | +| 落点 | `src-tauri/src/testing/mock_driver.rs`(已存在) | 拟 `packages/runtime/src/connection/testing/`(crate 尚未创建) | +| 抽象层 | `datazen_driver_api::DatabaseDriver` | connection-management.md §5.1 资源级端口 | +| 典型调用 | 测试直接 `driver.query(&handle, ...)` | runtime actor / 执行网关 | +| 能表达的句柄 | `ConnectionHandle` / `TransactionHandle`(`packages/driver-api/src/types.rs`) | `ResourceHandle` + §6.5 `SessionHandleRef` | +| 多进程/多 worker | 否 | 可配 `workerId`,可表达 epoch 碰撞 | +| 现状 | 已可运行 | 不存在 | + +两者不合并、不互相替代:既有 Host 测试继续用 `MockDriver`;连接运行时的新测试一律用本文 fake。**禁止**把新 runtime 测试写进 `src-tauri/src/testing/`。 + +## 2. 落点与模块划分 + +拟建目录(全部位于尚未创建的 runtime crate 内,crate 包名以实际 `Cargo.toml` 为准): + +| 路径 | 职责 | 关键导出 | +| --- | --- | --- | +| `packages/runtime/src/connection/testing/mod.rs` | 模块入口与 `#[cfg(any(test, feature = "test-harness"))]` 门控 | `FakeHarness` | +| `.../testing/fake_resource.rs` | fake provider、fake resource 状态机、§5.1 九个操作 | `FakeResourceProvider`、`FakeResource`、`FakeScript` | +| `.../testing/journal.rs` | CommandJournal 与变化点断言 | `CommandJournal`、`JournalAssert` | +| `.../testing/barrier.rs` | 命令级 barrier、协议 drain barrier | `Barrier`、`DrainBarrier` | +| `.../testing/clock.rs` | FakeClock(单调 + UTC) | `FakeClock` | +| `.../testing/ids.rs` | 可预测 ID 生成与强制碰撞 | `FakeIds`、`FakeIdScope` | +| `.../testing/fixtures.rs` | 固定实体(组织/用户/profile/命名空间 A、B) | `fixtures()` 常量与 `install_fixtures` | +| `.../testing/commands.rs` | 会话级句柄 fake 命令定义 | `session_handle_command_definitions()` | +| `.../testing/bench.rs` | CM-60 基准 harness | `cm60_harness()`、`cm60_stress()` | + +`cfg` 门控与 `src-tauri/src/testing/mod.rs` 现有写法保持一致(`app_state` 用 `#[cfg(any(test, feature = "test-harness"))]`,feature 名 `test-harness` 已在 `src-tauri/Cargo.toml` 定义)。 + +```mermaid +flowchart TD + T[connection tests] --> H[FakeHarness] + H --> P[FakeResourceProvider] + H --> J[CommandJournal] + H --> B[Barrier / DrainBarrier] + H --> C[FakeClock] + H --> I[FakeIds] + H --> F[fixtures] + H --> CMD[session handle commands] + P --> R[FakeResource 状态机] + P --> S[FakeScript 故障与延迟] + R --> J + R --> C + R --> I +``` + +依赖方向单向向下。`journal.rs` 不依赖 `fake_resource.rs`(journal 记录由 provider 写入,断言由测试调用),`clock.rs` 不依赖任何其他夹具模块。 + +隔离规则: + +- 夹具代码不得被生产路径引用;`src-tauri` 的 `datazen` crate 不依赖 runtime 夹具。 +- runtime crate 现有门禁 `pnpm test:layers`(`scripts/check-module-layers.mjs`)只扫描 JS/TS 源码,不覆盖 Rust;runtime crate 创建后必须把层/边界检查接入 CI 门禁,命令同步写入 CI 与开发计划 §15.1 的命令表,不把未创建命令列为现有脚本。 +- 夹具内禁止出现 `.env` / `.env.test` 的任何读取路径(见 §13)。 + +## 3. transport-neutral fake resource 契约 + +### 3.1 夹具对象 + +```rust +pub struct FakeResourceProvider { + script: FakeScript, // 故障与延迟注入,跨资源共享 + clock: FakeClock, // 与 runtime 共享同一时钟源 + ids: FakeIds, + resources: Mutex>, // resourceId -> 资源 + journal: CommandJournal, + id_counter: AtomicU64, // leaseId / 执行序号 + worker_id: Option, // None = 本地单进程;Some = 表达多 worker +} + +pub struct FakeResource { + pub resource_id: String, // API 定义的不可伪造 opaque ID + pub resource_key: String, // 来自 describeResource 的 driverResourceKey + pub pool_key: PoolKeyFingerprint, // 字段名(叙述层统一写作 poolKeyFingerprint);connectionId/identity/policy/driver 维度等值 + pub owner: Option, // dbSessionId + runtimeEpoch + pub target: CanonicalTarget, // §4.3 规范化结果 + pub state: FakeResourceState, // Opening / Ready / Executing / Cleaning / ... + pub capabilities: CapabilitySnapshot, + pub handles: IndexMap, // driver 内部持有 + pub protocol_drained: bool, + pub health: ResourceHealth, + pub execution_seq: u64, +} +``` + +关键点:`ResourceHandle` 只能由 provider 签发,provider 在每次操作上校验 `resourceId` + `runtimeEpoch` + owner 归属;**携带旧 `runtimeEpoch` 或他人 owner 的句柄必须被拒绝**(这是 CM-71、CM-74 的可测基础)。 + +### 3.2 §5.1 九个操作的 fake 行为 + +| 操作 | fake 默认行为 | 可编程状态 | 故障注入点 | +| --- | --- | --- | --- | +| `describeResource` | 纯函数:不建连、不计预算,按 `(connectionId, target, 身份范围)` 归一化出 `resourceKey`;`sessionContinuity=fixed`,`connectionCostPolicy=perPhysicalConnection` | 按 `poolKeyFingerprint` 覆盖 `resourceKey`、`initializationRequirements` | 返回 `UnsupportedPlan` / `TargetUnsupported` | +| `acquireResource` | 向 `BudgetPort` 申请 1 个物理许可 → 建 fake resource → 执行初始化脚本 → `state=Ready`;每次建连写 journal | 每物理连接计 1;`poolKeyFingerprint` 相同则可复用既有 Ready 资源 | 预算不足 `ResourceBusy`、超时、初始化脚本失败 | +| `executeOnResource` | 从脚本表取 `StatementResult`;若脚本声明 `sessionHandles` 则写入 `ExecutionCompletion.sessionHandles`;写 `protocolDrained` | 固定返回时延、结果集大小、是否流式、是否返回句柄 | 语句失败、协议写失败、背压(sink 写入等待) | +| `observeSession` | 在同一 fake resource 上读 `context` / `transactionState` / `handleCount` | 可置任一字段为 `unknown` | 返回 `unknown`(**禁止**回填 `initialTarget` 假充确认) | +| `changeContext` | 校验 `expectedContextRevision`;`namespaceSwitch=inPlace` 时原地改 target 并 `contextRevision+1` | 可置 `requiresReplacement` / `unsupported` | `ContextConflict`、`ResourceBusy` | +| `begin` / `commit` / `rollback` | `begin` 建 `FakeSessionHandle{kind:transaction}` 并**必须**随 completion 交出;`commit`/`rollback` 在**同一 resource** 上终结并置 `closed=true` | commit 可返回 `Ok` 或 `Unknown`;rollback 可失败 | `RollbackFailed`、`CleanupFailed`、commit `OutcomeUnknown` | +| `requestCancel` | 精确 `cancelHandle` 命中未完成执行时返回 `Requested`;无命中返回 `AlreadyFinished` | `preciseCancel` 可置 `unsupported`(返回 `Unsupported`,**不得**退化为 session-wide cancel) | cancel 不支持/未命中/目标已完成 | +| `resetResource` | 仅当 `handles` 为空且无未结束执行时返回 `Clean`;否则 `Discard` | 可置 `resetForReuse=unsupported`、可超时 | 协议基线未清、临时对象残留、reset 失败/超时 | +| `closeResource` | 幂等:首次 `Closed`,重复调用仍 `Closed` | 可置 `CloseUnconfirmed` | 关闭未确认、关闭途中资源丢失 | + +两处不可让步的 fake 语义: + +- **`resetResource` 的 `Clean` 不等于事务终结。** fake 对已交出的句柄**没有可见性**(句柄由宿主 actor 持有,connection-management.md §6.5),因此 `FakeResource.handles` 只反映 driver 侧协议状态。测试必须能构造「driver 返回 `Clean` 但宿主侧仍有已登记句柄」的场景,这正是 connection-management.md §9.4「driver 的 Clean 不构成事务终结证据」的可测点。 +- **`commit` 返回 `Unknown` 时 `effectOutcome` 必须是 `unknown`。** fake 不允许在 `Unknown` 分支返回成功态(`completed`),否则 CM-47、CM-52 的断言会假绿。 + +### 3.3 能力快照默认值 + +按 connection-management.md §5.2 的十项能力给出 fake 默认值;每项都可按 `driver_id` 覆盖,用于验证「能力 Unsupported 时宿主必须正确拒绝」: + +| 能力 | fake 默认 | 覆盖后用于验证 | +| --- | --- | --- | +| `statefulSession` | `supported` | 置 `unknown` 时不得开启固定会话保证 | +| `namespaceSwitch` | `inPlace` | 置 `requiresReplacement` 时走两阶段替换,UI 原子换绑 | +| `contextObservation` | `full` | 置 `partial`/`unsupported` 时 `observedContext` 保持 `unknown` | +| `transactionObservation` | `full` | 置 `partial` 时 `TransactionResolutionRequired` 路径 | +| `sessionScopedHandles` | `supported` | 置 `unsupported` 时句柄类用例必须断言「正确拒绝」而非静默 skip | +| `resetForReuse` | `verified` | 置 `unsupported` 时一切复用路径必须走关闭 | +| `preciseCancel` | `supported` | 置 `unsupported` 时取消返回 `Unsupported`,不得降级 | +| `snapshots` | `perDatabase` | 置 `unsupported` 时三件套快照计划必须报 `UnsupportedPlan` | +| `transactions` | 读已提交/可重复读 + savepoint | 置受限级别时计划必须按能力重算 | +| `ddlAtomicity` | 事务 DDL 原子,含可声明的非事务步骤 | 声明含非事务步骤时迁移计划必须显式告警并在提交后复核 | + +### 3.4 ExecutionCompletion 的组装 + +fake 的 `ExecutionCompletion` 必须逐字段给出,宿主断言才有意义: + +| 字段 | fake 默认 | 关键注入 | +| --- | --- | --- | +| `completionStatus` | `Ok` | 语句失败 → `Error` | +| `effectOutcome` | 成功写入 → `completed` | 提交不确定 → `unknown`;部分批次 → `partiallyApplied` | +| `statementResults` | 脚本指定的列/行 | 决定是否触发 `protocolDrained=false` | +| `contextBefore` / `contextAfter` | 当前 target 投影 | 可置 `unknown` | +| `transactionObservation` | `None` / `InTransaction` | 提交后置 `Unknown` | +| `sessionHandles` | 脚本声明的句柄数组 | 可置为空(模拟未交出,见 §9) | +| `protocolDrained` | `true` | 置 `false` 时宿主禁止归池(connection-management.md §9.4 前置之一) | +| `resourceHealth` | `Healthy` | 置 `Degraded`/`Lost` 时进入 `Lost` 状态机分支 | + +## 4. 故障与延迟注入点矩阵 + +### 4.1 注入点目录 + +`FakeScript` 按阶段寻址,键为 `(driver_id, command_id 或 `*`, occurrence)`,`occurrence` 支持 `always` / `first_n(k)` / `after(n)`,使「第二次及以后失败」可精确复现。 + +| 编号 | 阶段 | 注入类型 | +| --- | --- | --- | +| F1 | 描述(`describeResource`) | 失败 / `UnsupportedPlan` | +| F2 | 预算申请(`acquireResource` 前) | 拒绝 `ResourceBusy` / acquire 超时 10 秒 | +| F3 | 建连与初始化 | 失败 / 延迟 / 初始化脚本残留临时对象 | +| F4 | 语句派发 | 失败 / 延迟 / 取消命中 | +| F5 | 结果传输(`ResultSink` 写入) | 背压(写入等待)/ 写失败 | +| F6 | 观察(`observeSession`) | 返回 `unknown` 各字段 | +| F7 | 上下文切换 | `ContextConflict` / `requiresReplacement` | +| F8 | 事务 | `begin` 成功;`commit` 返回 `Unknown`;`rollback` 失败 `RollbackFailed` | +| F9 | 取消 | `Unsupported` / 未命中 `AlreadyFinished`(该注入类型由 §14 的 `CM-21~CM-29` 范围覆盖,无单独矩阵行)/ 取消后执行仍完成 | +| F10 | 重置归池 | 返回 `Clean` 但宿主前置不满足 / `Discard` / reset 超时 | +| F11 | 关闭 | `CloseUnconfirmed` / 关闭期间资源丢失 | +| F12 | 句柄登记 | 交出句柄但不返回(模拟未登记反例)/ 跨 epoch 复用句柄 | + +### 4.2 阶段 × 故障 × 期望可观察结果 + +| 注入 | 期望宿主可观察结果 | 关联 CM | +| --- | --- | --- | +| F1 `UnsupportedPlan` | 派发前拒绝 → 直接返回 `ApiError.code=UnsupportedPlan`,**不产生 execution 记录**、不建物理连接;`ExecutionState`/`errorCode` 在此路径上不存在 | CM-19、CM-52 | +| F2 `ResourceBusy` | 返回 `ResourceBusy`,等待可被取消;不得额外建连 | CM-29、CM-60 | +| F2 acquire 超时 | 10 秒期限由 FakeClock 判定,资源占用清零,journal 记录 permit 归还 | CM-29、CM-60 | +| F3 建连失败(未执行 SQL) | session 回到 `New` 可重试,预算归零 | CM-27 | +| F3 建连成功但初始化后协议损坏 | 进入 `Lost`,禁止后续执行,终结相关执行 | CM-27、CM-64 | +| F4 语句失败 | `errorCode` 为版本化枚举值;`effectOutcome` 独立表达(可能是 `unknown`) | CM-52、CM-72 | +| F5 背压 | `ResultSink` 写入等待;触发 8 MiB/执行上限后必须截断或按字节背压 | CM-46、CM-64 | +| F6 observe 返回 `unknown` | `observedContext` 保持 `unknown`,**不得**回填 `initialTarget` | CM-14、CM-18 | +| F7 `ContextConflict` | 返回 `ContextConflict`,要求重读后由用户操作重发 | CM-11、CM-21 | +| F8 commit `Unknown` | `effectOutcome=unknown`,`errorCode` 取实际成因(`protocolError`/`timeout`),要求核验、不自动重试;**不**返回 `TransactionResolutionRequired`(该码只用于「事务阻止切换/关闭」) | CM-47、CM-52 | +| F8 rollback 失败 | `RollbackFailed`,资源隔离 `Quarantined`,不归还 | CM-26、CM-64 | +| F9 cancel `Unsupported` | `CancelReceipt.disposition=unsupported`(正常返回值,不是异常) | CM-24、CM-72 | +| F9 取消后执行仍完成 | `executionState` 仍 `cancelled`(取消请求已送达),但 `errorCode` 报告执行真实终态 | CM-22、CM-72 | +| F10 driver `Clean` 但句柄非空 | 宿主判定事务未终结 → **关闭**而非归池 | CM-69、CM-73、CM-74 | +| F10 `resetForReuse=unsupported` | 直接关闭,预算在确认后核销 | CM-26、CM-69 | +| F11 `CloseUnconfirmed` | 保留预算占用或转待核验占用,不立即归零 | CM-26、CM-27、CM-28 | +| F12 句柄未返回 | runtime 拒绝把它交给宿主;该句柄在 fake 侧标记为 `orphaned` | CM-73、CM-74 | + +### 4.3 「注入后无泄漏」的可验证性 + +每个注入点必须配套一个不变量断言,测试在 fixture teardown 前统一执行: + +``` +I1 permits_returned == permits_requested // 预算 permit 收支平衡 +I2 live_resources.is_empty() // 无残留 fake resource +I3 live_leases.is_empty() // 无残留 ResourceLease +I4 session_registry.active_sessions.is_empty() // 无残留逻辑 session +I5 journal.handle_registry.is_empty() // §6.5 已登记句柄全部注销 +I6 journal.permit_ledger.balanced() // 与 I1 交叉验证 +I7 journal.orphan_handles.is_empty() // F12 反例留下的孤儿句柄 +I8 events.stream_sequence.is_contiguous() // 事件序号无缺口 +``` + +断言入口固定为 `FakeHarness::assert_no_leak()`,在每个故障注入用例的结尾以 `Drop` 之外的方式显式调用(不使用 `Drop` 隐式断言,失败信息必须出现在测试输出中)。`I1`~`I8` 全部来自 journal 与 registry 的内存状态,不需要 sleep。 + +## 5. CommandJournal + +### 5.1 记录内容 + +journal 记录**资源与执行的全部可观察变化**,是「每次建连/关闭/permit 变化时断言额度,无随机采样盲区」的实现载体(connection-management.md §15.3)。 + +| 类别 | 记录字段 | +| --- | --- | +| 建连/关闭序列 | `seq`、`resourceId`、`event`(`Created`/`OpeningReady`/`Closed`/`CloseUnconfirmed`/`Lost`)、`owner`、`poolKeyFingerprint`、`budgetClass` | +| 执行序列 | `seq`、`resourceId`、`executionId`、`commandId`、`startedAtMono`、`endedAtMono`、`effectOutcome`、`protocolDrained` | +| permit 申请/归还 | `seq`、`permitId`、`delta`(`+1`/`-1`)、`reason`(`acquire`/`close`/`quarantine`/`writeoff`)、`budgetClass` | +| 句柄登记/注销 | `seq`、`handleId`、`kind`、`resourceId`、`runtimeEpoch`、`action`(`registered`/`closed`/`rejected`/`orphaned`)、`reason` | + +### 5.2 数据结构 + +```rust +pub struct CommandJournal { + entries: Vec, // append-only,单调 seq + permits: Vec, // 独立账本,便于快速收支对账 + handles: IndexMap, + clock: Arc, +} + +pub struct PermitEvent { pub seq: u64, pub permit_id: String, pub delta: i32, pub reason: PermitReason } +``` + +`seq` 由单一原子计数器分配,因此任何两条并发写入的相对顺序都是确定的;这是「用 journal 断言顺序而不是靠 sleep 猜顺序」的基础。 + +### 5.3 变化点强制断言 + +断言挂在**变化点**上,不挂在采样上: + +| 变化点 | 断言 | 失败含义 | +| --- | --- | --- | +| 每次 `resourceId` 创建 | `permit 余额 = +1`,`live_resources` 同步 +1 | 建连未计预算 | +| 每次 `Closed` | `permit 余额 = -1`(仅在 `Closed` 而非 `CloseUnconfirmed` 时) | 未确认关闭被伪称回收 | +| 每次 `CloseUnconfirmed` / `Quarantined` | 余额**不变** | 预算被提前归零 | +| 每次 permit 变化 | `Σ delta == live_resources.len() + idle_pools + control_sockets` | 漏记或多记 | +| 每次句柄 `registered` | `handles[handleId].resourceId == execution.resourceId` 且 `runtimeEpoch` 匹配 | 句柄跨资源/epoch 登记 | +| 每次句柄 `closed` | 从 `handles` 移除并写 `closed` 事件 | 关闭无证据 | +| 每次执行终态 | `protocolDrained` 已记录或显式置 `false` | 归池前置缺失 | +| 每次 `Unknown` 提交 | `effectOutcome == unknown` | 未知被当成成功 | + +### 5.4 断言写法示例 + +```rust +#[test] +fn commit_unknown_never_reports_completed() { + let h = FakeHarness::new(); + h.script().fail_commit_with_unknown(); + let receipt = h.execute_in_session("fixture-P", "commit_probe"); + assert_eq!(receipt.execution.error_code, Some(ExecutionErrorCode::ProtocolError)); + assert_eq!(receipt.execution.effect_outcome, EffectOutcome::Unknown); + h.journal().assert_permits_balanced(); + h.assert_no_leak(); +} +``` + +`FakeHarness` 提供 `journal().assert_permits_balanced()`、`assert_handle_closed(id)`、`assert_no_return_to_pool_without_drain()` 等专用断言,失败信息直接包含相关 `seq`,避免手工翻日志。 + +## 6. Barrier 机制 + +### 6.1 命令级 barrier + +```rust +pub struct Barrier { seq: AtomicU64, arrivals: Mutex>>, ... } +impl Barrier { + pub fn arrive(&self, tag: &str) -> BarrierToken; // 到达并注册后续期望 + pub fn wait_for(&self, tag: &str); // 阻塞直到该 tag 到达 + pub fn release(&self, tag: &str); // 放行后续步骤 +} +``` + +barrier 作用在**命令执行槽位**上:把 fake 命令的「到达」和「放行」拆开,测试可以在两条命令精确交错的位置上观察中间状态。典型用法: + +``` +T1: 打开事务(barrier.arrive("after_begin")) +T2: 关闭会话(barrier.wait_for("after_begin") → 在 T1 尚未终结时进入关闭) +断言:关闭先在原 resource 上回滚并注销句柄,确认后才释放资源 +``` + +### 6.2 协议 drain barrier + +`DrainBarrier` 与命令级 barrier 分离,服务 CM-64(无消费者、有界 drain、截断): + +- 订阅建立后置 `no_consumer=true`,`executeOnResource` 的 `ResultSink` 写入必须等待; +- 推进到每订阅 256 事件 / 1 MiB、每执行 8 MiB 上限时,journal 记录 `truncated=true` 与 `truncationReason`; +- 无消费者等待 30 秒、drain 期限 10 秒均由 FakeClock 判定; +- 桌面 256 MiB 产物上限以「已产出字节计数」断言,不以内存占用断言。 + +```mermaid +sequenceDiagram + participant C as Consumer + participant R as Runtime actor + participant F as Fake provider + C->>R: subscribe(streamId) + R->>F: executeOnResource(executionId, sink) + F--xR: 写入等待(无消费者) + R->>R: drain 期限到期(FakeClock 推进 10s) + R-->>C: 截断事件 + protocolDrained=false + F->>F: 标记协议未排空 + Note over R: 宿主判定不得归池 → 关闭 +``` + +### 6.3 竞态用例编排 + +| 用例 | 编排 | 断言 | +| --- | --- | --- | +| CM-64 无消费者 | drain 期限到期 | 事件被截断、`protocolDrained=false`、资源被关闭 | +| CM-73 淘汰中途发起 commit | idle 淘汰启动后(barrier 停住)发起 `begin`→`commit` | 句柄在**原 resource** 上终结后才释放资源;不得在新 resource 上复用旧句柄 | +| CM-74 释放顺序 | 淘汰 + 句柄登记并存 | journal 顺序必须是 `handle closed` → `resource Closed` → `permit -1` | +| CM-22 取消与完成竞态 | 取消到达后让执行继续完成 | `executionState=cancelled` 且 `errorCode` 为执行真实终态 | +| CM-69 归池竞态 | 宿主前置全部满足但 driver 返回 `Discard` | 走关闭分支,permit 只在 `Closed` 后归还 | + +### 6.4 禁止用 sleep 猜顺序 + +- 所有顺序断言基于 journal `seq` 或 `Barrier`,不基于真实时间; +- 任何 `tokio::time::sleep` 只允许用于「让推进逻辑自己跑完」的推进步,且必须配合 FakeClock 的虚拟时间或显式 `yield_now`,不得作为断言前提; +- 夹具 lint:测试代码中出现「裸 `sleep(...)` 后直接断言顺序」的模式应被 review 拒绝。仓库已有规则要求生产路径禁止裸 `unwrap()`/`expect()`(见 `AGENTS.md`),夹具属于测试代码但同样要求断言信息可读。 + +## 7. FakeClock + +### 7.1 单调时钟与 UTC 的分工 + +| 时钟 | 用途 | 夹具实现 | +| --- | --- | --- | +| 单调(`MonoTime`) | 所有**持续时间**判定:acquire timeout 10 秒、队列上限等待、掉线 grace 60 秒、无事务 idle 30 分钟、空闲事务 5 分钟、cancel/cleanup 10 秒、pool idle TTL 60 秒、幂等令牌 24 小时 | 显式 `advance(Duration)`,基准固定为常量 `T0`,不读系统时钟 | +| UTC | 对外 `expiresAt` 投影 | 固定起点常量 + 单调推进量;只用于生成 `expiresAt` 字段 | + +两者必须分离:把 `expiresAt` 当成持续时间判定依据,或用 UTC 差值判断期限,都会在跨时区/时钟回拨下失真。夹具的 `FakeClock` 因此提供两个只读视图: + +```rust +impl FakeClock { + pub fn monotonic(&self) -> MonoTime; // 持续时间判定 + pub fn utc(&self) -> FixedUtc; // expiresAt 投影 + pub fn advance(&self, d: Duration); // 同时推进两者 +} +``` + +### 7.2 期限表与夹具默认值 + +| 期限 | 设计值(connection-management.md §9.2) | 夹具默认值 | 备注 | +| --- | --- | --- | --- | +| acquire timeout | 10 秒 | 同设计值 | 超时返回 `ResourceBusy` | +| session 普通队列 | 32 请求 | 同设计值 | 超过返回 `QueueFull` | +| 每用户 / 每组织逻辑 session | 100 / 1000 | 测试可下调到 4 / 8 | 用于额度用例,避免造 1000 个 session | +| client 掉线保留 | 60 秒 | 同设计值 | 与 idle 取最早 | +| 无事务会话业务空闲 | 30 分钟 | 同设计值 | attach/心跳不刷新 | +| 空闲事务 | 5 分钟 | 同设计值 | 存在未结束事务/游标时适用 | +| cancel/cleanup deadline | 10 秒 | 同设计值 | 超时隔离并记录真实 `effectOutcome` | +| 数据缓冲 | 每 pipeline 8 MiB | 测试下调到 64 KiB | 便于触发截断 | +| 幂等提交令牌 | 默认 24 小时 | 同设计值 | 过期重提交 `IdempotencyExpired` | + +### 7.3 推进与断言 + +```rust +h.clock().advance(Duration::from_secs(30 * 60)); // 触发 30 分钟 idle 关闭 +h.clock().advance(Duration::from_secs(5 * 60)); // 在空闲事务上触发 5 分钟规则 +h.clock().advance(Duration::from_secs(10)); // 触发 drain / cleanup 期限 +h.clock().advance(Duration::from_secs(24 * 3600)); // 触发 IdempotencyExpired +``` + +`advance` 是同步的:推进后所有已注册该期限的 timer 立即到期,测试在同一线程内即可断言,不需要异步等待。跨期限(如「detached 60 秒与 idle 30 分钟取最早」)必须断言实际选用的是最早者,而不是两个都触发。 + +## 8. 固定夹具命名与实体 + +### 8.1 实体常量 + +| 常量 | 语义 | 取值 | +| --- | --- | --- | +| `ORG_A` / `ORG_B` | 两个组织 O1 / O2 | `org-alpha` / `org-beta` | +| `USER_A1` | O1 下用户 U1 | `user-alpha-1` | +| `USER_A2` | O1 下用户 U2(与 U1 共享 DB 账号、策略不同) | `user-alpha-2` | +| `USER_B1` | O2 下用户(验证跨组织不可见) | `user-beta-1` | +| `PROFILE_P` | 固定连接配置 | `connectionId = conn-fixture-p`,`configRevision = 7` | +| `PROFILE_P_V2` | 配置变更后版本(验证 `poolKeyFingerprint` 换 key) | `configRevision = 8` | +| `IDENTITY_SHARED` | U1/U2 共享的 DB 登录身份 | `exec-identity-shared` | +| `IDENTITY_PERSONAL` | 个人执行身份 | `exec-identity-` | +| `NS_A` / `NS_B` | 同一 profile 下的两个命名空间 | database `dz_ns_a` / `dz_ns_b` | +| `MARKER_TABLE` | 目标标记表 | `dz_target_marker` | +| `MARKER_A` / `MARKER_B` | A/B 库同名表的不同标记值 | `dz-marker-alpha` / `dz-marker-beta` | + +`USER_A1` 与 `USER_A2` 共享 `IDENTITY_SHARED`(同一 DB 账号)但 `policyIsolationKey` 必须不同——这是 connection-management.md §9.6「policyIsolationKey 在共享 DB 账号但权限不同的用户之间必须不同」的可测点。夹具在 `fixtures.rs` 中以数据表形式提供,测试不得各自硬编码。 + +### 8.2 ID 生成约定 + +| ID | 格式 | 计数器域 | 说明 | +| --- | --- | --- | --- | +| `resourceId` | `res__` | 每 provider | 不透明,仅 provider 校验 | +| `leaseId` | `lse_#` | 每 resource | 每次 acquire 一条 | +| `dbSessionId` | `dbs__` | 每 provider | 内存态,**永不落盘** | +| `runtimeEpoch` | `.` | 每 dbSessionId,从 1 递增 | 换 owner 必增 | +| `executionId` | `exe__` | 每 dbSessionId | | +| `jobId` / stage | `job__` / `job:/stage:` | 每 provider | 资源 owner 是 jobId/stageId | +| `streamId` | `str_` | 每 provider | 事件序号连续 | + +`FakeIds` 提供 `force_collision(FakeIdScope::RuntimeEpoch, k)` 与 `force_collision(FakeIdScope::DbSessionId, k)`,用来在 CM-71 中**确定性**地制造 `dbSessionId` 或 `runtimeEpoch` 冲突,而不是靠概率。 + +**唯一不可预测的例外**:`attachmentToken` 与幂等令牌 nonce 使用真实随机源,且不写入 journal、不打印。夹具不为了「可预测」而牺牲这两项的安全性;测试需要断言的只是「同键同 receipt / 不同键不匹配」,不是令牌字面量。 + +## 9. 可返回会话级句柄的 fake 命令清单 + +服务 CM-73 / CM-74(淘汰、替换、隔离时必须先在**原 resource** 上回滚/关闭句柄并注销,确认后才释放资源)。 + +### 9.1 命令清单 + +| 命令 id | 访问级别 | 输入 | 返回句柄 kind | 语义 | +| --- | --- | --- | --- | --- | +| `begin_session_transaction` | Write | `{}` | `transaction` | 开启事务并把句柄随 completion 交出 | +| `begin_session_transaction_hold` | Write | `holdMs: u64` | `transaction` | 开启后不自动终结,用于驱动关闭/淘汰竞态 | +| `open_session_cursor` | Read | `rows: u32` | `cursor` | 游标句柄,需显式关闭;存在游标时适用 5 分钟空闲事务规则 | +| `prepare_server_statement` | Write | `name: string` | `serverPrepared` | 服务端预处理对象 | +| `commit_session_transaction` | Write | `handleId` | — | 在同一 resource 上提交;可注入 `Unknown` | +| `rollback_session_transaction` | Write | `handleId` | — | 回滚;可注入 `RollbackFailed` | +| `close_session_cursor` | Write | `handleId` | — | 关闭游标并注销 | +| `begin_session_transaction_unregistered` | Write | `{}` | — | **反例**:在 fake 侧建事务句柄但不写入 `sessionHandles` | +| `commit_with_stale_handle` | Write | `handleId`, `runtimeEpoch` | — | **反例**:携带旧 epoch 的句柄,必须被拒绝 | +| `handle_from_other_resource` | Write | `resourceId` | — | **反例**:跨 resource 句柄复用,必须被拒绝 | + +每条命令都给出 `DriverCommandDefinition`(`packages/driver-api/src/command.rs`:`id`/`name`/`description`/`input_schema`/`output_schema`/`permissions`/`metadata`),`metadata.requires_connection = true`,`metadata.category` 按读写设置,`metadata.risk` 对写操作显式声明 `CommandAccessLevel::Write`。command 通过 driver-api 的 `command_definitions()` / `execute_command()` 通道执行,fake driver 侧仍返回标准 `CommandResult { data }`。 + +### 9.2 登记与注销断言 + +| 命令 | 断言 | +| --- | --- | +| `begin_session_transaction` | completion 携带 `sessionHandles[0]`;在 execution 终态前 actor 已登记;`journal` 有 `registered`;idle 规则切到 5 分钟分支 | +| `begin_session_transaction_hold` | 淘汰/关闭触发时,journal 顺序为 `handle closed` → `resource Closed` → `permit -1` | +| `open_session_cursor` | 游标存在期间归池前置不成立;关闭游标后 `handles` 为空才允许 `Clean` 归池 | +| `prepare_server_statement` | 与事务句柄同等登记与注销;不得因「driver 内部映射」而免登记 | +| `commit_session_transaction` | 成功 → `effectOutcome=completed`;`Unknown` 注入 → `effectOutcome=unknown`,`errorCode` 取实际成因(`protocolError`/`timeout`),不自动再执行,不返回 `TransactionResolutionRequired` | +| `rollback_session_transaction` | 成功 → 句柄注销;`RollbackFailed` → 资源 `Quarantined`,预算占用保留 | +| `close_session_cursor` | 注销后 `journal.handle_registry` 为空 | +| `begin_session_transaction_unregistered` | runtime 拒绝交给宿主;`journal` 记 `orphaned`;`I7` 断言其在关闭路径上被回收 | +| `commit_with_stale_handle` | 拒绝并返回 `RuntimeEpochMismatch`,不改变原句柄状态 | +| `handle_from_other_resource` | 拒绝并返回 `SessionLost`(旧 resource 失效语义) | + +### 9.3 淘汰中途发起提交(CM-73) + +`FakeHarness::script_hold_for_eviction_then_begin_commit()` 固定编排: + +``` +1. 建立 session + 固定 Lease(resource R1,runtimeEpoch=1) +2. 通过 idle 扫描触发淘汰,在 close 前置检查处用 barrier 停住 +3. 在停住点发起 begin_session_transaction(落在 R1 上) +4. 放行淘汰:必须先在 R1 上回滚/注销,再释放 R1 +5. 若宿主为省事而重建 R2 并复用句柄 → journal 出现 handle registered 于 R2,断言失败 +``` + +断言集合:`R2` 上不存在任何 `registered` 事件;`I1`~`I8` 全部成立;恢复流程若被触发,必须以**新 dbSessionId** 出现,旧句柄不被重建(connection-management.md §6.5)。 + +CM-73/CM-74 的断言只依赖行为(不淘汰仍持有已登记活动句柄的资源、不以同一 dbSessionId 重建),**不依赖遗留管理器中任何函数名**;遗留 `ConnectionManager` 删除后这两条用例必须继续成立。 + +## 10. 真实驱动契约夹具模板 + +### 10.1 适用范围与落点 + +真实方言测试**严格**落在 `packages/drivers//tests/`(AGENTS.md「驱动测试落点」)。Host 只运行 transport-neutral 的统一 contract(用 §3 的 fake resource provider)。fake driver 模拟一般会话语义,不包含任何 MySQL/PostgreSQL 等方言实现。 + +### 10.2 专用测试环境与 A/B 目标 + +每个真实驱动契约测试文件必须满足: + +1. **开始前证明目标是专用测试环境**:`测试库/命名空间名` 必须匹配夹具前缀(如 `dz_fixture_`),且连接账号必须带只读之外的删除权限;不满足时**跳过并报告未验证范围**,不得连接生产/共享库。 +2. **A/B 两个库/命名空间**:同一 profile 下建两个目标,各自建**同名** `dz_target_marker` 表,写入**不同**标记值。`executeOnResource` 跨目标解析必须读到与请求目标一致的标记值——这是「无 `use_database` 的跨库解析」类缺陷(connection-management.md CM-13)的回归护栏。 +3. **临时对象与事务探测**:每个用例创建自己命名的临时对象,结束时显式删除;事务用例用独立 session,禁止复用编辑器 session。 +4. **结束后删除夹具**:无论成功、失败还是跳过,都在 teardown 中 `DROP`/`delete` 夹具对象;teardown 失败必须让测试失败而不是静默通过。 +5. **凭据只从进程环境或 CI secret 注入**,由 §13 纪律约束。 + +```sql +-- 关系库通用形态,方言由驱动测试自行改写 +CREATE TABLE dz_target_marker ( + id INTEGER PRIMARY KEY, + marker TEXT NOT NULL, + written_at TIMESTAMP NOT NULL +); +INSERT INTO dz_target_marker (id, marker, written_at) VALUES (1, :marker, :now); +``` + +### 10.3 每类驱动的差异维度 + +`packages/drivers/` 下现有目录:clickhouse、duckdb、elasticsearch、hbase、http-support、influxdb、kiwi、mongodb、mysql、postgres、redis、rqlite、sqlite、sqlserver、superset、turso、vector、victoriametrics(其中 kiwi、superset 不在 Cargo workspace 成员内,olap 被 workspace `exclude`)。**各驱动的具体能力本文不预设结论**,必须由各驱动自己的能力声明与测试确认;夹具模板只要求每个驱动回答同一组问题并在测试中固化: + +| 维度 | 驱动需声明 | 测试形态 | +| --- | --- | --- | +| 目标命名空间 | 数据库 / schema / index / db index / 集合 各自叫什么 | A/B 同名标记对象,跨目标解析断言 | +| 多目标 | 是否支持一连接多目标;`namespaceSwitch` 取值 | 两阶段替换用例(`requiresReplacement` 时) | +| 事务 | 隔离级别、savepoint、非事务 DDL | 提交/回滚/`Unknown` 三态断言 | +| 会话级句柄 | 事务、游标、服务端预处理对象是否可交出 | 与 §9 同构的 contract,用真实句柄替换 fake 句柄 | +| 精确取消 | 是否有 execution 级 cancel | 支持则断言精确命中;不支持则断言 `Unsupported`(**正确拒绝**) | +| 快照 | perTable / perDatabase / coordinated / unsupported | 三件套计划按声明取舍 | +| 重置归池 | `resetForReuse` 是否 verified | verified 才跑复用用例,否则走关闭路径 | + +### 10.4 断言纪律 + +- 能力 `Unsupported` 的用例断言「**正确拒绝**」,不能静默 `skip`;若因环境缺失无法运行,必须在报告中写明未验证范围。 +- 真实测试环境不可用时,**不得用 fake 代替真实协议结论**:fake 只能证明网关/运行时行为,不能证明驱动语义。 +- 现有可运行示例:`cargo test -p datazen-driver-postgres --test postgres_cross_database`(该测试在无 Postgres 时干净跳过)。它是本文模板的起点,但**它通过自带的 `load_dotenv_file()` 读取 `packages/drivers/.env` 获取 `TEST_PG_*` 凭据**(路径由 `CARGO_MANIFEST_DIR` 的父目录拼出,不是仓库根),与 §13 纪律冲突;新夹具必须改为只从进程环境/CI secret 注入,不复制该写法。 + +## 11. 基准 harness(CM-60) + +### 11.1 可执行参数 + +| 参数 | 值 | +| --- | --- | +| 构建 | release(`cargo build --release`),与发布 profile 一致 | +| 环境 | 4 vCPU / 8 GiB;单进程;**无数据库网络**(fake provider 全本地,禁止任何出站 socket) | +| 被测命令 | 固定 fake 命令,耗时 10 毫秒(虚拟时间驱动,见 §11.2) | +| 并发 | 8 | +| 预热 | 1000 次请求 | +| 每轮样本 | 10000 个**已获准且未排队**的请求 | +| 轮数 | 5 | +| 门槛 | 每轮 p95 ≤ 10 毫秒(nearest-rank) | + +### 11.2 两段单调时间的起止点 + +| 段 | 起点 | 终点 | +| --- | --- | --- | +| 网关段 | 鉴权与参数校验完成、即将派发 driver 的时刻 | driver 收到调用并开始执行的时刻 | +| 登记段 | driver 返回完成通知 | `ExecutionReceipt` 与事件状态登记完成的时刻 | + +两段都用 `std::time::Instant` 采样,**不受 FakeClock 虚拟时间影响**;fake 命令的 10 毫秒由 `tokio::time::pause()` + auto-advance 的虚拟时间消费,因此不进入测量窗口,但调度与分配开销仍然是真实的。 + +### 11.3 统计口径 + +- p95 = 把该段全部样本升序排序后,取第 `ceil(0.95 * N)` 项(1-based),`N` 为该轮样本数;要求**每一轮**都 ≤ 10 毫秒。 +- **排队请求单独报告**:被 `QueueFull` 拒绝或排队等待的请求不进入 p95 样本,单独给出其等待时长分位数与计数。 +- **不删除失败样本**:失败、超时、被取消的样本数与占比必须与分位数一起输出,禁止只统计成功样本。 +- 每轮输出:`N`、p50、p90、p95、p99、最大值、失败数、排队数。 + +### 11.4 压力部分另跑 + +压力用例(长连接占用、慢消费者、drain 期限)**单独运行**,其数据不与 p95 门槛混算。CM-60 的压力变体参数:总额度 20(其中 control 预留 2)、100 个逻辑 session、队列上限 32、1000 次操作加取消、2 个 worker;配额按 connection-management.md §9.5 的保留规则分配。 + +### 11.5 采集落点与产物 + +| 产物 | 内容 | 落点 | +| --- | --- | --- | +| 原始计时 | 每请求两段纳秒值、轮次、并发度 | `target/bench/cm60-raw-.json`(由 `bench.rs` 输出) | +| 环境记录 | CPU 核数、内存、OS、编译器版本 | 同上文件 `environment` 段 | +| 构建参数 | profile、features、依赖锁文件摘要 | 同上文件 `build` 段 | +| journal | 资源/permit/句柄全量 journal 摘要 | 同上文件 `journal` 段 | +| 汇总报告 | 分位数、判定结论 | `target/bench/cm60-summary-.json` | + +所有产物作为 CI artifact 保留(connection-management.md §15.3)。journal 摘要必须包含 permit 收支对账结果(§5.3),使基准同时充当泄漏检测。 + +### 11.6 判定与复现 + +- 判定式:`all(round.p95_ms <= 10.0 for round in rounds)` 且 `failures == 0`;任一不成立即 CM-60 不通过。 +- 复现要求:仅凭 artifact 中的 `environment` + `build` + `raw` 三段即可重跑,无需重新猜测环境。 +- 基准不得与功能测试共用同一二进制入口,单独入口便于 CI 区分超时原因。 + +## 12. 落点与运行命令 + +**已存在、可立即运行**(核对自 `package.json` 与 Cargo workspace): + +```bash +pnpm typecheck # 前端类型检查(含测试文件) +pnpm test:unit # Host 前端单测 +pnpm test:unit:drivers # 驱动 UI 单测 +pnpm test:layers # 源码层边界 +pnpm test:boundaries # 驱动导入边界 +pnpm test:ids # ID 术语一致性 +cargo test -p datazen --lib # Host Rust 单测 +cargo test -p datazen-driver-api # 驱动 API 契约 +cargo test -p datazen-driver-postgres # 示例:某个驱动 crate +pnpm tauri:build:webdriver # WDIO 构建(禁止裸 cargo build) +pnpm e2e:skip-build # 复用已编译 debug 二进制 +pnpm e2e:contract:matrix # Host 契约 × 驱动矩阵 +``` + +**crate/package 创建后才存在**(当前**不可**运行,不得在文档或 CI 中当作现有脚本): + +| 预期命令 | 前置条件 | +| --- | --- | +| `cargo test -p --lib` | runtime crate 创建并加入 workspace `members` | +| `cargo test -p --test contract` | 同上,且 `src-tauri/Cargo.toml` 注入依赖 | +| `cargo test -p --features test-harness` | `test-harness` feature 定义 | +| `cargo run --release -p --bin cm60-bench` | 基准入口创建 | + +创建后必须同步更新平台开发计划 §15.1 的命令表与 CI 门禁,不把未创建命令列为现有脚本。 + +`src-tauri/Cargo.toml` 已有 `test-harness` feature(用于 `datazen::test_harness` 与 `src-tauri/tests/` 的 IPC 迁移集成测试),runtime 夹具如需跨 crate 暴露,命名沿用该 feature,避免同一概念两个名字。 + +## 13. 安全纪律 + +| 规则 | 说明 | +| --- | --- | +| 禁止读取受保护 env 文件 | 任何 Agent 或程序都不得打开、读取、解析、`source` 或打印仓库及 worktree 中 `.env` / `.env.test` 的内容;只允许检查文件是否存在与 Git 忽略状态 | +| 禁止运行隐式加载它们的程序 | 不得执行会加载上述文件的命令或程序;`load_dotenv` 类调用在夹具中禁止出现 | +| 真实凭据注入方式 | 由 CI secret 或已获授权的进程环境变量注入;不写入提示、日志、报告或测试输出 | +| 既有实现不作为范本 | `packages/drivers/postgres/tests/postgres_cross_database.rs` 现有 `load_dotenv_file()` 读取 `packages/drivers/.env`(由 `CARGO_MANIFEST_DIR` 父目录拼出);新夹具**不复制**该写法,改造时改为只认进程环境 | +| 启动器检查 | 开发前检查测试启动器不加载受保护文件;Host E2E 必须经 `pnpm tauri:build:webdriver` 或 `pnpm e2e` 触发,不得裸 `cargo build` | +| 日志脱敏 | 夹具的 journal 不得记录凭据、附件令牌与幂等令牌 nonce;故障注入的脚本 id 可记录,字面量不可记录 | +| 夹具不产生真实外部连接 | fake provider 禁止任何出站 socket;真实驱动测试的连接目标必须是已证明的专用测试环境 | + +## 14. 验收映射 + +| 夹具能力 | 服务的 CM / 门槛 | 断言落点 | +| --- | --- | --- | +| 九操作 fake provider + 能力覆盖 | P0 类型/身份/目标门槛 CM-01、CM-04、CM-07 | §3.2、§3.3 | +| 固定实体与 `policyIsolationKey` 隔离 | CM-05、CM-67 | §8.1、§9.2 | +| 故障注入 F1~F12 | CM-11、CM-14、CM-18、CM-19、CM-21~CM-29、CM-46、CM-47、CM-52、CM-60、CM-64、CM-69、CM-72~CM-74 | §4.2 | +| CommandJournal + 变化点断言 | P0 基线与 CM-26、CM-29、CM-30、CM-60(permit 账本与预算核销) | §5.3 | +| Barrier / DrainBarrier | CM-22、CM-64、CM-69、CM-73、CM-74 | §6.2、§6.3 | +| FakeClock | CM-26、CM-29、CM-56、CM-60、CM-63、CM-64、CM-70、CM-73 | §7.2、§7.3 | +| 幂等令牌过期推进与 ID 生成 | CM-70、CM-71 | §7.2、§8.2 | +| 会话级句柄 fake 命令 | CM-73、CM-74 | §9.2、§9.3 | +| 无泄漏不变量 I1~I8 | 全部资源生命周期用例的收尾断言 | §4.3 | +| 基准 harness | CM-60 | §11.1、§11.3、§11.6 | +| 真实驱动契约模板 | CM-08~CM-19、CM-22~CM-24、CM-26、CM-48、CM-69 的「能力支持 vs 缺失」区分(本文 §10.3 的**夹具覆盖集**,比下方复述的 §17 P0 门槛多出精确取消 / 快照与源变化 / 重置与归池前置检查三个维度) | §10.2、§10.3 | + +P0 退出门槛要求的三项能力——CM-01/04/07 基线、可复现的 CM-73 证据、真实驱动的 CM-08~19 能力验证(区分「支持」与「缺失」)——分别由 fake provider、§9.3 编排与 §10 模板承担。回滚路径只涉及夹具与测试代码,不触及生产路径。 + +## 15. 与既有设计的关系 + +- [连接管理详细设计](connection-management.md) 是本文的上位契约:§5.1 提供九个资源操作与其强制语义,§5.2 提供十项能力,§6.5 定义会话级句柄登记,§9.2/§9.3/§9.4 定义期限、预算与归池前置,§14 把 `testing/fake_resource` 定为开发顺序第 2 步,§15.1~§15.3 给出 fake 与真实夹具的分工、测试层次与基准参数,§16 给出 CM-01~CM-74 用例。本文只把这些条款落成可实现的夹具接口、断言与产物,不新增也不修改上位契约。 +- [平台开发计划](../../development/platform-development-plan.md) §4 的 P0 交付项(transport-neutral fake resource、真实固定会话/事务/目标/清理契约测试、测试环境注入方式与启动器检查)在本文分别对应 §2~§9、§10、§13;§15.1 的已存在命令表与 §15.2 的 CI 门禁对应 §12。 +- [测试架构](../testing.md) 的分层与运行命令对应 §10.1 的落点纪律与 §12 的命令分区:运行时单测/集成属于新 runtime crate,Host 集成留在 `src-tauri`,驱动 Rust 集成严格留在 `packages/drivers//tests/`。 +- [系统概要](system-overview.md) 描述平台整体形态,本文不重复其内容。 +- [E2E 测试指南](../../development/e2e-testing.md) 的构建纪律对应 §13;本文不设计 WDIO 场景。 + +## 16. 本文不覆盖什么 + +- 连接管理运行时本身(actor、注册表、预算会计、执行网关)的内部设计——见 connection-management.md §6~§9。 +- §4 的 DTO 定义、字段语义与版本化枚举取值——本文只引用。 +- 任何真实数据库方言的行为:真实方言测试严格落在对应驱动 crate。 +- 驱动 Command API 的完整目录与前端 Command SDK 语义——见 `packages/driver-api` 与 `packages/driver-sdk`。 +- 前端 UI 绑定、Zustand store 与 i18n 文案。 +- 基准结果的结论与性能调优方案;本文只定义如何测量与判定。 +- 遗留 `ConnectionManager` 的迁移步骤与删除计划;CM-73/CM-74 需在其删除后继续成立,这由断言的行为依赖保证,不由本文保证迁移进度。 +- 排期、进度台账与缺陷清单(按仓库文档纪律不产出此类文档)。 diff --git a/docs/architecture/platform/persistence-model.md b/docs/architecture/platform/persistence-model.md new file mode 100644 index 000000000..0619f0279 --- /dev/null +++ b/docs/architecture/platform/persistence-model.md @@ -0,0 +1,800 @@ +# DataZen 持久化模型、可落盘白名单与 Schema 迁移详细设计 + +> 状态:目标设计,尚未实现。基线:2026-09-30,代码提交 `8592b0fe1`。本文交付的是开发契约,不代表已完成重构。 +> 代码核对时的工作区 HEAD 为 `e544e0699de0642a13d44693601048886273812f`(2026-09-30 21:08)。`8592b0fe1` 是 HEAD 的祖先,两者相差 4 个提交;`git diff --stat 8592b0fe1..HEAD -- src-tauri/src/store` 输出为空,因此 §4.1 的本地持久化现状结论对两个基线一致。本文自身在这两个提交下都还没有入库(`git status` 显示为未跟踪文件),所以对本文路径执行同一命令输出为空只反映"路径不存在于两个提交",不构成内容核对证据。 +> 读者:负责《分阶段开发计划》P1(抽取共享应用边界与持久化白名单)和 P7(服务端 ProfileRepository / JobRepository / Audit / ArtifactStore 与 schema migration)的实现者。本文定义**存什么、存在哪、谁来写、怎么迁**;DTO 形状、会话状态机、错误码取值以[连接管理详细设计](connection-management.md)为权威。 +> 配套:[系统概要](system-overview.md)、[连接管理详细设计](connection-management.md)、[共享应用边界与端口详细设计](shared-boundaries-and-ports.md)、[团队 Web 服务详细设计](team-server-and-auth.md)、[分阶段开发计划](../../development/platform-development-plan.md)。 +> 现状盘点来源:[持久化存储(现状)](../backend/store.md)、[`src-tauri/src/store/`](../../../src-tauri/src/store/mod.rs)。这些是**已实现事实**;本文其余章节为目标设计。 + +## 1. 文档定位与状态 + +| 本文负责 | 权威文档(不重复定义) | +| --- | --- | +| 可落盘白名单与禁持久化清单 | [连接 §4.4 可落盘来源与运行时绑定](connection-management.md#44-可落盘来源与运行时绑定) 给出原则,本文给出可执行的字段级清单与落库断言 | +| `DurableExecutionRecord` 的字段集合 | [连接 §4 DTO](connection-management.md#4-dto-与字段定义) 的 `ResultProvenance` / `StatementResultSource` / `ExecutionView` 形状,本文只定义其持久化投影 | +| 仓储 port 签名 | [共享边界 §4.3 持久化与授权端口](shared-boundaries-and-ports.md#43-持久化与授权端口) | +| 服务端进程形态、中间件、认证与 CSRF | [团队服务 §3–§9](team-server-and-auth.md#3-请求中间件链) | +| 登录会话行与授权数据的**表结构** | 字段语义见 [团队服务 §4.3](team-server-and-auth.md#43-服务端登录会话)、[§6.1](team-server-and-auth.md#61-角色与资源-acl);本文 §3.10 给 DDL / CHECK / 索引 / 保留策略 | +| 迁移的**运行方式**(何时执行、失败是否启动) | [团队服务 §13 Schema migration 运行方式](team-server-and-auth.md#13-schema-migration-运行方式);本文定义**版本策略、文件格式、expand/contract 判据与回滚边界** | +| 会话、lease、预算的行为契约 | [连接 §6 状态机与并发](connection-management.md#6-状态机与并发)、[§9 连接池、预算与清理](connection-management.md#9-连接池预算与清理) | +| 阶段交付顺序与门槛 | [开发计划 §5 P1](../../development/platform-development-plan.md#5-p1抽取共享应用边界和前端传输契约)、[§11 P7](../../development/platform-development-plan.md#11-p7单实例团队-web-服务) | + +三条本文反复使用的前提: + +1. `connectionId` 是可落盘的配置标识,`dbSessionId` 是进程内标识。落盘模型里只有前者。 +2. 物理资源(socket、事务、游标、服务端预处理对象)随进程终止而消失。任何"从旧库恢复会话"的说法都违反[连接 §12](connection-management.md#12-web多实例权限与结果)。 +3. 落盘的是**执行结果的事实与来源**,不是**继续执行的资格**。恢复出来的东西一律要重新授权、重新解析、重新验证。 + +## 2. 持久化白名单与禁持久化清单 + +### 2.1 白名单:允许落盘 + +| # | 可落盘对象 | 落盘载体 | 关键字段 | 依据 | +| --- | --- | --- | --- | --- | +| W1 | 执行来源 `ResultProvenance` | `durable_executions.provenance` | `organizationId`、`principalId`、`connectionId`、`configRevision`、`contextBefore`、`contextAfter`、`requestedTarget`、`capabilitySnapshot`、`executedAt` | [连接 §4.4](connection-management.md#44-可落盘来源与运行时绑定) 明示"结果来源可落盘" | +| W2 | 语句级来源 `StatementResultSource[]` | `durable_executions.statement_sources` | `executionId`、`statementIndex`、`context`、`relation`、`writableMapping` | 同上;`writableMapping` 落盘后用于写回前的映射判定 | +| W3 | 执行产物标识 `artifactIds[]` | `durable_executions.artifact_ids` + `artifact_metadata` | `artifactId` 列表、字节数、内容摘要、TTL | [概要 §5 统一领域对象](system-overview.md#5-统一领域对象) 中 Artifact 持久化 | +| W4 | 能力快照 `CapabilitySnapshot` | `durable_executions.provenance.capabilitySnapshot` | `driverId`、`driverVersion`、`protocolVersion`、`capabilityRevision`、`confirmed` | 审计需要"当时的能力",不含凭据 | +| W5 | 任务计划 | `jobs.plan` / `jobs.plan_fingerprint` | 冻结的目标、`configRevision` / `credentialRevision` / 能力版本、对象结构指纹、映射、事务要求 | [连接 §10.1](connection-management.md#101-公共处理) 计划冻结 | +| W6 | 任务检查点 | `job_checkpoints` | 稳定键、已确认提交边界、源一致性证据、映射指纹、恢复策略 | [连接 §10.4](connection-management.md#104-data-transfer);不能只存 offset | +| W7 | 任务状态与阶段 | `jobs` / `durable_executions` | `state`、`effectOutcome`、`stage`、`stateVersion`、时间戳 | [概要 §6.2](system-overview.md#62-backendclient-与领域客户端) `JobState` / `JobView` | +| W8 | 连接配置(非敏感部分) | `connections` | `connectionId`、`driverId`、`configRevision`、`credentialRevision`、`initialNamespace`、`publicOptions`、时间戳 | [连接 §4.1](connection-management.md#41-服务接口与补充响应) `ProfileView` | +| W9 | 幂等请求摘要 | `idempotency_records.request_digest` | `operation`、摘要、算法、签发与过期时间、`state` | [连接 §13.1](connection-management.md#131-幂等键期限与响应分类) | +| W10 | 幂等回执的**持久化投影** | `idempotency_records.receipt_projection` | `executionId` / `jobId`、稳定目标、owner 标识、终态 | [连接 §13](connection-management.md#13-错误重试和事件) 幂等记录原子保存 | +| W11 | 结果完整性 | `durable_executions.result_completeness` / `truncation_reason` | `pending` \| `complete` \| `truncated`、截断原因 | [连接 §7.7](connection-management.md#77-结果订阅与放弃消费) | +| W12 | 额度计数 | `quota_counters` | scope、已用、已预留、限额、窗口 | [概要 §6.4](system-overview.md#64-repositories-与环境-ports) `BudgetCoordinator` | +| W13 | 审计事件的白名单投影 | `audit_events` | 见 §3.7 | [连接 §16.7 CM-72](connection-management.md#167-补充契约与边界用例) 审计含非敏感能力版本,不含 live handle 与凭据 | +| W14 | 服务端登录会话行(服务端行,**不是**自包含令牌) | `login_sessions` | `session_id`、`organization_id`/`principal_id`、`auth_time`、`issued_at`/`absolute_expires_at`/`idle_expires_at`、`reauth_required`、`permission_version`、`client_binding`、`token_epoch`、`revoked_at`/`revoke_reason` | 字段语义见 [团队服务 §4.3](team-server-and-auth.md#43-服务端登录会话);DDL 见 §3.10;**不保存** IdP 令牌副本(F14) | +| W15 | 授权数据:成员关系 / 固定角色 / 资源 ACL / 组织权限版本 | `memberships` / `acl_entries` / `permission_versions` | `principalId → roleId` 基线、6 个固定角色(以 CHECK 表达,不建表)、逐资源逐 action 的 grant/deny、单调 `permission_version`、IdP `sub` 映射 | [团队服务 §6.1](team-server-and-auth.md#61-角色与资源-acl)、[§6.2](team-server-and-auth.md#62-权限版本与-policyisolationkey);DDL 见 §3.10 | + +### 2.2 禁持久化清单 + +| # | 禁止落盘 | 出现过的载体 | 处置 | +| --- | --- | --- | --- | +| F1 | `dbSessionId` | `SyncTask.source_db_session_id` / `target_db_session_id`([`store/models.rs`](../../../src-tauri/src/store/models.rs)) | 读取旧 JSON 时可读,落库前由 `normalize_legacy_state()` 清空,序列化时 `skip_serializing` | +| F2 | `SessionHandle{dbSessionId, runtimeEpoch}` | `SessionView.handle`、`OpenSessionReceipt` | 只在 runtime 内存;不得进入任何 repository 入参 | +| F3 | `RuntimeResultBinding{handle, resourceBindingId, executionId}` | `ExecutionView.runtimeBinding` | 仓储使用独立的 `DurableExecutionRecord`,**不序列化 `ExecutionView`** | +| F4 | `EventEnvelope.sessionHandle` | 事件流、SSE、审计 | 归档只写显式白名单投影(§2.4) | +| F5 | 取消句柄 `cancelHandle` | `ExecutionRecord` 内部 | runtime 内存;终态即失效 | +| F6 | `ResourceLease` / `leaseId` / 归池元数据 | pool 内部结构 | 内存;worker 终止后预算从零重启 | +| F7 | 游标、事务、`SessionHandleRef`([连接 §6.5](connection-management.md#65-会话级资源句柄登记)) | 会话 actor 登记表 | 内存;恢复流程不得重建句柄 | +| F8 | `attachmentToken` 及其哈希 | `OpenSessionReceipt.attachmentToken` | 哈希只在 owner 内存做去重,**明文与哈希都不落盘** | +| F9 | 隧道/网络路由句柄 | `NetworkProvider` 内部 | 内存;`networkRouteRef` 与 `networkRouteRevision` 是配置标识,可落盘 | +| F10 | 秘密材料本身(密码、token、TLS 私钥、可解密值) | `connections.driverOptions` 的敏感部分 | 只落 `secretRef`;`ProfileView.publicOptions` 不返回秘密 | +| F11 | 用户绝对路径 | 文件选择、备份工具路径 | 改为 artifact/配置引用([概要 §10](system-overview.md#10-安全和环境差异)) | +| F12 | `runtimeEpoch` | `SessionView`、内部记录 | **见下方说明** | +| F13 | `schemaCache` 内容与 `cacheRevision` | [`cache/schema_cache.rs`](../../../src-tauri/src/cache/schema_cache.rs) | 纯内存缓存,不落盘 | +| F14 | IdP `access_token` / `refresh_token` / 原始 `id_token` 及其可重放副本 | 登录会话行 | 只落服务端生成的会话行与 IdP `sub` 映射(§3.10);需要调用 IdP 后端接口时按需重新交换([团队服务 §4.3](team-server-and-auth.md#43-服务端登录会话)) | + +关于 F12:`runtimeEpoch` 是"某次 worker 启动的代号",既不含秘密也确实是数字,但落盘后极易被误读为"可以接续原绑定"的证明。[连接管理 §4.4](connection-management.md#44-可落盘来源与运行时绑定)(不是本文 §4.4「主密钥与团队秘密」)已明确 `configRevision` / `contextRevision` 不能证明物理资源连续性,`runtimeEpoch` 同理。它只用于 owner 校验([连接 §13.1](connection-management.md#131-幂等键期限与响应分类)),因此不进入任何表列。 + +关于 F8 的理由:不可逆哈希降低了泄露后果,但没有改变"它是可重放证明"这一性质。令牌一旦落盘,恢复出来的进程可能用旧哈希匹配新请求,绕过"令牌必须绑定 owner `runtimeEpoch`"的约束。 + +### 2.3 两层 DTO 与投影规则 + +```text +ExecutionView(对外实时视图,含 runtimeBinding) + │ ① 仓储只接受 DurableExecutionRecord(类型层面拒绝 View) + ▼ +DurableExecutionRecord(落盘投影) + executionId, organizationId, principalId, connectionId, configRevision, contextRevision, + requestedTarget, state, effectOutcome, provenance, statementSources, artifactIds, resultCompleteness, truncationReason, errorCode, startedAt, finishedAt + │ ② 反序列化后重建成 ExecutionView 时 runtimeBinding = null + ▼ +读回路径:ExecutionView { ..., runtimeBinding: null } // 客户端据此知道「需要重新建立」 +``` + +- 规则一:写入路径的类型是 `DurableExecutionRecord`,不是 `ExecutionView`。序列化器即使被绕过也拿不到 `runtimeBinding`,因为字段根本不在类型里。 +- 规则二:读回路径产出的 `ExecutionView.runtimeBinding` 恒为 `null`(或显式的 `bindingLost`),客户端不得用 `null` 推断"还没执行"。 +- 规则三:`statementSources[].context` 与 `relation` 必须先按 [连接 §4.3](connection-management.md#43-命名空间规范化契约) 的 `CanonicalTarget` 归一化后再落库,**不得**使用文件系统路径别名形式(CM-62)。 +- 规则四:`statement_sources` 单条执行最多保留 1024 条(**本文建议值,来自部署配置**;[连接 §4.4](connection-management.md#44-可落盘来源与运行时绑定) 与 [§9](connection-management.md#9-连接池预算与清理) 都没有给出该上限,因此落地前不得当作权威值引用);超出时截断并写审计事件(这是投影裁剪,**不是**结果截断,不改写 `result_completeness`)。 + +### 2.4 事件归档的白名单投影 + +事件归档**永远不序列化 `EventEnvelope`**。归档写入 `audit_events` 的映射是逐字段固定的: + +| `EventEnvelope` / payload 字段 | 归档列 | 规则 | +| --- | --- | --- | +| `streamId`、`sequence` | `audit_events.request_id`(附 `payload.streamRef`) | `sequence` 用 `BIGINT`;传输层不得转成 JS `Number`([连接 §13](connection-management.md#13-错误重试和事件)) | +| `executionId` / `jobId` | `subject_id` | 二选一,另一列为 NULL | +| `contextRevision` | `payload.contextRevision` | 审计字段,不参与绑定判断 | +| `payload` 自由内容 | **不归档** | 只允许映射到下表已列字段 | +| `sessionHandle` | **禁止** | F4 | +| 能力版本(driverId/version/protocolVersion/capabilityRevision) | `payload.capability` | CM-72 要求"审计包含非敏感能力版本" | +| 目标投影(`connectionId` + 归一化 namespace,不含路径) | `target_projection` | | +| 凭据、SQL 原文、参数、原始数据库错误 | 默认**不归档** | 是否保留由审计策略控制;默认不打印密码、令牌、原始数据库错误与用户绝对路径([概要 §10](system-overview.md#10-安全和环境差异)) | +| 终态与错误码 | `outcome` / `error_code` | `errorCode` 取 [连接 §4](connection-management.md#4-dto-与字段定义) 的 `ExecutionErrorCode` 枚举 | + +`audit_events.payload` 使用 JSONB 承载上述**已枚举**的白名单字段,键名固定在代码常量中;写入前由一个 `AuditProjection` 函数构造,构造函数的单测对键集合做相等断言——新增键必须显式改测试,而不是靠"对象序列化"顺带落库。 + +### 2.5 落库断言(CM-61 的可执行形式) + +CM-61([连接 §16.7](connection-management.md#167-补充契约与边界用例))要求"落盘结构无 `dbSessionId` / `SessionHandle` / `resourceBindingId` / lease / cancel 句柄"。落地为三条测试: + +1. **列名断言**:对每张表读取 `information_schema.columns`,断言列名不包含 `db_session_id`、`session_handle`、`resource_binding_id`、`lease_id`、`cancel_handle`、`attachment_token`、`credential`、`password`。仓库已有同形先例:[`store/history_db/migration_run.rs`](../../../src-tauri/src/store/history_db/migration_run.rs) 的测试遍历 `PRAGMA table_info(migration_run_history)`,断言不含 `sql`、`payload`、`credentials`、`db_session_id`、`file_token`。 +2. **序列化断言**:写入一条完整 `ExecutionView`(含 `runtimeBinding`)后回读 `DurableExecutionRecord`,断言结果 JSON 不含上述键名;同形先例是 [`store/tests/migration_tests.rs`](../../../src-tauri/src/store/tests/migration_tests.rs) 的 `!persisted.contains("dbSessionId")`。 +3. **JSONB 键断言**:对 `provenance`、`statement_sources`、`receipt_projection` 展开后逐层检查键名,命中禁持久化清单中任一项即失败。 + +## 3. 服务端元数据库表结构 + +### 3.1 选型与边界 + +- 服务端管理库使用 **PostgreSQL**([概要 §6.4](system-overview.md#64-repositories-与环境-ports) 推荐"第一版用 PostgreSQL 管理组织、配置版本、任务、审计和额度"),**不与用户业务数据库混用**,迁移脚本也绝不触碰用户业务库。 +- 桌面本地持久化**不是**这套 schema 的另一个实例。本地仍是文件 + SQLite(§4.1),两者的字段同构关系见 §4.2,但存储引擎、加密方式和一致性语义不同。 +- 所有表都带 `organization_id`,且**复合主键一律以 `organization_id` 打头**:越组织访问在存储层就是查不到,而不是靠应用层记得加过滤。 +- 时间列统一 `TIMESTAMPTZ`(UTC)。所有 TTL 的**起点**用服务端单调时钟保存([连接 §6.4](connection-management.md#64-attachment-与超期处理)),`expires_at` 只是最早适用期限的 UTC 投影。 +- v1 **不建** `organizations` / `principals` 主数据表:`organization_id` / `principal_id` 由认证适配器按 `RequestContext` 提供,`memberships` 是主体(principal)在管理库中的唯一登记点;组织开通走部署配置导入,不在运行期自助创建。因此 §3 的 `organization_id` 列没有可指向的组织表外键,越组织隔离由「主键以 `organization_id` 打头」保证,而非外键。 +- 本节只定义**表结构**。`ApiError.code` 的取值全集**不在本文**:[连接 §13](connection-management.md#13-错误重试和事件) 是权威表;[团队服务 §9.2](team-server-and-auth.md#92-apierror--http-映射表) 列出的 `Unauthenticated` / `NotFound` / `PayloadTooLarge` / `QuotaExceeded` / `RateLimited` / `ServiceUnavailable` / `ConfigRevisionMismatch` 这 7 个 code **已由 §13 正式登记**,团队服务 §9.2 只是它们在 HTTP 层的落地映射,不新增也不改写 code 的语义。 + +### 3.2 表清单 + +存在的表(13 张;顺序即 §3.3–§3.10 的 DDL 顺序,也是首批迁移文件内的建表顺序——任一表的 `REFERENCES` 目标都在它之前出现,含 `login_sessions` → `memberships` 的前后关系,理由见 §3.10): + +| 表 | 对应 port / 用途 | 主键 | +| --- | --- | --- | +| `connections` | `ProfileRepository` | `(organization_id, connection_id)` | +| `durable_executions` | 执行记录(`DurableExecutionRecord` 投影) | `(organization_id, execution_id)` | +| `idempotency_records` | 幂等请求摘要与回执投影 | `(organization_id, operation, idempotency_key)` | +| `jobs` | `JobRepository` 主记录 | `(organization_id, job_id)` | +| `job_checkpoints` | `JobRepository.save_checkpoint` | `(organization_id, job_id, checkpoint_version)` | +| `audit_events` | append-only 审计 | `(organization_id, event_id)` | +| `artifact_metadata` | `ArtifactStore` 元数据(字节在对象存储/受控目录) | `(organization_id, artifact_id)` | +| `quota_counters` | `BudgetCoordinator` 的持久额度部分 | `(organization_id, scope, scope_key, resource_kind)` | +| `schema_migration_history` | 迁移运行器自身状态 | `(version)` | +| `memberships` | 主体 → 角色基线(§3.10,W15) | `(organization_id, principal_id)` | +| `login_sessions` | 服务端登录会话行(§3.10,W14) | `(organization_id, session_id)` | +| `acl_entries` | 逐资源逐 action 的 grant/deny(§3.10,W15) | `(organization_id, resource_type, resource_id, subject_type, subject_id, action)` | +| `permission_versions` | 组织级单调权限版本(§3.10,W15) | `(organization_id)` | + +**不存在的表**(显式声明,防止后续实现"顺手加一个"): + +| 不存在 | 原因 | +| --- | --- | +| `sessions` / `session_directory` | 会话目录禁止磁盘持久化、快照和 append-only 日志([连接 §12](connection-management.md#12-web多实例权限与结果));丢失目录只会使会话明确失效,不据此恢复物理会话 | +| `session_tombstone` | `closeSession` 的 tombstone 只在内存保留默认 24 小时([连接 §13.1](connection-management.md#131-幂等键期限与响应分类)) | +| `attachment_tokens` | attachment 令牌及其哈希只在 owner 内存;服务端不保存可重放的会话凭据 | +| `idp_tokens` / `refresh_tokens` | 登录会话行不是 IdP 令牌副本(F14);需要后端调用时按需重新交换 | +| `resource_leases` / `pool_state` / `budget_runtime` | 物理占用与运行时额度随进程终止;进程重启后额度从零重新开始是单实例形态下的自洽前提([团队服务 §10.3](team-server-and-auth.md#103-sessiondirectory-与-budgetcoordinator单实例)) | +| `cursors` / `transactions` | 会话级句柄只存在于内存 actor | +| `credentials` | 只存 `secret_ref`;`SecretProvider` 在执行节点解析 | +| `schema_cache` | 元数据缓存是进程内缓存,且旧缓存不得回填(CM-67) | + +### 3.3 `connections` + +```sql +CREATE TABLE connections ( + organization_id TEXT NOT NULL, + connection_id TEXT NOT NULL, + name TEXT NOT NULL, + driver_id TEXT NOT NULL, + config_revision BIGINT NOT NULL DEFAULT 1, + credential_revision BIGINT NOT NULL DEFAULT 1, + initial_namespace JSONB NOT NULL, + public_options JSONB NOT NULL DEFAULT '{}'::jsonb, + driver_options JSONB NOT NULL DEFAULT '{}'::jsonb, + secret_ref TEXT, + read_only BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, connection_id), + CONSTRAINT ck_connections_config_revision CHECK (config_revision >= 1), + CONSTRAINT ck_connections_credential_revision CHECK (credential_revision >= 1), + CONSTRAINT ck_connections_options_object CHECK (jsonb_typeof(driver_options) = 'object') +); + +CREATE INDEX idx_connections_list + ON connections (organization_id, enabled, updated_at DESC, connection_id); +``` + +| 字段 | 规则 | +| --- | --- | +| `connection_id` | 服务端分配;不是 `dbSessionId`,可安全落盘 | +| `config_revision` | 每次 `compare_and_set` 成功 +1;参与 PoolKey([连接 §9.6](connection-management.md#96-poolkey版本与缓存的生产者)) | +| `credential_revision` | 只由 `advance_credential_revision` 递增;不使用密码散列作为版本 | +| `driver_options` | 按 driver schema 分离敏感字段;**敏感部分不落本表**,只写 `secret_ref` | +| `initial_namespace` | 已按 `CanonicalTarget` 归一化 | +| `name` | **不建唯一约束**:同组织允许同名 profile,列表按 `(updated_at DESC, connection_id)` 稳定排序,UI 靠 id 区分 | +| 缺失列 | 服务端另有 `policy_ref`、`policy_ref_revision`、`network_route_ref`、`network_route_revision`(见 §4.3;`policy_ref_revision` 是 [团队服务 §6.2](team-server-and-auth.md#62-权限版本与-policyisolationkey) 的 `policyIsolationKey` 输入之一),P7 首个迁移版本加入 | + +### 3.4 `durable_executions` + +```sql +CREATE TABLE durable_executions ( + organization_id TEXT NOT NULL, + execution_id TEXT NOT NULL, + connection_id TEXT NOT NULL, + config_revision BIGINT NOT NULL, + context_revision BIGINT NOT NULL, + state TEXT NOT NULL, + effect_outcome TEXT NOT NULL, + requested_target JSONB NOT NULL, + provenance JSONB NOT NULL, + statement_sources JSONB NOT NULL DEFAULT '[]'::jsonb, + artifact_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + result_completeness TEXT NOT NULL DEFAULT 'pending', + truncation_reason TEXT, + error_code TEXT, + owner_principal_id TEXT NOT NULL, + client_instance_id TEXT, + started_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, execution_id), + FOREIGN KEY (organization_id, connection_id) + REFERENCES connections (organization_id, connection_id), + CONSTRAINT ck_exec_state CHECK (state IN ('queued','running','cancelRequested', + 'succeeded','failed','cancelled')), + CONSTRAINT ck_exec_outcome CHECK (effect_outcome IN ('notStarted','completed','rolledBack', + 'partiallyApplied','unknown')), + CONSTRAINT ck_exec_complete CHECK (result_completeness IN ('pending','complete','truncated')), + CONSTRAINT ck_exec_trunc CHECK ((result_completeness = 'truncated' + AND truncation_reason IS NOT NULL) + OR (result_completeness <> 'truncated' + AND truncation_reason IS NULL)), + CONSTRAINT ck_exec_finished CHECK ((state IN ('succeeded','failed','cancelled')) + = (finished_at IS NOT NULL)) +); + +CREATE INDEX idx_executions_job_scope + ON durable_executions (organization_id, connection_id, started_at DESC); +CREATE INDEX idx_executions_open + ON durable_executions (organization_id, state) + WHERE state IN ('queued', 'running', 'cancelRequested'); +``` + +枚举取值全部来自[连接 §4](connection-management.md#4-dto-与字段定义) 的 `ExecutionState` / `EffectOutcome` / `resultCompleteness`,本文不新增取值。`ck_exec_trunc` 与 `ck_exec_finished` 是两条强不变量:截断必须有原因,终态必须有结束时间。两条 CHECK 都写成等价式(` = `),所以方向也是双向的——`truncation_reason` 非空就只能是 `truncated`,`finished_at` 非空就只能是终态,不存在"有原因但完整"或"有结束时间但仍在 running"的行。注意两条 CHECK 的绑定对象不同:`ck_exec_trunc` 绑 `result_completeness`,`ck_exec_finished` 只绑 `state`(终态行 `finished_at` 非空、非终态行为 NULL),**不绑** `result_completeness`,因此 `succeeded` + `result_completeness = 'pending'` 不会被它拒绝,这一组合由应用层写入路径保证不出现(见 §8.3)。 + +**没有的列**(对应 §2.2):`db_session_id`、`session_handle`、`resource_binding_id`、`runtime_epoch`、`cancel_handle`、`lease_id`。 + +`effect_outcome` **不由"请求成功"覆盖**(CM-72):`state='failed'` 且用户主动取消时,`effect_outcome` 仍可能是 `completed`。 + +### 3.5 `idempotency_records` + +```sql +CREATE TABLE idempotency_records ( + organization_id TEXT NOT NULL, + operation TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_digest TEXT NOT NULL, + digest_algorithm TEXT NOT NULL DEFAULT 'sha256', + key_version BIGINT, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + retained_until TIMESTAMPTZ NOT NULL, + state TEXT NOT NULL, + subject_kind TEXT NOT NULL, + subject_id TEXT NOT NULL, + receipt_projection JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, operation, idempotency_key), + CONSTRAINT ck_idem_operation CHECK (operation IN ('createProfile','openSession', + 'executeInSession','executeAtTarget','setSessionContext','startJob')), + CONSTRAINT ck_idem_state CHECK (state IN ('accepted','dispatched','completed', + 'failed','cancelled','outcomeUnknown')), + CONSTRAINT ck_idem_subject CHECK (subject_kind IN ('profile','execution','job')), + CONSTRAINT ck_idem_digest CHECK (digest_algorithm = 'sha256') +); + +CREATE INDEX idx_idempotency_expiry ON idempotency_records (retained_until); +CREATE UNIQUE INDEX uq_idempotency_live_subject + ON idempotency_records (organization_id, subject_id) + WHERE subject_kind = 'execution' AND state IN ('accepted', 'dispatched', 'outcomeUnknown'); +``` + +| 规则 | 内容 | +| --- | --- | +| 落库时点 | 接受记录必须在 SQL / Job 派发**之前**提交([连接 §13](connection-management.md#13-错误重试和事件)) | +| 保留期 | 完整记录至少保留到 `expires_at + 24 小时`,即 `retained_until`;之后可按审计策略删除 | +| 删除后 | 过期签名令牌仍被拒绝;过期和删除后都**不再执行**(CM-70) | +| 冲突 | 同 key 同摘要 → 返回同 receipt;同 key 不同摘要 → `IdempotencyConflict` | +| 不落盘 | `openSession` / `setSessionContext` 的完整指纹、`SessionView`、`attachmentToken`、会话回执**只在 owner 内存**。本表只保存请求摘要与 `executionId`/`jobId` 等稳定投影 | +| `key_version` | 令牌签发用的密钥版本;未知 `keyVersion` 一律拒绝,签名密钥保留期 ≥ 已发令牌全部过期时间 | +| `uq_idempotency_live_subject` | 保证一次执行至多被一个未终结幂等记录引用,避免两个不同 key 的记录指向同一次执行 | + +### 3.6 `jobs` 与 `job_checkpoints` + +```sql +CREATE TABLE jobs ( + organization_id TEXT NOT NULL, + job_id TEXT NOT NULL, + kind TEXT NOT NULL, + state TEXT NOT NULL, + state_version BIGINT NOT NULL DEFAULT 1, + stage TEXT, + connection_id TEXT NOT NULL, + config_revision BIGINT NOT NULL, + plan_fingerprint TEXT NOT NULL, + plan JSONB NOT NULL, + owner_principal_id TEXT NOT NULL, + delegation_id TEXT, + worker_id TEXT, + claim_expires_at TIMESTAMPTZ, + execution_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + artifact_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + error_code TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ, + PRIMARY KEY (organization_id, job_id), + FOREIGN KEY (organization_id, connection_id) + REFERENCES connections (organization_id, connection_id), + CONSTRAINT ck_jobs_state CHECK (state IN ('queued','running','succeeded','failed','cancelled')), + CONSTRAINT ck_jobs_claim CHECK ((worker_id IS NULL) = (claim_expires_at IS NULL)), + CONSTRAINT ck_jobs_claim_live CHECK (state <> 'running' OR worker_id IS NOT NULL) +); + +CREATE INDEX idx_jobs_list ON jobs (organization_id, created_at DESC, job_id); +CREATE INDEX idx_jobs_claim ON jobs (claim_expires_at) WHERE state = 'running'; +CREATE INDEX idx_jobs_recover ON jobs (organization_id, updated_at) + WHERE state IN ('queued', 'running', 'cancelled'); + +CREATE TABLE job_checkpoints ( + organization_id TEXT NOT NULL, + job_id TEXT NOT NULL, + checkpoint_version BIGINT NOT NULL, + stage TEXT NOT NULL, + stable_keys JSONB NOT NULL, + committed_boundary JSONB NOT NULL, + verification_evidence JSONB NOT NULL, + mapping_fingerprint TEXT NOT NULL, + recovery_policy TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, job_id, checkpoint_version), + FOREIGN KEY (organization_id, job_id) REFERENCES jobs (organization_id, job_id), + CONSTRAINT ck_ckpt_policy CHECK (recovery_policy IN ('resumeAfterVerify', + 'requireManualReview', 'abort')) +); +``` + +- `jobs.plan` 冻结计划([连接 §10.1](connection-management.md#101-公共处理)):目标、`configRevision` / `credentialRevision` / 能力版本、对象结构指纹、映射、事务要求。计划冻结后任何一项变化 → `PlanStale`。 +- `jobs.worker_id` / `claim_expires_at` 是**调度租约**,不是 `ResourceLease`:它只记录"哪个 worker 负责推进这个 Job"和到期时间,**不含任何物理资源标识**,因此不违反 §2.2 的 F6。终态时必须置 NULL(由 `ck_jobs_claim` 保证成对)。`renew` 失败即视为失联,Job 停止新增资源并转人工核验,**不重新派发副作用阶段**。 +- `job_checkpoints.stable_keys` 是稳定对象键(`connectionId` + 归一化对象标识 + 版本),**不是**字节 offset;`committed_boundary` 记录已确认提交的边界对象集合与核验时间;`verification_evidence` 记录源一致性证据([连接 §10.4](connection-management.md#104-data-transfer))。只存 offset 的检查点在设计上不合格。 +- 检查点**不保存** live session、lease、cursor 或句柄;恢复流程不得重建会话级句柄,只允许以新 `dbSessionId` 显式重建([连接 §6.5](connection-management.md#65-会话级资源句柄登记))。任务生命周期独立于窗口:关闭编辑器只取消订阅,不取消 Job(INV-12,[连接 §3](connection-management.md#3-不可破坏的不变量))。 + +### 3.7 `audit_events` + +```sql +CREATE TABLE audit_events ( + organization_id TEXT NOT NULL, + event_id BIGINT GENERATED ALWAYS AS IDENTITY, + occurred_at TIMESTAMPTZ NOT NULL, + principal_id TEXT, + principal_kind TEXT NOT NULL, + delegation_id TEXT, + client_instance_id TEXT, + action TEXT NOT NULL, + connection_id TEXT, + config_revision BIGINT, + target_projection JSONB, + outcome TEXT NOT NULL, + error_code TEXT, + request_id TEXT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (organization_id, event_id), + CONSTRAINT ck_audit_principal CHECK (principal_kind IN ('user','service','delegation')), + CONSTRAINT ck_audit_outcome CHECK (outcome IN ('succeeded','failed','denied','unknown')) +); + +CREATE INDEX idx_audit_time ON audit_events (organization_id, occurred_at DESC); +CREATE INDEX idx_audit_target ON audit_events (organization_id, connection_id, occurred_at DESC); +``` + +- **append-only 由权限保证**:应用角色只授予 `INSERT` 与 `SELECT`,不授予 `UPDATE` / `DELETE`([团队服务 §10.1](team-server-and-auth.md#101-实现矩阵))。保留期到期后由运维按时间分区整段卸载,不做逐行删除。 +- `event_id` 是 `GENERATED ALWAYS AS IDENTITY` 的全局单调序列,仅用于排序与分页;**它不是** `EventEnvelope.sequence`。每条执行事件的 `streamId + sequence` 由 runtime 维护并在 SSE 重放时使用,两者不互相替代。`payload` 只接受 §2.4 列出的白名单键。 + +### 3.8 `artifact_metadata` 与 `quota_counters` + +```sql +CREATE TABLE artifact_metadata ( + organization_id TEXT NOT NULL, + artifact_id TEXT NOT NULL, + execution_id TEXT, + job_id TEXT, + connection_id TEXT NOT NULL, + byte_size BIGINT NOT NULL, + chunk_count INTEGER NOT NULL, + content_digest TEXT NOT NULL, + result_completeness TEXT NOT NULL, + truncation_reason TEXT, + visibility TEXT NOT NULL DEFAULT 'private', + owner_principal_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, + finalized_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + PRIMARY KEY (organization_id, artifact_id), + FOREIGN KEY (organization_id, connection_id) + REFERENCES connections (organization_id, connection_id), + CONSTRAINT ck_artifact_size CHECK (byte_size >= 0), + CONSTRAINT ck_artifact_chunks CHECK (chunk_count >= 1), + CONSTRAINT ck_artifact_visible CHECK (visibility IN ('private','organization')), + CONSTRAINT ck_artifact_complete CHECK (result_completeness IN ('pending','complete','truncated')) +); + +CREATE INDEX idx_artifact_exec ON artifact_metadata (organization_id, execution_id); +CREATE INDEX idx_artifact_expiry ON artifact_metadata (expires_at) WHERE deleted_at IS NULL; + +CREATE TABLE quota_counters ( + organization_id TEXT NOT NULL, + scope TEXT NOT NULL, + scope_key TEXT NOT NULL, + resource_kind TEXT NOT NULL, + used BIGINT NOT NULL DEFAULT 0, + reserved BIGINT NOT NULL DEFAULT 0, + limit_value BIGINT, + window_started_at TIMESTAMPTZ, + window_ends_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, scope, scope_key, resource_kind), + CONSTRAINT ck_quota_scope CHECK (scope IN ('organization','principal','database')), + CONSTRAINT ck_quota_counts CHECK (used >= 0 AND reserved >= 0) +); +``` + +- `quota_counters` 只存**跨进程/跨实例需要**的额度(组织、用户、数据库三级与产物总量)。单进程内的 control/interactive/metadata/job 保留与队列属于 `BudgetCoordinator` 内存,**不落盘**。 +- 额度会计规则沿用[连接 §9.3](connection-management.md#93-预算会计):失败必须核销,不能把额度重复发放。`reserved` 在承诺时增加、`used` 在实际消耗时转移,两者都不得为负。 +- `visibility` 默认 `private`:共享业务结果默认私有,共享 SQL 文件不授予结果读取权([概要 §10](system-overview.md#10-安全和环境差异))。 + +### 3.9 `schema_migration_history` + +```sql +CREATE TABLE schema_migration_history ( + version BIGINT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), + duration_ms INTEGER NOT NULL DEFAULT 0 +); +``` + +只在校验和执行**都成功**后插入;失败不写行,迁移整体回滚(§5.5)。 + +仓库现有两个本地先例,命名不一致,本文不做统一改造(避免制造与本文无关的改动),只说明差异: + +| 位置 | 表 | 版本常量 | 记录形态 | +| --- | --- | --- | --- | +| [`store/app_db.rs`](../../../src-tauri/src/store/app_db.rs) | `schema_migrations` | `SCHEMA_VERSION: i32 = 1` | `(version INTEGER PRIMARY KEY, applied_at TEXT)`,只记版本 | +| [`store/history_db/schema.rs`](../../../src-tauri/src/store/history_db/schema.rs) | `schema_version` | 环形 v1→v4 | `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`,只记版本 | +| 服务端管理库(本文) | `schema_migration_history` | 内嵌于二进制 | 版本 + 文件名 + 校验和 + 耗时 | + +服务端额外记 `name` 与 `checksum` 的原因见 §5.2。 + +### 3.10 认证与授权表 + +`login_sessions` / `memberships` / `acl_entries` / `permission_versions` 是 [团队服务 §4.3](team-server-and-auth.md#43-服务端登录会话) 与 [§6.1](team-server-and-auth.md#61-角色与资源-acl) 的落库登记;**本节是它们表结构的权威**(字段语义仍以团队服务为准),DDL、CHECK、索引与保留策略一次给全。四张表都带 `organization_id` 且以它打头(§3.1)。本节 DDL 按**可执行顺序**给出:`memberships` 必须先于 `login_sessions` 建立,因为 `login_sessions` 的复合外键引用 `memberships (organization_id, principal_id)`;PostgreSQL 在建表时即解析外键目标,同一迁移事务内**后建的表不能作为先建表的外键目标**,前置引用会直接报 `relation "memberships" does not exist`,而 §5.5 是 fail-closed,服务因此起不来。§3.3–§3.9 的字面顺序同样已核验:`acl_entries` 与 `permission_versions` 不声明任何外键,其余外键的目标表都出现在引用它的表之前。因此首个迁移文件按 §3.3→§3.10 的文件内顺序自上而下执行即可满足全部外键依赖(§5.1)。 + +```sql +CREATE TABLE memberships ( + organization_id TEXT NOT NULL, principal_id TEXT NOT NULL, role_id TEXT NOT NULL, + external_subject TEXT NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, principal_id), + CONSTRAINT ck_membership_role CHECK (role_id IN ('orgAdmin','orgMember','connectionOwner','connectionViewer','jobOperator','auditor')), + CONSTRAINT ck_membership_subject CHECK (external_subject <> '') +); +CREATE INDEX idx_memberships_role ON memberships (organization_id, role_id) WHERE enabled; + +CREATE TABLE login_sessions ( + organization_id TEXT NOT NULL, session_id TEXT NOT NULL, principal_id TEXT NOT NULL, + auth_time TIMESTAMPTZ NOT NULL, issued_at TIMESTAMPTZ NOT NULL, + absolute_expires_at TIMESTAMPTZ NOT NULL, idle_expires_at TIMESTAMPTZ NOT NULL, + reauth_required BOOLEAN NOT NULL DEFAULT FALSE, permission_version BIGINT NOT NULL, + client_binding TEXT NOT NULL, token_epoch BIGINT NOT NULL DEFAULT 1, + revoked_at TIMESTAMPTZ, revoke_reason TEXT, + PRIMARY KEY (organization_id, session_id), + FOREIGN KEY (organization_id, principal_id) REFERENCES memberships (organization_id, principal_id), + CONSTRAINT ck_login_window CHECK (idle_expires_at <= absolute_expires_at), + CONSTRAINT ck_login_revoke CHECK ((revoked_at IS NULL) = (revoke_reason IS NULL)), + CONSTRAINT ck_login_reason CHECK (revoke_reason IS NULL OR revoke_reason IN ('logout','permission_revoked','admin','credential_rotated')), + CONSTRAINT ck_login_epoch CHECK (token_epoch >= 1) +); +CREATE INDEX idx_login_sessions_owner ON login_sessions (organization_id, principal_id) WHERE revoked_at IS NULL; +CREATE INDEX idx_login_sessions_sweep ON login_sessions (idle_expires_at) WHERE revoked_at IS NULL; + +CREATE TABLE acl_entries ( + organization_id TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL DEFAULT '', + subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, action TEXT NOT NULL, + deny BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, resource_type, resource_id, subject_type, subject_id, action), + CONSTRAINT ck_acl_resource CHECK (resource_type IN ('connection','session','execution','job','artifact','stream')), + CONSTRAINT ck_acl_subject CHECK (subject_type IN ('user','serviceAccount','delegated')), + CONSTRAINT ck_acl_action CHECK (action IN ('read','execute','write','cancel','download','subscribe','admin')) +); + +CREATE TABLE permission_versions ( + organization_id TEXT NOT NULL, permission_version BIGINT NOT NULL DEFAULT 1, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id), + CONSTRAINT ck_permission_version CHECK (permission_version >= 1) +); +``` + +规则与保留策略(`roles` 的处理见下条;字段语义与默认时长一律以[团队服务 §4.3](team-server-and-auth.md#43-服务端登录会话) 为准): + +- **没有 `roles` 表**:首版角色是固定集合([团队服务 §6.1](team-server-and-auth.md#61-角色与资源-acl) 明确不可由管理员自定义),因此用 `ck_membership_role` 的 CHECK 表达;P9 引入自定义角色时再补 `roles` 表与外键。 +- `session_id` 是服务端生成的不透明随机值,不是 `dbSessionId`(§2.1 W14);`login_sessions` 里**没有**任何 IdP 令牌列(F14),需要调用 IdP 后端接口时按需重新交换。`revoke_reason` 四个取值与 [团队服务 §4.5](team-server-and-auth.md#45-登出与会话撤销) 逐字一致;`token_epoch` 只增,用于令已签发的短期令牌立即失效。 +- `absolute_expires_at` / `idle_expires_at` 的默认时长取团队服务(**首版建议值,来自部署配置**),本文不写死;`idle_expires_at` 只由**入站请求**顺延,登录心跳与 SSE `: keepalive` 都不刷新它([共享边界 §4.5](shared-boundaries-and-ports.md#45-会话目录预算与令牌端口)、[团队服务 §8.3](team-server-and-auth.md#83-心跳与超时))。 +- `memberships.external_subject` 是 IdP `sub` 在管理库中的**唯一**落库处,只用于登录映射,不进任何 API 响应;成员关系只置 `enabled = FALSE`,不物理删除(撤销要可追溯)。`acl_entries.resource_id` 用空串 `''` 表示**组织级**条目(主键列不能为 NULL),`session` / `execution` / `stream` 的条目只在需要显式 deny 时登记,默认按 owner 继承([团队服务 §6.1](team-server-and-auth.md#61-角色与资源-acl))。 +- 撤销不靠改写 `acl_entries`,而靠 `permission_versions.permission_version` 自增,使缓存分区键与 PoolKey 立即变化([团队服务 §6.3](team-server-and-auth.md#63-撤销传播))。`permission_versions` 每组织一行、只增不减、**永不删除**;`login_sessions` 的撤销与过期行保留至 `absolute_expires_at` 之后按审计保留期清理,因为删除会话行等价于强制重新登录,**删除永远是安全方向**。 + +## 4. 本地 Store 与服务端 DB 映射 + +### 4.1 桌面本地持久化现状(已实现) + +| 载体 | 内容 | 加密 | 打开位置 | +| --- | --- | --- | --- | +| `{appData}/connections.json` | `ConnectionConfig` 数组(明文 JSON,仅 `password`、`ssh_tunnel.password`、`ssh_tunnel.passphrase` 逐字段 AES-256-GCM) | 字段级 | [`store/connections.rs`](../../../src-tauri/src/store/connections.rs) | +| `{appData}/settings.json` / `tunnels.json` / `groups.json` | 应用设置、隧道、分组 | 同上(仅凭据字段) | [`store/mod.rs`](../../../src-tauri/src/store/mod.rs) `load_all()` | +| `{appData}/ai_config.enc` | AI Provider 配置 | 整体密文 | [`store/ai_config.rs`](../../../src-tauri/src/store/ai_config.rs) | +| `{appData}/history.sqlite` | `query_history`、`workflow_history`、`migration_run_history`(WAL,`PRAGMA foreign_keys = ON`) | **明文**(SQL 文本、错误信息按明文存) | [`store/history_db.rs`](../../../src-tauri/src/store/history_db.rs) | +| `{appData}/datazen.sqlite` | `workflows`、`dashboards`、`widgets`、`widget_runs`、`widget_latest_run` | 明文 | [`store/app_db.rs`](../../../src-tauri/src/store/app_db.rs) | +| `{appData}/favorites/**/*.sql` | 收藏 SQL(含 front-matter) | 无 | [`store/favorites/`](../../../src-tauri/src/store/mod.rs) | +| `{appData}/.key` | AES-256 主密钥(仅 file 后端) | base64 明文文件 | [`store/key_store.rs`](../../../src-tauri/src/store/key_store.rs) | + +三点现状结论,直接影响 P1/P7: + +1. `connections.json` 里**没有** `configRevision`、`credentialRevision`、`organizationId`。这三个字段必须由新增的映射层补出,映射层不是"读现有文件"而是"把旧结构映射到新结构"。 +2. `history.sqlite` 明确以明文存 SQL 与错误信息(见[持久化存储 §1.3](../backend/store.md#13-查询历史明文)),因此备份 app data 目录必须视同敏感审计日志。本文的 §7 以此为前提。 +3. 旧实现把运行时会话 id 写进过持久化模型。`SyncTask` 的处理方式就是目标模板:字段保留在 Rust 结构里以便读旧 JSON,但标 `#[serde(default, skip_serializing)]` 并在归一化时清空([`store/models.rs`](../../../src-tauri/src/store/models.rs) `normalize_legacy_state()`)。**"能读旧数据、绝不再写出"** 是本仓已有的、已测试的先例。 + +### 4.2 字段同构(本地 `ConnectionConfig` → 服务端 `connections`) + +| 服务端列 | 本地来源 | 迁移动作 | +| --- | --- | --- | +| `organization_id` | 无 | 本地模式取固定本地组织 id;团队模式由认证与 membership 决定([概要 §6.1](system-overview.md#61-应用上下文)) | +| `connection_id` | `ConnectionConfig.id` | 直接映射;已是持久化标识 | +| `name` / `driver_id` | `name` / `type` | 直接映射 | +| `config_revision` | 无 | 新建为 `1`;此后只由服务端 `compare_and_set` 递增 | +| `credential_revision` | 无 | 新建为 `1`;`advance_credential_revision` 递增 | +| `initial_namespace` | `database` 等初始目标字段 | 按 `CanonicalTarget` 归一化后写入 | +| `public_options` | 非敏感连接选项 | 直接映射 | +| `driver_options` | 驱动选项(含敏感部分) | 敏感部分**不迁移**,改为 `secret_ref`(§4.4) | +| `secret_ref` | 无 | 团队模式下由 `SecretProvider` 在服务端创建;本地模式下不适用 | +| `read_only` / `enabled` | 无 | 新建为 `FALSE` / `TRUE`,之后由服务端策略维护 | +| `created_at` / `updated_at` | 无 | 新建为导入时间;**不伪造**历史时间 | + +反向(服务端 → 本地)只允许携带 `connection_id`、`name`、`driver_id`、`initial_namespace`、`public_options` 与 `credentialConfigured` 布尔位,形状等于 [`ProfileView`](connection-management.md#41-服务接口与补充响应);不返回密码、token 或 TLS 私钥。 + +### 4.3 只存在于服务端的字段 + +| 字段 | 归属 | 为什么不能落在本地 profile | +| --- | --- | --- | +| `organizationId` | `RequestContext` | 桌面本地模式是单组织;团队模式下身份由认证决定,不是配置属性 | +| `policyRef` / `policyIsolationKey` | `PolicyService` | 权限撤销必须让 PoolKey 立即变化,配置本地副本会延迟生效(CM-67) | +| `networkRouteRef` / `networkRouteRevision` | `NetworkProvider` | 出站路由是执行节点策略,客户端自报无效 | +| `executionIdentityKey` | `IdentityResolver` | 必须由服务端解析,不能用客户端传入的用户名 | +| `driverResourceKey` | `describeResource` | 由 driver 对 `CanonicalTarget` 的规范化结果决定 | +| `runtimeEpoch`、会话状态、attachment 状态 | SessionDirectory(内存) | 见 §2.2 | +| 额度、预算会计 | `BudgetCoordinator` | 组织/用户/数据库额度是服务端权威 | +| 审计事件 | `Audit` | 桌面只写本地日志;服务端审计是安全资产 | +| 登录会话行、membership(含 `role_id`)/ ACL、组织 `permissionVersion`、IdP `sub` 映射 | 认证适配器 + `PolicyService`(§3.10) | 桌面本地模式是单组织且没有登录会话行;团队模式下的身份与授权数据由服务端落库,本地 profile 只存连接配置,`external_subject` 不回传 | + +### 4.4 主密钥与团队秘密 + +| 形态 | 主密钥来源 | 连接秘密来源 | +| --- | --- | --- | +| 桌面本地模式 | `key_store::key_backend()`:默认 OS 钥匙串(`keyring::v1::Entry::new(APP_IDENTIFIER, "app-encryption-key")`);`DATAZEN_KEYRING=file` 或 macOS 未签名/adhoc 构建时用 `{appData}/.key`;旧 `.key` 会透明迁入钥匙串后删除 | 本地 `connections.json` 逐字段 AES-256-GCM(nonce 12 字节前置,base64 STANDARD) | +| 桌面团队模式 | 同上(本地设置仍需加密) | **不下载团队数据库凭据**([概要 §11 验收 2](system-overview.md#11-系统级验收));执行身份由服务端 `SecretProvider` 解析 | +| 服务端 | 不使用用户钥匙串(无用户钥匙串可访问) | KMS / 外部密钥服务;管理库只存 `secret_ref` | + +因此「团队秘密不存入本地普通 profile」有三重保证:客户端不接收秘密(只拿 `credentialConfigured` 布尔位);管理库不存秘密(只存 `secret_ref`);本地文件即使被读到也只会命中本地模式自己的凭据。`settings.json` 里的 `favoritesRoot` 这类"指向任意目录"的设置是本地偏好,不属于连接 profile,不得用于指代服务端路径。 + +## 5. Schema 版本与迁移运行器 + +### 5.1 版本策略 + +- 版本号是**单调递增整数**,语义与 [`store/app_db.rs`](../../../src-tauri/src/store/app_db.rs) 的 `SCHEMA_VERSION` 一致,但服务端用 `BIGINT` 并多存文件名与校验和。 +- 期望版本作为常量内嵌进二进制,运行器启动时读取 `SELECT COALESCE(MAX(version), 0) FROM schema_migration_history` 后比较: + +| 比较结果 | 行为 | +| --- | --- | +| 已应用 < 期望 | 顺序执行缺失版本;**不启动服务** | +| 已应用 = 期望 | 不做写操作,直接启动 | +| 已应用 > 期望 | **fail-closed**:拒绝启动,提示"数据库版本高于当前二进制",不做任何降级写 | +| 同版本但 `checksum` 不匹配 | **fail-closed**:拒绝启动(有人改过已合入的迁移文件) | + +- 首批迁移版本一次性建立 §3.2 的 13 张表(含 §3.10 的认证与授权表);这是纯 expand(只加表),不得与任何 contract 混版。文件内的建表顺序就是 §3.2 的列出顺序与 §3.3–§3.10 的 DDL 顺序,已核验满足全部外键依赖(被引用表先建,见 §3.10)。 + +### 5.2 迁移文件 + +- 位置:`server/migrations/`,随二进制内嵌(路径与加载方式见[团队服务 §13](team-server-and-auth.md#13-schema-migration-运行方式))。 +- 命名:`NNNN_snake_case_name.sql`,四位零填充序号 + 单下划线 + 语义名。序号即执行顺序,`VARCHAR` 字典序与数值序一致(例如 `0001_create_connections.sql`、`0002_add_job_claim_columns.sql`)。 +- 不可变性:**已合入的迁移文件永不修改**。写错了(例如先删字段后发现有问题)就发新版本向前修,不回头改旧文件——否则 `checksum` 校验会拒绝启动。 +- `checksum` = 规范化后文件内容的 SHA-256(统一 LF、去掉 BOM、去掉行尾空白)。`name` 列必须与文件名去掉扩展名后一致,由加载器断言。 +- 幂等:迁移文件不写 `IF NOT EXISTS`。表已存在说明环境不对,应由 `checksum` 与版本表报错,而不是静默跳过。 + +### 5.3 expand / contract 判据 + +| 类别 | 操作 | +| --- | --- | +| expand(旧代码可继续跑) | 加表、加可空列、加带默认值的列、加索引、放宽 CHECK、加新枚举值 | +| contract(必须等旧镜像下线) | 删列、删表、类型收窄、加 `NOT NULL`、删旧枚举值、收紧 CHECK、改主键 | + +规则: + +1. **禁止在同一个版本里对同一列做加与删**。开发计划的发布门槛原文是"数据库 schema 采用 expand/contract,不先删字段"([开发计划 §11 P7](../../development/platform-development-plan.md#11-p7单实例团队-web-服务))。同一版本 add + drop 会让"跑旧镜像"和"跑新镜像"两种状态在同一个部署里同时不存在或同时存在,回退时无路可走。 +2. contract 分两次发布:N 版 expand(双写:新旧列都写),N+1 或更晚的 N+k 版 contract。间隔至少跨过一次完整发布周期,且期间旧镜像已下线。 +3. 删列前必须先把该列的数据**搬到新列**(expand 阶段完成),contract 阶段只做删除。 +4. 索引创建使用 PostgreSQL 的 `CREATE INDEX CONCURRENTLY` 时必须**单独**成一个迁移文件,且不能包在事务里;这类迁移在文件名后缀标记(如 `0007_add_exec_idx_concurrently.sql`),运行器按后缀切换"事务外执行"模式。 +5. 用户业务数据库**永远不参与**本迁移体系。 + +### 5.4 触发方式 + +- `serve` 启动前自动执行([团队服务 §13](team-server-and-auth.md#13-schema-migration-运行方式)),迁移完成前不接流量,因此不存在"运行中代码遇到缺失列"的中间态。 +- 另提供显式 `migrate`(执行)与只读 `check-schema`(只比较版本与校验和,不写库),供运维在部署流水线里先跑一次。 +- 桌面本地 SQLite 侧维持现状:`init_schema()` 用 `CREATE TABLE IF NOT EXISTS` 建表([`store/app_db.rs`](../../../src-tauri/src/store/app_db.rs)),`run_migrations()` 按 `has_column` 探测逐版本升级([`store/history_db/schema.rs`](../../../src-tauri/src/store/history_db/schema.rs))。**本地不需要 expand/contract**,因为本地库与应用同版本发布、且可以整体替换;本文的 expand/contract 规则只约束服务端管理库。 +- 多实例并发启动时用 PostgreSQL advisory lock 串行化迁移;拿不到锁就等待,不并行执行。 + +### 5.5 失败与回滚 + +| 项 | 规则 | +| --- | --- | +| 单文件事务性 | 每个迁移文件在**单个事务**内执行;失败整体回滚 | +| 失败策略 | **fail-closed**:不进入"部分应用"状态,服务拒绝启动;错误写入服务日志与健康检查输出 | +| 成功记录 | 只在事务提交后写 `schema_migration_history`;因此表里不存在"应用了一半"的历史 | +| 回滚方式 | **不写反向 SQL 文件**。回退靠"回退镜像 + 向后兼容 schema"([开发计划 §11 P7](../../development/platform-development-plan.md#11-p7单实例团队-web-服务));已经发生的外部数据库写入**不能**通过回退镜像撤销 | +| 数据损坏处理 | 由运维用备份恢复管理库;恢复后按 §7 重新核验 | + +## 6. 迁移脚本要求 + +### 6.1 `dbSessionId` 不是迁移数据 + +迁移脚本**禁止**把任何会话标识、lease、cursor、`runtimeEpoch` 搬进新列。理由不是"这些字段敏感",而是它们没有物理含义:物理资源随进程终止而消失,会话目录禁止磁盘持久化、快照与 append-only 日志([连接 §12](connection-management.md#12-web多实例权限与结果))。把旧 `dbSessionId` 复制到新表,得到的是一串指向不存在资源的字符串。 + +落地要求:迁移脚本的单测在执行后对全表做 §2.5 的列名与 JSON 键断言;迁移脚本中出现 `db_session_id` 字面量即视为测试失败。 + +### 6.2 旧共享会话持久化数据标记为不可迁移 + +旧实现把连接句柄按 `connectionId` 复用(`get_session` 通过同一 id 重连,而事务 map 由命令层单独管理,这正是 CM-73 记录的基线缺陷,[连接 §16.7](connection-management.md#167-补充契约与边界用例))。因此**旧实现写下的任何"会话/句柄"字段都不是迁移数据**。三类旧数据分别这样处理: + +| 旧数据 | 处理 | 依据 | +| --- | --- | --- | +| `SyncTask.source_db_session_id` / `target_db_session_id` | 保留字段用于读旧 JSON,落库前清空(`normalize_legacy_state()`),序列化 `skip_serializing` | [`store/models.rs`](../../../src-tauri/src/store/models.rs) | +| `SyncTask.current_table_offset > 0` 或 `strategy = "continue"` | offset 归零、`strategy` 置 `unknown`、`status` 不得是 `running`/`paused` | 同上(`had_unsafe_checkpoint` 分支) | +| 服务端导入的旧 execution / job 记录 | **不自动再执行**;核验态只能落在合法取值上:`durable_executions` 记 `effect_outcome='unknown'`(`state` 仍留在 `ck_exec_state` 允许的 6 个取值内),`state='outcomeUnknown'` 只属于 `idempotency_records`(`ck_idem_state`,见 §3.5);`jobs` 没有 `effect_outcome` 列,其核验结论由同键幂等记录(`subject_kind='job'`)承担 | [连接 §13.1](connection-management.md#131-幂等键期限与响应分类) | + +"进程重启后非终态进入核验/OutcomeUnknown,不自动再执行"对本地与服务端是同一条规则。本地已有同形实现:打开 `history.sqlite` 时把 `status='running'` 的 `migration_run_history` 行更新为 `status='interrupted'`、`outcome='unknown'`、`phase='interrupted'`,并在 `rollback_outcome` 仍为默认值 `'notRequired'` 时置 `'unknown'`([`store/history_db/schema.rs`](../../../src-tauri/src/store/history_db/schema.rs) `init_schema` 的收尾 `UPDATE`)。 + +### 6.3 `configRevision` 变化后的行为 + +| 场景 | 行为 | +| --- | --- | +| profile 被 `compare_and_set` 更新 | `config_revision + 1`;PoolKey 变化,旧 idle 资源停发并关闭([连接 §9.6](connection-management.md#96-poolkey版本与缓存的生产者)) | +| 历史执行记录 | **不重写** `durable_executions.config_revision`。它记录的是"当时执行用的版本",属于审计事实 | +| 结果写回普通表 | 按 §8.3 的写回规则 1:重新授权完整对象目标 + 验证结构/可写映射/PK/version 后,用**新短租约**写回 | +| 结果写回临时对象 | 只读。临时对象不承诺跨会话存活 | +| 任务计划 | `config_revision`、`credential_revision`、能力版本或映射指纹任一变化 → `PlanStale`,不得静默继续 | +| 凭据轮换 | 只递增 `credential_revision`;已建立会话不被悄悄改配置(CM-38) | + +**禁止**用"配置版本相同"或"上下文指纹相同"去寻找"等价 session":`configRevision` / `contextRevision` 只能证明配置与上下文声明一致,不能证明物理资源连续性([连接 §4.4](connection-management.md#44-可落盘来源与运行时绑定))。丢失绑定时正确结果是新 `dbSessionId` + 重新授权,或 `SessionLost`。 + +### 6.4 一次性导入的护栏 + +| 场景 | 护栏 | 仓库先例 | +| --- | --- | --- | +| 本地 JSON → SQLite | 导入成功后**重命名**源表/源文件而不是删除;中途失败则源数据原样保留,下次启动重跑(幂等由"重命名"保证,不是标记文件) | `history_db::migrate_legacy_json` 的 `rename_aside`;收藏迁移把 `favorite_queries` 改名为 `favorite_queries_legacy_v1`([持久化存储 §1.4.1](../backend/store.md#141-从-favorite_queries-表迁移)) | +| 已归档表复活(备份恢复、版本回滚) | 迁移前先查归档表,命中即返回"无事可做",不读任何行 | 同上 | +| 确定性 id | 导入 id 由旧 id 确定性派生(收藏用 `Sha256(legacy_id)[0..10]`),保证重跑不产生副本 | 同上 | +| 本地 profile → 服务端管理库 | **显式用户动作**,不由应用自动上传;导入是"复制配置",不是"同步凭据" | §4.4 | + +### 6.5 迁移脚本的可测试性 + +每个迁移文件配一个测试夹具:旧版本 schema 的最小快照 → 执行迁移 → 断言(a)`schema_migration_history` 新增一行且 `checksum` 匹配;(b)§2.5 的三条禁持久化断言全绿;(c)该版本声明的 expand/contract 性质成立(例如 expand 版本执行后,**旧版代码路径**仍能读写该表——用前一个版本的仓储实现跑一遍最小读写)。 + +## 7. 备份与恢复边界 + +### 7.1 备份范围 + +| 类别 | 是否备份 | 说明 | +| --- | --- | --- | +| 服务端管理库(13 张表) | 是 | 组织、配置版本、执行记录、任务、审计、额度、迁移历史、登录会话与授权数据(`login_sessions` / `memberships` / `acl_entries` / `permission_versions`) | +| `ArtifactStore` 字节 | 按 TTL 与额度策略,可选 | 备份必须与 `artifact_metadata` 一起做,否则元数据会指向不存在的字节 | +| 令牌签名密钥(KMS / 配置) | 是,独立流程 | 保留期 ≥ 已发令牌全部过期时间;恢复后未知 `keyVersion` 仍必须拒绝 | +| 审计归档(超出在线保留期后卸载的部分) | 是 | append-only 卸载后仍需可追溯 | +| 桌面 `{appData}` 目录 | 按用户选择 | 包含 `.key` 时的处理见下;`history.sqlite` 明文含 SQL 与错误信息,备份视同敏感审计日志 | +| `{appData}/.key` | **不随应用数据 ZIP 打包** | 跨机恢复密文需另行备份主密钥 | + +### 7.2 恢复后必须显式失效的东西 + +| 对象 | 恢复后状态 | 原因 | +| --- | --- | --- | +| 所有数据库会话 | 全部失效,客户端重连得到 `SessionLost`,**不是**新句柄 | 物理连接随进程终止;目录不落盘 | +| 未终结 Job | 转为待核验,按 `job_checkpoints` 核验已提交边界,**不自动重放副作用阶段** | 提交未知不自动重试([概要 §11 验收 9](system-overview.md#11-系统级验收)) | +| 未终结幂等记录 | 保持记录,状态转 `outcomeUnknown`;同指纹同摘要仍返回同 receipt | CM-70 | +| attachment 令牌 | 全部失效;同 principal 无令牌重附着拒绝([连接 §16.7 CM-63](connection-management.md#167-补充契约与边界用例)) | 仅 `openSession` / 上下文替换的完整指纹与 `attachmentToken` 绑定 owner `runtimeEpoch`:owner 丢失时即使令牌未过期也返回 `SessionLost`,**不在新 owner 当作首次请求重建**([团队服务 §10.4](team-server-and-auth.md#104-幂等回执存储))。其余 attachment 路径的 TTL、detach 与竞态按 [连接 §6.4](connection-management.md#64-attachment-与超期处理) 判定,不受本行的 `runtimeEpoch` 绑定要求约束 | +| 会话目录、pool 元数据、运行时预算 | 从零开始 | 单实例自洽前提([团队服务 §10.3](team-server-and-auth.md#103-sessiondirectory-与-budgetcoordinator单实例)) | +| `close` tombstone | 丢失;恢复后对已关闭会话返回 `SessionLost` 而非重建 | tombstone 只在内存保留 | + +恢复后必须跑一次**对账**:把 `artifact_metadata` 的 `content_digest` 与对象存储/受控目录中的实际字节比对。校验不通过的产物标记为不可用,**不返回半份数据**;`deleted_at` 已置但字节仍在的,只做标记不删除(删除由 §8.2 的清理任务负责)。 + +### 7.3 不能声称的事 + +不能声称"恢复备份后活动数据库会话可恢复"——开发计划的发布门槛原文即"管理员能恢复服务元数据,活动数据库会话不声称可恢复"([开发计划 §11 P7](../../development/platform-development-plan.md#11-p7单实例团队-web-服务));不能声称 schema 回退能撤销已经发生的外部数据库写入(回退只回退服务端镜像);不能声称恢复过程会"继续"任何未完成任务(恢复后进入核验态);不能声称查询历史是隐私安全的——`history.sqlite` 明文存 SQL 与错误信息是既定事实([持久化存储 §1.3](../backend/store.md#13-查询历史明文)),备份必须按敏感审计日志对待。 + +本文也不规定 RPO / RTO 数值,这属于部署文档([团队服务 §14](team-server-and-auth.md#14-部署形态))与组织自身策略。 + +## 8. 事件与产物保留策略 + +### 8.1 事件序号与归档 + +| 机制 | 规则 | +| --- | --- | +| 事件序号 | 每条事件带 `streamId` + `sequence`;`sequence` 是 `BIGINT`,序列化为十进制字符串,**不得**经 JS `Number`([连接 §13](connection-management.md#13-错误重试和事件)) | +| 重放 | 断线重连带 `Last-Event-ID` 从**内存环形缓冲**重放([团队服务 §8.2](team-server-and-auth.md#82-last-event-id-回放));缓冲过期则客户端取快照 | +| 缺口与重复 | 缺口触发重读,重复事件忽略;`streamResetRequired` 后不再积压 | +| 队列上限 | 每订阅 256 条 / 1 MiB,先触及任一上限即停止订阅并发 `streamResetRequired`([连接 §7.7](connection-management.md#77-结果订阅与放弃消费)) | +| 归档 | 事件**不整体落库**。只按 §2.4 把白名单投影写入 `audit_events`;SSE 终态可从 repository 重建 | +| 事件体 | 不持久化 `EventEnvelope`,因此不持久化 `sessionHandle`、结果数据、SQL 原文 | + +### 8.2 产物额度与 TTL + +| 项 | 首版要求 | 取值来源 | +| --- | --- | --- | +| 每 execution 产物字节上限 | **必填**,不得为空 | 桌面默认 256 MiB,团队默认 1 GiB([连接 §7.7](connection-management.md#77-结果订阅与放弃消费)) | +| 组织总产物额度 | 必填,仍受组织额度约束 | 部署配置(`quota_counters`) | +| 未消费缓冲上限 | 8 MiB | [连接 §7.7](connection-management.md#77-结果订阅与放弃消费) | +| 无消费者等待 | 30 秒 | 同上 | +| 放弃后协议 drain deadline | 10 秒 | 同上 | +| 产物 TTL | 必填,按删除策略配置 | 部署配置;**本文不写死时长**(权威文档未给默认值),但要求:TTL 与额度是两套独立配置;`expires_at` 落库;清理任务先置 `deleted_at`(软删)再异步硬删;硬删前完成 §7.2 的对账 | +| 导出 | 导出前重做 ACL 校验;共享 SQL 文件不授予结果读取权 | [概要 §10](system-overview.md#10-安全和环境差异) | +| 服务端路径 | 不接受任意服务器绝对路径,只接受 artifact/配置引用 | 同上 | + +### 8.3 结果完整性与截断 + +| 状态 | 含义 | 落库 | +| --- | --- | --- | +| `pending` | 仍在执行;未定完整性 | 终态行的 `finished_at` 由 `ck_exec_finished` 保证非空,但该 CHECK 只绑 `state`、**不拒绝** `pending` 与终态并存(§3.4),所以这一组合由**应用层写入路径**保证不出现:写入终态行时必须显式落 `complete` 或 `truncated`,不得依赖列默认值 `'pending'`;完整性确实无法判定时落 `truncated` 并给出原因,不把不可判定伪装成 `pending`(CM-64 要求截断可见且原因落库)。本文**不**为此补 CHECK,理由见下方说明。 | +| `complete` | 完整产生 | `truncation_reason` 必须为 NULL | +| `truncated` | 有界截断 | `truncation_reason` 必须非空 | + +**B 的择一结论:不补数据库 CHECK,改由应用层写入路径保证。** 理由:`resultCompleteness` 与 `state` 的耦合不在 [连接 §4](connection-management.md#4-dto-与字段定义) 与 [§7.7](connection-management.md#77-结果订阅与放弃消费) 的权威定义内(连接只声明 `resultCompleteness: 'pending' | 'complete' | 'truncated'`,未声明终态必须非 `pending`),而 `pending` 的含义本身就含"未定完整性";在 §3.4 里新加一条只存在于本表的不变量,等于把本文的落地猜测抬成数据库约束,迫使仓储、恢复与导入路径反向服从,超出 §1「不重复定义 DTO 取值」的边界。排除该组合的责任因此落在写入路径:`DurableExecutionRecord.resultCompleteness` 是必填字段,写入终态行时必须显式赋值,并由 §6.5 的测试夹具断言"终态行 `result_completeness <> 'pending'`"。若连接后续收录"终态必须非 `pending`"的权威结论,再按 §5.3 补 CHECK——加/收紧 CHECK 属 contract,必须等旧镜像下线后单独发版。`truncation_reason` 建议取值如下——**本文建议值,待 [连接 §7.7](connection-management.md#77-结果订阅与放弃消费) 收录后方为权威**;[连接 §4](connection-management.md#4-dto-与字段定义) 只声明 `truncationReason: string | null`,故本节不是对既有 DTO 字段的重复定义或收窄,下表只是本文侧代码常量与 §3.4 的 `ck_exec_trunc` 配合的建议集合,**不是**权威枚举,取值以连接 §7.7 收录后的定义为准,届时本文只做引用;在此之前读取方(客户端、审计投影)必须按 `string` 处理 `truncationReason`,只对下表列出的值做展示映射,遇到未列出的值既不得报错也不得丢弃: + +| 建议值 | 触发条件 | +| --- | --- | +| `subscriptionQueueOverflow` | 每订阅 256 条 / 1 MiB 上限 | +| `unconsumedBufferOverflow` | 每 execution 8 MiB 未消费缓冲 | +| `noConsumerTimeout` | 无消费者等待 30 秒到期 | +| `artifactQuotaExhausted` | 产物额度或每 execution 字节上限 | +| `drainDeadlineExceeded` | 放弃后 10 秒未能 drain 完 | +| `cancelRequested` | 精确取消导致结果提前结束 | +| `producerError` | 协议/驱动侧错误导致结果不完整 | + +写回规则([连接 §4.4](connection-management.md#44-可落盘来源与运行时绑定)): + +1. 普通表结果:**重新授权完整对象目标 + 验证结构 / 可写映射 / PK / version**,通过后用新短租约写回。`writableMapping` 来自落库的 `statement_sources`(W2),但**必须重新验证**,不能直接信任历史值。 +2. 临时对象结果:只读。 +3. 截断产物不得被宣称为完整导出;客户端展示与导出入口都要读 `resultCompleteness`。 +4. 同一 `executionId` 不能被新 `dbSessionId` 接替原绑定(CM-61 断言"相同配置新 session 不能接替原绑定")。 + +## 9. 验收映射 + +用例编号与标题逐字取自 [连接 §16.7](connection-management.md#167-补充契约与边界用例)(含括号里的层标记,**不改写标题**);「首次落地阶段」取自[开发计划 §17:补充契约的阶段归属](../../development/platform-development-plan.md#17-补充契约的阶段归属),该表未单列的用例按计划中首次列出它的阶段门槛标注。本表只列**本文负责的那一层**:标 P7/P8 的行是本文为 W1 断言提供的落点,标更早阶段的行是本文为那些阶段提供的 schema / 列语义落点;两者都不得据此把整条用例计入 P7/P8 门槛——P7/P8 承接的补充契约以[开发计划 §17](../../development/platform-development-plan.md#17-补充契约的阶段归属) 的 P7/P8 行为准(当前为 CM-59、CM-63、CM-68、CM-70、CM-72 的 W1 断言),P7 的其余门槛见[开发计划 §11 P7](../../development/platform-development-plan.md#11-p7单实例团队-web-服务),服务端侧的逐条落点在[团队服务 §16](team-server-and-auth.md#16-验收映射)。 + +| ①用例([连接 §16.7](connection-management.md#167-补充契约与边界用例) 原标题) | ②首次落地阶段 | ③本文负责的层与断言落点 | +| --- | --- | --- | +| CM-05 跨用户/组织资源访问(H/W) | P1([开发计划 §5 P1](../../development/platform-development-plan.md#5-p1抽取共享应用边界和前端传输契约) 门槛含 CM-01、03~07);本文的 schema 断言随 P7 首批迁移落地 | **schema 层**:§3.1、§3.2 —— 全部主键以 `organization_id` 打头;跨组织查询在存储层查不到 | +| CM-06 前端伪造 owner(H/W) | P1(同上);schema 断言随 P7 | **schema 层**:§3.3、§3.4、§4.3 —— `owner_principal_id` / `client_instance_id` 由服务端按 `RequestContext` 填充,不接受客户端自报;`organizationId` 不是连接配置属性 | +| CM-07 目标缺失与冲突(H/D) | P0([开发计划 §4 P0](../../development/platform-development-plan.md#4-p0现状基线与契约测试夹具) 门槛含 CM-01、04、07)与 P1 | **schema 层**:§2.3 规则三、§3.3、§4.2 —— `requested_target` / `initial_namespace` 落库前归一化;不存在层级非 null、required null 在执行前拒绝 | +| CM-59 登出/权限撤销/CSRF(W) | P7/P8(§17:CM-59 的 W1 断言;[开发计划 §11 P7](../../development/platform-development-plan.md#11-p7单实例团队-web-服务) 门槛中的组织隔离与单实例认证部分) | **schema 层**:§2.1 W14/W15、§3.10、§4.3 —— 登录会话行、成员关系、固定角色与 ACL 可落库(字段语义见 [团队服务 §4.3](team-server-and-auth.md#43-服务端登录会话)、[§6.1](team-server-and-auth.md#61-角色与资源-acl)、[§6.3](team-server-and-auth.md#63-撤销传播),`external_subject` 是 IdP `sub` 唯一落库处);IdP 令牌副本与 attachment 令牌不落库;撤权靠 `permission_version` 自增使缓存与池键立即变化;删除会话行等价于强制重新登录。CSRF 拒绝与订阅/排队的时机本身不在本文 | +| CM-61 持久化白名单与临时结果恢复(H/F) | P0/P1(§17:类型与 schema 断言);P3 的 H、P4 的 F 不在本文 | **schema / 落库断言层**:§2.1、§2.2、§2.5、§3.4、§6.1、§6.2、§8.3 —— 落盘结构无 `dbSessionId`/`SessionHandle`/`resourceBindingId`/lease/cancel 句柄(§2.5 三条断言);普通表可重新授权后写、临时结果只读、同配置新 session 不能接替原绑定 | +| CM-62 命名空间规范化矩阵(H/D) | P0/P1(§17:类型与 schema 断言);P2 的 D 断言不在本文 | **schema 层**:§2.3 规则三、§3.3、§4.2 —— 缓存、权限、driver 与落库使用同一 `CanonicalTarget`;无文件路径误用 | +| CM-63 Attachment、TTL 与竞态(H/F/W1) | P3(§17:CM-61~74 的 H 断言);**P7/P8(§17:W1 断言)** | **W1 层**:§2.2 F8、§3.2「不存在的表」、§3.5 不落盘行、§7.2 attachment 行 —— `attachmentToken` 与哈希不落盘;恢复后全部失效、同 principal 无令牌重附着拒绝;仅 `openSession` / 上下文替换的指纹与令牌绑定 owner `runtimeEpoch`([团队服务 §10.4](team-server-and-auth.md#104-幂等回执存储)),TTL 与竞态取值以 [连接 §6.4](connection-management.md#64-attachment-与超期处理) 为准 | +| CM-64 无消费者、截断与有界 drain(H/D/F) | P2(§17:D 断言);P3 的 H、P4 的 F 不在本文 | **schema 层**:§8.2、§8.3 —— 缓冲与产物有界、截断可见且原因落库 | +| CM-67 PoolKey 版本和多 database(H/D) | P2(§17:D 断言);P3 的 H、P5/P6 的任务断言不在本文 | **schema 层**:§2.2 F13、§4.3、§7.2、§8.1 —— 旧缓存不回填、撤权即时生效(缓存与 `policyIsolationKey` 不落盘);恢复后会话显式失效 | +| CM-68 候选替换发布与响应丢失(H/W1/WN) | P3(§17:CM-61~74 的 H 断言);**P7/P8(§17:W1 断言)**;P9 的 WN 断言不在本文 | **W1 层(持久化投影侧)**:§3.5 落库时点与 `uq_idempotency_live_subject`、§6.3 profile CAS、§7.2 未终结幂等记录行 —— 接受记录在 SQL / Job 派发前提交;同 key 同摘要返回同 receipt;一次执行至多被一个未终结记录引用。候选 `prepared` 不可路由与提交原子性属 [团队服务 §10.3](team-server-and-auth.md#103-sessiondirectory-与-budgetcoordinator单实例),不在本文 | +| CM-70 过期幂等键与记录删除(H/W1) | P3(§17:H 断言);**P7/P8(§17:W1 断言)** | **W1 层**:§3.5、§6.2、§6.3、§7.2 —— 过期和删除后均不再执行;同 receipt、不同输入冲突;owner 重启后旧令牌 `SessionLost`;运行时 receipt / token 不落盘 | +| CM-72 错误、控制回执与能力审计(H/F/W1) | P0/P1(§17:类型与 schema 断言);P4 的 F 不在本文;**P7/P8(§17:W1 断言)** | **schema / W1 层**:§2.4、§3.4、§3.7 —— 审计包含非敏感能力版本(W4),不含 live handle 与凭据(F2/F10);`effect_outcome` 与 `state` / `result_completeness` 是各自独立的列,不被"请求成功"覆盖 | + +## 10. 与既有设计的关系与本文边界 + +- **不重复定义 DTO 与错误码取值**。`DurableExecutionRecord` 的语义来自 [连接 §4](connection-management.md#4-dto-与字段定义) 与 [§4.4](connection-management.md#44-可落盘来源与运行时绑定),错误码取值全集见 [连接 §4](connection-management.md#4-dto-与字段定义) 与 [§13](connection-management.md#13-错误重试和事件);本文只定义持久化投影、列映射与表结构。 +- **不重复定义端口**。`ProfileRepository` / `JobRepository` / `SecretProvider` / `ArtifactStore` / `BudgetCoordinator` 的方法签名以 [共享边界 §4.3](shared-boundaries-and-ports.md#43-持久化与授权端口) 与 [§4.6](shared-boundaries-and-ports.md#46-结果与事件端口) 为准,本文只给服务端实现落到哪张表。 +- **不重复定义迁移运行方式**。何时执行、失败是否启动、是否与回退镜像配合由 [团队服务 §13](team-server-and-auth.md#13-schema-migration-运行方式) 定义;本文给版本策略、文件格式、expand/contract 判据与回滚边界,二者必须一致读。 +- **沿用仓库已有先例**:`schema_migrations` / `schema_version` 版本表、`has_column` 探测式升级、legacy JSON 的 rename-aside 导入、`SyncTask` 的 `skip_serializing` 会话 id、收藏迁移的归档表护栏、`migration_run_history` 的禁列名断言。本文是把这些做法推广到服务端管理库,不是发明新机制。 +- **扩展 [持久化存储(现状)](../backend/store.md)**:那份文档描述已实现的桌面本地存储,本地加密文件格式与主密钥后端细节也在那里与 [`store/key_store.rs`](../../../src-tauri/src/store/key_store.rs);本文描述目标的服务端管理库与两者的映射,两者对同一主题的不同层次不构成冲突。 +- **扩写 [概要 §5 统一领域对象](system-overview.md#5-统一领域对象)** 的"持久化:是/否"列:本文把"是"展开成具体表与列,把"否"展开成禁持久化清单与断言;并补齐 [开发计划](../../development/platform-development-plan.md) P1 与 P7 的交付项——P1 的"所有持久化路径排除运行时句柄"由 §2.2 + §2.5 落地,P7 的"ProfileRepository / JobRepository / Audit / ArtifactStore 的服务端实现和 schema migration"由 §3、§5、§6 落地。 +- **本文不覆盖**:会话、lease、预算的**行为算法**([连接 §6](connection-management.md#6-状态机与并发) 与 [§9](connection-management.md#9-连接池预算与清理));服务端进程形态、中间件链、OIDC、CSRF、404 不可见策略与 SSE 实现细节([团队服务 §3–§9](team-server-and-auth.md#3-请求中间件链));Artifact 字节存储与出站网络策略的具体实现([团队服务 §11](team-server-and-auth.md#11-artifactstore-服务端实现)、[§12](team-server-and-auth.md#12-出站网络策略));包边界、依赖护栏与 CI 门禁([共享边界 §2](shared-boundaries-and-ports.md#2-包边界与依赖矩阵)、[§8](shared-boundaries-and-ports.md#8-依赖护栏与-ci-门禁));fake resource、故障注入与基准 harness([P0 夹具详细设计](fake-runtime-fixtures.md))。 +- **产物 TTL 的具体时长**、RPO / RTO 数值、组织级留存策略:权威文档未给出默认值,本文显式不写死常量,交由部署配置与运维策略决定。 +- 任何把 `dbSessionId` 恢复到磁盘、或让恢复出来的记录继续代表物理资源的方案。本文对此的立场是不可协商:落盘的是事实与来源,不是继续执行的资格。 diff --git a/docs/architecture/platform/shared-boundaries-and-ports.md b/docs/architecture/platform/shared-boundaries-and-ports.md new file mode 100644 index 000000000..0adcd643e --- /dev/null +++ b/docs/architecture/platform/shared-boundaries-and-ports.md @@ -0,0 +1,689 @@ +# DataZen 共享应用边界与端口详细设计 + +> 状态:目标设计,尚未实现。基线:2026-09-30,代码提交 `8592b0fe1`。本文交付的是开发契约,不代表已完成重构。 +> 代码核对时的工作区 HEAD 为 `e544e0699`(2026-09-30 21:08)。`8592b0fe1` 是 HEAD 的祖先,两者相差 4 个提交(改动仅限 `AGENTS.md`、`docs/`、`src/components/connection/navigator/`);`git diff --stat 8592b0fe1..HEAD -- Cargo.toml pnpm-workspace.yaml pnpm-lock.yaml tsconfig.json vite.config.ts package.json scripts src-tauri packages .github` 输出为空,因此本文关于 workspace 成员、前端三处接线与门禁脚本的事实对两个基线一致。 +> 读者:负责 P1「抽取共享应用边界和前端传输契约」的实现者。本文只定义包边界、端口签名、组装方式和护栏;DTO 语义、会话状态机、资源预算算法以既有文档为权威,本文不重复定义。 +> 配套:[系统概要](system-overview.md)、[连接管理详细设计](connection-management.md)、[分阶段开发计划](../../development/platform-development-plan.md)。 + +## 1. 本文定位与配套契约 + +| 本文负责 | 权威文档(不重复定义) | +| --- | --- | +| 新增包的职责、依赖方向、模块树 | [概要 §4 模块职责与边界](system-overview.md#4-模块职责与边界)、[§4.1 目标代码组织](system-overview.md#41-目标代码组织) | +| `RequestContext` 的字段来源与构造位置 | [概要 §6.1 应用上下文](system-overview.md#61-应用上下文)、[连接 §3 INV-01](connection-management.md#3-不可破坏的不变量) | +| 所有连接 DTO / 回执 / 事件形状 | [连接 §4 DTO 与字段定义](connection-management.md#4-dto-与字段定义)、[§4.1 服务接口与补充响应](connection-management.md#41-服务接口与补充响应)、[§4.2 内部记录](connection-management.md#42-内部记录) | +| 端口 trait 的 Rust 签名、错误与装配差异 | 本文 §4 | +| 会话/资源/预算的行为契约 | [连接 §6 状态机与并发](connection-management.md#6-状态机与并发)、[§9 连接池、预算与清理](connection-management.md#9-连接池预算与清理) | +| 错误码取值 | [连接 §13 错误、重试和事件](connection-management.md#13-错误重试和事件) | +| 阶段顺序与交付门槛 | [开发计划 §5 P1](../../development/platform-development-plan.md#5-p1抽取共享应用边界和前端传输契约)、[§17 补充契约的阶段归属](../../development/platform-development-plan.md#17-补充契约的阶段归属) | + +本文所有 crate 路径(`packages/application`、`packages/runtime`、`packages/platform-api`、`packages/backend-client`、`server/`)在基线提交上**均不存在**;仓库当前的 workspace 成员只有 `src-tauri`、`packages/driver-api`、`packages/ai-api`、`packages/drivers/*`(见根 [`Cargo.toml`](../../../Cargo.toml))。下文提到这些路径时一律为目标设计。 + +## 2. 包边界与依赖矩阵 + +### 2.1 四个新包的职责 + +| 包 | 一句话职责 | 拥有的概念 | 明确不拥有 | +| --- | --- | --- | --- | +| `packages/platform-api` | 无平台依赖的最底层契约:ID newtype、`RequestContext`、全部 port trait、port 错误 | ID 类型、身份上下文、端口签名 | 任何用例逻辑、任何具体实现、任何 I/O | +| `packages/application` | 用例编排:目标解析、授权顺序、DTO 校验、错误归一 | 请求/响应 DTO 的应用侧视图、`ApiError`、用例服务 | 会话状态、物理资源、驱动类型分支 | +| `packages/runtime` | 连接运行时:session actor、资源与 lease、预算记账、execution 登记、事件 | `DbSession`、`ResourceLease`、`Execution`、Job 阶段 runtime 状态 | 权限判定来源、持久化介质、网络出口 | +| `packages/backend-client` | TypeScript 侧传输无关契约:DTO 镜像、client 接口、transport 接口 | `Id`/`Counter`/`Timestamp` 镜像、`BackendClient` 接口、`PlatformServices` 接口 | Tauri/HTTP/fetch 任何具体实现 | + +### 2.2 模块树 + +```text +packages/platform-api/src/ +├── id.rs # ID newtype(含宏)、Counter、Timestamp +├── context.rs # RequestContext、OwnerRef、DelegationRef +├── error.rs # PortError +├── ports/ +│ ├── profile.rs # ProfileRepository +│ ├── policy.rs # PolicyService +│ ├── secret.rs # SecretProvider +│ ├── job.rs # JobRepository +│ ├── session_directory.rs # SessionDirectory +│ ├── token.rs # SubmissionTokenIssuer +│ ├── artifact.rs # ArtifactStore +│ ├── event.rs # EventSink +│ ├── network.rs # NetworkProvider +│ ├── budget.rs # BudgetCoordinator +│ └── identity.rs # IdentityResolver +└── lib.rs + +packages/application/src/ +├── dto/ # re-export 连接 §4 的形状,不新增字段语义 +├── error.rs # ApiError + code 常量 +├── target.rs # CanonicalTarget 计算与 targetRequirements 校验 +├── identity_policy.rs # INV-01..INV-03 的判定顺序编排 +├── sessions.rs # openSession/executeInSession/executeAtTarget/... +└── lib.rs + +packages/runtime/src/ +├── connection/ # 见 §3 +├── budget.rs # 本机多维许可(port 之后的本地实现) +├── execution.rs +├── events.rs +├── job.rs +└── lib.rs + +packages/backend-client/src/ +├── types/ # Id、Counter、Timestamp、SessionView、ExecutionView... +├── client.ts # BackendClient 接口 +├── transport.ts # BackendTransport 绑定接口、PlatformServices 接口(§7.1) +├── streams.ts # AsyncIterable 事件流抽象 +├── errors.ts # ApiError 反序列化 +└── index.ts +``` + +### 2.3 依赖方向 + +```mermaid +flowchart TD + subgraph host[Host / 装配层] + TA[src-tauri
Tauri adapter] + SRV[server
HTTP adapter] + FE[src + packages/backend-client
前端] + end + APP[packages/application] + RT[packages/runtime] + PA[packages/platform-api] + DAPI[packages/driver-api] + DOM[schema-diff / data-sync / data-transfer] + DRV[packages/drivers/*] + + TA --> APP + TA --> RT + TA --> DAPI + SRV --> APP + SRV --> RT + SRV --> DAPI + APP --> PA + APP --> DAPI + RT --> PA + RT --> DAPI + RT -.注册 JobHandler.-> DOM + DOM --> PA + DRV --> DAPI + FE -.只依赖契约.-> PA +``` + +### 2.4 禁止的反向依赖(可被门禁直接检查的断言) + +| 编号 | 断言 | 违背后果 | +| --- | --- | --- | +| F-01 | `packages/drivers/*` 的 crate 依赖闭包不含 `datazen`、`datazen-runtime`、`datazen-application`、`datazen-platform-api`、`datazen-ai-api` | 驱动反向引用宿主,Web 端与宿主无关的构建被拖垮 | +| F-02 | `packages/application`、`packages/runtime`、`packages/platform-api` 的 normal + build 依赖闭包不含任何 `tauri*` crate,且不含 `datazen` 宿主 crate | 内核无法在没有 Tauri 的进程(测试、server worker)中编译 | +| F-03 | `packages/application`、`packages/runtime` 不含 `axum`、`actix-web`、`warp`、`tonic` 等 HTTP/传输框架 | 传输策略渗入业务内核 | +| F-04 | `packages/platform-api` 不依赖 `application` / `runtime` / 领域包 / `src-tauri` | ports 与用例循环依赖 | +| 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` 字面量 | 传输无关契约被具体传输污染 | + +### 2.5 workspace 成员的目标写法 + +按根 `Cargo.toml` 现有风格(`members` 数组 + `[workspace.dependencies]` 只放被多处引用的 crate),P1 落地时新增条目形如: + +```toml +[workspace] +members = [ + "src-tauri", + "server", + "packages/ai-api", + "packages/application", + "packages/driver-api", + "packages/drivers/*", + "packages/platform-api", + "packages/runtime", +] +``` + +约束:`packages/backend-client` 是 TypeScript 包,**不得**加入 Cargo `members`(上一代码块中不出现该行即为此故)。`packages/drivers/*` 的通配与三个 `exclude`(kiwi / olap / superset)保持原样,驱动注入仍由 `scripts/resolve-drivers.mjs` 写入占位段。`[workspace.dependencies]` 中 `datazen-driver-api` 现有写法 `{ path = "packages/driver-api" }` 保持不变,新增 `datazen-application` / `datazen-runtime` / `datazen-platform-api` 同风格追加。 + +前端侧要接线的不是 pnpm workspace:当前 `pnpm-workspace.yaml` 只有 `allowBuilds:`、**没有 `packages:` 通配**,`pnpm-lock.yaml` 也只有根一个 importer,因此 `packages/*` 下的前端包并不通过 pnpm 链接进 `node_modules`(`node_modules/@datazen/*` 不存在)。既有 `packages/driver-sdk`、`packages/ui`、`packages/wapp-sdk`、`packages/extension-points` 靠三处显式配置生效,`packages/backend-client` 必须同样补齐三处: + +1. `tsconfig.json` 的 `compilerOptions.paths` 增加 `@datazen/backend-client` → `./packages/backend-client/src/index.ts`; +2. 同一文件的 `include` 数组显式加入 `packages/backend-client`(该数组当前逐个列出包目录,新增包不会自动纳入 `pnpm typecheck`); +3. `vite.config.ts` 的 `resolve.alias` 增加同一条别名(应用运行期解析与 `typecheck` 解析分开,只配 paths 会在 Vite 构建时解析不到)。 + +## 3. runtime 包内部模块 + +### 3.1 目录约定 + +拟新增模块全部落在 `packages/runtime/src/connection/`,与[连接管理 §14](connection-management.md#14-开发步骤与文件职责)的模块划分一一对应;runtime 的非连接职责(job 阶段调度、事件分发)留在 `packages/runtime/src/` 顶层,不进入 `connection/`。 + +```text +packages/runtime/src/connection/ +├── mod.rs +├── types.rs # 1 types/error +├── error.rs # 1 types/error +├── testing.rs # 2 testing/fake_resource +├── fake_resource.rs # 2 testing/fake_resource +├── budget.rs # 3 budget +├── resource.rs # 4 resource +├── registry.rs # 5 registry/actor +├── session_actor.rs # 5 registry/actor +├── execution.rs # 6 execution +├── adapters.rs # 7 adapters +├── consumers.rs # 8 consumers +└── routing.rs # 9 routing +``` + +### 3.2 九个模块 + +| # | 模块 | 职责 | 公开类型(`pub`) | 内部状态(不 `pub`) | 明确不导出 | +| --- | --- | --- | --- | --- | --- | +| 1 | `types` / `error` | newtype、DTO 镜像、枚举、schema 校验 | `DbSessionId`、`SessionHandle`、`CanonicalTarget`、`ResourceLease`、`ExecutionErrorCode`、`RuntimeError` | `NAMESPACE_FIELDS` 校验表 | 不导出任何驱动专属类型;不导出序列化格式决策(属 adapters) | +| 2 | `testing` / `fake_resource` | 假资源、状态、屏障、故障注入;可返回事务/游标句柄的假命令 | `FakeResource`、`FakeClock`、`FailureInjector`、`FakeResourceJournal` | journal 计数、屏障等待队列 | 仅在 `#[cfg(any(test, feature = "test-harness"))]` 下编译;不导出 `FakeResource` 的内部句柄结构 | +| 3 | `budget` | 本机多维许可、可取消等待、幂等核销 | `BudgetRequest`、`BudgetPermit`、`BudgetSnapshot` | 分类队列与保留额、permit 账本 | 不导出全局连接额度常量(由 `BudgetCoordinator` 提供者决定) | +| 4 | `resource` | acquire / cleanup / quarantine、隧道引用、归池前宿主检查 | `ResourceManager`、`LeaseRequest`、`CleanupReport` | 物理连接表、poolKey 索引 | 不导出驱动 `Clean` 判定;不导出物理连接句柄 | +| 5 | `registry` / `actor` | session 生命周期、串行队列、`RuntimeEpoch`、会话级句柄登记 | `SessionRegistry`、`SessionActorHandle`、`SessionView` | actor 邮箱、句柄登记表 | 不导出 `dbSessionId` 生成器(由 `SessionDirectory` 决定) | +| 6 | `execution` | 幂等、授权重校验、来源、事件发布、取消 | `ExecutionGateway`、`ExecutionReceipt`、`CancelReceipt`、`ResultProvenance` | 幂等键账本、取消句柄表 | 不导出 `policyIsolationKey` 计算(属 `PolicyService`) | +| 7 | `adapters` | IPC / BackendClient 窄适配、旧接口过渡 | `ConnectionServiceImpl`(实现[连接 §4.1](connection-management.md#41-服务接口与补充响应)的服务接口) | 旧 manager 的句柄映射表 | 不导出第二套执行逻辑;同一请求不同时经过新旧管理器 | +| 8 | `consumers` | Query / Table / metadata、三件套、Workflow 的资源消费策略 | `ResourceConsumerProfile`、各 consumer 的声明式策略 | consumer → 资源类别映射 | 不持有 UI 生命周期;不把 tab 关闭当会话关闭 | +| 9 | `routing` | worker 目录入口、全局预算入口、drain | `RuntimeRouter`、`DrainPlan` | 节点心跳、drain 中的 worker 集合 | 不把旧 session 绑定到替代 worker;不做 sticky 路由决策以外的持久化 | + +### 3.3 文件规模与拆分规则 + +- 单文件推荐不超过 800 行([AGENTS.md](../../../AGENTS.md) 约定)。`session_actor.rs`、`resource.rs`、`execution.rs` 是最可能触顶的三个文件。 +- 拆分单位是职责而非行数:状态机与它的转移表同文件,序列化与协议映射进 `adapters.rs`,两者不得互相内联。 +- 生产路径禁止裸 `unwrap()` / `expect()`;错误一律 `thiserror` + `tracing`,对外统一 `CommandError` 映射(`CommandError` 定义在 `src-tauri/src/commands/error.rs`,Tauri adapter 侧职责见 [概要 §4](system-overview.md#4-模块职责与边界))。 + +## 4. 端口 trait 草案 + +### 4.1 通用约定 + +- 所有 ID 参数使用 `platform-api` 的 newtype(`OrganizationId`、`PrincipalId`、`ConnectionId`、`DbSessionId`、`JobId`、`ExecutionId`、`ArtifactId`、`StreamId`、`RuntimeEpoch`…),**不使用裸 `String` 标识语义**。`connectionId` = 持久化配置 ID,`dbSessionId` = 运行时会话 ID,两者永不混用([ID 术语规范](../../../AGENTS.md#id-术语规范))。 +- **`RequestContext` 与全部 ID newtype 定义在 `platform-api`**,无行为、无平台依赖;`application` 与 `runtime` 用 `pub use` 再导出。这样端口签名(`platform-api`)与用例签名(`application`)共用同一份类型定义而不产生依赖倒置(否则 `platform-api` 必须反向依赖 `application`,与 F-04 冲突)。 +- 端口 trait 一律 `#[async_trait] pub trait X: Send + Sync + 'static`,对象安全,通过 `Arc` 注入。 +- 端口错误统一 `PortError`;**业务拒绝不在端口层抛出**,由用例层转 `ApiError`(码表见[连接 §13](connection-management.md#13-错误重试和事件));执行终态错误走 `ExecutionErrorCode`,与 `ApiError.code` 是两个独立命名空间。 +- 端口不做重试、不做权限判定、不做缓存。缓存与版本计算由 port 的提供方负责(例如 `SecretProvider` 负责 `credentialRevision`)。 + +**类型名的来源声明(本文集中说明一次)**:§4.3–§4.6 签名里出现的类型分两类,不得混为一谈。 + +- **复用既有文档的既有类型,形状不在本文重定义**:`RequestContext`([概要 §6.1](system-overview.md#61-应用上下文))、`OwnerRef`、`ExecutionTarget`、`SessionHandle`、`ConnectionEvent`、`IdempotentOperation`、`SubmissionToken`、`EventEnvelope`、`ExecutionErrorCode`([连接 §4](connection-management.md#4-dto-与字段定义) 与 [§4.1](connection-management.md#41-服务接口与补充响应))、`ProfileDraft`、`ProfilePatch`、`JobState`、`ArtifactChunk`([概要 §6.2 BackendClient 与领域客户端](system-overview.md#62-backendclient-与领域客户端))、`ApiError`([概要 §6.3 首版 HTTP 映射](system-overview.md#63-首版-http-映射)、[连接 §13](connection-management.md#13-错误重试和事件))。其中 `SubmissionToken.idempotencyKey` 的标量类型在本文记作 `IdempotencyKey`,指的就是该字段本身,不是新概念。 +- **本文新定义的类型**(这些名字在既有文档中或只被引用而无形体定义(如 `AuthorizationDecision`、`PortError`),或只以字段名/裸 `Counter` 出现(`delegationId`、`afterSequence`、`secretRef`、`credentialRevision`、`policyIsolationKey`、`executionIdentityKey`、`networkRouteRef`);类型定义随 P1 一起在本包建立,语义仍以既有文档为权威):§4.1 的 `PortError`;§4.3 的 `ProfileScope`/`ProfileRecord`/`ConfigRevision`/`JobDefinition`/`JobRecord`/`StageRecord`/`JobStateVersion`/`JobFilter`/`RecoveryFilter`/`Checkpoint`(形状即[连接 §4.2](connection-management.md#42-内部记录) 的 `JobCheckpoint`,端口层另起本名)/`CommitBoundary`/`JobClaim`/`WorkerId`/`AuthorizationSubject`/`AuthorizationAction`/`AuthorizationDecision`/`DelegationRef`/`DelegationGrant`/`PolicyIsolationKey`/`PolicyChangeStream`/`ExecutionIdentity`;§4.4 的 `SecretRef`/`SecretPurpose`/`ResolvedCredential`/`CredentialRevision`/`NetworkRouteRef`/`NetworkRouteRevision`/`RoutePlan`/`TunnelSpec`/`TunnelBinding`;§4.5 的 `SessionOwner`/`ReplacementCommit`/`ReplacementOutcome`/`InvalidationReason`/`CloseDisposition`/`BudgetRequest`/`BudgetPermit`/`BudgetPermitSet`/`NodeLease`/`DrainScope`/`DrainStatus`/`ReleaseOutcome`/`BudgetSnapshot`/`SubmissionPresentation`/`VerifiedSubmission`/`KeyVersion`;§4.6 的 `ArtifactSpec`/`ArtifactWriter`/`ChunkPayload`/`ChunkIndex`/`ByteRange`/`ExportSink`/`ExportReceipt`/`RevokeReason`/`EventSubscription`/`EventSequence`。它们是端口契约的私有词汇表,落盘与序列化规则各自写在其方法注释里。 + +```rust +// packages/platform-api/src/error.rs(目标设计) +#[derive(Debug, thiserror::Error)] +pub enum PortError { + #[error("backend unavailable: {0}")] BackendUnavailable(String), + #[error("compare-and-set conflict on {entity} {id}")] + CasConflict { entity: &'static str, id: String }, + #[error("not found: {0}")] NotFound(String), + #[error("token verification failed")] TokenInvalid, + #[error("provider timeout: {0}")] ProviderTimeout(String), + #[error("artifact expired")] ArtifactExpired, + #[error("quota exceeded: {0}")] QuotaExceeded(String), +} +``` + +### 4.2 端口方法语义的对齐来源 + +[概要 §6.4 repositories 与环境 ports](system-overview.md#64-repositories-与环境-ports) 已给出 10 个端口的**方法语义**(谁提供什么、什么不得序列化、什么禁止落盘)。本节的 Rust 签名是那张表的类型化草案,共 11 个 trait = §6.4 的 10 行 + 本文新增的 `IdentityResolver`(§4.3 末):§6.4 只有 10 行、最后一行是 `BudgetCoordinator`,`IdentityResolver` 不在其中(名字已见于[连接 §9.6](connection-management.md#96-poolkey版本与缓存的生产者) 的 PoolKey 维度表中 `executionIdentityKey` 一行,但 §6.4 的端口表没有它);它与 §6.4 的 `SecretProvider` 行还有一处职责再划分,见下方第三条。 + +- 签名**不得收窄** [§6.4](system-overview.md#64-repositories-与环境-ports) 列出的语义;该表没写的方法名以本文为准。 +- 端口之间允许协作(如 `ProfileRepository::disable` 后由 `PolicyService` 感知),但**不合并 trait**,以免实现方被迫实现不相关方法。 +- 执行身份解析的归属已**收口**:§6.4 曾把「resolve execution identity」写在 `SecretProvider` 行,本文改判给 `IdentityResolver`(§4.3 末),`SecretProvider` 只提供版本化材料读取与轮换。现行[概要 §6.4](system-overview.md#64-repositories-与环境-ports) 的 `SecretProvider` 行与[概要 §4](system-overview.md#4-模块职责与边界) 的 `SecretProvider` 行均已移除「执行身份解析」,并在该表后新增一行说明「本表 10 行不含 `IdentityResolver`:执行身份解析由 `IdentityResolver` 承担」;**§6.4 仍是 10 行**,没有把 `IdentityResolver` 补成第 11 行——它由本文 §4.3 末新增,与 §4.2「§6.4 的 10 行 + 本文新增的 `IdentityResolver`」的口径一致。本文的判读与[连接 §9.6](connection-management.md#96-poolkey版本与缓存的生产者)(`executionIdentityKey` → `IdentityResolver`)和 [persistence-model §4.3](persistence-model.md#43-只存在于服务端的字段) 的生产者表三方同口径。 + +### 4.3 持久化与授权端口 + +```rust +#[async_trait] +pub trait ProfileRepository: Send + Sync + 'static { + /// 以组织限定查询;越组织访问返回 NotFound 而不是 Empty(CM-05)。 + async fn list(&self, ctx: &RequestContext, scope: ProfileScope) + -> Result, PortError>; + async fn get(&self, ctx: &RequestContext, connection_id: ConnectionId) + -> Result, PortError>; + async fn create(&self, ctx: &RequestContext, draft: ProfileDraft, idem: &IdempotencyKey) + -> Result; + /// 按 expectedRevision 保存:版本不匹配返回 CasConflict,端口只承诺这一取值;它落到哪个 ApiError.code 由 server host 决定:[连接 §13](connection-management.md#13-错误重试和事件) code 表里的 TargetConflict 指命名空间**目标**冲突,与 CAS 失败无关;configRevision CAS 失败由 server host([团队服务 §9.2](team-server-and-auth.md#92-apierror--http-映射表))映射 409 ConfigRevisionMismatch,该 code 已由 §13 正式登记(触发列即本方法的 expectedRevision CAS 失败)。 + async fn compare_and_set(&self, ctx: &RequestContext, connection_id: ConnectionId, + expected: ConfigRevision, patch: ProfilePatch) -> Result; + /// 禁用后不再出现在默认列表;物理会话按[连接 §7.5](connection-management.md#75-closesession)关闭,不由本端口触发。 + async fn disable(&self, ctx: &RequestContext, connection_id: ConnectionId) + -> Result; + async fn advance_credential_revision(&self, ctx: &RequestContext, connection_id: ConnectionId) + -> Result; +} + +#[async_trait] +pub trait JobRepository: Send + Sync + 'static { + /// 先持久化后取资源([连接 §10.1](connection-management.md#101-公共处理))。成功返回即代表 Job 已受理。 + async fn accept(&self, ctx: &RequestContext, definition: JobDefinition, idem: &IdempotencyKey) + -> Result; + async fn get(&self, ctx: &RequestContext, job_id: JobId) -> Result; + async fn list(&self, ctx: &RequestContext, filter: JobFilter) + -> Result, PortError>; + async fn record_stage(&self, ctx: &RequestContext, job_id: JobId, stage: StageRecord) + -> Result<(), PortError>; + async fn record_commit_boundary(&self, ctx: &RequestContext, job_id: JobId, boundary: CommitBoundary) + -> Result<(), PortError>; + /// 状态 CAS;并发推进同一 Job 时由版本不匹配拒绝,不做覆盖写。 + async fn compare_and_set_state(&self, ctx: &RequestContext, job_id: JobId, + expected: JobStateVersion, next: JobState) -> Result; + /// claim/renew:多 worker 抢占与续约;续约失败即视为失联(Job 停止新增资源)。 + async fn claim(&self, ctx: &RequestContext, job_id: JobId, worker: WorkerId) + -> Result; + async fn renew(&self, claim: &JobClaim) -> Result; + /// 检查点写入与恢复候选查询:只返回需要人工/计划核验的待办,不自动重放副作用阶段。 + async fn save_checkpoint(&self, ctx: &RequestContext, job_id: JobId, cp: Checkpoint) + -> Result<(), PortError>; + async fn list_recoverable(&self, ctx: &RequestContext, filter: RecoveryFilter) + -> Result, PortError>; +} + +#[async_trait] +pub trait PolicyService: Send + Sync + 'static { + async fn authorize(&self, ctx: &RequestContext, subject: AuthorizationSubject, + action: AuthorizationAction) -> Result; + /// 委托校验:被委托主体、作用域与有效期三者均需匹配,失败不得降级为调用者权限(CM-06)。 + async fn verify_delegation(&self, ctx: &RequestContext, delegation: DelegationRef) + -> Result; + /// 参与 PoolKey 的策略隔离键;权限撤销必须使键立即变化。 + fn isolation_key(&self, ctx: &RequestContext) -> Result; + /// 权限版本变更通知:订阅者据此淘汰缓存键,不得轮询。 + fn subscribe_version_changes(&self) -> PolicyChangeStream; +} + +#[async_trait] +pub trait IdentityResolver: Send + Sync + 'static { + /// 产出不可伪造的 executionIdentityKey;PoolKey 只用该键,不用客户端传入用户名。 + async fn resolve(&self, ctx: &RequestContext, target: &ExecutionTarget) + -> Result; + async fn resolve_delegation(&self, ctx: &RequestContext, delegation: DelegationRef) + -> Result; +} +``` + +**端口命名来源(已统一)**:全仓统一用 `PolicyService`([概要 §4](system-overview.md#4-模块职责与边界)、[概要 §6.4](system-overview.md#64-repositories-与环境-ports))。此前只有[连接 §9.6](connection-management.md#96-poolkey版本与缓存的生产者) 把 `policyIsolationKey` 的生产者写作 `AuthorizationService`,与本节 `PolicyService::isolation_key` 是**同一职责的两种叫法**(组织/用户/策略版本与只读限制),不是两个服务——`authorize`/`verify_delegation` 也落在本节同一 trait。该处**已改写为 `PolicyService`**,`AuthorizationService` 在文档与代码中归零,也没有拆分出新服务;[persistence-model §4.3](persistence-model.md#43-只存在于服务端的字段) 的生产者表本就记在 `PolicyService` 名下,三方现为同一名字。 + +`JobRepository` 的落库白名单必须排除 `dbSessionId`、SessionHandle、lease/cursor、取消句柄与 attachment token([连接 §4.4](connection-management.md#44-可落盘来源与运行时绑定));`ExecutionView` 的持久化投影按[连接 §4](connection-management.md#4-dto-与字段定义)的两层来源规则拆分,实时 `runtimeBinding` 只留在 runtime 内存。 + +### 4.4 秘密、网络与执行材料端口 + +```rust +#[async_trait] +pub trait SecretProvider: Send + Sync + 'static { + /// 只供建连/轮换等执行路径;ResolvedCredential 不得进入任何普通查询响应,也不入日志。 + async fn resolve(&self, ctx: &RequestContext, secret_ref: SecretRef, purpose: SecretPurpose) + -> Result; + /// 读取指定版本的历史材料(回滚用),不改变当前版本。 + async fn read_versioned(&self, ctx: &RequestContext, secret_ref: SecretRef, + revision: CredentialRevision) -> Result; + async fn rotate(&self, ctx: &RequestContext, secret_ref: SecretRef, + expected: CredentialRevision) -> Result; + /// 不透明版本号;禁止用密码散列充当 PoolKey 组件。 + fn revision(&self, secret_ref: SecretRef) -> Result; +} + +#[async_trait] +pub trait NetworkProvider: Send + Sync + 'static { + /// 端点校验:出站策略检查含 DNS 解析后地址、重定向与隧道目标([概要 §10](system-overview.md#10-安全和环境差异))。 + async fn resolve_route(&self, ctx: &RequestContext, route_ref: NetworkRouteRef) + -> Result; + fn revision(&self, route_ref: NetworkRouteRef) -> Result; + /// 共享引用:同一路由/隧道被多处使用时返回同一 binding 的引用计数句柄。 + async fn ensure_tunnel(&self, ctx: &RequestContext, spec: TunnelSpec) + -> Result; + async fn release_tunnel(&self, binding: &TunnelBinding) -> Result<(), PortError>; +} +``` + +`SecretProvider` 只认 `SecretRef`,不认 `connectionId`;`connectionId` → `SecretRef` 的映射由 `ProfileRecord` 持有([连接 §4.1](connection-management.md#41-服务接口与补充响应) 的内部 `ConnectionProfile` 同列 `connectionId` 与 `secretRef`)。因此[团队服务 §10.2 `SecretProvider`](team-server-and-auth.md#102-secretprovider) 的「按 `connectionId` + `credentialRevision` 解析执行身份」在端口层拆成三步:`ProfileRepository::get` 由 `connectionId` 取 `secretRef` 与 `configRevision`,`SecretProvider::resolve(ctx, secret_ref, purpose)` 配 `revision(secret_ref)` 取材料与 `CredentialRevision`,执行身份本身由 `IdentityResolver::resolve` 产出(§4.3)。 + +### 4.5 会话目录、预算与令牌端口 + +```rust +#[async_trait] +pub trait SessionDirectory: Send + Sync + 'static { + /// dbSessionId 为随机 128 位以上;登记 owner/epoch 原子完成,碰撞重生成。 + async fn register(&self, owner: SessionOwner) -> Result; + async fn lookup(&self, db_session_id: DbSessionId) + -> Result, PortError>; + /// 只更新业务活动时间,且只在已接受的实际业务操作开始与终结时更新;登录心跳、读取状态、订阅与 attach 均不得刷新([连接 §6.4](connection-management.md#64-attachment-与超期处理)、[§9.2](connection-management.md#92-可调初始值))。 + async fn touch(&self, handle: &SessionHandle, business_activity: Timestamp) + -> Result<(), PortError>; + /// old/new/operation 状态必须原子提交:prepared 候选不可路由,committed 后旧条目改关闭路由。 + async fn commit_replacement(&self, commit: ReplacementCommit) + -> Result; + /// worker 失联/资源丢失时作废条目;调用方必须让后续请求得到 SessionLost,不得透明重建。 + async fn invalidate(&self, db_session_id: DbSessionId, reason: InvalidationReason) + -> Result<(), PortError>; + async fn release(&self, handle: &SessionHandle, disposition: CloseDisposition) + -> Result<(), PortError>; + async fn sweep_expired(&self, now: Timestamp) -> Result, PortError>; +} + +#[async_trait] +pub trait BudgetCoordinator: Send + Sync + 'static { + /// 可取消等待,acquire timeout 默认 10 秒(取值来源:[连接 §9.2 可调初始值](connection-management.md#92-可调初始值),首版配置起点而非硬编码),超时由用例转 ApiError ResourceBusy。 + async fn acquire(&self, request: BudgetRequest) -> Result; + async fn try_acquire(&self, request: BudgetRequest) + -> Result, PortError>; + /// Job 多端申请:一次预留全部许可或整体失败释放,不持有半边无限等待。 + async fn reserve_many(&self, requests: &[BudgetRequest]) + -> Result; + /// 节点额度续期;续约失败即视为失联,停止发放新资源,已有 permit 走各自正常归还。 + async fn renew_node_lease(&self, lease: &NodeLease) -> Result; + /// drain:进入排空后不再发放新 permit,等待已有 permit 归还。 + async fn drain(&self, scope: DrainScope) -> Result; + /// 幂等核销:同一 permit 重复归还不重复释放额度([连接 §9.3](connection-management.md#93-预算会计) / INV-10)。 + async fn release(&self, permit: &BudgetPermit, outcome: ReleaseOutcome) + -> Result<(), PortError>; + async fn snapshot(&self) -> Result; +} + +#[async_trait] +pub trait SubmissionTokenIssuer: Send + Sync + 'static { + /// createProfile 不绑定会话;其余运行时会话操作绑定 owner runtimeEpoch。 + async fn issue(&self, ctx: &RequestContext, operation: IdempotentOperation, + handle: Option<&SessionHandle>) -> Result; + async fn verify(&self, presented: &SubmissionPresentation) + -> Result; + fn key_version(&self) -> Result; +} +``` + +`SessionDirectory` **禁止**磁盘持久化、快照与 append-only 日志;丢失目录必须使相关会话明确失效,不得据此恢复物理会话([连接 §12](connection-management.md#12-web多实例权限与结果))。`SubmissionTokenIssuer` 的签名载荷按[连接 §4.1](connection-management.md#41-服务接口与补充响应)的 `IdempotentOperation` 覆盖,默认有效期 24 小时,过期语义见[连接 §13.1](connection-management.md#131-幂等键期限与响应分类)。 + +### 4.6 结果与事件端口 + +```rust +#[async_trait] +pub trait ArtifactStore: Send + Sync + 'static { + /// create writer:返回可写 artifact 与 writer 句柄;ACL 在 create 时绑定组织/owner。 + async fn create(&self, ctx: &RequestContext, spec: ArtifactSpec) + -> Result<(ArtifactId, ArtifactWriter), PortError>; + async fn append_chunk(&self, writer: &mut ArtifactWriter, chunk: ChunkPayload) + -> Result; + /// finalize 后才可读;未 finalize 的 artifact 不可枚举也不可导出。 + async fn finalize(&self, writer: ArtifactWriter) -> Result; + async fn read_chunk(&self, ctx: &RequestContext, artifact_id: ArtifactId, chunk_index: ChunkIndex) + -> Result; + async fn read_range(&self, ctx: &RequestContext, artifact_id: ArtifactId, range: ByteRange) + -> Result; + /// sink 是受控导出目标(对话框返回的句柄、服务端授权存储路径);不接受任意服务器绝对路径。 + async fn export(&self, ctx: &RequestContext, artifact_id: ArtifactId, sink: ExportSink) + -> Result; + /// TTL 删除:过期后读取返回 ArtifactExpired,不得以缓存副本继续服务。 + async fn delete_expired(&self, now: Timestamp) -> Result, PortError>; + async fn revoke(&self, ctx: &RequestContext, artifact_id: ArtifactId, reason: RevokeReason) + -> Result<(), PortError>; +} + +#[async_trait] +pub trait EventSink: Send + Sync + 'static { + async fn publish(&self, ctx: &RequestContext, envelope: EventEnvelope) + -> Result; + /// 服务端一半;前端一半见 §7 的 AsyncIterable 抽象。 + async fn subscribe(&self, ctx: &RequestContext, stream_id: StreamId, + after_sequence: Option) -> Result; +} +``` + +`PortError::ArtifactExpired` 是端口层取值,**HTTP 状态码映射由 server host 决定**,不构成端口契约:team-server 出于防 ID 枚举把 `ArtifactExpired` 与 `NotFound` 统一映射为 404(不返回 410,见[团队服务 §9.2](team-server-and-auth.md#92-apierror--http-映射表))。 + +### 4.7 三种交付形态的适配差异 + +| 端口 | 桌面本地 | server 单进程 | 多 worker | +| --- | --- | --- | --- | +| `ProfileRepository` | 现有 SQLite + AES-256-GCM 存储(`src-tauri/src/store/`) | 同一实现,服务端数据库 | 同一实现,服务端数据库 | +| `JobRepository` | 本地库;窗口关闭即停止任务 | 服务端库,任务独立于连接存活 | 同上 | +| `PolicyService` | 固定本地组织,全部放行 + `readOnly` 配置 | OIDC 登录会话 + membership | 同单进程 | +| `IdentityResolver` | 当前桌面登录用户 + 配置中的连接账号 | 数据库侧服务身份 + 委托 | 同单进程 | +| `SecretProvider` | 本机钥匙串主密钥(开发/`DATAZEN_KEYRING=file` 走 `{appData}/.key`) | 服务端密钥管理 | 同单进程 | +| `NetworkProvider` | 允许 `localhost`/本机出口 + SSH/代理隧道 | 出站网络策略白名单 | 按 worker 节点策略 | +| `SessionDirectory` | 进程内 `HashMap` + TTL | 同一 port 的单进程实现 | 共享内存目录 + CAS | +| `BudgetCoordinator` | 进程内许可表,总额 16([连接 §9.2](connection-management.md#92-可调初始值) 的「桌面总目标连接」,首版配置起点) | 节点额度 + 全局 permit | 全局 permit;失去续约停止新建连接 | +| `SubmissionTokenIssuer` | 进程内 HMAC 密钥 | 服务端签名密钥 + `keyVersion` 轮换 | 同单进程 | +| `ArtifactStore` | 本机目录 + 授权对话框 sink | 服务端存储 + 授权导出 | 同单进程 | +| `EventSink` | Tauri 事件推送 | SSE | SSE + worker 转发 | + +### 4.8 装配形态 + +```mermaid +flowchart LR + subgraph D[桌面] + D1[TauriAdapter] --> APP[ApplicationServices] + APP --> R[Runtime] + end + subgraph W[团队] + W1[HttpAdapter] --> APP2[ApplicationServices] + APP2 --> R2[Runtime] + end + APP --> PORTS[Arc dyn ports] + APP2 --> PORTS + PORTS --> INMEM[内存适配] + PORTS --> DIST[服务端适配] +``` + +`ApplicationServices` 与 `Runtime` 的构造函数签名对两种形态相同,只有传入的 `Arc` 实现不同;不允许为团队形态另写一套用例。 + +## 5. RequestContext 的三个构造来源 + +`RequestContext` 由 **adapter** 构造并向下传递,**任何请求体字段都不得覆盖身份**([连接 §3 INV-01](connection-management.md#3-不可破坏的不变量)、[概要 §6.1](system-overview.md#61-应用上下文))。 + +| 字段 | Tauri adapter(桌面本地) | HTTP adapter(团队) | 后台 Job / 子执行 | +| --- | --- | --- | --- | +| `organizationId` | 固定本地组织 ID(进程常量) | 认证会话解析出的组织,交叉 membership 校验 | 发起 Job 的组织,逐阶段继承 | +| `principalId` | 当前桌面登录用户 | OIDC `sub` 映射的应用用户 | 服务身份;委托子执行取被委托主体 | +| `authenticationSessionId` | `null`(本地模式无登录会话) | 服务端登录会话 ID | 服务身份时为 `null`;用户委托时为父登录会话 ID | +| `clientInstanceId` | 每次应用启动生成的实例 ID | 前端实例注册 ID,绑定到认证身份 | `null`(无客户端) | +| `requestId` | 每个 IPC 调用生成 | 每个 HTTP 请求生成(回写响应头) | Job/阶段 ID 派生的稳定 ID,便于日志串联 | +| `delegationId` | `null` | 请求显式携带且经 `PolicyService` 校验 | 父执行/Job 的委托记录 ID | + +### 5.1 三条硬约束 + +1. **身份只来自 adapter**:application/runtime 的用例函数签名以 `&RequestContext` 为第一参数,不提供任何「从 body 读 organizationId/principalId」的入口。 +2. **后台执行不依赖 GUI 上下文**:Job 阶段构造的 `RequestContext` 不携带 `clientInstanceId`,`clientInstanceId` 不是授权依据([概要 §6.1](system-overview.md#61-应用上下文))。 +3. **OwnerRef 与身份绑定**:`owner: editor` 必须在 adapter 层校验归属当前 client;`owner: job | workflowBlock` 必须校验属于已授权 Job。允许用户声称任意 job owner 违反[连接 §4 的 OwnerRef 规则](connection-management.md#4-dto-与字段定义);组织与用户绑定存于服务端记录,前端不提供覆盖入口。 + +```rust +// 目标签名:身份是前置参数,不是可覆盖字段 +pub struct OpenSessionUseCase; +impl OpenSessionUseCase { + pub async fn handle(&self, ctx: &RequestContext, req: OpenSessionRequest) + -> Result; + // req 中不含 organizationId / principalId / owner 的自由文本字段; + // owner 只能取 OwnerRef::Editor 或 OwnerRef::Job { job_id },并由 adapter 预校验。 +} +``` + +## 6. Tauri adapter 组装根 + +### 6.1 AppState 的职责拆分 + +当前 `AppState`([`src-tauri/src/commands/mod.rs`](../../../src-tauri/src/commands/mod.rs))集中持有 18 个字段。P1 后按「是否属于平台适配」二分: + +| 保留在 Tauri adapter(桌面专属) | 迁入 `packages/application` / `packages/runtime` | +| --- | --- | +| `driver_registry`(组装入口,运行时经 port 间接使用) | 连接用例(open / execute / setContext / close / attach / detach) | +| `store`、`schema_cache`、`sync_adapters`(`store` 作为 `ProfileRepository` 的桌面实现;后两者的端口 §4 未定义,暂不外移) | `ConnectionManager` 的会话生命周期与资源管理 | +| `schema_context_builder`、`prompt_resolver`(AI 组装) | `QueryExecutionRegistry` → `ExecutionGateway` 的 executionId ↔ 绑定登记 | +| `ai_registry`、`cancel_registry`(AI 组装) | `session_transactions` 的登记语义 → `SessionRegistry` 的会话级句柄登记 | +| `wapps`、`mcp_client_manager`、`workflow_scheduler`、`workflow_registry`、`workflow_history`、`monitor_*`(各自消费连接端口) | 连接配置写入(create/update/disable) | + +拆分后 `AppState` 只保留「adapter 自己的状态 + 组装好的 `Arc`」,不再直接持有会话/资源数据结构。 + +### 6.2 组装顺序 + +```mermaid +flowchart TD + A1[1 DriverRegistry] --> A6 + A2[2 持久化 Store / app_db] --> A3 + A3[3 repositories 适配
ProfileRepository / JobRepository / ArtifactStore] --> A6 + A4[4 环境适配
SecretProvider / NetworkProvider / PolicyService / IdentityResolver] --> A6 + A5[5 运行时适配
SessionDirectory / BudgetCoordinator / EventSink / SubmissionTokenIssuer] --> A6 + A6[6 Runtime
budget → resource → registry/actor → execution] --> A7 + A7[7 ApplicationServices
target → identity_policy → sessions] --> A8 + A8[8 Tauri command 层注册
窄适配保持既有 IPC 名称与载荷] --> A9 + A9[9 state.manage 既有窗口/托盘/插件] +``` + +顺序不可交换的约束:`Runtime` 先于 `ApplicationServices` 构造(后者依赖前者);`PolicyService`/`IdentityResolver` 必须先于 `Runtime`,因为池键计算依赖它们;`EventSink` 必须先于 `execution`,否则事件发布点会缺失。 + +### 6.3 保持不动的现有服务与窄适配 + +| 现有组件 | P1 处理方式 | +| --- | --- | +| `services/connection_manager.rs`(`ActiveSession`、`ConnectionManager`、`ConnectionError`) | 不删除;由 `connection/adapters.rs` 包裹为过渡实现,逐 consumer 迁移后删除旧路径 | +| `services/connection_manager/sessions.rs`、`tunnels.rs` | 同上;隧道生命周期通过 `NetworkProvider::ensure_tunnel` 的桌面实现承接 | +| `services/transaction.rs`(`TransactionHandle`) | 保持数据结构;登记语义改为写入 `SessionRegistry` 的会话级句柄表([连接 §6.5](connection-management.md#65-会话级资源句柄登记)) | +| AI(`ai_registry`、`prompt_resolver`、`schema_context_builder`、`cancel_registry`) | 不动;只把「需要连接执行」的部分改为调用 application 用例 | +| Workflow(`workflow_registry`、`workflow_history`、`workflow_scheduler`、`command_runtime.rs`) | 不动;目标解析改为调用 application 的目标解析 | +| `store/`、`cache/`(`SchemaCache`) | 不动;`store` 作为 `ProfileRepository` 的桌面实现接入,`SchemaCache` 的端口尚不在 §4 范围内 | + +## 7. BackendClient 与 PlatformServices 注入 + +### 7.1 传输无关契约 + +```typescript +// packages/backend-client/src/transport.ts(目标设计) +export interface BackendTransport { + call(method: K, payload: MethodMap[K]['request']): + Promise; + /** 流传输适配为 AsyncIterable;结束迭代只取消订阅。 */ + subscribe?(method: 'subscribeEvents', payload: SubscribeEventsRequest): + AsyncIterable>; + cancel?(opaque: string, reason: string): Promise; +} + +export interface PlatformServices { + saveTextWithDialog(input: SaveTextInput): Promise; + saveBinaryWithDialog(input: SaveBinaryInput): Promise; + openTextWithDialog(input: OpenTextInput): Promise; + openDirectoryWithDialog(input: OpenDirectoryInput): Promise; + writeClipboard(text: string): Promise; + readClipboard(): Promise; +} +``` + +契约只声明方法与载荷形状,不含 `@tauri-apps/` 前缀、`fetch`、`XMLHttpRequest` 任何字面量(门禁 F-07)。业务方法清单与输入输出以 [概要 §6.2 BackendClient 与领域客户端](system-overview.md#62-backendclient-与领域客户端) 的表为权威,本文不重复列举;`backendId` 只用于选择门面,不作为业务方法参数,也不进入 `ExecutionTarget`。`PlatformServices` 沿用[概要 §4 模块职责与边界](system-overview.md#4-模块职责与边界)的能力划分口径(文件选择/上传/下载、窗口、剪贴板),名称与 §2.1 保持一致,不另立 `PlatformBridge` 之类的别名。 + +### 7.2 替换 invoke / Channel 的范围 + +基线上直接引用 Tauri 传输的 SDK 文件([`packages/driver-sdk/src/ipc/driverCommands.ts`](../../../packages/driver-sdk/src/ipc/driverCommands.ts) 首行 `import { Channel, invoke } from '@tauri-apps/api/core';`;同目录 `schemaClient.ts`、`fileCommands.ts` 同形): + +| 现状 | P1 目标 | +| --- | --- | +| `driverCommands.executeStream` 用 `new Channel()` + `invoke('execute_driver_command_stream', { onEvent })` | 改为调用已注入 transport 的 `executeDriverCommandStream`,`onEvent` 回调由 AsyncIterable 适配层提供 | +| `driverCommands.execute` / `schemaClient.*` 直接 `invoke` | 改为 transport `call`;签名与返回 `{ data }` 解包保持不变 | +| `fileCommands.*` 直接 `invoke` | 改由 `PlatformServices` 提供,桌面实现委托 `tauri-plugin-dialog` | +| `confirmDialogBridge` 的 `bindConfirmDialog` 绑定模式 | 保留该模式并扩展为 `PlatformServices` 的同类绑定,作为未注入时报错的样板 | + +薄再导出过渡:`src/commands/*.ts` 现有 Tauri IPC 封装继续导出同名同签名函数,内部改为转发到已注入 transport;调用方不改 import,后续阶段再逐步改为直接依赖 `backend-client`。 + +### 7.3 backend 选择与注入时机 + +- backend 面板在应用启动阶段决定 backend 集合(桌面本地 / 团队远端),每个 backend 生成一个 `BackendClient` 门面实例。 +- 注入时机:应用入口在首屏渲染前完成 `setBackendClient(backendId, client)` 与 `setPlatformServices(services)`;之后 `backendId` 变更只切换门面引用,不重建注入。 +- 事件流:门面内部持有各自的订阅句柄,切换 backend 时对旧门面 `return()` 结束迭代;结束迭代只取消订阅,**不触发 `cancelExecution`**。 + +### 7.4 未注入时的显式错误 + +未注入即调用必须抛出可定位错误,不允许静默回退到 Tauri 直连。样板(沿用 [`packages/driver-sdk/src/confirmDialogBridge.ts`](../../../packages/driver-sdk/src/confirmDialogBridge.ts) 现有 `throw new Error('ConfirmDialog has not been bound to driver-sdk yet.')` 的形状): + +```typescript +export function useBackendClient(): BackendClient { + const client = boundClient; + if (!client) { + throw new Error('BackendClient has not been bound; check that the platform adapter ran.'); + } + return client; +} +``` + +## 8. 依赖护栏与 CI 门禁 + +### 8.1 前端侧:复用现有护栏的字符串扫描 + +`scripts/check-module-layers.mjs` 的 `LAYER_RULES`(`{name, from, forbidden[], forbiddenPackages[]}`,对已解析路径与裸包名同时做 `String.prototype.startsWith` 前缀匹配,无通配语义,配合 `scripts/lib/scanSourceCode.mjs` 与 `scripts/lib/scanTargets.mjs` 的 `SCAN_EXTENSIONS`/`SKIP_DIR_NAMES`/`isSkippedPath`/`readScannedIfPresent`)直接承载两条新规则: + +| 规则名 | from | forbiddenPackages | +| --- | --- | --- | +| `backend-client-transport-agnostic` | `packages/backend-client/src` | `@tauri-apps/` | +| `driver-sdk-no-direct-tauri` | `packages/driver-sdk/src` | `@tauri-apps/` | + +`forbiddenPackages` 必须写成 `@tauri-apps/` 而非 `@tauri-apps/*`:脚本只做前缀匹配,写成带星号的值将永远匹配不到 `@tauri-apps/api/core`。现有 `packages/ui` 规则使用的正是 `'@tauri-apps/'`,此处沿用同一写法,新增官方插件子包时也无需再改脚本。 + +driver-sdk 现有 3 个 `ipc/*.ts` 文件会命中第二条,因此该规则在 P1 落地时必须与 `packages/driver-sdk` 的迁移在同一 PR 内完成,或按 `check-driver-import-boundaries.mjs` 的 `ALLOWLIST` 精确三元组(规则 + 文件 + 说明符,带原因与到期报告)临时挂起——**禁止目录级或通配级豁免**。 + +### 8.2 Rust 侧:新增依赖闭包检查(目标设计,脚本尚未创建) + +Rust 依赖不能用字符串扫描判定,必须解析依赖图。新脚本 `scripts/check-rust-dependency-boundaries.mjs`(**拟新增,基线提交上不存在**)读取 `cargo metadata --format-version 1` 的 `resolve.nodes`,对 §2.4 的 F-01~F-06 做 normal + build 双向闭包判定: + +```mermaid +flowchart LR + M[cargo metadata] --> N[resolve.nodes] + N --> C1[F-01 驱动→宿主] + N --> C2[F-02 内核无 tauri*] + N --> C3[F-03 内核无 HTTP 框架] + N --> C4[F-04 platform-api 无上层] + N --> C6[F-06 server 独立] + C1 & C2 & C3 & C4 & C6 --> R[violations + exit 1] +``` + +与既有脚本的复用关系: + +| 维度 | 与 `check-*.mjs` 现有护栏 | +| --- | --- | +| 数据来源 | **不同**:本次解析 `cargo metadata` 的依赖图,不扫描源码字符串 | +| CLI 形态 | 相同:`--root=

` 选项、违规打印 `rule: file/dep`、exit 1 | +| 白名单 | 相同思路:`ALLOWLIST` 精确三元组 + 到期报告,禁止目录级豁免 | +| 规则分级 | 相同:blocking 与 advisory 分开,advisory 不阻断 | +| 单测 | 相同落点:`scripts/__tests__/check-rust-dependency-boundaries.test.mjs`,用内联虚拟 `cargo metadata` JSON 夹具,不在单测里真跑 cargo | + +### 8.3 CI 阻断方式 + +`.github/workflows/ci.yml` 当前把 8 条严格守卫合并在 frontend job 的一步里(`check-managed-stubs` / `check-structure-editor-guardrails` / `pnpm test:ids` / `pnpm test:layers` / `pnpm test:ci-docs` / `pnpm test:version` / `pnpm test:boundaries` / `pnpm test:i18n-keys`),任一失败即 fail-fast;聚合 job `ci` 是 `needs: [frontend, rust]` 的唯一 required status check。新门禁按数据来源分两处接入: + +| 门禁 | 接入位置 | 理由 | +| --- | --- | --- | +| `test:layers`(**在既有 `pnpm test:layers` 脚本内新增两条规则**,脚本本身已存在) | frontend job | 纯 Node 字符串扫描,无需 cargo | +| `test:deps`(**拟新增**:`package.json` 当前没有 `test:deps`,P1 落地时新增该 script 指向 `scripts/check-rust-dependency-boundaries.mjs`) | rust job,**在 "Resolve drivers (basic)" 之后** | 驱动 crate 由 `scripts/resolve-drivers.mjs` 注入为 feature,注入前跑会漏掉驱动依赖边 | +| `cargo build -p datazen-server`(**目标 crate**,`server/` 目录在基线上不存在) | rust job,紧随 `test:deps` | 依赖图干净不等于能构建 | + +任一失败 → 对应 job 失败 → 聚合 job `ci` 失败 → 分支保护的 required check 阻断合并。`scripts/run-regression.sh` 的本地全量门禁同步追加同一条命令,位置与现有 ① 边界护栏之后一致。 + +## 9. P1 退出标准与回退边界 + +### 9.1 退出标准(与[开发计划 §5 P1](../../development/platform-development-plan.md#5-p1抽取共享应用边界和前端传输契约)一致) + +下表「可验证方式」列中的 `datazen-platform-api` / `datazen-application` / `datazen-runtime` / `datazen-server` 四个 crate 与 `pnpm test:deps` 脚本在基线提交上均不存在,属 P1 随包创建的目标产物(与[开发计划 §15.1](../../development/platform-development-plan.md#151-当前已有命令)「不把未创建命令列为现有脚本」一致);`pnpm test:layers`、`pnpm test:unit`、`npx vitest run`、`cargo test -p datazen --lib` 为基线已有命令。 + +| 编号 | 验收项 | 可验证方式 | +| --- | --- | --- | +| E-1 | CM-01(ID 类型与序列化)、CM-03 会话创建幂等、CM-04 禁止配置 ID 回退、CM-05 跨用户/组织访问、CM-06 前端伪造 owner、CM-07 目标缺失与冲突的类型与 schema 断言 | `cargo test -p datazen-platform-api`、`cargo test -p datazen-application`;断言位置按[连接 §17 的类型/身份/目标组](connection-management.md#17-验收标准与证据) | +| E-2 | DTO 往返:`SessionView`、`ExecutionView`、`ContextChangeReceipt`、`CloseReceipt`、`CancelReceipt`、`OpenSessionReceipt` 经 adapter 序列化后反序列化字段等价 | 双向 round-trip 单测,含 64 位 Counter 以十进制字符串往返 | +| E-3 | 内核无 Tauri 编译:`packages/application`/`runtime`/`platform-api` 在没有 Tauri 的测试进程中可用 | `cargo test -p datazen-runtime --lib` 通过,且 F-02 门禁全绿 | +| E-4 | CI 依赖图检查通过 | `pnpm test:deps`(拟新增)与 `pnpm test:layers` 在 CI 中 exit 0 | +| E-5 | 旧桌面基本连接/查询流程保持可用 | 现有 `cargo test -p datazen --lib` 与 `npx vitest run` 不回归;基本连接 + 查询的人工旅程可用 | +| E-6 | RequestContext 三来源可验证 | 用例签名不接受身份字段的编译期断言 + 三来源字段装配单测 | +| E-7 | 未注入即调用报明确错误 | 前端单测覆盖 `backend-client` 与 `PlatformServices` 两条未绑定路径 | + +### 9.2 回退条件与回退方式 + +| 触发条件 | 回退动作 | 禁止动作 | +| --- | --- | --- | +| E-5 回归且无法在 P1 窗口内修复 | Tauri adapter 回退为直接调用原用例实现,旧 `ConnectionManager` 继续承担全部连接路径 | 不得把新 owner 语义伪映射为旧的共享 session | +| 新 `BackendClient` 注入导致既有前端链路不可用 | 还原 `src/commands/*.ts` 的 Tauri 直连实现,`backend-client` 包保留但不注入 | 不得保留「部分调用点走新桥、部分走旧 IPC」的混合态 | +| `test:deps` 误报真实违规(例如 `tauri-plugin-dialog` 经 `src-tauri` 传递进入 runtime 闭包) | 以 `ALLOWLIST` 精确三元组记录原因与归属里程碑,**并在同一 PR 修正依赖方向** | 不得用 advisory 降级掩盖实际违规 | +| 依赖图门禁在 CI 中不稳定(metadata 解析随注入变化而漂移) | 固定检查时机(resolve-drivers 之后)并把期望快照纳入脚本夹具 | 不得把门禁改成永不失败的空转 | + +## 与既有设计的关系 + +- **补充系统概要**:本文把 [概要 §4.1 目标代码组织](system-overview.md#41-目标代码组织) 列出的新建目录中属于本文边界的四个包(`platform-api` / `application` / `runtime` / `backend-client`)落到「谁拥有哪个类型、哪些反向依赖被禁止、workspace 成员怎么写」;把 §6.1 的 `RequestContext` 六字段落到三种 adapter 的逐字段取值来源;把 §6.2 的 BackendClient 落到 TS 传输无关接口与注入时机。§4.1 同时列出的 `schema-diff` / `data-sync` / `data-transfer` / `themes` / `server` 不在本文范围内。概要未涉及的端口 trait 签名、装配顺序、文件规模拆分规则由本文新增。 +- **补充连接管理**:本文不重复 [连接 §4](connection-management.md#4-dto-与字段定义) 的任何 DTO 字段与 [§13](connection-management.md#13-错误重试和事件) 的任何错误码,只说明这些形状被哪些端口消费;把 §12 的 SessionDirectory 与 §9 的 BudgetCoordinator 从「行为描述」落成「Rust trait 签名与装配差异」;把 §14 的九个 runtime 模块补上公开类型、内部状态与不导出项。 +- **不重叠的边界**:连接管理不规定 crate 依赖方向与 CI 脚本形态;系统概要不规定端口签名;开发计划只规定阶段与门槛,不规定包内文件划分。三者本文均不复述。 + +## 本文不覆盖什么 + +- 驱动固定资源契约、namespaceShape 与操作级 targetRequirements 的具体字段(P2)。 +- 会话与 Lease 的状态机转移、资源清理判定、PoolKey 各字段的生产者细则([连接管理 §6](connection-management.md#6-状态机与并发)、[§9.4](connection-management.md#94-归池前检查)、[§9.6](connection-management.md#96-poolkey版本与缓存的生产者))。 +- 多 worker 路由、原子 owner 登记、分区与全局预算的验收([连接管理 §12](connection-management.md#12-web多实例权限与结果),阶段归属 P9)。 +- HTTP 路由、认证协议、CSRF、SSE 重连细节([概要 §10](system-overview.md#10-安全和环境差异)给出要求,细则在 P7)。 +- 迁移三件套、Workflow、AI、MCP、Wapp 的内部重构计划。 +- 任何阶段排期、人员安排或进度记录。 diff --git a/docs/architecture/platform/system-overview.md b/docs/architecture/platform/system-overview.md index 3a4d30191..f3c93ed51 100644 --- a/docs/architecture/platform/system-overview.md +++ b/docs/architecture/platform/system-overview.md @@ -70,7 +70,8 @@ flowchart TD | Application services | 用例、目标解析、策略编排 | 业务请求、Execution/Job 引用 | 不引用 Tauri、HTTP 框架或 React | | PolicyService | connection/session/job/result 授权 | AuthorizationDecision | 不通过前端角色标记授予权限 | | ProfileRepository | 连接配置、版本、共享范围 | ConnectionProfile | 不保存 runtime dbSessionId | -| SecretProvider | 解密/取凭据、轮换、短期执行材料 | secretRef + 执行身份 | 不把密码返回到普通查询响应 | +| SecretProvider | 解密/取凭据、轮换、短期执行材料 | secretRef | 不把密码返回到普通查询响应 | +| IdentityResolver | 解析并签发执行身份(`executionIdentityKey`) | 组织/用户、连接上下文 | 不读取或产出凭据本身 | | DriverRegistry | 描述、协议检查、factory 注册 | descriptor、provider | 不按数据库名称拼 SQL | | ExecutionGateway | 参数/能力/权限校验、执行登记、取消、来源 | ExecutionReceipt、事件 | 不创建隐式共享会话 | | SessionRegistry | owner、状态机、串行队列、上下文 revision、会话级句柄登记 | SessionView | 不用 connectionId 代替 session 身份 | @@ -247,7 +248,7 @@ interface ArtifactChunk { | --- | --- | | ProfileRepository | get/list、按 expectedRevision 保存、禁用;以组织限定查询 | | PolicyService | authorize action/resource、委托校验、权限版本变更通知 | -| SecretProvider | resolve execution identity、读取版本化材料、轮换;材料不序列化到 API | +| SecretProvider | 读取版本化秘密材料(`resolve(SecretRef, SecretPurpose)`)与轮换(`revision(SecretRef)`);材料不序列化到 API | | JobRepository | create、状态 CAS、claim/renew、检查点、恢复待核验任务 | | SessionDirectory | 内存 register/get、owner epoch CAS、原子替换提交、invalidate;禁止落盘 | | SubmissionTokenIssuer | 签名、验证作用域/有效期/owner epoch;未知版本拒绝 | @@ -256,6 +257,8 @@ interface ArtifactChunk { | NetworkProvider | 按路由策略建立直连/隧道、端点校验、共享引用 | | BudgetCoordinator | 申请节点额度、续期、drain;失联时停止新增资源 | +本表 10 行不含 `IdentityResolver`(它已在 §4 模块职责表中引入):执行身份解析(`executionIdentityKey` 的生产者)由 `IdentityResolver` 承担,`SecretProvider` 只负责版本化秘密材料的读取与轮换;边界见共享边界详细设计。 + Desktop adapters 使用现有 Store/keychain/file 管理能力逐步适配;Server adapters 使用服务端元数据数据库、SecretProvider 与 ArtifactStore。第一版推荐 PostgreSQL 管理组织、配置版本、任务、审计和额度,不与用户业务数据库混用。SSE 用于单向状态通知,取消走 HTTP;有双向交互需要时再引入 WebSocket。 ## 7. 核心交互 diff --git a/docs/architecture/platform/team-server-and-auth.md b/docs/architecture/platform/team-server-and-auth.md new file mode 100644 index 000000000..f057500e6 --- /dev/null +++ b/docs/architecture/platform/team-server-and-auth.md @@ -0,0 +1,796 @@ +# DataZen 团队 Web 服务:Server Host、认证授权与安全设计 + +> 状态:目标设计,尚未实现。基线:2026-09-30,代码提交 `8592b0fe1`;代码核对时的工作区 HEAD 为 `e544e0699`(2026-09-30 21:08),`8592b0fe1` 是其祖先,两者相差 4 个提交,而 `git diff --stat 8592b0fe1..HEAD -- server packages/application packages/runtime packages/platform-api` 输出为空(这些目录均未创建),因此本文结论对两个基线一致。本文描述 P7「单实例团队 Web 服务」在 `server/` 目录中的落地形态;截至该基线,仓库中**尚不存在 `server/` 目录**,`packages/application`、`packages/runtime`、`packages/platform-api` 也仍是目标目录。因此本文是实施级目标设计,不是已实现事实的记录。 +> 读者:负责《分阶段开发计划》P7 的实现者。本文定义**怎么组、怎么鉴权、怎么限流、怎么映射错误**;连接 DTO 与错误码表以[连接与会话管理](connection-management.md)为权威,管理库表结构以[持久化模型](persistence-model.md)为权威,出现时一律以链接为准。 +> 配套:[系统概要设计](system-overview.md)(分层、领域对象、v1 HTTP 路由表、安全总纲)、[连接与会话管理](connection-management.md)(DTO、错误表、幂等、CM-01~74)、[持久化模型](persistence-model.md)(管理库表结构与迁移策略)、[共享应用边界与端口](shared-boundaries-and-ports.md)(端口签名与装配差异)、[P0 runtime fake 夹具](fake-runtime-fixtures.md)(transport-neutral fake 资源、故障/竞态注入与 CM-60 基准 harness)、[分阶段开发计划 §11 P7](../../development/platform-development-plan.md#11-p7单实例团队-web-服务)(阶段交付与退出门槛)。 + +--- + +## 1. 本文解决的问题与不变量 + +[系统概要设计 §6.3](system-overview.md#63-首版-http-映射) 给出的是 v1 路由表与状态码概要,[§10](system-overview.md#10-安全和环境差异) 给出的是五段安全总纲;二者都未回答「一个请求依次经过哪些判定」「撤销权限后正在跑的 SQL 会怎样」「SSE 断线回放到哪里为止」。本文把这些判定落成可实施的中间件链、数据模型与运行规则。以下不变量全程适用(编号是本文局部命名空间 `INV-S1`~`INV-S8`;共享契约的唯一命名空间是[连接 §3](connection-management.md#3-不可破坏的不变量) 的 `INV-01`~`INV-12`,其中 `INV-S1` 与 `INV-01` 同一口径): + +| 编号 | 不变量 | 本文落点 | +| --- | --- | --- | +| INV-S1 | 身份只来自适配器构造的 `RequestContext`,请求体中的 `organizationId`/`principalId`/角色标记永不生效 | [§6.5](#65-前端角色标记禁令) | +| INV-S2 | 权限与登录态在长连接上持续复核,不能只在握手鉴权一次 | [§8.4](#84-权限复核与登录过期) | +| INV-S3 | 无法区分「无权限」与「不存在」的资源一律 404 | [§7](#7-不可见资源的统一-404) | +| INV-S4 | 权限撤销后,排队请求不执行;已执行的操作按**实际取消结果**记录,不假设回滚 | [§6.3](#63-撤销传播) | +| INV-S5 | SSE 断开只解除订阅,不直接取消正在执行的 SQL | [§8.5](#85-断开与取消的边界) | +| INV-S6 | 服务端响应不回传数据库凭据、runtime 句柄与可解密材料 | [§10.2](#102-secretprovider) | +| INV-S7 | `ApiError.code`(请求被拒绝)与 `ExecutionErrorCode`(执行终态)是两个命名空间 | [§9.4](#94-apierrorcode-与-executionerrorcode) | +| INV-S8 | `server` crate 的依赖闭包不含 Tauri crate 与 UI runtime | [§2.2](#22-cargo-依赖方向与禁止闭包) | + +--- + +## 2. `server` crate 形态与 composition root + +### 2.1 目标目录与模块树 + +`server` 是 Cargo workspace 的新成员(crate 名 `datazen-server`,目录 `server/`,两者目前都不存在),与 `src-tauri` 平级、互不依赖。目标结构: + +```text +server/ +├── Cargo.toml +├── migrations/ 展开/收缩 SQL,按 version 单调应用(版本表见持久化模型 §3.9) +└── src/ + ├── main.rs 进程入口:子命令解析 → bootstrap(对齐 src-tauri/src/main.rs 的 headless 形态) + ├── bootstrap.rs 唯一 composition root;migration.rs 是 migrate / check-schema 子命令入口 + ├── config.rs / logging.rs ServerConfig 校验(不回显凭据)、tracing 与 log_redact 接入 + ├── http/ + │ ├── router.rs v1 路由表(与 system-overview §6.3 一一对应) + │ ├── middleware/ request_id / auth / session / csrf / limit / authorize / error / audit + │ ├── extract.rs 路径/查询/JSON 提取,统一 InvalidArgument + │ ├── response.rs ApiError → HTTP 状态码 + │ ├── sse.rs 事件流、心跳、回放 + │ └── handlers/ auth.rs profile.rs session.rs execution.rs job.rs artifact.rs event.rs health.rs + ├── auth/ oidc.rs(发现/JWKS/token 交换)、session.rs(登录会话表)、claims.rs(id_token 校验) + ├── policy/ rbac.rs、acl.rs、version.rs(权限版本与失效广播)、delegation.rs(delegationId) + ├── csrf.rs / ratelimit.rs CSRF 校验与令牌桶限流 + ├── network_policy.rs 出站地址校验 + ├── adapters/ 服务端 ports 实现,见 §10 + └── tests/ 身份/权限/CSRF/SSE/Artifact 集成测试 +``` + +沿用现有仓库的模块纪律:单文件不超过 800 行、生产路径不出现裸 `unwrap()`/`expect()`、错误类型以 `thiserror` 分层、观测统一走 `tracing`。现有 `src-tauri/src/commands/error.rs` 的 `CommandError` 是 **IPC 层**错误,server 侧对应物是 `ApiError`,两者不共用类型(见 [§9](#9-请求限制与错误映射))。 + +### 2.2 Cargo 依赖方向与禁止闭包 + +`server` 允许的依赖方向: + +```text +server ──→ packages/application, packages/runtime + ├──→ packages/platform-api(SessionDirectory / BudgetCoordinator 等 ports) + ├──→ packages/driver-api、packages/ai-api;异构三件套经 packages/runtime 暴露的 port 访问宿主内 src-tauri/src/{schema_diff,data_sync,data_transfer} + └──→ HTTP 框架、tokio、管理库客户端、序列化 + +application/runtime ──→ platform-api / driver-api;driver ──→ driver-api +``` + +`server` **禁止**出现在依赖闭包中的一切:任何 `tauri*` crate(含 `tauri-plugin-*`)、`wry`/`tao`、桌面 host crate `datazen`(`src-tauri` 的 package 名)、任何前端 UI runtime。 + +这条约束由 CI 执行,而非靠评审自觉: + +```bash +# 依赖闭包检查:命中任一禁用 crate 即失败 +cargo tree -p datazen-server --edges normal,build -e normal,build,no-dev --prefix none \ + | awk -F'"' '{print $2}' | sort -u > /tmp/server-deps.txt +node scripts/check-server-deps.mjs /tmp/server-deps.txt # 目标脚本,随 server crate 一并新增 + +# 独立构建:不开 Tauri 相关 feature 也能通过 +cargo build -p datazen-server --release && cargo test -p datazen-server +``` + +`scripts/check-module-layers.mjs`(`pnpm test:layers`)与 `scripts/check-driver-import-boundaries.mjs`(`pnpm test:boundaries`)是仓库已有的边界检查,server 的依赖闭包检查脚本与它们并列新增到 CI,不塞进这两个脚本的既有职责。管理库 DDL 见 [持久化模型](persistence-model.md)。 + +### 2.3 组装顺序 + +`bootstrap.rs` 是唯一的组装点,任何模块都不得在其之外隐式构造全局单例: + +```mermaid +flowchart TD + A["解析子命令 serve | migrate | check-schema"] --> B["logging::init + log_redact 挂载"] + B --> C["config::load 加载并校验 ServerConfig"] + C --> D["rustls CryptoProvider 选择(进程内,与桌面 tls.rs 同源但独立初始化)"] + D --> E["migration::ensure(版本高于或低于期望均拒绝启动)"] + E --> F["连接管理库(组织/配置/任务/审计/额度)"] + F --> G["SecretProvider(KMS/外部密钥管理适配)"] + G --> H["DriverRegistry(inventory 注册,随构建期驱动选型)"] + H --> I["BudgetCoordinator(单实例)与 SessionDirectory(单进程内存)"] + I --> J["SessionRegistry / ExecutionGateway / JobRuntime / ResourceManager"] + J --> K["ArtifactStore(字节存储 + 元数据)"] + K --> L["PolicyService(ACL/角色/权限版本)"] + L --> M["http::router 装配中间件链与 handler"] + M --> N["监听 :8443,暴露 /healthz、/readyz 与 /api/v1"] +``` + +失败策略是 **fail-closed**:任一步失败即拒绝启动,错误可诊断但不泄密;`tracing` 与凭据脱敏复用 `src-tauri/src/log_redact.rs` 的口径(默认不打印密码、令牌、原始数据库错误与用户绝对路径)。 + +### 2.4 进程模式 + +沿用 `src-tauri/src/main.rs` 的「一个二进制、按参数选择模式」形态,`server` 首版提供三个子命令: + +| 子命令 | 用途 | 是否监听端口 | +| --- | --- | --- | +| `serve` | 正常服务:HTTP + Job 轮询 + 执行 worker,全部在同一进程 | 是 | +| `migrate` | 只跑 expand/contract 迁移后退出 | 否 | +| `check-schema` | 只读校验当前 schema 版本与代码期望是否一致 | 否 | + +首版**单实例**:一个进程同时承担 API、admin service 与 execution worker。进程退出即失去全部内存态 `SessionDirectory` 与会话回执;这是 P7 明确接受的限制,也是 P9 引入多 worker 的起点。 + +--- + +## 3. 请求中间件链 + +### 3.1 进入顺序 + +```mermaid +sequenceDiagram + participant B as 浏览器 + participant L0 as L0 连接/请求ID + participant L1 as L1 请求体字节上限 + participant L2 as L2 认证 + participant L3 as L3 会话解析 + participant L4 as L4 CSRF + participant L5 as L5 体量与速率 + participant L6 as L6 授权 + participant H as Handler + participant E as 统一错误映射 + participant A as 审计 + + B->>L0: HTTP 请求 + L0->>L0: 分配/透传 requestId,注入 trace + L0->>L1: 传输层字节闸(Content-Length + 流式) + L1-->>L0: 413 请求体超限 + L0->>L2: 解析登录会话 cookie + L2-->>L0: 401 未认证(无/过期/已撤销) + L0->>L3: 解析 SessionHandle + runtimeEpoch + L3-->>L0: 409 SessionNotFound / SessionLost + L0->>L4: 校验 CSRF 令牌 + L4-->>L0: 403 跨站请求被拒 + L0->>L5: 反序列化 + 分页/取块语义上限 + L5-->>L0: 400 参数错误 / 429 超限 + L0->>L6: PolicyService.authorize + L6-->>L0: 404 不可见 / 403 无权限 + L0->>H: 已鉴权 RequestContext + H-->>E: 结果或 ApiError + E-->>A: 统一错误映射 + 审计落库 + E-->>B: HTTP 响应(含 requestId) +``` + +真实顺序是:连接接入 → 请求 ID → 路由匹配(L7,图上并入 handler 节点)→ 中间件链(字节上限 L1 → 认证 → 会话 → CSRF → 限流与体量 L5)→ handler → 错误映射 → 审计。一个实现约束:**字节上限的物理拦截是 L1,发生在 L2 之前**。认证层不消费请求体,但未认证请求的 body 仍会占内存;因此 `RequestBodyLimitLayer` 装在认证层外侧、对所有路由生效(这就是图中 L1 的 413 来源),L5 只负责反序列化后的语义上限(分页、取块)。 + +### 3.2 逐层职责与失败行为 + +| 层 | 职责 | 失败行为 | 状态码 | +| --- | --- | --- | --- | +| L0 连接/请求 ID | 分配 `requestId`(下游凭此定位审计与日志),注入 trace | 头缺失则生成;不回显用户提供的任意值 | — | +| L1 请求体字节上限 | 传输层 `Content-Length` 与流式字节双闸 | 直接中断响应,不进入业务逻辑 | 413 / 400 | +| L2 认证 | 解析登录会话 cookie,查会话行,校验绝对/空闲到期时间与撤销标记 | `ApiError`(脱敏,不说明会话是否存在) | 401 | +| L3 会话解析 | 校验 `SessionHandle + runtimeEpoch`,解析 owner | 句柄无效/不匹配 | 409 | +| L4 CSRF | 校验令牌与登录会话绑定 | 审计单独记录,响应体不新增字段 | 403 | +| L5 体量与速率 | 反序列化、分页/取块上限、令牌桶限流 | 参数语义错误 / 超限 | 400 / 429 | +| L6 授权 | `PolicyService.authorize`,产出 `AuthorizationDecision` | 不可见/无权限 | 404 / 403 | +| L7 路由 | 匹配到 handler,未匹配路径 | 统一 404,不区分「路径不存在」与「方法不允许」 | 404 | +| E 错误映射 | 任何层抛出的 `ApiError` → HTTP,见 [§9.2](#92-apierror--http-映射表) | 保证响应体形状恒定 | 见 §9.2 | +| A 审计 | 记录组织、应用用户、数据库执行身份、目标、执行结果与配置版本 | 审计写失败不吞掉业务响应,但触发告警指标 | — | + +### 3.3 早期拒绝的适用范围 + +| 路由类 | 最小到达层 | 说明 | +| --- | --- | --- | +| `/healthz`、`/readyz` | L0 | 不做认证;不泄露版本、依赖组件与配置细节 | +| `/api/v1/auth/*`(login/callback/logout;**本文新增路由**,概要 §6.3 未列) | L0→L2 部分豁免 | login/callback 自身依赖 state 校验,见 [§4](#4-认证oidc) | +| `/api/v1/submission-tokens` | L2 | 需要登录;令牌有效期直接取[连接 §13.1](connection-management.md#131-幂等键期限与响应分类) 的权威值「运行时会话操作还绑定 owner `runtimeEpoch`,默认有效期 24 小时,不含秘密」,本文不另立数值;`createProfile` 令牌不绑定会话。注意 §13.1 里有**两个不同的 24 小时**:**令牌默认有效期 24 小时**约束的是令牌 `expiresAt`;**完整记录至少保留至 `expiresAt + 24 小时`**约束的是幂等**记录**的 `retained_until`([§10.4](#104-幂等回执存储) 与 [persistence-model §3.5](persistence-model.md#35-idempotency_records))。前者决定令牌何时失效、后者决定回执记录还能查多久,二者起点相同但不是同一件事,不可互相替代 | +| 以 `{id}` 寻址的所有路由 | L2→L6 | 不可见统一 404,见 [§7](#7-不可见资源的统一-404) | +| 集合类路由(`GET /api/v1/connections`、`GET /api/v1/jobs`) | L2→L6 | 只返回该 principal 可见集合;组织级动作权限不足才 403 | +| `GET /api/v1/events/{streamId}` | L2→L6→SSE | 特殊:需要 `Origin` 校验替代 CSRF 头,见 [§8.1](#81-端点与握手鉴权) | +| 静态资源与 Wapp 资源 | L0 | 由反向规则分发到独立 origin,见 [§15](#15-wapp--ep--theme-在-web-上的边界) | + +--- + +## 4. 认证(OIDC) + +### 4.1 登录流程 + +采用授权码 + PKCE(`S256`),首版只支持这一种交互流。本节所有时长(state 事务有效期、时钟偏移容差、登录会话寿命、cookie 生命周期)都是**首版建议值,来自部署配置**,不是协议常量。 + +```mermaid +sequenceDiagram + participant UI as Web shell + participant S as server + participant IdP as OIDC IdP + + UI->>S: GET /api/v1/auth/login + S->>S: 生成 state / nonce / PKCE(verifier, challenge) + S-->>UI: 302 → IdP authorization endpoint + UI->>IdP: 认证 + 授权 + IdP-->>UI: 302 → /api/v1/auth/callback?code&state + UI->>S: 回调(同源顶层导航,携带 Lax cookie) + S->>S: 常量时间比较 state,消费即焚 + S->>IdP: POST token endpoint(code + code_verifier) + IdP-->>S: id_token / access_token + S->>S: 校验签名/iss/aud/exp/nonce,映射 principalId + S->>S: 校验 membership,创建登录会话行 + S-->>UI: Set-Cookie 会话 cookie;302 回应用 +``` + +### 4.2 state / nonce / PKCE 校验 + +| 校验项 | 规则 | 失败结果 | +| --- | --- | --- | +| `state` | 服务端生成的 128-bit 以上随机值,单次消费,TTL 10 分钟,与浏览器 cookie 常量时间比较 | 400,审计记录,不重放 | +| `nonce` | 写入 `id_token` 校验;重放的 `id_token` 因此不可用 | 401 | +| PKCE | `code_challenge = BASE64URL(SHA256(verifier))`,`code_challenge_method=S256`;禁用 `plain` | 400 | +| `iss` / `aud` / `exp` / `iat` | 严格等值/区间校验,允许 60 秒时钟偏移 | 401 | +| 签名 | 依 IdP `jwks_uri` 拉取 JWKS 并按 `kid` 缓存轮换;未知 `kid` 触发一次刷新 | 401 | +| `azp` | 多 audience 时必须等值于本服务 client id | 401 | +| membership | 外部 subject 必须映射到本组织内已启用的 membership,否则拒绝登录(不在登录时隐式开户) | 403 | + +`id_token` 中的稳定主体标识(`sub`)只在服务端用于映射 `principalId`;对外一律使用 `principalId`,不转发 IdP 原始标识。 + +### 4.3 服务端登录会话 + +登录会话是**服务端行**,不是自包含令牌,因此登出、撤销与权限变更可以立即生效。首版落在管理库:表 `login_sessions`,DDL / CHECK / 索引 / 保留策略以 [持久化模型 §3.10 认证与授权表](persistence-model.md#310-认证与授权表) 为权威,本节只给字段语义(不重复 DDL): + +| 字段 | 语义 | +| --- | --- | +| `session_id` | ≥128-bit 随机、不透明、不可从 `principalId` 推导 | +| `organization_id` / `principal_id` | 会话绑定的组织与主体 | +| `auth_time` | IdP 认证时刻,用于判断敏感操作是否需要重新认证 | +| `issued_at` / `absolute_expires_at` | 绝对寿命(默认 24 小时) | +| `idle_expires_at` | 空闲寿命(默认 8 小时),每次入站请求顺延 | +| `reauth_required` | 敏感操作前的重新认证标记 | +| `revoked_at` / `revoke_reason` | 撤销时刻与原因(`logout` / `permission_revoked` / `admin` / `credential_rotated`) | +| `permission_version` | 建立会话时的组织权限版本快照,供撤销传播比对 | +| `client_binding` | 浏览器实例指纹(UA 摘要 + 客户端实例标识),异常时要求重新登录 | +| `token_epoch` | 登出/改密时自增,令 `ts_epoch` 之前签发的短期令牌立即失效 | + +会话行**不保存** access_token 的可重放副本;需要调用 IdP 后端接口时按需重新交换。 + +### 4.4 Cookie 属性 + +所有会话相关 cookie 使用 `__Host-` 前缀(强制 `Secure`、无 `Domain`、`Path=/`),由同源 HTTPS 部署保证成立: + +| Cookie | `HttpOnly` | `SameSite` | 生命周期 | 用途 | +| --- | --- | --- | --- | --- | +| `__Host-dz_session` | 是 | `Lax` | 与会话行同步 | 登录会话标识;`Lax` 是为了让 IdP 顶层导航回调能带上 cookie | +| `__Host-dz_oidc_state` | 是 | `Lax` | 10 分钟 | 登录事务 state,单次消费后立即删除 | +| `__Host-dz_csrf` | 否(前端需读取以放入请求头) | `Strict` | 与会话同步 | CSRF 令牌的公开部分,见 [§5](#5-csrf) | + +`__Host-` 前缀的作用是阻断子域向父域种 cookie 的注入路径:任何来自子域的 `Set-Cookie: dz_session=...` 都会被浏览器丢弃。`SameSite=Lax` + `__Host-` 组合后,跨站 POST 表单与 iframe 都不会携带会话 cookie。 + +会话过期时:清空上述 cookie → 401 + `requestId`;Web shell 跳转登录页并保留原路由;重登录成功后回到原页面。**不自动重放**任何原请求(含写操作),由用户确认后重新提交(与 [连接与会话管理 §13.1](connection-management.md#131-幂等键期限与响应分类) 的幂等令牌过期规则一致)。 + +### 4.5 登出与会话撤销 + +| 触发 | 动作 | +| --- | --- | +| 用户登出 | 标记 `revoked_at`(reason=`logout`)、自增 `token_epoch`、删除全部 cookie;若 IdP 支持 RP-initiated logout 则同步发起,否则仅本地失效 | +| 管理员禁用账号/成员 | 同样标记撤销并自增 `token_epoch`;**所有**该 principal 的会话行一并失效 | +| 密码/凭据轮换 | 撤销相关 membership 的会话,重置 `reauth_required` | +| 风险信号(客户端指纹突变、速率异常) | 撤销该会话行,不影响同 principal 的其他客户端之外的登录 | + +撤销语义保证:撤销后**下一个入站请求**必定 401;已在进行的长连接按 [§8.4](#84-权限复核与登录过期) 关闭,不需要等待 cookie 自然过期。 + +--- + +## 5. CSRF + +### 5.1 方案选择 + +采用**会话绑定的签名双提交令牌**(session-bound signed double-submit),而不是纯双提交,也不是纯服务端令牌表: + +- 纯双提交(cookie 值 == header 值)在子域可读 cookie 时防不住注入;`__Host-` 前缀把这一类风险压到接近零,但没有消除。 +- 纯服务端令牌表每个写请求要多一次存储查找;会话绑定把这部分成本并入 L2 已加载的会话行。 + +令牌结构:`base64url(random) || "." || base64url(HMAC(secret, login_session_id || ":" || random))`,同时写入 `__Host-dz_csrf` cookie 与响应体/前端缓存。校验三件事: + +1. `random` 与 header 提交值常量时间相等; +2. HMAC 在**当前请求解析出的登录会话 id**下验证通过(换会话即失效); +3. 令牌所属会话行未被撤销。 + +### 5.2 适用范围 + +| 方法/路由 | 需要 CSRF 令牌 | 说明 | +| --- | --- | --- | +| `GET` / `HEAD` 安全读取 | 否 | 但仍受认证与授权约束 | +| 所有 `POST` / `PUT` / `PATCH` / `DELETE` | 是 | 含 `POST /cancel`、配置写入、artifact 上传、权限变更 | +| `GET /api/v1/events/{streamId}`(SSE) | 否 | 浏览器 `EventSource` 无法设置自定义头;改用 `Origin` 严格校验 + cookie `SameSite` + `__Host-`,见 [§8.1](#81-端点与握手鉴权) | +| `GET /api/v1/auth/callback`(本文新增路由) | 否 | 自身以 `state` 校验防登录 CSRF | + +### 5.3 失败行为 + +CSRF 校验失败:HTTP 403,`ApiError.code` 取 `PermissionDenied`(沿用 [连接与会话管理 §13](connection-management.md#13-错误重试和事件) 的权威表,**不新增** code 以免与 `ExecutionErrorCode` 命名空间混用),`retryDisposition` 为 `never`;审计记录 `csrf_rejected` 并带上 `requestId`、来源 `Origin` 与路由。响应体不新增区分字段——对攻击者而言 CSRF 失败与普通无权本就不可区分。 + +前端凡发起非安全方法一律经统一 `BackendClient` 封装注入令牌,禁止在业务代码里手写 `fetch` 绕过。 + +--- + +## 6. 授权模型 + +### 6.1 角色与资源 ACL + +授权是两层:组织内**角色**给出基线,资源**ACL** 给出逐资源覆盖;显式 deny 优先于一切 grant。 + +```text +organization + ├── membership(principalId → roleId) 角色基线 + └── resource(connection | job | artifact | ...) + └── acl(subjectType, subjectId → permissions, deny?) +``` + +| 主体维度 | 取值 | 说明 | +| --- | --- | --- | +| `subjectType` | `user` / `serviceAccount` / `delegated` | 服务身份与委托身份有独立 ACL 记录 | +| 权限 | `read` / `execute` / `write` / `cancel` / `download` / `subscribe` / `admin` | 逐 action 判定,不做「全权限」通配 | +| 资源类型 | `connection` / `session` / `execution` / `job` / `artifact` / `stream` | 会话与执行按 owner 继承,不单独授权 | + +首版内置固定角色(**不可由管理员自定义**):`orgAdmin`、`orgMember`、`connectionOwner`、`connectionViewer`、`jobOperator`、`auditor`;首版不做策略语言,因此也不建 `roles` 表,6 个取值由 `memberships.role_id` 的 CHECK 表达。这些授权数据落在管理库的 `memberships` / `acl_entries` / `permission_versions`,表结构与保留策略以 [持久化模型 §3.10 认证与授权表](persistence-model.md#310-认证与授权表) 为权威。 + +求值顺序(任一步终止): + +1. **组织边界**:`resource.organizationId != request.organizationId` → `NotVisible`(→ 404); +2. **显式 deny**:存在 `deny` 条目命中 (subject, action) → `Denied`; +3. **资源 ACL grant**:存在 grant 覆盖该 action → `Allowed`; +4. **角色基线**:角色在该 `resourceType` 上允许该 action → `Allowed`; +5. 否则 → `Denied`(resourceId 寻址路由映射为 404,见 [§7](#7-不可见资源的统一-404))。 + +共享账号的特别规则:连接使用共享数据库账号时,应用层 ACL **不能**替代数据库侧账号/角色权限;ACL 只决定「谁可以发起」,最终可读范围由数据库账号决定(对齐 [系统概要设计 §10](system-overview.md#10-安全和环境差异))。 + +### 6.2 权限版本与 `policyIsolationKey` + +组织持有一个单调递增的 `permission_version`(成员关系、角色、ACL、委托任一变更即自增)。客户端与前端**不持有** `policyIsolationKey`;它由服务端派生(`policyRef_revision` / `permissionVersion` 两个分量分别对应 [持久化模型 §3.3](persistence-model.md#33-connections) 的 `policy_ref_revision` 列与 [§3.10](persistence-model.md#310-认证与授权表) 的 `permission_version` 列,`readOnlyRestriction` 取自同节的 `connections.read_only`): + +```text +policyIsolationKey = HMAC(server_secret, organizationId + | principalId + | policyRef_revision + | permissionVersion + | readOnlyRestriction) +``` + +它的用途是**隔离**而非**授权**: + +- 元数据缓存与结果缓存的分区键(缓存条目不跨不同 `policyIsolationKey` 复用,见 [连接与会话管理 §9.6](connection-management.md#96-poolkey版本与缓存的生产者)); +- 连接池 `PoolKey` 的分区维度之一——`connectionId`/`configRevision`/`credentialRevision` 由 `PoolKey` **单独承载**,不重复塞进本键;池隔离由执行身份与权限范围决定,不按显示用户名; +- SSE 订阅在连接建立时固定的授权快照,权限版本变化时用于判定是否需要重新授权。 + +任何客户端读到的 `policyIsolationKey` 只能作为**请求的一部分**回传用于一致性检查;服务端收到后一律重新求值,收到与当前派生值不一致的值时按「客户端状态过期」处理(刷新视图,不拒绝写操作本身,除非该 action 要求严格 CAS)。 + +### 6.3 撤销传播 + +组织 `permission_version` 变更后,传播按对象类型分层: + +| 对象 | 传播动作 | 断言 | +| --- | --- | --- | +| 排队中的执行/Job | 派发前比对入队时快照的 `permissionVersion`;不匹配则拒绝,不申请预算、不调 driver | CM-39:禁用后新/排队请求不执行 | +| 已运行且支持精确取消 | 发 `cancelExecution`,按 `CancelReceipt.disposition` 记录 `requested`/`unsupported`/`alreadyFinished` | CM-59:running Job 按策略取消 | +| 已运行且不支持取消 | **不假设回滚**,登记「因权限撤销不可取消」,等自然终态 | INV-S4 | +| 外部数据库已提交 | 不改写终态,审计记录真实 `effectOutcome` | INV-S4;对应 [连接与会话管理 §13.1](connection-management.md#131-幂等键期限与响应分类) 的 `effectOutcome` 正交规则 | +| SSE 订阅 | 下一个心跳复核点关闭输出 | [§8.4](#84-权限复核与登录过期) | +| Wapp 桥接订阅 | 关闭桥接通道并要求重新握手 | [§15](#15-wapp--ep--theme-在-web-上的边界) | + +传播时延上界:`max(一个心跳周期, 下一个入站请求)`。权限版本缓存在进程内,TTL ≤ 1 秒(首版建议值,来自部署配置);撤销入口在同一进程内同步广播,因此单实例形态下撤销对所有连接同时生效。 + +### 6.4 委托身份(`delegationId`) + +AI、MCP client、Workflow 服务身份不直接拥有组织权限,而是持有一个受限的 `delegationId`: + +| 字段 | 语义 | +| --- | --- | +| `delegationId` | 随机不透明标识,不是可自增/可猜测的整数 | +| `delegator_principal_id` | 委托来源主体(审计必须能定位到人) | +| `subject` | 实际执行身份:受限于 `delegator` 的一个编辑器会话或一个目标资源 | +| `actions` / `resourceType` / `resourceId` | 允许的动作与资源范围 | +| `expires_at` / `max_uses` | 默认 30 分钟(首版建议值,来自部署配置)、最少使用次数约束 | +| `parent_delegation_id` | 支持链式委托,但每一环都收窄权限,不允许放大 | + +签发路径:用户在 UI 显式授权「把当前 editor session 授权给 AI」→ 应用服务签发 `delegationId` → 写入执行主体。校验路径:每次 MCP/Wapp/AI 调用都重新校验(scope、有效期、父链、成员关系),**不缓存判定结果**。CM-53 的断言由这条链路保证:未授权拒绝,授权后在同队列可见,客户端断开只清理自己的资源。 + +### 6.5 前端角色标记禁令 + +请求体中出现 `role`、`isAdmin`、`canExecute`、`permissions`、`organizationId`、`principalId` 任何一项时: + +- **认证层**已从会话行解析出真实身份,body 里的同名字段**一律忽略**,不参与任何求值; +- 出现「客户端声明与服务端身份冲突」的字段时记审计事件 `identity_field_ignored`(脱敏记录字段名,不记录值); +- 任何以 body 身份字段参与求值的代码路径视为架构缺陷,由 CI 的适配一致性检查(同一用例 IPC 与 HTTP 行为一致)暴露。 + +这条对应 CM-06:拒绝把 owner 指向他人 editor/job 的请求,不创建任何会话。 + +--- + +## 7. 不可见资源的统一 404 + +「无权限」与「不存在」在响应上不可区分,是防 ID 枚举的基本要求。 + +### 7.1 判定与映射 + +`PolicyService` 的求值结果有三个:`Allowed`、`Denied`、`NotVisible`。映射规则: + +| 结果 | 路由形态 | HTTP | `ApiError.code` | message | +| --- | --- | --- | --- | --- | +| `NotVisible` | 任意 | 404 | `NotFound` | 固定文案,不含资源名、类型、归属、大小、时间戳 | +| `Denied` | 以 `{id}` 寻址 | 404 | `NotFound` | 同上(与 `NotVisible` 完全一致) | +| `Denied` | 组织级动作(如无 `orgAdmin` 却被请求组织级变更) | 403 | `PermissionDenied` | 固定文案 | +| 资源确实不存在 | 任意 | 404 | `NotFound` | 同上 | + +补充约束: + +- 错误响应的 `requestId` 是随机值,不编码资源存在性;不返回 `ETag`、`Last-Modified` 或任何资源元数据。 +- 审计表记录**真实**原因(`not_visible` / `denied` / `absent`)供管理员排查,对外表达统一;过期或已删除的 artifact 同样返回 404(不返回 410),避免用状态码区分「曾经存在过」。这里有一处**有意的降级**:端口层区分 `PortError::ArtifactExpired` 与 `PortError::NotFound`([共享边界 §4.6](shared-boundaries-and-ports.md#46-结果与事件端口)),本层(server host)出于防 ID 枚举把两者统一映射为 404 + `NotFound`,**不返回 410**;契约本身不变,降级只发生在这个映射点上。 + +### 7.2 适用的路由 + +全部以标识符寻址的路由:`GET/PATCH /api/v1/connections/{id}`、`POST /api/v1/sessions/{id}/attach`、`POST /api/v1/sessions/{id}/detach`、`GET /api/v1/sessions/{id}`、`POST /api/v1/sessions/{id}/executions`、`PUT /api/v1/sessions/{id}/context`、`POST /api/v1/sessions/{id}/close`、`GET /api/v1/executions/{id}`、`POST /api/v1/executions/{id}/cancel`、`GET /api/v1/jobs/{id}`、`POST /api/v1/jobs/{id}/cancel`、`GET /api/v1/artifacts/{id}`、`GET /api/v1/events/{streamId}`。路径与方法与概要 [§6.3](system-overview.md#63-首版-http-映射) 的 v1 路由表逐条对齐;该表**没有任何 `DELETE` 路由**,因此 v1 不定义删除操作(配置停用走 `PATCH ... enabled=false`)。集合类路由(`GET /api/v1/connections`、`POST /api/v1/connections`、`POST /api/v1/submission-tokens`、`POST /api/v1/sessions`、`POST /api/v1/executions`、`POST /api/v1/artifacts/uploads`、`GET /api/v1/jobs`、`POST /api/v1/jobs`)路径里没有资源 id,不适用本节规则,按 [§3.3](#33-早期拒绝的适用范围) 的集合类口径处理。 + +CM-05 的断言即由本节保证:跨组织读取、执行、关闭、取消、订阅、下载全部拒绝,且不能观察资源存在性或数据,不能触发 driver 的 cancel/execute。 + +--- + +## 8. SSE 实现 + +### 8.1 端点与握手鉴权 + +`GET /api/v1/events/{streamId}`,单向状态通知;取消走独立 HTTP 路径(不使用 WebSocket)。 + +浏览器 `EventSource` 无法设置自定义请求头,因此该端点的鉴权由三部分替代 CSRF 头: + +1. `__Host-dz_session` cookie(`Secure` + `HttpOnly` + `SameSite=Lax` + `__Host-` 前缀); +2. `Origin` 头必须等值于配置的 UI origin,否则 403(`EventSource` 一定带 `Origin`,CSRF 攻击页面也会带自己的 `Origin`); +3. L6 授权:`streamId` 解析出的 owner 与资源集合重新求值。 + +如需携带额外头(例如客户端实例标识),前端改用 `fetch` + `ReadableStream` 手动解析 `text/event-stream`,服务端两种客户端同等对待。 + +### 8.2 `Last-Event-ID` 回放 + +| 项 | 规则 | +| --- | --- | +| 续传参数 | 优先 `Last-Event-ID` 请求头(标准),其次 `?afterSequence=` 查询参数 | +| sequence 类型 | `Counter`(十进制字符串),前端不得 `Number()` 转换 | +| 回放范围 | 从 `lastEventId + 1` 起,到当前已发布的最大 sequence 为止,严格连续不跳号 | +| 缓冲区 | 每 stream 环形缓冲,容量 **首版建议值 1024 条**(来自部署配置);它与 [连接 §7.7](connection-management.md#77-结果订阅与放弃消费) 的「每订阅投递队列 256 条 / 1 MiB」是不同对象,两者都要成立;进程重启后回放窗口清零 | +| 窗口过期 | `lastEventId` 早于最老保留 sequence → 不回放,发 `streamResetRequired` 事件,客户端用 `getSession`/`getExecution`/`getJob` 取快照重建 | +| 事件未覆盖 | 事件终态可从 repository 重建,因此「丢失窗口」只损失实时增量,不损失事实 | +| 慢消费者 | 消费者缓冲上限内未能跟上 → 先发 `streamResetRequired`,再关闭连接;**不做无限缓冲**(对应 [连接与会话管理 §7.7](connection-management.md#77-结果订阅与放弃消费) 的放弃消费政策) | + +### 8.3 心跳与超时 + +- 每 15 秒发送 SSE 注释行(`: keepalive`,首版建议值,来自部署配置),不占用 sequence、不进回放缓冲;连续 3 个心跳周期无成功写出判定连接失效,释放订阅与缓冲; +- 服务端连接上限按 principal 计,超限按 L5 令牌桶返回 429 + `Retry-After`; +- 空闲超 `idle_expires_at` 的流主动关闭并附 `auth_required` 事件; +- **心跳不刷新登录空闲期**:`SessionDirectory::touch` 只更新**业务活动**时间,登录心跳不得刷新它([共享边界 §4.5](shared-boundaries-and-ports.md#45-会话目录预算与令牌端口))。这里涉及三个不同对象,不得混为一谈:`: keepalive` 是 SSE 传输层的注释行;[§8.2](#82-last-event-id-回放) 的 1024 条环形回放缓冲是每 stream 的事件缓冲;`idle_expires_at` 是**登录会话行**(§4.3)的空闲寿命,只由入站请求顺延。因此一个只挂 SSE、不再发其他请求的客户端会在登录空闲期到期后被关闭,这是有意的语义——保活必须发业务请求或重新登录。 + +### 8.4 权限复核与登录过期 + +握手鉴权**不是**唯一鉴权点。每个心跳周期在同一任务上复核三件事: + +1. 登录会话行未撤销、未超过 `absolute_expires_at`、且未超过 `idle_expires_at`(心跳**不**刷新空闲期,见 [§8.3](#83-心跳与超时)); +2. 组织 `permission_version` 未变化(变化则对 `streamId` 内的资源集合重新求值); +3. 底层会话/job 归属未变(会话失效时对应流关闭)。 + +任一不满足:先发一条终态事件说明原因(`auth_required` / `permission_revoked` / `session_lost`),**然后关闭流**。客户端收到 `auth_required` 必须先 `close()` 掉 `EventSource` 再跳转登录,否则浏览器会自动重连形成登录风暴;服务端对此以 204 响应抑制自动重连(CM-56、CM-59 的 **W** 断言)。`W` 是上位泛称,阶段性落点按[开发计划 §17](../../development/platform-development-plan.md#17-补充契约的阶段归属) 判定:CM-59 落在该节 P7/P8 行的「CM-59 的 W1 断言」;CM-56 无独立表行,落在该节收尾段「P3/P4 必须完成 CM-54~56 的 runtime/前端部分;P7 完成单实例适配部分」,即其单实例(W1)适配部分随 P7 落地,H/F 部分在 P3/P4。 + +### 8.5 断开与取消的边界 + +**SSE 断开只解除订阅,不直接取消正在执行的 SQL。** 执行的生命周期由其自身状态机与预算/产物配额决定: + +- 无订阅者的执行继续按计划运行,结果写入 ArtifactStore; +- 结果无人消费时按 [连接与管理 §7.7](connection-management.md#77-结果订阅与放弃消费) 的有界放弃消费政策超期取消; +- 只有 `cancelExecution` / `POST /api/v1/executions/{id}/cancel` 才是显式取消路径。 + +事件归档只写显式白名单投影,与 [连接与会话管理 §4.4](connection-management.md#44-可落盘来源与运行时绑定) 的持久化白名单保持一致;**永不进入**归档的字段包括 `dbSessionId`、`SessionHandle`、`RuntimeResultBinding`、attachmentToken、取消句柄、lease/游标、凭据与解密材料。 + +--- + +## 9. 请求限制与错误映射 + +### 9.1 请求限制 + +| 限制 | 首版取值(建议值,来自部署配置) | 触发行为 | +| --- | --- | --- | +| 请求体字节 | 8 MiB(L1 物理闸) | 413 | +| Artifact 上传单块 | 4 MiB(首版建议值,来自部署配置;严格小于 8 MiB 请求体上限,为 multipart 封装与请求头留出余量) | 单块超限由 L1 按 413 拒绝;组织产物配额超限 429 | +| `listJobs` 分页 | `limit` 默认 50、上限 200 + 游标 | 400 | +| `GET /api/v1/connections` | 按 [系统概要设计 §6.2](system-overview.md#62-backendclient-与领域客户端) 走**不分页**契约;组织内 profile 数量受组织配额约束 | 配额超限 429 | +| `GET /api/v1/artifacts/{id}?chunkIndex=n` | 需 `0 ≤ chunkIndex < totalChunks`;流式未收齐(`totalChunks: null`)时该形式不可用 | 400 | +| `GET /api/v1/artifacts/{id}?offset=&limit=` | `limit ∈ [1, 4 MiB]`,`offset` 越界报错 | 400 | +| 速率 | 按 principal + 路由类的令牌桶;令牌签发/开会话/提交执行/取消为严格档 | 429 + `Retry-After` | +| 并发 SSE | 按 principal 上限 | 429 | +| 字符串长度 | 路径、目标、SQL 原文有独立上限 | 400 | + +限流与预算超限都是「请求未被接受」,因此**不会**产生执行记录,也不会进入 `ExecutionErrorCode`。 + +### 9.2 `ApiError` → HTTP 映射表 + +`ApiError` 形状由 [系统概要设计 §6.3](system-overview.md#63-首版-http-映射) 定义:`code`、脱敏 `message`、`requestId`、`retryDisposition`。本表把该节的分类展开成 server 侧逐条映射,是 server 侧 HTTP 映射的落地口径;分类本身仍以概要为准。 + +| HTTP | 条件 | `ApiError.code` | `retryDisposition` | 说明 | +| --- | --- | --- | --- | --- | +| 400 | 请求体/查询/路径反序列化失败、参数语义错误、目标缺失或冲突、块索引或取块越界 | `InvalidArgument` / `TargetRequired` / `TargetConflict` / `TargetUnsupported` | `never` | 修复参数后重发 | +| 401 | 无 cookie、会话过期、会话撤销、OIDC 校验失败 | `Unauthenticated` | `never` | 跳转登录;不自动重放原请求 | +| 403 | 组织级动作权限不足;CSRF 失败;出站策略拒绝;SSE `Origin` 不匹配 | `PermissionDenied` | `never` | 统一脱敏文案 | +| 404 | 不可见资源、不存在资源、不可区分二者的情形 | `NotFound` | `never` | 见 [§7](#7-不可见资源的统一-404) | +| 410(本文**不产生**) | 端口层返回 `PortError::ArtifactExpired` 的过期/已删除 artifact | 统一映射为 `NotFound` | `never` | **不返回 410**:用状态码区分「曾经存在过」会破坏 [§7.1](#71-判定与映射) 的防 ID 枚举口径 | +| 409 | 幂等键过期、幂等指纹冲突、`configRevision` CAS 失败、`runtimeEpoch` 不匹配、上下文冲突、`SessionLost`/`SessionNotFound`、需要用户决策的事务错误 | `IdempotencyExpired` / `IdempotencyConflict` / `ConfigRevisionMismatch` / `RuntimeEpochMismatch` / `ContextConflict` / `SessionNotFound` / `SessionLost` / `TransactionResolutionRequired` | `never` | 重新读取状态后由用户决定(`ConfigRevisionMismatch` **已由 [连接 §13](connection-management.md#13-错误重试和事件) 正式登记**,触发列写的就是 `ProfileRepository::compare_and_set` 的 `expectedRevision` CAS 失败) | +| 413 | 请求体超过物理上限 | `PayloadTooLarge` | `never` | — | +| 429 | 预算、速率、逻辑会话额度、队列上限超限 | `QuotaExceeded` / `RateLimited` / `SessionQuotaExceeded` / `ResourceBusy` / `QueueFull` | `never` | 必须带 `Retry-After` | +| 503 | 暂时无可用执行节点、drain 中、管理库不可达、迁移未完成 | `ServiceUnavailable` | `never` | 退避重发时**必须复用同一 `idempotencyKey`** | + +**`TargetConflict`(400)与 `ConfigRevisionMismatch`(409)是两个 code,本文不合并**:前者指**同一目标身份在请求内自相矛盾或与已登记状态冲突**([连接 §13](connection-management.md#13-错误重试和事件) 把 `TargetConflict` 与 `TargetRequired` / `TargetUnsupported` 并列为「目标缺失/重复冲突/无法定位」),属**参数错误**,修好参数即可重发;后者指 `ProfileRepository::compare_and_set` 的 `expectedRevision` CAS 失败([共享边界 §4.3](shared-boundaries-and-ports.md#43-持久化与授权端口) 的 `PortError::CasConflict`),请求本身合法,只是基于旧的 `configRevision`,属**并发状态冲突**,动作是重读最新版本后由用户决定。两者 `retryDisposition` 都是 `never`,但前端恢复路径不同(改参数 vs 刷新状态再提交);合并会让「目标不合法」与「目标合法但版本旧」不可区分。该端口只承诺 `PortError::CasConflict`([共享边界 §4.3](shared-boundaries-and-ports.md#43-持久化与授权端口) 已声明「它落到哪个 `ApiError.code` 由 server host 决定」):`TargetConflict` 只指命名空间目标冲突([连接 §13](connection-management.md#13-错误重试和事件) 已列),`configRevision` CAS 失败在本层映射为 409 `ConfigRevisionMismatch`(该 code 已由 §13 正式登记,触发列即这次 CAS 失败)。 + +两处本文补充:① 概要 [§6.3](system-overview.md#63-首版-http-映射) 只列举 400/401/403/404/409/429/503,本表的 413、[§9.3](#93-状态码约定成功侧) 的 201/202/200(302 与 204 分别在 [§4.1](#41-登录流程) 与 [§8.4](#84-权限复核与登录过期),不属 §9.3 与本表),以及 503 的「drain 中、管理库不可达、迁移未完成」都属于既有分类的自然展开,不新增状态码类别;② `Unauthenticated`、`NotFound`、`PayloadTooLarge`、`QuotaExceeded`、`RateLimited`、`ServiceUnavailable`、`ConfigRevisionMismatch` 这 7 个 code **已由 [连接 §13](connection-management.md#13-错误重试和事件) 正式登记**(§13 正文原已声明 `RequestError` 范围含「未认证」与「暂无可用 worker」,登记行即按该范围落地)。本表只是它们在 HTTP 层的**落地映射**,不新增也不改写 code 的语义;其余 code 仍以 §13 表为准。 + +§13 这 7 行的触发与 UI 动作在 HTTP 层的落点:`Unauthenticated`(未认证)→ 401 跳登录、不自动重放原请求;`NotFound`(不可见或不存在、二者不可区分)→ 404,不据此推断资源存在性;`PayloadTooLarge`(请求体超物理上限)→ 413,缩减请求;`QuotaExceeded`(组织/用户/数据库额度超限)→ 429 带 `Retry-After`,等待或申请配额;`RateLimited`(令牌桶限流)→ 429 按 `Retry-After` 退避;`ServiceUnavailable`(暂无可用 worker / drain / 管理库不可达 / 迁移未完成)→ 503,退避后重发且**复用同一 `idempotencyKey`**;`ConfigRevisionMismatch`(`configRevision` CAS 失败)→ 409,重读最新 `configRevision` 后由用户决定、不自动覆盖(此前以 `PlanStale` 近似,现已不需要)。逐条权威触发与 UI 动作见 [连接 §13](connection-management.md#13-错误重试和事件)。 + +`retryDisposition` 的三条取值的权威表述见 [系统概要设计 §6.3](system-overview.md#63-首版-http-映射)。补充一条实施口径:`never` 表示「**不得在未修改请求的前提下自动重发**」;503 的退避重发是「经显式 `Retry-After` 等待后的受控重发」,且必须复用原 `idempotencyKey`,与 `checkExecution` 语义不同——后者表示**已经被接受**、结果未知、需要先 `getExecution` 核验。 + +### 9.3 状态码约定(成功侧) + +| 场景 | 状态码 | 响应体 | +| --- | --- | --- | +| 创建连接配置 | 201 | `ProfileView` | +| 打开编辑器会话(懒建,不建连) | 201 | `OpenSessionReceipt`(含 `attachmentToken`) | +| 签发幂等提交令牌 | 201 | `SubmissionToken` | +| **异步接受**执行/Job/取消/启动任务 | **202** | 稳定 ID(`executionId` / `jobId`)或 `CancelReceipt`;凡是结果需另行查询的请求一律 202 + 稳定 ID,客户端不得把 202 读作成功执行 | +| SSE 订阅 | 200 + `text/event-stream` | 事件流 | +| 上传产物块 | 201(首块)/ 200(后续块) | chunk 确认 | + +### 9.4 `ApiError.code` 与 `ExecutionErrorCode` + +这两个是**两个命名空间**,任何位置都不得混用: + +| 维度 | `ApiError.code` | `ExecutionErrorCode` | +| --- | --- | --- | +| 表达什么 | 请求**被拒绝** | 执行**已到达终态且失败** | +| 何时出现 | HTTP 响应体 | `ExecutionView.errorCode`、执行事件 | +| HTTP 关系 | 与状态码一一映射 | 所在 HTTP 响应通常是 **200** | +| 权威定义 | 本文 §9.2 + [连接与会话管理 §13](connection-management.md#13-错误重试和事件) | [连接与会话管理 §4](connection-management.md#4-dto-与字段定义) 的版本化枚举 | + +派发后的执行终态失败**只**由 `ExecutionState='failed'` + `ExecutionErrorCode` + 事件表达,不产生 `ApiError`,也不出现在请求错误表里。已建立执行记录后由宿主二次校验拒绝的记 `hostRejected`;尚未建立记录时直接返回 `ApiError`。CM-72 的 W1 断言要求前端对两者的序列化大小写与枚举字面值完全一致(`cancelled`/`rolledBack`/`partiallyApplied` 等一律小写),CI 的 DTO 漂移检查负责守这条线。 + +`effectOutcome` 与 `errorCode` 正交:超时不等于回滚。权限撤销导致的处置同样遵守这条([§6.3](#63-撤销传播))。 + +--- + +## 10. 服务端仓储与 ports 实现 + +### 10.1 实现矩阵 + +[系统概要设计 §6.4](system-overview.md#64-repositories-与环境-ports) 给出 ports 语义,本表给出 server 侧的首版落地与与桌面实现的差异: + +| port | server 首版实现 | 存储 | 与桌面/现状的差异 | +| --- | --- | --- | --- | +| `ProfileRepository` | PostgreSQL,按 `organization_id` 限定查询,`expectedRevision` CAS | 管理库 | 桌面现为 `src-tauri/src/store/connections.rs` 的本地库;服务端多组织隔离且无本地用户边界 | +| `JobRepository` | PostgreSQL,claim/renew 用条件更新 + 租约 | 管理库 | 桌面无 Job 持久化;服务端需在进程重启后把非终态置为待核验而非重新执行 | +| `Audit` | 追加写表,应用角色无 UPDATE/DELETE 授权 | 管理库 | 桌面只写本地日志;服务端审计是安全资产 | +| `ArtifactStore` | 字节存储 + 元数据表 | 对象存储/受控目录 + 管理库 | 桌面走 `src-tauri/src/commands/file.rs` 的本地路径与扩展名白名单;服务端不接受路径(见 §11) | +| `SecretProvider` | 服务端密钥管理适配 | KMS/外部密钥服务 | 桌面 `src-tauri/src/store/key_store.rs` + `platform_vault.rs` 的 OS 钥匙串路径在服务端**不适用**(无用户钥匙串) | +| `SessionDirectory` | **单进程内存** | 内存 | 禁止落盘/快照/追加日志;多实例归 P9 | +| `BudgetCoordinator` | **单实例**实现 | 内存 + 组织/用户/数据库额度取自管理库 | 多 worker 全局预算归 P9 | +| `SubmissionTokenIssuer` | 签名密钥由配置/KMS 提供 | 不落库 | 密钥保留期 ≥ 已发令牌全部过期时间;未知 `keyVersion` 一律拒绝 | +| `NetworkProvider` | 出站策略适配(§12) | 配置 | 桌面允许用户配置任意地址;服务端受策略约束 | +| `EventSink` | SSE 通道 + 每 stream 环形缓冲 | 内存 | 终态可从 repository 重建,故不持久化事件体 | + +### 10.2 `SecretProvider` + +| 规则 | 内容 | +| --- | --- | +| 身份解析 | 按 `connectionId` + `credentialRevision` 解析执行身份,返回短期执行材料——`SecretProvider` 出材料 + `IdentityResolver` 出执行身份([共享边界 §4.3](shared-boundaries-and-ports.md#43-持久化与授权端口);`PolicyService` 是同职责端口层名称,不含身份解析) | +| 不可外泄 | 密码、token、TLS 私钥、可解密材料**不进入**任何 API 响应;`ProfileView.publicOptions` 只含可见且非敏感字段 | +| 轮换 | `rotate` 按 `expected` 递增 `credentialRevision`(版本 CAS),`read_versioned` 支持按指定历史版本回读且不改变当前版本;旧 idle 资源不再发放,新资源用新材料,已建立会话不被悄悄改配置(CM-38) | +| 池隔离 | 共享账号下按执行身份与权限范围分池,不按显示用户名 | +| 审计 | 凭据轮换、失败解析、跨组织尝试均落审计,但不落秘密本身 | + +日志与审计的脱敏口径对齐 `src-tauri/src/log_redact.rs`:默认不打印密码、令牌、原始数据库错误与用户绝对路径。 + +### 10.3 `SessionDirectory` 与 `BudgetCoordinator`(单实例) + +`SessionDirectory` 内存实现必须提供:register/get、owner `runtimeEpoch` CAS、**候选替换的原子发布**、invalidate。CM-68 的 W1 断言在单实例下即成立:候选替换提交前处于 `prepared` 且不可被路由命中,提交是一次原子操作,其后丢失响应返回同键同回执(见 [连接与管理 §12](connection-management.md#12-web多实例权限与结果))。 + +`BudgetCoordinator` 首版为单实例实现:组织/用户/数据库三级额度 + 控制连接/cluster/隐藏连接统计,失败核销保证额度不重复发放。它与 `SessionDirectory` 一样**不落盘**——进程重启后额度归零,但旧物理连接已随进程终止,这是单实例形态的自洽前提(多实例的保守回收归 P9)。 + +### 10.4 幂等回执存储 + +| 回执类型 | 存储 | 保留 | 丢失后行为 | +| --- | --- | --- | --- | +| 执行 / Job 接受记录 | 管理库持久化:请求摘要指纹、`executionId`/`jobId`、稳定目标与 owner、状态、receipt 的 durable 投影(**不含**原请求里的 `SessionHandle`) | 完整记录至少保留至 `expiresAt + 24 小时`([连接 §13.1](connection-management.md#131-幂等键期限与响应分类) 权威值) | 未过期同指纹返回同 receipt;不同指纹 `IdempotencyConflict`;记录已删除则返回 `IdempotencyExpired` | +| `openSession` / 上下文替换的完整指纹与 `attachmentToken` | **仅进程内存** | 至令牌过期或 runtime 终止 | 令牌绑定 `runtimeEpoch`;owner 丢失返回 `SessionLost`,**不在新 owner 当作首次请求重建** | +| `close` tombstone | 仅内存([连接 §13.1](connection-management.md#131-幂等键期限与响应分类) 默认 24 小时) | 24 小时 | 返回终态或 `SessionLost`,不恢复资源 | +| 令牌签名密钥 | 配置/KMS | ≥ 已发令牌全部过期时间 | 未知 `keyVersion` 一律拒绝 | + +接受记录必须在 SQL/Job 派发**之前**提交;进程重启后的非终态进入核验/`OutcomeUnknown`,不自动再执行([连接 §13.1](connection-management.md#131-幂等键期限与响应分类) 的幂等期限规则);记录已删除后过期签名令牌仍被拒绝(CM-70)。 + +--- + +## 11. `ArtifactStore` 服务端实现 + +```mermaid +flowchart LR + C[客户端] -->|POST /api/v1/artifacts/uploads 分块| W[create writer] + W -->|append chunkIndex| S[字节存储] + W -->|finalize 校验 chunk 覆盖| M[元数据表 artifact] + X[执行] -->|结果分块| W + C -->|GET /api/v1/artifacts/…?chunkIndex 或 offset+limit| R[读前 ACL 校验] + R --> S + T[TTL 清理任务] --> S + T --> M +``` + +| 主题 | 规则 | +| --- | --- | +| 写入 | `create writer` → `append(chunkIndex, bytes)` 允许乱序(同一 `chunkIndex` 重复写以最后一次为准)→ `finalize` 校验 chunk 覆盖 `0..totalChunks-1`,缺块则 finalize 失败并记 `resultCompleteness`/`truncationReason` | +| 流式未收齐 | `totalChunks: null` 表示仍在写入;此时只有 `offset+limit` 可用,且不得给出完整性承诺 | +| 读取 | 每次请求**重新授权**(继承 execution 来源授权 + 显式 grant);跨组织一律不可见 | +| 取块 | `chunkIndex` 与 `offset`+`limit` 互斥;越界报 400,不泄露存储布局 | +| TTL | `expires_at` 到期由清理任务删字节并置元数据终态;读已过期产物在端口层是 `ArtifactExpired`,到 HTTP 层**有意**统一为 404 + `NotFound`(不返回 410),理由见 [§7.1](#71-判定与映射) | +| ACL 变更 | 组织/连接 ACL 收紧后,旧 artifact 立即不可读;不需要也不能靠「产物已生成」豁免授权 | +| 共享语义 | 共享 SQL 文件**不授予**结果读取权;结果默认私有 | +| 路径 | **不接受任何服务器绝对路径**;导出/下载只用 `artifactId`;备份产物由 Job 生成后进 ArtifactStore | + +受控上传(`POST /api/v1/artifacts/uploads`)的额外约束:扩展名白名单(对齐 `src-tauri/src/commands/file.rs` 的 `ALLOWED_EXTENSIONS` 口径)、组织产物配额、路径遍历防护;上传内容按不可信数据处理,不自动解压执行。 + +--- + +## 12. 出站网络策略 + +`NetworkProvider` 在**每次实际建连前**执行解析与校验,不信任请求体中的任何地址: + +```mermaid +flowchart TD + A[profile 中的 host 引用] --> B[DNS 解析 A/AAAA 全部地址] + B --> C{"任一地址命中拒绝类别?"} + C -->|是| D[拒绝建连,审计] + C -->|否| E[固定已校验 IP 到本次连接] + E --> F[建连] + F --> G{"收到重定向?"} + G -->|是| H[首版不自动跟随;需跨跳由调用方显式二次请求并重新校验] + G -->|否| I[连接可用] +``` + +| 规则 | 内容 | +| --- | --- | +| 解析后校验 | 拒绝环回(除显式放行的执行节点本机)、link-local、云元数据地址、未授权私网段、节点自身管理地址 | +| DNS rebinding | 校验通过后**固定已校验 IP**用于本次连接,连接期间不再重新解析 | +| 重定向 | 首版禁用自动跟随;跨跳由显式二次请求触发完整校验 | +| 隧道目标 | SSH 隧道([`src-tauri/src/ssh_tunnel.rs`](../../../src-tauri/src/ssh_tunnel.rs) 的 `SshTunnel`)与代理隧道(`src-tauri/src/tunnel/` 的 `HttpProxyTunnel`、`WebSocketTunnel`)的最终目标走同一校验;隧道端点本身是管理员配置的 `networkRouteRef`,不接受请求体提供 | +| `localhost` 的含义 | `localhost` 指**执行节点自身**。这是明确约定而非绕过:私网可达性通过把执行节点部署在可达网络解决,而不是靠 SSRF 洞。是否允许字面 `localhost` 由组织级 `egressPolicy.loopback` 决定,默认 `deny` | +| 工具与证书路径 | TLS 证书、私钥、备份/dump 工具二进制路径改为**配置引用或 artifact 引用**;不接受请求体路径;规范化后必须落在允许根目录内,拒绝符号链接逃逸 | +| 失败表达 | 一律 `ApiError.code = PermissionDenied` + 审计事件,不回显被拒地址的内部结构 | + +--- + +## 13. Schema migration 运行方式 + +表结构、列、索引与版本策略以 [持久化模型](persistence-model.md) 为准,本文只定义**运行方式**。 + +| 项 | 规则 | +| --- | --- | +| 迁移文件 | `server/migrations/` 下按 `version` 单调编号的 SQL,随二进制内嵌(版本表 `schema_migration_history` 见 [持久化模型 §3.9](persistence-model.md#39-schema_migration_history)) | +| 触发 | 启动时在 `serve` 之前自动执行;也可显式 `migrate` / 只读 `check-schema` | +| 事务性 | 每个迁移文件在**单个事务**内执行(PostgreSQL 支持事务 DDL);失败整体回滚 | +| 失败策略 | **fail-closed**:每个迁移文件在单个事务内执行、失败整体回滚;迁移失败或版本高于代码期望时拒绝启动,不进入「部分应用」状态;失败**不写** `schema_migration_history`(该表只在事务提交后记录已成功迁移),错误写入服务日志与健康检查输出(对齐 [持久化模型 §5.5](persistence-model.md#55-失败与回滚)) | +| expand/contract | expand(加表/加可空列/加索引)在同一次发布完成,旧代码仍可运行;contract(删旧列/删表)必须等旧镜像下线且跨过一次发布周期,禁止先删后加 | +| 不涉及 | 不迁移、不触碰任何用户业务数据库;只管服务端管理库 | +| 与运行时兼容 | 迁移在接收流量前完成,因此不存在「运行中代码遇到缺失列」的中间态;需要不停机的长 DDL 属后续议题,首版要求运维窗口 | + +回退边界:schema 回退通过回退镜像 + 向后兼容 schema 实现;**已经发生的外部数据库写入不能通过回退镜像撤销**。 + +--- + +## 14. 部署形态 + +首版是**单进程模块化单体**:HTTP listener、admin service 与 execution worker 同处一进程,因此 CM-40(关闭浏览器/窗口后 Job 继续)无需额外的后台进程分离即可成立——Job 生命周期不挂在任何 UI 连接上。 + +```mermaid +flowchart TB + B[浏览器 / 桌面团队客户端] -->|HTTPS 同源| P[TLS 终止:进程内 rustls 或前置反向代理] + P --> S[datazen-server 单进程] + S --> M[(管理数据库 PostgreSQL,独立于用户业务库)] + S --> K[SecretProvider / KMS] + S --> A[(ArtifactStore 字节存储)] + S --> W[用户业务数据库(受出站策略约束)] + X[Web 静态资源] -.同源.-> P + Y[Wapp 静态资源] -.独立受限 origin.-> P +``` + +| 项 | 首版决定 | +| --- | --- | +| 管理数据库 | **PostgreSQL**([概要 §6.4](system-overview.md#64-repositories-与环境-ports) 已给结论:管理组织、配置版本、任务、审计和额度,不与用户业务数据库混用),独立实例/独立库 | +| TLS | 进程内 rustls 或前置代理终止;**同源 HTTPS**,浏览器只看到一个 origin(Wapp 静态资源除外) | +| rustls 初始化 | 进程级 `CryptoProvider` 选择与 `src-tauri/src/tls.rs` 同源,但 server 独立初始化(不能依赖桌面 host 的入口) | +| 资源额度 | 组织/用户/数据库三级 + 控制与 cluster 资源统计,进程内会计 | +| 客户端一致性 | 同一用例经 IPC(桌面)与 HTTP(Web)运行,断言结果与错误语义一致(CI 门禁) | +| 不承诺 | 会话无损故障转移、跨服务器原子事务、浏览器代理数据库流量、驱动后端直迁 | + +**本文不覆盖完整部署运维手册。** 发布门槛要求部署文档另行说明管理数据库、SecretProvider、网络可达性、备份、迁移、TLS 与资源额度,以及「管理员能恢复服务元数据,但**不声称**活动数据库会话可恢复」——那属于部署文档的职责范围,本节只给服务可运行所必需的形态。 + +--- + +## 15. Wapp / EP / Theme 在 Web 上的边界 + +### 15.1 Wapp iframe 与桥接 + +Web 场景下 Wapp iframe 使用**独立受限 origin**(与主应用不同源),因此桌面 `datazen://` 的部分假设不成立,必须显式收紧: + +| 校验项 | 桌面(`datazen://`) | Web(独立受限 origin) | +| --- | --- | --- | +| iframe 沙箱 | 自定义 scheme + `ASSET_CSP`(`connect-src 'none'` 等,见 `src-tauri/src/wapps/protocol.rs`) | `sandbox` 属性:`allow-scripts allow-forms`,**不含** `allow-same-origin`/`allow-top-navigation`/`allow-popups`/`allow-modals`;CSP `connect-src 'none'; frame-ancestors 'none'` | +| `event.source` | 必须是该 iframe 的 `contentWindow`(对齐 `src/lib/wappBridge.ts`) | 同上,且必须匹配已注册 iframe | +| `event.origin` | 不透明 origin,无法按名匹配 | **必须等值于配置中登记的 Wapp origin**,否则丢弃 | +| 一次性 nonce | 依赖 scheme 不可被外部页面引用 | 创建 iframe 时交换,握手后立即失效,重放被拒 | +| 消息 schema / 大小 | envelope + 权限声明 | 同上,另加大小上限与并发上限(对齐 `MAX_INFLIGHT_REQUESTS` 的思路) | +| `targetOrigin` | SDK 侧使用 `'*'`(不透明 origin 无法具名,见 `packages/wapp-sdk/src/bridge.ts` 的注释) | **必须使用精确 origin,禁止 `'*'`** | +| 权限 | manifest 声明,deny-by-default | 同左,另加服务端组织 ACL 复核 | + +桥接的硬性约束(跨桌面与 Web 一致): + +- 只暴露 manifest 声明过的 Command 与 Artifact 操作,缺声明先 `E_PERMISSION`; +- **绝不传递** `dbSessionId`、`attachmentToken`、取消句柄或数据库凭据; +- 所有操作经当前 backend 的 `BackendClient`,父应用与服务端各自重新鉴权,Wapp 不能直连 driver 或 HTTP API; +- iframe 跨域且 `SameSite` 生效,用户 cookie 不会随 Wapp 请求发送,Wapp 也不能自行调用任意服务端 URL; +- Wapp 卸载或撤权即刻关闭桥接订阅。 + +### 15.2 EP 与 Theme + +- **Web server 不加载桌面 native EP**,也不把 EP 包下发到浏览器执行;团队版若需要同等能力,必须走显式授权的服务端 API(§6.4 的 `delegationId` 路径)。 +- **Theme 只分发静态资源**(manifest、`tokens.css`、编辑器/图表 JSON、图标),经 schema 校验后发布;Web CSP 禁止脚本执行;主题不能调用 backend、不能访问 secret。 + +--- + +## 16. 验收映射 + +### 16.1 P7 侧需提交的 Web 断言(这些用例的首次落地门槛见[连接 §17](connection-management.md#17-验收标准与证据)) + +| 用例 | 本文小节 | 本文负责的断言要点 | +| --- | --- | --- | +| CM-05 跨用户/组织资源访问 | [§6](#6-授权模型)、[§7](#7-不可见资源的统一-404)、[§8](#8-sse-实现)、[§11](#11-artifactstore-服务端实现) | 读取/执行/关闭/取消/订阅/下载全部拒绝,不暴露存在性,不触发 driver | +| CM-06 前端伪造 owner | [§6.5](#65-前端角色标记禁令)、[§3](#3-请求中间件链) | body 身份字段无法覆盖 `RequestContext`,不创建会话 | +| CM-07 目标缺失与冲突 | [§9.2](#92-apierror--http-映射表) | 400 映射;目标规范化沿用 [连接与管理 §4.3](connection-management.md#43-命名空间规范化契约) | +| CM-38 配置修改与凭据轮换 | [§10.1](#101-实现矩阵)、[§6.2](#62-权限版本与-policyisolationkey) | CAS 冲突;新资源用新材料;旧 idle 不再发放;旧会话不被改配置 | +| CM-39 配置禁用/删除 | [§6.3](#63-撤销传播)、[§10.1](#101-实现矩阵) | 新/排队请求不执行;已运行操作按实际取消结果记录;历史可定位原配置版本 | +| CM-40 任务关闭窗口继续 | [§14](#14-部署形态)、[§8.2](#82-last-event-id-回放) | Job 不随连接释放;重订阅不重新创建 Job;权限仍检查 | +| CM-53 AI/MCP/Wapp 归属 | [§6.4](#64-委托身份delegationid)、[§15.1](#151-wapp-iframe-与桥接) | 授权后同队列可见;未授权拒绝;断开只清理自身资源 | +| CM-54 超时与幂等写 | [§10.4](#104-幂等回执存储)、[§9.2](#92-apierror--http-映射表) | 指纹冲突与过期的判定发生在服务端 | +| CM-55 重复/乱序/大 revision 事件 | [§8.2](#82-last-event-id-回放) | sequence 连续;`Counter` 不转 JS Number;超窗口发 `streamResetRequired` | +| CM-56 断网与事务 TTL | [§4.3](#43-服务端登录会话)、[§8.4](#84-权限复核与登录过期) | 登录空闲/绝对期在服务端裁定;SSE 定时复核 | +| CM-59 登出/权限撤销/CSRF(**阶段归属见[开发计划 §17](../../development/platform-development-plan.md#17-补充契约的阶段归属) 的 P7/P8 行「CM-59 的 W1 断言」**,故本节断言属 P7/P8 门槛范围) | [§5](#5-csrf)、[§4.5](#45-登出与会话撤销)、[§6.3](#63-撤销传播)、[§8.4](#84-权限复核与登录过期) | 跨站变更被拒;旧订阅关闭;排队不执行;运行中按实际结果记录 | +| CM-60 资源压力与 drain(单实例部分) | [§9.1](#91-请求限制)、[§10.3](#103-sessiondirectory-与-budgetcoordinator单实例)、[§14](#14-部署形态) | 总量/控制保留/逻辑会话/队列上限生效;性能门禁在单实例形态下测量。owner 路由、worker 分区、跨节点预算归 P9 | +| CM-63 的 **W1** 断言 | [§10.4](#104-幂等回执存储) | 会话回执仅内存、绑定 `runtimeEpoch`、owner 丢失不重建 | +| CM-68 的 **W1** 断言 | [§10.3](#103-sessiondirectory-与-budgetcoordinator单实例) | 候选替换 `prepared` 不可路由;提交原子;丢响应返回同结果 | +| CM-70 的 **W1** 断言 | [§9.2](#92-apierror--http-映射表)、[§10.4](#104-幂等回执存储) | 过期令牌 409;保留 ≥24h;删除后仍拒绝 | +| CM-72 的 **W1** 断言 | [§9.4](#94-apierrorcode-与-executionerrorcode) | 两个错误命名空间不混用;序列化大小写一致 | +| (无编号断言)浏览器无需数据库凭据 | [§10.2](#102-secretprovider)、[§4.1](#41-登录流程) | 登录只经 OIDC;profile 列表只返 `publicOptions` | +| (无编号断言)组织隔离、CSRF、产物与事件越权全部拒绝 | [§6](#6-授权模型)、[§5](#5-csrf)、[§11](#11-artifactstore-服务端实现)、[§8](#8-sse-实现) | 本文四个核心小节共同覆盖 | + +### 16.2 CI 门禁 + +| 门禁 | 落点 | +| --- | --- | +| server 依赖闭包无 Tauri/UI runtime + 可独立构建 | [§2.2](#22-cargo-依赖方向与禁止闭包) | +| application/runtime 无 Tauri/server/UI 依赖 | 依赖同一条检查(正向) | +| DTO 与 API schema 生成漂移 | 覆盖 §9.4 的枚举字面值一致性 | +| IPC/HTTP 适配一致性(同一用例同语义) | 覆盖 §6.5 的身份字段不可覆盖 | +| Web 身份/权限/CSRF/SSE/Artifact 集成 | 覆盖 [§4](#4-认证oidc)、[§5](#5-csrf)、[§6](#6-授权模型)、[§7](#7-不可见资源的统一-404)、[§8](#8-sse-实现)、[§11](#11-artifactstore-服务端实现) | +| runtime fake 故障/竞态/预算 | 覆盖 §10.3;多 worker 分区断言归 P9 | + +--- + +## 17. 与既有设计的关系 + +- 本文是 [系统概要设计 §6.3、§9.1、§10](system-overview.md#63-首版-http-映射) 的实施级展开,不改变其中的路由表、状态码与安全总纲;如有冲突以概要设计为准。 +- DTO、服务接口、错误码表、幂等与会话规则以 [连接与会话管理](connection-management.md) 为权威,本文只描述它们在 HTTP 宿主上的落地方式。 +- 管理库表结构以 [持久化模型](persistence-model.md) 为权威;本文的 [§10](#10-服务端仓储与-ports-实现) 与 [§13](#13-schema-migration-运行方式) 只描述运行行为。本文 [§4.3](#43-服务端登录会话) 的登录会话行与 [§6.1](#6-授权模型) 的成员关系 / ACL / 权限版本已由该文档 [§3.10 认证与授权表](persistence-model.md#310-认证与授权表) 登记为 `login_sessions` / `memberships` / `acl_entries` / `permission_versions`(固定角色用 CHECK 表达,不建 `roles` 表)。 +- 阶段交付与退出门槛以 [平台开发计划 §11](../../development/platform-development-plan.md#11-p7单实例团队-web-服务) 为准;P9 的多 worker 与全局预算不在本文范围。 +- 现有 `src-tauri` 的 store/keyring/file 能力是**桌面**实现,服务端不复用其存储形态(见 [§10.1](#101-实现矩阵)),但沿用其错误分类、日志脱敏与安全约定(`CommandError`、`log_redact`、路径遍历防护、扩展名白名单)。 +- 现有 Wapp 桥接在 `datazen://` 不透明 origin 下必须使用 `'*'` target origin;Web 场景 origin 已知,因此本文**收紧**为精确 origin,这是有意的差异而非不一致([§15.1](#151-wapp-iframe-与桥接))。 + +## 18. 本文不覆盖什么 + +- **完整部署运维手册**:备份、恢复演练、监控告警、容量规划、滚动升级与故障处置流程。发布门槛要求部署文档另行覆盖;本文 [§14](#14-部署形态) 只给服务可运行所必需的形态。 +- **P9 的多实例形态**:多 API 实例会话路由(CM-57)、worker 分区与失租接管(CM-58)、CM-60 的 WN 部分、全局预算分配与保守回收。 +- **持久化表结构**:列、索引、约束与版本演进细节在 [持久化模型](persistence-model.md)。 +- **连接与执行语义本身**:会话连续性、切库、事务、来源标注、结果完整性等由 [连接与会话管理](connection-management.md) 定义。 +- **前端交付**:Web shell 的页面/面板替换、文件选择与剪贴板实现属于 P7 前端交付,本文不描述组件结构;桌面团队客户端(P8)的 backendId、per-backend client 与跨 backend 迁移限制同样不在本文范围。 +- **治理细则**:角色自定义、策略语言、多因素认证、组织层级、配额计费模型。 +- **已实现事实**:本文所有模块路径与类型名均为目标命名,落地后按仓库纪律改写为事实并更新索引。 diff --git a/docs/architecture/platform/workflow-resource-model.md b/docs/architecture/platform/workflow-resource-model.md new file mode 100644 index 000000000..469ceb4e4 --- /dev/null +++ b/docs/architecture/platform/workflow-resource-model.md @@ -0,0 +1,799 @@ +# DataZen Workflow 资源模型详细设计(step / session block / transaction block) + +> 状态:**目标设计,尚未实现**。基线:2026-09-30,`8592b0fe1`。 +> 本文描述的 step / session block / transaction block 三种执行单元、目标继承链、Job 归属与版本兼容边界,在当前代码中**均不存在**;现状见第 2 节,落地顺序见 [开发计划 §10](../../development/platform-development-plan.md#10-p6workflowaimcpwapp-与辅助任务)。 +> **本文是破坏性模型变更,旧 Workflow 定义的行为会变化,迁移路径见第 10.6 节。** +> 配套文档:[系统概要](system-overview.md#4-模块职责与边界)(§4 Workflow Runtime、§6.4 repositories、§7.3)、[连接管理详细设计](connection-management.md#91-默认资源政策)(§9.1 资源政策、§9.5 资源类别、§11 Workflow 语义、§16.5 验收用例)。 +> 已实现部分见 [Workflow 架构](../backend/workflow.md) 与 [Workflow 使用指南](../../features/workflow-guide.zh-CN.md)。 + +--- + +## 1. 目标与范围 + +### 1.1 为什么需要独立成篇 + +[连接管理 §11](connection-management.md#11-workflow--ai--mcp--wapp) 给 Workflow 的资源语义只有 3 段散文(第 4 段是 AI/MCP/Wapp 边界),[系统概要 §7.3](system-overview.md#73-三件套与-workflow) 讲 Workflow 只有 1 句。本文把这些意图展开到可直接编码的粒度:三种执行单元的判定规则、目标解析算法、Job 归属、重试范围、计划预校验和版本兼容。 + +这是平台改造中**唯一破坏已发布功能**的部分:Workflow 引擎已经上线(GUI、Tauri IPC、MCP、调度器、Dashboard 五个入口共用),YAML 定义已经存在用户文件、注册表内建样例和 Dashboard 引用,而三种资源范围此前并不存在。因此本文同时给出现状事实(第 2 节)和破坏性迁移路径(第 10 节)。 + +### 1.2 覆盖范围 + +| 覆盖 | 不覆盖 | +| --- | --- | +| 三种执行单元的模型、YAML、校验与运行时行为 | QueryPanel / TablePanel 的会话与预算(见[连接管理 §9](connection-management.md#9-连接池预算与清理)) | +| Workflow 目标的四级继承与冲突处理 | 三件套 Job 的阶段资源语义(见[连接管理 §10](connection-management.md#10-三件套与-job-实现细则)) | +| Workflow 执行是否包裹 Job、OwnerRef 与资源释放时机 | AI / MCP / Wapp 自身的授权与协议细节 | +| 并行分支资源划分、重试范围、Checkpoint 边界 | 驱动方言差异与命令实现 | +| transaction block 的计划预校验与 `UnsupportedPlan` | 事务隔离级别选型与锁策略 | +| 定义版本解析与旧定义兼容 | Workflow 步骤类型的通用语法(见[使用指南](../../features/workflow-guide.zh-CN.md)) | + +### 1.3 术语 + +沿用既有文档,不重新定义 DTO:`connectionId`(持久化连接配置 id)、`dbSessionId`(运行时数据库会话 id,内存态,永不落盘)、`ExecutionTarget`、`SessionHandle`、`OwnerRef`、`ResourceLease`、`Job`、`Checkpoint`、`UnsupportedPlan`。类型与字段形状见[连接管理 §4](connection-management.md#4-dto-与字段定义),各对象职责与归属见[连接管理 §1](connection-management.md#1-开发前先理解的六个对象),本文只引用不复述。 + +- **block**:一个声明了资源范围与生命周期的执行单元容器,可包含多个 step。 +- **step**:block 内的最小可重试、可观测执行节点。 +- **资源范围(resource scope)**:一个 block 在其生命周期内允许持有的物理资源形态:每次初始化的短操作、跨 step 保持的固定 session、或 runtime 托管的事务。 + +--- + +## 2. 现状事实(以 2026-09-30 `8592b0fe1` 代码为准) + +本节全部结论来自实际读过的代码。凡未能在代码中验证的,写为「待 P0 验证」并汇总在[第 12 节](#12-待-p0-验证清单)。 + +### 2.1 模块结构 + +`src-tauri/src/workflow/` 实际有 14 个源文件。`mod.rs` 只声明 9 个模块:8 个 `pub mod` 加 1 个 `pub(crate) mod migration`(`migration` 不是公开模块);`conditions.rs`、`context.rs`、`data_step.rs`、`executor.rs` 更是通过 [`workflows.rs`](../../../src-tauri/src/workflow/workflows.rs) 的 `#[path]` 声明为私有子模块,不出现在 `mod.rs` 中。`[已实现的 Workflow 架构](../backend/workflow.md)` 的文件清单(10 项)缺 `data_step.rs`、`error.rs`、`migration.rs`、`scheduler.rs`,且把 `#[path]` 子模块当成平铺模块描述。 + +`data_step.rs`(749 行)**不涉及任何数据库资源**,内容是 `merge` / `transform` 的行集合并与表达式求值(`run_merge`、`run_transform`、`evaluate_expression`)。`context.rs` 负责变量与模板上下文,`conditions.rs` 负责条件比较。 + +### 2.2 定义模型 + +[`model.rs`](../../../src-tauri/src/workflow/model.rs) 的 `WorkflowDefinition` 为扁平结构:顶层 `connection`(默认连接,可为 `{{var}}` 模板)与 `database`(默认库)被 step 继承;**没有任何 block 层级字段**。`WorkflowStep` 用 `#[serde(tag = "type")]` 区分 `query` / `command` / `ai` / `condition` / `foreach` / `migration` / `merge` / `transform`。 + +| 观察 | 证据 | +| --- | --- | +| `query` step 有顶层 `database` 字段 | `WorkflowStep::Query { id, sql, connection, database, timeout_secs, on_error }` | +| `command` step **没有**顶层 `database` 字段 | `WorkflowStep::Command { id, command, connection, input, timeout_secs, on_error }`,库名只能写在 `input.database` | +| `ai` step **没有** `connection` / `database` 字段 | AI 步骤当前不接触目标资源 | +| `version: Option` 存在 | `WorkflowDefinition.version` | +| 三个内建 YAML 均未写 `version` | [`src-tauri/resources/builtin-workflows/`](../../../src-tauri/resources/builtin-workflows) 下的 `hello-query.yaml`、`cross-db-sample.yaml`、`ai-summarize.yaml` | + +### 2.3 版本字段的现状(重要) + +`version` 字段在**全仓库范围内只被写入,从未被读取**: + +- 没有任何按版本分派的解析代码(无 `match version`、无 `CURRENT_VERSION` 比较,三个内建 YAML 也未设置该字段); +- 生产代码写入点只有 `src-tauri/src/dashboard/create.rs:64` 的 `version: None` 字面量;其余 11 处全在测试代码内(`workflow/registry.rs:260`、`workflow/executor.rs:901/938/972`、`workflow/scheduler.rs:231`、`workflow/migration.rs:892`、`commands/dashboard.rs:461`、`commands/ai/integration_tests.rs:356/906/1039/1085`),其中仅 `integration_tests.rs:356` 写 `Some("1")`,同样无人读取。 + +对比:仓库中其他"定义版本"确有实现——`schema_diff/profile.rs:44-47` 的 `CURRENT_VERSION` 并在遇到未知版本时报 "unsupported ... version";`data_sync::SyncProfile`、`data_transfer::profile.rs` 同样有 `CURRENT_VERSION`;`store/favorites/migrate.rs` 有 `MIGRATION_VERSION`。Workflow 是这些带 `CURRENT_VERSION` 校验的定义中唯一未校验的一个;另需注意 `store/settings.rs:15` 的 `OnboardingState.version` 同样只写不读(生产路径只读 `completed`)。 + +### 2.4 目标解析与资源获取 + +[`executor.rs:124`](../../../src-tauri/src/workflow/executor.rs): + +```rust +let workflow_connection = workflow.connection.as_deref().or(connection_id); +let workflow_database = workflow.database.as_deref(); +``` + +`connection_id` 是**调用方传入的运行时参数**,由 IPC / MCP / 调度器决定,是 P6 要消除的隐式目标来源。当前优先级因此只有两级:step 显式 → (workflow 默认 → 调用方 connectionId)。 + +query 分支([executor.rs:444-489](../../../src-tauri/src/workflow/executor.rs)): + +1. `step_database = step.database`(可为 `{{var}}`,先做模板解析); +2. `inherited_database = workflow_database`; +3. `resolved_database = step_database.or(inherited_database)`; +4. 仅当 `resolved_database.is_none()` 且 `database_is_required(connection_database, driver_has_multi_database)` 时报 `MissingDatabase`;该判定只对多库驱动生效,PostgreSQL 驱动在 `resolve_connect_database` 中硬编码 `"postgres"`。 + +command 分支([command_runtime.rs`](../../../src-tauri/src/workflow/command_runtime.rs)): + +1. `resolve_connection_id(step, workflow_connection)` → `step.effective_connection(workflow_connection)`,缺失报 `MissingConnection { step_id }`; +2. `inject_inherited_database(input, workflow_database)`:显式非空 `input.database` 优先,空白串按未设置处理,trim,非对象则空操作; +3. `connection_manager.resolve_session_for_connection(connection_id)` 返回 `(runtime_id, driver, handle)`;该方法内部按 **`session_owner_map` 里 owner == 配置 id** 查找已有会话,找不到则 `connect(connection_id)` 新建(`resolve_session_for_connection` 见 [sessions.rs:92-103](../../../src-tauri/src/services/connection_manager/sessions.rs),owner 查找与新建在同文件 `get_or_connect_session`,[sessions.rs:8-37](../../../src-tauri/src/services/connection_manager/sessions.rs)); +4. 在 `driver.command_definitions()` 中查 id,未找到报 `UnsupportedCommand`,`metadata.workflow == false` 报 `CommandNotInWorkflow`; +5. `validate_command_input` + 按 `permission_mode` 的 `check_command_access`;id 为 `"query"` / `"execute"` 时再过 `sql_guard::check_sql` 与 `mcp::permission::check_sql_allowed`; +6. `inject_sql_target_fields(&mut input, database, schema)` 后 `driver.execute_command`。 + +`command_runtime.rs:115-119` 的注释明确记录了目标定位策略:**不再做 session 切库**,目标随 command input 下传,由支持 `qualify_sql_target` 的驱动就地改写未限定关系名;当前 6 个 SQL 驱动(clickhouse、sqlite、duckdb、mysql、postgres、sqlserver)均已实现该方法,mongodb 虽声明 `has_multi_database` 但不走 SQL 路径。 + +### 2.5 调度、历史与 Job + +| 观察 | 证据 | +| --- | --- | +| 调度器不包裹 Job | `scheduler.rs:176` 直接调用 `commands::workflow_execute_impl(state, id, json!({}), None)`,连接参数恒为 `None` | +| 调度器无资源会计 | `WorkflowScheduler` 只有 `app_handle` / `paused` / `last_run` / `in_flight` / `cancel_token`,无预算与阶段字段;间隔下限 30 秒、默认 3600 秒,进程启动首次观察只上膛不触发 | +| Dashboard 同样不传连接 | `dashboard/execute.rs:222` 传 `None` | +| MCP 入口显式可选 | `mcp/tools.rs` 的 `run_workflow` 接受 `connection_id: Option`,只读模式下 `run_workflow` 被 `READ_ONLY_BLOCKED_TOOLS` 拦截 | +| 历史按可见性写入 | `commands/workflow.rs`:`visibility == DashboardHidden` 不写历史;变量经 `migration::sanitize_variables` 脱敏 | +| 全链路**没有平台 Job 概念** | Workflow 执行不产生 jobId、不进 `JobRepository`、没有阶段资源与检查点;连 `migration` step 也把三件套请求的 `job_id` 传 `None`([migration.rs:509](../../../src-tauri/src/workflow/migration.rs)) | + +### 2.6 错误与事务 + +`error.rs` 的 `WorkflowError` 共 13 个变体,IPC 边界仍用 `into_ipc_string()` **把错误拍平成字符串**:没有结构化 `ExecutionErrorCode`,没有 `UnsupportedPlan`、`TargetRequired`、`OutcomeUnknown` 等目标化错误码。全仓库 grep 不到 `UnsupportedPlan` 在 Workflow 路径上的产生点。 + +事务:Workflow 执行路径**完全不管理事务**。`services/transaction.rs` 的 `TransactionScope` 目前唯一的生产消费者是 `schema_diff/deploy.rs`。`migration.rs`(三件套 step 执行器,**不是定义版本迁移机制**)内部用 `RuntimeSession { db_session_id, database, schema }` 表达自己的端点会话并在结束时 `release`,同样不暴露给 Workflow block 语义。 + +### 2.7 现状与目标的差距小结 + +| 维度 | 现状(已验证) | 目标(第 3–10 节) | +| --- | --- | --- | +| 执行单元 | 只有 step,扁平 | 独立 step / session block / transaction block | +| 目标来源 | 3 处:step、workflow 默认、调用方 connectionId | 4 级:step 显式 → block → workflow 默认 → profile 初始目标 | +| 资源持有 | 每次 command 独立 `resolve_session_for_connection` | block 声明范围,session/transaction block 跨 step 固定 Lease | +| 事务 | 无 | runtime 控制 begin/commit/rollback,含计划预校验 | +| 归属 | 无 | 本文提议的 Job 包裹 + `OwnerRef { kind: 'workflowBlock' }`([§5.1](#51-workflow-执行是否包裹-job),待 P3 定案) | +| 版本 | 字段存在,从不读取 | 按版本分派解析,未知版本拒绝 | +| 错误 | 字符串 | 版本化 `ExecutionErrorCode` + `effectOutcome` | +| Checkpoint | 无 | 稳定目标、版本、映射指纹、已确认提交边界 | + +### 2.8 一处已确认的身份错配 + +GUI 的 AI 侧栏把**运行时会话 id 当作 `connectionId` 传给 Workflow**:[`components/ai/WorkflowPanel.tsx:104`](../../../src/components/ai/WorkflowPanel.tsx) 在 `handleExecute`(L99-107)里执行时传 `connectionId: dbSessionId`,而 `dbSessionId` 来自 `activeConnections[conn.id]?.dbSessionId`(连接窗口的运行时会话标识)。后端 `resolve_session_for_connection` 按 owner **配置 id** 查表,按[命名规范](../../../AGENTS.md) 这是被禁止的双模混用。同一页面的独立 Workflow 窗口([`WorkflowPage.tsx:417`](../../../src/windows/workflow/WorkflowPage.tsx))则完全不传该参数。 + +这正是第 9 节"AI/MCP/Wapp 与 Workflow 边界"要清理的入口耦合,实际失败表现列入[第 12 节](#12-待-p0-验证清单)。 + +--- + +## 3. 目标资源模型:三种执行单元 + +### 3.1 定义 + +| 执行单元 | 资源范围 | 生命周期 | 原子性承诺 | 状态跨 step | +| --- | --- | --- | --- | --- | +| **独立 step** | 每次执行独立初始化的短操作资源 | 单个 step | 无 | 不保留 | +| **session block** | 一条跨 step 连续的固定 Lease | block 首个 step 开始 → 最后一个 step 结束 | 无 | 保留(临时对象、会话变量、已确认上下文) | +| **transaction block** | 一条固定 Lease + runtime 托管事务 | begin → commit/rollback 确认 | 提交边界由 runtime 保证 | 保留,且在提交前对外不可见 | + +三种单元共享同一套约束:资源 owner 明确([第 5 节](#5-session--lease--budget-集成))、target 操作不改变任何其他 session 的默认库或事务([INV-05](connection-management.md#3-不可破坏的不变量))、实际上下文由 driver 确认而非 SQL 文本推断([INV-04](connection-management.md#3-不可破坏的不变量))。 + +### 3.2 独立 step + +每次执行独立初始化资源,步骤内的 `USE` / `SET search_path` 等会话状态**只在该 step 内有效**,不泄漏到后续 step,也不在步骤之间保留临时对象。独立 step 用顶层 `steps:`(v2 保留的 v1 扁平容器,见 [§8.1](#81-顶层) 与 [§10.2](#102-按版本分派解析)),无需 block 包裹。 + +```yaml +version: "2" +id: independent-steps +name: 独立 step 默认语义 +steps: + # step1 自己的资源上切库;只在本 step 内生效 + - type: query + id: probe + connection: "{{conn}}" + database: "reporting" + sql: "SELECT current_database() AS db, count(*) AS n FROM orders" + + # 未指定目标 → 回到 workflow 默认目标,不继承 step1 的 reporting + - type: query + id: aggregate + sql: "SELECT count(*) AS total FROM orders" + + # 显式目标优先于 workflow 默认目标 + - type: query + id: archive + connection: "{{archive_conn}}" + sql: "SELECT count(*) AS archived FROM orders" +``` + +这与 [CM-50](connection-management.md#165-三件套与-workflow) 的断言一一对应:step1 用自己的资源 `USE B`,step2 用 A,step3 用 C,step1 状态不泄漏,资源全部清理。**旧定义在目标 runtime 下的默认语义就是独立 step**([第 10.4 节](#104-旧定义在目标-runtime-下的默认语义))。 + +### 3.3 session block + +block 内所有 step 在**同一条物理资源**上串行执行,状态跨 step 保留;块结束才释放。并行分支各自获得独立资源([第 6 节](#6-并行分支与重试)),**同一 session block 永不并行执行**——同一 blockId 在同一 Workflow run 内同时只能有一个执行实例。 + +```yaml +version: "2" +id: session-block-demo +name: 临时对象跨 step 可见 +blocks: + - type: sessionBlock + id: staging + connection: "{{conn}}" + database: "analytics" + steps: + - type: command + id: create_temp + command: "create_temp_table" + input: { name: "matched_orders" } + + - type: command + id: fill_temp + command: "insert_rows" + input: + source: "steps.fetch.result" + + # 与 create_temp 同一物理资源:临时表可见 + - type: query + id: verify_temp + sql: "SELECT count(*) AS n FROM matched_orders" + + - type: query + id: force_reconnect + # 声明了需要重连的 database 变更,静态校验直接拒绝 + database: "analytics_archive" + sql: "SELECT 1" +``` + +规则: + +1. block 首个 step 触发资源获取,其后所有 step 复用同一 Lease,满足 [INV-02](connection-management.md#3-不可破坏的不变量)(driver 不得内部换连接)。 +2. 同一 session 的普通执行串行([INV-03](connection-management.md#3-不可破坏的不变量));取消走独立控制路径。 +3. block 内的 step 不得声明 `database` 变更到需要**重连**的库(如 PostgreSQL 换库需新连接)。声明了则静态校验拒绝,运行期只能通过 driver 声明的**不需重连**的上下文切换(如 MySQL `USE`),且切换后必须由 driver 确认实际上下文([INV-04](connection-management.md#3-不可破坏的不变量))。 +4. block 结束无条件释放:走[§9.4 归池前检查](connection-management.md#94-归池前检查),失败不归池。 +5. session block **可以**执行原生任意 SQL,但**不提供** runtime 管理的整块原子性([第 7 节](#7-transaction-block-计划预校验))。 + +### 3.4 transaction block + +runtime 控制 begin / commit / rollback,步骤在事务内执行且**不得破坏提交边界**。 + +```yaml +version: "2" +id: transfer-block +name: 受控 DML 事务块 +blocks: + - type: transactionBlock + id: apply + connection: "{{conn}}" + database: "orders" + steps: + - type: command + id: debit + command: "order_debit" + input: { order_id: "{{order_id}}", amount: "{{amount}}" } + + - type: command + id: credit + command: "order_credit" + input: { order_id: "{{order_id}}", amount: "{{amount}}" } + + - type: query + id: assert_balance + sql: "SELECT balance FROM ledger WHERE order_id = '{{order_id}}'" +``` + +规则: + +1. **开始前**由 driver 校验整份计划能否保证提交边界([第 7 节](#7-transaction-block-计划预校验)),不能证明则整块拒绝,返回 `UnsupportedPlan`,**不执行任何语句**。 +2. 事务开启时若实际事务状态为 `unknown`([§6.1 Session 状态机](connection-management.md#61-session-状态机)),拒绝开始,返回 `TransactionResolutionRequired`。 +3. 任一 step 失败 → runtime 执行 rollback 并记录 `effectOutcome`;只有**已证实回滚**才允许重试整块([第 6.2 节](#62-重试范围))。 +4. commit 发出后若结果无法确认 → `effectOutcome = unknown`,`errorCode` 取**实际成因**(`protocolError` / `timeout`),**不自动重试**([INV-12](connection-management.md#3-不可破坏的不变量))。 +5. 需要重连的 database 变更**不得**进入已开启的 transaction block;静态校验拒绝,运行期也不提供中途换库。 +6. 事务句柄按 [§6.5 会话级资源句柄登记](connection-management.md#65-会话级资源句柄登记) 在终态前登记到 session actor;归池前必须为空。 + +### 3.5 对照 + +| 维度 | 独立 step | session block | transaction block | +| --- | --- | --- | --- | +| 资源获取时机 | 每次 step | block 首 step | block 首 step | +| 资源释放时机 | step 结束 | block 结束 | commit/rollback 确认后 | +| step 间状态 | 不保留 | 保留 | 保留,提交前对外不可见 | +| 并行 | 允许 | 同一 block 不允许 | 同一 block 不允许 | +| 原子性 | 无 | 无 | 提交边界由 runtime 保证 | +| 可含任意 SQL | 是 | 是 | 受限(需可证明) | +| 默认 checkpoint 恢复 | 不适用 | **不允许从中间恢复** | 整块重跑或核验 | +| 目标冲突行为 | 各自解析 | 同一 block 内必须一致 | 同一 block 内必须一致 | + +--- + +## 4. 目标继承与覆盖优先级 + +### 4.1 四级链 + +``` +step 显式目标 > block target > workflow 默认目标 > profile 初始目标 +``` + +这是[连接管理 §11](connection-management.md#11-workflow--ai--mcp--wapp)第一句的展开。四级的定义: + +| 级别 | 来源 | 适用范围 | 备注 | +| --- | --- | --- | --- | +| L1 step 显式目标 | step 的 `connection` / `database` / `input.database` | 单个 step | 最高优先级;必须是**完整**目标(connectionId + 命名空间),不允许只给 connectionId 让下游猜库 | +| L2 block target | block 的 `connection` / `database` | 整个 block | 独立 step 模式下等价于 workflow 默认 | +| L3 workflow 默认目标 | 顶层 `connection` / `database` | 全部 step | 保留旧定义的既有字段 | +| L4 profile 初始目标 | `ConnectionProfile` 绑定的初始 database | 全部 step | 不可被调用方参数覆盖 | + +**调用方参数不在链内。** GUI 当前连接、MCP `connection_id`、Dashboard 当前库都不再参与解析,这是 P6 的核心目的([开发计划 §10](../../development/platform-development-plan.md#10-p6workflowaimcpwapp-与辅助任务) "消除 GUI 外入口的隐式目标和共享状态")。旧链中 `workflow.connection.or(caller_connection_id)` 的回退分支在 v2 中删除;需要"跟随当前连接"的语义改为在 block 或 step 上**显式**写一个绑定当前编辑器 session 的引用([第 9 节](#9-ai--mcp--wapp-与后台入口的边界))。 + +### 4.2 解析算法 + +对每个需要资源的数据 step,按序执行: + +```text +resolveTarget(step, block, workflow, profile) -> ExecutionTarget | error + +1. t = normalize(step.target ?? step.connection + step.database) # L1 +2. 若 t 完整(connectionId + 必需命名空间)→ 返回 t +3. t = normalize(block.connection + block.database) # L2 +4. 若 t 完整 → 返回 t +5. t = normalize(workflow.connection + workflow.database) # L3 +6. 若 t 完整 → 返回 t +7. t = profile.initialTarget # L4 +8. 若 profile 绑定 database 但 profile 缺失该 database → TargetRequired +9. 若解析出的 connectionId != block 已持有资源的 connectionId 且 block 为 + session/transaction block → TargetConflict(见 4.3) +10. 否则按 ExecutionGateway::executeAtTarget 执行 +``` + +`normalize` 的规则:模板 `{{var}}` 先在 step 开始前解析成字面量;空串与纯空白按**未设置**处理(与现状 `inject_inherited_database` 一致);`connection` 必须是 `connectionId`,不接受 `dbSessionId`([命名规范](../../../AGENTS.md));多库驱动下 `database` 缺失即 `TargetRequired`,不允许静默使用会话当前库。 + +### 4.3 冲突处理 + +| 情形 | 处理 | 错误码 | +| --- | --- | --- | +| step 显式目标与 block 目标指向不同 connectionId,block 是 session/transaction | 静态校验在执行前拒绝整块 | `TargetConflict` | +| 同一 block 内两个 step 目标不同,但差异只在命名空间 | 静态拒绝;跨库不是 session block 的能力 | `TargetConflict` | +| 需要切库但该库要求新连接 | 静态拒绝(`connection-management §8` 的 driver 契约) | `TargetUnsupported` | +| 解析结果缺必需 database | 运行时按实际驱动能力判定 | `TargetRequired` | +| 目标资源不可用 / 队列满 | 不创建额外连接,可取消等待 | `ResourceBusy` / `QueueFull` | +| 并行分支写同一对象 | 允许,但提交冲突按乐观锁判定,不静默覆盖 | `TargetConflictRows` | + +### 4.4 禁止项 + +- 禁止为了定位目标而改写任意 SQL 的所有关系名;受控对象由 driver 渲染限定名,任意 SQL 用真实会话目标([连接管理 §8](connection-management.md#8-multidb-与用户-sql))。 +- 禁止用 `connectionId` 兜底查已有会话再失败回落新建([INV-08](connection-management.md#3-不可破坏的不变量) 的透明重建禁令)。 +- 禁止把 `dbSessionId` 写入 Workflow 定义、Job 记录或 Checkpoint。 +- 禁止用 SQL 文本推断实际上下文来更新 `observedContext`。 + +--- + +## 5. Session / Lease / Budget 集成 + +### 5.1 Workflow 执行是否包裹 Job + +**本文提议:包裹(待 P3 定案)**,不是既有契约。既有契约只把 Job 明确分给三件套与导出阶段:[§9.1](connection-management.md#91-默认资源政策) 给 Workflow 的资源策略是"step/block 声明范围",[§9.5](connection-management.md#95-资源类别与调度) 的 `job` 预算类别只分给"迁移/导出阶段",[§11](connection-management.md#11-workflow--ai--mcp--wapp) 与 CM-50~52 都没有 Workflow 产生 Job 的断言(CM-40 的前置是**迁移 Job**,属 P5);唯一相容的既有钩子是 [§4](connection-management.md#4-dto-与字段定义) 的 `OwnerRef` 变体 `workflowBlock { jobId, blockId }` 与 [§17](connection-management.md#17-验收标准与证据) 的"Job owner 断言"。本文提议的理由是三条硬要求都只能由 Job 满足: + +1. **窗口无关**([INV-12](connection-management.md#3-不可破坏的不变量) / CM-40):关闭 Workflow 窗口或桌面标签不能释放资源; +2. **提交未知不自动重试**需要可持久化的执行记录与 `effectOutcome`(既有契约中对应 [§4.4 `DurableExecutionRecord`](connection-management.md#44-可落盘来源与运行时绑定),不必是 Job); +3. **Checkpoints** 记录稳定目标、版本、映射指纹与已确认提交边界,需要落盘的 [§4.2 `JobCheckpoint`](connection-management.md#42-内部记录)。 + +现状是全链路无 Job([§2.5](#25-调度历史与-job))。若 P3 采纳:block 是 Job 的阶段载体,阶段资源按 [§9.5 job 类](connection-management.md#95-资源类别与调度) 申请;GUI 前台交互式运行仍可见进度,但**资源归属**是 Job 而不是窗口。若不采纳,第 5 章其余结论按"block 资源的 owner 载体待定"降级,重试与 checkpoint 语义不变。 + +### 5.2 OwnerRef + +block 的资源 owner 使用[连接管理 §4](connection-management.md#4-dto-与字段定义) 已定义的 `OwnerRef` 变体,不新增: + +```typescript +{ kind: 'workflowBlock'; jobId: Id; blockId: Id } +``` + +| 场景 | owner | +| --- | --- | +| Workflow block 的 session / transaction Lease | `workflowBlock { jobId, blockId }` | +| 独立 step 的短操作 Lease | 该 step 所属 block 的 `workflowBlock`(是否归 Job 见 [§5.1](#51-workflow-执行是否包裹-job),不用 `clientSession`) | +| MCP 显式 session | `clientSession { clientInstanceId, purpose }`,TTL 配额控制 | +| Wapp session | 实例或 Job | + +约束:请求方不能声称任意 job owner;后端确认 Job 存在、属于调用主体且处于可执行状态([§4 补充说明](connection-management.md#4-dto-与字段定义))。Job 不使用交互 attachment TTL([§6.4](connection-management.md#64-attachment-与超期处理))。 + +### 5.3 资源获取与释放 + +```mermaid +stateDiagram-v2 + [*] --> 解析目标 + 解析目标 --> 申请资源: 目标完整 + 解析目标 --> 失败: TargetRequired / TargetConflict + 申请资源 --> 执行: 获得固定 Lease + 申请资源 --> 等待: ResourceBusy + 等待 --> 执行: 预算允许 + 等待 --> 失败: 超期或取消 + 执行 --> 执行: 下一个 step(复用同一 Lease) + 执行 --> 结束提交: transaction block + 结束提交 --> 已确认提交: commit 确认 + 结束提交 --> 已回滚: rollback 确认 + 结束提交 --> 结果未知: 无法确认 + 执行 --> 清理: 独立 step / session block 结束 + 已确认提交 --> 清理 + 已回滚 --> 清理 + 结果未知 --> 核验 + 清理 --> [*] +``` + +| 时点 | 动作 | +| --- | --- | +| block 首个数据 step 前 | 一次性预留该 block 需要的全部端点许可(多端时要么全成功要么全释放,不持半边无限等待,[连接管理 §9.5](connection-management.md#95-资源类别与调度)) | +| transaction block 开始前 | 先做计划预校验([第 7 节](#7-transaction-block-计划预校验)),通过后才申请资源 | +| block 结束 | 事务终结、句柄清空、结果消费完成 → 走归池前检查 → 归池或关闭 | +| Job 终态 | 无论完成/失败/取消都走 cleanup;预算只释放一次([INV-10](connection-management.md#3-不可破坏的不变量)) | +| 进程关闭 | 停止派发;未完成 Job 进入核验 / `OutcomeUnknown`,不自动再执行 | + +### 5.4 失败与清理 + +- 事务回滚失败 → `RollbackFailed`,隔离资源、报告真实 `effectOutcome`,不归池([INV-06](connection-management.md#3-不可破坏的不变量))。 +- 资源健康未确认 → 关闭而非归池;driver 返回 `Clean` 不构成宿主检查通过的证据。 +- 取消:走独立控制路径,不排在待取消查询之后([INV-03](connection-management.md#3-不可破坏的不变量))。取消只回滚未提交范围,**已提交批次保留**。 +- `cancelled` 只表示取消请求已送达驱动,不构成"写入已回滚"的证据([§13 effectOutcome 规则](connection-management.md#13-错误重试和事件))。 + +--- + +## 6. 并行分支与重试 + +### 6.1 分支资源划分 + +- 每个并行分支**各自申请资源**,不共享 Lease;分支内 block 内部仍保持"一个 block 一条资源"。 +- 同一 session block 在同一 Workflow run 内不并行:静态校验发现同一 blockId 被并发引用时拒绝配置,运行期重复进入返回 `ResourceBusy`。 +- 并行度受 [§9.5 队列与 Job 并发上限](connection-management.md#95-资源类别与调度) 约束:每类每服务排队上限 32、每用户 32、Job 最大并发阶段数默认 4,共享额度按加权轮转。 +- 分支共享的是**只读变量上下文**,不是数据库资源;分支之间通过 `merge` / `transform` 汇聚结果(这两者当前是纯 Rust 计算,不接触资源,[§2.1](#21-模块结构))。 + +### 6.2 重试范围 + +| 失败位置 | 重试范围 | 前提 | +| --- | --- | --- | +| 独立 step 执行失败 | 该 step | 幂等键未过期;命令为可重放语义 | +| session block 中途失败 | **整个 block,从头重跑** | 资源已确认清理;不携带中间状态 | +| transaction block 失败 | **整个 block,从头重跑** | **必须先证实已完整回滚** | +| commit 结果未知 | **不重试** | 返回 `OutcomeUnknown`,走人工/自动核验 | +| 计划预校验失败 | 不重试 | `UnsupportedPlan`,需修改定义 | + +**任意 session block 默认不能从中间 checkpoint 恢复。** 原因:块内状态(临时表、会话变量、驱动内部准备状态、已确认的上下文变化)没有可重建的持久表示,按 [§6.5](connection-management.md#65-会话级资源句柄登记) 恢复流程也禁止重建句柄,只允许以新 `dbSessionId` 显式重建。需要"从某步继续"的场景必须拆成两个 block 或两个 Workflow run,并在前一个 block 的输出中传递稳定键。 + +### 6.3 Checkpoint 边界 + +`job_checkpoints` 的表结构、字段与约束的**唯一权威**是 [persistence-model §3.6](persistence-model.md#36-jobs-与-job_checkpoints)(`stable_keys`、`committed_boundary`、`verification_evidence`、`mapping_fingerprint`、`recovery_policy` 与 `ck_ckpt_policy` 约束);本文只规定**何时写检查点**,不重复定义字段。 + +写入时机:每个 block(Job 阶段)结束且结果已确认后写一次;跨端补偿、`merge` / `transform` 汇聚与 Job claim 转移前,先确保上一阶段的已确认边界已落盘;同一次运行的同一阶段重复写入时按 `checkpoint_version` 递增追加,不覆盖既有记录。 + +语义禁止项(不新增字段):不得写入任何 live `dbSessionId` / `SessionHandle` / `ResourceLease` 句柄、未消费完的游标或结果缓冲,也不得把"应该已提交"这类推测结论当作已确认边界;跨重启的版本比对以 `jobs.plan` 冻结的 config / credential / capability 版本为准([§7.2](#72-校验输入)),不塞进检查点。 + +Checkpoint 与目标 commit 之间存在崩溃窗口(CM-47):恢复时必须查询目标记录确认提交,无证据时 `OutcomeUnknown`,**不按旧 offset 重跑**。 + +### 6.4 跨端原子性与补偿 + +跨端事务(源与目标分属不同连接 / 不同服务)**不承诺原子性**。跨端 workflow 一律按幂等 + 补偿处理: + +1. 每个写入步骤携带稳定幂等键(目标侧记录 batchId / 业务唯一键); +2. 已确认提交写入 Checkpoint; +3. 中断后先核验目标实际生效范围,再决定重放、跳过或补偿; +4. 补偿动作本身也是 workflow,必须可重复执行且不依赖运行时状态。 + +--- + +## 7. transaction block 计划预校验 + +### 7.1 原则 + +**先证明,后执行。** transaction block 在执行任何语句之前,由 driver 校验整份计划是否能保证提交边界。校验失败是**执行前拒绝**,不是运行到一半失败。 + +### 7.2 校验输入 + +| 输入 | 说明 | +| --- | --- | +| 块内全部写步骤的语句/Command 形状 | 受控 DML Command 的 id + 参数 schema;任意 SQL 的原文 | +| 目标 connectionId 的 config / credential / capability 版本 | 冻结版本,变化则重校验 | +| 目标 database / schema 的完整命名空间 | 提交边界的作用域 | +| 驱动的方言能力声明 | 哪些语句属于隐式提交、哪些需要新连接 | +| 块的读写范围 | 读步骤只影响快照语义,不影响边界证明 | + +### 7.3 校验输出 + +```typescript +type PlanVerdict = + | { ok: true; commitBoundary: 'singleTransaction'; transactionScope: string } + | { ok: false; reason: 'UnsupportedPlan'; offendingSteps: string[]; detail: string } + | { ok: false; reason: 'CapabilityUnsupported'; detail: string }; +``` + +- `ok: true` 才允许申请资源并 `begin`。 +- `offendingSteps` 用于 UI 逐条高亮。CM-52 的断言只要求"不支持计划在执行前被拒绝",不强制 stepId 定位;精确定位是本文为可用性追加的要求。 + +### 7.4 判定规则 + +1. **受控 DML Command 优先**:Command 定义显式声明 `transactionSafe: true` 且参数 schema 不接受原始 SQL 时,直接判定可保证边界。 +2. **任意 SQL**:必须由**方言解析器**证明不含下列行为才判为安全: + - 用户显式 `COMMIT` / `ROLLBACK` / `END` / `BEGIN`; + - 修改 `autocommit` / `SET autocommit = 0`; + - DDL 或其他隐式提交语句(MySQL 多种 DDL 隐式提交,见[连接管理 §10.2](connection-management.md#102-schema-diff)); + - 过程 / 函数 / 触发器内部可能提交的路径,或驱动无法解析的动态 SQL。 +3. 任一项无法证明 → `UnsupportedPlan`。 +4. driver 缺少方言解析能力 → `CapabilityUnsupported`,**不允许降级为"看起来安全"**。 + +### 7.5 为什么不能用关键词正则 + +**明确禁止只用关键词正则宣称安全。** 原因是这类判定在工程上不可靠: + +| 失败模式 | 例子 | +| --- | --- | +| 漏判破坏边界的语句 | `COMMIT` 写在字符串字面量/注释里被误删;`CALL` 内部提交、存储过程、`SAVEPOINT` 释放、DDL 触发的隐式提交未被关键词覆盖 | +| 误判破坏安全性的语句 | 字符串 `'COMMIT'`、列名 `commit_time`、`insert into commits(...)` 被误判为危险 | +| 语义随方言变化 | 同一条语句在不同驱动/版本下语义不同;PostgreSQL 的 `VACUUM`、SQL Server 的 DDL 事务表现各异 | +| 掩盖真实边界 | 通过"删掉 COMMIT"制造出用户没预期的语义变更 | + +因此 `sql_guard::check_sql`(现状用于 read-only / safe-mode 的语句过滤)**不构成**提交边界证明,不能被复用于本判定。它解决的是"能不能执行",本节解决的是"能不能保证整块原子性"。 + +### 7.6 拒绝路径 + +```text +静态解析/能力检查 + → UnsupportedPlan / CapabilityUnsupported + → 不申请资源、不执行任何语句 + → Job 记录 offendingSteps(Job 包裹本身是[本文提议](#51-workflow-执行是否包裹-job),待 P3 定案) + → `ApiError.code = UnsupportedPlan`,`effectOutcome = notStarted`(预检在派发前,不产生 `ExecutionErrorCode`) + → UI 定位到具体 step 并给出可操作原因 + → 该结果不进入自动重试 +``` + +对比允许的 session block:它**不要求**上述证明([§3.3](#33-session-block)),因为它不承诺整块原子性;代价是失败后状态未知,必须整块重跑。 + +--- + +## 8. 目标 YAML 结构 + +### 8.1 顶层 + +```yaml +version: "2" # 必填;缺失按 version 1 解析(见第 10 节) +id: order-sync +name: 订单同步 +description: 拉取订单并落入目标库 +variables: + - { name: conn, type: connection, required: true } + - { name: order_id, type: string, required: true } +steps: [] # v1 保留;v2 允许与 blocks 二选一或并存 +blocks: [] # v2 新增 +timeout_secs: 300 +error_handling: { strategy: abort } +``` + +### 8.2 block 字段 + +| 字段 | 适用 block | 说明 | +| --- | --- | --- | +| `id` | 全部 | run 内唯一,出现在 `OwnerRef.blockId` 与检查点中 | +| `type` | 全部 | `sessionBlock` / `transactionBlock` | +| `connection` / `database` | 全部 | block 目标(L2) | +| `parallel` | `sessionBlock` | 默认 `false`;同一 block 并行配置直接拒绝 | +| `steps` | 全部 | 子步骤,可嵌套 `condition` / `foreach`;块内 step 的输出用 `blocks..steps..result` 寻址,不扁平化到顶层 `steps` | +| `retry` | `transactionBlock` | 默认 `block: 0`;只有已证实回滚才允许 | + +### 8.3 完整示例 + +```yaml +version: "2" +id: order-sync +name: 订单同步 +variables: + - { name: conn, type: connection, required: true } + - { name: order_id, type: string, required: true } + - { name: amount, type: number, required: true } + +steps: + # 独立 step:每次独立初始化 + - type: query + id: fetch_order + connection: "{{conn}}" + database: "orders_live" + sql: "SELECT * FROM orders WHERE order_id = '{{order_id}}'" + +blocks: + # session block:跨 step 保留状态,串行 + - type: sessionBlock + id: staging + connection: "{{conn}}" + database: "orders_live" + steps: + - type: command + id: create_stage + command: "create_temp_table" + input: { name: "order_batch" } + - type: command + id: load_stage + command: "insert_rows" + input: { source: "steps.fetch_order.result" } + - type: query + id: check_stage + sql: "SELECT count(*) AS n FROM order_batch" + + # transaction block:runtime 控制提交边界 + - type: transactionBlock + id: apply + connection: "{{conn}}" + database: "orders" + retry: { block: 1 } + steps: + - type: command + id: debit + command: "order_debit" + input: { order_id: "{{order_id}}", amount: "{{amount}}" } + - type: command + id: credit + command: "order_credit" + input: { order_id: "{{order_id}}", amount: "{{amount}}" } + +output: + format: markdown + template: "订单 {{order_id}} 同步完成:{{blocks.staging.steps.check_stage.result}}" +``` + +--- + +## 9. AI / MCP / Wapp 与后台入口的边界 + +本节把[连接管理 §11](connection-management.md#11-workflow--ai--mcp--wapp) 的第二段落到 Workflow 侧。 + +### 9.1 AI + +| 场景 | 行为 | +| --- | --- | +| 默认 | AI 对数据库的操作是**独立目标操作**:每次按完整 `ExecutionTarget` 执行,不共享会话,不假设当前库 | +| 用户显式授权当前编辑器 session | 进入**同一队列**(session actor)并共享真实状态:能看到同一 session 内已提交的临时对象,遵从同一串行规则 | +| 授权撤销 / 附件过期 | 回到独立目标操作;已建立的会话按 [§6.4](connection-management.md#64-attachment-与超期处理) 关闭 | +| AI 生成 Workflow YAML | 生成的定义必须显式写 `version` 与目标;不得依赖调用方隐式传入的连接 | + +对应 CM-53:未授权时读取编辑器临时对象必须失败,授权后同队列可见。 + +### 9.2 MCP + +- 显式 session 归**客户端/身份**所有(`clientSession`),受 TTL 配额控制;客户端断开只清理自己的资源(CM-53 断言)。 +- 不向 MCP 客户端暴露凭据或运行时句柄。 +- `run_workflow` 的 `connection_id` 参数在 v2 中不再是"隐式默认目标":要么显式绑定某个 profile 初始目标,要么要求 MCP 客户端先建立自己的 session。调度类运行使用**服务身份**,不依赖用户登录态。 + +### 9.3 Wapp + +Wapp session 归**实例或 Job**。Wapp 通过受限的 `execute_driver_command` 通道取数,宿主判定它不是 editor;它既不能加入用户的编辑器 session,也不能声称 `workflowBlock` owner。Wapp 触发的 Workflow 运行以该 Wapp 实例身份发起 Job(Job 包裹本身是[本文提议](#51-workflow-执行是否包裹-job),待 P3 定案)。 + +### 9.4 后台调度与 Dashboard + +- 调度器使用**服务身份 + 委托权限**执行,目标是 Workflow 定义里显式声明的连接(或 profile 初始目标),**不依赖 GUI 当前库,也不依赖用户登录态**。 +- Dashboard 组件同样不传隐式连接;`DashboardHidden` 工作流当前不写历史([§2.5](#25-调度历史与-job)),目标模型下改为写入执行记录、由可见性决定 UI 投影,而不是决定是否落盘。 +- 所有辅助任务(导出、备份、恢复)共享同一预算,不污染编辑器资源([开发计划 §10](../../development/platform-development-plan.md#10-p6workflowaimcpwapp-与辅助任务))。 + +--- + +## 10. 版本与兼容边界 + +### 10.1 破坏性变更清单 + +| 变更 | 影响面 | +| --- | --- | +| 移除 `workflow.connection.or(caller_connection_id)` 回退 | 依赖 GUI/MCP 隐式连接的 Workflow 会开始失败 | +| 引入 block 层与资源范围 | 旧定义解析为独立 step,行为等价但资源获取路径改变 | +| Workflow 运行产生 Job([本文提议](#51-workflow-执行是否包裹-job),待 P3 定案) | 历史记录、取消、重试、清理的归属改变 | +| 错误从字符串改为版本化 code | 前端错误展示、i18n 文案需同步 | +| GUI 不再注入 `dbSessionId` 冒充 `connectionId` | AI 侧栏执行路径改变([§2.8](#28-一处已确认的身份错配)) | + +### 10.2 按版本分派解析 + +目标:定义版本驱动解析,**新 block 格式绝不交给旧 executor**。 + +```text +parseWorkflowDefinition(yaml) -> ParsedDefinition + 1. 反序列化为通用信封 { version?: string, ... } // 不含类型约束 + 2. v = version 缺失 → "1"(默认);否则必须匹配 ^\d+$ + 3. switch v: + "1" → parseV1() // 扁平 steps,等价于 blocks: [全部独立 step] + "2" → parseV2() // 启用 block 解析、目标四级链、Job 包裹 + _ → UnsupportedDefinitionVersion(拒绝执行,不猜测;解析层拒绝属请求拒绝,对外映射为 `ApiError.code = InvalidArgument`,见 §10.3) + 4. 解析产物为统一的 ExecutableWorkflow { version, targets, blocks } +``` + +仓库内已有可参照的版本化实现([§2.3](#23-版本字段的现状重要)):`schema_diff/profile.rs`、`data_sync::SyncProfile`、`data_transfer/profile.rs` 的 `CURRENT_VERSION` + 未知版本拒绝。 + +### 10.3 未知版本 + +- 缺 `version` → 按 v1 解析,**不报错**(存量 YAML 大量缺失该字段)。 +- 版本号非整数 → 校验期拒绝(`InvalidArgument`),不进入执行。 +- 合法但高于 runtime 支持的最高版本 → 拒绝执行并提示升级,**绝不按低版本猜测解析**。这与[连接管理 §13](connection-management.md#13-错误重试和事件) 的 SubmissionToken "未知 keyVersion 一律拒绝"一致。 +- 同一个 `WorkflowDefinition` 不会同时交给两套 executor:分派发生在解析层,执行层只认 `ExecutableWorkflow`。 + +### 10.4 旧定义在目标 runtime 下的默认语义 + +v1 定义的映射规则是机械的、无歧义的: + +| v1 写法 | v2 等价 | +| --- | --- | +| `steps: [...]` | 全部包进隐式独立 step 序列,每步一个短操作资源 | +| 顶层 `connection` / `database` | workflow 默认目标(L3) | +| step `connection` / `database` / `input.database` | step 显式目标(L1) | +| 依赖调用方 `connectionId` 的未声明连接 | **不再回退** → `TargetRequired` | +| `error_handling` / `on_error` 策略 | 语义不变([使用指南 §10](../../features/workflow-guide.zh-CN.md#10-超时与错误处理)) | +| `schedule` | 语义不变,只是改由服务身份执行 | + +因此"旧定义按版本解析,默认独立 step 语义"是[开发计划 §10](../../development/platform-development-plan.md#10-p6workflowaimcpwapp-与辅助任务) 回退方案与本文的共同前提。 + +### 10.5 用户可感知差异与提示 + +| 场景 | 现象 | 提示 | +| --- | --- | --- | +| 旧 Workflow 依赖 GUI 当前连接 | 执行失败 | 提示"缺少显式目标,请声明 `connection` 或 `connection` 变量",并列出受影响 stepId | +| 旧 Workflow 依赖 MCP 传入的 `connection_id` | 该参数不再回退 | Tool schema 标注 `connection_id` 已弃用,改用变量或显式 session | +| 使用 `sessionBlock` | 多 step 共享会话,块内 `USE` 生效、块间不生效 | 步骤面板显示块分组与资源持有者 | +| 使用 `transactionBlock` 但步骤不可证明边界 | 执行前拒绝,**无任何写入** | 定位到具体 step,建议改用受控 DML Command 或改用 session block | +| 事务失败 | 整块回滚;已证实回滚才可能整块重试 | 结果面板显示 `effectOutcome`,未知提交不显示"已回滚" | +| 旧 `version` 缺失 | 正常执行,按独立 step | 结果摘要标注"v1 兼容模式" | + +### 10.6 迁移路径 + +1. **先加解析层**:引入 `version` 分派与 `ExecutableWorkflow`,v1 → 独立 step 映射,纯函数、有单测;此时执行器行为不变。 +2. **再开新执行路径**:block 资源调度、Job 包裹、计划预校验作为新执行器;`version: "1"` 仍走旧执行器,`version: "2"` 走新执行器。**新 block 格式不交旧 executor**(静态拒绝:`ExecutorFor(version)` 查不到即报错)。 +3. **再切断隐式目标**:移除 `workflow.connection.or(caller_connection_id)`,GUI / MCP / Dashboard 入口停止注入隐式连接;此步是唯一会打断存量工作流的行为变更,必须在前两步完成并发出迁移提示后进行。 +4. **再改错误契约**:结构化 `ExecutionErrorCode` + `effectOutcome`,前端与 i18n 同步。 +5. **回退策略**:保留 v1 解析与旧执行器;回退时新 block 格式被拒绝执行而不是错误执行,存量 v1 定义继续按独立 step 运行。 + +--- + +## 11. 验收映射 + +### 11.1 P6 退出门槛 + +| 用例([连接管理 §16.5](connection-management.md#165-三件套与-workflow) / [§16.7](connection-management.md#167-补充契约与边界用例)) | 断言 | 对应本文 | +| --- | --- | --- | +| [CM-50 Workflow 独立 step](connection-management.md#165-三件套与-workflow) | step2 用 A、step3 用 C、step1 状态不泄漏、资源全部清理 | [§3.2](#32-独立-step)、[§4.1](#41-四级链) | +| [CM-51 Workflow session block](connection-management.md#165-三件套与-workflow) | 同资源连续、并行配置被拒绝、另一 block 独立、block 结束关闭 | [§3.3](#33-session-block)、[§6.1](#61-分支资源划分)、[§5.3](#53-资源获取与释放) | +| [CM-52 Workflow transaction block](connection-management.md#165-三件套与-workflow) | 不支持计划执行前被拒、合法失败完整回滚、重试整个块、未知提交不自动重试 | [§7](#7-transaction-block-计划预校验)、[§6.2](#62-重试范围)、[§5.4](#54-失败与清理) | +| [CM-53 AI/MCP/Wapp 归属](connection-management.md#165-三件套与-workflow) | 授权后同队列可见、未授权拒绝、断开只清理自己资源 | [§9](#9-ai--mcp--wapp-与后台入口的边界) | +| [CM-73 空闲淘汰与活动事务句柄连续旅程 / CM-74 会话级句柄登记与释放顺序(§16.7)](connection-management.md#167-补充契约与边界用例) | 活动事务不得被静默淘汰后同 ID 重建 | [§5.4](#54-失败与清理)、[§3.4](#34-transaction-block) | +| [CM-65 类别保留与无饥饿(§16.7)](connection-management.md#167-补充契约与边界用例) | 类保留不被侵占、控制可用、持续队列按 4:2:1 获派发、用户轮转无饥饿、队列取消无许可泄漏;层 H,H 断言属 P3、任务断言属 P5/P6([开发计划 §17](../../development/platform-development-plan.md#17-补充契约的阶段归属)) | [§6.1](#61-分支资源划分)、[§5.3](#53-资源获取与释放) | +| [CM-67 PoolKey 版本和多 database(§16.7)](connection-management.md#167-补充契约与边界用例) | 按 database/policy 分 key、总 socket 不越预算、旧 idle 停发并关闭、旧缓存不能回填;层 H/D,D 断言属 P2、H 断言属 P3、任务断言属 P5/P6([开发计划 §17](../../development/platform-development-plan.md#17-补充契约的阶段归属)) | [§4.2](#42-解析算法)、[§5.3](#53-资源获取与释放) | +| [CM-40 任务关闭窗口继续](connection-management.md#165-三件套与-workflow) | 窗口关闭不 release 资源(前置是**迁移 Job**,属 P5) | [§5.1](#51-workflow-执行是否包裹-job)(本文提议) | + +### 11.2 默认目标契约回归 + +[开发计划 §10](../../development/platform-development-plan.md#10-p6workflowaimcpwapp-与辅助任务) 要求"原 Workflow 的 query / command 默认目标契约仍通过"。对应回归集: + +| 回归项 | 断言 | 对应本文 | +| --- | --- | --- | +| R1 继承链等价 | v1 定义在 v2 runtime 下,`connection` / `database` 继承结果与现状一致 | [§10.4](#104-旧定义在目标-runtime-下的默认语义) | +| R2 显式覆盖 | step 显式目标覆盖 workflow 默认([现状 `effective_connection`](../../../src-tauri/src/workflow/command_runtime.rs) 行为保持) | [§4.1](#41-四级链) | +| R3 `input.database` 优先级 | 显式非空 `input.database` 优先,空白按未设置 | [§4.2](#42-解析算法) | +| R4 多库缺 database | 仍报 `TargetRequired`(现为 `MissingDatabase`),仅多库驱动 | [§4.3](#43-冲突处理) | +| R5 缺连接 | 仍报缺失连接(现为 `MissingConnection`),且错误升级为结构化 code | [§4.2](#42-解析算法) | +| R6 使用指南样例 | [使用指南 §9.2](../../features/workflow-guide.zh-CN.md#92-跨库模式) 的跨库工作流(显式 `connection` 变量)保持可用 | [§10.5](#105-用户可感知差异与提示) | +| R7 内建样例 | 三个 `builtin-workflows/*.yaml` 保持可加载可执行 | [§10.6](#106-迁移路径) | +| R8 超时与错误策略 | 全局 300s、单步 30s、`abort`/`skip`/`fallback` 语义不变 | [§10.4](#104-旧定义在目标-runtime-下的默认语义) | +| R9 隐式目标断裂 | 依赖调用方 `connectionId` 的路径明确失败并给出提示 | [§10.5](#105-用户可感知差异与提示) | + +### 11.3 需要新增的测试落点 + +| 测试 | 落点 | +| --- | --- | +| v1 → 独立 step 映射(纯函数) | `src-tauri/src/workflow/` 单元测试 | +| 目标四级链与冲突 | 同上,含 `TargetConflict` / `TargetRequired` 断言 | +| 计划预校验:受控 Command / 任意 SQL 拒绝 / 能力缺失 | 宿主单元 + 各驱动 crate `tests/` | +| block 资源获取、跨 step 状态保留、块结束释放 | 假驱动 fake resource([连接管理 §15.1 基础夹具](connection-management.md#151-基础夹具)) | +| 已证实回滚才重试 / 未知提交不重试 | 故障注入 | +| Checkpoint 白名单与黑名单 | 序列化快照断言:不得出现 `dbSessionId` / lease 句柄 | + +--- + +## 12. 待 P0 验证清单 + +以下事实未能在本次基线代码中确认,落地前必须先补验证或补测试: + +1. **driver 是否暴露显式的 begin/commit/rollback Command 与计划接受接口**。第 7 节依赖"受控 DML Command 声明 `transactionSafe`",需确认 `packages/driver-api` 的 `DriverCommandDefinition` 描述结构(现状只有 `id`/`name`/`description`/`input_schema`/`output_schema`/`permissions`/`metadata`,无事务相关字段)与各驱动实现的现状。 +2. **各驱动的隐式提交清单**。MySQL 已知多种 DDL 隐式提交([连接管理 §10.2](connection-management.md#102-schema-diff));PostgreSQL、SQL Server、SQLite 的对应行为需按驱动逐一核验。 +3. **[§2.8 身份错配](#28-一处已确认的身份错配) 的实际失败表现**:`WorkflowPanel` 把 `dbSessionId` 作为 `connectionId` 传入时,`get_or_connect_session` 的 owner 匹配会失败并转入 `connect(connection_id)`;代码上该路径会落到 `establish_connection` 的 `ConnectionConfigNotFound`,但需在真实环境确认,是否存在其他恰好命中的路径。 +4. **Dashboard 工作流组件的目标来源**:目前传 `None`,需确认目标 Design 中 dashboard 组件的目标是否应固化在 widget 定义里(本文未纳入)。 +5. **Workflow runtime 与编辑器事务管理是否会在同一物理 session 上形成双事务控制**:现状 Workflow 不复用 `services/transaction.rs`([§2.6](#26-错误与事务)),但目标模型要求事务由 Workflow runtime 托管,需确认二者不会各自持有同一物理 resource 的事务。 +6. **调度器服务身份的凭据来源与委托权限模型**([§9.4](#94-后台调度与-dashboard)),需与[连接管理 §10.1 公共处理](connection-management.md#101-公共处理) 中的服务身份 / 委托权限顺序对齐。 +7. **`WorkflowStep::Ai` 无目标字段**是否需要扩展:AI 步骤若要在 session block 内读取块内临时对象,模型需增加目标引用,本文暂按"AI 步骤不参与块内资源"处理。 + +--- + +## 13. 与既有设计的关系 + +| 既有文档 | 关系 | +| --- | --- | +| [系统概要 §4 Workflow Runtime](system-overview.md#4-模块职责与边界) | 该行"目标继承、step/block、DAG、重试 / 不继承 GUI 当前数据库"是本文的模块级结论;本文不改写它,只给出实现粒度 | +| [系统概要 §6.4 JobRepository](system-overview.md#64-repositories-与环境-ports) | Job 落盘、claim/renew、检查点写入由 JobRepository 承担,字段权威见 [persistence-model §3.6](persistence-model.md#36-jobs-与-job_checkpoints);Workflow 不自建持久化,本文只定[写入时机](#63-checkpoint-边界) | +| [连接管理 §9.1](connection-management.md#91-默认资源政策) / §9.5 / §11 | 这三节的既有契约只给 Workflow "step/block 声明范围",`job` 预算类别只分给"迁移/导出阶段",均未规定 Workflow 运行产生 Job;"每次 Workflow 运行产生一个 Job"是**本文提议**([§5.1](#51-workflow-执行是否包裹-job)),待 P3 定案 | +| [系统概要 §7.3](system-overview.md#73-三件套与-workflow) | 该节 JobRuntime 两句对应本文[第 5 章](#5-session--lease--budget-集成),Workflow 一句对应[第 3 章](#3-目标资源模型三种执行单元) | +| [连接管理 §11](connection-management.md#11-workflow--ai--mcp--wapp) | 散文级意图,本文是它的可编码展开;唯一超出既有契约的结论已在[§5.1](#51-workflow-执行是否包裹-job) 标为本文提议 | +| [连接管理 §4 DTO](connection-management.md#4-dto-与字段定义) | `ExecutionTarget` / `SessionHandle` / `OwnerRef` / `SessionHandleRef` 等**直接引用不改写** | +| [连接管理 §3 不变量](connection-management.md#3-不可破坏的不变量) | INV-02/03/04/05/12 是三种执行单元的硬约束来源 | +| [连接管理 §9 资源政策](connection-management.md#9-连接池预算与清理) | Workflow 行"step/block 声明范围"由本文兑现;预算与队列数值不在本文重复定义 | +| [连接管理 §16.5 / §16.7 用例](connection-management.md#165-三件套与-workflow) | CM-40/50~53(§16.5)与 CM-73/74(§16.7)是本文的验收出口,本文不修改其断言 | +| [开发计划 §10 P6](../../development/platform-development-plan.md#10-p6workflowaimcpwapp-与辅助任务) | 交付项与退出门槛逐条对应本文;回退方案在[第 10.6 节](#106-迁移路径) | +| [backend/workflow.md](../backend/workflow.md) | 已实现架构,描述现状;本文落地后应把 block/Job 相关结论改写为"已实现"并入该文档,并修正其文件清单缺 `data_step.rs` / `executor.rs` 的问题 | +| [features/workflow-guide.zh-CN.md](../../features/workflow-guide.zh-CN.md) | 用户可见基线;[§11.2](#112-默认目标契约回归) 的回归集以其中的样例为准 | + +--- + +## 14. 本文不覆盖什么 + +- **不定义**三种执行单元的 UI 编辑器交互与提示文案,只约束运行时行为与用户可感知差异的类别。 +- **不设计** Workflow 的 DAG / 条件 / 循环 / 变量模板语法,这些保持现状(见[使用指南](../../features/workflow-guide.zh-CN.md))。 +- **不规定**事务隔离级别、锁策略、死锁处理与重试退避参数。 +- **不规定**驱动方言实现,只规定宿主对 driver 能力验证的输入输出([第 7 章](#7-transaction-block-计划预校验))。 +- **不重复**连接管理文档中的 DTO、预算数值、队列上限、TTL 与幂等键期限,也不重复 [persistence-model §3.6](persistence-model.md#36-jobs-与-job_checkpoints) 的检查点表结构与字段语义。 +- **不处理** Workflow 与三件套 profile 的复用关系、`migration` step 在 block 内的定位([§12](#12-待-p0-验证清单) 第 1/5 条)。 +- **不承诺** session block 的中间恢复能力,也不为跨端事务提供原子性。 +- **不引入**新的进度、验收清单类文档;本文结论落地后应改写为"已实现"并入 [backend/workflow.md](../backend/workflow.md)。 diff --git a/docs/development/platform-development-plan.md b/docs/development/platform-development-plan.md index 273596ff8..208156034 100644 --- a/docs/development/platform-development-plan.md +++ b/docs/development/platform-development-plan.md @@ -293,11 +293,12 @@ WDIO 必须使用已注入 driver 的构建;三件套可运行 app 已生成 | 阶段 | 必须交付的补充契约 | 验收范围 | | --- | --- | --- | | P0/P1 | DTO、持久化白名单、目标规范化、错误/回执、能力快照、令牌 port | CM-61/62/72 的类型与 schema 断言 | -| P2 | namespaceShape、targetRequirements、规范化资源 key、driver 清理与协议 drain | CM-62/64/67/69 的 D 断言 | +| P2 | namespaceShape、targetRequirements、规范化资源 key、driver 清理与协议 drain | CM-62/64/67 的 D 断言 | | P3 | attachment、结果放弃、类别调度、逻辑配额、PoolKey/cache、替换发布、幂等过期、内存目录、事务失效回收与会话级句柄登记 | CM-61~74 的 H 断言,CM-60 基准 harness | | P4 | 恢复结果只读/写回、TTL 与切库错误投影、关闭结果视图行为 | CM-61~64/72 的 F 断言 | | P5/P6 | 能力快照计划核验、Job 多端预算、独立消费者政策 | CM-40~53 与 CM-65/67 的任务断言 | | P7/P8 | 单实例认证重附着、HTTP 回执与持久化投影、签名令牌和 SSE | CM-63/68/70/72 的 W1 断言 | +| P7/P8 | 登出、权限撤销与 CSRF 跨站拒绝(撤销传播到本实例的订阅与排队执行) | CM-59 的 W1 断言 | | P9 | 原子 owner 登记、碰撞/失效、跨节点替换发布、分区和全局预算 | CM-57/58/60/68/71 的 WN 断言 | 测试标签代表执行层,不代表整条用例只属于某阶段。P3/P4 必须完成 CM-54~56 的 runtime/前端部分;P7 完成单实例适配部分;只有 owner 路由、worker 分区及跨节点预算推迟到 P9。P10 逐层汇总全部 74 条适用用例,不能把未执行的 D/WN 断言计为已通过。 From 05ecab697d1a895f691adada38b5adbbe8efa187 Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 08:15:15 +0800 Subject: [PATCH 055/666] =?UTF-8?q?test(cmds):=20=E5=A4=8D=E7=8E=B0=20CM-7?= =?UTF-8?q?3=20=E7=A9=BA=E9=97=B2=E6=B7=98=E6=B1=B0=E4=B8=8E=E6=B4=BB?= =?UTF-8?q?=E5=8A=A8=E4=BA=8B=E5=8A=A1=E5=8F=A5=E6=9F=84=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E6=97=85=E7=A8=8B=E5=B9=B6=E7=95=99=E5=9F=BA=E7=BA=BF=E8=AF=81?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 docs/architecture/platform/connection-management.md:1279-1285 驱动完整旅程: begin_session_transaction 登记真实事务句柄并写入未提交 marker → 推进到空闲淘汰 期限并执行 cleanup_idle_connections → 以同一 dbSessionId 再次查询 → 记录物理 resourceId、owner map、rollback/close 调用。 - cm73_baseline_idle_eviction_reproduces_the_defect(绿):钉住实测缺陷序列, P3 转绿时整条删除而非放宽。 - cm73_registered_live_handle_survives_idle_eviction_and_is_never_reused (#[ignore] 已知失败):仅断言行为目标,legacy ConnectionManager 删除后 仍须成立。 测试钩子复用既有 #[cfg(test)] expire_test_session;生产执行路径零改动。 --- src-tauri/src/commands/cm73_baseline_tests.rs | 636 ++++++++++++++++++ src-tauri/src/commands/mod.rs | 9 + 2 files changed, 645 insertions(+) create mode 100644 src-tauri/src/commands/cm73_baseline_tests.rs diff --git a/src-tauri/src/commands/cm73_baseline_tests.rs b/src-tauri/src/commands/cm73_baseline_tests.rs new file mode 100644 index 000000000..27bbcff27 --- /dev/null +++ b/src-tauri/src/commands/cm73_baseline_tests.rs @@ -0,0 +1,636 @@ +//! CM-73 baseline evidence — 空闲淘汰与活动事务句柄连续旅程(P0 / `p0-cm73-baseline`)。 +//! +//! Spec: `docs/architecture/platform/connection-management.md:1279-1285` (CM-73, H). +//! +//! The journey driven here is the one the spec asks for, end to end on a single +//! `dbSessionId`: +//! 1. `begin_session_transaction` opens a **real** transaction handle through the +//! driver and registers it, then writes an uncommitted marker. +//! 2. The session is advanced past the idle-eviction deadline and +//! `cleanup_idle_connections` runs. +//! 3. A query is issued again with the **same `dbSessionId`**. +//! 4. We record the physical `resourceId`, whether the owner map survived, +//! whether the old `TransactionHandle` is still readable, and every +//! rollback/close call the driver actually received. +//! +//! Two tests share one journey: +//! - `cm73_baseline_idle_eviction_reproduces_the_defect` is **green** and pins +//! the *observed* sequence — that is the `:1284` baseline evidence. When the +//! P3 connection-runtime track turns CM-73 green, this test is **deleted, not +//! updated**: the defect it pins no longer exists. +//! - `cm73_target_behavior_...` is `#[ignore]`d and asserts the **target +//! behavior only**. Per the `:1285` retention clause its assertions must +//! survive the removal of the legacy `ConnectionManager`, so they are written +//! as behavior ("while a registered live handle exists the runtime must not +//! evict the physical resource and must not rebuild under the same +//! `dbSessionId`") and never as legacy structure (no assertion that any +//! particular map is empty, no reliance on legacy function names). +//! +//! Trade-off, stated once and deliberately: observing the current `session_transactions` +//! map is what makes the defect legible (an old handle that still points at a dead +//! physical connection is the harm), so the baseline test records it — but the +//! *target* assertions deliberately abstain from that shape. `session_transactions` +//! is command-layer state that CM-73 will move into the runtime; asserting its +//! emptiness would hard-code a structure that is about to change. + +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use super::query::{ + begin_session_transaction_impl, execute_query_impl, rollback_session_transaction_impl, + session_transaction_status_impl, +}; +use super::AppState; +use crate::db::{ + ConnectionConfig, ConnectionHandle, DatabaseDriver, DatabaseType, DriverError, MultiQueryResult, + QueryResult, ServerInfo, TableInfo, TableSchema, TransactionHandle, Value, +}; +use crate::db::registry::DriverRegistry; +use crate::store::Store; +use crate::testing::FileKeyringGuard; +use crate::testing::app_state::{build_app_state, sample_postgres_config}; +use datazen_driver_api::{ + execute_standard_sql_command, query_command_definition, CommandResult, DriverCommandDefinition, +}; + +const CONNECTION_ID: &str = "cm73-conn"; + +// ── Recording driver ───────────────────────────────────────────────────────── + +/// One physical resource = one `connect()`. The id is carried in +/// `ConnectionHandle::pool_id` so every later call can be attributed to the +/// resource that is actually alive. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Call { + Connect { resource: String }, + Disconnect { resource: String }, + Begin { resource: String, transaction: String }, + Query { resource: String, sql: String }, + Commit { resource: String, transaction: String }, + Rollback { resource: String, transaction: String }, +} + +struct JournalDriver { + calls: Mutex>, + live: Mutex>, + /// `ConnectionHandle::id` (as returned by `connect`) → physical resource. + /// The runtime overwrites `handle.id` with the session id when it adopts a + /// connection, so this map is only consulted to resolve the `connection_id` + /// frozen into a `TransactionHandle` — never to route a live call. + resource_by_connection_id: Mutex>, + tx_resource: Mutex>, + next_resource: AtomicUsize, + next_transaction: AtomicUsize, +} + +impl JournalDriver { + fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + live: Mutex::new(HashSet::new()), + resource_by_connection_id: Mutex::new(HashMap::new()), + tx_resource: Mutex::new(HashMap::new()), + next_resource: AtomicUsize::new(0), + next_transaction: AtomicUsize::new(0), + } + } + + fn snapshot(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + + /// The physical resource behind a live call. `pool_id` is the driver's own + /// identity for the resource and the runtime never rewrites it, so it stays + /// correct even after the session id is re-pointed at a fresh connection. + fn resource_of(&self, handle: &ConnectionHandle) -> String { + handle.pool_id.clone() + } + + /// The physical resource a (possibly stale) `connection_id` was issued for. + fn resource_of_connection_id(&self, connection_id: &str) -> Option { + self.resource_by_connection_id + .lock() + .unwrap() + .get(connection_id) + .cloned() + } + + /// Resolve the resource that is currently serving `db_session_id`'s handle, + /// i.e. the newest `Connect` call for it. + fn current_resource(&self, calls: &[Call], db_session_id: &str) -> Option { + let _ = db_session_id; + calls.iter().rev().find_map(|call| match call { + Call::Connect { resource } => Some(resource.clone()), + _ => None, + }) + } + + fn finish_transaction( + &self, + commit: bool, + tx: TransactionHandle, + ) -> Result<(), DriverError> { + let resource = self + .tx_resource + .lock() + .unwrap() + .get(&tx.id) + .cloned() + .unwrap_or_else(|| "unknown-resource".to_string()); + self.calls.lock().unwrap().push(if commit { + Call::Commit { + resource: resource.clone(), + transaction: tx.id.clone(), + } + } else { + Call::Rollback { + resource: resource.clone(), + transaction: tx.id.clone(), + } + }); + if !self.live.lock().unwrap().contains(&resource) { + // A real driver must fail closed here: the outcome of the + // transaction is genuinely unknown once its resource is gone. + return Err(DriverError::TransactionError(format!( + "transaction {} was opened on {} which is already closed: outcome unknown", + tx.id, resource + ))); + } + Ok(()) + } +} + +#[async_trait] +impl DatabaseDriver for JournalDriver { + fn driver_type(&self) -> DatabaseType { + "postgres".into() + } + + async fn connect(&self, _config: &ConnectionConfig) -> Result { + let resource = format!( + "resource-{}", + self.next_resource.fetch_add(1, Ordering::SeqCst) + 1 + ); + let handle = ConnectionHandle { + id: format!("{resource}/conn"), + pool_id: resource.clone(), + }; + self.calls + .lock() + .unwrap() + .push(Call::Connect { + resource: resource.clone(), + }); + self.live.lock().unwrap().insert(resource.clone()); + self.resource_by_connection_id + .lock() + .unwrap() + .insert(handle.id.clone(), resource); + Ok(handle) + } + + async fn disconnect(&self, handle: ConnectionHandle) -> Result<(), DriverError> { + let resource = handle.pool_id.clone(); + self.calls + .lock() + .unwrap() + .push(Call::Disconnect { + resource: resource.clone(), + }); + self.live.lock().unwrap().remove(&resource); + Ok(()) + } + + async fn begin_transaction( + &self, + handle: &ConnectionHandle, + ) -> Result { + let resource = self.resource_of(handle); + if !self.live.lock().unwrap().contains(&resource) { + return Err(DriverError::TransactionError(format!( + "cannot open a transaction on closed resource {resource}" + ))); + } + let transaction = format!( + "tx-{}", + self.next_transaction.fetch_add(1, Ordering::SeqCst) + 1 + ); + self.tx_resource + .lock() + .unwrap() + .insert(transaction.clone(), resource.clone()); + self.calls.lock().unwrap().push(Call::Begin { + resource, + transaction: transaction.clone(), + }); + Ok(TransactionHandle { + id: transaction, + connection_id: handle.id.clone(), + }) + } + + async fn commit(&self, tx: TransactionHandle) -> Result<(), DriverError> { + self.finish_transaction(true, tx) + } + + async fn rollback(&self, tx: TransactionHandle) -> Result<(), DriverError> { + self.finish_transaction(false, tx) + } + + async fn query_multi( + &self, + handle: &ConnectionHandle, + sql: &str, + _limit: Option, + ) -> Result { + let resource = self.resource_of(handle); + if !self.live.lock().unwrap().contains(&resource) { + return Err(DriverError::QueryFailed(format!( + "query on closed resource {resource}" + ))); + } + self.calls.lock().unwrap().push(Call::Query { + resource, + sql: sql.to_string(), + }); + Ok(MultiQueryResult { + results: Vec::new(), + total_time_ms: 0, + }) + } + + fn command_definitions(&self) -> Vec { + vec![query_command_definition()] + } + + async fn execute_command( + &self, + handle: &ConnectionHandle, + command: &str, + input: serde_json::Value, + ) -> Result { + execute_standard_sql_command(self, handle, command, input).await + } + + async fn test_connection(&self, _config: &ConnectionConfig) -> Result { + Err(DriverError::QueryFailed("journal driver".into())) + } + + async fn get_databases(&self, _handle: &ConnectionHandle) -> Result, DriverError> { + Ok(vec![]) + } + + async fn get_tables( + &self, + _handle: &ConnectionHandle, + _database: &str, + _schema: Option<&str>, + ) -> Result, DriverError> { + Ok(vec![]) + } + + async fn get_table_schema( + &self, + _handle: &ConnectionHandle, + _table: &str, + _database: &str, + _schema: Option<&str>, + ) -> Result { + Err(DriverError::QueryFailed("journal driver".into())) + } + + async fn query( + &self, + _handle: &ConnectionHandle, + _sql: &str, + ) -> Result { + Err(DriverError::QueryFailed("journal driver".into())) + } + + async fn query_with_params( + &self, + _handle: &ConnectionHandle, + _sql: &str, + _params: &[Value], + ) -> Result { + Err(DriverError::QueryFailed("journal driver".into())) + } + + async fn execute(&self, _handle: &ConnectionHandle, _sql: &str) -> Result { + Err(DriverError::QueryFailed("journal driver".into())) + } + + async fn cancel_query(&self, _handle: &ConnectionHandle) -> Result<(), DriverError> { + Ok(()) + } +} + +// ── Journey ────────────────────────────────────────────────────────────────── + +struct Fixture { + _keyring: FileKeyringGuard, + _temp: tempfile::TempDir, + state: AppState, + driver: Arc, +} + +async fn fixture() -> Fixture { + let keyring = FileKeyringGuard::set(); + let temp = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(Store::init_with_path(temp.path()).await.expect("store init")); + let registry = Arc::new(DriverRegistry::new()); + let driver = Arc::new(JournalDriver::new()); + registry + .register_test_driver("postgres", driver.clone()) + .await; + let state = build_app_state(store.clone(), registry); + store + .save_connection(sample_postgres_config(CONNECTION_ID)) + .await + .expect("save_connection"); + Fixture { + _keyring: keyring, + _temp: temp, + state, + driver, + } +} + +/// Everything the CM-73 case asks us to record, captured in one pass so both +/// tests read the same journey instead of re-deriving it. +#[derive(Debug)] +struct Observation { + db_session_id: String, + /// Physical resource that hosted the transaction (`resource-1`). + transaction_resource: String, + /// Did idle cleanup really close the physical resource? + physical_disconnect_observed: bool, + /// Is the `dbSessionId → connectionId` owner entry still present after cleanup? + owner_after_evict: Option, + /// Does the session still report an open transaction after cleanup? + status_after_evict: bool, + /// Did the same `dbSessionId` successfully run a query again, and on what + /// physical resource? + query_after_evict_ok: bool, + resource_after_query: Option, + /// Is the old `TransactionHandle` still readable, and what does it point at? + handle_after_query: Option<(String, String)>, + handle_resource_after_query: Option, + /// Result of a rollback attempt against that old handle. + rollback_result: Result<(), String>, + /// Was a commit/rollback delivered to a resource other than the one that + /// opened the transaction? + transaction_call_misrouted: bool, + calls: Vec, +} + +async fn observe_journey() -> Observation { + let fixture = fixture().await; + let state = &fixture.state; + let driver = fixture.driver.as_ref(); + + // The session must be opened with `ConnectionManager::connect`, which does + // not acquire a reference: `cleanup_idle_connections` only considers + // sessions whose ref count is 0, and every host connect path + // (`get_or_connect_session` / `connect_dedicated`) leaves the count at 1. + // This is the idle state the eviction sweep is written for. + let db_session_id = state + .connection_manager + .connect(CONNECTION_ID) + .await + .expect("connect"); + let after_connect = driver.snapshot(); + let transaction_resource = driver + .current_resource(&after_connect, &db_session_id) + .expect("physical resource of the first connect"); + + // 1. Real transaction handle, registered, plus an uncommitted marker. + begin_session_transaction_impl(state, db_session_id.clone()) + .await + .expect("begin_session_transaction"); + let marker = execute_query_impl( + state, + db_session_id.clone(), + "INSERT INTO cm73_marker (note) VALUES ('uncommitted')".to_string(), + None, + ) + .await; + assert!(marker.is_ok(), "uncommitted marker must be writable"); + + // 2. Advance to the idle-eviction deadline without touching production code. + // `expire_test_session` rewinds `last_used` by `idle_timeout + 1s`; it is + // `#[cfg(test)]`-only and adds nothing to a production build. The deadline + // itself stays the 30-minute production constant. + state + .connection_manager + .expire_test_session(&db_session_id) + .await; + state + .connection_manager + .cleanup_idle_connections() + .await; + + let after_evict = driver.snapshot(); + let physical_disconnect_observed = after_evict.contains(&Call::Disconnect { + resource: transaction_resource.clone(), + }); + let owner_after_evict = state + .connection_manager + .owner_connection_id(&db_session_id) + .await; + let status_after_evict = session_transaction_status_impl(state, db_session_id.clone()) + .await + .expect("session_transaction_status"); + + // 3. Same `dbSessionId` again — this is where the silent rebuild happens. + let query_after_evict_ok = execute_query_impl( + state, + db_session_id.clone(), + "SELECT 1".to_string(), + None, + ) + .await + .is_ok(); + + let after_query = driver.snapshot(); + let resource_after_query = driver.current_resource(&after_query, &db_session_id); + let handle_after_query = { + let txs = state.session_transactions.lock().await; + txs.get(&db_session_id) + .map(|tx| (tx.id.clone(), tx.connection_id.clone())) + }; + let handle_resource_after_query = handle_after_query + .as_ref() + .and_then(|(_, connection_id)| driver.resource_of_connection_id(connection_id)); + + // 4. Finally try to unwind the transaction that started on the dead resource. + let rollback_result = rollback_session_transaction_impl(state, db_session_id.clone()) + .await + .map_err(|err| err.to_string()); + + let final_calls = driver.snapshot(); + let transaction_resource_owned = final_calls.iter().find_map(|call| match call { + Call::Begin { resource, .. } => Some(resource.clone()), + _ => None, + }); + let transaction_call_misrouted = final_calls.iter().any(|call| { + let (resource, commit) = match call { + Call::Commit { resource, .. } => (resource, true), + Call::Rollback { resource, .. } => (resource, false), + _ => return false, + }; + let _ = commit; + Some(resource.clone()) != transaction_resource_owned.clone() + }); + + Observation { + db_session_id, + transaction_resource, + physical_disconnect_observed, + owner_after_evict, + status_after_evict, + query_after_evict_ok, + resource_after_query, + handle_after_query, + handle_resource_after_query, + rollback_result, + transaction_call_misrouted, + calls: final_calls, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +/// CM-73 baseline evidence (`:1284`). Green, and pins the *observed* sequence. +/// +/// **Delete this test when CM-73 goes green in the P3 connection-runtime +/// track** — do not relax it. Every assertion below describes the defect. +#[tokio::test] +async fn cm73_baseline_idle_eviction_reproduces_the_defect() { + let obs = observe_journey().await; + eprintln!("[CM-73 baseline] db_session_id={}", obs.db_session_id); + eprintln!("[CM-73 baseline] calls={:#?}", obs.calls); + + // Observed 1: idle cleanup really did close the physical resource that + // hosted the transaction. + assert!( + obs.physical_disconnect_observed, + "baseline: idle cleanup is expected to close {}", + obs.transaction_resource + ); + + // Observed 2: the `dbSessionId → connectionId` owner entry survives, which + // is precisely what lets the old id rebuild a session later. + assert_eq!( + obs.owner_after_evict.as_deref(), + Some(CONNECTION_ID), + "baseline: the owner entry is expected to survive eviction" + ); + + // Observed 3: the session still reports an open transaction although its + // physical connection is gone. + assert!( + obs.status_after_evict, + "baseline: an open transaction is expected to still be reported after the physical loss" + ); + + // Observed 4: a query on the same `dbSessionId` succeeds again, on a + // *different* physical resource that was silently connected for us. + assert!( + obs.query_after_evict_ok, + "baseline: the same dbSessionId is expected to serve queries again" + ); + assert_ne!( + obs.resource_after_query.as_deref(), + Some(obs.transaction_resource.as_str()), + "baseline: the rebuild is expected to land on a brand-new physical resource" + ); + + // Observed 5: the old `TransactionHandle` is still readable and still points + // at the dead physical connection. + let (tx_id, tx_connection_id) = obs + .handle_after_query + .as_ref() + .expect("baseline: the old handle is expected to still be registered"); + assert!( + !tx_id.is_empty() && !tx_connection_id.is_empty(), + "baseline: the old handle is expected to remain readable" + ); + assert_eq!( + obs.handle_resource_after_query.as_deref(), + Some(obs.transaction_resource.as_str()), + "baseline: the old handle is expected to keep pointing at the closed resource {}", + obs.transaction_resource + ); + + // Observed 6: unwinding it cannot succeed — the resource that owns it is + // gone, so the outcome is unknown. + assert!( + obs.rollback_result.is_err(), + "baseline: rollback of the orphaned handle is expected to fail, got {:?}", + obs.rollback_result + ); +} + +/// CM-73 target behavior. **Known failure** for the whole of P0 (`:1284` permits +/// it); `:1307` requires it to be green in the P3 connection-runtime track. +/// +/// The assertions are behavioral on purpose (`:1285` retention clause): they +/// must keep holding after the legacy `ConnectionManager` is deleted, so they +/// never name a legacy function, a legacy struct field, or a map shape. +/// Deleting or weakening any of them to reach green is forbidden. +#[tokio::test] +#[ignore = "CM-73 known failure: the current runtime evicts a resource that still has a registered live transaction handle and silently rebuilds it under the same dbSessionId. connection-management.md:1284 allows this as a known failure; :1307 requires green in the P3 connection-runtime track."] +async fn cm73_registered_live_handle_survives_idle_eviction_and_is_never_reused() { + let obs = observe_journey().await; + eprintln!("[CM-73 target] calls={:#?}", obs.calls); + + // T1 — While a registered live transaction handle exists, idle eviction must + // not close the physical resource behind it. + assert!( + !obs.physical_disconnect_observed, + "idle eviction closed the physical resource {} while a live transaction handle was still registered for dbSessionId {}", + obs.transaction_resource, + obs.db_session_id + ); + + // T2 — The physical loss must never be papered over by rebuilding the same + // `dbSessionId`. Either the resource stays up (T1) or recovery requires an + // explicitly new session with a new `dbSessionId`; a silent same-id + // rebuild that keeps serving queries is neither. + assert!( + !(obs.physical_disconnect_observed + && obs.query_after_evict_ok + && obs.resource_after_query.is_some()), + "after losing the physical resource, dbSessionId {} was silently rebuilt on {:?} and kept answering queries", + obs.db_session_id, + obs.resource_after_query + ); + + // T3 — Transaction status must track the physical session's liveness: it may + // not keep reporting Active once the resource is gone, and it may not stop + // reporting Active while the resource is still up. + assert_eq!( + obs.status_after_evict, obs.physical_disconnect_observed, + "transaction status ({}) disagrees with whether the physical resource {} is still alive", + obs.status_after_evict, obs.transaction_resource + ); + + // T4 — The old handle must never be committed or rolled back on a resource + // other than the one that opened it. + assert!( + !obs.transaction_call_misrouted, + "a transaction handle was delivered to a resource that did not open it" + ); + + // T5 — When the outcome cannot be known, unwinding must fail closed rather + // than report success. + assert!( + obs.rollback_result.is_err(), + "unwinding a transaction whose physical session is lost must fail closed, got {:?}", + obs.rollback_result + ); +} \ No newline at end of file diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2ccc2b7d4..62e639aef 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -294,3 +294,12 @@ mod tunnel_probe_tests; #[cfg(test)] #[path = "tunnel_summary_tests.rs"] mod tunnel_summary_tests; + +// CM-73 baseline evidence. Declared here (not under `services/connection_manager`) +// because the journey spans the command layer's transaction handling and +// `mod query` is private to `commands` — reaching it from `services` would +// require widening production visibility, which the P0 rollback clause forbids. +// `#[cfg(test)]` only: absent from production builds. +#[cfg(test)] +#[path = "cm73_baseline_tests.rs"] +mod cm73_baseline_tests; From e1e894933e1f09026eeb75be4cc7e377633a05d0 Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 08:29:33 +0800 Subject: [PATCH 056/666] =?UTF-8?q?docs(architecture/backend):=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=90=84=20consumer=20=E7=9A=84=E4=BC=9A=E8=AF=9D/?= =?UTF-8?q?=E7=9B=AE=E6=A0=87/=E5=8F=96=E6=B6=88=E7=8E=B0=E7=8A=B6?= =?UTF-8?q?=E4=B8=8E=20DTO=20=E6=8C=81=E4=B9=85=E5=8C=96=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 consumer inventory 结论并入既有架构文档(无新增文件): - services.md:ConnectionManager 拆出连接 ID 约定、引用计数与 idle eviction、 事务句柄不随 idle eviction 回收、隧道;新增「消费方的会话获取与目标路径」 矩阵(创建/复用/关闭/取消/配置目标 × Query、Table·DataTable·导出、 Schema Diff、Data Sync、Data Transfer、Workflow、MCP、AI 诊断), 并展开 Query execution 与表/导出两条路径。 - commands.md:新增「连接 ID 参数约定」,说明参数语义决定是否创建会话、 owner_connection_id 反查,以及由源码契约测试钉住的参数规则。 - store.md:新增 1.5「DTO 与持久化格式兼容范围」,记录字段级 default 规则、 「旧 session ID 不是迁移数据」的三层保证与不落盘的运行时状态。 - data-sync.md / schema-diff.md / workflow.md / mcp.md / ai.md:分别补写 连接、会话、目标与取消的已实现现状(Data Sync 后端按目标库决定专用会话; Schema Diff / Data Transfer 专用会话由前端建放;Workflow 共享且不释放; MCP 白名单为空即拒绝一切;AI 诊断只消费既有会话)。 修正的失效引用:mcp.md 中指向 services.md 与 ai.md 的两个 anchor。 --- docs/architecture/backend/ai.md | 20 ++++- docs/architecture/backend/commands.md | 22 ++++- docs/architecture/backend/data-sync.md | 23 +++++- docs/architecture/backend/mcp.md | 16 +++- docs/architecture/backend/schema-diff.md | 13 ++- docs/architecture/backend/services.md | 100 ++++++++++++++++++++--- docs/architecture/backend/store.md | 38 +++++++++ docs/architecture/backend/workflow.md | 16 ++++ 8 files changed, 227 insertions(+), 21 deletions(-) diff --git a/docs/architecture/backend/ai.md b/docs/architecture/backend/ai.md index 3bc1ac237..21be29896 100644 --- a/docs/architecture/backend/ai.md +++ b/docs/architecture/backend/ai.md @@ -181,7 +181,7 @@ groups: **前端识别**:`ContextPicker` 中 `.ctx.yaml` 文件使用 `Layers` 图标区分于普通文件。 -### 1.10 AI Chat MCP 工具 +### 1.11 AI Chat MCP 工具 `ai_chat` 在流式 tool loop 中合并两类工具定义: @@ -195,3 +195,21 @@ groups: - 未连接 MCP Server 时行为与原先一致,仅暴露 DB tools。 配置与连接管理见 [`mcp.md` — MCP Client AI 集成](./mcp.md#mcp-client-ai-集成)。 + +### 1.12 会话、目标与取消 + +AI 侧与数据库的接触分两类,边界互不重叠: + +**一、会话语义(诊断 / NL2SQL / EXPLAIN 分析 / Schema 文档)——只消费已存在的会话,不创建。** + +- 这些 IPC 的形参是 `db_session_id`(运行时会话 ID),不是 `connectionId`;命令体内只用 `get_session` / `get_session_config` 查找。这条边界由契约测试直接对源码断言:会话语义命令的形参必须含 `db_session_id` 且**不得**含 `connection_id`,且命令体内不得出现「把名为 `connection_id` 的变量喂给 `get_session`」的写法(`commands/ai/ipc_contract_guards.rs`)。 +- **`ai_chat` 的 schema 上下文是尽力而为**。会话取不到时不报错,而是降级为不向模型注入 db tools 并记一条告警——聊天仍可继续,只是失去「模型自己查表」的能力。诊断类命令则直接返回错误。 +- **目标不切库**。AI 与其他消费方一样把目标 database / schema 随命令传递;宿主不实现生产环境的切库路径。 + +**二、配置语义(`ai_diagnose_connection`)——收 `connectionId`,读的是落盘配置**,用于「连不上」的故障排查,与已建立会话无关。契约测试同样钉住这一侧。 + +**三、唯一的反向映射:`ai_analyze_queries`。** 它收运行时会话 ID,但**不碰会话**——把该 ID 反查成所属的 `connectionId`,再按连接过滤查询历史。这样「AI 分析这个面板的历史」不会把同名的其它连接的历史混进来。反查失败时退化为不按连接过滤,而不是报错。 + +**四、AI 的工具调用与 MCP 共用一条取会话路径。** AI DB tools 走 `services/db_tools.rs`,与 MCP 完全相同:拿持久化 `connectionId`、先过连接白名单、再转成运行时会话,且**不释放引用**。因此被 AI 工具访问过的会话同样不会被空闲回收扫掉。 + +**五、取消是 AI 自己的注册表。** AI 侧用一张按调用 ID 登记的取消令牌表(`ai/cancel.rs`),在开始时登记、结束时注销;取消一个已结束或从未开始的调用不报错,也不产生任何效果。它与 Data Sync / Data Transfer 使用的 job registry 是**两套互不相通**的机制,key 也不同(AI 是调用 ID,job registry 是 job ID)。两者都只存在于内存。 diff --git a/docs/architecture/backend/commands.md b/docs/architecture/backend/commands.md index 0c059db22..d0c5e60dd 100644 --- a/docs/architecture/backend/commands.md +++ b/docs/architecture/backend/commands.md @@ -40,7 +40,21 @@ Driver / Store / runtime 前端 IPC wrapper 与 Rust command 名称保持显式映射,不在组件中直接拼接后端协议。 -## 3. 专用领域 Command +## 3. 连接 ID 参数约定 + +Command 的参数名携带语义,不是随意的命名。`connection_id` 是持久化连接配置 ID,`db_session_id` 是运行时会话 ID,二者不可互换,也不存在「一个参数兼容两种语义」的回退路径: + +| 参数 | 语义 | 典型命令 | +|---|---|---| +| `connection_id` | 查配置、排任务、算归属 | `connect`、`connect_dedicated`、`workflow_execute`、`ai_diagnose_connection`、MCP 的全部 DB tools | +| `db_session_id` | 操作已建立的会话 | `execute_query`、`execute_query_stream`、`get_explain`、`close_database`、`release_connection`、`disconnect`、`preview_pending_changes`、`execute_row_change_plan`、`commit_pending_changes`、`write_data_file`、Schema Diff 的一整组 IPC、`ai_generate_sql`、`ai_diagnose_error`、`ai_analyze_explain`、`ai_generate_schema_doc` | +| `source_/target_db_session_id` | 迁移三件套的成对运行时目标 | Schema Diff 的比较 / 应用 / 预览类 IPC | + +参数的语义决定了会不会创建会话:**创建发生在入口,不发生在操作里**。真正会建立会话的只有三处路径——连接类 command(`connect` 共享 / `connect_dedicated` 专用)、Workflow 的 step 执行(见 [workflow.md](./workflow.md))、以及 Data Sync 的端点解析(目标库不是连接默认库时开专用会话,见 [data-sync.md](./data-sync.md))。其余 command 一律假定会话已存在,查找失败只走「用同一个 `dbSessionId` 重建物理连接」这一条透明路径。需要把运行时 ID 换回持久化 ID 时(例如 DataTable 提交前校验归属、AI 查询历史按连接过滤、Schema Diff 落库),统一走 `owner_connection_id(db_session_id)` 反查 owner 映射,不做「两种 ID 都能试」的兼容。 + +参数语义由测试钉住,而不是靠约定:`commands/ai/ipc_contract_guards.rs` 的契约测试直接对命令源码做断言,要求会话语义命令的形参包含 `db_session_id` 且**不含** `connection_id`,配置语义命令反之;同文件的另一组断言禁止把名为 `connection_id` 的变量喂给 `get_session` / `owner_connection_id`。历史上出现过「改了名字但语义反了」的回归,这组断言就是为堵住它。 + +## 4. 专用领域 Command 复杂功能有独立 command module: @@ -51,17 +65,17 @@ Driver / Store / runtime 这些 command 不应把产品流程重新实现一遍;比较、计划、映射和执行等领域逻辑分别位于 `schema_diff/`、`data_sync/`、`data_transfer/` 等模块。 -## 4. Driver Command +## 5. Driver Command `commands/driver_command.rs` 是通用 Driver Command IPC 入口。它把 connection/session 上下文交给 Driver Registry,再通过 `DriverCommandDefinition` 做 command discovery 和执行。 Workflow 同样复用 Driver Command Runtime,而不是绕过 Driver API 建立另一套 Driver-specific IPC。 -## 5. 错误 +## 6. 错误 Backend 使用结构化错误并在 IPC 边界转换成前端可消费的错误信息。Driver 原始错误不应直接泄漏实现细节到 React。 -## 6. 测试 +## 7. 测试 IPC contract 和 command 行为的测试位于: diff --git a/docs/architecture/backend/data-sync.md b/docs/architecture/backend/data-sync.md index ee528144c..0a3b04a9c 100644 --- a/docs/architecture/backend/data-sync.md +++ b/docs/architecture/backend/data-sync.md @@ -92,7 +92,26 @@ Execute 前支持 `revalidate_data_sync`,发现结构/PK 漂移时阻止执行 - `execute_data_sync` - `cancel_data_sync` -## 6. Frontend +## 6. 连接、会话与取消 + +Data Sync **不接受任何运行时会话 ID**:源端与目标端一律从任务落盘的 `connectionId` 现场解析,会话由后端在每次端点解析时决定,用完释放或保持共享有明确分支(`commands/sync/tasks.rs` 的 `resolve_task_endpoint`): + +| 任务保存的目标库 | 会话来源 | 是否释放 | +|---|---|---| +| 与连接配置默认库不同 | `connect_dedicated(connectionId, 该库)`,始终新建物理连接 | **是**——只释放 dedicated 会话 | +| 未指定,或就是连接配置的默认库 | `resolve_session_for_connection(connectionId)`,复用 GUI 可能已开着的同一条会话 | **否**——共享路径不释放引用 | + +区分的理由是宿主不做切库:复用那条「可变的共享会话」会让 PostgreSQL 挂在错误的库上,所以目标库非默认时必须另开一条独立会话。dedicated 会话在后续任何一步失败时也会回滚释放。 + +三条随之而来的约束: + +- **落盘的 `connectionId` 是唯一恢复依据**。任务文件里曾经存在过的运行时 `dbSessionId` 被刻意忽略——它是进程内的,重启后可能已经指向别的库。目标库 / schema 作为**独立的字符串字段**随任务保存;每次解析出会话后会拿**目标库**与该连接的实际库复核一次,不一致直接报冲突并提示重开任务,而不是静默统计到另一个库。schema 则相反:它在限定被检对象时显式应用,允许与连接的可变默认 search path 不同,因此不参与这次复核。 +- **`connectionId` 缺失是配置错误**,不是降级路径。任务必须重新打开并选择连接。 +- **取消走 job 标志**。`jobId` 是取消的唯一句柄,进程内有效、跨进程失效;不按连接或会话归类。取消早于执行到达时同样有效。 + +Data Sync 与另两个成员的边界:**Data Synchronization 只做同族、且结构与主键完全一致的复制**;结构差异与 DDL 生成属于 Schema Diff,异构数据搬迁属于 Data Transfer。写任务时不要把三者混成一种「迁移」。 + +## 7. Frontend `DataSyncWindow.tsx` 使用 6 步流程: @@ -102,7 +121,7 @@ Endpoints → Setup → Objects → Compare → Preview → Result Compare / Preview / Execute 的状态都在当前窗口流程中维护;取消通过 `jobId` 发送到 backend。 -## 7. Tests +## 8. Tests - Rust:`src-tauri/src/data_sync/**`、`src-tauri/src/commands/sync/tests.rs` - Frontend:`src/windows/data-sync/__tests__/` diff --git a/docs/architecture/backend/mcp.md b/docs/architecture/backend/mcp.md index 40092d8ff..f443bf270 100644 --- a/docs/architecture/backend/mcp.md +++ b/docs/architecture/backend/mcp.md @@ -93,14 +93,26 @@ Settings → **External MCP Servers** 管理已保存配置与运行时连接态 | `commands/ai.rs` | `collect_mcp_tool_definitions()` 合并 DB tools + MCP tools;`run_streaming_tool_loop` 路由 `mcp/*` 前缀 | | 前端 `McpClientSection` | 配置 CRUD、env 编辑、连接/重试、运行时工具列表 | -AI Chat(`ai_chat`)在已连接外部 MCP 时将 MCP 工具与内置 DB tools 一并注入 Provider;模型调用 `mcp/…` 工具时由后端经 `McpClientManager` 转发至对应子进程。详见 [`ai.md` — AI Chat MCP 工具](./ai.md#110-ai-chat-mcp-工具)。 +AI Chat(`ai_chat`)在已连接外部 MCP 时将 MCP 工具与内置 DB tools 一并注入 Provider;模型调用 `mcp/…` 工具时由后端经 `McpClientManager` 转发至对应子进程。详见 [`ai.md` — AI Chat MCP 工具](./ai.md#111-ai-chat-mcp-工具)。 ### 1.3 连接 ID(connection_id vs db_session_id) -MCP tools 与 GUI IPC 共用 `ConnectionManager` 语义(详见 [服务层 — 连接 ID 约定](./services.md#连接-id-约定)): +MCP tools 与 GUI IPC 共用 `ConnectionManager` 语义(详见 [服务层 — 连接 ID 约定](./services.md#21-连接-id-约定)): - **`connection_id`**:`connections.json` 中的持久化连接 UUID;`list_connections` 返回值;MCP tools / prompts / AI db tools 入参。 - **`db_session_id`**:GUI `connect` 返回的运行时会话 ID;GUI IPC(`query`、`get_schema` 等)在已连接后传此 ID。 > 历史演进:早期版本 MCP 工具入参叫 `config_id`、运行时句柄叫 `connection_id`。现行为上表术语,且 MCP 侧不保留 `config_id` 兼容别名——旧键会被直接拒绝(见 CHANGELOG 破坏性变更)。 +MCP Server 与 GUI 跑在同一个进程里:内嵌 server 启动时复制的是同一份 `AppState`,其中 `ConnectionManager`、会话事务表与查询执行注册表都是**共享的同一个实例**。`src-tauri/src/mcp/` 内不做任何会话管理——不建立连接、不释放引用、不管生命周期,所有取数都落到与 GUI 相同的 [db tools 辅助](services.md) 上。 + +由此得到三条已实现的行为边界: + +- **MCP 用持久化 id 入参,用共享会话执行**。每个 DB tool 先过连接白名单,再把 `connection_id` 交给 `resolve_connection_with_id` 转成运行时会话。因为会话是共享的,一次 MCP `query` 命中的很可能正是 GUI 查询编辑器正在用的那条 `dbSessionId`。 +- **引用只增不减**。该取会话路径取得引用后不释放,因此被 MCP 访问过的会话引用计数常驻 ≥1,不会被空闲回收扫掉。 +- **目标完全来自显式参数**。database / schema 按「调用方参数 → 连接配置回退 → 驱动约定」的顺序解析,**从不读取会话当前所在的库**——因为 MCP 是「人在工具调用里打字写表名」的入口,借用会话默认库会让同一份提示在不同会话下得到不同结果。 + +连接白名单是显式配置:白名单为空时按**拒绝一切**处理,不是「空即放行」。被拒绝的调用返回的是明确的权限错误,而不是静默返回空结果。 + +MCP 侧只有一个执行类入口会绕过 db tools:`run_workflow` 把 `connection_id` 与 MCP 自己的权限模式交给与 GUI 共用的 Workflow 执行器。 + diff --git a/docs/architecture/backend/schema-diff.md b/docs/architecture/backend/schema-diff.md index 4381869b7..410a37337 100644 --- a/docs/architecture/backend/schema-diff.md +++ b/docs/architecture/backend/schema-diff.md @@ -112,7 +112,18 @@ Endpoints - `SchemaDiffPlanPanel` - `SchemaDiffDeployPanel` -## 6. Tests +## 6. 连接与目标 + +Schema Diff 的 IPC **全部**收成对的 `sourceDbSessionId` / `targetDbSessionId`:`commands/schema_diff.rs` 里没有任何一条路径会建立会话,比较、应用、预览全部消费前端已经建好的会话。 + +- **专用会话由前端建、由前端放**。源端与目标端各自持有一条专用会话:端点组件用 `ensureDedicatedSession` 调 `connectDedicated` 建立,并在端点或目标库变化、以及组件卸载时用 `releaseDedicatedSession` 释放上一条(走的是与普通释放同一套引用计数,计数归零才真正断开)。释放发生在建立新会话**之前**,不会新旧两条同时挂着。端点没变时先 `ping` 探活,探活失败才重连。后端只做 `get_session` / `get_session_config` 查找。 +- **profile 落盘的是 `connectionId`**。加密的 profile 文件保存 `sourceConnectionId` / `targetConnectionId`,不保存任何运行时会话 ID;Deploy 的历史记录写入的同样是解析出来的 `connectionId`。 +- **反查依赖 owner 映射**。部署时把目标会话反查成 `connectionId` 以便落库;反查失败即中止部署并报「目标连接归属不可用」,不会退化成写一个空归属。Idle 回收故意保留 owner 映射正是为了让这条反查在会话物理连接消失后仍然可用。 +- **目标库在建连时就定死,不切库**。专用会话建立时把用户选中的库作为连接覆盖传下去(SQLite 例外:`main` 是目录别名,会传文件路径而不是 `main`),因此会话始终停在选中的库上,宿主不需要切库路径。 + +与另两个成员的边界:Schema Diff 负责**结构差异对比与 DDL 迁移生成**;同族数据复制属于 Data Synchronization,异构数据搬迁属于 Data Transfer。三者共用「专用会话 + 持久化 connectionId」的约束,但各自的会话由谁建立、profile 存什么并不相同。 + +## 7. Tests - Rust unit tests:`src-tauri/src/schema_diff/**` - Frontend:`src/windows/schema-diff/__tests__/` diff --git a/docs/architecture/backend/services.md b/docs/architecture/backend/services.md index 4ba36b7e7..ac672beb3 100644 --- a/docs/architecture/backend/services.md +++ b/docs/architecture/backend/services.md @@ -17,10 +17,14 @@ Services 是 DataZen backend 中复用性的运行时服务层,不承担 Tauri ## 2. ConnectionManager -DataZen 明确区分: +### 2.1 连接 ID 约定 -- `connectionId`:持久化连接配置 ID。 -- `dbSessionId`:运行时数据库 session ID。 +DataZen 明确区分两种 ID,二者不可互换,也不存在双模回退: + +| ID | 含义 | 落盘 | 语义 | +|---|---|---|---| +| `connectionId` | 持久化连接配置 ID(原 `configId`) | 写入 `connections.json` / 任务 / profile | 配置、归属、调度 | +| `dbSessionId` | 运行时数据库 session ID | **永不落盘** | 操作已建立的会话 | ```text connectionId @@ -34,21 +38,95 @@ Driver.connect() dbSessionId ``` -Schema Diff、Data Sync、Data Transfer 使用 dedicated session,避免共享主工作区的 database selection 或事务状态。 +从持久化 ID 到运行时会话只有三个入口(`connection_manager/sessions.rs`、`connection_manager/connections.rs`): + +| 入口 | 行为 | 语义 | +|---|---|---| +| `get_or_connect_session(connection_id)` | 在 `session_owner_map` 中查找 owner 等于该 `connectionId` 的**任意**现存会话;命中即复用并 +1 引用计数,否则新建物理连接 | 复用优先 | +| `connect_dedicated(connection_id, database)` | 绕过复用扫描,始终新建物理连接,随后 +1 引用计数 | 需要独立会话或独立 database | +| `resolve_session_for_connection(connection_id)` | 前两者的组合:先 `get_or_connect_session`,再 `get_session` | 「给我这个 `connectionId` 对应的会话」 | + +`get_or_connect_session` 对同一 `connectionId` 的所有调用方返回**同一个** `dbSessionId`;调用方之间没有会话隔离,除非显式走 `connect_dedicated`。`resolve_session_for_connection` 返回的运行时会话 ID 会被 Workflow、MCP / AI db tools、Data Sync 的共享路径直接丢弃,**这些路径不调用 `release`**——引用计数只增不减。 + +### 2.2 引用计数、释放与 idle eviction + +`release(db_session_id)` 递减计数,仅在计数归零时断开;`disconnect(db_session_id)` 立即清除计数、owner 映射并调用 `driver.disconnect`。 + +后台 `cleanup_idle_connections` 每 60 秒执行一次,只挑「引用计数为 0 且 `last_used` 超过 `idle_timeout`」的会话,`idle_timeout` 默认 1800 秒。该清理**故意不经过 `disconnect()`**:保留 `session_owner_map` 条目才能让 `reconnect()` 以同一个 `dbSessionId` 重建会话。因此被清理的物理连接消失了,`dbSessionId` 在映射上仍然「存在」;后续 `get_session` 未命中会直接 `reconnect`,并把新句柄的 ID 重写为同一个 `dbSessionId`。代价见下一节。 + +### 2.3 事务句柄不随 idle eviction 回收(已实现的现状) + +`AppState.session_transactions` 保存「`dbSessionId` → 事务句柄」映射,它位于 `AppState` 而不在 `ConnectionManager` 内: + +- `begin_session_transaction_impl` 用 `get_session` 开启事务并写入该表,**不取引用计数**; +- `release_connection_impl` 只递减引用,不回滚事务;后台 idle eviction 也只丢物理连接,两者都不碰这张表; +- 只有 `disconnect_impl` 会先移除并回滚事务句柄再断开。 + +因此存在一条可达路径:`connect` → `begin_session_transaction` → `release_connection`(计数归零、事务句柄残留)→ 空闲超过 1800 秒被清理 → 对同一 `dbSessionId` 提交 → `get_session` 触发 `reconnect` → 提交落在一条**从未开启该事务**的物理连接上,且提交按成功返回。这条连续旅程目前没有测试覆盖;本节只记录已实现的现状,不预判修复形态。 + +### 2.4 隧道 连接建立时先解析隧道再创建 driver:配置若带 `tunnel_id`,`resolve_tunnel_ref` 会从 `tunnels.json` 还原为内联的 `tunnel_kind` + 各类隧道参数;随后 `maybe_start_tunnel` 在本机监听回环端口并把 driver 看到的 host/port 改写为该回环地址。四种隧道类型(直连 / SSH / HTTP CONNECT / WebSocket)的差异对 driver 不可见。详见 [tunnel.md](tunnel.md)。 -## 3. Query execution +## 3. 消费方的会话获取与目标路径 + +下表是**已实现的现状**:每个消费方在「创建 / 复用 / 关闭 / 取消 / 配置与目标路径」五个维度上各自做了什么。文档描述的是当前代码行为,不是目标形态。 + +| 消费方 | 创建 | 复用 | 关闭 | 取消 | 配置与目标路径 | +|---|---|---|---|---|---| +| **Query** | 不创建。`execute_query` / `execute_query_stream` / `get_explain` 一律收 `db_session_id` | 与同一 `connectionId` 的所有消费方共享同一条会话 | 不关闭;会话由连接面板的 `release_connection` / `disconnect` 结束 | `QueryExecutionId` + 归属校验(校验 `dbSessionId` 与执行记录一致后才允许取消) | database 随命令 envelope 传递,`schema` 固定为 `None`;不做切库,手写 SQL 落到连接默认库 | +| **Table / DataTable / 导出** | 不创建。三个入口都只收 `db_session_id` | DataTable 写操作**复用同一 `dbSessionId` 上已存在的事务**,没有才自开一个 | 不关闭。导出同样只消费既有会话 | **导出没有中途取消**:结果里的「已取消」只代表保存对话框被放弃 | DataTable 提交前把 `dbSessionId` 映射回 `connectionId`,与表格记录上的 `connectionId` 不一致即拒绝 | +| **Schema Diff** | 后端不创建;专用会话由前端 `useSchemaDiffEndpoints` 调 `connectDedicated` 建立并在结束时 `releaseDedicatedSession` | 不与主工作区共享 | 前端在比较结束 / 卸载时释放 | 无独立取消通道 | IPC 收成对的 `sourceDbSessionId` / `targetDbSessionId`;profile 落盘的是 `sourceConnectionId` / `targetConnectionId`,部署记录也只写 `connectionId` | +| **Data Sync** | `resolve_task_endpoint`:任务目标库不是连接默认库时走 `connect_dedicated`,是默认库时走 `resolve_session_for_connection` | 共享路径下与 GUI 同一条会话 | 仅 dedicated 路径释放;共享路径不释放 | 进程级 job registry,按 `jobId` 取消;取消早于开始执行也有效 | 落盘的是 `sourceConnectionId` / `targetConnectionId` 与四个 database/schema 字段;任务文件里的运行时 session ID 一律不作为迁移数据 | +| **Data Transfer** | 后端不创建;前端 `DataTransferWindow` 持有源 / 目标两个专用会话并负责创建与释放 | 不与主工作区共享 | 前端在窗口卸载时释放,后端事件带回 `dbSessionId` 时清掉对应一侧 | 进程级 job registry | 运行时端点是「`dbSessionId` + database (+ schema)」,只随任务内存流转;落盘的 profile 只有 `connectionId` | +| **Workflow** | `resolve_session_for_connection`(首次执行时建立) | 与 GUI 同一条会话 | **不调用 `release`**:命令运行时与执行器都不释放,引用计数常驻 ≥1,因此这类会话不会被 idle eviction 回收 | 无独立取消通道,失败按 step 的错误策略 abort / skip / fallback | step 显式目标 → block 目标 → workflow 默认目标 → profile 初始目标;目标随命令输入走,**不做切库** | +| **MCP** | DB tools 收**持久化 `connection_id`**,经 `resolve_connection_with_id` → `resolve_session_for_connection` 建立 | 与 GUI 共享同一个 `ConnectionManager` 实例,MCP 的一次 `query` 可能命中的正是 GUI 查询编辑器正在用的那条会话 | 不释放引用 | 无独立取消通道 | database / schema 全部来自调用参数 → 连接配置回退 → 驱动约定,**从不读会话当前库**;白名单为空时按拒绝一切处理 | +| **AI 诊断** | 不创建。诊断、NL2SQL、EXPLAIN 分析、Schema 文档都只收 `db_session_id` | AI Chat 的 schema 上下文分支用既有会话,取不到就降级为不注入 db tools | 不关闭 | AI 侧独立的取消注册表 | 唯一反向的入口是查询历史分析:它把 `dbSessionId` 映射回 `connectionId`,再按连接过滤历史 | + +要点: + +- **会话创建集中在少数入口**。生产代码里真正新建会话的地方只有连接 IPC(共享 / 专用两种)、Data Sync 的端点解析、Workflow 的命令运行时与执行器、以及 db_tools(MCP 与 AI 共用)这几处;Query、DataTable、导出、Schema Diff、Data Transfer、AI 诊断都只消费已存在的 `dbSessionId`。 +- **「三件套」的专用会话不是同一套机制**。Schema Diff 与 Data Transfer 的专用会话由**前端**建立并释放;Data Sync 的专用会话由**后端**按目标库是否等于连接默认库自行决定要不要开。三者都避免与主工作区共享事务状态。 +- **共享即钉住**。Workflow 与 MCP 取得会话后不释放引用,使这些会话不再满足 idle eviction 的「计数为 0」条件。 +- **不切库是全局约束**。宿主不实现生产环境的切库路径:切库命令仅存在于测试代码中。因此所有消费方的目标都通过命令 envelope 传递。 + +## 4. Query execution + +普通 Query 由 command 层进入 QueryExecutor / Driver。Services 不根据 PostgreSQL/MySQL 等类型拼接方言 SQL;数据库特定能力由 Driver 提供。 + +Query 是最纯粹的会话消费方,也是这套约定的基准形状: + +- **创建**:不创建。执行、EXPLAIN、流式查询的 IPC 形参都是 `dbSessionId`,命令体内只做会话查找。 +- **复用**:无独立策略——用的是调用方给的那条会话;会话若已被空闲回收,会话管理器会用同一个 ID 重建物理连接(见 [2.2](#22-引用计数释放与-idle-eviction))。 +- **关闭**:不在 Query 层关闭。会话由连接 IPC 释放或断开。 +- **取消**:按**执行**而不是按会话取消。流式执行分配一个执行 ID,取消时校验发起者与会话一致,不一致直接拒绝——避免一个面板取消另一个面板的查询。 +- **配置目标**:database 随命令 envelope 传递,schema 恒为 `None`(不切库)。目标名的解析交给驱动:能内联限定名的驱动自己改写,不能的驱动由用户写全限定名。**读**命令另有 `closeDatabase`:它关掉当前打开的库并作废 schema 缓存,不是把会话切到别的库。 + +事务:显式事务按会话登记句柄。开启事务**不取引用**,因此它的存亡不受引用计数保护(见 [2.3](#23-事务句柄不随-idle-eviction-回收已实现的现状))。 + +## 5. 表与数据导出 + +DataTable 的行编辑与数据导出是两条不同的写路径,不要按同一套语义理解: + +**行编辑(DataTable 暂存改动的提交)** + +- **创建 / 复用**:都只在调用方已建的会话上执行。会话找不到时走同一条透明重建路径。 +- **事务**:预览待提交改动**不**开事务,只读;真正执行改动计划时,若该会话已有显式事务就复用,否则自己开一个。语义不明确的写入(例如无法判定影响行数)一律拒绝,不做「尽力而为」。 +- **关闭**:不涉及。 +- **取消**:无独立的取消句柄——粒度是「提交前 / 提交后」,不是执行中途。 +- **配置目标**:提交前复核四件事:改动计划里的表必须属于这条会话的**所属连接**、驱动类型与 database/schema 必须与当前配置一致、连接不得只读、以及结构指纹是否仍然匹配。任一不符即中止,避免把过期界面上的改动写到已经变化的表上。表所属连接是从会话反查出来的(见 [2.1](#21-连接-id-约定)),该反查依赖 owner 映射在空闲回收后仍然保留。 -普通 Query 由 command 层进入 QueryExecutor / Driver。流式查询可以绑定 `QueryExecutionId`,用于精确取消。 +**数据导出** -Services 不根据 PostgreSQL/MySQL 等类型拼接方言 SQL;数据库特定能力由 Driver 提供。 +- **创建 / 复用**:只消费调用方的 `dbSessionId`,不创建;导出前先过 SQL 安全门闸,再以流式回调逐批输出。 +- **关闭 / 取消**:**导出没有中途取消机制**——没有取消令牌也没有取消标志位,与 Data Sync / Data Transfer 的 job 标志是两种模型。导出结果里的「已取消」只表示用户在保存对话框里放弃了选文件,也就是导出根本没开始;一旦开始流式写盘,就只能由查询本身失败而结束。 +- **配置目标**:database / schema 随导出请求传递,并从会话配置补齐缺省项,不读会话当前状态。 -## 4. 长任务 +## 6. 长任务 -Data Sync、Data Transfer 等任务通过 job registry / job id 管理取消和生命周期。取消是显式的 job 操作,不应通过关闭 UI 猜测任务已经停止。 +Data Sync、Data Transfer 等任务通过 job registry / job id 管理取消和生命周期。取消是显式的 job 操作,不应通过关闭 UI 猜测任务已经停止。Job registry 是**进程内**的取消标志表,key 是 job ID,不按 `connectionId` 或 `dbSessionId` 归类,也不落盘;应用重启后旧 job ID 不再有效。取消标志先于执行到达时被保留,因此「先取消再开始」也是被支持的顺序。 -## 5. 依赖方向 +## 7. 依赖方向 ```text Commands @@ -62,7 +140,7 @@ Concrete Drivers 持久化通过 Store;Services 不把 React/Zustand 状态下沉到 Rust。 -## 6. Schema metadata +## 8. Schema metadata 宿主在 `execute_driver_command` 网关注册 `list_catalog`、`read_relation_columns`、`read_relation_schema` 和 `refresh_schema_metadata`。会话通过 envelope 的 `dbSessionId` 指定,database/schema/name 全部放在 input 的 `RelationRef` 中;这组命令拒绝重复的 envelope database/schema,避免目标冲突。 diff --git a/docs/architecture/backend/store.md b/docs/architecture/backend/store.md index c125a2821..9682db146 100644 --- a/docs/architecture/backend/store.md +++ b/docs/architecture/backend/store.md @@ -475,3 +475,41 @@ SELECT * FROM orders WHERE d = CURRENT_DATE; - **排序不变。** 旧面板是 `ORDER BY created_at DESC`,导出后按 `created_at` 倒序(同毫秒以 id 兜底),一致。 覆盖以上各点的测试在 `src-tauri/src/store/favorites/tests/migration.rs`(`tests.rs` 里的 `mod migration`),全部使用真实 `tempfile` 临时目录;文件可执行性在同目录的 `executability.rs`。把 `ALTER TABLE … RENAME` 换成空操作会让其中 4 个测试转红。 + +### 1.5 DTO 与持久化格式兼容范围 + +本节记录**当前已实现**的落盘格式能读什么、能写什么,以及哪些运行时状态**被刻意排除在持久化之外**。 + +#### 1.5.1 字段级兼容规则 + +所有落盘 DTO 的序列化键名统一为 camelCase(`#[serde(rename_all = "camelCase")]`),Rust 侧保持 snake_case。缺字段一律靠 `#[serde(default)]` 兜底,因此**旧文件永远读得进来,新字段不需要版本号**: + +| DTO | 关键字段 | 兼容规则 | +|---|---|---| +| `QueryHistoryEntry` | `connectionId`(持久化连接 id)、`database`(会话当时的逻辑库,`""` 表示未知 / 旧行)、`schema` | `schema` 为可选且带 `default`;旧行没有 schema 时读作 `None`,不反推 | +| `FavoriteQuery` | `id`(`.sql` 文件名的 stem)、`connectionId`、`updatedAt`、`keyword`、`database`、`folder` | `updatedAt` 为可选且带 `default`:从 SQLite 迁移来的收藏没有这一列。`id` 只来自文件名,改标题不会改文件名 | +| `SyncTask` | `sourceConnectionId` / `targetConnectionId`、`source/targetDatabase`、`source/targetSchema` | 全部 `#[serde(default)]`;连接 id 是**恢复与展示的唯一依据** | +| `SyncTask` | `sourceDbSessionId` / `targetDbSessionId` | `#[serde(default, skip_serializing)]`:**只读不写**。字段留在 Rust 模型里是为了能解析旧 JSON,落盘时一律省略 | + +#### 1.5.2 旧 session ID 不是迁移数据 + +Data Sync 任务文件是唯一曾经写入过运行时 `dbSessionId` 的地方,现在这条边界由三处机制共同保证: + +1. **模型层**:两个 session 字段带 `skip_serializing`,序列化时不存在于输出中,因此任何 `save_sync_task` 都不会把它们写回文件。 +2. **归一化层**:`SyncTask::normalize_legacy_state` 清空这两个字段;同时认为「从行偏移续跑」在进程重启后不安全,把 `current_table_offset` 归零、`strategy` 置 `unknown`、`status` 置 `interrupted`、`resume_state` 置 `unknown` 并记录一条明确的错误信息。加载路径与保存路径都调用它。 +3. **结构层**:迁移运行记录表里根本没有承载 session id 的列——测试直接读表结构断言 `db_session_id` 等敏感列名一个都不存在。 + +因此:**任务恢复只依据持久化的 `sourceConnectionId` / `targetConnectionId`(以及 database / schema 字段)**,运行时 session ID 既不是迁移输入,也不是迁移输出。Data Transfer 与 Schema Diff 的 profile 同理,落盘记录只含 `connectionId`;Data Transfer 的运行时端点(`dbSessionId` + database + schema)只随任务在内存中流转,从不进入任何 profile 文件。Schema Diff 的 profile 文件本身是加密存储。 + +#### 1.5.3 不落盘的运行时状态 + +以下状态**只存在于内存**,跨进程即失效,任何格式变更都不应把它们纳入兼容承诺: + +| 状态 | 位置 | 失效表现 | +|---|---|---| +| 会话取消标志 | `services/job_registry.rs` | 进程级表,key 是 job ID;应用重启后旧 job ID 不可取消,且先于开始执行到达的取消标志会被保留 | +| AI 调用取消令牌 | `ai/cancel.rs` | 按 AI 调用注册 / 注销 | +| 查询流执行注册表 | `AppState.query_executions` | 按 `QueryExecutionId` 归属校验;未知或已结束的 ID 一律拒绝 | +| 会话事务句柄 | `AppState.session_transactions` | 键是 `dbSessionId`;只有显式断开才回滚 | + +兼容性的验证落在 `src-tauri/src/store/tests/migration_tests.rs`:一个测试写入带 `sourceDbSessionId` / `targetDbSessionId` 与行偏移的任务,断言保存后磁盘 JSON 中**不含**这两个键、重新加载后两者为空且断点被中和;另一个测试直接喂一段手写的旧 JSON 数组,断言同样被中和、而连接 id 存活。 diff --git a/docs/architecture/backend/workflow.md b/docs/architecture/backend/workflow.md index afde539f7..f7a0cb802 100644 --- a/docs/architecture/backend/workflow.md +++ b/docs/architecture/backend/workflow.md @@ -66,6 +66,22 @@ steps: Step 没有指定 `connection` 时继承 Workflow 默认 connection;Step 显式指定时覆盖默认值。这样常见的“一个 connection 执行多个 step”不需要每个 step 重复选择 connection。 +### 会话:共享、且不释放 + +Workflow 的 IPC 用**持久化 `connectionId`** 指定连接(不要求连接的命令可以只给 `driverType`,见 [IPC](#ipc))。每条 step 执行时用 `resolve_session_for_connection(connectionId)` 取得运行时会话,但**拿到的会话 ID 被直接丢弃**(`let (_runtime_id, driver, handle)`)——它只被当作 driver 句柄用完就扔。 + +由此产生三条已实现的事实: + +- **与 GUI 共享同一条会话**。同一 `connectionId` 在 GUI 里已连接时,Workflow 走的是那一条,而不是另开一条。好处是上下文一致,代价是无法与主工作区隔离事务状态。 +- **引用只增不减**。该路径不调用 `release`,所以被 Workflow 执行过的会话引用计数常驻 ≥1,永远不满足空闲回收的「计数为 0」条件。会话只会在用户显式断开连接或应用退出时结束。 +- **取显示名也会建会话**。执行器为某个 step 计算展示用的连接名时同样调用 `resolve_session_for_connection`,因此一次纯粹的标签解析就能建立并钉住一条会话。 + +唯一会释放的例外是 Workflow 定义迁移(把 YAML 转成内部定义的路径),它在解析完连接后显式归还引用。 + +### 目标:不切库 + +Step 的目标 database / schema **随命令输入一起下发**,宿主不做切库。能内联改写 SQL 的驱动由驱动自己把未限定名改写到 step 的目标库;不做改写的驱动则由用户自己写全限定名。宿主没有生产环境的切库路径——切库命令只存在于测试代码里。 + ## Command 模型 Command 是 Driver 暴露给 Workflow / IPC / UI 的统一能力描述。 From 937b80a97549186f7af56cb1d0f0397d22ca6842 Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 08:32:49 +0800 Subject: [PATCH 057/666] =?UTF-8?q?test(postgres,mysql):=20=E5=8F=AA?= =?UTF-8?q?=E8=AE=A4=E8=BF=9B=E7=A8=8B=E7=8E=AF=E5=A2=83=20+=20=E6=8A=BD?= =?UTF-8?q?=E5=87=BA=E7=9C=9F=E5=AE=9E=E9=A9=B1=E5=8A=A8=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 postgres/mysql 测试里所有 dotenv 类加载器:配置只来自 TEST_*/TEST_MYSQL_* 进程环境变量(CI secret 或开发者 shell 注入),彻底不再解析 packages/drivers/.env - 新增可复用的真实驱动契约测试模板(http-support/tests/support/,按 AGENTS.md 放在驱动 crate 目录内而非 Host),free 层用真实驱动实例证明声明自洽、能力 「支持/缺失」可区分、缺失能力必须是显式拒绝断言;live 层每个 CM 维度一个 具名测试,无真实数据库时逐维度自述「未验证」,绝不把跳过说成已验证 - postgres/mysql 各加一份方言绑定,方言差异全部留在各自驱动目录 - postgres_cross_database:目录被服务端列出但本角色无权打开时干净跳过并报告 未验证(原先会 panic),连接错误只打印错误变体名,避免注入的凭据进入 CI 日志 --- .../tests/support/real_driver_contract.rs | 546 +++++++++++++++++ .../support/real_driver_contract_live.rs | 552 ++++++++++++++++++ .../support/real_driver_contract_plumbing.rs | 394 +++++++++++++ .../mysql/tests/mysql_cross_database.rs | 71 +-- .../mysql/tests/real_driver_contract.rs | 69 +++ .../postgres/tests/postgres_cross_database.rs | 113 ++-- .../tests/postgres_dump_empty_schema.rs | 61 +- .../tests/postgres_empty_query_columns.rs | 62 +- .../postgres/tests/real_driver_contract.rs | 72 +++ .../postgres/tests/schema_missing_table.rs | 62 +- 10 files changed, 1776 insertions(+), 226 deletions(-) create mode 100644 packages/drivers/http-support/tests/support/real_driver_contract.rs create mode 100644 packages/drivers/http-support/tests/support/real_driver_contract_live.rs create mode 100644 packages/drivers/http-support/tests/support/real_driver_contract_plumbing.rs create mode 100644 packages/drivers/mysql/tests/real_driver_contract.rs create mode 100644 packages/drivers/postgres/tests/real_driver_contract.rs diff --git a/packages/drivers/http-support/tests/support/real_driver_contract.rs b/packages/drivers/http-support/tests/support/real_driver_contract.rs new file mode 100644 index 000000000..3877c58ee --- /dev/null +++ b/packages/drivers/http-support/tests/support/real_driver_contract.rs @@ -0,0 +1,546 @@ +//! Reusable real-driver contract template (shared by every `path` driver). +//! +//! This file is **not** a Cargo test target on its own — `packages/drivers//tests/` +//! has no `support/main.rs`, so `cargo test -p datazen-driver-http-support` ignores it. +//! A driver opts in with a thin binding, e.g. `packages/drivers/postgres/tests/real_driver_contract.rs`: +//! +//! ```ignore +//! #[path = "../../http-support/tests/support/real_driver_contract.rs"] +//! mod support; +//! +//! use support::{Capability, Contract, Dialect}; +//! +//! const CONTRACT: Contract = Contract { /* this driver's declared expectations */ }; +//! fn contract_driver() -> PostgresDriver { PostgresDriver::new() } +//! ``` +//! +//! Why `http-support`: it is the one crate under `packages/drivers/` documented as a +//! *shared helper* rather than a driver (`driver-capability-migration.md` §1.2), so no +//! driver crate owns the contract and every driver pulls it in as a peer file. `#[path]` +//! keeps the template inside `tests/**` (AGENTS.md「驱动测试落点」: driver tests never move +//! to Host) and avoids needing a `[dev-dependencies]` entry in each driver `Cargo.toml`. +//! Every line of real dialect SQL lives in the driver's own crate, never here. +//! +//! Two tiers: +//! +//! * **Free tier** — real assertions that need no server. They run everywhere, including +//! CI, and are what actually proves the *declared* contract is self-consistent. +//! * **Live tier** — one named `#[tokio::test]` per CM dimension. Each needs a real +//! server and **dedicated** fixture databases; without them it skips and reports the +//! dimension it could not verify. `fake-runtime-fixtures.md` §10.4 forbids substituting +//! a fake runtime for a real-protocol conclusion, so a skip is reported as unverified, +//! never as "verified". +//! +//! Credentials come from the **process environment only** (CI secret or developer shell), +//! per `fake-runtime-fixtures.md` §10.2 rule 5 and AGENTS.md「本地环境变量文件保护」. +//! `test_sources_never_read_env_files` enforces that as a regression guard. +//! +//! Add `-- --nocapture` to see the skip reasons printed by the live tier. + +#![allow(dead_code)] + +use std::path::PathBuf; + +use datazen_driver_api::{ + validate_schema_target, DatabaseDriver, DdlAtomicity, DriverError, SchemaScope, SqlTarget, +}; +#[path = "real_driver_contract_plumbing.rs"] +mod plumbing; +pub use plumbing::*; + +#[path = "real_driver_contract_live.rs"] +mod live; + +// --------------------------------------------------------------------------- +// Declared-capability vocabulary +// --------------------------------------------------------------------------- + +/// Per-capability verdict, mirroring the `driver-capability-migration.md` §5.2 +/// vocabulary. `Unsupported` and `Unknown` are deliberately distinct: conflating +/// them is exactly the "silently skipped, then claimed as verified" failure mode +/// `fake-runtime-fixtures.md` §10.4 forbids. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Capability { + /// The driver implements it and the contract asserts the real behavior. + Supported, + /// The driver declares it absent and must refuse explicitly. + Unsupported, + /// The driver cannot answer; fail closed, same explicit-refusal duty. + Unknown, +} + +impl Capability { + fn is_present(self) -> bool { + matches!(self, Self::Supported) + } +} + +/// What a test must prove for a declared capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + /// Run the real-protocol journey against a live server. + Verify, + /// Must return `Err(DriverError::Unsupported(..))` — a *passing* refusal. + RefuseAsUnsupported, + /// Must return `Err(DriverError::TransactionError(..))`. + RefuseAsTransactionError, +} + +/// Fail-closed mapping from a declaration to the obligation it creates. +/// A missing capability never produces a skip: it produces a refusal assertion. +fn verdict_for(capability: Capability, refusal: Verdict) -> Verdict { + if capability.is_present() { + Verdict::Verify + } else { + refusal + } +} + +// --------------------------------------------------------------------------- +// Driver-supplied contract +// --------------------------------------------------------------------------- + +/// All dialect-specific SQL lives here, i.e. in the driver's own crate. +/// The template itself contains no `CREATE TABLE`, no `USE`, no quoting literal. +pub struct Dialect { + /// Quoted name of the A/B marker table. + pub marker: &'static str, + pub create_marker: &'static str, + /// Insert statement carrying a `{marker}` placeholder. + pub insert_marker: &'static str, + pub select_marker: &'static str, + pub drop_marker: &'static str, + /// Scalar expression naming the current namespace (`current_database()` …). + pub current_namespace: &'static str, + /// `SELECT` whose text merely *mentions* the switch keyword; must not switch. + pub decoy_text_select: &'static str, + /// Relation name that never exists, used to force a clean statement failure. + pub missing_object: &'static str, + /// `{name}`-templated statements for a per-case uniquely named table. + pub create_named: &'static str, + pub insert_named: &'static str, + pub select_named: &'static str, + pub drop_named: &'static str, + /// `{name}`-templated statements for a session-scoped temporary object. + pub create_temp: &'static str, + pub insert_temp: &'static str, + pub select_temp: &'static str, + /// The context-switch keyword this engine must never emit implicitly. + pub switch_keyword: &'static str, +} + +/// Everything a driver declares about itself. The free tier asserts the +/// declarations against the live trait; the live tier drives the real journeys. +pub struct Contract { + /// `driver_type()` / `ConnectionConfig::database_type` value. + pub label: &'static str, + /// Every `driver_type()` this contract covers (MySQL also reports `mariadb`). + pub driver_types: &'static [&'static str], + /// Process-environment key prefix, e.g. `TEST_PG_`. + pub env_prefix: &'static str, + pub default_port: u16, + pub default_user: &'static str, + + pub has_schema_level: bool, + pub default_schema: Option<&'static str>, + pub has_multi_database: bool, + pub ddl_atomicity: DdlAtomicity, + pub supports_offset: bool, + /// The driver keeps a closable resource **per database** (postgres-style + /// per-database pools). A driver that serves every database from one pool + /// must declare `false`; CM-69 then asserts the *no-resource* contract. + pub per_database_resource: bool, + + pub transactions: Capability, + pub precise_cancel: Capability, + pub session_scoped_state: Capability, + pub read_snapshots: Capability, + pub reset_for_reuse: Capability, + + pub dialect: Dialect, +} + +impl Contract { + /// Live-tier availability, with a reason so the unverified report can say + /// *which* precondition failed. + fn availability(&self) -> Result<(), String> { + let prefix = self.env_prefix; + if !std::env::vars().any(|(k, _)| k.starts_with(prefix)) { + return Err(format!("no `{prefix}*` in the process environment")); + } + let a = required_env(format!("{prefix}DATABASE"))?; + let b = optional_env(format!("{prefix}DATABASE_B")).unwrap_or_else(|| a.clone()); + if a == b { + return Err("the two fixture targets are the same database".into()); + } + for (key, db) in [("DATABASE", &a), ("DATABASE_B", &b)] { + if !db.starts_with(FIXTURE_PREFIX) { + return Err(format!( + "`{prefix}{key}` is `{db}`, which is not a dedicated fixture database \ + (must start with `{FIXTURE_PREFIX}`) — refusing to touch it" + )); + } + } + Ok(()) + } +} +// --------------------------------------------------------------------------- +// Free tier — runs everywhere, no server required +// --------------------------------------------------------------------------- + +#[test] +fn contract_matrix_covers_every_required_dimension() { + let contract = &crate::CONTRACT; + let mut seen = std::collections::BTreeSet::new(); + for (id, test, _) in REQUIRED_DIMENSIONS { + assert!(seen.insert(*id), "dimension {id} is listed twice"); + assert!(test.starts_with("cm"), "{id} owner {test} is not a cm* test"); + } + assert!( + REQUIRED_DIMENSIONS.len() >= 12, + "the required-dimension table must not shrink silently" + ); + assert!(!contract.driver_types.is_empty(), "driver_types must not be empty"); + assert!( + contract.driver_types.contains(&contract.label), + "driver_types must include the canonical label" + ); + assert!(!contract.dialect.marker.is_empty()); +} + +#[test] +fn capability_declarations_match_the_contract() { + let contract = &crate::CONTRACT; + let driver = crate::contract_driver(); + + let reported = driver.driver_type(); + assert!( + contract.driver_types.contains(&reported.as_str()), + "driver_type() = {reported} is not a declared value {:?}", + contract.driver_types + ); + assert_eq!(driver.has_schema_level(), contract.has_schema_level); + assert_eq!(driver.has_multi_database(), contract.has_multi_database); + assert_eq!(driver.default_schema(), contract.default_schema); + assert_eq!(driver.ddl_atomicity(), contract.ddl_atomicity); + assert_eq!(driver.supports_offset(), contract.supports_offset); + assert!(driver.quote_char() != '\0', "quote_char must name a real delimiter"); + assert!(!driver.sync_family().is_empty(), "sync_family must not be empty"); + + // A schema level without a default schema (or the reverse) is a lie the + // Host would act on, so it is refused here rather than discovered later. + assert_eq!( + contract.has_schema_level, + contract.default_schema.is_some(), + "schema level and default schema must agree" + ); + + // `Unknown` / `Unsupported` must not be quietly upgraded to a capability the + // driver does not actually implement. + if !contract.precise_cancel.is_present() { + assert!( + !driver.supports_query_execution_cancel(), + "{} declares precise cancel missing but advertises execution cancellation", + contract.label + ); + } + // Pagination is a pure function of (limit, offset) and must stay that way. + let syntax = driver.pagination_syntax(25, 50); + assert_eq!(syntax, driver.pagination_syntax(25, 50)); + assert!(!syntax.clause.is_empty(), "the dialect must produce a pagination clause"); + assert_eq!( + syntax.clause.contains("OFFSET"), + contract.supports_offset, + "OFFSET support and the emitted clause must agree" + ); +} + +/// The schema dimension, proven with no server: the shared chokepoint must +/// **refuse** the wrong shape for this driver instead of guessing. +#[test] +fn schema_target_validation_refuses_the_wrong_shape() { + let driver = crate::contract_driver(); + let database = "dz_target"; + + let declared_schema = crate::CONTRACT.default_schema; + assert!( + validate_schema_target(&driver, database, declared_schema, SchemaScope::AnySchema).is_ok(), + "the declared schema shape must always be accepted in AnySchema scope" + ); + // The declared shape is always resolvable exactly. + assert!( + validate_schema_target(&driver, database, declared_schema, SchemaScope::ExactSchema).is_ok(), + "the declared schema shape must be resolvable in ExactSchema scope too" + ); + // Only a schema-level engine has something to complain about when no schema + // is given; for a schema-less engine the database *is* the namespace. + assert_eq!( + validate_schema_target(&driver, database, None, SchemaScope::ExactSchema).is_ok(), + !driver.has_schema_level(), + "an ExactSchema target carrying no schema is refused exactly when the engine has a schema level" + ); + + if driver.has_schema_level() { + let schema = crate::CONTRACT.default_schema.expect("schema level needs a default"); + assert!(validate_schema_target(&driver, database, Some(schema), SchemaScope::AnySchema).is_ok()); + assert!(validate_schema_target(&driver, database, Some(schema), SchemaScope::ExactSchema).is_ok()); + // Exact resolution with no schema is ambiguous → must be refused. + assert!( + matches!( + validate_schema_target(&driver, database, None, SchemaScope::ExactSchema), + Err(DriverError::InvalidConfig(_)) + ), + "a schema-level driver must refuse an ExactSchema target that carries no schema" + ); + } else { + // A schema-less engine must refuse a schema it cannot honour, in both scopes. + for scope in [SchemaScope::AnySchema, SchemaScope::ExactSchema] { + assert!( + matches!( + validate_schema_target(&driver, database, Some("public"), scope), + Err(DriverError::InvalidConfig(_)) + ), + "a schema-less driver must refuse a schema-qualified target in {scope:?}" + ); + } + } + // Blank is "absent", never a schema named "". + assert!(validate_schema_target(&driver, database, Some(" "), SchemaScope::AnySchema).is_ok()); +} + +/// The "missing vs supported" decision table. Every non-`Supported` verdict must +/// map to an explicit refusal — never to a silent skip, and never to a claim that +/// the capability was verified. +#[test] +fn missing_capabilities_demand_an_explicit_refusal() { + let contract = &crate::CONTRACT; + let cases: [(&str, Capability, Verdict); 5] = [ + ("transactions", contract.transactions, Verdict::RefuseAsTransactionError), + ("precise_cancel", contract.precise_cancel, Verdict::RefuseAsUnsupported), + ("session_scoped_state", contract.session_scoped_state, Verdict::RefuseAsUnsupported), + ("read_snapshots", contract.read_snapshots, Verdict::RefuseAsUnsupported), + ("reset_for_reuse", contract.reset_for_reuse, Verdict::RefuseAsUnsupported), + ]; + for (dimension, capability, refusal) in cases { + let verdict = verdict_for(capability, refusal); + if capability == Capability::Supported { + assert_eq!( + verdict, + Verdict::Verify, + "{dimension}: a declared-supported capability must be verified against a real server" + ); + } else { + assert_eq!( + verdict, + refusal, + "{dimension}: a missing capability must produce an explicit-refusal assertion" + ); + assert_ne!( + verdict, + Verdict::Verify, + "{dimension} is declared missing but would be asserted as verified" + ); + } + } + // Unknown is not the same answer as Unsupported: both refuse, neither verifies. + assert_ne!(verdict_for(Capability::Unknown, Verdict::RefuseAsUnsupported), Verdict::Verify); + assert_eq!( + verdict_for(Capability::Unknown, Verdict::RefuseAsUnsupported), + verdict_for(Capability::Unsupported, Verdict::RefuseAsUnsupported) + ); +} + +/// CM-13/CM-10: target qualification is pure, idempotent, and never emits a +/// context switch — the caller-visible guarantee that a qualified name cannot +/// move the session. Provable without a server because the trait documents +/// `qualify_sql_target` as pure/stateless and idempotent. +#[test] +fn qualified_target_sql_is_idempotent_and_never_switches_context() { + let driver = crate::contract_driver(); + let contract = &crate::CONTRACT; + let sql = format!("SELECT marker FROM {}", contract.dialect.marker); + let target_db = "dz_fixture_other"; + + let Some(first) = driver.qualify_sql_target(&sql, Some(target_db), contract.default_schema) + else { + // Declared as incapable → the dimension is served by the driver's own + // per-database routing instead, which the live tier asserts. + assert!( + !contract.per_database_resource, + "{} declares a per-database resource but implements no target qualification", + contract.label + ); + return; + }; + let second = driver + .qualify_sql_target(&first, Some(target_db), contract.default_schema) + .expect("a second pass must return Some, not give up mid-rewrite"); + assert_eq!(first, second, "target qualification must be idempotent"); + assert!( + !first.trim_start().to_uppercase().starts_with(&contract.dialect.switch_keyword), + "qualification must never emit a context switch: {first}" + ); + // How the database dimension is honoured is itself part of the declared + // semantic model, and the two branches here are why one template can serve + // two genuinely different engines: + if contract.per_database_resource { + // PostgreSQL cannot name another database in one statement; the `*_at` + // methods route to that database's own pool instead. Inlining would be + // a lie, so the template requires the database to stay out of the SQL. + assert!( + !first.contains(target_db), + "a per-database-resource engine routes by connection and must not inline the database: {first}" + ); + } else { + // One pool serves every database, so the only way to reach the requested + // target is to qualify the name with it. + assert!( + first.contains(target_db), + "a single-pool engine must inline the requested database: {first}" + ); + } + // No target, no rewrite: a completion lookup must not move context either. + let absent = SqlTarget::new(None, None); + assert!(!absent.is_present()); + assert_eq!(absent.schema(), None); + assert_eq!(SqlTarget::new(Some(" "), None).database(), None); +} + +/// §10.2 rule 5 as a regression guard: no Rust source under this driver crate's +/// `tests/`, and no shared template source, may name an env file as a **string +/// literal** or call a dotenv-style loader. Prose that mentions such a file in +/// backticks stays allowed, so the guard cannot be satisfied by deleting words +/// from the docs, and it cannot be defeated by a comment. +#[test] +fn test_sources_never_read_env_files() { + // Built from fragments so this guard does not match its own source. + let forbidden: Vec = vec![ + concat!("load_", "dotenv").to_string(), + concat!("dot", "env()").to_string(), + concat!("dot", "env::").to_string(), + concat!("dot", "envy").to_string(), + format!("\"{}\"", concat!(".en", "v")), + format!("\"{}test\"", concat!(".en", "v")), + format!("'{}'", concat!(".en", "v")), + format!("'{}test'", concat!(".en", "v")), + ]; + + assert!( + file!().ends_with("http-support/tests/support/real_driver_contract.rs"), + "this file is not the shared template (file!() = {})", + file!() + ); + + let mut sources = template_sources(); + assert!( + sources.iter().all(|path| path.is_file()), + "the shared template must be reachable from the driver crate: {sources:?}" + ); + collect_rs(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests"), &mut sources); + assert!(sources.len() > 3, "the guard found no driver sources to scan"); + + for path in &sources { + let content = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + for token in &forbidden { + assert!( + !content.contains(token.as_str()), + "{} must not contain {token:?} — credentials come from the process environment only", + path.display() + ); + } + } +} + +/// §10.2 rules 1 and 3, statically: fixture objects are uniquely named and carry +/// the dedicated prefix, so a live run can never touch a shared or production +/// object and teardown can target exactly what it created. +#[test] +fn fixture_objects_use_the_dedicated_prefix_and_unique_names() { + let dialect = &crate::CONTRACT.dialect; + for statement in [ + dialect.create_marker, + dialect.insert_marker, + dialect.select_marker, + dialect.drop_marker, + ] { + assert!( + statement.to_lowercase().contains(FIXTURE_PREFIX), + "fixture statement does not reference the dedicated prefix: {statement}" + ); + } + assert!( + dialect.insert_marker.contains("{marker}"), + "the A/B marker insert must carry a {{marker}} placeholder" + ); + assert!( + dialect.select_marker.to_lowercase().contains(&dialect.marker.to_lowercase()), + "the marker select must read the marker table by name" + ); + for statement in [ + dialect.create_named, + dialect.insert_named, + dialect.select_named, + dialect.drop_named, + dialect.create_temp, + dialect.insert_temp, + dialect.select_temp, + ] { + assert!( + statement.contains("{name}"), + "per-case statement must be name-templated: {statement}" + ); + } + + let mut fixture = Fixture::new("static"); + let first = fixture.name("one"); + let second = fixture.name("two"); + assert_ne!(first, second, "fixture names must be unique per case"); + for name in [&first, &second] { + assert!(name.starts_with(FIXTURE_PREFIX), "{name} lacks the fixture prefix"); + assert!(!name.contains(' '), "{name} must be usable in an unquoted identifier"); + assert!(!name.contains(';'), "{name} must not break statement splitting"); + } + fixture.on_drop(dialect.drop_marker.to_string()); + assert_eq!(fixture.drops.len(), 1, "teardown must be registered explicitly"); +} + +/// Anti-silent-drop guard: a required dimension may not stay in the matrix while +/// its live test quietly disappears. +#[test] +fn every_required_dimension_owns_a_named_test() { + let source = template_sources() + .iter() + .map(|path| { + std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) + }) + .collect::>() + .join("\n"); + for (id, test, _) in REQUIRED_DIMENSIONS { + assert!( + source.contains(&format!("async fn {test}(")), + "{id} is listed as required but has no live test named {test}" + ); + } +} + +/// The honest inventory of what a run did **not** verify. Always runs, so a green +/// suite can never be read as "capabilities verified" when no real database was +/// involved. Use `-- --nocapture` to read the reasons. +#[test] +fn unverified_scope_report() { + let contract = &crate::CONTRACT; + eprintln!("\n=== {} real-driver contract: 未验证范围 ===", contract.label); + match contract.availability() { + Ok(()) => eprintln!( + "live 层前置条件齐备;若某维度未出现在测试输出中,说明其 live 测试已真实执行并通过。" + ), + Err(reason) => eprintln!("live 层未启用:{reason}"), + } + for (id, test, what) in REQUIRED_DIMENSIONS { + eprintln!(" - {id} / {test}: {what}"); + } + eprintln!("free 层(声明自洽、能力区分、拒绝断言、幂等性、无 env 文件读取)已实际执行并通过。"); + eprintln!("============================================\n"); +} diff --git a/packages/drivers/http-support/tests/support/real_driver_contract_live.rs b/packages/drivers/http-support/tests/support/real_driver_contract_live.rs new file mode 100644 index 000000000..e56e7aa51 --- /dev/null +++ b/packages/drivers/http-support/tests/support/real_driver_contract_live.rs @@ -0,0 +1,552 @@ +//! Live tier of the real-driver contract template: one named `#[tokio::test]` per +//! required CM dimension. `#[path]`-included by `real_driver_contract.rs`; it is not a +//! Cargo target on its own. Each test either proves a dimension against a real server +//! or reports that exact dimension as unverified — it never turns a skip into a pass +//! (`fake-runtime-fixtures.md` §10.4). + +#![allow(dead_code, unused_imports)] + +use std::time::{SystemTime, UNIX_EPOCH}; + +use datazen_driver_api::{DatabaseDriver, DriverError, MultiQueryResult, QueryExecutionId}; + +// The whole template surface, so a live test can reach any helper the free tier +// also uses. `#[path]`-included, so nothing here is a public API of the crate. +use super::*; + +/// Open the live tier for one dimension, or return `None` after saying which +/// dimension went unverified and why. +async fn open(driver: D, dimension: &'static str) -> Option> { + super::open_live(driver, dimension).await +} + +// --------------------------------------------------------------------------- +// Live tier — one named test per dimension +// --------------------------------------------------------------------------- + +/// CM-08 — two independent sessions each stay on their own database; the same +/// named relation resolves to that session's own catalog. +#[tokio::test] +async fn cm08_two_sessions_keep_independent_databases() { + let Some(live) = open(crate::contract_driver(), "CM-08").await else { + return; + }; + let dialect = live.dialect; + let mut fixture = Fixture::new("cm08"); + + let outcome = async { + let target_a = live.profile.a.clone(); + let target_b = live.profile.b.clone(); + assert_ne!(target_a, target_b, "CM-08 needs two distinct targets"); + + let other = live.second_session("dz-contract-cm08-a").await?; + // §10.2 rule 2: identically named marker tables, different marker values. + seed_marker(&live.driver, &other, dialect, "dz_marker_a").await?; + seed_marker(&live.driver, &live.handle, dialect, "dz_marker_b").await?; + fixture.on_drop(dialect.drop_marker.to_string()); + + let namespace_b = live.namespace().await?; + let namespace_a = live + .driver + .query(&other, &format!("SELECT {}", dialect.current_namespace)) + .await + .map(|result| first_cell(&result))?; + assert_eq!(namespace_b, target_b, "session B drifted off its own target"); + assert_eq!(namespace_a, target_a, "session A drifted off its own target"); + assert_ne!(namespace_a, namespace_b, "the two sessions must stay isolated"); + + // The same named relation resolves inside each session's own catalog: + // an unqualified read never crosses over to the other session's target. + let read_b = live.query(dialect.select_marker).await?; + assert!( + rows_contain(&read_b, "dz_marker_b") && !rows_contain(&read_b, "dz_marker_a"), + "an unqualified read must stay inside its own session: {:?}", + read_b.rows + ); + let read_a = live.driver.query(&other, dialect.select_marker).await?; + assert!( + rows_contain(&read_a, "dz_marker_a") && !rows_contain(&read_a, "dz_marker_b"), + "an unqualified read must stay inside its own session: {:?}", + read_a.rows + ); + + live.driver.disconnect(other).await + } + .await; + + let teardown = fixture.finish(&live.driver, &live.handle).await; + assert!(teardown.is_ok(), "fixture teardown must succeed"); + outcome.expect("CM-08 two independent sessions"); +} + +/// CM-09 — session-scoped temporary state stays inside the session that made it. +#[tokio::test] +async fn cm09_session_scoped_state_stays_in_its_own_session() { + let Some(live) = open(crate::contract_driver(), "CM-09").await else { + return; + }; + let dialect = live.dialect; + let mut fixture = Fixture::new("cm09"); + + let outcome = async { + let temp = fixture.name("tmp"); + let create = render(dialect.create_temp, &temp); + match crate::CONTRACT.session_scoped_state { + Capability::Supported => { + live.execute(&create).await?; + live.execute(&render(dialect.insert_temp, &temp)).await?; + let here = live.query(&render(dialect.select_temp, &temp)).await?; + assert!(!here.rows.is_empty(), "the owning session must see its own row"); + let other = live.second_session("dz-contract-cm09-a").await?; + let leaked = live + .driver + .query(&other, &render(dialect.select_temp, &temp)) + .await + .expect_err("a session-scoped object must not be visible to another session"); + assert_eq!(err_kind(&leaked), "QueryFailed"); + live.driver.disconnect(other).await + } + _ => { + // Missing capability: the contract requires an explicit refusal, not + // a skip that goes on to claim the dimension was verified. + let err = live + .execute(&create) + .await + .expect_err("a driver without session-scoped state must refuse, not accept"); + assert!( + matches!(err, DriverError::Unsupported(_) | DriverError::NotSupported(_)), + "expected an explicit Unsupported refusal, got {}", + err_kind(&err) + ); + Ok(()) + } + } + } + .await; + + let teardown = fixture.finish(&live.driver, &live.handle).await; + assert!(teardown.is_ok(), "fixture teardown must succeed"); + outcome.expect("CM-09 session-scoped state"); +} + +/// CM-10 — a raw script keeps its order and leaves the session on its final target. +#[tokio::test] +async fn cm10_script_order_is_preserved_and_final_target_is_last() { + let Some(live) = open(crate::contract_driver(), "CM-10").await else { + return; + }; + let dialect = live.dialect; + let mut fixture = Fixture::new("cm10"); + + let outcome = async { + // §10.2 rule 3: a uniquely named table, dropped again in teardown. + let table = fixture.name("ordered"); + let drop_table = render(dialect.drop_named, &table); + fixture.on_drop(drop_table); + live.execute(&render(dialect.create_named, &table)).await?; + let script = format!( + "{}; {}; SELECT {}", + render(dialect.insert_named, &table), + render(dialect.select_named, &table), + dialect.current_namespace + ); + let MultiQueryResult { results, .. } = live.driver.query_multi(&live.handle, &script, None).await?; + assert!( + results.len() >= 3, + "every statement in a raw script must produce its own result, got {}", + results.len() + ); + // Order is part of the contract: the select that runs after the insert must + // already see the row it wrote. + let reading = results + .iter() + .find(|result| result.sql.contains(&table)) + .expect("the script's own select must be reported"); + assert!( + statement_rows_contain(reading, "42"), + "statements must execute in order: the select must already see the insert" + ); + let last = results.last().expect("a non-empty script"); + assert_eq!( + statement_first_cell(last), + live.profile.b, + "the session must end on the script's final target" + ); + Ok::<(), DriverError>(()) + } + .await; + + let teardown = fixture.finish(&live.driver, &live.handle).await; + assert!(teardown.is_ok(), "fixture teardown must succeed"); + outcome.expect("CM-10 raw script order"); +} + +/// CM-13 / CM-15 — an explicitly targeted statement reads the marker of the +/// requested target, not of the session's own catalog, and never switches. +#[tokio::test] +async fn cm13_cross_target_resolution_reads_the_requested_target() { + let Some(live) = open(crate::contract_driver(), "CM-13").await else { + return; + }; + let dialect = live.dialect; + let mut fixture = Fixture::new("cm13"); + + let outcome = async { + let marker_a = fixture.name("a"); + let marker_b = fixture.name("b"); + let target_a = live.profile.a.clone(); + let other = live.second_session("dz-contract-cm13-a").await?; + seed_marker(&live.driver, &other, dialect, &marker_a).await?; + seed_marker(&live.driver, &live.handle, dialect, &marker_b).await?; + fixture.on_drop(dialect.drop_marker.to_string()); + + // Requesting A must yield A's marker, even from a session opened on B. + let requested = live + .driver + .query_at(&live.handle, dialect.select_marker, target_for(&target_a)) + .await?; + assert!( + rows_contain(&requested, &marker_a) && !rows_contain(&requested, &marker_b), + "a targeted read must resolve the requested target's marker, got {:?}", + requested.rows + ); + // And the session must still be on B afterwards: qualification is not a switch. + assert_eq!(live.namespace().await?, live.profile.b); + live.driver.disconnect(other).await + } + .await; + + let teardown = fixture.finish(&live.driver, &live.handle).await; + assert!(teardown.is_ok(), "fixture teardown must succeed"); + outcome.expect("CM-13 cross-target resolution"); +} + +/// CM-14 — the switch keyword inside a string literal must not move the session. +/// Text that merely looks like a switch is not a switch. +#[tokio::test] +async fn cm14_text_containing_the_switch_keyword_never_switches() { + let Some(live) = open(crate::contract_driver(), "CM-14").await else { + return; + }; + let dialect = live.dialect; + + let outcome = async { + let before = live.namespace().await?; + let result = live.query(dialect.decoy_text_select).await?; + assert!( + rows_contain(&result, dialect.switch_keyword.trim()), + "the decoy statement must round-trip its own text" + ); + assert_eq!( + before, + live.namespace().await?, + "text that merely contains the switch keyword must not change context" + ); + Ok::<(), DriverError>(()) + } + .await; + + outcome.expect("CM-14 keyword-shaped text must not switch database"); +} + +/// CM-17 — a hand-written transaction rolls back, and the row it wrote is gone. +#[tokio::test] +async fn cm17_hand_written_transaction_rolls_back_cleanly() { + let Some(live) = open(crate::contract_driver(), "CM-17").await else { + return; + }; + let dialect = live.dialect; + let mut fixture = Fixture::new("cm17"); + // §10.2 rule 3/4: the name is decided up front so teardown can always target it, + // whether the assertions passed, failed, or were never reached. + let table = fixture.name("tx"); + fixture.on_drop(render(dialect.drop_named, &table)); + + let outcome = async { + let create = render(dialect.create_named, &table); + let insert = render(dialect.insert_named, &table); + let select = render(dialect.select_named, &table); + match crate::CONTRACT.transactions { + Capability::Supported => { + live.execute(&create).await?; + let other = live.second_session("dz-contract-cm17-a").await?; + let transaction = live.driver.begin_transaction(&live.handle).await?; + live.execute(&insert).await?; + let uncommitted = live + .driver + .query(&other, &select) + .await + .expect_err("an open transaction must stay invisible to other sessions"); + assert_eq!(err_kind(&uncommitted), "QueryFailed"); + live.driver.rollback(transaction).await?; + // The data is gone, and the session is usable with no active handle. + let after = live.query(&select).await; + assert!( + after.map(|r| r.rows.is_empty()).unwrap_or(true), + "rolled-back data must not be readable" + ); + live.namespace().await?; + live.driver.disconnect(other).await + } + _ => { + let err = live + .driver + .begin_transaction(&live.handle) + .await + .expect_err("a driver without transactions must refuse, not silently succeed"); + assert!( + matches!(err, DriverError::TransactionError(_) | DriverError::Unsupported(_)), + "expected an explicit transaction refusal, got {}", + err_kind(&err) + ); + Ok(()) + } + } + } + .await; + + let teardown = fixture.finish(&live.driver, &live.handle).await; + assert!(teardown.is_ok(), "fixture teardown must succeed"); + outcome.expect("CM-17 hand-written transaction journey"); +} + +/// CM-18 — a failed statement leaves the next operation usable; an aborted or +/// unknown state must never be reported as "nothing happened". +#[tokio::test] +async fn cm18_failed_statement_leaves_the_next_operation_working() { + let Some(live) = open(crate::contract_driver(), "CM-18").await else { + return; + }; + let dialect = live.dialect; + + let outcome = async { + let err = live + .query(&format!("SELECT * FROM {}", dialect.missing_object)) + .await + .expect_err("a statement against a missing relation must fail"); + assert_eq!(err_kind(&err), "QueryFailed"); + // Recovery: the very next statement must work on the same session. + live.namespace().await?; + Ok::<(), DriverError>(()) + } + .await; + + outcome.expect("CM-18 recovery after a failed statement"); +} + +/// CM-22 / CM-23 — cancellation is addressed to one execution: an execution that +/// was never registered, or that has already finished, is rejected rather than +/// turned into a session-wide cancel. +#[tokio::test] +async fn cm22_precise_cancel_is_addressed_to_one_execution() { + let Some(live) = open(crate::contract_driver(), "CM-22").await else { + return; + }; + + let outcome = async { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let execution = QueryExecutionId::new(format!("{FIXTURE_PREFIX}unregistered_{stamp}")); + match crate::CONTRACT.precise_cancel { + Capability::Supported => { + live.driver.prepare_query_execution(&live.handle, &execution).await?; + // Cleanup is idempotent and makes the handle stale by design. + live.driver.cleanup_query_execution(&live.handle, &execution).await?; + live.driver.cleanup_query_execution(&live.handle, &execution).await?; + let err = live + .driver + .cancel_query_with_execution(&live.handle, &execution) + .await + .expect_err("a stale execution handle must be rejected, not honoured"); + assert!( + matches!( + err, + DriverError::QueryExecutionNotFound(_) | DriverError::Unsupported(_) + ), + "a finished/stale execution must be refused, got {}", + err_kind(&err) + ); + // A forged id that was never prepared is refused the same way. + let forged = QueryExecutionId::new(format!("{FIXTURE_PREFIX}forged_{stamp}")); + assert!(live.driver.cancel_query_with_execution(&live.handle, &forged).await.is_err()); + } + _ => { + let err = live + .driver + .cancel_query_with_execution(&live.handle, &execution) + .await + .expect_err("a driver without precise cancel must refuse explicitly"); + assert!( + matches!(err, DriverError::Unsupported(_)), + "expected Unsupported, got {}", + err_kind(&err) + ); + } + } + // Either way the session is untouched: E2 was not cancelled by proxy. + live.namespace().await?; + Ok::<(), DriverError>(()) + } + .await; + + outcome.expect("CM-22/CM-23 precise cancel is addressed to one execution"); +} + +/// CM-24 — with no session-wide fallback, the legacy cancel must not be used to +/// paper over a missing precise cancel: it must refuse and say why. +#[tokio::test] +async fn cm24_cancel_never_falls_back_to_session_wide() { + let Some(live) = open(crate::contract_driver(), "CM-24").await else { + return; + }; + + let outcome = async { + let err = live + .driver + .cancel_query(&live.handle) + .await + .expect_err("a session-wide cancel must never be a silent success"); + assert!( + matches!(err, DriverError::Unsupported(_) | DriverError::NotSupported(_)), + "session-wide cancel must be explicitly refused, got {}", + err_kind(&err) + ); + // Explicit close still works, which is what actually terminates work. + live.namespace().await?; + Ok::<(), DriverError>(()) + } + .await; + + outcome.expect("CM-24 no session-wide cancel fallback"); +} + +/// CM-26 — a failure must not leave a resource that is blindly reused: the open +/// resource set stays coherent and the next operation works without repair. +#[tokio::test] +async fn cm26_failed_statement_resource_is_not_reused_blindly() { + let Some(live) = open(crate::contract_driver(), "CM-26").await else { + return; + }; + let dialect = live.dialect; + + let err = live + .query(&format!("SELECT * FROM {}", dialect.missing_object)) + .await + .expect_err("the probe statement is expected to fail"); + assert_eq!(err_kind(&err), "QueryFailed"); + + let open = live + .driver + .open_databases(&live.handle) + .await + .expect("the resource set must still be reportable after a failure"); + assert!( + !open.iter().any(|db| db == &live.profile.a), + "a target the driver never opened must not be reported as open: {open:?}" + ); + live.namespace() + .await + .expect("the session must still be usable after a failure — no blind reuse"); + + if !crate::CONTRACT.reset_for_reuse.is_present() { + // Fail closed: without a proven reset, nothing may claim the resource is + // clean. There is no `resetResource` on today's trait surface to assert, + // so the obligation is recorded here and the API gap is reported. + eprintln!( + "note: {} declares reset_for_reuse = {:?}; the trait has no resetResource yet, \ + so only the non-blind-reuse half of CM-26 is asserted.", + crate::CONTRACT.label, + crate::CONTRACT.reset_for_reuse + ); + } +} + +/// CM-48 — readers share one proven snapshot, or the driver refuses parallel +/// strict-consistency reads outright. Refusal is a pass; an unverified shared +/// read is not. +#[tokio::test] +async fn cm48_read_snapshots_are_shared_or_explicitly_refused() { + let Some(live) = open(crate::contract_driver(), "CM-48").await else { + return; + }; + + match live.driver.begin_read_snapshot(&live.handle).await { + Ok(snapshot) => { + let view = live + .namespace() + .await + .expect("every reader must see the proven snapshot"); + assert_eq!(view, live.profile.b); + live.driver + .rollback(snapshot) + .await + .expect("a proven snapshot must be releasable"); + } + Err(error) => { + // An absent snapshot capability is an explicit refusal, not a skip. + assert!( + crate::CONTRACT.read_snapshots != Capability::Supported, + "{} declares stable read snapshots but refused to start one ({})", + crate::CONTRACT.label, + err_kind(&error) + ); + assert!( + matches!(error, DriverError::Unsupported(_) | DriverError::TransactionError(_)), + "snapshot refusal must be explicit, got {}", + err_kind(&error) + ); + } + } +} + +/// CM-69 — a database that was closed stops being reported as open. A driver +/// that holds no per-database resource must instead report nothing at all, so +/// the tree never marks a database the user never opened. +#[tokio::test] +async fn cm69_closed_database_stops_being_reported_open() { + let Some(live) = open(crate::contract_driver(), "CM-69").await else { + return; + }; + let contract = &crate::CONTRACT; + + let outcome = async { + let target_a = live.profile.a.clone(); + if contract.per_database_resource { + // Open the other target for real, then release it. + let _ = live + .driver + .query_at(&live.handle, "SELECT 1", target_for(&target_a)) + .await; + let closed = live + .driver + .close_database(&live.handle, &target_a) + .await?; + assert!( + closed, + "a driver holding a per-database resource must report the close" + ); + } else { + let closed = live + .driver + .close_database(&live.handle, &target_a) + .await?; + assert!( + !closed, + "a driver that holds no per-database resource must report nothing to close" + ); + } + let open = live.driver.open_databases(&live.handle).await?; + assert!( + !open.iter().any(|db| db == &target_a), + "a closed database must stop being reported as open: {open:?}" + ); + // Reopening on demand must still work. + live.namespace().await?; + Ok::<(), DriverError>(()) + } + .await; + + outcome.expect("CM-69 closed database reporting"); +} diff --git a/packages/drivers/http-support/tests/support/real_driver_contract_plumbing.rs b/packages/drivers/http-support/tests/support/real_driver_contract_plumbing.rs new file mode 100644 index 000000000..3a8b16070 --- /dev/null +++ b/packages/drivers/http-support/tests/support/real_driver_contract_plumbing.rs @@ -0,0 +1,394 @@ +//! Shared plumbing for the real-driver contract template: the only credential +//! source (the process environment), fixture bookkeeping, and the live-session +//! handle. Split out of `real_driver_contract.rs` to keep every file well under the +//! 800-line ceiling in AGENTS.md; it is `#[path]`-included, never compiled alone. + +#![allow(dead_code, unused_imports)] + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use datazen_driver_api::{ + ConnectionConfig, ConnectionHandle, DatabaseDriver, DriverError, QueryResult, SqlTarget, + SslMode, StatementResult, Value, +}; + +use super::Dialect; + +/// The only fixture prefix `fake-runtime-fixtures.md` §10.2 rule 1 accepts. +/// Live targets that are not prefixed are refused — fail closed, no escape hatch. +pub const FIXTURE_PREFIX: &str = "dz_fixture_"; +/// Every dimension this contract must cover, with the `fn cmNN_` test that owns it. +/// +/// `contract_matrix_covers_every_required_dimension` and +/// `every_required_dimension_owns_a_named_test` both read this table, so a dimension +/// cannot quietly drop out of the coverage. +pub const REQUIRED_DIMENSIONS: &[(&str, &str, &str)] = &[ + ( + "CM-08", + "cm08_two_sessions_keep_independent_databases", + "两个编辑器会话各自锁定自己的 database", + ), + ( + "CM-09", + "cm09_session_scoped_state_stays_in_its_own_session", + "会话级临时状态不可跨会话可见", + ), + ( + "CM-10", + "cm10_script_order_is_preserved_and_final_target_is_last", + "原始脚本顺序与最终上下文", + ), + ( + "CM-13", + "cm13_cross_target_resolution_reads_the_requested_target", + "限定名解析命中被请求的目标", + ), + ( + "CM-14", + "cm14_text_containing_the_switch_keyword_never_switches", + "字符串/注释里的切换关键字不触发切库", + ), + ( + "CM-17", + "cm17_hand_written_transaction_rolls_back_cleanly", + "手写事务旅程与回滚", + ), + ( + "CM-18", + "cm18_failed_statement_leaves_the_next_operation_working", + "失败语句后的可恢复性", + ), + ( + "CM-22", + "cm22_precise_cancel_is_addressed_to_one_execution", + "精确取消只作用于目标执行", + ), + ( + "CM-24", + "cm24_cancel_never_falls_back_to_session_wide", + "无精确取消时必须明确拒绝", + ), + ( + "CM-26", + "cm26_failed_statement_resource_is_not_reused_blindly", + "失败后的资源不复用", + ), + ( + "CM-48", + "cm48_read_snapshots_are_shared_or_explicitly_refused", + "快照共享或明确拒绝", + ), + ( + "CM-69", + "cm69_closed_database_stops_being_reported_open", + "关闭后的数据库不再报告为已打开", + ), +]; +// --------------------------------------------------------------------------- +// Process-environment helpers (the only credential source, §10.2 rule 5) +// --------------------------------------------------------------------------- + +pub fn optional_env(key: String) -> Option { + std::env::var(key).ok().filter(|v| !v.trim().is_empty()) +} + +pub fn required_env(key: String) -> Result { + optional_env(key.clone()).ok_or_else(|| format!("`{key}` is missing or empty")) +} + +pub fn raw_env(key: String) -> String { + // A password may legitimately be empty, so it is read without the filter. + std::env::var(key).unwrap_or_default() +} + +pub struct Profile { + pub host: String, + pub port: u16, + pub user: String, + pub password: String, + /// Target A of the A/B pair (§10.2 rule 2). + pub a: String, + /// Target B — a *different* database holding a *different* marker. + pub b: String, +} + +pub fn profile() -> Result { + let contract = &crate::CONTRACT; + let prefix = contract.env_prefix; + let a = required_env(format!("{prefix}DATABASE"))?; + Ok(Profile { + host: optional_env(format!("{prefix}HOST")).unwrap_or_else(|| "127.0.0.1".into()), + port: optional_env(format!("{prefix}PORT")) + .and_then(|v| v.parse().ok()) + .unwrap_or(contract.default_port), + user: optional_env(format!("{prefix}USER")) + .unwrap_or_else(|| contract.default_user.into()), + password: raw_env(format!("{prefix}PASSWORD")), + b: optional_env(format!("{prefix}DATABASE_B")).unwrap_or_else(|| a.clone()), + a, + }) +} + +pub fn connection_config(profile: &Profile, database: &str, id: &str) -> ConnectionConfig { + let contract = &crate::CONTRACT; + ConnectionConfig { + id: id.to_string(), + name: format!("dz contract {database}"), + database_type: contract.label.to_string(), + host: Some(profile.host.clone()), + port: Some(profile.port), + database: Some(database.to_string()), + schema: contract.default_schema.map(str::to_string), + username: Some(profile.user.clone()), + password: Some(profile.password.clone()), + ssl_mode: SslMode::Prefer, + connection_timeout: 5, + max_pool_size: 5, + ssh_tunnel: None, + tunnel_kind: None, + tunnel_id: None, + http_proxy_tunnel: None, + websocket_tunnel: None, + color_tag: None, + group: None, + last_connected_at: None, + server_version: None, + options: None, + read_only: false, + pinned: false, + } +} + +/// Never print a driver error verbatim: a server message can echo connection +/// details. Tests print the variant only. +pub fn err_kind(error: &DriverError) -> &'static str { + match error { + DriverError::ConnectionFailed(_) => "ConnectionFailed", + DriverError::ConnectionTimeout => "ConnectionTimeout", + DriverError::AuthenticationFailed(_) => "AuthenticationFailed", + DriverError::QueryFailed(_) => "QueryFailed", + DriverError::InvalidConfig(_) => "InvalidConfig", + DriverError::TransactionError(_) => "TransactionError", + DriverError::Unsupported(_) => "Unsupported", + DriverError::NotSupported(_) => "NotSupported", + DriverError::PoolExhausted => "PoolExhausted", + DriverError::QueryExecutionNotFound(_) => "QueryExecutionNotFound", + _ => "Other", + } +} + +pub fn row_text(cell: &Option) -> String { + match cell { + Some(Value::String(s)) | Some(Value::Timestamp(s)) => s.clone(), + Some(Value::Integer(i)) => i.to_string(), + Some(Value::Float(f)) => f.to_string(), + Some(Value::Bool(b)) => b.to_string(), + Some(Value::Json(v)) => v.to_string(), + Some(Value::Bytes(b)) => format!("{} bytes", b.len()), + Some(Value::Null) | None => String::new(), + } +} + +pub fn first_cell(result: &QueryResult) -> String { + match result.rows.first().and_then(|row| row.first()) { + Some(cell) => row_text(cell), + None => String::new(), + } +} + +pub fn rows_contain(result: &QueryResult, needle: &str) -> bool { + result.rows.iter().flatten().any(|cell| row_text(cell).contains(needle)) +} + +pub fn statement_rows_contain(result: &StatementResult, needle: &str) -> bool { + result.rows.iter().flatten().any(|cell| row_text(cell).contains(needle)) +} + +/// The first cell of the first row of one statement of a raw script. +pub fn statement_first_cell(result: &StatementResult) -> String { + match result.rows.first().and_then(|row| row.first()) { + Some(cell) => row_text(cell), + None => String::new(), + } +} + +pub fn render(template: &str, name: &str) -> String { + template.replace("{name}", name) +} + +/// The CM-13 target shape: the explicitly requested database plus this driver's +/// own default schema. Blank/absent parts stay absent (`SqlTarget::nonblank`). +pub fn target_for(database: &str) -> SqlTarget<'_> { + SqlTarget::new(Some(database), crate::CONTRACT.default_schema) +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/// Accumulates teardown SQL and runs it **after** the assertions, whether they +/// passed or failed. Teardown failure fails the test (§10.2 rule 4). +pub struct Fixture { + pub tag: String, + pub drops: Vec, +} + +impl Fixture { + pub fn new(tag: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + Self { + tag: format!("{tag}_{}_{stamp}", std::process::id()), + drops: Vec::new(), + } + } + + /// A per-case unique object name carrying the dedicated prefix (§10.2 rule 3). + pub fn name(&self, suffix: &str) -> String { + format!("{FIXTURE_PREFIX}{}_{suffix}", self.tag) + } + + pub fn on_drop(&mut self, sql: String) { + self.drops.push(sql); + } + + pub async fn finish( + &mut self, + driver: &D, + handle: &ConnectionHandle, + ) -> Result<(), DriverError> { + // Reverse order: nested fixtures unwind before their parents. + for sql in std::mem::take(&mut self.drops).into_iter().rev() { + // Propagated: teardown must never silently pass. + driver.execute(handle, &sql).await?; + } + Ok(()) + } +} + +/// Writes the A/B marker rows demanded by `fake-runtime-fixtures.md` §10.2 rule 2: +/// identically named tables in both targets, **different** marker values. +pub async fn seed_marker( + driver: &D, + handle: &ConnectionHandle, + dialect: &Dialect, + marker: &str, +) -> Result<(), DriverError> { + driver.execute(handle, dialect.create_marker).await?; + driver + .execute(handle, &dialect.insert_marker.replace("{marker}", marker)) + .await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Live session +// --------------------------------------------------------------------------- + +pub struct Live { + pub driver: D, + pub handle: ConnectionHandle, + pub profile: Profile, + pub dialect: &'static Dialect, +} + +/// Prints the honest skip line: which dimension is therefore **unverified** and +/// why. No fake stands in for a real-protocol conclusion. +pub fn report_unverified(dimension: &str, reason: &str) { + eprintln!( + "⏭ {dimension} 未验证({}):{reason} — 该维度需要真实数据库,本次运行不产生任何真实协议结论。", + crate::CONTRACT.label + ); +} + +pub async fn open_live(driver: D, dimension: &'static str) -> Option> { + if let Err(reason) = crate::CONTRACT.availability() { + report_unverified(dimension, &reason); + return None; + } + let profile = match profile() { + Ok(profile) => profile, + Err(reason) => { + report_unverified(dimension, &reason); + return None; + } + }; + let config = connection_config(&profile, &profile.b, "dz-contract-b"); + let handle = match driver.connect(&config).await { + Ok(handle) => handle, + Err(error) => { + report_unverified( + dimension, + &format!("cannot reach the fixture target: {}", err_kind(&error)), + ); + return None; + } + }; + Some(Live { + driver, + handle, + profile, + dialect: &crate::CONTRACT.dialect, + }) +} + +impl Live { + pub async fn query(&self, sql: &str) -> Result { + self.driver.query(&self.handle, sql).await + } + + pub async fn execute(&self, sql: &str) -> Result<(), DriverError> { + self.driver.execute(&self.handle, sql).await.map(|_| ()) + } + + pub async fn namespace(&self) -> Result { + let result = self + .query(&format!("SELECT {}", self.dialect.current_namespace)) + .await?; + Ok(first_cell(&result)) + } + + /// An independent session opened on the *other* fixture target. + pub async fn second_session(&self, id: &str) -> Result { + let config = connection_config(&self.profile, &self.profile.a, id); + self.driver.connect(&config).await + } +} +/// `packages/drivers`, found by name so the depth of the crate inside the +/// workspace never becomes a hidden assumption of the template. +pub fn drivers_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .find(|ancestor| ancestor.file_name().is_some_and(|name| name == "drivers")) + .expect("a driver crate must live at packages/drivers/") + .to_path_buf() +} + +pub fn collect_rs(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rs(&path, out); + } else if path.extension().is_some_and(|ext| ext == "rs") { + out.push(path); + } + } +} + +/// Every source file of the shared template, so a source-level guard scans the +/// whole template instead of only the file that happens to hold the test. +pub fn template_sources() -> Vec { + let dir = drivers_root().join("http-support/tests/support"); + [ + "real_driver_contract.rs", + "real_driver_contract_plumbing.rs", + "real_driver_contract_live.rs", + ] + .iter() + .map(|name| dir.join(name)) + .collect() +} diff --git a/packages/drivers/mysql/tests/mysql_cross_database.rs b/packages/drivers/mysql/tests/mysql_cross_database.rs index f6c940e22..c6e92516c 100644 --- a/packages/drivers/mysql/tests/mysql_cross_database.rs +++ b/packages/drivers/mysql/tests/mysql_cross_database.rs @@ -5,8 +5,10 @@ //! family, `WHERE TABLE_SCHEMA = ?` for `information_schema`), so the borrowed //! pool connection is never re-pointed and no `USE` leaks back into the pool. //! -//! Skips cleanly when MySQL is unavailable. Credentials come from process env -//! and/or the repo-root `.env` file (same `TEST_MYSQL_*` keys as workflow tests). +//! Skips cleanly when MySQL is unavailable. Credentials come from the **process +//! environment only** (`TEST_MYSQL_*`, injected by shell or CI secret) — see +//! `packages/drivers/postgres/tests/postgres_cross_database.rs` for why no +//! `.env` file is read. //! //! Run (skip if no MySQL): //! cargo test -p datazen-driver-mysql --test mysql_cross_database -- --nocapture @@ -17,12 +19,11 @@ //! TEST_MYSQL_DATABASE_B=datazen_sync_mysql_tgt \ //! cargo test -p datazen-driver-mysql --test mysql_cross_database -- --nocapture -use std::collections::HashMap; -use std::path::PathBuf; - use datazen_driver_api::{ ConnectionConfig, DatabaseDriver, DriverError, SqlTarget, TransactionHandle, Value, }; +use std::collections::HashMap; + use datazen_driver_mysql::MysqlDriver; #[derive(Clone, Debug)] @@ -48,70 +49,42 @@ impl Default for MysqlTestConfig { } } -fn load_dotenv_file() -> HashMap { - let env_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join(".env"); - - let mut map = HashMap::new(); - let Ok(content) = std::fs::read_to_string(&env_path) else { - return map; - }; - - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if let Some((k, v)) = line.split_once('=') { - map.insert(k.trim().to_string(), v.trim().to_string()); - } - } - map -} - -fn env_or_file(file: &HashMap, key: &str) -> Option { - std::env::var(key) - .ok() - .filter(|v| !v.is_empty()) - .or_else(|| file.get(key).cloned().filter(|v| !v.is_empty())) +/// Resolve a `TEST_MYSQL_*` key from the **process environment only**. +/// +/// No file fallback: a test must never parse `packages/drivers/.env` +/// (AGENTS.md 「本地环境变量文件保护」). Inject the keys from the shell or CI. +fn env_var(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) } -/// Gate on `TEST_MYSQL_*` in process env or repo-root `.env`. -/// Password may be intentionally empty. +/// Gate on `TEST_MYSQL_*` in the process environment. Password may be +/// intentionally empty, so it is read without the empty filter. fn load_mysql_config() -> Option { - let file = load_dotenv_file(); - let has_marker = std::env::vars().any(|(k, _)| k.starts_with("TEST_MYSQL_")) - || file.keys().any(|k| k.starts_with("TEST_MYSQL_")); + let has_marker = std::env::vars().any(|(k, _)| k.starts_with("TEST_MYSQL_")); if !has_marker { - eprintln!("⏭ Skipping mysql_use_database: no TEST_MYSQL_* in env or .env"); + eprintln!("⏭ Skipping mysql_use_database: no TEST_MYSQL_* in process env"); return None; } let mut cfg = MysqlTestConfig::default(); - - // Prefer explicit TEST_MYSQL_* (process env wins over .env). - if let Some(v) = env_or_file(&file, "TEST_MYSQL_HOST") { + if let Some(v) = env_var("TEST_MYSQL_HOST") { cfg.host = v; } - if let Some(v) = env_or_file(&file, "TEST_MYSQL_PORT") { - cfg.port = v.parse().unwrap_or(3306); + if let Some(v) = env_var("TEST_MYSQL_PORT") { + cfg.port = v.parse().unwrap_or(cfg.port); } - if let Some(v) = env_or_file(&file, "TEST_MYSQL_USER") { + if let Some(v) = env_var("TEST_MYSQL_USER") { cfg.user = v; } // Empty password is valid; allow override from env including empty string. if let Ok(v) = std::env::var("TEST_MYSQL_PASSWORD") { cfg.password = v; - } else if let Some(v) = file.get("TEST_MYSQL_PASSWORD") { - cfg.password = v.clone(); } - if let Some(v) = env_or_file(&file, "TEST_MYSQL_DATABASE") { + if let Some(v) = env_var("TEST_MYSQL_DATABASE") { cfg.database_a = v; } - if let Some(v) = env_or_file(&file, "TEST_MYSQL_DATABASE_B") { + if let Some(v) = env_var("TEST_MYSQL_DATABASE_B") { cfg.database_b = v; } diff --git a/packages/drivers/mysql/tests/real_driver_contract.rs b/packages/drivers/mysql/tests/real_driver_contract.rs new file mode 100644 index 000000000..f2837083c --- /dev/null +++ b/packages/drivers/mysql/tests/real_driver_contract.rs @@ -0,0 +1,69 @@ +//! MySQL binding of the shared real-driver contract template. +//! +//! Second binding on purpose: MySQL and PostgreSQL share the *shape* of the +//! contract (two sessions, two databases, A/B markers) but not the semantic +//! model — MySQL is schema-less, the database *is* the namespace, DDL is +//! auto-committed, and every database is served from one pool rather than from a +//! per-database resource. One shared template, two genuinely different +//! contracts, and every dialect difference stays in this file, which is why the +//! template can hold no SQL of its own. +//! +//! Run with `-- --nocapture` to see the unverified-scope report. + +use datazen_driver_api::DdlAtomicity; +use datazen_driver_mysql::MysqlDriver; + +#[path = "../../http-support/tests/support/real_driver_contract.rs"] +mod support; + +use support::{Capability, Contract, Dialect}; + +const CONTRACT: Contract = Contract { + label: "mysql", + // One implementation serves both wire dialects. + driver_types: &["mysql", "mariadb"], + env_prefix: "TEST_MYSQL_", + default_port: 3306, + default_user: "root", + // No schemas: `USE` moves between databases, and the default schema is absent. + has_schema_level: false, + default_schema: None, + has_multi_database: true, + ddl_atomicity: DdlAtomicity::AutoCommitPerStatement, + supports_offset: true, + // One pool serves every database, so there is no per-database resource to + // hand out; target routing happens through the session. + per_database_resource: false, + // Temporary tables live in the session's own temporary namespace. + session_scoped_state: Capability::Supported, + // InnoDB gives a repeatable read within a transaction, but the driver + // refuses rather than pretending a bare snapshot exists. + read_snapshots: Capability::Unsupported, + precise_cancel: Capability::Supported, + transactions: Capability::Supported, + reset_for_reuse: Capability::Supported, + dialect: Dialect { + marker: "dz_fixture_marker", + create_marker: + "CREATE TABLE IF NOT EXISTS dz_fixture_marker (dz_fixture_marker_value VARCHAR(255) NOT NULL)", + insert_marker: + "INSERT INTO dz_fixture_marker (dz_fixture_marker_value) VALUES ('{marker}')", + select_marker: "SELECT dz_fixture_marker_value FROM dz_fixture_marker", + drop_marker: "DROP TABLE IF EXISTS dz_fixture_marker", + current_namespace: "DATABASE()", + decoy_text_select: "SELECT 'USE dz_fixture_other' AS dz_fixture_decoy", + missing_object: "dz_fixture_absent_object", + create_named: "CREATE TABLE {name} (dz_fixture_value INT NOT NULL)", + insert_named: "INSERT INTO {name} (dz_fixture_value) VALUES (42)", + select_named: "SELECT dz_fixture_value FROM {name}", + drop_named: "DROP TABLE IF EXISTS {name}", + create_temp: "CREATE TEMPORARY TABLE {name} (dz_fixture_value INT NOT NULL)", + insert_temp: "INSERT INTO {name} (dz_fixture_value) VALUES (7)", + select_temp: "SELECT dz_fixture_value FROM {name}", + switch_keyword: "USE", + }, +}; + +fn contract_driver() -> MysqlDriver { + MysqlDriver::new(false) +} diff --git a/packages/drivers/postgres/tests/postgres_cross_database.rs b/packages/drivers/postgres/tests/postgres_cross_database.rs index e10d2efed..43b6e117e 100644 --- a/packages/drivers/postgres/tests/postgres_cross_database.rs +++ b/packages/drivers/postgres/tests/postgres_cross_database.rs @@ -6,8 +6,14 @@ //! BUG-003, where the session was re-pointed and every later read silently //! resolved against the wrong database. //! -//! Skips cleanly when PostgreSQL is unavailable. Credentials come from process -//! env and/or the repo-root `.env` file (`TEST_PG_*` keys, same as workflow tests). +//! Skips cleanly when PostgreSQL is unavailable. Credentials come from the +//! **process environment only** (`TEST_PG_*` keys, injected by CI secret or by +//! the developer shell). This file used to fall back to parsing +//! `packages/drivers/.env`, which conflicted with the repo's `.env` protection +//! rule (AGENTS.md 「本地环境变量文件保护」 and +//! `docs/architecture/platform/fake-runtime-fixtures.md` §13): a test that +//! silently sources a credential file cannot prove where its secrets came from. +//! Inject them explicitly instead — see the env block below. //! //! Run (skip if no Postgres): //! cargo test -p datazen-driver-postgres --test postgres_cross_database -- --nocapture @@ -20,9 +26,6 @@ //! Fixture: discovered at runtime — any `public` relation present in //! database_a and absent from database_b. The test skips when there is none. -use std::collections::HashMap; -use std::path::PathBuf; - use datazen_driver_api::{ConnectionConfig, DatabaseDriver, DriverError, SqlTarget, Value}; use datazen_driver_postgres::PostgresDriver; @@ -49,54 +52,38 @@ impl Default for PgTestConfig { } } -fn load_dotenv_file() -> HashMap { - let env_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join(".env"); - - let mut map = HashMap::new(); - let Ok(content) = std::fs::read_to_string(&env_path) else { - return map; - }; - - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if let Some((key, value)) = line.split_once('=') { - map.insert(key.trim().to_string(), value.trim().to_string()); - } - } - map -} - -fn env_or_file(file: &HashMap, key: &str) -> Option { - std::env::var(key).ok().or_else(|| file.get(key).cloned()) +/// Resolve a `TEST_PG_*` key from the **process environment only**. +/// +/// No file fallback, deliberately: see the module header. An unset key keeps +/// the default, so a developer with a local server on the default port still +/// gets a live run without exporting anything. +fn env_var(key: &str) -> Option { + std::env::var(key).ok().filter(|value| !value.is_empty()) } +/// Build the live-test config from `TEST_PG_*` process env. +/// +/// Returns `None` only when the caller must not proceed at all; today every +/// key has a usable default, so the gate is decided by whether the server is +/// actually reachable (see `cross_database_reads_never_move_the_session`). fn load_pg_config() -> Option { - let file = load_dotenv_file(); let mut cfg = PgTestConfig::default(); - if let Some(v) = env_or_file(&file, "TEST_PG_HOST") { + if let Some(v) = env_var("TEST_PG_HOST") { cfg.host = v; } - if let Some(v) = env_or_file(&file, "TEST_PG_PORT") { - cfg.port = v.parse().unwrap_or(5432); + if let Some(v) = env_var("TEST_PG_PORT") { + cfg.port = v.parse().unwrap_or(cfg.port); } - if let Some(v) = env_or_file(&file, "TEST_PG_USER") { + if let Some(v) = env_var("TEST_PG_USER") { cfg.user = v; } - if let Ok(v) = std::env::var("TEST_PG_PASSWORD") { + if let Some(v) = env_var("TEST_PG_PASSWORD") { cfg.password = v; - } else if let Some(v) = file.get("TEST_PG_PASSWORD") { - cfg.password = v.clone(); } - if let Some(v) = env_or_file(&file, "TEST_PG_DATABASE") { + if let Some(v) = env_var("TEST_PG_DATABASE") { cfg.database_a = v; } - if let Some(v) = env_or_file(&file, "TEST_PG_DATABASE_B") { + if let Some(v) = env_var("TEST_PG_DATABASE_B") { cfg.database_b = v; } @@ -133,6 +120,22 @@ fn connection_config(cfg: &PgTestConfig) -> ConnectionConfig { } } +/// Only the variant of a driver error is safe to print from a test: a connect +/// failure can embed the connection string it was handed, and this file must +/// never leak an injected credential into CI output. +fn err_label(err: &DriverError) -> &'static str { + match err { + DriverError::ConnectionFailed(_) => "ConnectionFailed", + DriverError::ConnectionTimeout => "ConnectionTimeout", + DriverError::AuthenticationFailed(_) => "AuthenticationFailed", + DriverError::SslError(_) => "SslError", + DriverError::InvalidConfig(_) => "InvalidConfig", + DriverError::PoolExhausted => "PoolExhausted", + DriverError::QueryFailed(_) => "QueryFailed", + _ => "other", + } +} + fn cell_as_i64(value: &Option) -> Option { match value { Some(Value::Integer(i)) => Some(*i), @@ -183,8 +186,10 @@ async fn cross_database_reads_never_move_the_session() { Ok(h) => h, Err(e) => { eprintln!( - "⏭ Skipping: cannot connect to PostgreSQL at {}:{}: {e}", - cfg.host, cfg.port + "⏭ Skipping: cannot connect to PostgreSQL at {}:{} ({})", + cfg.host, + cfg.port, + err_label(&e) ); return; } @@ -194,7 +199,7 @@ async fn cross_database_reads_never_move_the_session() { Ok(d) => d, Err(e) => { let _ = driver.disconnect(handle).await; - eprintln!("⏭ Skipping: list databases failed: {e}"); + eprintln!("⏭ Skipping: list databases failed ({})", err_label(&e)); return; } }; @@ -209,6 +214,28 @@ async fn cross_database_reads_never_move_the_session() { } } + // `get_databases` reports what the server advertises, which is a wider set + // than what this role may actually open (`CONNECT` is a separate privilege). + // Probe each catalog with `test_connection` — deliberately *not* `query_at`, + // so this gate never exercises the very capability the test is about to + // assert. An unreachable catalog is a missing fixture, so the cross-database + // dimension reports itself unverified instead of failing a check that never + // got to run. + for database in [&cfg.database_a, &cfg.database_b] { + let mut probe = connection_config(&cfg); + probe.id = format!("pg-cross-database-it-probe-{database}"); + probe.database = Some((*database).clone()); + if let Err(e) = driver.test_connection(&probe).await { + let _ = driver.disconnect(handle).await; + eprintln!( + "⏭ Skipping: catalog `{database}` is advertised but not openable with these \ + credentials ({}). Cross-database reads are therefore UNVERIFIED for this run.", + err_label(&e) + ); + return; + } + } + // Listing targets the named catalog without touching the handle's pool. let a_tables = driver .get_tables(&handle, &cfg.database_a, None) diff --git a/packages/drivers/postgres/tests/postgres_dump_empty_schema.rs b/packages/drivers/postgres/tests/postgres_dump_empty_schema.rs index 25e1927a0..d912201eb 100644 --- a/packages/drivers/postgres/tests/postgres_dump_empty_schema.rs +++ b/packages/drivers/postgres/tests/postgres_dump_empty_schema.rs @@ -5,9 +5,9 @@ //! `-- Table:` + `CREATE TABLE IF NOT EXISTS "" (...)`. Restoring such an //! artifact fails with `zero-length delimited identifier at or near ""`. //! -//! Skips when PostgreSQL is unavailable. Credentials come from process env -//! and/or `packages/drivers/.env` (`TEST_PG_*` keys, same as the other live -//! tests). +//! Skips when PostgreSQL is unavailable. Credentials come from the **process +//! environment only** (`TEST_PG_*`, injected by shell or CI secret) — see +//! `postgres_cross_database.rs` for why no `.env` file is read. //! //! Run (skip if no Postgres): //! cargo test -p datazen-driver-postgres --test postgres_dump_empty_schema -- --nocapture @@ -15,61 +15,34 @@ //! Fixture: any writable database; the test creates and drops its own throwaway //! schema so no pre-existing state is required. -use std::collections::HashMap; -use std::path::PathBuf; - use datazen_driver_api::{ BackupDumpOptions, ConnectionConfig, ConnectionHandle, DatabaseDriver, DriverError, }; use datazen_driver_postgres::PostgresDriver; -fn load_dotenv_file() -> HashMap { - let env_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join(".env"); - - let mut map = HashMap::new(); - let Ok(content) = std::fs::read_to_string(&env_path) else { - return map; - }; - - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if let Some((k, v)) = line.split_once('=') { - map.insert(k.trim().to_string(), v.trim().to_string()); - } - } - map -} - -fn env_or_file(file: &HashMap, key: &str) -> Option { - std::env::var(key) - .ok() - .filter(|v| !v.is_empty()) - .or_else(|| file.get(key).cloned().filter(|v| !v.is_empty())) +/// Resolve a `TEST_PG_*` key from the **process environment only**. +/// +/// No file fallback: a test must never parse `packages/drivers/.env` +/// (AGENTS.md 「本地环境变量文件保护」). Inject the keys from the shell or CI. +fn env_var(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) } -/// Gate on `TEST_PG_*` in process env or `packages/drivers/.env`. +/// Gate on `TEST_PG_*` in the process environment. fn load_pg_config() -> Option<(String, u16, String, String, String)> { - let file = load_dotenv_file(); - let has_marker = std::env::vars().any(|(k, _)| k.starts_with("TEST_PG_")) - || file.keys().any(|k| k.starts_with("TEST_PG_")); + let has_marker = std::env::vars().any(|(k, _)| k.starts_with("TEST_PG_")); if !has_marker { - eprintln!("⏭ Skipping postgres_dump_empty_schema: no TEST_PG_* in env or .env"); + eprintln!("⏭ Skipping postgres_dump_empty_schema: no TEST_PG_* in process env"); return None; } - let host = env_or_file(&file, "TEST_PG_HOST").unwrap_or_else(|| "127.0.0.1".into()); - let port = env_or_file(&file, "TEST_PG_PORT") + let host = env_var("TEST_PG_HOST").unwrap_or_else(|| "127.0.0.1".into()); + let port = env_var("TEST_PG_PORT") .and_then(|v| v.parse().ok()) .unwrap_or(5432); - let user = env_or_file(&file, "TEST_PG_USER").unwrap_or_else(|| "postgres".into()); - let password = env_or_file(&file, "TEST_PG_PASSWORD").unwrap_or_default(); - let database = env_or_file(&file, "TEST_PG_DATABASE").unwrap_or_else(|| "postgres".into()); + let user = env_var("TEST_PG_USER").unwrap_or_else(|| "postgres".into()); + let password = env_var("TEST_PG_PASSWORD").unwrap_or_default(); + let database = env_var("TEST_PG_DATABASE").unwrap_or_else(|| "postgres".into()); Some((host, port, user, password, database)) } diff --git a/packages/drivers/postgres/tests/postgres_empty_query_columns.rs b/packages/drivers/postgres/tests/postgres_empty_query_columns.rs index 52426391a..895756820 100644 --- a/packages/drivers/postgres/tests/postgres_empty_query_columns.rs +++ b/packages/drivers/postgres/tests/postgres_empty_query_columns.rs @@ -1,14 +1,12 @@ //! Gated live test: empty SELECT result sets still expose column metadata. //! -//! Skips when PostgreSQL is unavailable. Uses the same TEST_PG_* env / `.env` as -//! `postgres_use_database`. +//! Skips when PostgreSQL is unavailable. Uses `TEST_PG_*` from the **process +//! environment only** (shell export or CI secret) — see +//! `postgres_cross_database.rs` for why no `.env` file is read. //! //! Run: //! cargo test -p datazen-driver-postgres --test postgres_empty_query_columns -- --nocapture -use std::collections::HashMap; -use std::path::PathBuf; - use datazen_driver_api::{ConnectionConfig, DatabaseDriver, Value}; use datazen_driver_postgres::PostgresDriver; @@ -33,62 +31,36 @@ impl Default for PgTestConfig { } } -fn load_dotenv_file() -> HashMap { - let env_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join(".env"); - - let mut map = HashMap::new(); - let Ok(content) = std::fs::read_to_string(&env_path) else { - return map; - }; - - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if let Some((k, v)) = line.split_once('=') { - map.insert(k.trim().to_string(), v.trim().to_string()); - } - } - map -} - -fn env_or_file(file: &HashMap, key: &str) -> Option { - std::env::var(key) - .ok() - .filter(|v| !v.is_empty()) - .or_else(|| file.get(key).cloned().filter(|v| !v.is_empty())) +/// Resolve a `TEST_PG_*` key from the **process environment only**. +/// +/// No file fallback: a test must never parse `packages/drivers/.env` +/// (AGENTS.md 「本地环境变量文件保护」). Inject the keys from the shell or CI. +fn env_var(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) } fn load_pg_config() -> Option { - let file = load_dotenv_file(); - let has_marker = std::env::vars().any(|(k, _)| k.starts_with("TEST_PG_")) - || file.keys().any(|k| k.starts_with("TEST_PG_")); + let has_marker = std::env::vars().any(|(k, _)| k.starts_with("TEST_PG_")); if !has_marker { - eprintln!("⏭ Skipping postgres_empty_query_columns: no TEST_PG_* in env or .env"); + eprintln!("⏭ Skipping postgres_empty_query_columns: no TEST_PG_* in process env"); return None; } let mut cfg = PgTestConfig::default(); - if let Some(v) = env_or_file(&file, "TEST_PG_HOST") { + if let Some(v) = env_var("TEST_PG_HOST") { cfg.host = v; } - if let Some(v) = env_or_file(&file, "TEST_PG_PORT") { - cfg.port = v.parse().unwrap_or(5432); + if let Some(v) = env_var("TEST_PG_PORT") { + cfg.port = v.parse().unwrap_or(cfg.port); } - if let Some(v) = env_or_file(&file, "TEST_PG_USER") { + if let Some(v) = env_var("TEST_PG_USER") { cfg.user = v; } - if let Ok(v) = std::env::var("TEST_PG_PASSWORD") { + if let Some(v) = env_var("TEST_PG_PASSWORD") { cfg.password = v; - } else if let Some(v) = file.get("TEST_PG_PASSWORD") { - cfg.password = v.clone(); } - if let Some(v) = env_or_file(&file, "TEST_PG_DATABASE") { + if let Some(v) = env_var("TEST_PG_DATABASE") { cfg.database = v; } Some(cfg) diff --git a/packages/drivers/postgres/tests/real_driver_contract.rs b/packages/drivers/postgres/tests/real_driver_contract.rs new file mode 100644 index 000000000..f6340dfa8 --- /dev/null +++ b/packages/drivers/postgres/tests/real_driver_contract.rs @@ -0,0 +1,72 @@ +//! PostgreSQL binding of the shared real-driver contract template. +//! +//! The template itself lives in +//! `packages/drivers/http-support/tests/support/real_driver_contract.rs` and is +//! pulled in with `#[path]`; nothing about PostgreSQL is written there. This file +//! only declares what PostgreSQL *is* — its driver type, its capabilities, and +//! its SQL dialect — and hands back a driver instance. Every real-dialect +//! assertion about PostgreSQL therefore still lives inside this driver crate +//! (`fake-runtime-fixtures.md` §10.1), never in Host. +//! +//! Run with `-- --nocapture` to see the unverified-scope report. + +use datazen_driver_api::DdlAtomicity; +use datazen_driver_postgres::PostgresDriver; + +#[path = "../../http-support/tests/support/real_driver_contract.rs"] +mod support; + +use support::{Capability, Contract, Dialect}; + +/// PostgreSQL is a schema-qualified, multi-database engine reached over one +/// protocol, so its per-database routing is statement qualification rather than +/// a separate pooled resource. +const CONTRACT: Contract = Contract { + label: "postgresql", + driver_types: &["postgresql"], + env_prefix: "TEST_PG_", + default_port: 5432, + default_user: "postgres", + has_schema_level: true, + default_schema: Some("public"), + has_multi_database: true, + ddl_atomicity: DdlAtomicity::Transactional, + supports_offset: true, + per_database_resource: true, + // A session keeps its own `search_path` and its own temporary schema; nothing + // else in a second session may observe either. + session_scoped_state: Capability::Supported, + // `BEGIN ... ISOLATION LEVEL REPEATABLE READ` gives a stable read view. + read_snapshots: Capability::Supported, + // `cancel_query` is deliberately disabled; `cancel_query_with_execution` + // addresses one `pg_cancel_backend` PID. + precise_cancel: Capability::Supported, + transactions: Capability::Supported, + // Nothing in the contract hands a closed database back to the pool. + reset_for_reuse: Capability::Supported, + dialect: Dialect { + marker: "dz_fixture_marker", + create_marker: + "CREATE TABLE IF NOT EXISTS dz_fixture_marker (dz_fixture_marker_value TEXT NOT NULL)", + insert_marker: + "INSERT INTO dz_fixture_marker (dz_fixture_marker_value) VALUES ('{marker}')", + select_marker: "SELECT dz_fixture_marker_value FROM dz_fixture_marker", + drop_marker: "DROP TABLE IF EXISTS dz_fixture_marker", + current_namespace: "current_database()", + // The switch keyword inside a string literal must not move the context. + decoy_text_select: "SELECT 'USE dz_fixture_other' AS dz_fixture_decoy", + missing_object: "dz_fixture_absent_object", + create_named: "CREATE TABLE {name} (dz_fixture_value INTEGER NOT NULL)", + insert_named: "INSERT INTO {name} (dz_fixture_value) VALUES (42)", + select_named: "SELECT dz_fixture_value FROM {name}", + drop_named: "DROP TABLE IF EXISTS {name}", + create_temp: "CREATE TEMP TABLE {name} (dz_fixture_value INTEGER NOT NULL)", + insert_temp: "INSERT INTO {name} (dz_fixture_value) VALUES (7)", + select_temp: "SELECT dz_fixture_value FROM {name}", + switch_keyword: "USE", + }, +}; + +fn contract_driver() -> PostgresDriver { + PostgresDriver::new() +} diff --git a/packages/drivers/postgres/tests/schema_missing_table.rs b/packages/drivers/postgres/tests/schema_missing_table.rs index d909bfcb2..10747f77c 100644 --- a/packages/drivers/postgres/tests/schema_missing_table.rs +++ b/packages/drivers/postgres/tests/schema_missing_table.rs @@ -12,15 +12,13 @@ //! (the driver picks that database's pool), while a relation that really is //! absent keeps failing instead of degrading to a column-less table. //! -//! Skips when PostgreSQL is unavailable. Uses the same TEST_PG_* env / `.env` -//! as `postgres_empty_query_columns`. +//! Skips when PostgreSQL is unavailable. Uses `TEST_PG_*` from the **process +//! environment only** (shell export or CI secret) — see +//! `postgres_cross_database.rs` for why no `.env` file is read. //! //! Run: //! cargo test -p datazen-driver-postgres --test schema_missing_table -- --nocapture -use std::collections::HashMap; -use std::path::PathBuf; - use datazen_driver_api::{ConnectionConfig, DatabaseDriver, DriverError}; use datazen_driver_postgres::PostgresDriver; @@ -45,62 +43,36 @@ impl Default for PgTestConfig { } } -fn load_dotenv_file() -> HashMap { - let env_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .join(".env"); - - let mut map = HashMap::new(); - let Ok(content) = std::fs::read_to_string(&env_path) else { - return map; - }; - - for line in content.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if let Some((k, v)) = line.split_once('=') { - map.insert(k.trim().to_string(), v.trim().to_string()); - } - } - map -} - -fn env_or_file(file: &HashMap, key: &str) -> Option { - std::env::var(key) - .ok() - .filter(|v| !v.is_empty()) - .or_else(|| file.get(key).cloned().filter(|v| !v.is_empty())) +/// Resolve a `TEST_PG_*` key from the **process environment only**. +/// +/// No file fallback: a test must never parse `packages/drivers/.env` +/// (AGENTS.md 「本地环境变量文件保护」). Inject the keys from the shell or CI. +fn env_var(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) } fn load_pg_config() -> Option { - let file = load_dotenv_file(); - let has_marker = std::env::vars().any(|(k, _)| k.starts_with("TEST_PG_")) - || file.keys().any(|k| k.starts_with("TEST_PG_")); + let has_marker = std::env::vars().any(|(k, _)| k.starts_with("TEST_PG_")); if !has_marker { - eprintln!("⏭ Skipping schema_missing_table: no TEST_PG_* in env or .env"); + eprintln!("⏭ Skipping schema_missing_table: no TEST_PG_* in process env"); return None; } let mut cfg = PgTestConfig::default(); - if let Some(v) = env_or_file(&file, "TEST_PG_HOST") { + if let Some(v) = env_var("TEST_PG_HOST") { cfg.host = v; } - if let Some(v) = env_or_file(&file, "TEST_PG_PORT") { - cfg.port = v.parse().unwrap_or(5432); + if let Some(v) = env_var("TEST_PG_PORT") { + cfg.port = v.parse().unwrap_or(cfg.port); } - if let Some(v) = env_or_file(&file, "TEST_PG_USER") { + if let Some(v) = env_var("TEST_PG_USER") { cfg.user = v; } - if let Ok(v) = std::env::var("TEST_PG_PASSWORD") { + if let Some(v) = env_var("TEST_PG_PASSWORD") { cfg.password = v; - } else if let Some(v) = file.get("TEST_PG_PASSWORD") { - cfg.password = v.clone(); } - if let Some(v) = env_or_file(&file, "TEST_PG_DATABASE") { + if let Some(v) = env_var("TEST_PG_DATABASE") { cfg.database = v; } Some(cfg) From 60eb2a3ea62576f84c796d4d9cbaa181e77f1e74 Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Thu, 1 Oct 2026 08:43:04 +0800 Subject: [PATCH 058/666] =?UTF-8?q?test(cmds):=20=E8=A1=A5=E5=85=85=20CM-7?= =?UTF-8?q?3=20=E7=A9=BA=E9=97=B2=E6=B7=98=E6=B1=B0=E4=B8=8D=E5=8F=98?= =?UTF-8?q?=E5=BC=8F=E6=B5=8B=E8=AF=95=EF=BC=88=E5=BC=95=E7=94=A8=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E3=80=81release=20=E9=A1=BA=E5=BA=8F=E3=80=81?= =?UTF-8?q?=E7=89=A9=E7=90=86=E8=B5=84=E6=BA=90=E9=93=BE=E3=80=81=E5=90=8C?= =?UTF-8?q?=20ID=20=E9=87=8D=E5=BB=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connection-management.md:1279-1285 (CM-73) 的同一条旅程上,本提交补齐 baseline 旅程未覆盖的四个问题: - 仍持有引用的会话在任意轮 sweep 下都不得被关闭(cleanup_idle_connections 的 前置条件); - 一次 sweep 对多个无引用会话各关闭一次、owner 条目各留一条,重复 sweep 为空操作; - release 先于 / 后于 sweep 两种顺序下,release 都是唯一权威的关闭动作; - 同一 dbSessionId 连续两轮淘汰→重建必须留下可归因的物理资源链。 另加一条 #[ignore] 的目标行为证据:物理会话丢失后重建的会话不得再以丢失前 的 dbSessionId 对外呈现。 均为行为断言,不依赖 legacy 函数名或 map 形状,符合 :1285 的保留声明。 --- .../src/commands/cm73_idle_eviction_tests.rs | 607 ++++++++++++++++++ src-tauri/src/commands/mod.rs | 7 + 2 files changed, 614 insertions(+) create mode 100644 src-tauri/src/commands/cm73_idle_eviction_tests.rs diff --git a/src-tauri/src/commands/cm73_idle_eviction_tests.rs b/src-tauri/src/commands/cm73_idle_eviction_tests.rs new file mode 100644 index 000000000..0994a2a0b --- /dev/null +++ b/src-tauri/src/commands/cm73_idle_eviction_tests.rs @@ -0,0 +1,607 @@ +//! CM-73 idle-sweep invariants that the baseline journey does not pin. +//! +//! Spec: `docs/architecture/platform/connection-management.md:1279-1285` (CM-73, +//! H) — the sweep must never act behind a referenced session, must not churn +//! owner bookkeeping, and must not race the `release` path into a double close. +//! +//! `cm73_baseline_tests.rs` records the *single* eviction of one unreferenced +//! session. Everything here is a different question about the same sweep: +//! +//! - does a session that still holds a reference survive repeated sweeps? +//! - does the sweep churn owner bookkeeping across many sessions / many runs? +//! - is `release` authoritative, in both orderings against the sweep? +//! - is the physical resource chain of two consecutive cycles observable? +//! - does a rebuilt session get advertised under the *old* `dbSessionId`? +//! +//! All assertions are behavioural: they read the driver journal, the command +//! layer, and the public session id. They name no legacy `ConnectionManager` +//! internals, so they must keep holding when §14 retires the old manager — only +//! the fixture (`build_app_state` gives us a `state`) and the command-layer +//! entry points are borrowed, exactly as the spec's journey is. + +use std::collections::HashSet; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use super::query::execute_query_impl; +use super::AppState; +use crate::db::registry::DriverRegistry; +use crate::db::{ + ConnectionConfig, ConnectionHandle, DatabaseDriver, DatabaseType, DriverError, MultiQueryResult, + QueryResult, ServerInfo, TableInfo, TableSchema, TransactionHandle, Value, +}; +use crate::store::Store; +use crate::testing::FileKeyringGuard; +use crate::testing::app_state::{build_app_state, sample_postgres_config}; +use datazen_driver_api::{ + execute_standard_sql_command, query_command_definition, CommandResult, DriverCommandDefinition, +}; + +const DB_TYPE: &str = "postgres"; + +// ── Recording driver ───────────────────────────────────────────────────────── + +/// One `connect()` = one physical resource, named in `ConnectionHandle::pool_id` +/// (the runtime never rewrites `pool_id`). `ConnectionHandle::id` is recorded +/// separately because the runtime *does* rewrite it — that is the whole point of +/// the last test in this file. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Call { + Connect { resource: String, connection_id: String }, + Disconnect { resource: String }, + Query { resource: String, connection_id: String }, +} + +impl Call { + /// Compact one-line rendering, so a whole journey can be asserted as a list. + fn tag(&self) -> String { + match self { + Call::Connect { resource, .. } => format!("C:{resource}"), + Call::Disconnect { resource } => format!("D:{resource}"), + Call::Query { resource, .. } => format!("Q:{resource}"), + } + } +} + +struct SweepDriver { + calls: Mutex>, + live: Mutex>, + next: AtomicUsize, +} + +impl SweepDriver { + fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + live: Mutex::new(HashSet::new()), + next: AtomicUsize::new(0), + } + } + + fn calls(&self) -> Vec { + self.calls.lock().expect("journal").clone() + } + + fn tags(&self) -> Vec { + self.calls().iter().map(Call::tag).collect() + } + + fn connects(&self) -> Vec { + self.calls() + .iter() + .filter_map(|c| match c { + Call::Connect { resource, .. } => Some(resource.clone()), + _ => None, + }) + .collect() + } + + fn disconnects(&self) -> Vec { + self.calls() + .iter() + .filter_map(|c| match c { + Call::Disconnect { resource } => Some(resource.clone()), + _ => None, + }) + .collect() + } + + /// The `ConnectionHandle::id` the host presented for each query, in order. + fn query_connection_ids(&self) -> Vec { + self.calls() + .iter() + .filter_map(|c| match c { + Call::Query { connection_id, .. } => Some(connection_id.clone()), + _ => None, + }) + .collect() + } +} + +#[async_trait] +impl DatabaseDriver for SweepDriver { + fn driver_type(&self) -> DatabaseType { + DB_TYPE.into() + } + + async fn connect(&self, _config: &ConnectionConfig) -> Result { + let n = self.next.fetch_add(1, Ordering::SeqCst) + 1; + let resource = format!("res-{n}"); + let handle = ConnectionHandle { + id: format!("{resource}/conn"), + pool_id: resource.clone(), + }; + self.calls + .lock() + .expect("journal") + .push(Call::Connect { resource: resource.clone(), connection_id: handle.id.clone() }); + self.live.lock().expect("live").insert(resource); + Ok(handle) + } + + async fn disconnect(&self, handle: ConnectionHandle) -> Result<(), DriverError> { + let resource = handle.pool_id.clone(); + self.calls + .lock() + .expect("journal") + .push(Call::Disconnect { resource: resource.clone() }); + self.live.lock().expect("live").remove(&resource); + Ok(()) + } + + async fn query_multi( + &self, + handle: &ConnectionHandle, + sql: &str, + _limit: Option, + ) -> Result { + self.calls.lock().expect("journal").push(Call::Query { + resource: handle.pool_id.clone(), + connection_id: handle.id.clone(), + }); + let _ = sql; + if !self.live.lock().expect("live").contains(&handle.pool_id) { + return Err(DriverError::QueryFailed(format!( + "sweep driver: physical resource {} is closed", + handle.pool_id + ))); + } + Ok(MultiQueryResult { results: vec![], total_time_ms: 0 }) + } + + async fn query(&self, _handle: &ConnectionHandle, _sql: &str) -> Result { + Err(DriverError::QueryFailed("sweep driver: query is not used here".into())) + } + + async fn query_with_params( + &self, + _handle: &ConnectionHandle, + _sql: &str, + _params: &[Value], + ) -> Result { + Err(DriverError::QueryFailed("sweep driver: query_with_params is not used here".into())) + } + + async fn execute( + &self, + _handle: &ConnectionHandle, + _sql: &str, + ) -> Result { + Err(DriverError::QueryFailed("sweep driver: execute is not used here".into())) + } + + async fn begin_transaction( + &self, + _handle: &ConnectionHandle, + ) -> Result { + Err(DriverError::TransactionError("sweep driver: no transactions here".into())) + } + + async fn commit(&self, _tx: TransactionHandle) -> Result<(), DriverError> { + Err(DriverError::TransactionError("sweep driver: no transactions here".into())) + } + + async fn rollback(&self, _tx: TransactionHandle) -> Result<(), DriverError> { + Err(DriverError::TransactionError("sweep driver: no transactions here".into())) + } + + async fn get_databases(&self, _handle: &ConnectionHandle) -> Result, DriverError> { + Ok(vec![]) + } + + async fn get_tables( + &self, + _handle: &ConnectionHandle, + _database: &str, + _schema: Option<&str>, + ) -> Result, DriverError> { + Ok(vec![]) + } + + async fn get_table_schema( + &self, + _handle: &ConnectionHandle, + _table: &str, + _database: &str, + _schema: Option<&str>, + ) -> Result { + Err(DriverError::QueryFailed("sweep driver: no schemas here".into())) + } + + async fn get_server_info(&self, _handle: &ConnectionHandle) -> Result { + Err(DriverError::QueryFailed("sweep driver: no server info here".into())) + } + + async fn cancel_query(&self, _handle: &ConnectionHandle) -> Result<(), DriverError> { + Ok(()) + } + + fn command_definitions(&self) -> Vec { + vec![query_command_definition()] + } + + async fn execute_command( + &self, + handle: &ConnectionHandle, + command: &str, + input: serde_json::Value, + ) -> Result { + execute_standard_sql_command(self, handle, command, input).await + } + + async fn test_connection(&self, _config: &ConnectionConfig) -> Result { + Err(DriverError::QueryFailed("sweep driver".into())) + } +} + +// ── Fixture ────────────────────────────────────────────────────────────────── + +struct Fixture { + _keyring: FileKeyringGuard, + _temp: tempfile::TempDir, + state: AppState, + driver: Arc, +} + +/// Persist one connection config per id, so a single fixture can host several +/// independent sessions. +async fn fixture(connection_ids: &[&str]) -> Fixture { + let keyring = FileKeyringGuard::set(); + let temp = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(Store::init_with_path(temp.path()).await.expect("store init")); + let registry = Arc::new(DriverRegistry::new()); + let driver = Arc::new(SweepDriver::new()); + registry + .register_test_driver(DB_TYPE, driver.clone()) + .await; + let state = build_app_state(store.clone(), registry.clone()); + for id in connection_ids { + store + .save_connection(sample_postgres_config(id)) + .await + .expect("save_connection"); + } + Fixture { _keyring: keyring, _temp: temp, state, driver } +} + +impl Fixture { + /// Push the session past the production idle deadline (30 min) without + /// touching production code, then run the sweep. + async fn expire_and_sweep(&self, db_session_id: &str) { + self.state.connection_manager.expire_test_session(db_session_id).await; + self.state.connection_manager.cleanup_idle_connections().await; + } + + async fn query(&self, db_session_id: &str, sql: &str) -> Result<(), String> { + execute_query_impl(&self.state, db_session_id.to_string(), sql.to_string(), None) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +/// A session that still holds a reference must survive any number of sweeps. +/// +/// This is the guard `cleanup_idle_connections` is built on: only ref-count 0 +/// sessions are eligible. It is pinned here because the baseline journey only +/// exercises the ref-count 0 side, and a sweep that ignored the guard would +/// close live sessions behind a referenced caller. +#[tokio::test] +async fn idle_sweep_never_closes_a_session_that_still_holds_a_reference() { + let f = fixture(&["cm73-ref-1"]).await; + let mgr = &f.state.connection_manager; + + let db_session_id = mgr + .connect_dedicated("cm73-ref-1", None) + .await + .expect("connect_dedicated"); + let resource = f.driver.connects().remove(0); + assert_eq!(mgr.ref_count(&db_session_id).await, 1, "a dedicated session holds one reference"); + + for _ in 0..3 { + f.expire_and_sweep(&db_session_id).await; + } + + assert_eq!( + f.driver.disconnects(), + Vec::::new(), + "an idle-looking session that still holds a reference must never be closed, journal: {:?}", + f.driver.tags() + ); + assert_eq!(mgr.ref_count(&db_session_id).await, 1, "the sweep must not touch reference counts"); + assert_eq!(mgr.owner_connection_id(&db_session_id).await.as_deref(), Some("cm73-ref-1")); + + // Still the same physical session, and no silent reconnect behind our back. + let connects_before = f.driver.connects().len(); + f.query(&db_session_id, "SELECT 1") + .await + .expect("a referenced idle session must keep serving"); + assert_eq!( + f.driver.connects().len(), + connects_before, + "the session was rebuilt although it was never evicted" + ); + assert_eq!( + f.driver.tags().last().map(String::as_str), + Some(format!("Q:{resource}").as_str()), + "the query must land on the original physical resource, journal: {:?}", + f.driver.tags() + ); +} + +/// One sweep, many unreferenced sessions: exactly one close each, exactly one +/// owner entry each, and a second sweep changes nothing. +/// +/// The baseline journey evicts a single session, so it cannot see a sweep that +/// leaks owner entries (monotonic growth) or one that re-closes on every run. +#[tokio::test] +async fn idle_sweep_closes_each_unreferenced_session_once_and_keeps_one_owner_entry_each() { + let f = fixture(&["cm73-many-1", "cm73-many-2", "cm73-many-3"]).await; + let mgr = &f.state.connection_manager; + + let mut sessions = Vec::new(); + for id in ["cm73-many-1", "cm73-many-2", "cm73-many-3"] { + sessions.push(mgr.connect(id).await.expect("connect")); + } + assert_eq!(f.driver.connects().len(), 3, "three distinct physical resources"); + assert_eq!( + mgr.session_owner_map_len().await, + 3, + "one owner entry per established session" + ); + + for db_session_id in &sessions { + f.expire_and_sweep(db_session_id).await; + } + + assert_eq!( + f.driver.disconnects().len(), + 3, + "each unreferenced session must be closed exactly once, journal: {:?}", + f.driver.tags() + ); + for (index, db_session_id) in sessions.iter().enumerate() { + assert_eq!( + mgr.owner_connection_id(db_session_id).await.as_deref(), + Some(format!("cm73-many-{}", index + 1).as_str()), + "the owner entry of an evicted session must survive so recovery is possible" + ); + } + assert_eq!(mgr.session_owner_map_len().await, 3, "one owner entry per evicted session"); + + // A second sweep over the same (now connection-less) sessions must be a + // no-op: no extra closes, no owner-map growth, no shrink either. + mgr.cleanup_idle_connections().await; + mgr.cleanup_idle_connections().await; + assert_eq!( + f.driver.disconnects().len(), + 3, + "repeating the sweep must not re-close or resurrect sessions, journal: {:?}", + f.driver.tags() + ); + assert_eq!(mgr.session_owner_map_len().await, 3, "the owner map must not drift on a no-op sweep"); + + // Owner entries are not immortal: an explicit teardown still reclaims them. + mgr.disconnect(&sessions[0]).await.expect("disconnect"); + assert_eq!(mgr.session_owner_map_len().await, 2, "an explicit disconnect reclaims its owner entry"); + assert!(mgr.owner_connection_id(&sessions[0]).await.is_none()); +} + +/// Ordering A: the sweep runs first and must hold off, then `release` closes +/// the session exactly once and reclaims its owner entry. +#[tokio::test] +async fn release_after_the_sweep_is_the_only_close_of_a_referenced_session() { + let f = fixture(&["cm73-order-a"]).await; + let mgr = &f.state.connection_manager; + + let db_session_id = mgr + .connect_dedicated("cm73-order-a", None) + .await + .expect("connect_dedicated"); + let resource = f.driver.connects().remove(0); + + for _ in 0..2 { + f.expire_and_sweep(&db_session_id).await; + } + assert_eq!(f.driver.disconnects(), Vec::::new(), "the sweep must not close a referenced session"); + f.query(&db_session_id, "SELECT 1") + .await + .expect("the session is still up after the sweeps"); + + let released = mgr.release(&db_session_id).await.expect("release"); + assert!(released, "release reports that it closed the session"); + + assert_eq!( + f.driver.disconnects(), + vec![resource.clone()], + "release must close the physical resource exactly once, journal: {:?}", + f.driver.tags() + ); + assert_eq!(mgr.ref_count(&db_session_id).await, 0); + assert_eq!(mgr.session_owner_map_len().await, 0, "release reclaims the owner entry"); + assert!(mgr.owner_connection_id(&db_session_id).await.is_none()); + + mgr.cleanup_idle_connections().await; + assert_eq!( + f.driver.disconnects(), + vec![resource], + "a later sweep must not close an already released session a second time" + ); +} + +/// Ordering B: `release` runs first and is authoritative; the sweep that follows +/// must not double-close, resurrect, or leave a stale owner entry behind. +#[tokio::test] +async fn a_sweep_after_release_does_not_close_the_session_twice() { + let f = fixture(&["cm73-order-b"]).await; + let mgr = &f.state.connection_manager; + + let db_session_id = mgr + .connect_dedicated("cm73-order-b", None) + .await + .expect("connect_dedicated"); + let resource = f.driver.connects().remove(0); + + // Expired, but still referenced: the reference is the only thing standing + // between the session and the sweep. Release it, then let the sweep run. + f.state.connection_manager.expire_test_session(&db_session_id).await; + assert!(mgr.release(&db_session_id).await.expect("release")); + assert_eq!(f.driver.disconnects(), vec![resource.clone()], "release performed the close"); + + for _ in 0..2 { + mgr.cleanup_idle_connections().await; + } + + assert_eq!( + f.driver.disconnects(), + vec![resource], + "the sweep must not re-close a session release already tore down, journal: {:?}", + f.driver.tags() + ); + assert_eq!(mgr.session_owner_map_len().await, 0, "no owner entry may be left behind"); + assert_eq!(mgr.ref_count(&db_session_id).await, 0); + assert_eq!(f.driver.connects().len(), 1, "the torn-down session must not be silently rebuilt"); + + // A query on the released id must not resurrect anything behind the user's back. + assert!( + f.query(&db_session_id, "SELECT 1").await.is_err(), + "a released session must not come back as a same-id rebuild" + ); + assert_eq!(f.driver.connects().len(), 1, "the failed query must not have connected anything"); +} + +/// Two consecutive eviction→rebuild cycles of the *same* `dbSessionId` must leave +/// a fully observable resource chain: each cycle closes the previous physical +/// resource before opening the next one, and exactly one resource is live at +/// the end. A chain that skipped a close, or left a zombie live, is what makes +/// CM-73's "记录物理 resourceId" evidence unreadable later. +#[tokio::test] +async fn two_consecutive_eviction_cycles_of_one_session_id_are_fully_observable() { + let f = fixture(&["cm73-chain"]).await; + let mgr = &f.state.connection_manager; + + let db_session_id = mgr.connect("cm73-chain").await.expect("connect"); + + for cycle in 1..=2u32 { + let connects_before = f.driver.connects().len(); + f.query(&db_session_id, "SELECT 1") + .await + .unwrap_or_else(|e| panic!("cycle {cycle}: a live session must serve queries: {e}")); + // Cycle 1 runs on the session `connect` just opened, so it must not + // rebuild. Every later cycle runs on a session the previous sweep + // closed, so the recovery is exactly one new physical resource. + let expected_connects = if cycle == 1 { connects_before } else { connects_before + 1 }; + assert_eq!( + f.driver.connects().len(), + expected_connects, + "cycle {cycle}: exactly one physical resource per cycle is expected, journal: {:?}", + f.driver.tags() + ); + + f.expire_and_sweep(&db_session_id).await; + assert_eq!( + f.driver.disconnects().len(), + cycle as usize, + "cycle {cycle}: exactly one physical resource must have been closed, journal: {:?}", + f.driver.tags() + ); + } + + // 1 initial connect + exactly 1 rebuild for the second cycle. + assert_eq!(f.driver.connects().len(), 2, "one rebuild per eviction, journal: {:?}", f.driver.tags()); + let live: HashSet = f.driver.live.lock().expect("live").clone(); + assert!( + live.is_empty(), + "the last sweep must not leave a physical resource live, journal: {:?}", + f.driver.tags() + ); + + // Ordering: the chain reads C:res-1, D:res-1, C:res-2, D:res-2 — each + // resource is closed before its successor is opened, so a reader can + // attribute every call to a resource that was actually alive. + let actual: Vec = f.driver.tags().into_iter().filter(|t| !t.starts_with("Q:")).collect(); + assert_eq!( + actual, + vec!["C:res-1", "D:res-1", "C:res-2", "D:res-2"], + "the physical resource chain is not observable in order" + ); +} + +/// CM-73 target evidence, known failure until the P3 connection-runtime track +/// (`connection-management.md:1283`, `:1284`, `:1307`). +/// +/// After the physical session is lost, the runtime must not reconnect and then +/// hand the caller back the *same* `dbSessionId` for a brand-new physical +/// resource. Today the rebuilt `ConnectionHandle` is re-labelled with the old +/// session id, so a caller that only knows `dbSessionId` cannot tell a recovered +/// session from the one that was lost — which is precisely the silent same-id +/// rebuild the spec forbids. The assertion reads the id the host *presented to +/// the driver*, so it holds no matter how the recovery is implemented. +#[tokio::test] +#[ignore = "CM-73 known failure: the rebuilt session is advertised under the old dbSessionId, so a caller cannot distinguish it from the session that was lost. connection-management.md:1283 forbids same-id reuse; :1284 allows the known failure; :1307 requires green in the P3 connection-runtime track."] +async fn a_rebuilt_session_is_never_advertised_under_the_pre_loss_session_id() { + let f = fixture(&["cm73-same-id"]).await; + let mgr = &f.state.connection_manager; + + let db_session_id = mgr.connect("cm73-same-id").await.expect("connect"); + let first_resource = f.driver.connects().remove(0); + f.query(&db_session_id, "SELECT 1") + .await + .expect("the freshly opened session must serve queries"); + + f.expire_and_sweep(&db_session_id).await; + assert_eq!( + f.driver.disconnects(), + vec![first_resource.clone()], + "precondition: the physical resource was closed" + ); + + let rebuilt = f.query(&db_session_id, "SELECT 1").await; + assert!( + rebuilt.is_ok(), + "this test only pins the *identity* of a recovered session; recovering at all is a separate question, got {rebuilt:?}" + ); + assert_eq!( + f.driver.connects().len(), + 2, + "precondition: a rebuild opened a new physical resource, journal: {:?}", + f.driver.tags() + ); + + let presented = f.driver.query_connection_ids(); + assert_eq!( + presented.len(), + 2, + "both the pre-loss and the post-loss query must be journaled, journal: {:?}", + f.driver.tags() + ); + assert_ne!( + presented[0], presented[1], + "a new physical resource was opened but the host still presented {} for it; a caller holding only the dbSessionId cannot tell the rebuilt session from the lost one", + presented[0] + ); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 62e639aef..ef6f117d8 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -303,3 +303,10 @@ mod tunnel_summary_tests; #[cfg(test)] #[path = "cm73_baseline_tests.rs"] mod cm73_baseline_tests; + +// Companion sweep invariants for the same spec case (`:1279-1285`): reference +// counting, `release` ordering, and the observable physical-resource chain. +// Same placement rationale as `cm73_baseline_tests` above. +#[cfg(test)] +#[path = "cm73_idle_eviction_tests.rs"] +mod cm73_idle_eviction_tests; From d8269abd465c9dd5e0195bb3879a732251c9942c Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 08:48:13 +0800 Subject: [PATCH 059/666] =?UTF-8?q?test(driver-contract):=20=E6=94=B6?= =?UTF-8?q?=E7=B4=A7=20env=20=E6=96=87=E4=BB=B6=E5=AE=88=E5=8D=AB=E7=9A=84?= =?UTF-8?q?=E4=BD=9C=E7=94=A8=E5=9F=9F=E4=B8=8E=20token=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_sources_never_read_env_files` 只扫描 `env!("CARGO_MANIFEST_DIR")` 下的 tests/,也就是「包含本模板的那个 crate」。任何没有绑定模板的驱动 crate 实际上没有任何一次运行会扫到它 —— sqlserver 的 tests/common/mod.rs 一直在解析本地 env 文件,而这条名为「守卫」的断言一直全绿。 守卫强化(Tester 的增量,单独成提交): * token 按**收尾引号**匹配,不再按裸子串匹配。于是 `../`、 `fixtures/`、`x` 这类目录限定/前缀写法能被抓到,而 `contract.env_prefix` 与反引号散文保持合法。顺带补上 `.env.test` 变体的引号形态(此前只覆盖裸名与裸名+test 两种拼写)。 * 新增 `the_env_guard_matches_every_shape_of_env_file_literal`: 18 例表驱动,把「必须报出」与「必须放行」的形状各自钉死,防止 token 列表悄悄退化而断言继续恒绿。 * 新增 `every_driver_crate_with_tests_either_binds_this_guard_or_is_declared`: 带原因的白名单 `UNGUARDED_DRIVER_CRATES`。新增带 tests/ 的驱动 crate 却忘了登记会响亮失败;登记项修好了却忘了摘掉同样会失败。 * sqlserver 在白名单里,理由即 D2 待修项;修好后必须摘掉该条目。 --- .../tests/support/real_driver_contract.rs | 177 ++++++++++++++++-- 1 file changed, 162 insertions(+), 15 deletions(-) diff --git a/packages/drivers/http-support/tests/support/real_driver_contract.rs b/packages/drivers/http-support/tests/support/real_driver_contract.rs index 3877c58ee..e4edc6f22 100644 --- a/packages/drivers/http-support/tests/support/real_driver_contract.rs +++ b/packages/drivers/http-support/tests/support/real_driver_contract.rs @@ -406,6 +406,44 @@ fn qualified_target_sql_is_idempotent_and_never_switches_context() { assert_eq!(SqlTarget::new(Some(" "), None).database(), None); } +/// The env-file names the guard refuses to see spelled out in a string literal. +/// Assembled from fragments so this guard cannot match its own source. +fn env_file_names() -> [&'static str; 2] { + [concat!(".en", "v"), concat!(".en", "v.test")] +} + +/// Every spelling that names an env file inside a string literal, plus the +/// dotenv-style loader calls that read one implicitly. +/// +/// Each env-file name is matched **against its closing quote**, not as a bare +/// substring. That is what keeps `contract.env_prefix` and the backticked prose +/// in the sibling tests legal while still catching a directory-qualified read +/// such as `../` or `fixtures/` — a shape that both a bare name +/// substring and a bare quoted-name token miss. +fn env_guard_tokens() -> Vec { + let mut tokens: Vec = vec![ + concat!("load_", "dotenv").to_string(), + concat!("dot", "env()").to_string(), + concat!("dot", "env::").to_string(), + concat!("dot", "envy").to_string(), + ]; + for name in env_file_names() { + for quote in ['"', '\''] { + // A bare quoted name, a prefixed name and a path-qualified name all + // end in this suffix, in either quote style. + tokens.push(format!("{name}{quote}")); + } + } + tokens +} + +/// The first forbidden token this source contains, if any. +fn env_guard_violation(content: &str) -> Option { + env_guard_tokens() + .into_iter() + .find(|token| content.contains(token.as_str())) +} + /// §10.2 rule 5 as a regression guard: no Rust source under this driver crate's /// `tests/`, and no shared template source, may name an env file as a **string /// literal** or call a dotenv-style loader. Prose that mentions such a file in @@ -413,18 +451,6 @@ fn qualified_target_sql_is_idempotent_and_never_switches_context() { /// from the docs, and it cannot be defeated by a comment. #[test] fn test_sources_never_read_env_files() { - // Built from fragments so this guard does not match its own source. - let forbidden: Vec = vec![ - concat!("load_", "dotenv").to_string(), - concat!("dot", "env()").to_string(), - concat!("dot", "env::").to_string(), - concat!("dot", "envy").to_string(), - format!("\"{}\"", concat!(".en", "v")), - format!("\"{}test\"", concat!(".en", "v")), - format!("'{}'", concat!(".en", "v")), - format!("'{}test'", concat!(".en", "v")), - ]; - assert!( file!().ends_with("http-support/tests/support/real_driver_contract.rs"), "this file is not the shared template (file!() = {})", @@ -442,9 +468,8 @@ fn test_sources_never_read_env_files() { for path in &sources { let content = std::fs::read_to_string(path) .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); - for token in &forbidden { - assert!( - !content.contains(token.as_str()), + if let Some(token) = env_guard_violation(&content) { + panic!( "{} must not contain {token:?} — credentials come from the process environment only", path.display() ); @@ -452,6 +477,128 @@ fn test_sources_never_read_env_files() { } } +/// The guard above only ever sees **one** driver crate: `env!("CARGO_MANIFEST_DIR")` +/// resolves to the crate that included this template, not to the crate the file +/// physically lives in. A crate that does not bind the template is therefore +/// scanned by nobody, silently — which is how `sqlserver` kept reading a local +/// env file while a guard named `test_sources_never_read_env_files` stayed green. +/// +/// This test makes that scope explicit instead of accidental. A driver crate that +/// ships a `tests/` directory must either bind the template (and so be scanned +/// whenever its own contract test runs) or be listed here with its reason. Adding +/// a driver crate and forgetting the list therefore fails loudly, and fixing a +/// listed crate and forgetting to remove it also fails loudly. +#[test] +fn every_driver_crate_with_tests_either_binds_this_guard_or_is_declared() { + let root = drivers_root(); + let template = template_sources(); + let mut declared: Vec = UNGUARDED_DRIVER_CRATES + .iter() + .map(|(name, _)| (*name).to_string()) + .collect(); + declared.sort(); + + let mut scanned_by_nobody: Vec = Vec::new(); + let entries = std::fs::read_dir(&root) + .unwrap_or_else(|e| panic!("cannot list {}: {e}", root.display())); + for entry in entries.flatten() { + let path = entry.path(); + if !path.join("tests").is_dir() { + continue; + } + // A crate is covered when it runs the template, or when it *is* the + // template's home and its sources are already in the scanned set. + let bound = path.join("tests/real_driver_contract.rs").is_file(); + let is_template_home = !template.is_empty() && template.iter().all(|src| src.starts_with(&path)); + if bound || is_template_home { + continue; + } + scanned_by_nobody.push(entry.file_name().to_string_lossy().into_owned()); + } + scanned_by_nobody.sort(); + + assert_eq!( + scanned_by_nobody, declared, + "driver crates that ship tests but are scanned by no run of this guard: \ + {scanned_by_nobody:?} vs declared {declared:?}" + ); + for (name, reason) in UNGUARDED_DRIVER_CRATES { + assert!( + drivers_root().join(name).join("tests").is_dir(), + "UNGUARDED_DRIVER_CRATES lists `{name}` but that crate no longer ships tests: \ + remove it from the list" + ); + let _ = reason; + } +} + +/// Driver crates that ship a `tests/` directory but never run this guard, with +/// the reason each one is not covered yet. Every entry is a known, visible gap +/// in the §10.2 rule 5 enforcement, not a silent one. +const UNGUARDED_DRIVER_CRATES: &[(&str, &str)] = &[ + ("clickhouse", "no contract binding yet; no env-file read in its tests today"), + ("duckdb", "no contract binding yet; no env-file read in its tests today"), + ("mongodb", "no contract binding yet; no env-file read in its tests today"), + ("redis", "no contract binding yet; no env-file read in its tests today"), + ("sqlite", "no contract binding yet; no env-file read in its tests today"), + ( + "sqlserver", + "tests/common/mod.rs still parses a local env file for its live suite", + ), +]; + +/// The guard's own matcher, proven against the spellings that must be caught and +/// the ones that must stay legal. +/// +/// Without this, a token list can quietly stop matching the shape it was written +/// for and a green guard keeps asserting nothing. That is exactly how a +/// directory-qualified `read_to_string` of a prefixed name and a reader of the +/// `test` variant both passed a guard written to forbid them. +#[test] +fn the_env_guard_matches_every_shape_of_env_file_literal() { + let name = env_file_names()[0]; + let test_variant = env_file_names()[1]; + + // (source, must_be_flagged) + let cases: Vec<(String, bool)> = vec![ + // --- must be flagged: a quoted path that names an env file + (format!("read_to_string({q}{name}{q})", q = '"'), true), + (format!("read_to_string({q}{name}{q})", q = '\''), true), + (format!("read_to_string({q}../{name}{q})", q = '"'), true), + (format!("dir.join({q}fixtures/{name}{q})", q = '"'), true), + (format!("read_to_string({q}x{name}{q})", q = '"'), true), + (format!("read_to_string({q}{test_variant}{q})", q = '"'), true), + (format!("dir.join({q}{test_variant}{q})", q = '"'), true), + (format!("read_to_string({q}{test_variant}{q})", q = '\''), true), + (format!("read_to_string({q}../{test_variant}{q})", q = '"'), true), + // --- must be flagged: an implicit loader + (concat!("load_", "dotenv").to_string(), true), + (concat!("dot", "env::from_filename").to_string(), true), + (concat!("dot", "envy::from_filename").to_string(), true), + // --- must stay legal: prose in backticks, and substrings, not paths + (format!("//! why no {q}{name}{q} file is read", q = '`'), false), + ( + format!("/// must never parse {q}packages/drivers/{name}{q}", q = '`'), + false, + ), + ( + format!("/// configured in {q}drivers/sqlserver/{test_variant}{q}", q = '`'), + false, + ), + ("contract.env_prefix".to_string(), false), + ("TEST_PG_DATABASE".to_string(), false), + ("sql_guard".to_string(), false), + ]; + + for (source, must_flag) in &cases { + assert_eq!( + env_guard_violation(source).is_some(), + *must_flag, + "guard disagreed about {source:?}" + ); + } +} + /// §10.2 rules 1 and 3, statically: fixture objects are uniquely named and carry /// the dedicated prefix, so a live run can never touch a shared or production /// object and teardown can target exactly what it created. From 3c13ba933318d22b0df4bca9b207f49cefe7c7bc Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 08:58:19 +0800 Subject: [PATCH 060/666] =?UTF-8?q?test(backend-docs):=20=E6=8C=89?= =?UTF-8?q?=E6=BA=90=E7=A0=81=E4=BF=AE=E6=AD=A3=E4=B8=89=E5=A4=84=E7=8E=B0?= =?UTF-8?q?=E7=8A=B6=E6=8F=8F=E8=BF=B0=E7=9A=84=E4=BA=8B=E5=AE=9E=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 独立复核 commit e1e8949 时逐条打开 file:line 原文核对,10 项抽验中 3 项 与源码不符,均为对既有代码的错误概括(非捏造不存在的事实),按源码改写: - Data Sync 专用会话判据写反:文档称「目标库≠默认库才开专用会话,等于默认库 则复用共享会话」。实况 commands/sync/tasks.rs:77 只判目标库字符串非空, 代码从不与连接配置默认库比较,:79-82 注释明确 always establish a dedicated session。原规则会让人写出与实现相反的代码。同步修正 services.md §3 与 commands.md §3 的扩散处。 - AI Chat schema 上下文降级方向写反:文档称会话取不到时「禁用 db tools 并 告警」。实况 chat.rs:812 的 if let Ok 包住整段构建,静默跳过且不记日志, attach_db_tools 保持初值 true(:808),db tools 照常注入(:979-981)。 而唯一会 warn! 并置 false 的 :866-875 分支,因 schema_pipeline.rs:83-87 与 :98-107 吞错、唯一 ?(:111-119)只在 supports_tools==false 分支内, 对支持工具的 provider 不可达。真实后果是模型拿不到 DDL 而非失去查表能力。 - Schema Diff 被写成「无独立取消通道」:实况 commands/schema_diff.rs:2231 有 cancel_schema_diff_deploy,已在 bootstrap/run.rs:470 注册、 src/commands/schemaDiff.ts:198 暴露,部署路径 :2177-2180 观察该标志。 原表述与同表 Data Sync/Data Transfer 行自相矛盾。 补写遗漏(均基于已核实事实,无占位):services.md 新增三套互不相通的取消 key 空间对照表;schema-diff.md §6 补「仅部署阶段可取消」;收紧「切库命令 只存在于测试代码里」的表述。 复核:链接扫描 HEAD 1321/26 失效,基线 931a53d64 为 1312/27 —— 本轨道新引入 0 条,另修复 1 条既有失效引用。tsc --noEmit exit 0。全部文件 ≤800 行, cache.md 772 行未被触碰。未新增任何文档文件。 --- docs/architecture/backend/ai.md | 4 +++- docs/architecture/backend/commands.md | 2 +- docs/architecture/backend/data-sync.md | 8 ++++--- docs/architecture/backend/schema-diff.md | 3 ++- docs/architecture/backend/services.md | 30 ++++++++++++++++++------ docs/architecture/backend/workflow.md | 2 +- 6 files changed, 35 insertions(+), 14 deletions(-) diff --git a/docs/architecture/backend/ai.md b/docs/architecture/backend/ai.md index 21be29896..b710b48ca 100644 --- a/docs/architecture/backend/ai.md +++ b/docs/architecture/backend/ai.md @@ -203,7 +203,9 @@ AI 侧与数据库的接触分两类,边界互不重叠: **一、会话语义(诊断 / NL2SQL / EXPLAIN 分析 / Schema 文档)——只消费已存在的会话,不创建。** - 这些 IPC 的形参是 `db_session_id`(运行时会话 ID),不是 `connectionId`;命令体内只用 `get_session` / `get_session_config` 查找。这条边界由契约测试直接对源码断言:会话语义命令的形参必须含 `db_session_id` 且**不得**含 `connection_id`,且命令体内不得出现「把名为 `connection_id` 的变量喂给 `get_session`」的写法(`commands/ai/ipc_contract_guards.rs`)。 -- **`ai_chat` 的 schema 上下文是尽力而为**。会话取不到时不报错,而是降级为不向模型注入 db tools 并记一条告警——聊天仍可继续,只是失去「模型自己查表」的能力。诊断类命令则直接返回错误。 +- **`ai_chat` 的 schema 上下文是尽力而为,但降级的方向和直觉相反**。`commands/ai/chat.rs:810-813` 用 `if let Ok((driver, _handle)) = state.connection_manager.get_session(conn_id)` 包住整段 schema 上下文构建:会话取不到时这一整块被**静默跳过**——不报错、不记日志、不追加系统消息,而 `attach_db_tools` 保持初值 `true`(`chat.rs:808`),因此 **db tools 照常注入模型**(`chat.rs:979-981`)。也就是说会话失效时的实际后果是「模型拿不到 DDL 上下文」,而不是「失去模型自己查表的能力」。 + 真正会 `warn!` 并把 `attach_db_tools` 置为 `false` 的是 `chat.rs:866-875` 的管线 `Err` 分支。但由于 `SchemaContextPipeline::resolve`(`ai/schema_pipeline.rs:74-128`)在 `:83-87` 吞掉了 `get_table_names` 的错误、在 `:98-107` 吞掉了 `build_selective_context` 的错误,唯一的 `?`(`:111-119`)只存在于 `supports_tools == false` 的分支——而那种情况下 `attach_db_tools` 本来就等于 `supports_tools`,即 `false`。**对支持工具的 provider,这条「告警 + 禁用 db tools」的分支实际不可达。** + 与之对照,配置语义的诊断命令(`ai_diagnose_connection`)不碰会话,连接配置缺失时直接返回 `CommandError::NotFound`(`commands/ai/generate.rs:813-817`)。 - **目标不切库**。AI 与其他消费方一样把目标 database / schema 随命令传递;宿主不实现生产环境的切库路径。 **二、配置语义(`ai_diagnose_connection`)——收 `connectionId`,读的是落盘配置**,用于「连不上」的故障排查,与已建立会话无关。契约测试同样钉住这一侧。 diff --git a/docs/architecture/backend/commands.md b/docs/architecture/backend/commands.md index d0c5e60dd..7191ec782 100644 --- a/docs/architecture/backend/commands.md +++ b/docs/architecture/backend/commands.md @@ -50,7 +50,7 @@ Command 的参数名携带语义,不是随意的命名。`connection_id` 是 | `db_session_id` | 操作已建立的会话 | `execute_query`、`execute_query_stream`、`get_explain`、`close_database`、`release_connection`、`disconnect`、`preview_pending_changes`、`execute_row_change_plan`、`commit_pending_changes`、`write_data_file`、Schema Diff 的一整组 IPC、`ai_generate_sql`、`ai_diagnose_error`、`ai_analyze_explain`、`ai_generate_schema_doc` | | `source_/target_db_session_id` | 迁移三件套的成对运行时目标 | Schema Diff 的比较 / 应用 / 预览类 IPC | -参数的语义决定了会不会创建会话:**创建发生在入口,不发生在操作里**。真正会建立会话的只有三处路径——连接类 command(`connect` 共享 / `connect_dedicated` 专用)、Workflow 的 step 执行(见 [workflow.md](./workflow.md))、以及 Data Sync 的端点解析(目标库不是连接默认库时开专用会话,见 [data-sync.md](./data-sync.md))。其余 command 一律假定会话已存在,查找失败只走「用同一个 `dbSessionId` 重建物理连接」这一条透明路径。需要把运行时 ID 换回持久化 ID 时(例如 DataTable 提交前校验归属、AI 查询历史按连接过滤、Schema Diff 落库),统一走 `owner_connection_id(db_session_id)` 反查 owner 映射,不做「两种 ID 都能试」的兼容。 +参数的语义决定了会不会创建会话:**创建发生在入口,不发生在操作里**。真正会建立会话的只有三处路径——连接类 command(`connect` 共享 / `connect_dedicated` 专用)、Workflow 的 step 执行(见 [workflow.md](./workflow.md))、以及 Data Sync 的端点解析(任务保存了非空目标库时开专用会话,否则复用共享会话,见 [data-sync.md](./data-sync.md))。其余 command 一律假定会话已存在,查找失败只走「用同一个 `dbSessionId` 重建物理连接」这一条透明路径。需要把运行时 ID 换回持久化 ID 时(例如 DataTable 提交前校验归属、AI 查询历史按连接过滤、Schema Diff 落库),统一走 `owner_connection_id(db_session_id)` 反查 owner 映射,不做「两种 ID 都能试」的兼容。 参数语义由测试钉住,而不是靠约定:`commands/ai/ipc_contract_guards.rs` 的契约测试直接对命令源码做断言,要求会话语义命令的形参包含 `db_session_id` 且**不含** `connection_id`,配置语义命令反之;同文件的另一组断言禁止把名为 `connection_id` 的变量喂给 `get_session` / `owner_connection_id`。历史上出现过「改了名字但语义反了」的回归,这组断言就是为堵住它。 diff --git a/docs/architecture/backend/data-sync.md b/docs/architecture/backend/data-sync.md index 0a3b04a9c..aa900a6ae 100644 --- a/docs/architecture/backend/data-sync.md +++ b/docs/architecture/backend/data-sync.md @@ -98,10 +98,12 @@ Data Sync **不接受任何运行时会话 ID**:源端与目标端一律从任 | 任务保存的目标库 | 会话来源 | 是否释放 | |---|---|---| -| 与连接配置默认库不同 | `connect_dedicated(connectionId, 该库)`,始终新建物理连接 | **是**——只释放 dedicated 会话 | -| 未指定,或就是连接配置的默认库 | `resolve_session_for_connection(connectionId)`,复用 GUI 可能已开着的同一条会话 | **否**——共享路径不释放引用 | +| **指定了**目标库(哪怕它正好等于连接配置的默认库) | `connect_dedicated(connectionId, 该库)`,始终新建物理连接 | **是**——只释放 dedicated 会话 | +| 未指定 / 只有空白字符 | `resolve_session_for_connection(connectionId)`,复用 GUI 可能已开着的同一条会话 | **否**——共享路径不释放引用 | -区分的理由是宿主不做切库:复用那条「可变的共享会话」会让 PostgreSQL 挂在错误的库上,所以目标库非默认时必须另开一条独立会话。dedicated 会话在后续任何一步失败时也会回滚释放。 +判据是**目标库字符串是否非空**:`commands/sync/tasks.rs:77` 写的是 `database.filter(|value| !value.trim().is_empty())`,代码里**从不**把它与连接配置的默认库作比较,所以「目标库恰好等于默认库」照样开专用会话。这么判的理由是宿主不做切库:共享会话当前挂在哪个库是可变的,复用它无法保证任务落在任务自己指定的那个库上(`tasks.rs:79-82` 的注释)。dedicated 会话在后续任何一步失败时也会回滚释放。 + +与默认库的**比较**确实存在,但它发生在开完会话**之后**,而且是**配置漂移检查**而非会话选择判据(`tasks.rs:125-139`):持久化任务存的 database 与**连接配置当前的 `config.database` 字段**比对,不一致说明任务 outlive 了一次配置编辑(该处注释原文:`A persisted task may outlive an edited connection config`),随即释放已开的 dedicated 会话并报冲突。注意它比的是配置字段,不是会话实际挂载的库。 三条随之而来的约束: diff --git a/docs/architecture/backend/schema-diff.md b/docs/architecture/backend/schema-diff.md index 410a37337..d1a15d6cd 100644 --- a/docs/architecture/backend/schema-diff.md +++ b/docs/architecture/backend/schema-diff.md @@ -120,8 +120,9 @@ Schema Diff 的 IPC **全部**收成对的 `sourceDbSessionId` / `targetDbSessio - **profile 落盘的是 `connectionId`**。加密的 profile 文件保存 `sourceConnectionId` / `targetConnectionId`,不保存任何运行时会话 ID;Deploy 的历史记录写入的同样是解析出来的 `connectionId`。 - **反查依赖 owner 映射**。部署时把目标会话反查成 `connectionId` 以便落库;反查失败即中止部署并报「目标连接归属不可用」,不会退化成写一个空归属。Idle 回收故意保留 owner 映射正是为了让这条反查在会话物理连接消失后仍然可用。 - **目标库在建连时就定死,不切库**。专用会话建立时把用户选中的库作为连接覆盖传下去(SQLite 例外:`main` 是目录别名,会传文件路径而不是 `main`),因此会话始终停在选中的库上,宿主不需要切库路径。 +- **只有部署阶段可取消,比较阶段不可**。`cancel_schema_diff_deploy` 收一个 `jobId`,写的是与 Data Sync / Data Transfer 同一个进程级 job 标志(`services/job_registry.rs`),后端不做任何归属校验,因此谁拿到该 jobId 都能取消;部署执行时取出这个标志并观察它(`commands/schema_diff.rs:2177-2180`)。比较(compare)本身没有对应的取消命令,只能靠关闭面板让前端释放专用会话。 -与另两个成员的边界:Schema Diff 负责**结构差异对比与 DDL 迁移生成**;同族数据复制属于 Data Synchronization,异构数据搬迁属于 Data Transfer。三者共用「专用会话 + 持久化 connectionId」的约束,但各自的会话由谁建立、profile 存什么并不相同。 +与另两个成员的边界:Schema Diff 负责**结构差异对比与 DDL 迁移生成**;同族数据复制属于 Data Synchronization,异构数据搬迁属于 Data Transfer。三者共用「专用会话 + 持久化 connectionId」的约束,但各自的会话由谁建立、profile 存什么并不相同。三者的取消也都落到同一张 job 标志表上,但只有 Schema Diff 的取消点局限在部署阶段。 ## 7. Tests diff --git a/docs/architecture/backend/services.md b/docs/architecture/backend/services.md index ac672beb3..7a76ee505 100644 --- a/docs/architecture/backend/services.md +++ b/docs/architecture/backend/services.md @@ -76,19 +76,35 @@ dbSessionId |---|---|---|---|---|---| | **Query** | 不创建。`execute_query` / `execute_query_stream` / `get_explain` 一律收 `db_session_id` | 与同一 `connectionId` 的所有消费方共享同一条会话 | 不关闭;会话由连接面板的 `release_connection` / `disconnect` 结束 | `QueryExecutionId` + 归属校验(校验 `dbSessionId` 与执行记录一致后才允许取消) | database 随命令 envelope 传递,`schema` 固定为 `None`;不做切库,手写 SQL 落到连接默认库 | | **Table / DataTable / 导出** | 不创建。三个入口都只收 `db_session_id` | DataTable 写操作**复用同一 `dbSessionId` 上已存在的事务**,没有才自开一个 | 不关闭。导出同样只消费既有会话 | **导出没有中途取消**:结果里的「已取消」只代表保存对话框被放弃 | DataTable 提交前把 `dbSessionId` 映射回 `connectionId`,与表格记录上的 `connectionId` 不一致即拒绝 | -| **Schema Diff** | 后端不创建;专用会话由前端 `useSchemaDiffEndpoints` 调 `connectDedicated` 建立并在结束时 `releaseDedicatedSession` | 不与主工作区共享 | 前端在比较结束 / 卸载时释放 | 无独立取消通道 | IPC 收成对的 `sourceDbSessionId` / `targetDbSessionId`;profile 落盘的是 `sourceConnectionId` / `targetConnectionId`,部署记录也只写 `connectionId` | -| **Data Sync** | `resolve_task_endpoint`:任务目标库不是连接默认库时走 `connect_dedicated`,是默认库时走 `resolve_session_for_connection` | 共享路径下与 GUI 同一条会话 | 仅 dedicated 路径释放;共享路径不释放 | 进程级 job registry,按 `jobId` 取消;取消早于开始执行也有效 | 落盘的是 `sourceConnectionId` / `targetConnectionId` 与四个 database/schema 字段;任务文件里的运行时 session ID 一律不作为迁移数据 | +| **Schema Diff** | 后端不创建;专用会话由前端 `useSchemaDiffEndpoints` 调 `connectDedicated` 建立并在结束时 `releaseDedicatedSession` | 不与主工作区共享 | 前端在比较结束 / 卸载时释放 | 进程级 job registry,按 `jobId` 取消(`cancel_schema_diff_deploy`,与 Data Sync / Data Transfer 共用同一张表);比较阶段无取消通道 | IPC 收成对的 `sourceDbSessionId` / `targetDbSessionId`;profile 落盘的是 `sourceConnectionId` / `targetConnectionId`,部署记录也只写 `connectionId` | +| **Data Sync** | `resolve_task_endpoint`:任务保存了**非空目标库**时走 `connect_dedicated`(含恰好等于连接默认库的情形),未指定时才走 `resolve_session_for_connection` | 共享路径下与 GUI 同一条会话 | 仅 dedicated 路径释放;共享路径不释放 | 进程级 job registry,按 `jobId` 取消;取消早于开始执行也有效 | 落盘的是 `sourceConnectionId` / `targetConnectionId` 与四个 database/schema 字段;任务文件里的运行时 session ID 一律不作为迁移数据 | | **Data Transfer** | 后端不创建;前端 `DataTransferWindow` 持有源 / 目标两个专用会话并负责创建与释放 | 不与主工作区共享 | 前端在窗口卸载时释放,后端事件带回 `dbSessionId` 时清掉对应一侧 | 进程级 job registry | 运行时端点是「`dbSessionId` + database (+ schema)」,只随任务内存流转;落盘的 profile 只有 `connectionId` | | **Workflow** | `resolve_session_for_connection`(首次执行时建立) | 与 GUI 同一条会话 | **不调用 `release`**:命令运行时与执行器都不释放,引用计数常驻 ≥1,因此这类会话不会被 idle eviction 回收 | 无独立取消通道,失败按 step 的错误策略 abort / skip / fallback | step 显式目标 → block 目标 → workflow 默认目标 → profile 初始目标;目标随命令输入走,**不做切库** | | **MCP** | DB tools 收**持久化 `connection_id`**,经 `resolve_connection_with_id` → `resolve_session_for_connection` 建立 | 与 GUI 共享同一个 `ConnectionManager` 实例,MCP 的一次 `query` 可能命中的正是 GUI 查询编辑器正在用的那条会话 | 不释放引用 | 无独立取消通道 | database / schema 全部来自调用参数 → 连接配置回退 → 驱动约定,**从不读会话当前库**;白名单为空时按拒绝一切处理 | -| **AI 诊断** | 不创建。诊断、NL2SQL、EXPLAIN 分析、Schema 文档都只收 `db_session_id` | AI Chat 的 schema 上下文分支用既有会话,取不到就降级为不注入 db tools | 不关闭 | AI 侧独立的取消注册表 | 唯一反向的入口是查询历史分析:它把 `dbSessionId` 映射回 `connectionId`,再按连接过滤历史 | +| **AI 诊断** | 不创建。诊断、NL2SQL、EXPLAIN 分析、Schema 文档都只收 `db_session_id` | AI Chat 的 schema 上下文分支用既有会话;取不到时**静默跳过 DDL 上下文,db tools 仍然注入**(见 [ai.md](./ai.md)) | 不关闭 | AI 侧独立的取消注册表 | 唯一反向的入口是查询历史分析:它把 `dbSessionId` 映射回 `connectionId`,再按连接过滤历史 | 要点: - **会话创建集中在少数入口**。生产代码里真正新建会话的地方只有连接 IPC(共享 / 专用两种)、Data Sync 的端点解析、Workflow 的命令运行时与执行器、以及 db_tools(MCP 与 AI 共用)这几处;Query、DataTable、导出、Schema Diff、Data Transfer、AI 诊断都只消费已存在的 `dbSessionId`。 -- **「三件套」的专用会话不是同一套机制**。Schema Diff 与 Data Transfer 的专用会话由**前端**建立并释放;Data Sync 的专用会话由**后端**按目标库是否等于连接默认库自行决定要不要开。三者都避免与主工作区共享事务状态。 +- **「三件套」的专用会话不是同一套机制**。Schema Diff 与 Data Transfer 的专用会话由**前端**建立并释放;Data Sync 的专用会话由**后端**按任务是否保存了非空目标库自行决定要不要开(判据是字符串是否为空,不是「目标库是否等于连接默认库」,见 `commands/sync/tasks.rs:77-108`)。三者都避免与主工作区共享事务状态。 - **共享即钉住**。Workflow 与 MCP 取得会话后不释放引用,使这些会话不再满足 idle eviction 的「计数为 0」条件。 -- **不切库是全局约束**。宿主不实现生产环境的切库路径:切库命令仅存在于测试代码中。因此所有消费方的目标都通过命令 envelope 传递。 +- **不切库是全局约束**。驱动契约里已经没有 `use_database` 方法;源码中残留的同名符号只有 Mock 驱动测试中恒空的 `use_database_calls()` 回归绊线,以及 MySQL 驱动在**建连时**内部拼 `USE` 语句的细节——两者都不构成命令级切库通道。因此所有消费方的目标都通过命令 envelope 传递。 + +### 取消维度:三套互不相通的 key 空间 + +「取消」在宿主里不是一个全局机制。同一时刻存在三张彼此独立的取消表,key、粒度、归属校验和生命周期都不同——**拿其中一张表的 key 去查另一张表一定落空**: + +| 取消表 | key | 粒度 | 归属校验 | 生命周期 | 位置 | +|---|---|---|---|---|---| +| 查询流执行注册表 | `QueryExecutionId` | 单次流式查询执行 | **有**:发起会话与执行记录不一致直接拒绝(`belongs to a different db session`) | 进程内;条目在每个流的终态路径上被移除,未知或已过期的 ID 报 `unknown or stale` | `AppState.query_executions`(`commands/mod.rs:88-142`) | +| 任务 job registry | `jobId` | 整个长任务:Schema Diff 部署、Data Sync、Data Transfer | **无**:只有 jobId,不按 `connectionId` / `dbSessionId` 归类,任何拿到该 ID 的调用方都能取消 | 进程内静态表;`cancel_job` 会先把标志置位再返回,因此**先取消再开始也有效**;应用重启后全部失效 | `services/job_registry.rs`(全局 `static JOBS`) | +| AI 调用取消注册表 | AI 调用 ID(`request_id`) | 单次 AI 调用(含流式) | **无**:只按调用 ID 查找 | 进程内;开始时登记、结束时注销,取消一个从未登记或已注销的调用返回 `false` 且无副作用 | `ai/cancel.rs` | + +要点: + +- **只有查询执行这一路做归属校验**。另两张表是「谁拿到 ID 谁取消」,所以跨面板、跨窗口误取消是可能的,这不是漏洞而是当前实现的既定语义。 +- **只有 job registry 支持「先取消再开始」**,因为 `cancel_job` 内部先 `ensure_job` 建标志再置位(`job_registry.rs:19-23`)。 +- **三者都不落盘、都不按连接或会话归类**,因此都不存在「换一个连接继续取消」的语义。判断一次取消为什么无效,先确认它用的是哪一张表的 key。 ## 4. Query execution @@ -124,7 +140,7 @@ DataTable 的行编辑与数据导出是两条不同的写路径,不要按同 ## 6. 长任务 -Data Sync、Data Transfer 等任务通过 job registry / job id 管理取消和生命周期。取消是显式的 job 操作,不应通过关闭 UI 猜测任务已经停止。Job registry 是**进程内**的取消标志表,key 是 job ID,不按 `connectionId` 或 `dbSessionId` 归类,也不落盘;应用重启后旧 job ID 不再有效。取消标志先于执行到达时被保留,因此「先取消再开始」也是被支持的顺序。 +Schema Diff 部署、Data Sync、Data Transfer 三者的任务都通过同一张 job registry / job id 管理取消和生命周期。取消是显式的 job 操作(Schema Diff 是 `cancel_schema_diff_deploy`,另两个分别是 `cancel_data_sync` / `cancel_data_transfer`),不应通过关闭 UI 猜测任务已经停止——Schema Diff 的比较阶段没有取消命令,只有部署阶段有。Job registry 是**进程内**的取消标志表,key 是 job ID,不按 `connectionId` 或 `dbSessionId` 归类,也不落盘;应用重启后旧 job ID 不再有效。取消标志先于执行到达时被保留,因此「先取消再开始」也是被支持的顺序。三张取消表的横向对比见 [3.x](#取消维度三套互不相通的-key-空间)。 ## 7. 依赖方向 @@ -144,7 +160,7 @@ Concrete Drivers 宿主在 `execute_driver_command` 网关注册 `list_catalog`、`read_relation_columns`、`read_relation_schema` 和 `refresh_schema_metadata`。会话通过 envelope 的 `dbSessionId` 指定,database/schema/name 全部放在 input 的 `RelationRef` 中;这组命令拒绝重复的 envelope database/schema,避免目标冲突。 -`RelationRef` 的 schema 必须显式为字符串或 null。具有 schema 层级的驱动要求精确 schema,读取不会通过 `use_database` 改变会话上下文。目录响应分开返回 schema 名称与关系条目,空 schema 不再伪装成空名称关系。 +`RelationRef` 的 schema 必须显式为字符串或 null。具有 schema 层级的驱动要求精确 schema;驱动契约里已经没有 `use_database`,元数据读取不会改变会话上下文,目标只能由调用方在 `RelationRef` 里写全。目录响应分开返回 schema 名称与关系条目,空 schema 不再伪装成空名称关系。 列读取每次最多接受 256 个目标,按完整身份去重,响应按请求顺序为各关系返回 `ok` 或 `error`。同一 database/schema 的未缓存目标可以使用驱动批量读取;驱动不支持批量读取时使用逐关系读取。完整结构缓存同时填充列缓存;失败信息经过凭据脱敏。 diff --git a/docs/architecture/backend/workflow.md b/docs/architecture/backend/workflow.md index f7a0cb802..82feeeb39 100644 --- a/docs/architecture/backend/workflow.md +++ b/docs/architecture/backend/workflow.md @@ -80,7 +80,7 @@ Workflow 的 IPC 用**持久化 `connectionId`** 指定连接(不要求连接 ### 目标:不切库 -Step 的目标 database / schema **随命令输入一起下发**,宿主不做切库。能内联改写 SQL 的驱动由驱动自己把未限定名改写到 step 的目标库;不做改写的驱动则由用户自己写全限定名。宿主没有生产环境的切库路径——切库命令只存在于测试代码里。 +Step 的目标 database / schema **随命令输入一起下发**,宿主不做切库。能内联改写 SQL 的驱动由驱动自己把未限定名改写到 step 的目标库;不做改写的驱动则由用户自己写全限定名。宿主没有生产环境的切库路径:驱动契约里已经没有 `use_database` 方法,源码中残留的同名符号只有 Mock 驱动测试里恒空的 `use_database_calls()` 回归绊线,以及 MySQL 驱动在**建连时**内部拼 `USE` 语句的细节——后者不构成 step 级的切库命令。 ## Command 模型 From cd489ddd4fb0c62951ad409d191b6dfe859f0ed9 Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 09:02:29 +0800 Subject: [PATCH 061/666] =?UTF-8?q?test(cmds):=20=E4=BF=AE=E6=AD=A3=20CM-7?= =?UTF-8?q?3=20=E6=96=AD=E8=A8=80=E6=96=B9=E5=90=91=E5=B9=B6=E8=AE=A9?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E8=B7=AF=E7=94=B1=E5=8F=AF=E8=A7=82=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEST_FAILED 修复:T1/T3/T5 的断言方向是反的,在两种合规实现下都会红;D4 的 transaction_call_misrouted 结构上恒为假。 阻塞缺陷 - T1:`:1283` 禁止的是"已关闭物理资源"且"句柄仍可提交"这一**合取**,不是单 方面关闭。改为 `!(physical_disconnect_observed && old_handle_finished_after_loss)`, 新观测只在 Disconnect **之后**仍有该事务的 finish 调用时为真,合规实现 B 自 己在关闭前的回滚因此不算。缺陷旅程实测红。 - T3:状态必须与"物理 session 是否还在"反向。改为 `status_after_evict == !physical_disconnect_observed`。 - T5:只有物理资源真的丢了才要求 fail-closed;资源还在时回滚必须报真实结果而 不是未知。改为按 `physical_disconnect_observed` 分支。 - D4:`commit`/`rollback` 只收到 `TransactionHandle`,无法直接看到资源。改为在 `host_binding` 里记录宿主最近一次为某个 `ConnectionHandle::id` 呈现的 `pool_id`(`begin_transaction`/`query_multi` 入口即绑定),finish 调用按该绑定 记账,再与 `Begin` 记录的开户资源比较。实测缺陷旅程日志出现 `Rollback { resource: "resource-2", transaction: "tx-1" }`,断言转红。 轻量修复 - D5:删掉 `current_resource(calls, db_session_id)` 的死参。 - D6:删掉 `let _ = commit;` 死语句。 - D7:模块文档改为指向真实测试名。 可达性实证 新增 `a_session_recovered_through_the_host_command_path_is_still_swept_when_it_goes_idle`: 经 `execute_query_impl` → `resolve_command_driver` → `get_session` → `reconnect` (全程不直接调 `ConnectionManager::connect`)恢复会话,实测 ref_count == 0 且连接 存活,再扫一次确实被关闭 —— 空闲淘汰分支在生产路径上可达,该测试恒绿。 顺带对两个测试文件跑 rustfmt(HEAD 版本本就不合规)。生产代码零改动。 --- src-tauri/src/commands/cm73_baseline_tests.rs | 272 +++++++++++----- .../src/commands/cm73_idle_eviction_tests.rs | 299 ++++++++++++++---- 2 files changed, 430 insertions(+), 141 deletions(-) diff --git a/src-tauri/src/commands/cm73_baseline_tests.rs b/src-tauri/src/commands/cm73_baseline_tests.rs index 27bbcff27..6cbda9239 100644 --- a/src-tauri/src/commands/cm73_baseline_tests.rs +++ b/src-tauri/src/commands/cm73_baseline_tests.rs @@ -18,13 +18,13 @@ //! the *observed* sequence — that is the `:1284` baseline evidence. When the //! P3 connection-runtime track turns CM-73 green, this test is **deleted, not //! updated**: the defect it pins no longer exists. -//! - `cm73_target_behavior_...` is `#[ignore]`d and asserts the **target -//! behavior only**. Per the `:1285` retention clause its assertions must -//! survive the removal of the legacy `ConnectionManager`, so they are written -//! as behavior ("while a registered live handle exists the runtime must not -//! evict the physical resource and must not rebuild under the same -//! `dbSessionId`") and never as legacy structure (no assertion that any -//! particular map is empty, no reliance on legacy function names). +//! - `cm73_registered_live_handle_survives_idle_eviction_and_is_never_reused` +//! is `#[ignore]`d and asserts the **target behavior only**. Per the `:1285` +//! retention clause its assertions must survive the removal of the legacy +//! `ConnectionManager`, so they are written as behavior ("the runtime must not +//! leave a live handle behind a closed physical resource, and must not rebuild +//! under the same `dbSessionId`") and never as legacy structure (no assertion +//! that any particular map is empty, no reliance on legacy function names). //! //! Trade-off, stated once and deliberately: observing the current `session_transactions` //! map is what makes the defect legible (an old handle that still points at a dead @@ -44,14 +44,14 @@ use super::query::{ session_transaction_status_impl, }; use super::AppState; +use crate::db::registry::DriverRegistry; use crate::db::{ - ConnectionConfig, ConnectionHandle, DatabaseDriver, DatabaseType, DriverError, MultiQueryResult, - QueryResult, ServerInfo, TableInfo, TableSchema, TransactionHandle, Value, + ConnectionConfig, ConnectionHandle, DatabaseDriver, DatabaseType, DriverError, + MultiQueryResult, QueryResult, ServerInfo, TableInfo, TableSchema, TransactionHandle, Value, }; -use crate::db::registry::DriverRegistry; use crate::store::Store; -use crate::testing::FileKeyringGuard; use crate::testing::app_state::{build_app_state, sample_postgres_config}; +use crate::testing::FileKeyringGuard; use datazen_driver_api::{ execute_standard_sql_command, query_command_definition, CommandResult, DriverCommandDefinition, }; @@ -65,12 +65,33 @@ const CONNECTION_ID: &str = "cm73-conn"; /// resource that is actually alive. #[derive(Debug, Clone, PartialEq, Eq)] enum Call { - Connect { resource: String }, - Disconnect { resource: String }, - Begin { resource: String, transaction: String }, - Query { resource: String, sql: String }, - Commit { resource: String, transaction: String }, - Rollback { resource: String, transaction: String }, + Connect { + resource: String, + }, + Disconnect { + resource: String, + }, + Begin { + resource: String, + transaction: String, + }, + Query { + resource: String, + sql: String, + }, + /// `resource` is the physical resource the **host was serving the + /// transaction's session id on** when the call arrived — not the resource the + /// transaction was opened on, which is the `Begin` call's `resource`. The two + /// are the same for every healthy runtime; they diverge the moment a session + /// id is re-pointed at a rebuilt connection. + Commit { + resource: String, + transaction: String, + }, + Rollback { + resource: String, + transaction: String, + }, } struct JournalDriver { @@ -81,6 +102,12 @@ struct JournalDriver { /// connection, so this map is only consulted to resolve the `connection_id` /// frozen into a `TransactionHandle` — never to route a live call. resource_by_connection_id: Mutex>, + /// `ConnectionHandle::id` → the physical resource the host most recently + /// presented **under that id**. `commit`/`rollback` receive only a + /// `TransactionHandle`, so this is the journal's only honest way to see which + /// resource a finish call was routed to: the runtime can re-point a session id + /// at a rebuilt connection without changing the id the caller still holds. + host_binding: Mutex>, tx_resource: Mutex>, next_resource: AtomicUsize, next_transaction: AtomicUsize, @@ -92,6 +119,7 @@ impl JournalDriver { calls: Mutex::new(Vec::new()), live: Mutex::new(HashSet::new()), resource_by_connection_id: Mutex::new(HashMap::new()), + host_binding: Mutex::new(HashMap::new()), tx_resource: Mutex::new(HashMap::new()), next_resource: AtomicUsize::new(0), next_transaction: AtomicUsize::new(0), @@ -118,45 +146,66 @@ impl JournalDriver { .cloned() } - /// Resolve the resource that is currently serving `db_session_id`'s handle, - /// i.e. the newest `Connect` call for it. - fn current_resource(&self, calls: &[Call], db_session_id: &str) -> Option { - let _ = db_session_id; + /// Note that the host handed out `handle`, i.e. that `handle.id` is currently + /// served by `handle.pool_id`. Recorded before the liveness check so that even + /// a call the driver rejects still counts as "the host pointed here". + fn bind_host(&self, handle: &ConnectionHandle) { + self.host_binding + .lock() + .unwrap() + .insert(handle.id.clone(), handle.pool_id.clone()); + } + + /// The newest physical resource the driver has opened — the one the session + /// id would be served by now. + fn current_resource(&self, calls: &[Call]) -> Option { calls.iter().rev().find_map(|call| match call { Call::Connect { resource } => Some(resource.clone()), _ => None, }) } - fn finish_transaction( - &self, - commit: bool, - tx: TransactionHandle, - ) -> Result<(), DriverError> { - let resource = self + /// Journal a commit/rollback and decide its result. + /// + /// Two different resources matter here and must not be conflated: + /// - *routed to*: the physical resource the host is currently serving the + /// transaction's session id on. A transaction is only safe to finish on the + /// resource that opened it, so a divergence is journaled, not hidden. + /// - *opened on*: where the transaction actually lives. This — and only this — + /// decides the result, because a loss of *that* resource is what makes the + /// outcome genuinely unknowable. + fn finish_transaction(&self, commit: bool, tx: TransactionHandle) -> Result<(), DriverError> { + let opened_on = self .tx_resource .lock() .unwrap() .get(&tx.id) .cloned() .unwrap_or_else(|| "unknown-resource".to_string()); + let routed_to = self + .host_binding + .lock() + .unwrap() + .get(&tx.connection_id) + .cloned() + .unwrap_or_else(|| opened_on.clone()); self.calls.lock().unwrap().push(if commit { Call::Commit { - resource: resource.clone(), + resource: routed_to, transaction: tx.id.clone(), } } else { Call::Rollback { - resource: resource.clone(), + resource: routed_to, transaction: tx.id.clone(), } }); - if !self.live.lock().unwrap().contains(&resource) { + if !self.live.lock().unwrap().contains(&opened_on) { // A real driver must fail closed here: the outcome of the // transaction is genuinely unknown once its resource is gone. return Err(DriverError::TransactionError(format!( "transaction {} was opened on {} which is already closed: outcome unknown", - tx.id, resource + tx.id, opened_on ))); } Ok(()) @@ -178,12 +227,9 @@ impl DatabaseDriver for JournalDriver { id: format!("{resource}/conn"), pool_id: resource.clone(), }; - self.calls - .lock() - .unwrap() - .push(Call::Connect { - resource: resource.clone(), - }); + self.calls.lock().unwrap().push(Call::Connect { + resource: resource.clone(), + }); self.live.lock().unwrap().insert(resource.clone()); self.resource_by_connection_id .lock() @@ -194,12 +240,9 @@ impl DatabaseDriver for JournalDriver { async fn disconnect(&self, handle: ConnectionHandle) -> Result<(), DriverError> { let resource = handle.pool_id.clone(); - self.calls - .lock() - .unwrap() - .push(Call::Disconnect { - resource: resource.clone(), - }); + self.calls.lock().unwrap().push(Call::Disconnect { + resource: resource.clone(), + }); self.live.lock().unwrap().remove(&resource); Ok(()) } @@ -208,6 +251,7 @@ impl DatabaseDriver for JournalDriver { &self, handle: &ConnectionHandle, ) -> Result { + self.bind_host(handle); let resource = self.resource_of(handle); if !self.live.lock().unwrap().contains(&resource) { return Err(DriverError::TransactionError(format!( @@ -246,6 +290,7 @@ impl DatabaseDriver for JournalDriver { sql: &str, _limit: Option, ) -> Result { + self.bind_host(handle); let resource = self.resource_of(handle); if !self.live.lock().unwrap().contains(&resource) { return Err(DriverError::QueryFailed(format!( @@ -340,7 +385,11 @@ struct Fixture { async fn fixture() -> Fixture { let keyring = FileKeyringGuard::set(); let temp = tempfile::tempdir().expect("tempdir"); - let store = Arc::new(Store::init_with_path(temp.path()).await.expect("store init")); + let store = Arc::new( + Store::init_with_path(temp.path()) + .await + .expect("store init"), + ); let registry = Arc::new(DriverRegistry::new()); let driver = Arc::new(JournalDriver::new()); registry @@ -379,6 +428,12 @@ struct Observation { /// Is the old `TransactionHandle` still readable, and what does it point at? handle_after_query: Option<(String, String)>, handle_resource_after_query: Option, + /// Did the host still route the old transaction's finish call to the driver + /// **after** the physical resource had already been closed? This is the + /// journal-level, map-free form of "a committable handle survived the loss of + /// the thing it commits on": a runtime that terminates the transaction before + /// closing never produces it. + old_handle_finished_after_loss: bool, /// Result of a rollback attempt against that old handle. rollback_result: Result<(), String>, /// Was a commit/rollback delivered to a resource other than the one that @@ -404,7 +459,7 @@ async fn observe_journey() -> Observation { .expect("connect"); let after_connect = driver.snapshot(); let transaction_resource = driver - .current_resource(&after_connect, &db_session_id) + .current_resource(&after_connect) .expect("physical resource of the first connect"); // 1. Real transaction handle, registered, plus an uncommitted marker. @@ -428,10 +483,7 @@ async fn observe_journey() -> Observation { .connection_manager .expire_test_session(&db_session_id) .await; - state - .connection_manager - .cleanup_idle_connections() - .await; + state.connection_manager.cleanup_idle_connections().await; let after_evict = driver.snapshot(); let physical_disconnect_observed = after_evict.contains(&Call::Disconnect { @@ -446,17 +498,13 @@ async fn observe_journey() -> Observation { .expect("session_transaction_status"); // 3. Same `dbSessionId` again — this is where the silent rebuild happens. - let query_after_evict_ok = execute_query_impl( - state, - db_session_id.clone(), - "SELECT 1".to_string(), - None, - ) - .await - .is_ok(); + let query_after_evict_ok = + execute_query_impl(state, db_session_id.clone(), "SELECT 1".to_string(), None) + .await + .is_ok(); let after_query = driver.snapshot(); - let resource_after_query = driver.current_resource(&after_query, &db_session_id); + let resource_after_query = driver.current_resource(&after_query); let handle_after_query = { let txs = state.session_transactions.lock().await; txs.get(&db_session_id) @@ -472,19 +520,52 @@ async fn observe_journey() -> Observation { .map_err(|err| err.to_string()); let final_calls = driver.snapshot(); - let transaction_resource_owned = final_calls.iter().find_map(|call| match call { - Call::Begin { resource, .. } => Some(resource.clone()), + + // Where the transaction was opened, per the driver journal itself. + let opened: Option<(String, String)> = final_calls.iter().find_map(|call| match call { + Call::Begin { + resource, + transaction, + } => Some((resource.clone(), transaction.clone())), _ => None, }); - let transaction_call_misrouted = final_calls.iter().any(|call| { - let (resource, commit) = match call { - Call::Commit { resource, .. } => (resource, true), - Call::Rollback { resource, .. } => (resource, false), - _ => return false, - }; - let _ = commit; - Some(resource.clone()) != transaction_resource_owned.clone() - }); + + // "The handle outlived the loss": a finish call for the journey's + // transaction that reaches the driver *after* the close. A runtime that + // terminates the transaction on the original resource before closing it + // produces nothing here; a runtime that keeps the resource up never closes + // it, so the position lookup below finds no close at all. + let old_handle_finished_after_loss = match opened.as_ref() { + None => false, + Some((_, transaction)) => match final_calls.iter().position(|call| { + matches!(call, Call::Disconnect { resource } if resource == &transaction_resource) + }) { + None => false, + Some(closed_at) => final_calls[closed_at..].iter().any(|call| { + matches!(call, Call::Commit { transaction: t, .. } | Call::Rollback { transaction: t, .. } if t == transaction) + }), + }, + }; + + // Misrouting compares the resource the host delivered the finish call on + // against the resource the transaction was actually opened on. Both ends + // come from the journal: `Begin` names the origin, the finish call names the + // routing. They are only equal when the host never re-pointed the session id + // at a rebuilt connection underneath the caller's handle. + let transaction_call_misrouted = match opened.as_ref() { + None => false, + Some((origin, transaction)) => final_calls.iter().any(|call| match call { + Call::Commit { + resource, + transaction: t, + } + | Call::Rollback { + resource, + transaction: t, + } => t == transaction && resource != origin, + _ => false, + }), + }; Observation { db_session_id, @@ -496,6 +577,7 @@ async fn observe_journey() -> Observation { resource_after_query, handle_after_query, handle_resource_after_query, + old_handle_finished_after_loss, rollback_result, transaction_call_misrouted, calls: final_calls, @@ -588,11 +670,17 @@ async fn cm73_registered_live_handle_survives_idle_eviction_and_is_never_reused( let obs = observe_journey().await; eprintln!("[CM-73 target] calls={:#?}", obs.calls); - // T1 — While a registered live transaction handle exists, idle eviction must - // not close the physical resource behind it. + // T1 — `:1283` does not forbid the sweep from closing a physical resource; it + // forbids the *conjunction* of a closed physical resource and a handle that + // is still offered for finishing. Both compliant shapes make the conjunction + // false: one keeps the resource up for the life of the transaction (nothing + // is closed, so the first half is false), the other terminates the + // transaction on the original resource *before* closing it (so no finish call + // can still be routed afterwards). Only "close first, keep offering the + // handle" — today's behavior — makes it true. assert!( - !obs.physical_disconnect_observed, - "idle eviction closed the physical resource {} while a live transaction handle was still registered for dbSessionId {}", + !(obs.physical_disconnect_observed && obs.old_handle_finished_after_loss), + "idle eviction closed the physical resource {} while dbSessionId {} was still handing its transaction to the driver to be finished there", obs.transaction_resource, obs.db_session_id ); @@ -612,9 +700,13 @@ async fn cm73_registered_live_handle_survives_idle_eviction_and_is_never_reused( // T3 — Transaction status must track the physical session's liveness: it may // not keep reporting Active once the resource is gone, and it may not stop - // reporting Active while the resource is still up. + // reporting Active while the resource is still up (`:1283` "事务状态不得在 + // 物理 session 丢失后继续报告 Active"). Exactly one of the two must hold, so + // status is the *negation* of "the physical resource was closed" — under the + // two compliant shapes respectively: kept up → Active, torn down first → + // not Active. assert_eq!( - obs.status_after_evict, obs.physical_disconnect_observed, + obs.status_after_evict, !obs.physical_disconnect_observed, "transaction status ({}) disagrees with whether the physical resource {} is still alive", obs.status_after_evict, obs.transaction_resource ); @@ -623,14 +715,26 @@ async fn cm73_registered_live_handle_survives_idle_eviction_and_is_never_reused( // other than the one that opened it. assert!( !obs.transaction_call_misrouted, - "a transaction handle was delivered to a resource that did not open it" + "a transaction handle was delivered to a resource that did not open it; journal: {:#?}", + obs.calls ); - // T5 — When the outcome cannot be known, unwinding must fail closed rather - // than report success. - assert!( - obs.rollback_result.is_err(), - "unwinding a transaction whose physical session is lost must fail closed, got {:?}", - obs.rollback_result - ); -} \ No newline at end of file + // T5 — Failing closed is conditional: the outcome is unknowable only once the + // resource the transaction lives on is actually gone. While it is still up, + // unwinding must succeed (`:1283` — only an unknown outcome becomes + // OutcomeUnknown/SessionLost, so a *known* outcome may never be reported as + // unknown); after the loss it must fail, never report success. + if obs.physical_disconnect_observed { + assert!( + obs.rollback_result.is_err(), + "unwinding a transaction whose physical session was lost must fail closed, got {:?}", + obs.rollback_result + ); + } else { + assert!( + obs.rollback_result.is_ok(), + "the physical session {} is still up, so unwinding its transaction must report the real outcome instead of an unknown one, got {:?}", + obs.transaction_resource, obs.rollback_result + ); + } +} diff --git a/src-tauri/src/commands/cm73_idle_eviction_tests.rs b/src-tauri/src/commands/cm73_idle_eviction_tests.rs index 0994a2a0b..35c709f13 100644 --- a/src-tauri/src/commands/cm73_idle_eviction_tests.rs +++ b/src-tauri/src/commands/cm73_idle_eviction_tests.rs @@ -12,6 +12,8 @@ //! - is `release` authoritative, in both orderings against the sweep? //! - is the physical resource chain of two consecutive cycles observable? //! - does a rebuilt session get advertised under the *old* `dbSessionId`? +//! - is a session recovered through the production command path idle-eligible, +//! i.e. is the sweep's eviction branch reachable outside a hand-built fixture? //! //! All assertions are behavioural: they read the driver journal, the command //! layer, and the public session id. They name no legacy `ConnectionManager` @@ -29,12 +31,12 @@ use super::query::execute_query_impl; use super::AppState; use crate::db::registry::DriverRegistry; use crate::db::{ - ConnectionConfig, ConnectionHandle, DatabaseDriver, DatabaseType, DriverError, MultiQueryResult, - QueryResult, ServerInfo, TableInfo, TableSchema, TransactionHandle, Value, + ConnectionConfig, ConnectionHandle, DatabaseDriver, DatabaseType, DriverError, + MultiQueryResult, QueryResult, ServerInfo, TableInfo, TableSchema, TransactionHandle, Value, }; use crate::store::Store; -use crate::testing::FileKeyringGuard; use crate::testing::app_state::{build_app_state, sample_postgres_config}; +use crate::testing::FileKeyringGuard; use datazen_driver_api::{ execute_standard_sql_command, query_command_definition, CommandResult, DriverCommandDefinition, }; @@ -49,9 +51,17 @@ const DB_TYPE: &str = "postgres"; /// the last test in this file. #[derive(Debug, Clone, PartialEq, Eq)] enum Call { - Connect { resource: String, connection_id: String }, - Disconnect { resource: String }, - Query { resource: String, connection_id: String }, + Connect { + resource: String, + connection_id: String, + }, + Disconnect { + resource: String, + }, + Query { + resource: String, + connection_id: String, + }, } impl Call { @@ -133,20 +143,19 @@ impl DatabaseDriver for SweepDriver { id: format!("{resource}/conn"), pool_id: resource.clone(), }; - self.calls - .lock() - .expect("journal") - .push(Call::Connect { resource: resource.clone(), connection_id: handle.id.clone() }); + self.calls.lock().expect("journal").push(Call::Connect { + resource: resource.clone(), + connection_id: handle.id.clone(), + }); self.live.lock().expect("live").insert(resource); Ok(handle) } async fn disconnect(&self, handle: ConnectionHandle) -> Result<(), DriverError> { let resource = handle.pool_id.clone(); - self.calls - .lock() - .expect("journal") - .push(Call::Disconnect { resource: resource.clone() }); + self.calls.lock().expect("journal").push(Call::Disconnect { + resource: resource.clone(), + }); self.live.lock().expect("live").remove(&resource); Ok(()) } @@ -168,11 +177,20 @@ impl DatabaseDriver for SweepDriver { handle.pool_id ))); } - Ok(MultiQueryResult { results: vec![], total_time_ms: 0 }) + Ok(MultiQueryResult { + results: vec![], + total_time_ms: 0, + }) } - async fn query(&self, _handle: &ConnectionHandle, _sql: &str) -> Result { - Err(DriverError::QueryFailed("sweep driver: query is not used here".into())) + async fn query( + &self, + _handle: &ConnectionHandle, + _sql: &str, + ) -> Result { + Err(DriverError::QueryFailed( + "sweep driver: query is not used here".into(), + )) } async fn query_with_params( @@ -181,30 +199,36 @@ impl DatabaseDriver for SweepDriver { _sql: &str, _params: &[Value], ) -> Result { - Err(DriverError::QueryFailed("sweep driver: query_with_params is not used here".into())) + Err(DriverError::QueryFailed( + "sweep driver: query_with_params is not used here".into(), + )) } - async fn execute( - &self, - _handle: &ConnectionHandle, - _sql: &str, - ) -> Result { - Err(DriverError::QueryFailed("sweep driver: execute is not used here".into())) + async fn execute(&self, _handle: &ConnectionHandle, _sql: &str) -> Result { + Err(DriverError::QueryFailed( + "sweep driver: execute is not used here".into(), + )) } async fn begin_transaction( &self, _handle: &ConnectionHandle, ) -> Result { - Err(DriverError::TransactionError("sweep driver: no transactions here".into())) + Err(DriverError::TransactionError( + "sweep driver: no transactions here".into(), + )) } async fn commit(&self, _tx: TransactionHandle) -> Result<(), DriverError> { - Err(DriverError::TransactionError("sweep driver: no transactions here".into())) + Err(DriverError::TransactionError( + "sweep driver: no transactions here".into(), + )) } async fn rollback(&self, _tx: TransactionHandle) -> Result<(), DriverError> { - Err(DriverError::TransactionError("sweep driver: no transactions here".into())) + Err(DriverError::TransactionError( + "sweep driver: no transactions here".into(), + )) } async fn get_databases(&self, _handle: &ConnectionHandle) -> Result, DriverError> { @@ -227,11 +251,15 @@ impl DatabaseDriver for SweepDriver { _database: &str, _schema: Option<&str>, ) -> Result { - Err(DriverError::QueryFailed("sweep driver: no schemas here".into())) + Err(DriverError::QueryFailed( + "sweep driver: no schemas here".into(), + )) } async fn get_server_info(&self, _handle: &ConnectionHandle) -> Result { - Err(DriverError::QueryFailed("sweep driver: no server info here".into())) + Err(DriverError::QueryFailed( + "sweep driver: no server info here".into(), + )) } async fn cancel_query(&self, _handle: &ConnectionHandle) -> Result<(), DriverError> { @@ -270,12 +298,14 @@ struct Fixture { async fn fixture(connection_ids: &[&str]) -> Fixture { let keyring = FileKeyringGuard::set(); let temp = tempfile::tempdir().expect("tempdir"); - let store = Arc::new(Store::init_with_path(temp.path()).await.expect("store init")); + let store = Arc::new( + Store::init_with_path(temp.path()) + .await + .expect("store init"), + ); let registry = Arc::new(DriverRegistry::new()); let driver = Arc::new(SweepDriver::new()); - registry - .register_test_driver(DB_TYPE, driver.clone()) - .await; + registry.register_test_driver(DB_TYPE, driver.clone()).await; let state = build_app_state(store.clone(), registry.clone()); for id in connection_ids { store @@ -283,22 +313,38 @@ async fn fixture(connection_ids: &[&str]) -> Fixture { .await .expect("save_connection"); } - Fixture { _keyring: keyring, _temp: temp, state, driver } + Fixture { + _keyring: keyring, + _temp: temp, + state, + driver, + } } impl Fixture { /// Push the session past the production idle deadline (30 min) without /// touching production code, then run the sweep. async fn expire_and_sweep(&self, db_session_id: &str) { - self.state.connection_manager.expire_test_session(db_session_id).await; - self.state.connection_manager.cleanup_idle_connections().await; + self.state + .connection_manager + .expire_test_session(db_session_id) + .await; + self.state + .connection_manager + .cleanup_idle_connections() + .await; } async fn query(&self, db_session_id: &str, sql: &str) -> Result<(), String> { - execute_query_impl(&self.state, db_session_id.to_string(), sql.to_string(), None) - .await - .map(|_| ()) - .map_err(|e| e.to_string()) + execute_query_impl( + &self.state, + db_session_id.to_string(), + sql.to_string(), + None, + ) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) } } @@ -320,7 +366,11 @@ async fn idle_sweep_never_closes_a_session_that_still_holds_a_reference() { .await .expect("connect_dedicated"); let resource = f.driver.connects().remove(0); - assert_eq!(mgr.ref_count(&db_session_id).await, 1, "a dedicated session holds one reference"); + assert_eq!( + mgr.ref_count(&db_session_id).await, + 1, + "a dedicated session holds one reference" + ); for _ in 0..3 { f.expire_and_sweep(&db_session_id).await; @@ -332,8 +382,15 @@ async fn idle_sweep_never_closes_a_session_that_still_holds_a_reference() { "an idle-looking session that still holds a reference must never be closed, journal: {:?}", f.driver.tags() ); - assert_eq!(mgr.ref_count(&db_session_id).await, 1, "the sweep must not touch reference counts"); - assert_eq!(mgr.owner_connection_id(&db_session_id).await.as_deref(), Some("cm73-ref-1")); + assert_eq!( + mgr.ref_count(&db_session_id).await, + 1, + "the sweep must not touch reference counts" + ); + assert_eq!( + mgr.owner_connection_id(&db_session_id).await.as_deref(), + Some("cm73-ref-1") + ); // Still the same physical session, and no silent reconnect behind our back. let connects_before = f.driver.connects().len(); @@ -367,7 +424,11 @@ async fn idle_sweep_closes_each_unreferenced_session_once_and_keeps_one_owner_en for id in ["cm73-many-1", "cm73-many-2", "cm73-many-3"] { sessions.push(mgr.connect(id).await.expect("connect")); } - assert_eq!(f.driver.connects().len(), 3, "three distinct physical resources"); + assert_eq!( + f.driver.connects().len(), + 3, + "three distinct physical resources" + ); assert_eq!( mgr.session_owner_map_len().await, 3, @@ -391,7 +452,11 @@ async fn idle_sweep_closes_each_unreferenced_session_once_and_keeps_one_owner_en "the owner entry of an evicted session must survive so recovery is possible" ); } - assert_eq!(mgr.session_owner_map_len().await, 3, "one owner entry per evicted session"); + assert_eq!( + mgr.session_owner_map_len().await, + 3, + "one owner entry per evicted session" + ); // A second sweep over the same (now connection-less) sessions must be a // no-op: no extra closes, no owner-map growth, no shrink either. @@ -403,11 +468,19 @@ async fn idle_sweep_closes_each_unreferenced_session_once_and_keeps_one_owner_en "repeating the sweep must not re-close or resurrect sessions, journal: {:?}", f.driver.tags() ); - assert_eq!(mgr.session_owner_map_len().await, 3, "the owner map must not drift on a no-op sweep"); + assert_eq!( + mgr.session_owner_map_len().await, + 3, + "the owner map must not drift on a no-op sweep" + ); // Owner entries are not immortal: an explicit teardown still reclaims them. mgr.disconnect(&sessions[0]).await.expect("disconnect"); - assert_eq!(mgr.session_owner_map_len().await, 2, "an explicit disconnect reclaims its owner entry"); + assert_eq!( + mgr.session_owner_map_len().await, + 2, + "an explicit disconnect reclaims its owner entry" + ); assert!(mgr.owner_connection_id(&sessions[0]).await.is_none()); } @@ -427,7 +500,11 @@ async fn release_after_the_sweep_is_the_only_close_of_a_referenced_session() { for _ in 0..2 { f.expire_and_sweep(&db_session_id).await; } - assert_eq!(f.driver.disconnects(), Vec::::new(), "the sweep must not close a referenced session"); + assert_eq!( + f.driver.disconnects(), + Vec::::new(), + "the sweep must not close a referenced session" + ); f.query(&db_session_id, "SELECT 1") .await .expect("the session is still up after the sweeps"); @@ -442,7 +519,11 @@ async fn release_after_the_sweep_is_the_only_close_of_a_referenced_session() { f.driver.tags() ); assert_eq!(mgr.ref_count(&db_session_id).await, 0); - assert_eq!(mgr.session_owner_map_len().await, 0, "release reclaims the owner entry"); + assert_eq!( + mgr.session_owner_map_len().await, + 0, + "release reclaims the owner entry" + ); assert!(mgr.owner_connection_id(&db_session_id).await.is_none()); mgr.cleanup_idle_connections().await; @@ -468,9 +549,16 @@ async fn a_sweep_after_release_does_not_close_the_session_twice() { // Expired, but still referenced: the reference is the only thing standing // between the session and the sweep. Release it, then let the sweep run. - f.state.connection_manager.expire_test_session(&db_session_id).await; + f.state + .connection_manager + .expire_test_session(&db_session_id) + .await; assert!(mgr.release(&db_session_id).await.expect("release")); - assert_eq!(f.driver.disconnects(), vec![resource.clone()], "release performed the close"); + assert_eq!( + f.driver.disconnects(), + vec![resource.clone()], + "release performed the close" + ); for _ in 0..2 { mgr.cleanup_idle_connections().await; @@ -482,16 +570,28 @@ async fn a_sweep_after_release_does_not_close_the_session_twice() { "the sweep must not re-close a session release already tore down, journal: {:?}", f.driver.tags() ); - assert_eq!(mgr.session_owner_map_len().await, 0, "no owner entry may be left behind"); + assert_eq!( + mgr.session_owner_map_len().await, + 0, + "no owner entry may be left behind" + ); assert_eq!(mgr.ref_count(&db_session_id).await, 0); - assert_eq!(f.driver.connects().len(), 1, "the torn-down session must not be silently rebuilt"); + assert_eq!( + f.driver.connects().len(), + 1, + "the torn-down session must not be silently rebuilt" + ); // A query on the released id must not resurrect anything behind the user's back. assert!( f.query(&db_session_id, "SELECT 1").await.is_err(), "a released session must not come back as a same-id rebuild" ); - assert_eq!(f.driver.connects().len(), 1, "the failed query must not have connected anything"); + assert_eq!( + f.driver.connects().len(), + 1, + "the failed query must not have connected anything" + ); } /// Two consecutive eviction→rebuild cycles of the *same* `dbSessionId` must leave @@ -514,7 +614,11 @@ async fn two_consecutive_eviction_cycles_of_one_session_id_are_fully_observable( // Cycle 1 runs on the session `connect` just opened, so it must not // rebuild. Every later cycle runs on a session the previous sweep // closed, so the recovery is exactly one new physical resource. - let expected_connects = if cycle == 1 { connects_before } else { connects_before + 1 }; + let expected_connects = if cycle == 1 { + connects_before + } else { + connects_before + 1 + }; assert_eq!( f.driver.connects().len(), expected_connects, @@ -532,7 +636,12 @@ async fn two_consecutive_eviction_cycles_of_one_session_id_are_fully_observable( } // 1 initial connect + exactly 1 rebuild for the second cycle. - assert_eq!(f.driver.connects().len(), 2, "one rebuild per eviction, journal: {:?}", f.driver.tags()); + assert_eq!( + f.driver.connects().len(), + 2, + "one rebuild per eviction, journal: {:?}", + f.driver.tags() + ); let live: HashSet = f.driver.live.lock().expect("live").clone(); assert!( live.is_empty(), @@ -543,7 +652,12 @@ async fn two_consecutive_eviction_cycles_of_one_session_id_are_fully_observable( // Ordering: the chain reads C:res-1, D:res-1, C:res-2, D:res-2 — each // resource is closed before its successor is opened, so a reader can // attribute every call to a resource that was actually alive. - let actual: Vec = f.driver.tags().into_iter().filter(|t| !t.starts_with("Q:")).collect(); + let actual: Vec = f + .driver + .tags() + .into_iter() + .filter(|t| !t.starts_with("Q:")) + .collect(); assert_eq!( actual, vec!["C:res-1", "D:res-1", "C:res-2", "D:res-2"], @@ -605,3 +719,74 @@ async fn a_rebuilt_session_is_never_advertised_under_the_pre_loss_session_id() { presented[0] ); } + +/// Reachability proof (executable, not reasoning): a session recovered through the +/// **production** command path lands in the sweep's eligible state and is really +/// evicted by it. +/// +/// Why this needs its own test: `cleanup_idle_connections` only closes a session +/// whose `ref_counts` entry is 0 *and* whose `last_used` is older than the idle +/// timeout. A fixture built with `ConnectionManager::connect` only exercises that +/// state for the connection a caller opened, which is not the path a real caller +/// takes. `execute_query_impl` resolves its driver through +/// `resolve_command_driver` → `get_session`, and `get_session` falls through to +/// `reconnect()` for a session id the sweep already closed. `reconnect` re-inserts +/// into `connections` **without taking a reference**, so the state it produces is +/// "live physical connection, zero references" — exactly what the sweep exists to +/// collect, and the state the CM-73 journey is about. +/// +/// The recovery below is an ordinary query on a `dbSessionId`; the only test-only +/// tool is the `expire_test_session` deadline helper the rest of this file already +/// uses to cross the 30-minute idle timeout without waiting for it. +#[tokio::test] +async fn a_session_recovered_through_the_host_command_path_is_still_swept_when_it_goes_idle() { + let f = fixture(&["cm73-reachable"]).await; + let mgr = &f.state.connection_manager; + + let db_session_id = mgr.connect("cm73-reachable").await.expect("connect"); + assert_eq!( + f.driver.connects(), + vec!["res-1".to_string()], + "precondition: opening the session connects exactly one physical resource, journal: {:?}", + f.driver.tags() + ); + + f.expire_and_sweep(&db_session_id).await; + assert_eq!( + f.driver.disconnects(), + vec!["res-1".to_string()], + "precondition: the first sweep closes that physical resource, journal: {:?}", + f.driver.tags() + ); + + // Production recovery path — `execute_query_impl` → `resolve_command_driver` → + // `get_session` → miss → `reconnect`. No direct `ConnectionManager::connect`, + // so whatever the query observes is what a real caller observes. + f.query(&db_session_id, "SELECT 1") + .await + .unwrap_or_else(|e| { + panic!("the query that triggers recovery must succeed on the rebuilt session: {e}") + }); + assert_eq!( + f.driver.connects(), + vec!["res-1".to_string(), "res-2".to_string()], + "the query must have rebuilt the session on a brand-new physical resource, journal: {:?}", + f.driver.tags() + ); + + // The state the sweep's predicate keys on, measured rather than assumed. + assert_eq!( + mgr.ref_count(&db_session_id).await, + 0, + "`reconnect` must not take a reference, otherwise a recovered session could never become idle-eligible" + ); + + // Therefore the next sweep must close it. + f.expire_and_sweep(&db_session_id).await; + assert_eq!( + f.driver.disconnects(), + vec!["res-1".to_string(), "res-2".to_string()], + "a session recovered at reference count 0 is idle-eligible and must be closed by the sweep; journal: {:?}", + f.driver.tags() + ); +} From 8a7ec080e916aa5dd80eec67cc7f20e976e12a6e Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 09:05:48 +0800 Subject: [PATCH 062/666] =?UTF-8?q?test(data-transfer):=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=B4=A2=E5=BC=95=E4=B8=8E=E5=A4=96=E9=94=AE=E5=8F=91?= =?UTF-8?q?=E5=B0=84=E3=80=81=E7=BB=93=E6=9E=84=E9=98=B6=E6=AE=B5=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E9=97=A8=E9=97=B8=E4=B8=8E=E6=8B=92=E7=BB=9D=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 data_transfer::sql_structure_object_tests:二级索引在全部建表之后 发出并 depends_on 源表;is_primary 索引不单独成句;外键在两张表之后发出且 depends_on 被引用表;未勾选表的引用外键 fail-closed - 命名门闸:默认 flag(schema 级、大小写不敏感)下同名索引报错并要求重命名; table-scoped / case-sensitive 目标允许共存 - 列映射:user_id→buyer_id 反映在索引 DDL;跳列索引明确拒绝 - 前缀长度索引 name(10) fail-closed,并记录真实触发点(映射层/target IR 查列) - 把既有夹具抽到 sql_structure_fixtures 供两个测试模块复用 - data-transfer 指南补充结构阶段覆盖与拒绝范围 --- docs/features/data-transfer-guide.zh-CN.md | 22 + src-tauri/src/data_transfer/mod.rs | 4 + src-tauri/src/data_transfer/sql_structure.rs | 163 +---- .../data_transfer/sql_structure_fixtures.rs | 277 ++++++++ .../sql_structure_object_tests.rs | 608 ++++++++++++++++++ 5 files changed, 915 insertions(+), 159 deletions(-) create mode 100644 src-tauri/src/data_transfer/sql_structure_fixtures.rs create mode 100644 src-tauri/src/data_transfer/sql_structure_object_tests.rs diff --git a/docs/features/data-transfer-guide.zh-CN.md b/docs/features/data-transfer-guide.zh-CN.md index 9f83e42e3..8a83df452 100644 --- a/docs/features/data-transfer-guide.zh-CN.md +++ b/docs/features/data-transfer-guide.zh-CN.md @@ -56,6 +56,28 @@ Preview 包含: 跨方言结构通过 `src-tauri/src/transfer/` 的 IR adapter 转换,目标 DDL 由 target adapter 生成。数据值同样在 IR 路径中转换。 +## 结构阶段覆盖范围 + +结构阶段按固定顺序生成计划:先全部建表 DDL,再全部二级索引,最后全部外键约束,保证每条二级对象语句都落在被依赖的表之后。 + +会迁移: + +- 基表及其列映射(含重命名后的目标列名); +- 主键:随 `CREATE TABLE` 一起生成,不会单独产生一条索引语句; +- 二级索引(唯一与非唯一),最终 DDL 由 target adapter 渲染; +- 外键:字段映射到目标列名后用 `ALTER TABLE ... ADD CONSTRAINT` 追加。 + +会明确拒绝(fail-closed,整份计划不执行,不会静默丢弃): + +- 计算列 / 生成列等未进入传输结构 IR 的源对象; +- CHECK 约束; +- deferrable 外键; +- 目标列不是整型、或目标 adapter 无法渲染的 identity/auto-increment 列; +- 前缀长度索引与表达式索引(例如 `name(10)`); +- 索引或外键引用了未勾选迁移的表; +- 索引/外键列未映射或被跳过; +- 命名冲突:索引与外键名在目标命名空间内重复时要求先重命名源对象。命名空间与大小写敏感性由 target adapter 决定:按表隔离索引名、大小写敏感的目标(如 Postgres)可共存;按 schema 隔离、大小写不敏感的目标则视为冲突。 + ## 当前限制 Data Transfer 当前主要面向基表。它不承诺迁移视图、函数、触发器、存储过程等完整数据库对象生态;具体支持范围由当前 Driver adapter 和 UI 能力决定。 diff --git a/src-tauri/src/data_transfer/mod.rs b/src-tauri/src/data_transfer/mod.rs index 1a6edf089..dcbba1874 100644 --- a/src-tauri/src/data_transfer/mod.rs +++ b/src-tauri/src/data_transfer/mod.rs @@ -50,3 +50,7 @@ pub(crate) static TEST_COMMIT_ACK_LOSS_TEST_LOCK: tokio::sync::Mutex<()> = #[cfg(test)] mod execution_tests; +#[cfg(test)] +mod sql_structure_fixtures; +#[cfg(test)] +mod sql_structure_object_tests; diff --git a/src-tauri/src/data_transfer/sql_structure.rs b/src-tauri/src/data_transfer/sql_structure.rs index 7d28ff2b4..93da436c7 100644 --- a/src-tauri/src/data_transfer/sql_structure.rs +++ b/src-tauri/src/data_transfer/sql_structure.rs @@ -682,165 +682,10 @@ pub(crate) fn build_structure_plan( #[cfg(test)] mod database_plan_tests { use super::*; - use crate::data_transfer::model::{ - ColumnMapping, Endpoint, TableMapping, TransferMode, TransferOptions, WriteMode, - }; - use crate::db::Value; - use crate::transfer::ir::{IRColumn, IRDefault, IRType}; - use datazen_driver_api::{ColumnSchema, ForeignKeyInfo, IndexInfo}; - - struct Source; - - impl SyncSourceAdapter for Source { - fn column_to_ir(&self, column: &ColumnSchema, _native_full_type: Option<&str>) -> IRColumn { - IRColumn { - name: column.name.clone(), - ir_type: IRType::Int32, - nullable: column.nullable, - default_expr: None, - is_primary_key: column.is_primary_key, - is_auto_increment: column.is_auto_increment, - comment: None, - } - } - } - - struct Target; - - impl SyncTargetAdapter for Target { - fn ir_type_to_native(&self, _: &IRType) -> String { - "INTEGER".into() - } - fn format_default(&self, default: &IRDefault) -> Option { - match default { - IRDefault::Literal(value) => Some(value.clone()), - IRDefault::CurrentTimestamp | IRDefault::RawExpression(_) => None, - } - } - fn format_literal(&self, _: &Option, _: &IRType) -> String { - "NULL".into() - } - } - - struct IdentityTarget; - - impl SyncTargetAdapter for IdentityTarget { - fn ir_type_to_native(&self, _: &IRType) -> String { - "INTEGER".into() - } - fn format_default(&self, default: &IRDefault) -> Option { - match default { - IRDefault::Literal(value) => Some(value.clone()), - IRDefault::CurrentTimestamp | IRDefault::RawExpression(_) => None, - } - } - fn format_literal(&self, _: &Option, _: &IRType) -> String { - "NULL".into() - } - fn auto_increment_keyword(&self) -> Option<&str> { - Some("IDENTITY(1,1)") - } - } - - fn column(name: &str, primary: bool) -> ColumnSchema { - ColumnSchema { - name: name.into(), - data_type: "integer".into(), - nullable: false, - default_value: None, - comment: None, - is_primary_key: primary, - is_auto_increment: false, - } - } - - fn schema(name: &str, columns: &[(&str, bool)]) -> TableSchema { - let primary_keys = columns - .iter() - .filter(|(_, primary)| *primary) - .map(|(name, _)| (*name).to_string()) - .collect(); - TableSchema { - table_name: name.into(), - columns: columns - .iter() - .map(|(name, primary)| column(name, *primary)) - .collect(), - primary_keys, - indexes: Vec::new(), - foreign_keys: Vec::new(), - check_constraints: Vec::new(), - table_options: Default::default(), - } - } - - fn mapping(source: &str, target: &str, columns: &[(&str, &str)]) -> TableMapping { - TableMapping { - source_table: source.into(), - target_table: target.into(), - create_new: true, - enabled: true, - column_mappings: columns - .iter() - .map(|(source, target)| ColumnMapping { - source_column: (*source).into(), - target_column: (*target).into(), - skip: false, - target_native_type: None, - }) - .collect(), - ddl_override: None, - source_filter: None, - recordset: None, - } - } - - fn inspected(source: &str, target: &str, columns: &[(&str, &str)]) -> TableInspectResult { - TableInspectResult { - source_table: source.into(), - target_table: target.into(), - status: TableMappingStatus::CreateNew, - create_new: true, - enabled: true, - column_mappings: columns - .iter() - .map(|(source, target)| ColumnMapping { - source_column: (*source).into(), - target_column: (*target).into(), - skip: false, - target_native_type: None, - }) - .collect(), - source_columns: Vec::new(), - source_primary_keys: Vec::new(), - target_columns: Vec::new(), - source_column_types: HashMap::new(), - target_column_types: HashMap::new(), - incompatible_reason: None, - source_row_count: None, - recordset: None, - } - } - - fn job(tables: Vec) -> TransferJob { - TransferJob { - source: Endpoint { - db_session_id: "source".into(), - database: "source_db".into(), - schema: Some("public".into()), - }, - target: Some(Endpoint { - db_session_id: "target".into(), - database: "target_db".into(), - schema: Some("archive".into()), - }), - sql_file_target: None, - mode: TransferMode::StructureAndData, - write_mode: WriteMode::Insert, - tables, - options: TransferOptions::default(), - } - } + use crate::data_transfer::model::{TransferMode, WriteMode}; + use crate::data_transfer::sql_structure_fixtures::*; + use datazen_driver_api::ForeignKeyInfo; + use datazen_driver_api::IndexInfo; #[test] fn database_structure_plan_maps_renamed_tables_columns_indexes_and_fks() { diff --git a/src-tauri/src/data_transfer/sql_structure_fixtures.rs b/src-tauri/src/data_transfer/sql_structure_fixtures.rs new file mode 100644 index 000000000..60ccc7909 --- /dev/null +++ b/src-tauri/src/data_transfer/sql_structure_fixtures.rs @@ -0,0 +1,277 @@ +//! Fake adapters and builders shared by the SQL-file structure-plan tests. +//! +//! The target fakes deliberately inherit every `SyncTargetAdapter` default +//! except the flag a test is exercising, so a change to the shipped defaults +//! stays visible in these tests instead of being frozen into a fake. + +use std::collections::HashMap; + +use datazen_driver_api::{ColumnSchema, IndexInfo, TableSchema}; + +use crate::data_transfer::model::{ + ColumnMapping, Endpoint, TableInspectResult, TableMapping, TableMappingStatus, TransferJob, + TransferMode, TransferOptions, WriteMode, +}; +use crate::db::Value; +use crate::transfer::adapter::{SyncSourceAdapter, SyncTargetAdapter}; +use crate::transfer::ir::{IRColumn, IRDefault, IRType}; + +/// Source adapter that only implements the mandatory projection hook: table-level +/// objects keep the catalog shape the tests build. +pub(crate) struct Source; + +impl SyncSourceAdapter for Source { + fn column_to_ir(&self, column: &ColumnSchema, _native_full_type: Option<&str>) -> IRColumn { + IRColumn { + name: column.name.clone(), + ir_type: IRType::Int32, + nullable: column.nullable, + default_expr: None, + is_primary_key: column.is_primary_key, + is_auto_increment: column.is_auto_increment, + comment: None, + } + } +} + +/// Target adapter with the shipped trait defaults: schema-scoped index/FK names +/// and case-folded object names. SQL Server keeps exactly these defaults. +pub(crate) struct Target; + +impl Target { + fn default_target() -> Self { + Target + } +} + +impl SyncTargetAdapter for Target { + fn ir_type_to_native(&self, _: &IRType) -> String { + "INTEGER".into() + } + fn format_default(&self, default: &IRDefault) -> Option { + match default { + IRDefault::Literal(value) => Some(value.clone()), + IRDefault::CurrentTimestamp | IRDefault::RawExpression(_) => None, + } + } + fn format_literal(&self, _: &Option, _: &IRType) -> String { + "NULL".into() + } +} + +/// Target whose secondary-index names are local to their table (MySQL-shaped). +/// Names still fold case, so this only isolates the table-scoping rule. +pub(crate) struct TableScopedIndexTarget; + +impl SyncTargetAdapter for TableScopedIndexTarget { + fn ir_type_to_native(&self, ir_type: &IRType) -> String { + Target::default_target().ir_type_to_native(ir_type) + } + fn format_default(&self, default: &IRDefault) -> Option { + Target::default_target().format_default(default) + } + fn format_literal(&self, value: &Option, ir_type: &IRType) -> String { + Target::default_target().format_literal(value, ir_type) + } + fn index_names_are_table_scoped(&self) -> bool { + true + } +} + +/// Target whose quoted object names keep their case (PostgreSQL-shaped). +pub(crate) struct CaseSensitiveTarget; + +impl SyncTargetAdapter for CaseSensitiveTarget { + fn ir_type_to_native(&self, ir_type: &IRType) -> String { + Target::default_target().ir_type_to_native(ir_type) + } + fn format_default(&self, default: &IRDefault) -> Option { + Target::default_target().format_default(default) + } + fn format_literal(&self, value: &Option, ir_type: &IRType) -> String { + Target::default_target().format_literal(value, ir_type) + } + fn object_names_are_case_sensitive(&self) -> bool { + true + } +} + +/// Target that spells auto-increment with the SQL Server `IDENTITY` keyword. +pub(crate) struct IdentityTarget; + +impl SyncTargetAdapter for IdentityTarget { + fn ir_type_to_native(&self, _: &IRType) -> String { + "INTEGER".into() + } + fn format_default(&self, default: &IRDefault) -> Option { + match default { + IRDefault::Literal(value) => Some(value.clone()), + IRDefault::CurrentTimestamp | IRDefault::RawExpression(_) => None, + } + } + fn format_literal(&self, _: &Option, _: &IRType) -> String { + "NULL".into() + } + fn auto_increment_keyword(&self) -> Option<&str> { + Some("IDENTITY(1,1)") + } +} + +pub(crate) fn column(name: &str, primary: bool) -> ColumnSchema { + ColumnSchema { + name: name.into(), + data_type: "integer".into(), + nullable: false, + default_value: None, + comment: None, + is_primary_key: primary, + is_auto_increment: false, + } +} + +pub(crate) fn schema(name: &str, columns: &[(&str, bool)]) -> TableSchema { + let primary_keys = columns + .iter() + .filter(|(_, primary)| *primary) + .map(|(name, _)| (*name).to_string()) + .collect(); + TableSchema { + table_name: name.into(), + columns: columns + .iter() + .map(|(name, primary)| column(name, *primary)) + .collect(), + primary_keys, + indexes: Vec::new(), + foreign_keys: Vec::new(), + check_constraints: Vec::new(), + table_options: Default::default(), + } +} + +/// Add one secondary (non-primary) index to a source schema. +pub(crate) fn with_index( + mut schema: TableSchema, + name: &str, + columns: &[&str], + is_unique: bool, +) -> TableSchema { + schema.indexes.push(IndexInfo { + name: name.into(), + columns: columns.iter().map(|name| (*name).into()).collect(), + is_unique, + is_primary: false, + index_type: "btree".into(), + }); + schema +} + +/// Add one index flagged as the primary key to a source schema. +pub(crate) fn with_primary_index( + mut schema: TableSchema, + name: &str, + columns: &[&str], +) -> TableSchema { + schema.indexes.push(IndexInfo { + name: name.into(), + columns: columns.iter().map(|name| (*name).into()).collect(), + is_unique: true, + is_primary: true, + index_type: "clustered".into(), + }); + schema +} + +/// Same as [`mapping`] but without any column mapping, so constraint columns +/// reach the target renderer exactly as the source catalog reported them. +pub(crate) fn mapping_without_columns(source: &str, target: &str) -> TableMapping { + mapping(source, target, &[]) +} + +pub(crate) fn mapping(source: &str, target: &str, columns: &[(&str, &str)]) -> TableMapping { + TableMapping { + source_table: source.into(), + target_table: target.into(), + create_new: true, + enabled: true, + column_mappings: columns + .iter() + .map(|(source, target)| ColumnMapping { + source_column: (*source).into(), + target_column: (*target).into(), + skip: false, + target_native_type: None, + }) + .collect(), + ddl_override: None, + source_filter: None, + recordset: None, + } +} + +/// Same as [`mapping`], but marks the source column as skipped in the target. +pub(crate) fn mapping_with_skipped_column( + source: &str, + target: &str, + columns: &[(&str, &str)], + skipped: &str, +) -> TableMapping { + let mut mapping = mapping(source, target, columns); + for column in mapping.column_mappings.iter_mut() { + if column.source_column == skipped { + column.skip = true; + } + } + mapping +} + +pub(crate) fn inspected( + source: &str, + target: &str, + columns: &[(&str, &str)], +) -> TableInspectResult { + TableInspectResult { + source_table: source.into(), + target_table: target.into(), + status: TableMappingStatus::CreateNew, + create_new: true, + enabled: true, + column_mappings: columns + .iter() + .map(|(source, target)| ColumnMapping { + source_column: (*source).into(), + target_column: (*target).into(), + skip: false, + target_native_type: None, + }) + .collect(), + source_columns: Vec::new(), + source_primary_keys: Vec::new(), + target_columns: Vec::new(), + source_column_types: HashMap::new(), + target_column_types: HashMap::new(), + incompatible_reason: None, + source_row_count: None, + recordset: None, + } +} + +pub(crate) fn job(tables: Vec) -> TransferJob { + TransferJob { + source: Endpoint { + db_session_id: "source".into(), + database: "source_db".into(), + schema: Some("public".into()), + }, + target: Some(Endpoint { + db_session_id: "target".into(), + database: "target_db".into(), + schema: Some("archive".into()), + }), + sql_file_target: None, + mode: TransferMode::StructureAndData, + write_mode: WriteMode::Insert, + tables, + options: TransferOptions::default(), + } +} diff --git a/src-tauri/src/data_transfer/sql_structure_object_tests.rs b/src-tauri/src/data_transfer/sql_structure_object_tests.rs new file mode 100644 index 000000000..55590b4e3 --- /dev/null +++ b/src-tauri/src/data_transfer/sql_structure_object_tests.rs @@ -0,0 +1,608 @@ +//! Host-side coverage for the secondary-index and foreign-key stages of the +//! SQL-file structure plan. +//! +//! These tests drive the plan builder with fake adapters so the shipped +//! `SyncTargetAdapter` defaults decide the outcome: index/FK naming scope, case +//! folding and the conservative index renderer. + +use std::collections::HashMap; + +use datazen_driver_api::{ForeignKeyDeferrability, ForeignKeyInfo, IndexInfo, TableSchema}; + +use super::model::DdlPreviewKind; +use super::sql_structure::build_database_structure_plan; +use super::sql_structure_fixtures::*; + +/// A table with one non-primary index on `label`. +fn records_with_label_index(index_name: &str) -> TableSchema { + with_index( + schema("records", &[("id", true), ("label", false)]), + index_name, + &["label"], + false, + ) +} + +#[test] +fn secondary_indexes_are_emitted_after_every_table_and_depend_on_their_source_table() { + let orders = with_index( + schema("orders", &[("id", true), ("customer_id", false)]), + "ix_orders_customer", + &["customer_id"], + false, + ); + let customers = with_index( + schema("customers", &[("id", true), ("name", false)]), + "uq_customers_name", + &["name"], + true, + ); + let schemas = HashMap::from([("orders".into(), orders), ("customers".into(), customers)]); + let mappings = vec![ + mapping( + "orders", + "orders", + &[("id", "id"), ("customer_id", "customer_id")], + ), + mapping("customers", "customers", &[("id", "id"), ("name", "name")]), + ]; + let inspected = vec![ + inspected( + "orders", + "orders", + &[("id", "id"), ("customer_id", "customer_id")], + ), + inspected("customers", "customers", &[("id", "id"), ("name", "name")]), + ]; + + let plan = + build_database_structure_plan(&Source, &Target, &job(mappings), &inspected, &schemas) + .expect("two secondary indexes must be planned"); + + let kinds: Vec = plan.iter().map(|item| item.kind).collect(); + assert_eq!( + kinds, + vec![ + DdlPreviewKind::Table, + DdlPreviewKind::Table, + DdlPreviewKind::Index, + DdlPreviewKind::Index, + ] + ); + + let table_count = plan + .iter() + .filter(|item| item.kind == DdlPreviewKind::Table) + .count(); + for (position, item) in plan.iter().enumerate() { + if item.kind == DdlPreviewKind::Index { + assert!( + position >= table_count, + "index DDL must follow every CREATE TABLE: {item:?}" + ); + } + } + + let orders_index = plan + .iter() + .find(|item| item.ddl.contains("ix_orders_customer")) + .expect("orders index must be planned"); + assert_eq!(orders_index.kind, DdlPreviewKind::Index); + assert_eq!(orders_index.source_table, "orders"); + assert_eq!(orders_index.depends_on, vec!["orders"]); + assert!(orders_index.ddl.contains(r#"ON "archive"."orders""#)); + assert!(orders_index.ddl.contains(r#"("customer_id")"#)); + + let customers_index = plan + .iter() + .find(|item| item.ddl.contains("uq_customers_name")) + .expect("customers index must be planned"); + assert!(customers_index.ddl.starts_with("CREATE UNIQUE INDEX")); + assert_eq!(customers_index.source_table, "customers"); + assert_eq!(customers_index.depends_on, vec!["customers"]); + assert!(customers_index.ddl.contains(r#"ON "archive"."customers""#)); +} + +#[test] +fn a_primary_index_produces_no_index_statement_because_create_table_owns_it() { + let records = with_primary_index( + records_with_label_index("ix_records_label"), + "PK_records", + &["id"], + ); + let schemas = HashMap::from([("records".into(), records)]); + let mappings = vec![mapping( + "records", + "records", + &[("id", "id"), ("label", "label")], + )]; + let inspected = vec![inspected( + "records", + "records", + &[("id", "id"), ("label", "label")], + )]; + + let plan = + build_database_structure_plan(&Source, &Target, &job(mappings), &inspected, &schemas) + .expect("a primary key must not block the plan"); + + let index_items: Vec<_> = plan + .iter() + .filter(|item| item.kind == DdlPreviewKind::Index) + .collect(); + assert_eq!(index_items.len(), 1, "only the secondary index is emitted"); + assert!(index_items[0].ddl.contains("ix_records_label")); + assert!( + !plan.iter().any(|item| item.ddl.contains("PK_records")), + "the primary key belongs to CREATE TABLE, not to a separate CREATE INDEX" + ); + assert!(plan[0].kind == DdlPreviewKind::Table); + assert!(plan[0].ddl.contains("PRIMARY KEY"), "{}", plan[0].ddl); +} + +#[test] +fn a_foreign_key_is_emitted_after_both_tables_and_depends_on_the_referenced_table() { + let mut orders = schema("orders", &[("id", true), ("customer_id", false)]); + orders.foreign_keys.push(ForeignKeyInfo { + name: "fk_orders_customer".into(), + columns: vec!["customer_id".into()], + referenced_table: "public.customers".into(), + referenced_columns: vec!["id".into()], + on_update: "NO ACTION".into(), + on_delete: "NO ACTION".into(), + deferrability: ForeignKeyDeferrability::NotDeferrable, + }); + let customers = schema("customers", &[("id", true), ("name", false)]); + let schemas = HashMap::from([("orders".into(), orders), ("customers".into(), customers)]); + let mappings = vec![ + mapping( + "orders", + "orders", + &[("id", "id"), ("customer_id", "customer_id")], + ), + mapping("customers", "customers", &[("id", "id"), ("name", "name")]), + ]; + let inspected = vec![ + inspected( + "orders", + "orders", + &[("id", "id"), ("customer_id", "customer_id")], + ), + inspected("customers", "customers", &[("id", "id"), ("name", "name")]), + ]; + + let plan = + build_database_structure_plan(&Source, &Target, &job(mappings), &inspected, &schemas) + .expect("a resolvable foreign key must be planned"); + + let kinds: Vec = plan.iter().map(|item| item.kind).collect(); + assert_eq!( + kinds, + vec![ + DdlPreviewKind::Table, + DdlPreviewKind::Table, + DdlPreviewKind::ForeignKey, + ] + ); + + let last_table = plan + .iter() + .rposition(|item| item.kind == DdlPreviewKind::Table) + .expect("both tables are created"); + let fk_position = plan + .iter() + .position(|item| item.kind == DdlPreviewKind::ForeignKey) + .expect("foreign key is planned"); + assert!( + fk_position > last_table, + "the constraint follows every CREATE TABLE so it can be added with ALTER TABLE" + ); + + let fk = &plan[fk_position]; + assert_eq!(fk.source_table, "orders"); + assert_eq!(fk.target_table, "orders"); + assert_eq!(fk.depends_on, vec!["customers"]); + assert!( + fk.ddl.contains(r#"ALTER TABLE "archive"."orders""#), + "{}", + fk.ddl + ); + assert!( + fk.ddl + .contains(r#"REFERENCES "archive"."customers" ("id")"#), + "{}", + fk.ddl + ); +} + +#[test] +fn a_foreign_key_pointing_outside_the_selection_is_rejected_instead_of_dropped() { + let mut orders = schema("orders", &[("id", true), ("customer_id", false)]); + orders.foreign_keys.push(ForeignKeyInfo { + name: "fk_orders_customer".into(), + columns: vec!["customer_id".into()], + referenced_table: "public.customers".into(), + referenced_columns: vec!["id".into()], + on_update: "NO ACTION".into(), + on_delete: "NO ACTION".into(), + deferrability: ForeignKeyDeferrability::NotDeferrable, + }); + let schemas = HashMap::from([("orders".into(), orders)]); + let mappings = vec![mapping( + "orders", + "orders", + &[("id", "id"), ("customer_id", "customer_id")], + )]; + let inspected = vec![inspected( + "orders", + "orders", + &[("id", "id"), ("customer_id", "customer_id")], + )]; + + let error = + build_database_structure_plan(&Source, &Target, &job(mappings), &inspected, &schemas) + .expect_err("a foreign key to an unmigrated table must fail the whole plan"); + + let message = error.to_string(); + assert!( + message.contains("unselected or out-of-scope table"), + "{message}" + ); + assert!(message.contains("public.customers"), "{message}"); +} + +#[test] +fn duplicate_index_names_are_rejected_when_the_target_scopes_names_per_schema() { + let schemas = HashMap::from([ + ( + "orders".into(), + with_index( + schema("orders", &[("id", true), ("ref", false)]), + "idx_shared", + &["ref"], + false, + ), + ), + ( + "customers".into(), + with_index( + schema("customers", &[("id", true), ("code", false)]), + "idx_shared", + &["code"], + false, + ), + ), + ]); + let mappings = vec![ + mapping("orders", "orders", &[("id", "id"), ("ref", "ref")]), + mapping("customers", "customers", &[("id", "id"), ("code", "code")]), + ]; + let inspected = vec![ + inspected("orders", "orders", &[("id", "id"), ("ref", "ref")]), + inspected("customers", "customers", &[("id", "id"), ("code", "code")]), + ]; + + let error = + build_database_structure_plan(&Source, &Target, &job(mappings), &inspected, &schemas) + .expect_err("schema-scoped index names must not be emitted twice"); + + let message = error.to_string(); + assert!(message.contains("collides between"), "{message}"); + assert!( + message.contains("rename one source object before transfer"), + "{message}" + ); + assert!(message.contains("idx_shared"), "{message}"); + assert!( + message.contains("orders") && message.contains("customers"), + "{message}" + ); +} + +#[test] +fn duplicate_index_names_are_accepted_when_the_target_scopes_index_names_per_table() { + let schemas = HashMap::from([ + ( + "orders".into(), + with_index( + schema("orders", &[("id", true), ("ref", false)]), + "idx_shared", + &["ref"], + false, + ), + ), + ( + "customers".into(), + with_index( + schema("customers", &[("id", true), ("code", false)]), + "idx_shared", + &["code"], + false, + ), + ), + ]); + let mappings = vec![ + mapping("orders", "orders", &[("id", "id"), ("ref", "ref")]), + mapping("customers", "customers", &[("id", "id"), ("code", "code")]), + ]; + let inspected = vec![ + inspected("orders", "orders", &[("id", "id"), ("ref", "ref")]), + inspected("customers", "customers", &[("id", "id"), ("code", "code")]), + ]; + + let plan = build_database_structure_plan( + &Source, + &TableScopedIndexTarget, + &job(mappings), + &inspected, + &schemas, + ) + .expect("table-scoped index names may repeat across tables"); + + let index_items: Vec<_> = plan + .iter() + .filter(|item| item.kind == DdlPreviewKind::Index) + .collect(); + assert_eq!(index_items.len(), 2); + // Secondary objects are emitted in (target table, DDL) order, so the plan + // is deterministic even when both indexes share a name. + let customers_index = plan + .iter() + .find(|item| item.kind == DdlPreviewKind::Index && item.ddl.contains(r#""customers""#)) + .expect("customers index is planned"); + let orders_index = plan + .iter() + .find(|item| item.kind == DdlPreviewKind::Index && item.ddl.contains(r#""orders""#)) + .expect("orders index is planned"); + assert!(customers_index.ddl.contains(r#"ON "archive"."customers""#)); + assert!(orders_index.ddl.contains(r#"ON "archive"."orders""#)); + assert_eq!(customers_index.depends_on, vec!["customers"]); + assert_eq!(orders_index.depends_on, vec!["orders"]); + assert!(!customers_index.ddl.contains(r#""orders""#)); +} + +#[test] +fn index_names_that_differ_only_by_case_collide_on_a_case_insensitive_target() { + let schemas = HashMap::from([ + ( + "orders".into(), + with_index( + schema("orders", &[("id", true), ("ref", false)]), + "Idx_Shared", + &["ref"], + false, + ), + ), + ( + "customers".into(), + with_index( + schema("customers", &[("id", true), ("code", false)]), + "idx_shared", + &["code"], + false, + ), + ), + ]); + let mappings = vec![ + mapping("orders", "orders", &[("id", "id"), ("ref", "ref")]), + mapping("customers", "customers", &[("id", "id"), ("code", "code")]), + ]; + let inspected = vec![ + inspected("orders", "orders", &[("id", "id"), ("ref", "ref")]), + inspected("customers", "customers", &[("id", "id"), ("code", "code")]), + ]; + + let error = + build_database_structure_plan(&Source, &Target, &job(mappings), &inspected, &schemas) + .expect_err("a case-folding target such as SQL Server cannot carry both spellings"); + + let message = error.to_string(); + assert!(message.contains("collides between"), "{message}"); + assert!( + message.contains("rename one source object before transfer"), + "{message}" + ); +} + +#[test] +fn index_names_that_differ_only_by_case_coexist_on_a_case_sensitive_target() { + let schemas = HashMap::from([ + ( + "orders".into(), + with_index( + schema("orders", &[("id", true), ("ref", false)]), + "Idx_Shared", + &["ref"], + false, + ), + ), + ( + "customers".into(), + with_index( + schema("customers", &[("id", true), ("code", false)]), + "idx_shared", + &["code"], + false, + ), + ), + ]); + let mappings = vec![ + mapping("orders", "orders", &[("id", "id"), ("ref", "ref")]), + mapping("customers", "customers", &[("id", "id"), ("code", "code")]), + ]; + let inspected = vec![ + inspected("orders", "orders", &[("id", "id"), ("ref", "ref")]), + inspected("customers", "customers", &[("id", "id"), ("code", "code")]), + ]; + + let plan = build_database_structure_plan( + &Source, + &CaseSensitiveTarget, + &job(mappings), + &inspected, + &schemas, + ) + .expect("a case-preserving target keeps both spellings distinct"); + + let index_ddls: Vec<&str> = plan + .iter() + .filter(|item| item.kind == DdlPreviewKind::Index) + .map(|item| item.ddl.as_str()) + .collect(); + assert_eq!(index_ddls.len(), 2); + assert!(index_ddls.iter().any(|ddl| ddl.contains(r#""Idx_Shared""#))); + assert!(index_ddls.iter().any(|ddl| ddl.contains(r#""idx_shared""#))); +} + +#[test] +fn index_ddl_uses_the_renamed_target_column() { + let orders = with_index( + schema("orders", &[("id", true), ("user_id", false)]), + "ix_orders_user", + &["user_id"], + false, + ); + let schemas = HashMap::from([("orders".into(), orders)]); + let mappings = vec![mapping( + "orders", + "buyers", + &[("id", "id"), ("user_id", "buyer_id")], + )]; + let inspected = vec![inspected( + "orders", + "buyers", + &[("id", "id"), ("user_id", "buyer_id")], + )]; + + let plan = + build_database_structure_plan(&Source, &Target, &job(mappings), &inspected, &schemas) + .expect("a renamed column still yields an index"); + + let index_item = plan + .iter() + .find(|item| item.kind == DdlPreviewKind::Index) + .expect("secondary index is planned"); + assert!( + index_item.ddl.contains(r#"("buyer_id")"#), + "{}", + index_item.ddl + ); + assert!( + !index_item.ddl.contains("user_id"), + "the source name must never leak into target DDL: {}", + index_item.ddl + ); + assert!(plan[0].ddl.contains(r#""buyer_id""#), "{}", plan[0].ddl); +} + +#[test] +fn an_index_on_a_skipped_column_is_rejected_with_a_clear_message() { + let orders = with_index( + schema("orders", &[("id", true), ("user_id", false)]), + "ix_orders_user", + &["user_id"], + false, + ); + let schemas = HashMap::from([("orders".into(), orders)]); + let mappings = vec![mapping_with_skipped_column( + "orders", + "orders", + &[("id", "id"), ("user_id", "user_id")], + "user_id", + )]; + let inspected = vec![inspected( + "orders", + "orders", + &[("id", "id"), ("user_id", "user_id")], + )]; + + let error = + build_database_structure_plan(&Source, &Target, &job(mappings), &inspected, &schemas) + .expect_err("an index on an excluded column cannot be preserved"); + + let message = error.to_string(); + assert!( + message.contains("source constraint column 'user_id' is skipped"), + "{message}" + ); +} + +#[test] +fn a_prefix_length_index_is_rejected_instead_of_emitting_wrong_ddl() { + let mut records = schema("records", &[("id", true), ("name", false)]); + records.indexes.push(IndexInfo { + name: "ix_records_name_prefix".into(), + columns: vec!["name(10)".into()], + is_unique: false, + is_primary: false, + index_type: "btree".into(), + }); + let schemas = HashMap::from([("records".into(), records)]); + let inspected = vec![inspected( + "records", + "records", + &[("id", "id"), ("name", "name")], + )]; + + // Without a column mapping the catalog spelling reaches the target IR + // lookup first: `name(10)` is not a created column, so the plan fails + // closed before `render_index_ddl` is even consulted. Documented current + // behaviour - the wording names the key column but neither the index nor + // the "prefix length" cause. + let error = build_database_structure_plan( + &Source, + &Target, + &job(vec![mapping_without_columns("records", "records")]), + &inspected, + &schemas, + ) + .expect_err("a prefix-length index must not become a full-column index"); + + let message = error.to_string(); + assert!( + message.contains("mapped target index column 'name(10)' was not created"), + "{message}" + ); + assert!(!message.contains("CREATE INDEX"), "{message}"); +} + +#[test] +fn a_prefix_length_index_is_reported_as_a_missing_mapping_when_columns_are_mapped() { + let mut records = schema("records", &[("id", true), ("name", false)]); + records.indexes.push(IndexInfo { + name: "ix_records_name_prefix".into(), + columns: vec!["name(10)".into()], + is_unique: false, + is_primary: false, + index_type: "btree".into(), + }); + let schemas = HashMap::from([("records".into(), records)]); + let mappings = vec![mapping( + "records", + "records", + &[("id", "id"), ("name", "name")], + )]; + let inspected = vec![inspected( + "records", + "records", + &[("id", "id"), ("name", "name")], + )]; + + // Documented current behaviour: the column mapping is consulted before the + // renderer, so the user sees a mapping complaint instead of the precise + // "prefix lengths" rejection. Still fail-closed, but the wording hides the + // real cause. + let error = + build_database_structure_plan(&Source, &Target, &job(mappings), &inspected, &schemas) + .expect_err("an unmapped index key must still fail the plan"); + + let message = error.to_string(); + assert!( + message.contains("source constraint column 'name(10)' is not mapped"), + "{message}" + ); + assert!( + !message.contains("prefix"), + "the message hides the prefix-index cause: {message}" + ); +} From 93bd6bce94bd60135b5940234423be8e76d93629 Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 09:09:52 +0800 Subject: [PATCH 063/666] feat(sqlserver): stop rejecting transfer objects the plan can recreate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data transfer precheck for SQL Server rejected any table carrying a secondary index or a foreign key, but the host already builds IRTableObjects from those same catalog rows and emits them through render_index_ddl / render_foreign_key_ddl after every CREATE TABLE. The gate therefore refused objects Data Transfer already recreates on the target. Narrow unsupported_transfer_structure_query down to the three genuinely inexpressible kinds — computed columns (no IR concept), CHECK constraints (no emission path) and identity columns with a non-default seed/increment (auto_increment_keyword hardcodes IDENTITY(1,1)) — matching the granularity of the PostgreSQL adapter. Escaping invariants are unchanged. Removing the index branch exposed a second blocker: parse_indexes reports CLUSTERED / NONCLUSTERED / UNIQUE_CONSTRAINT:, while the shared render_index_ddl only accepts an ordinary B-tree index, and nothing in the host normalizes it, so every secondary index would have failed the plan instead of the precheck. Normalize that vocabulary in table_objects_to_ir, at the point where the catalog wording enters the IR, so all target adapters work — not only SQL Server itself. Foreign-key actions already fit the shared renderer, and a genuine SET DEFAULT still fails closed. Also report index names as table-scoped: SQL Server identifies an index by (object_id, index_id), so the same name may be reused across tables, while foreign keys remain schema-scoped objects in sys.objects. --- .../drivers/sqlserver/src/sync_adapter.rs | 135 +++++++++++++--- .../sqlserver/tests/sync_adapter_smoke.rs | 153 +++++++++++++++++- 2 files changed, 267 insertions(+), 21 deletions(-) diff --git a/packages/drivers/sqlserver/src/sync_adapter.rs b/packages/drivers/sqlserver/src/sync_adapter.rs index dcdb3d7b3..768de474c 100644 --- a/packages/drivers/sqlserver/src/sync_adapter.rs +++ b/packages/drivers/sqlserver/src/sync_adapter.rs @@ -2,8 +2,9 @@ use datazen_driver_api::sync::contract_from_column; use datazen_driver_api::{ - BoxedSyncAdapter, ColumnSchema, IRColumn, IRDefault, IRType, SyncAdapterFactory, - SyncKeyContract, SyncKeyKind, SyncSourceAdapter, SyncTargetAdapter, Value, + BoxedSyncAdapter, ColumnSchema, IRColumn, IRDefault, IRForeignKey, IRIndex, IRTableObjects, + IRType, SyncAdapterFactory, SyncKeyContract, SyncKeyKind, SyncSourceAdapter, SyncTargetAdapter, + TableSchema, Value, }; pub struct SqlServerSyncAdapter; @@ -93,6 +94,27 @@ fn base_type(raw: &str) -> String { lower } +/// Map SQL Server's catalog index-type vocabulary onto the dialect-neutral +/// index model used by the transfer IR. +/// +/// `CLUSTERED` / `NONCLUSTERED` — and the `UNIQUE_CONSTRAINT:` prefix the +/// catalog parser adds for constraint-backed unique indexes — describe +/// physical layout and constraint backing rather than a different index kind: +/// an ordinary `CREATE [UNIQUE] INDEX` reproduces the indexed columns and +/// uniqueness, which is exactly what the IR can carry. Anything else (gin, rum, +/// hash, …) is returned untouched so the shared renderer keeps rejecting index +/// methods it cannot express. +fn portable_index_type(raw: &str) -> String { + match raw.trim().to_ascii_uppercase().as_str() { + "" + | "CLUSTERED" + | "NONCLUSTERED" + | "UNIQUE_CONSTRAINT:CLUSTERED" + | "UNIQUE_CONSTRAINT:NONCLUSTERED" => String::new(), + _ => raw.to_string(), + } +} + fn native_type_name(raw: &str) -> &str { raw.trim() .split(|character: char| character == '(' || character.is_whitespace()) @@ -212,14 +234,70 @@ impl SyncSourceAdapter for SqlServerSyncAdapter { )) } + /// Mirror of the shared mapping, except that SQL Server's catalog index + /// types are translated into the dialect-neutral vocabulary the transfer + /// renderers understand (see [`portable_index_type`]). + /// + /// The shared `render_index_ddl` only accepts an ordinary B-tree index, so + /// leaving SQL Server's `CLUSTERED` / `NONCLUSTERED` vocabulary in place + /// would fail the whole structure plan for any secondary index. Doing the + /// translation here — at the point where the catalog vocabulary enters the + /// IR — keeps every target adapter working, not just SQL Server itself. + fn table_objects_to_ir(&self, schema: &TableSchema) -> IRTableObjects { + IRTableObjects { + indexes: schema + .indexes + .iter() + .map(|index| IRIndex { + name: index.name.clone(), + columns: index.columns.clone(), + is_unique: index.is_unique, + is_primary: index.is_primary, + index_type: portable_index_type(&index.index_type), + }) + .collect(), + foreign_keys: schema + .foreign_keys + .iter() + .map(|foreign_key| IRForeignKey { + name: foreign_key.name.clone(), + columns: foreign_key.columns.clone(), + referenced_table: foreign_key.referenced_table.clone(), + referenced_columns: foreign_key.referenced_columns.clone(), + on_update: foreign_key.on_update.clone(), + on_delete: foreign_key.on_delete.clone(), + }) + .collect(), + } + } + fn unsupported_transfer_structure_query( &self, database: &str, schema: Option<&str>, table: &str, ) -> Option { - // The current transfer plan preserves columns and PKs only, so refuse - // table-level objects that would otherwise disappear from the export. + // Refuse only table-level objects the transfer plan genuinely cannot + // recreate, so this gate keeps the same granularity as the PostgreSQL + // adapter: reject what is inexpressible, let through what the IR and + // the DDL emitters already carry. + // + // Secondary indexes and foreign keys are deliberately NOT listed. The + // host derives `IRTableObjects` from these same catalog rows and emits + // them through `render_index_ddl` / `render_foreign_key_ddl` after all + // CREATE TABLE statements, so rejecting them here would refuse objects + // Data Transfer already recreates on the target. Anything the catalog + // reader cannot model into `IndexInfo` / `ForeignKeyInfo` (filtered, + // INCLUDE or descending indexes; disabled or NOT FOR REPLICATION + // foreign keys) is already refused there, as is any remaining + // referential action the shared renderer cannot express (SET DEFAULT). + // + // What remains below is inexpressible by the IR model or by the SQL + // Server emitters: the model has no computed-column concept, neither + // emission path renders CHECK constraints, and + // `auto_increment_keyword()` hardcodes `IDENTITY(1,1)`, so a + // non-default seed or increment cannot be recreated. + // // Keep catalog qualifiers and user names safely quoted as identifiers // or literals, including `]` and apostrophes. let catalog = if database.trim().is_empty() { @@ -236,19 +314,6 @@ impl SyncSourceAdapter for SqlServerSyncAdapter { JOIN {catalog}sys.schemas s ON s.schema_id = t.schema_id \ WHERE s.name = N'{schema}' AND t.name = N'{table}' \ UNION ALL \ - SELECT CONCAT('secondary index ', i.name) \ - FROM {catalog}sys.indexes i \ - JOIN {catalog}sys.tables t ON t.object_id = i.object_id \ - JOIN {catalog}sys.schemas s ON s.schema_id = t.schema_id \ - WHERE s.name = N'{schema}' AND t.name = N'{table}' \ - AND i.index_id > 0 AND i.is_primary_key = 0 \ - UNION ALL \ - SELECT CONCAT('foreign key ', fk.name) \ - FROM {catalog}sys.foreign_keys fk \ - JOIN {catalog}sys.tables t ON t.object_id = fk.parent_object_id \ - JOIN {catalog}sys.schemas s ON s.schema_id = t.schema_id \ - WHERE s.name = N'{schema}' AND t.name = N'{table}' \ - UNION ALL \ SELECT CONCAT('CHECK constraint ', cc.name) \ FROM {catalog}sys.check_constraints cc \ JOIN {catalog}sys.tables t ON t.object_id = cc.parent_object_id \ @@ -461,6 +526,15 @@ impl SyncTargetAdapter for SqlServerSyncAdapter { true } + /// SQL Server identifies an index by `(object_id, index_id)`, not by a + /// schema-level naming object, so two different tables may reuse the same + /// index name. Reporting the wider namespace here stops the host's + /// `ensure_unique_object_name` from rejecting a plan that SQL Server would + /// accept. + fn index_names_are_table_scoped(&self) -> bool { + true + } + fn validate_transfer_column_type( &self, source_column: &ColumnSchema, @@ -834,17 +908,38 @@ mod tests { } #[test] - fn sqlserver_source_preflight_rejects_unmodeled_structure_objects() { + fn sqlserver_source_preflight_rejects_only_unrepresentable_structure_objects() { let query = SqlServerSyncAdapter .unsupported_transfer_structure_query("db]name", Some("sales' data"), "people's") .expect("SQL Server source preflight query"); + // Escaping invariants: catalog as a quoted identifier, schema/table as + // quoted literals. assert!(query.contains("[db]]name].sys.computed_columns")); assert!(query.contains("s.name = N'sales'' data'")); assert!(query.contains("t.name = N'people''s'")); - assert!(query.contains("secondary index ")); - assert!(query.contains("foreign key ")); + // Still rejected: no IR concept, no emission path, hardcoded IDENTITY. + assert!(query.contains("computed column ")); assert!(query.contains("CHECK constraint ")); assert!(query.contains("non-default seed/increment")); + // Not rejected: the host emits both from the same catalog rows through + // `render_index_ddl` / `render_foreign_key_ddl`. + assert!(!query.contains("secondary index ")); + assert!(!query.contains("foreign key ")); + assert!(!query.contains("sys.indexes")); + assert!(!query.contains("sys.foreign_keys")); + } + + #[test] + fn sqlserver_portable_index_type_maps_own_catalog_vocabulary_only() { + assert_eq!(portable_index_type("NONCLUSTERED"), ""); + assert_eq!(portable_index_type("CLUSTERED"), ""); + assert_eq!(portable_index_type("UNIQUE_CONSTRAINT:NONCLUSTERED"), ""); + assert_eq!(portable_index_type("UNIQUE_CONSTRAINT:CLUSTERED"), ""); + assert_eq!(portable_index_type(" "), ""); + // Other engines' index methods stay visible so the shared renderer + // keeps rejecting them. + assert_eq!(portable_index_type("btree"), "btree"); + assert_eq!(portable_index_type("GIN"), "GIN"); } #[test] diff --git a/packages/drivers/sqlserver/tests/sync_adapter_smoke.rs b/packages/drivers/sqlserver/tests/sync_adapter_smoke.rs index 3d0383a2e..dffe171de 100644 --- a/packages/drivers/sqlserver/tests/sync_adapter_smoke.rs +++ b/packages/drivers/sqlserver/tests/sync_adapter_smoke.rs @@ -1,6 +1,9 @@ //! SQL Server sync adapter smoke (no live server). -use datazen_driver_api::{ColumnSchema, IRType, SyncSourceAdapter, SyncTargetAdapter}; +use datazen_driver_api::{ + ColumnSchema, ForeignKeyDeferrability, ForeignKeyInfo, IRType, IndexInfo, SyncSourceAdapter, + SyncTargetAdapter, TableOptions, TableSchema, +}; use datazen_driver_sqlserver::SqlServerSyncAdapter; fn col(name: &str, data_type: &str) -> ColumnSchema { @@ -37,3 +40,151 @@ fn sqlserver_quote_ident_and_target_types() { assert_eq!(adapter.ir_type_to_native(&IRType::Int32), "INT"); assert_eq!(adapter.auto_increment_keyword(), Some("IDENTITY(1,1)")); } + +#[test] +fn sqlserver_index_types_are_normalized_into_the_portable_ir() { + let adapter = SqlServerSyncAdapter; + // A source index reported by the SQL Server catalog must survive the + // transfer structure plan instead of failing it closed. + let schema = table_schema_with_indexes(&[ + ( + "IX_orders_customer_id", + "customer_id", + false, + "NONCLUSTERED", + ), + ("PK_orders", "id", true, "CLUSTERED"), + ( + "AX_orders_total", + "total_cents", + false, + "UNIQUE_CONSTRAINT:NONCLUSTERED", + ), + ("IX_orders_slot", "slot", false, "CLUSTERED"), + ]); + let objects = adapter.table_objects_to_ir(&schema); + let rendered = objects + .indexes + .iter() + .map(|index| { + // Every translated index must be renderable by the shared renderer. + let ddl = adapter + .render_index_ddl("[dbo].[orders]", index) + .ok() + .flatten(); + (index.name.as_str(), index.index_type.as_str(), ddl) + }) + .collect::>(); + assert_eq!( + rendered, + vec![ + ( + "IX_orders_customer_id", + "", + Some( + "CREATE INDEX [IX_orders_customer_id] ON [dbo].[orders] ([customer_id])".into() + ) + ), + // Primary keys stay out of the standalone index statements. + ("PK_orders", "", None), + ( + "AX_orders_total", + "", + Some( + "CREATE UNIQUE INDEX [AX_orders_total] ON [dbo].[orders] ([total_cents])" + .into() + ) + ), + ( + "IX_orders_slot", + "", + Some("CREATE INDEX [IX_orders_slot] ON [dbo].[orders] ([slot])".into()) + ), + ] + ); +} + +#[test] +fn sqlserver_preserves_foreign_keys_for_the_transfer_ir() { + let adapter = SqlServerSyncAdapter; + let mut schema = table_schema_with_indexes(&[( + "IX_orders_customer_id", + "customer_id", + false, + "NONCLUSTERED", + )]); + schema.foreign_keys = vec![ForeignKeyInfo { + name: "FK_orders_customers".into(), + columns: vec!["customer_id".into()], + referenced_table: "[dbo].[customers]".into(), + referenced_columns: vec!["id".into()], + on_update: "NO ACTION".into(), + on_delete: "CASCADE".into(), + deferrability: ForeignKeyDeferrability::Unknown, + }]; + let objects = adapter.table_objects_to_ir(&schema); + assert_eq!(objects.indexes.len(), 1); + assert_eq!(objects.foreign_keys.len(), 1); + let foreign_key = &objects.foreign_keys[0]; + assert_eq!(foreign_key.name, "FK_orders_customers"); + assert_eq!(foreign_key.referenced_table, "[dbo].[customers]"); + assert_eq!(foreign_key.on_delete, "CASCADE"); + assert_eq!( + adapter.render_foreign_key_ddl("[dbo].[orders]", foreign_key, "[dbo].[customers]"), + Ok("ALTER TABLE [dbo].[orders] ADD CONSTRAINT [FK_orders_customers] FOREIGN KEY ([customer_id]) REFERENCES [dbo].[customers] ([id]) ON DELETE CASCADE".to_string()) + ); +} + +/// Build a table whose catalog-level indexes carry the index types that +/// `parse_indexes` really reports for SQL Server, so the transfer mapping is +/// exercised end to end. `is_unique` mirrors the catalog: only +/// `UNIQUE_CONSTRAINT:`-prefixed rows are unique indexes. +fn table_schema_with_indexes(indexes: &[(&str, &str, bool, &str)]) -> TableSchema { + TableSchema { + table_name: "orders".into(), + columns: vec![ + col("id", "int"), + col("customer_id", "int"), + col("total_cents", "int"), + col("slot", "int"), + ], + primary_keys: vec!["id".into()], + indexes: indexes + .iter() + .map(|(name, column, is_primary, index_type)| IndexInfo { + name: (*name).into(), + columns: vec![(*column).into()], + is_unique: index_type.starts_with("UNIQUE_CONSTRAINT"), + is_primary: *is_primary, + index_type: (*index_type).into(), + }) + .collect(), + foreign_keys: vec![], + check_constraints: vec![], + table_options: TableOptions::default(), + } +} + +#[test] +fn sqlserver_preflight_defaults_schema_and_catalog() { + let adapter = SqlServerSyncAdapter; + let query = adapter + .unsupported_transfer_structure_query(" ", None, "orders") + .expect("SQL Server source preflight query"); + // An empty database means no catalog prefix; the schema falls back to dbo. + assert!(query.contains("FROM sys.computed_columns c")); + assert!(query.contains("s.name = N'dbo'")); + assert!(query.contains("t.name = N'orders'")); + // Exactly the three inexpressible object kinds survive: two UNION ALL. + assert_eq!(query.matches("UNION ALL").count(), 2); +} + +#[test] +fn sqlserver_name_scoping_matches_catalog_semantics() { + let adapter = SqlServerSyncAdapter; + // Index names are per-table on SQL Server (`(object_id, index_id)`), while + // foreign keys are schema-level objects in `sys.objects` and keep the + // stricter schema-wide rule from the trait default. + assert!(adapter.index_names_are_table_scoped()); + assert!(!adapter.foreign_key_names_are_table_scoped()); +} From 6b71b0c9ace5ff25aad85470c099c0b1666acb5b Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 09:22:43 +0800 Subject: [PATCH 064/666] test(sqlserver): pin the transfer precheck gate and index normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock in the fail-closed structure precheck and the catalog index-type translation that commit 93bd6bce9 introduced, so neither can silently regress back into a permissive or over-rejecting gate. Drive the index tests from real parse_indexes output rather than hand-written index_type strings: the complete set of IndexInfo.index_type values production can emit is enumerated as a closed set, and each one is run through table_objects_to_ir plus the shared render_index_ddl. That keeps the catalog vocabulary and the portable mapping from drifting apart unnoticed, and proves the normalization is a translation rather than a blanket reset — a type the parser refuses (HASH) is still rejected, and a remotely referenced action the emitter cannot express (SET DEFAULT) still fails closed after its precheck branch was removed. The precheck test additionally pins the escaping invariants: catalog brackets doubled, schema/table apostrophes doubled, no prefix for a blank catalog, and exactly two UNION ALL separators so the removed index and foreign-key branches cannot come back through another catalog view. --- packages/drivers/sqlserver/src/metadata.rs | 237 ++++++++++++++++++ .../sqlserver/tests/sync_adapter_smoke.rs | 157 ++++++++++++ 2 files changed, 394 insertions(+) diff --git a/packages/drivers/sqlserver/src/metadata.rs b/packages/drivers/sqlserver/src/metadata.rs index 25e2e810a..b5cb729a7 100644 --- a/packages/drivers/sqlserver/src/metadata.rs +++ b/packages/drivers/sqlserver/src/metadata.rs @@ -527,6 +527,7 @@ fn incomplete(message: impl Into) -> DriverError { #[cfg(test)] mod tests { use super::*; + use datazen_driver_api::{TableOptions, TableSchema}; const DATABASE_COLLATION: &str = "SQL_Latin1_General_CP1_CI_AS"; @@ -1062,4 +1063,240 @@ mod tests { ForeignKeyDeferrability::NotDeferrable ); } + + /// One row in the `indexes_sql` projection order: index_id, name, + /// is_unique, is_primary_key, type_desc, is_unique_constraint, has_filter, + /// is_disabled, is_hypothetical, key_ordinal, column_name, + /// is_included_column, is_descending_key, data_space_type. + fn index_row( + id: i64, + name: &str, + unique: bool, + primary: bool, + type_desc: &str, + unique_constraint: bool, + column: &str, + ) -> Vec> { + vec![ + Some(Value::Integer(id)), + text(name), + bit(unique), + bit(primary), + text(type_desc), + bit(unique_constraint), + bit(false), + bit(false), + bit(false), + Some(Value::Integer(1)), + text(column), + bit(false), + bit(false), + text("FG"), + ] + } + + /// Render a parsed catalog index list through the Data Transfer IR mapping + /// and the shared `SyncTargetAdapter` index emitter, returning + /// `(name, IR index_type, rendered DDL)`. This keeps the test driven by + /// what `parse_indexes` really returns rather than by hand-written + /// `index_type` strings, so the catalog vocabulary and the transfer + /// normalization can never drift apart unnoticed. + fn transfer_index_ddl(indexes: Vec) -> Vec<(String, String, Option)> { + use crate::sync_adapter::SqlServerSyncAdapter; + use datazen_driver_api::SyncSourceAdapter as _; + use datazen_driver_api::SyncTargetAdapter as _; + + let schema = TableSchema { + table_name: "orders".into(), + columns: vec![], + primary_keys: vec!["id".into()], + indexes, + foreign_keys: vec![], + check_constraints: vec![], + table_options: TableOptions::default(), + }; + let objects = SqlServerSyncAdapter.table_objects_to_ir(&schema); + objects + .indexes + .iter() + .map(|index| { + let ddl = SqlServerSyncAdapter + .render_index_ddl("[dbo].[orders]", index) + .ok() + .flatten(); + (index.name.clone(), index.index_type.clone(), ddl) + }) + .collect() + } + + #[test] + fn parsed_catalog_index_types_all_render_in_the_transfer_plan() { + // A table whose primary key owns the clustered layout, so the only + // secondary index is nonclustered. + let clustered_pk = transfer_index_ddl( + parse_indexes(result(vec![ + index_row(1, "PK_orders", true, true, "CLUSTERED", false, "id"), + index_row( + 2, + "IX_orders_customer", + false, + false, + "NONCLUSTERED", + false, + "customer_id", + ), + ])) + .expect("parse clustered-pk indexes"), + ); + assert_eq!( + clustered_pk, + vec![ + ( + "PK_orders".to_string(), + String::new(), + // A primary key is never emitted as a standalone index. + None + ), + ( + "IX_orders_customer".to_string(), + String::new(), + Some( + "CREATE INDEX [IX_orders_customer] ON [dbo].[orders] ([customer_id])" + .to_string() + ) + ), + ] + ); + + // A heap table: the unique constraint is the clustered index, and the + // two `UNIQUE_CONSTRAINT:` vocabulary values must both be accepted. + let heap = transfer_index_ddl( + parse_indexes(result(vec![ + index_row( + 1, + "UQ_orders_email", + true, + false, + "CLUSTERED", + true, + "email", + ), + index_row( + 2, + "UQ_orders_code", + true, + false, + "NONCLUSTERED", + true, + "code", + ), + index_row( + 3, + "IX_orders_slot", + false, + false, + "NONCLUSTERED", + false, + "slot", + ), + ])) + .expect("parse heap indexes"), + ); + assert_eq!( + heap, + vec![ + ( + "UQ_orders_email".to_string(), + String::new(), + Some( + "CREATE UNIQUE INDEX [UQ_orders_email] ON [dbo].[orders] ([email])" + .to_string() + ) + ), + ( + "UQ_orders_code".to_string(), + String::new(), + // Exactly one UNIQUE keyword: the constraint prefix must not + // be re-emitted on top of `is_unique`. + Some( + "CREATE UNIQUE INDEX [UQ_orders_code] ON [dbo].[orders] ([code])" + .to_string() + ) + ), + ( + "IX_orders_slot".to_string(), + String::new(), + Some("CREATE INDEX [IX_orders_slot] ON [dbo].[orders] ([slot])".to_string()) + ), + ] + ); + } + + #[test] + fn parsed_index_type_domain_is_exactly_the_four_translated_variants() { + // Every `type_desc` the parser accepts, crossed with the + // `is_unique_constraint` flag, defines the complete set of + // `IndexInfo.index_type` values production can produce. Each one must + // normalize to the empty string, otherwise the shared renderer rejects + // the whole structure plan. + let mut produced = Vec::new(); + for (type_desc, unique_constraint) in [ + ("CLUSTERED", false), + ("CLUSTERED", true), + ("NONCLUSTERED", false), + ("NONCLUSTERED", true), + ] { + let indexes = parse_indexes(result(vec![index_row( + 1, + "IX_orders_probe", + unique_constraint, + false, + type_desc, + unique_constraint, + "customer_id", + )])) + .expect("parse probe index"); + let produced_type = indexes[0].index_type.clone(); + let rendered = transfer_index_ddl(indexes); + assert_eq!( + rendered[0].1, "", + "{type_desc}/{unique_constraint} did not normalize to the portable type" + ); + assert!( + rendered[0].2.is_some(), + "{type_desc}/{unique_constraint} produced no CREATE INDEX statement" + ); + produced.push(produced_type); + } + assert_eq!( + produced, + vec![ + "CLUSTERED", + "UNIQUE_CONSTRAINT:CLUSTERED", + "NONCLUSTERED", + "UNIQUE_CONSTRAINT:NONCLUSTERED", + ] + ); + } + + #[test] + fn unsupported_catalog_index_type_is_rejected_before_the_transfer_mapping() { + // The normalization must stay a translation of SQL Server's own + // vocabulary, not a blanket reset: a type the parser refuses is still + // a hard error instead of being silently downgraded. + let error = parse_indexes(result(vec![index_row( + 1, + "IX_orders_hash", + false, + false, + "HASH", + false, + "customer_id", + )])) + .expect_err("HASH index must not parse"); + assert!( + error.to_string().contains("unsupported type 'HASH'"), + "unexpected rejection: {error}" + ); + } } diff --git a/packages/drivers/sqlserver/tests/sync_adapter_smoke.rs b/packages/drivers/sqlserver/tests/sync_adapter_smoke.rs index dffe171de..5d8f365e7 100644 --- a/packages/drivers/sqlserver/tests/sync_adapter_smoke.rs +++ b/packages/drivers/sqlserver/tests/sync_adapter_smoke.rs @@ -188,3 +188,160 @@ fn sqlserver_name_scoping_matches_catalog_semantics() { assert!(adapter.index_names_are_table_scoped()); assert!(!adapter.foreign_key_names_are_table_scoped()); } + +#[test] +fn sqlserver_renders_every_catalog_index_type_without_manual_translation() { + // The four `index_type` values `parse_indexes` can produce, each with the + // `is_unique` flag the catalog would report for it. Whatever the parser + // emits must already be renderable, so no caller has to pre-translate. + let adapter = SqlServerSyncAdapter; + let schema = table_schema_with_indexes(&[ + ("IX_a", "customer_id", false, "NONCLUSTERED"), + ("IX_b", "slot", false, "CLUSTERED"), + ("UQ_c", "email", false, "UNIQUE_CONSTRAINT:NONCLUSTERED"), + ("UQ_d", "code", false, "UNIQUE_CONSTRAINT:CLUSTERED"), + ]); + let rendered = adapter + .table_objects_to_ir(&schema) + .indexes + .iter() + .map(|index| { + let ddl = adapter + .render_index_ddl("[dbo].[orders]", index) + .expect("every catalog index type must render"); + ( + index.name.clone(), + index.index_type.clone(), + ddl.expect("a secondary index renders a CREATE INDEX statement"), + ) + }) + .collect::>(); + assert_eq!( + rendered, + vec![ + ( + "IX_a".to_string(), + String::new(), + "CREATE INDEX [IX_a] ON [dbo].[orders] ([customer_id])".to_string() + ), + ( + "IX_b".to_string(), + String::new(), + "CREATE INDEX [IX_b] ON [dbo].[orders] ([slot])".to_string() + ), + ( + "UQ_c".to_string(), + String::new(), + // `is_unique` is the only source of UNIQUE; the catalog prefix + // must not add a second one. + "CREATE UNIQUE INDEX [UQ_c] ON [dbo].[orders] ([email])".to_string() + ), + ( + "UQ_d".to_string(), + String::new(), + "CREATE UNIQUE INDEX [UQ_d] ON [dbo].[orders] ([code])".to_string() + ), + ] + ); + for (_, _, ddl) in &rendered { + assert_eq!( + ddl.matches("UNIQUE").count(), + usize::from(ddl.contains("UNIQUE")), + "expected at most one UNIQUE keyword in {ddl}" + ); + } +} + +#[test] +fn sqlserver_untranslatable_index_type_still_fails_closed_in_the_renderer() { + // The mapping translates SQL Server's own vocabulary; it must not act as a + // blanket reset that would let an index kind the target cannot express + // through silently. + let adapter = SqlServerSyncAdapter; + let schema = table_schema_with_indexes(&[("IX_orders_hash", "customer_id", false, "HASH")]); + let objects = adapter.table_objects_to_ir(&schema); + assert_eq!(objects.indexes[0].index_type, "HASH"); + let error = adapter + .render_index_ddl("[dbo].[orders]", &objects.indexes[0]) + .expect_err("an untranslated index type must be rejected"); + assert!( + error.contains("cannot represent index type 'HASH'"), + "unexpected rejection: {error}" + ); +} + +#[test] +fn sqlserver_precheck_escapes_catalog_schema_and_table_names() { + let adapter = SqlServerSyncAdapter; + // A catalog name with a bracket must be double-bracketed, and quote + // characters in schema/table must be doubled so the precheck cannot be + // closed early by the identifier it is inspecting. + let query = adapter + .unsupported_transfer_structure_query("db]", Some("sales data"), "people's") + .expect("SQL Server source preflight query"); + assert!( + query.contains("FROM [db]]].sys.computed_columns"), + "{query}" + ); + assert!(query.contains("s.name = N'sales data'"), "{query}"); + assert!(query.contains("t.name = N'people''s'"), "{query}"); + + // An explicit non-default schema is used verbatim; only an absent schema + // falls back to dbo. + let explicit = adapter + .unsupported_transfer_structure_query("sales", Some("archive"), "orders") + .expect("SQL Server source preflight query"); + assert!( + explicit.contains("FROM [sales].sys.computed_columns"), + "{explicit}" + ); + assert!(explicit.contains("s.name = N'archive'"), "{explicit}"); + + // Blank catalog and absent schema: no leading dot, dbo default, and the + // three retained branches stay joined by exactly two UNION ALL. + for (database, schema) in [("", None), (" ", Some("dbo"))] { + let query = adapter + .unsupported_transfer_structure_query(database, schema, "orders") + .expect("SQL Server source preflight query"); + assert!(query.contains("FROM sys.computed_columns"), "{query}"); + assert!(!query.contains("FROM ."), "{query}"); + assert!(query.contains("s.name = N'dbo'"), "{query}"); + assert!(!query.contains("s.name = N''"), "{query}"); + assert_eq!(query.matches("UNION ALL").count(), 2, "{query}"); + // The removed branches must not come back through another catalog view. + assert!(!query.contains("sys.indexes"), "{query}"); + assert!(!query.contains("sys.foreign_keys"), "{query}"); + } +} + +#[test] +fn sqlserver_removed_foreign_key_branch_keeps_foreign_keys_fail_closed() { + // Dropping the foreign-key precheck row must not make an inexpressible + // referential action renderable: the shared emitter is the last gate. + let adapter = SqlServerSyncAdapter; + let mut schema = table_schema_with_indexes(&[]); + schema.foreign_keys = vec![ForeignKeyInfo { + name: "FK_orders_customers".into(), + columns: vec!["customer_id".into()], + referenced_table: "[dbo].[customers]".into(), + referenced_columns: vec!["id".into()], + on_update: "NO ACTION".into(), + // `normalize_action` turns SQL Server's `SET_DEFAULT` into this. + on_delete: "SET DEFAULT".into(), + deferrability: ForeignKeyDeferrability::NotDeferrable, + }]; + let objects = adapter.table_objects_to_ir(&schema); + assert_eq!(objects.foreign_keys.len(), 1); + assert_eq!(objects.foreign_keys[0].on_delete, "SET DEFAULT"); + let error = adapter + .render_foreign_key_ddl( + "[dbo].[orders]", + &objects.foreign_keys[0], + "[dbo].[customers]", + ) + .expect_err("SET DEFAULT must stay unrenderable"); + assert!( + error.contains("cannot represent foreign-key action 'SET DEFAULT'"), + "unexpected rejection: {error}" + ); +} From ee49a04af59f2aa1d55c12c253f58f23b79afa0f Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 09:24:03 +0800 Subject: [PATCH 065/666] fix(sqlserver): keep a blank schema from turning the precheck fail-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unsupported_transfer_structure_query fell back to `dbo` only when the schema was absent, so an explicit Some("") rendered `s.name = N''`. That predicate matches no row in sys.schemas, the precheck reported zero unsupported objects, and Data Transfer admitted every table unchecked — the exact fail-open inverse of what this gate exists to prevent. Today the hazard is unreachable: the only caller passes the output of Endpoint::normalized_schema(), which yields either None or an already trimmed non-empty string. This is defense in depth for the next caller, so treat blank as absent explicitly. Trim before the emptiness check rather than after, so a whitespace-only schema takes the same path as an absent one, and a padded real schema name resolves instead of silently matching nothing. Apostrophe escaping still runs after the fallback decision, and the catalog, table and branch count are untouched. --- .../drivers/sqlserver/src/sync_adapter.rs | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/drivers/sqlserver/src/sync_adapter.rs b/packages/drivers/sqlserver/src/sync_adapter.rs index 768de474c..b0ff739b2 100644 --- a/packages/drivers/sqlserver/src/sync_adapter.rs +++ b/packages/drivers/sqlserver/src/sync_adapter.rs @@ -305,7 +305,15 @@ impl SyncSourceAdapter for SqlServerSyncAdapter { } else { format!("[{}].", database.replace(']', "]]")) }; - let schema = schema.unwrap_or("dbo").replace('\'', "''"); + // A blank schema must fall back to `dbo`, exactly as an absent one + // does. Emitting `s.name = N''` instead would match no catalog row, so + // the precheck would report zero unsupported objects and silently + // admit every table — turning this fail-closed gate into fail-open. + let schema = schema + .map(str::trim) + .filter(|schema| !schema.is_empty()) + .unwrap_or("dbo") + .replace('\'', "''"); let table = table.replace('\'', "''"); Some(format!( "SELECT CONCAT('computed column ', c.name) AS unsupported_object \ @@ -929,6 +937,51 @@ mod tests { assert!(!query.contains("sys.foreign_keys")); } + #[test] + fn sqlserver_source_preflight_treats_a_blank_schema_as_absent() { + // `s.name = N''` matches no catalog row, so a blank schema would make + // the precheck report zero unsupported objects and silently admit + // every table. It must fall back to `dbo` instead. + for schema in ["", " ", "\t\n "] { + let query = SqlServerSyncAdapter + .unsupported_transfer_structure_query("sales", Some(schema), "orders") + .expect("SQL Server source preflight query"); + assert!(query.contains("s.name = N'dbo'"), "{schema:?}: {query}"); + assert!(!query.contains("N''"), "{schema:?}: {query}"); + assert!( + query.contains("FROM [sales].sys.computed_columns"), + "{schema:?}: {query}" + ); + } + + // The absent-schema fallback is unchanged, and a legitimate schema + // name keeps its interior space: trimming must not damage a real + // identifier. + let absent = SqlServerSyncAdapter + .unsupported_transfer_structure_query("sales", None, "orders") + .expect("SQL Server source preflight query"); + assert!(absent.contains("s.name = N'dbo'"), "{absent}"); + + let named = SqlServerSyncAdapter + .unsupported_transfer_structure_query("sales", Some("sales data"), "orders") + .expect("SQL Server source preflight query"); + assert!(named.contains("s.name = N'sales data'"), "{named}"); + assert!(!named.contains("s.name = N''"), "{named}"); + + // Trimming is only a fallback decision; a padded real schema name is + // still quoted verbatim around its trimmed value. + let padded = SqlServerSyncAdapter + .unsupported_transfer_structure_query("sales", Some(" sales "), "orders") + .expect("SQL Server source preflight query"); + assert!(padded.contains("s.name = N'sales'"), "{padded}"); + + // Apostrophe escaping still applies after the fallback decision. + let quoted = SqlServerSyncAdapter + .unsupported_transfer_structure_query("sales", Some(" o'brien "), "orders") + .expect("SQL Server source preflight query"); + assert!(quoted.contains("s.name = N'o''brien'"), "{quoted}"); + } + #[test] fn sqlserver_portable_index_type_maps_own_catalog_vocabulary_only() { assert_eq!(portable_index_type("NONCLUSTERED"), ""); From 7f35e1932c7668e808e7ee295b6b8aedb1f5d3a6 Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 09:25:54 +0800 Subject: [PATCH 066/666] =?UTF-8?q?fix(tsconfig):=20=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=A3=80=E6=9F=A5=E5=8F=AA=E8=A6=86=E7=9B=96?= =?UTF-8?q?=20path=20=E9=A9=B1=E5=8A=A8=EF=BC=8C=E4=B8=8D=E6=89=AB=20git?= =?UTF-8?q?=20=E9=A9=B1=E5=8A=A8=E5=85=8B=E9=9A=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `include` 里的 `packages/drivers/*/ui` 是通配,会把 resolve-drivers.mjs 克隆到 packages/drivers// 的 git 驱动一并纳入。TypeScript 的 exclude 不读 .gitignore,所以 kiwi / olap / superset 这类非 Cargo workspace member、gitignored 的外部插件代码会被宿主门禁类型检查。 本机克隆了 superset 后,SupersetSchemaTree.tsx 的 3 个 TS2554 报错就让 `pnpm typecheck` 失败,而工作区(无克隆)却是干净的——门禁结果取决于本机 克隆了哪些驱动。 改为按 drivers-registry.json 中有 `path` 键的驱动逐个点名(15 个), 与 .gitignore:99-131 已有的白名单保持同一口径。用白名单而非黑名单:新增 git 驱动默认不进入宿主门禁,需要时显式放行。 实测:主检出区(存在 superset 克隆)`tsc --noEmit` EXIT=0; `--listFiles` 确认 15 个 path 驱动 UI 覆盖完整,kiwi/superset 已排除。 --- tsconfig.json | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 462fed1f5..074346d29 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,27 @@ "@datazen/ui": ["./packages/ui/src/index.ts"] } }, - "include": ["src", "packages/driver-sdk", "packages/extension-points", "packages/wapp-sdk", "packages/ui", "packages/drivers/*/ui"], + "include": [ + "src", + "packages/driver-sdk", + "packages/extension-points", + "packages/wapp-sdk", + "packages/ui", + "packages/drivers/clickhouse/ui", + "packages/drivers/duckdb/ui", + "packages/drivers/elasticsearch/ui", + "packages/drivers/hbase/ui", + "packages/drivers/influxdb/ui", + "packages/drivers/mongodb/ui", + "packages/drivers/mysql/ui", + "packages/drivers/postgres/ui", + "packages/drivers/redis/ui", + "packages/drivers/rqlite/ui", + "packages/drivers/sqlite/ui", + "packages/drivers/sqlserver/ui", + "packages/drivers/turso/ui", + "packages/drivers/vector/ui", + "packages/drivers/victoriametrics/ui" + ], "references": [{ "path": "./tsconfig.node.json" }] } From a36f9c504a418f7289eba3cd52fe04c7730faa19 Mon Sep 17 00:00:00 2001 From: probe Date: Thu, 1 Oct 2026 09:36:51 +0800 Subject: [PATCH 067/666] =?UTF-8?q?test(tester):=20=E8=A1=A5=E9=BD=90=20sq?= =?UTF-8?q?lserver=20=E5=8F=98=E4=BD=93=EF=BC=8C=E4=BF=AE=E6=8E=89?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=9F=A9=E9=98=B5=E6=BC=8F=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 根因 `tester_tunnelValidationMatrix.test.tsx` 的穷尽性守卫断言 `VARIANTS[].formVariant` 的取值集合必须等于 `DB_REGISTRY` 中实际注册的 `connectionForm` 集合。sqlserver 是 `drivers-registry.json` 里的 **path 驱动**(`packages/drivers/sqlserver`,git 跟踪、已进 codegen),其 `meta.ts` 声明 `connectionForm: 'sqlserver'`,因此它一定会出现在 `DB_REGISTRY` 里;但矩阵里从未为它建条目,于是守卫长期红灯。 之所以迟迟没被发现:`src/extensions/generated.ts` 是 gitignored 的 codegen。新建 worktree 时该文件不存在(或从主检出拷贝到的是不含 sqlserver 的旧产物),`DB_REGISTRY` 退化为静态三项,断言恰好通过。 一旦以 `--drivers=all` 重新 codegen,真相立刻暴露。 ## 修法 按同级条目的既有写法在 `VARIANTS` 末尾补一条 sqlserver: - `formVariant: 'sqlserver'`、`databaseType: 'sqlserver'` - `hasDriverValidator: false` —— sqlserver 只注册了 driver form 组件 (`SqlServerConnectionFields`),没有注册 driver validator,所以它同时 满足 `isDriverForm === true` 且拿不到 `getDriverValidator()` 的返回值; 这正是 `standard` / `file` 与 `redis` 的分界所在 - `supportsSSH: true` —— 对齐 `sqlserverMeta.supportsSSH` - `base` 沿用 `standard` 条目的 host/port/database 三字段写法,取 sqlserver 的真实默认值(1433 / master / sa) 追加在末尾而非插入中间,是为了让第 277、294 行按**下标**引用 `VARIANTS[0]` / `VARIANTS[1]` 的既有用例保持原指向不变。 ## 附带发现(本次不改,见下) 矩阵补全后 7 条 sqlserver 用例**首跑即绿**,未暴露 tester 的隧道校验缺陷。 但过程中确认了一个既有缺陷:sqlserver 的连接表单用 `