feat: continue tree rust family cutover
- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker - route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans - preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
Generated
+53
@@ -481,6 +481,15 @@ version = "2.10.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deranged"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
dependencies = [
|
||||||
|
"powerfmt",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive-where"
|
name = "derive-where"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
@@ -1410,6 +1419,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"storage-convex-bridge",
|
"storage-convex-bridge",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -1432,6 +1442,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oco_ref"
|
name = "oco_ref"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -1519,6 +1535,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -2341,6 +2363,37 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time"
|
||||||
|
version = "0.3.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||||
|
dependencies = [
|
||||||
|
"deranged",
|
||||||
|
"itoa",
|
||||||
|
"num-conv",
|
||||||
|
"powerfmt",
|
||||||
|
"serde_core",
|
||||||
|
"time-core",
|
||||||
|
"time-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-core"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "time-macros"
|
||||||
|
version = "0.2.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||||
|
dependencies = [
|
||||||
|
"num-conv",
|
||||||
|
"time-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
use std::io::{self, Read};
|
use std::io::{self, Read};
|
||||||
|
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
|
build_artifact_success_response, build_failure_response, build_success_response,
|
||||||
|
execute_runtime_command_artifact, execute_runtime_input, execute_runtime_query,
|
||||||
runtime_input_requests_result, RuntimeFailure, RuntimeInput,
|
runtime_input_requests_result, RuntimeFailure, RuntimeInput,
|
||||||
};
|
};
|
||||||
use storage_convex_bridge::{BridgeError, BridgeErrorKind};
|
use storage_convex_bridge::{BridgeError, BridgeErrorKind};
|
||||||
@@ -26,7 +27,16 @@ fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if runtime_input_requests_result(&runtime_input) {
|
if matches!(runtime_input, RuntimeInput::CommandArtifact { .. }) {
|
||||||
|
match execute_runtime_command_artifact(runtime_input) {
|
||||||
|
Ok(artifacts) => {
|
||||||
|
let payload = serde_json::to_string(&build_artifact_success_response(artifacts))
|
||||||
|
.expect("bridge runtime artifact 成功响应必须可序列化");
|
||||||
|
println!("{payload}");
|
||||||
|
}
|
||||||
|
Err(error) => emit_failure(error),
|
||||||
|
}
|
||||||
|
} else if runtime_input_requests_result(&runtime_input) {
|
||||||
match execute_runtime_query(runtime_input) {
|
match execute_runtime_query(runtime_input) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let payload = serde_json::to_string(&serde_json::json!({
|
let payload = serde_json::to_string(&serde_json::json!({
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ pub struct KernelProjectionFilter {
|
|||||||
pub node_types: Vec<KernelNodeType>,
|
pub node_types: Vec<KernelNodeType>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub edge_types: Vec<KernelEdgeType>,
|
pub edge_types: Vec<KernelEdgeType>,
|
||||||
|
pub query: Option<String>,
|
||||||
|
pub max_results: Option<usize>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub include_deleted: bool,
|
pub include_deleted: bool,
|
||||||
}
|
}
|
||||||
@@ -526,6 +528,8 @@ mod tests {
|
|||||||
filters: KernelProjectionFilter {
|
filters: KernelProjectionFilter {
|
||||||
node_types: vec![KernelNodeType::Page, KernelNodeType::Folder],
|
node_types: vec![KernelNodeType::Page, KernelNodeType::Folder],
|
||||||
edge_types: vec![KernelEdgeType::ParentOf],
|
edge_types: vec![KernelEdgeType::ParentOf],
|
||||||
|
query: Some("预算".into()),
|
||||||
|
max_results: Some(20),
|
||||||
include_deleted: false,
|
include_deleted: false,
|
||||||
},
|
},
|
||||||
include_content: false,
|
include_content: false,
|
||||||
@@ -535,6 +539,8 @@ mod tests {
|
|||||||
let value = serde_json::to_value(&request).expect("request 应可序列化");
|
let value = serde_json::to_value(&request).expect("request 应可序列化");
|
||||||
assert_eq!(value["projection"], json!("sidebar_tree"));
|
assert_eq!(value["projection"], json!("sidebar_tree"));
|
||||||
assert_eq!(value["subtree"]["rootNodeId"], json!("page_root"));
|
assert_eq!(value["subtree"]["rootNodeId"], json!("page_root"));
|
||||||
|
assert_eq!(value["filters"]["query"], json!("预算"));
|
||||||
|
assert_eq!(value["filters"]["maxResults"], json!(20));
|
||||||
|
|
||||||
let decoded: KernelProjectionRequest =
|
let decoded: KernelProjectionRequest =
|
||||||
serde_json::from_value(value).expect("request 应可反序列化");
|
serde_json::from_value(value).expect("request 应可反序列化");
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ tracing = "0.1"
|
|||||||
tracing-subscriber = { version = "0.3", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["fmt"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
|
use axum::Json;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::http::{HeaderName, HeaderValue};
|
use axum::http::{HeaderName, HeaderValue};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::Json;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -68,6 +68,10 @@ impl WebError {
|
|||||||
self.headers.push((name, value.into()));
|
self.headers.push((name, value.into()));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
&self.message
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for WebError {
|
impl IntoResponse for WebError {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
use crate::app::AppConfig;
|
use crate::app::AppConfig;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::transport::convex::execute_convex_command_plan;
|
use crate::transport::convex::{
|
||||||
|
ConvexCommandExecution, execute_convex_command_plan, execute_convex_command_plan_with_artifacts,
|
||||||
|
};
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||||
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
|
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
|
||||||
RuntimeTargetWire,
|
RuntimeTargetWire, execute_runtime_input,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -66,6 +68,27 @@ pub async fn execute_runtime_command_via_convex(
|
|||||||
execute_convex_command_plan(config, context, &plan).await
|
execute_convex_command_plan(config, context, &plan).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn execute_runtime_command_via_convex_with_artifacts(
|
||||||
|
config: &AppConfig,
|
||||||
|
context: &RequestContext,
|
||||||
|
effective_workspace_id: Option<&str>,
|
||||||
|
command: RuntimeCommandEnvelopeWire,
|
||||||
|
) -> Result<ConvexCommandExecution, WebError> {
|
||||||
|
let runtime_context = runtime_context(context, effective_workspace_id);
|
||||||
|
let runtime_input = RuntimeInput::Command {
|
||||||
|
context: runtime_context.clone(),
|
||||||
|
command: command.clone(),
|
||||||
|
};
|
||||||
|
let RuntimeExecutionPlan::Command(plan) = execute_runtime_input(runtime_input)
|
||||||
|
.map_err(|error| WebError::bad_request(error.message).with_context(context))?
|
||||||
|
else {
|
||||||
|
return Err(WebError::internal("runtime command 未返回 command plan").with_context(context));
|
||||||
|
};
|
||||||
|
|
||||||
|
execute_convex_command_plan_with_artifacts(config, context, &runtime_context, &command, &plan)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_tree_target(
|
pub fn build_tree_target(
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
page_id: Option<&str>,
|
page_id: Option<&str>,
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ pub async fn next_sidebar(
|
|||||||
workspace_id: &effective_workspace_id,
|
workspace_id: &effective_workspace_id,
|
||||||
root_node_id: None,
|
root_node_id: None,
|
||||||
depth: None,
|
depth: None,
|
||||||
|
query: None,
|
||||||
|
max_results: None,
|
||||||
projection: KernelProjectionKind::SidebarTree,
|
projection: KernelProjectionKind::SidebarTree,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ pub struct KernelProjectionQuery {
|
|||||||
pub workspace_id: Option<String>,
|
pub workspace_id: Option<String>,
|
||||||
pub root_node_id: Option<String>,
|
pub root_node_id: Option<String>,
|
||||||
pub depth: Option<u32>,
|
pub depth: Option<u32>,
|
||||||
|
pub query: Option<String>,
|
||||||
|
pub max_results: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -73,6 +75,8 @@ async fn project_projection(
|
|||||||
workspace_id: &effective_workspace_id,
|
workspace_id: &effective_workspace_id,
|
||||||
root_node_id: query.root_node_id.as_deref(),
|
root_node_id: query.root_node_id.as_deref(),
|
||||||
depth: query.depth,
|
depth: query.depth,
|
||||||
|
query: query.query.as_deref(),
|
||||||
|
max_results: query.max_results,
|
||||||
projection,
|
projection,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -388,4 +392,64 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|value| value == "expand"));
|
.any(|value| value == "expand"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn file_tree_projection_query_returns_matches_and_ancestors() {
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root&query=%E9%A2%84%E7%AE%97")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
|
let items = payload["result"]["items"].as_array().expect("items");
|
||||||
|
let row_ids = items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item["rowId"].as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(row_ids, vec!["doc:page_root", "asset:table_1"]);
|
||||||
|
assert_eq!(items[0]["expandedByDefault"], true);
|
||||||
|
assert_eq!(items[1]["resourceMeta"]["resourceKind"], "table");
|
||||||
|
let edges = payload["result"]["edges"].as_array().expect("edges");
|
||||||
|
assert_eq!(edges.len(), 1);
|
||||||
|
assert_eq!(edges[0]["fromNodeId"], "page_root");
|
||||||
|
assert_eq!(edges[0]["toNodeId"], "asset:table_1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn file_tree_projection_query_honors_max_results_and_keeps_ancestors() {
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root&query=png&maxResults=1")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
|
let items = payload["result"]["items"].as_array().expect("items");
|
||||||
|
let row_ids = items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item["rowId"].as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(row_ids, vec!["doc:page_root", "asset:asset_file_1"]);
|
||||||
|
assert_eq!(items[0]["expandedByDefault"], true);
|
||||||
|
assert_eq!(items[1]["resourceMeta"]["assetKind"], "image");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ pub struct ProjectionSnapshotSpec<'a> {
|
|||||||
pub workspace_id: &'a str,
|
pub workspace_id: &'a str,
|
||||||
pub root_node_id: Option<&'a str>,
|
pub root_node_id: Option<&'a str>,
|
||||||
pub depth: Option<u32>,
|
pub depth: Option<u32>,
|
||||||
|
pub query: Option<&'a str>,
|
||||||
|
pub max_results: Option<usize>,
|
||||||
pub projection: KernelProjectionKind,
|
pub projection: KernelProjectionKind,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +55,8 @@ pub fn projection_query(spec: &ProjectionSnapshotSpec<'_>) -> RuntimeQueryEnvelo
|
|||||||
"workspaceId": spec.workspace_id,
|
"workspaceId": spec.workspace_id,
|
||||||
"rootNodeId": spec.root_node_id,
|
"rootNodeId": spec.root_node_id,
|
||||||
"depth": spec.depth,
|
"depth": spec.depth,
|
||||||
|
"query": spec.query,
|
||||||
|
"maxResults": spec.max_results,
|
||||||
"includeEdges": true,
|
"includeEdges": true,
|
||||||
"includeContent": false,
|
"includeContent": false,
|
||||||
"nodeTypes": [KernelNodeType::Page],
|
"nodeTypes": [KernelNodeType::Page],
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ pub async fn events(
|
|||||||
&workspace_id,
|
&workspace_id,
|
||||||
&overview,
|
&overview,
|
||||||
change.cursor,
|
change.cursor,
|
||||||
change.delta.unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
|
change
|
||||||
|
.delta
|
||||||
|
.unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
|
||||||
);
|
);
|
||||||
return Some((Ok(stream_event("delta", &payload)), Some(state)));
|
return Some((Ok(stream_event("delta", &payload)), Some(state)));
|
||||||
}
|
}
|
||||||
@@ -95,8 +97,7 @@ pub async fn events(
|
|||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
state.query = next_query;
|
state.query = next_query;
|
||||||
state.current_cursor =
|
state.current_cursor = read_stream_cursor_from_payload(&snapshot_payload);
|
||||||
read_stream_cursor_from_payload(&snapshot_payload);
|
|
||||||
return Some((
|
return Some((
|
||||||
Ok(stream_event(
|
Ok(stream_event(
|
||||||
"resync",
|
"resync",
|
||||||
|
|||||||
@@ -125,7 +125,11 @@ fn is_record(value: &Value) -> bool {
|
|||||||
fn read_string_field(value: &Value, keys: &[&str]) -> Option<String> {
|
fn read_string_field(value: &Value, keys: &[&str]) -> Option<String> {
|
||||||
let map = value.as_object()?;
|
let map = value.as_object()?;
|
||||||
for key in keys {
|
for key in keys {
|
||||||
let candidate = map.get(*key).and_then(Value::as_str).map(str::trim).unwrap_or("");
|
let candidate = map
|
||||||
|
.get(*key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("");
|
||||||
if !candidate.is_empty() {
|
if !candidate.is_empty() {
|
||||||
return Some(candidate.to_string());
|
return Some(candidate.to_string());
|
||||||
}
|
}
|
||||||
@@ -149,22 +153,30 @@ fn encode_stream_cursor(id: &str, created_at: &str) -> Option<String> {
|
|||||||
if id.is_empty() || created_at.is_empty() {
|
if id.is_empty() || created_at.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(json!({
|
Some(
|
||||||
|
json!({
|
||||||
"createdAt": created_at,
|
"createdAt": created_at,
|
||||||
"id": id,
|
"id": id,
|
||||||
})
|
})
|
||||||
.to_string())
|
.to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_command_cursor(row: &Value) -> Option<String> {
|
fn encode_command_cursor(row: &Value) -> Option<String> {
|
||||||
let id = read_string_field(row, &["id", "command_id", "commandId"])?;
|
let id = read_string_field(row, &["id", "command_id", "commandId"])?;
|
||||||
let created_at = read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"])?;
|
let created_at = read_string_field(
|
||||||
|
row,
|
||||||
|
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||||
|
)?;
|
||||||
encode_stream_cursor(&id, &created_at)
|
encode_stream_cursor(&id, &created_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_domain_event_cursor(row: &Value) -> Option<String> {
|
fn encode_domain_event_cursor(row: &Value) -> Option<String> {
|
||||||
let id = read_string_field(row, &["event_id", "eventId", "id"])?;
|
let id = read_string_field(row, &["event_id", "eventId", "id"])?;
|
||||||
let created_at = read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"])?;
|
let created_at = read_string_field(
|
||||||
|
row,
|
||||||
|
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||||
|
)?;
|
||||||
encode_stream_cursor(&format!("domain_event:{id}"), &created_at)
|
encode_stream_cursor(&format!("domain_event:{id}"), &created_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,10 +188,7 @@ fn decode_stream_cursor(raw: &str) -> Option<DecodedStreamCursor> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_stream_cursor(
|
pub fn resolve_stream_cursor(overview: Option<&Value>, fallback: Option<&str>) -> Option<String> {
|
||||||
overview: Option<&Value>,
|
|
||||||
fallback: Option<&str>,
|
|
||||||
) -> Option<String> {
|
|
||||||
let fallback = fallback
|
let fallback = fallback
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
@@ -218,18 +227,17 @@ pub fn resolve_stream_cursor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_new_command_logs(
|
fn collect_new_command_logs(rows: &[Value], previous_cursor: Option<&str>) -> (Vec<Value>, bool) {
|
||||||
rows: &[Value],
|
|
||||||
previous_cursor: Option<&str>,
|
|
||||||
) -> (Vec<Value>, bool) {
|
|
||||||
let Some(previous_cursor) = previous_cursor.and_then(decode_stream_cursor) else {
|
let Some(previous_cursor) = previous_cursor.and_then(decode_stream_cursor) else {
|
||||||
return (rows.to_vec(), false);
|
return (rows.to_vec(), false);
|
||||||
};
|
};
|
||||||
|
|
||||||
let previous_index = rows.iter().position(|row| {
|
let previous_index = rows.iter().position(|row| {
|
||||||
let id = read_string_field(row, &["id", "command_id", "commandId"]).unwrap_or_default();
|
let id = read_string_field(row, &["id", "command_id", "commandId"]).unwrap_or_default();
|
||||||
let created_at =
|
let created_at = read_string_field(
|
||||||
read_string_field(row, &["created_at", "createdAt", "finished_at", "finishedAt"])
|
row,
|
||||||
|
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||||
|
)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
id == previous_cursor.id && created_at == previous_cursor.created_at
|
id == previous_cursor.id && created_at == previous_cursor.created_at
|
||||||
});
|
});
|
||||||
@@ -237,10 +245,80 @@ fn collect_new_command_logs(
|
|||||||
if let Some(index) = previous_index {
|
if let Some(index) = previous_index {
|
||||||
(rows.iter().take(index).cloned().collect(), false)
|
(rows.iter().take(index).cloned().collect(), false)
|
||||||
} else {
|
} else {
|
||||||
|
let newer_rows = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| {
|
||||||
|
read_string_field(
|
||||||
|
row,
|
||||||
|
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||||
|
)
|
||||||
|
.map(|created_at| created_at > previous_cursor.created_at)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if newer_rows.len() < rows.len() {
|
||||||
|
return (newer_rows, false);
|
||||||
|
}
|
||||||
(rows.to_vec(), !rows.is_empty())
|
(rows.to_vec(), !rows.is_empty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_new_domain_events(rows: &[Value], previous_cursor: Option<&str>) -> (Vec<Value>, bool) {
|
||||||
|
let Some(previous_cursor) = previous_cursor.and_then(decode_stream_cursor) else {
|
||||||
|
return (rows.to_vec(), false);
|
||||||
|
};
|
||||||
|
let previous_id = previous_cursor
|
||||||
|
.id
|
||||||
|
.strip_prefix("domain_event:")
|
||||||
|
.unwrap_or(previous_cursor.id.as_str());
|
||||||
|
|
||||||
|
let previous_index = rows.iter().position(|row| {
|
||||||
|
let id = read_string_field(row, &["event_id", "eventId", "id"]).unwrap_or_default();
|
||||||
|
let created_at = read_string_field(
|
||||||
|
row,
|
||||||
|
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
id == previous_id && created_at == previous_cursor.created_at
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(index) = previous_index {
|
||||||
|
(rows.iter().take(index).cloned().collect(), false)
|
||||||
|
} else {
|
||||||
|
let newer_rows = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|row| {
|
||||||
|
read_string_field(
|
||||||
|
row,
|
||||||
|
&["created_at", "createdAt", "finished_at", "finishedAt"],
|
||||||
|
)
|
||||||
|
.map(|created_at| created_at > previous_cursor.created_at)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if newer_rows.len() < rows.len() {
|
||||||
|
return (newer_rows, false);
|
||||||
|
}
|
||||||
|
(rows.to_vec(), !rows.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_stream_delta_candidate(candidate: &Value) -> Option<Value> {
|
||||||
|
if candidate
|
||||||
|
.as_object()
|
||||||
|
.and_then(|map| map.get("op"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Some(candidate.clone());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn read_command_payload_delta(row: &Value) -> Option<Value> {
|
fn read_command_payload_delta(row: &Value) -> Option<Value> {
|
||||||
let command_name = read_string_field(row, &["command_name", "commandName"]).unwrap_or_default();
|
let command_name = read_string_field(row, &["command_name", "commandName"]).unwrap_or_default();
|
||||||
if TREE_STREAM_NOOP_COMMANDS.contains(&command_name.as_str()) {
|
if TREE_STREAM_NOOP_COMMANDS.contains(&command_name.as_str()) {
|
||||||
@@ -255,18 +333,53 @@ fn read_command_payload_delta(row: &Value) -> Option<Value> {
|
|||||||
let candidate = payload
|
let candidate = payload
|
||||||
.as_object()
|
.as_object()
|
||||||
.and_then(|map| map.get("streamDelta").or_else(|| map.get("stream_delta")))?;
|
.and_then(|map| map.get("streamDelta").or_else(|| map.get("stream_delta")))?;
|
||||||
if candidate
|
read_stream_delta_candidate(candidate)
|
||||||
.as_object()
|
|
||||||
.and_then(|map| map.get("op"))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return Some(candidate.clone());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_domain_event_payload_delta(row: &Value) -> Option<Value> {
|
||||||
|
let payload = row.as_object()?.get("payload")?;
|
||||||
|
if !is_record(payload) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = payload
|
||||||
|
.as_object()
|
||||||
|
.and_then(|map| map.get("streamDelta").or_else(|| map.get("stream_delta")))?;
|
||||||
|
read_stream_delta_candidate(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_command_row_command_id(row: &Value) -> Option<String> {
|
||||||
|
read_string_field(row, &["command_id", "commandId", "id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_domain_event_command_id(row: &Value) -> Option<String> {
|
||||||
|
read_string_field(row, &["command_id", "commandId"])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_matching_command_domain_event_delta(
|
||||||
|
command_rows: &[Value],
|
||||||
|
command_drifted: bool,
|
||||||
|
domain_event_rows: &[Value],
|
||||||
|
_domain_event_drifted: bool,
|
||||||
|
) -> Option<Value> {
|
||||||
|
if command_drifted || command_rows.len() != 1 || domain_event_rows.len() != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let command_id = read_command_row_command_id(&command_rows[0])?;
|
||||||
|
let event_command_id = read_domain_event_command_id(&domain_event_rows[0])?;
|
||||||
|
if command_id != event_command_id {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let command_delta = read_command_payload_delta(&command_rows[0])?;
|
||||||
|
let event_delta = read_domain_event_payload_delta(&domain_event_rows[0])?;
|
||||||
|
if command_delta == event_delta {
|
||||||
|
Some(event_delta)
|
||||||
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_stream_change(
|
pub fn resolve_stream_change(
|
||||||
overview: &Value,
|
overview: &Value,
|
||||||
@@ -281,8 +394,36 @@ pub fn resolve_stream_change(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows = read_array_field(overview, &["command_logs", "commandLogs"]).cloned().unwrap_or_default();
|
let rows = read_array_field(overview, &["command_logs", "commandLogs"])
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
let (new_rows, drifted) = collect_new_command_logs(&rows, previous_cursor.as_deref());
|
let (new_rows, drifted) = collect_new_command_logs(&rows, previous_cursor.as_deref());
|
||||||
|
let event_rows = read_array_field(overview, &["domain_events", "domainEvents"])
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (new_event_rows, event_drifted) =
|
||||||
|
collect_new_domain_events(&event_rows, previous_cursor.as_deref());
|
||||||
|
if !new_rows.is_empty() && !new_event_rows.is_empty() {
|
||||||
|
if let Some(delta) = resolve_matching_command_domain_event_delta(
|
||||||
|
&new_rows,
|
||||||
|
drifted,
|
||||||
|
&new_event_rows,
|
||||||
|
event_drifted,
|
||||||
|
) {
|
||||||
|
return Some(StreamChange {
|
||||||
|
kind: StreamChangeKind::Delta,
|
||||||
|
cursor: next_cursor,
|
||||||
|
delta: Some(delta),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Some(StreamChange {
|
||||||
|
kind: StreamChangeKind::Resync,
|
||||||
|
cursor: next_cursor,
|
||||||
|
delta: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if !drifted && new_rows.len() == 1 {
|
if !drifted && new_rows.len() == 1 {
|
||||||
if let Some(delta) = read_command_payload_delta(&new_rows[0]) {
|
if let Some(delta) = read_command_payload_delta(&new_rows[0]) {
|
||||||
return Some(StreamChange {
|
return Some(StreamChange {
|
||||||
@@ -293,6 +434,16 @@ pub fn resolve_stream_change(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !event_drifted && new_rows.is_empty() && new_event_rows.len() == 1 {
|
||||||
|
if let Some(delta) = read_domain_event_payload_delta(&new_event_rows[0]) {
|
||||||
|
return Some(StreamChange {
|
||||||
|
kind: StreamChangeKind::Delta,
|
||||||
|
cursor: next_cursor,
|
||||||
|
delta: Some(delta),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some(StreamChange {
|
Some(StreamChange {
|
||||||
kind: StreamChangeKind::Resync,
|
kind: StreamChangeKind::Resync,
|
||||||
cursor: next_cursor,
|
cursor: next_cursor,
|
||||||
@@ -377,6 +528,8 @@ pub async fn load_stream_snapshot(
|
|||||||
workspace_id: &effective_workspace_id,
|
workspace_id: &effective_workspace_id,
|
||||||
root_node_id: None,
|
root_node_id: None,
|
||||||
depth: query.depth,
|
depth: query.depth,
|
||||||
|
query: None,
|
||||||
|
max_results: None,
|
||||||
projection: KernelProjectionKind::SidebarTree,
|
projection: KernelProjectionKind::SidebarTree,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -388,8 +541,8 @@ pub async fn load_stream_snapshot(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
StreamSnapshotScope::Subtree => {
|
StreamSnapshotScope::Subtree => {
|
||||||
let root_node_id = normalize_root_node_id(query)
|
let root_node_id =
|
||||||
.expect("subtree scope 已确保 rootNodeId 存在");
|
normalize_root_node_id(query).expect("subtree scope 已确保 rootNodeId 存在");
|
||||||
let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?;
|
let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?;
|
||||||
let tree = execute_kernel_query(
|
let tree = execute_kernel_query(
|
||||||
context,
|
context,
|
||||||
@@ -435,8 +588,8 @@ pub async fn load_stream_snapshot(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
resolve_stream_change, resolve_stream_cursor, resolve_stream_scope,
|
resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind,
|
||||||
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
|
StreamSnapshotQuery, StreamSnapshotScope,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -487,6 +640,7 @@ mod tests {
|
|||||||
let overview = json!({
|
let overview = json!({
|
||||||
"command_logs": [
|
"command_logs": [
|
||||||
{
|
{
|
||||||
|
"id": "clog_2",
|
||||||
"command_id": "cmd_2",
|
"command_id": "cmd_2",
|
||||||
"created_at": "2026-04-25T10:00:02Z",
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
"command_name": "tree.node.archive",
|
"command_name": "tree.node.archive",
|
||||||
@@ -498,6 +652,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "clog_1",
|
||||||
"command_id": "cmd_1",
|
"command_id": "cmd_1",
|
||||||
"created_at": "2026-04-25T10:00:01Z"
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
}
|
}
|
||||||
@@ -507,14 +662,14 @@ mod tests {
|
|||||||
|
|
||||||
let change = resolve_stream_change(
|
let change = resolve_stream_change(
|
||||||
&overview,
|
&overview,
|
||||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#),
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||||
)
|
)
|
||||||
.expect("应识别到变化");
|
.expect("应识别到变化");
|
||||||
|
|
||||||
assert_eq!(change.kind, StreamChangeKind::Delta);
|
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
change.cursor,
|
change.cursor,
|
||||||
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"cmd_2"}"#.into())
|
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"clog_2"}"#.into())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
change.delta,
|
change.delta,
|
||||||
@@ -525,11 +680,195 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_change_preserves_move_document_delta_fields() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [
|
||||||
|
{
|
||||||
|
"id": "clog_2",
|
||||||
|
"command_id": "cmd_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"command_name": "tree.subtree.move",
|
||||||
|
"payload": {
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "move_document",
|
||||||
|
"documentId": "page_2",
|
||||||
|
"parentId": "page_1",
|
||||||
|
"sortOrder": 3,
|
||||||
|
"updatedAt": "2026-04-25T10:00:02Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clog_1",
|
||||||
|
"command_id": "cmd_1",
|
||||||
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domain_events": []
|
||||||
|
});
|
||||||
|
|
||||||
|
let change = resolve_stream_change(
|
||||||
|
&overview,
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||||
|
)
|
||||||
|
.expect("应识别到变化");
|
||||||
|
|
||||||
|
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||||
|
assert_eq!(
|
||||||
|
change.delta,
|
||||||
|
Some(json!({
|
||||||
|
"op": "move_document",
|
||||||
|
"documentId": "page_2",
|
||||||
|
"parentId": "page_1",
|
||||||
|
"sortOrder": 3,
|
||||||
|
"updatedAt": "2026-04-25T10:00:02Z"
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_change_preserves_upsert_documents_delta_fields() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [
|
||||||
|
{
|
||||||
|
"id": "clog_2",
|
||||||
|
"command_id": "cmd_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"command_name": "tree.subtree.copy",
|
||||||
|
"payload": {
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "upsert_documents",
|
||||||
|
"upsertDocuments": [
|
||||||
|
{
|
||||||
|
"id": "copy_1",
|
||||||
|
"workspace_id": "ws_1",
|
||||||
|
"title": "Copy",
|
||||||
|
"parent_id": null,
|
||||||
|
"sort_order": 2,
|
||||||
|
"is_starred": false,
|
||||||
|
"access_scope": "private",
|
||||||
|
"is_template": false,
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"updated_at": "2026-04-25T10:00:02Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clog_1",
|
||||||
|
"command_id": "cmd_1",
|
||||||
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domain_events": []
|
||||||
|
});
|
||||||
|
|
||||||
|
let change = resolve_stream_change(
|
||||||
|
&overview,
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||||
|
)
|
||||||
|
.expect("应识别到变化");
|
||||||
|
|
||||||
|
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||||
|
assert_eq!(
|
||||||
|
change.delta,
|
||||||
|
Some(json!({
|
||||||
|
"op": "upsert_documents",
|
||||||
|
"upsertDocuments": [
|
||||||
|
{
|
||||||
|
"id": "copy_1",
|
||||||
|
"workspace_id": "ws_1",
|
||||||
|
"title": "Copy",
|
||||||
|
"parent_id": null,
|
||||||
|
"sort_order": 2,
|
||||||
|
"is_starred": false,
|
||||||
|
"access_scope": "private",
|
||||||
|
"is_template": false,
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"updated_at": "2026-04-25T10:00:02Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_change_preserves_upsert_assets_delta_fields() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [
|
||||||
|
{
|
||||||
|
"id": "clog_2",
|
||||||
|
"command_id": "cmd_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"command_name": "tree.resource.move",
|
||||||
|
"payload": {
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "upsert_assets",
|
||||||
|
"upsertAssets": [
|
||||||
|
{
|
||||||
|
"id": "asset_1",
|
||||||
|
"workspace_id": "ws_1",
|
||||||
|
"document_id": "doc_target",
|
||||||
|
"asset_type": "file",
|
||||||
|
"file_url": "/file.pdf",
|
||||||
|
"thumbnail_url": "/file.pdf",
|
||||||
|
"file_name": "file.pdf",
|
||||||
|
"file_size": 1024,
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"updated_at": "2026-04-25T10:00:02Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clog_1",
|
||||||
|
"command_id": "cmd_1",
|
||||||
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domain_events": []
|
||||||
|
});
|
||||||
|
|
||||||
|
let change = resolve_stream_change(
|
||||||
|
&overview,
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||||
|
)
|
||||||
|
.expect("应识别到变化");
|
||||||
|
|
||||||
|
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||||
|
assert_eq!(
|
||||||
|
change.delta,
|
||||||
|
Some(json!({
|
||||||
|
"op": "upsert_assets",
|
||||||
|
"upsertAssets": [
|
||||||
|
{
|
||||||
|
"id": "asset_1",
|
||||||
|
"workspace_id": "ws_1",
|
||||||
|
"document_id": "doc_target",
|
||||||
|
"asset_type": "file",
|
||||||
|
"file_url": "/file.pdf",
|
||||||
|
"thumbnail_url": "/file.pdf",
|
||||||
|
"file_name": "file.pdf",
|
||||||
|
"file_size": 1024,
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"updated_at": "2026-04-25T10:00:02Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stream_change_detects_noop_delta_for_non_tree_mutating_command() {
|
fn stream_change_detects_noop_delta_for_non_tree_mutating_command() {
|
||||||
let overview = json!({
|
let overview = json!({
|
||||||
"command_logs": [
|
"command_logs": [
|
||||||
{
|
{
|
||||||
|
"id": "clog_2",
|
||||||
"command_id": "cmd_2",
|
"command_id": "cmd_2",
|
||||||
"created_at": "2026-04-25T10:00:02Z",
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
"command_name": "page.body.save",
|
"command_name": "page.body.save",
|
||||||
@@ -538,6 +877,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "clog_1",
|
||||||
"command_id": "cmd_1",
|
"command_id": "cmd_1",
|
||||||
"created_at": "2026-04-25T10:00:01Z"
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
}
|
}
|
||||||
@@ -547,7 +887,7 @@ mod tests {
|
|||||||
|
|
||||||
let change = resolve_stream_change(
|
let change = resolve_stream_change(
|
||||||
&overview,
|
&overview,
|
||||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#),
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||||
)
|
)
|
||||||
.expect("应识别到变化");
|
.expect("应识别到变化");
|
||||||
|
|
||||||
@@ -555,11 +895,276 @@ mod tests {
|
|||||||
assert_eq!(change.delta, Some(json!({ "op": "noop" })));
|
assert_eq!(change.delta, Some(json!({ "op": "noop" })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_change_detects_delta_from_single_new_domain_event() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [],
|
||||||
|
"domain_events": [
|
||||||
|
{
|
||||||
|
"event_id": "evt_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"payload": {
|
||||||
|
"command_name": "tree.node.rename",
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "upsert_document",
|
||||||
|
"document": {
|
||||||
|
"id": "page_2",
|
||||||
|
"title": "新标题"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_id": "evt_1",
|
||||||
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let change = resolve_stream_change(
|
||||||
|
&overview,
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_1"}"#),
|
||||||
|
)
|
||||||
|
.expect("应识别到 domain event delta");
|
||||||
|
|
||||||
|
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||||
|
assert_eq!(
|
||||||
|
change.cursor,
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"domain_event:evt_2"}"#.into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
change.delta,
|
||||||
|
Some(json!({
|
||||||
|
"op": "upsert_document",
|
||||||
|
"document": {
|
||||||
|
"id": "page_2",
|
||||||
|
"title": "新标题"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_change_falls_back_to_resync_for_unknown_domain_event_payload() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [],
|
||||||
|
"domain_events": [
|
||||||
|
{
|
||||||
|
"event_id": "evt_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"payload": {
|
||||||
|
"schema": "mnote.tree.domain_event",
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"eventType": "tree.node.unknown"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_id": "evt_1",
|
||||||
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let change = resolve_stream_change(
|
||||||
|
&overview,
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_1"}"#),
|
||||||
|
)
|
||||||
|
.expect("应识别到未知 domain event 推进");
|
||||||
|
|
||||||
|
assert_eq!(change.kind, StreamChangeKind::Resync);
|
||||||
|
assert_eq!(
|
||||||
|
change.cursor,
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"domain_event:evt_2"}"#.into())
|
||||||
|
);
|
||||||
|
assert_eq!(change.delta, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_change_falls_back_to_resync_when_command_and_domain_event_both_advance() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [
|
||||||
|
{
|
||||||
|
"id": "clog_2",
|
||||||
|
"command_id": "cmd_2",
|
||||||
|
"created_at": "2026-04-25T10:00:03Z",
|
||||||
|
"command_name": "tree.node.rename",
|
||||||
|
"payload": {
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "upsert_document",
|
||||||
|
"document": {
|
||||||
|
"id": "page_2",
|
||||||
|
"title": "命令标题"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clog_1",
|
||||||
|
"command_id": "cmd_1",
|
||||||
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domain_events": [
|
||||||
|
{
|
||||||
|
"event_id": "evt_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"payload": {
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "remove_document",
|
||||||
|
"documentId": "page_3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let change = resolve_stream_change(
|
||||||
|
&overview,
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||||
|
)
|
||||||
|
.expect("应识别到混合变化");
|
||||||
|
|
||||||
|
assert_eq!(change.kind, StreamChangeKind::Resync);
|
||||||
|
assert_eq!(change.delta, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_change_dedupes_matching_command_and_domain_event_delta() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [
|
||||||
|
{
|
||||||
|
"id": "clog_2",
|
||||||
|
"command_id": "cmd_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"command_name": "tree.node.rename",
|
||||||
|
"payload": {
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "upsert_document",
|
||||||
|
"document": {
|
||||||
|
"id": "page_2",
|
||||||
|
"title": "同一标题"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clog_1",
|
||||||
|
"command_id": "cmd_1",
|
||||||
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domain_events": [
|
||||||
|
{
|
||||||
|
"event_id": "evt_2",
|
||||||
|
"command_id": "cmd_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"payload": {
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "upsert_document",
|
||||||
|
"document": {
|
||||||
|
"id": "page_2",
|
||||||
|
"title": "同一标题"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let change = resolve_stream_change(
|
||||||
|
&overview,
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||||
|
)
|
||||||
|
.expect("应识别到同命令去重 delta");
|
||||||
|
|
||||||
|
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||||
|
assert_eq!(
|
||||||
|
change.delta,
|
||||||
|
Some(json!({
|
||||||
|
"op": "upsert_document",
|
||||||
|
"document": {
|
||||||
|
"id": "page_2",
|
||||||
|
"title": "同一标题"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_change_dedupes_matching_delta_when_previous_cursor_is_command_log() {
|
||||||
|
let overview = json!({
|
||||||
|
"command_logs": [
|
||||||
|
{
|
||||||
|
"id": "clog_2",
|
||||||
|
"command_id": "cmd_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"command_name": "tree.subtree.move",
|
||||||
|
"payload": {
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "move_document",
|
||||||
|
"documentId": "page_2",
|
||||||
|
"parentId": "page_1",
|
||||||
|
"sortOrder": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "clog_1",
|
||||||
|
"command_id": "cmd_1",
|
||||||
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"domain_events": [
|
||||||
|
{
|
||||||
|
"event_id": "evt_2",
|
||||||
|
"command_id": "cmd_2",
|
||||||
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
|
"payload": {
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "move_document",
|
||||||
|
"documentId": "page_2",
|
||||||
|
"parentId": "page_1",
|
||||||
|
"sortOrder": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event_id": "evt_1",
|
||||||
|
"command_id": "cmd_1",
|
||||||
|
"created_at": "2026-04-25T10:00:01Z",
|
||||||
|
"payload": {
|
||||||
|
"streamDelta": {
|
||||||
|
"op": "noop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let change = resolve_stream_change(
|
||||||
|
&overview,
|
||||||
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||||
|
)
|
||||||
|
.expect("应识别到同命令去重 delta");
|
||||||
|
|
||||||
|
assert_eq!(change.kind, StreamChangeKind::Delta);
|
||||||
|
assert_eq!(
|
||||||
|
change.delta,
|
||||||
|
Some(json!({
|
||||||
|
"op": "move_document",
|
||||||
|
"documentId": "page_2",
|
||||||
|
"parentId": "page_1",
|
||||||
|
"sortOrder": 2
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stream_change_falls_back_to_resync_when_delta_is_unstable() {
|
fn stream_change_falls_back_to_resync_when_delta_is_unstable() {
|
||||||
let overview = json!({
|
let overview = json!({
|
||||||
"command_logs": [
|
"command_logs": [
|
||||||
{
|
{
|
||||||
|
"id": "clog_2",
|
||||||
"command_id": "cmd_2",
|
"command_id": "cmd_2",
|
||||||
"created_at": "2026-04-25T10:00:02Z",
|
"created_at": "2026-04-25T10:00:02Z",
|
||||||
"command_name": "tree.subtree.move",
|
"command_name": "tree.subtree.move",
|
||||||
@@ -568,6 +1173,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "clog_1",
|
||||||
"command_id": "cmd_1",
|
"command_id": "cmd_1",
|
||||||
"created_at": "2026-04-25T10:00:01Z"
|
"created_at": "2026-04-25T10:00:01Z"
|
||||||
}
|
}
|
||||||
@@ -577,7 +1183,7 @@ mod tests {
|
|||||||
|
|
||||||
let change = resolve_stream_change(
|
let change = resolve_stream_change(
|
||||||
&overview,
|
&overview,
|
||||||
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"cmd_1"}"#),
|
Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"clog_1"}"#),
|
||||||
)
|
)
|
||||||
.expect("应识别到变化");
|
.expect("应识别到变化");
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,14 @@
|
|||||||
use crate::app::AppConfig;
|
use crate::app::AppConfig;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use bridge_runtime::{RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan};
|
use bridge_runtime::{
|
||||||
use serde_json::{json, Value};
|
RuntimeBridgeContextWire, RuntimeCommandArtifactPlan, RuntimeCommandEnvelopeWire,
|
||||||
|
RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan, build_runtime_command_artifact_plan,
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||||
|
|
||||||
const HEADER_REQUEST_ID: &str = "x-request-id";
|
const HEADER_REQUEST_ID: &str = "x-request-id";
|
||||||
const HEADER_TRACE_ID: &str = "x-trace-id";
|
const HEADER_TRACE_ID: &str = "x-trace-id";
|
||||||
@@ -154,6 +158,14 @@ fn load_mutation_fixture(
|
|||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
plan: &RuntimeCommandExecutionPlan,
|
plan: &RuntimeCommandExecutionPlan,
|
||||||
|
) -> Result<Option<Value>, WebError> {
|
||||||
|
load_mutation_fixture_by_name(config, context, plan.function_name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_mutation_fixture_by_name(
|
||||||
|
config: &AppConfig,
|
||||||
|
context: &RequestContext,
|
||||||
|
function_name: &str,
|
||||||
) -> Result<Option<Value>, WebError> {
|
) -> Result<Option<Value>, WebError> {
|
||||||
if !config.allow_dev_fixtures {
|
if !config.allow_dev_fixtures {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -175,7 +187,7 @@ fn load_mutation_fixture(
|
|||||||
|
|
||||||
Ok(fixtures
|
Ok(fixtures
|
||||||
.as_object()
|
.as_object()
|
||||||
.and_then(|map| map.get(plan.function_name.as_str()))
|
.and_then(|map| map.get(function_name))
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +446,253 @@ pub async fn execute_convex_command_plan(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ConvexCommandExecution {
|
||||||
|
pub result: Value,
|
||||||
|
pub artifacts: Option<RuntimeCommandArtifactPlan>,
|
||||||
|
pub artifact_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso_like() -> String {
|
||||||
|
OffsetDateTime::now_utc()
|
||||||
|
.format(&Rfc3339)
|
||||||
|
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_convex_mutation_by_name(
|
||||||
|
config: &AppConfig,
|
||||||
|
context: &RequestContext,
|
||||||
|
function_name: &str,
|
||||||
|
args: Value,
|
||||||
|
workspace_id: Option<&str>,
|
||||||
|
idempotency_key: Option<&str>,
|
||||||
|
error_phase: &'static str,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
if let Some(fixture) = load_mutation_fixture_by_name(config, context, function_name)? {
|
||||||
|
return Ok(fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = json!({
|
||||||
|
"path": function_name,
|
||||||
|
"format": "convex_encoded_json",
|
||||||
|
"args": [args],
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", "client_build")
|
||||||
|
.with_header("x-upstream-service", "convex")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut request = client
|
||||||
|
.post(format!("{}/api/mutation", convex_url(config, context)?))
|
||||||
|
.header("Authorization", build_authorization(config, context)?)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Convex-Client", "mnote-web")
|
||||||
|
.header(HEADER_REQUEST_ID, &context.trace.request_id)
|
||||||
|
.header(HEADER_TRACE_ID, &context.trace.trace_id)
|
||||||
|
.header(HEADER_SOURCE_CHANNEL, context.source.channel.as_str())
|
||||||
|
.header(HEADER_SOURCE_CLIENT, context.source.client.as_str())
|
||||||
|
.json(&payload);
|
||||||
|
|
||||||
|
if let Some(workspace_id) = workspace_id
|
||||||
|
.or(context.workspace.workspace_id.as_deref())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
request = request.header(HEADER_WORKSPACE_ID, workspace_id);
|
||||||
|
}
|
||||||
|
if let Some(idempotency_key) = idempotency_key
|
||||||
|
.or(context.source.idempotency_key.as_deref())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
request = request.header(HEADER_IDEMPOTENCY_KEY, idempotency_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|error| {
|
||||||
|
let base = if error.is_timeout() {
|
||||||
|
WebError::gateway_timeout_code(
|
||||||
|
"convex_timeout",
|
||||||
|
format!("Convex mutation 超时: {error}"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
WebError::service_unavailable_code(
|
||||||
|
"convex_unavailable",
|
||||||
|
format!("Convex mutation 请求失败: {error}"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
base.with_context(context)
|
||||||
|
.with_header("x-error-phase", error_phase)
|
||||||
|
.with_header("x-upstream-service", "convex")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body: Value = response.json().await.map_err(|error| {
|
||||||
|
WebError::bad_gateway_code(
|
||||||
|
"convex_bad_response",
|
||||||
|
format!("Convex mutation 响应解析失败: {error}"),
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", error_phase)
|
||||||
|
.with_header("x-upstream-service", "convex")
|
||||||
|
.with_header("x-upstream-status", status.as_u16().to_string())
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
let message = body
|
||||||
|
.get("errorMessage")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("Convex mutation 失败");
|
||||||
|
return Err(
|
||||||
|
WebError::bad_gateway_code("convex_upstream_error", message.to_string())
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", error_phase)
|
||||||
|
.with_header("x-upstream-service", "convex")
|
||||||
|
.with_header("x-upstream-status", status.as_u16().to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match body.get("status").and_then(Value::as_str) {
|
||||||
|
Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)),
|
||||||
|
Some("error") => Err(WebError::bad_gateway_code(
|
||||||
|
"convex_upstream_error",
|
||||||
|
body.get("errorMessage")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("Convex 返回 error")
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", error_phase)
|
||||||
|
.with_header("x-upstream-service", "convex")
|
||||||
|
.with_header("x-upstream-status", status.as_u16().to_string())),
|
||||||
|
_ => Err(WebError::bad_gateway_code(
|
||||||
|
"convex_bad_response",
|
||||||
|
format!("未知 Convex 响应: {body}"),
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", error_phase)
|
||||||
|
.with_header("x-upstream-service", "convex")
|
||||||
|
.with_header("x-upstream-status", status.as_u16().to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_log_artifact_args(artifact: &bridge_runtime::RuntimeCommandLogArtifactPlan) -> Value {
|
||||||
|
json!({
|
||||||
|
"workspaceId": artifact.workspace_id,
|
||||||
|
"id": artifact.id,
|
||||||
|
"requestId": artifact.request_id,
|
||||||
|
"traceId": artifact.trace_id,
|
||||||
|
"commandId": artifact.command_id,
|
||||||
|
"commandName": artifact.command_name,
|
||||||
|
"actorId": artifact.actor_id,
|
||||||
|
"actorType": artifact.actor_type,
|
||||||
|
"sourceChannel": artifact.source_channel,
|
||||||
|
"sourceClient": artifact.source_client,
|
||||||
|
"status": artifact.status,
|
||||||
|
"targetPageId": artifact.target_page_id,
|
||||||
|
"targetBlockId": artifact.target_block_id,
|
||||||
|
"payload": artifact.payload,
|
||||||
|
"payloadSummary": artifact.payload_summary,
|
||||||
|
"refs": artifact.refs,
|
||||||
|
"idempotencyKey": artifact.idempotency_key,
|
||||||
|
"error": artifact.error,
|
||||||
|
"createdAt": artifact.created_at,
|
||||||
|
"finishedAt": artifact.finished_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain_event_artifact_args(artifact: &bridge_runtime::RuntimeDomainEventArtifactPlan) -> Value {
|
||||||
|
json!({
|
||||||
|
"workspaceId": artifact.workspace_id,
|
||||||
|
"id": artifact.id,
|
||||||
|
"requestId": artifact.request_id,
|
||||||
|
"traceId": artifact.trace_id,
|
||||||
|
"commandId": artifact.command_id,
|
||||||
|
"commandLogId": artifact.command_log_id,
|
||||||
|
"eventType": artifact.event_type,
|
||||||
|
"aggregateType": artifact.aggregate_type,
|
||||||
|
"aggregateId": artifact.aggregate_id,
|
||||||
|
"eventVersion": artifact.event_version,
|
||||||
|
"status": artifact.status,
|
||||||
|
"actorType": artifact.actor_type,
|
||||||
|
"payload": artifact.payload,
|
||||||
|
"createdAt": artifact.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn persist_runtime_command_artifacts(
|
||||||
|
config: &AppConfig,
|
||||||
|
context: &RequestContext,
|
||||||
|
artifacts: &RuntimeCommandArtifactPlan,
|
||||||
|
) -> Result<(), WebError> {
|
||||||
|
execute_convex_mutation_by_name(
|
||||||
|
config,
|
||||||
|
context,
|
||||||
|
"bridgeLogs:recordCommandLog",
|
||||||
|
command_log_artifact_args(&artifacts.command_log),
|
||||||
|
Some(artifacts.command_log.workspace_id.as_str()),
|
||||||
|
artifacts.command_log.idempotency_key.as_deref(),
|
||||||
|
"artifact_command_log",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(domain_event) = artifacts.domain_event.as_ref() {
|
||||||
|
execute_convex_mutation_by_name(
|
||||||
|
config,
|
||||||
|
context,
|
||||||
|
"bridgeLogs:recordDomainEvent",
|
||||||
|
domain_event_artifact_args(domain_event),
|
||||||
|
Some(domain_event.workspace_id.as_str()),
|
||||||
|
artifacts.command_log.idempotency_key.as_deref(),
|
||||||
|
"artifact_domain_event",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_convex_command_plan_with_artifacts(
|
||||||
|
config: &AppConfig,
|
||||||
|
context: &RequestContext,
|
||||||
|
runtime_context: &RuntimeBridgeContextWire,
|
||||||
|
command: &RuntimeCommandEnvelopeWire,
|
||||||
|
plan: &RuntimeCommandExecutionPlan,
|
||||||
|
) -> Result<ConvexCommandExecution, WebError> {
|
||||||
|
let result = execute_convex_command_plan(config, context, plan).await?;
|
||||||
|
let artifacts = build_runtime_command_artifact_plan(
|
||||||
|
runtime_context,
|
||||||
|
command,
|
||||||
|
plan,
|
||||||
|
&result,
|
||||||
|
&now_iso_like(),
|
||||||
|
);
|
||||||
|
if let Some(artifacts) = artifacts.as_ref() {
|
||||||
|
if let Err(error) = persist_runtime_command_artifacts(config, context, artifacts).await {
|
||||||
|
let message = error.message().to_string();
|
||||||
|
tracing::warn!(
|
||||||
|
error = %message,
|
||||||
|
command_id = %command.command_id,
|
||||||
|
"Rust command artifact 持久化失败,主 mutation 结果继续返回"
|
||||||
|
);
|
||||||
|
return Ok(ConvexCommandExecution {
|
||||||
|
result,
|
||||||
|
artifacts: Some(artifacts.clone()),
|
||||||
|
artifact_error: Some(message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ConvexCommandExecution {
|
||||||
|
result,
|
||||||
|
artifacts,
|
||||||
|
artifact_error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::build_authorization;
|
use super::build_authorization;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum TreeShellAction {
|
||||||
|
Open,
|
||||||
|
CreateChild,
|
||||||
|
Rename,
|
||||||
|
Move,
|
||||||
|
ContextMenu,
|
||||||
|
Pick,
|
||||||
|
AssetOpen,
|
||||||
|
ResourceCopy,
|
||||||
|
ResourceMove,
|
||||||
|
ResourceUpload,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct TreeShellActionRegistry {
|
||||||
|
pub actions: BTreeSet<TreeShellAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeShellActionRegistry {
|
||||||
|
pub fn page_tree() -> Self {
|
||||||
|
Self {
|
||||||
|
actions: BTreeSet::from([
|
||||||
|
TreeShellAction::Open,
|
||||||
|
TreeShellAction::CreateChild,
|
||||||
|
TreeShellAction::Rename,
|
||||||
|
TreeShellAction::Move,
|
||||||
|
TreeShellAction::ContextMenu,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_tree() -> Self {
|
||||||
|
Self {
|
||||||
|
actions: BTreeSet::from([
|
||||||
|
TreeShellAction::Open,
|
||||||
|
TreeShellAction::ContextMenu,
|
||||||
|
TreeShellAction::AssetOpen,
|
||||||
|
TreeShellAction::ResourceCopy,
|
||||||
|
TreeShellAction::ResourceMove,
|
||||||
|
TreeShellAction::ResourceUpload,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn picker() -> Self {
|
||||||
|
Self {
|
||||||
|
actions: BTreeSet::from([TreeShellAction::Pick]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allows(&self, action: TreeShellAction) -> bool {
|
||||||
|
self.actions.contains(&action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{TreeShellAction, TreeShellActionRegistry};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_action_registry_separates_page_filetree_and_picker_actions() {
|
||||||
|
let page = TreeShellActionRegistry::page_tree();
|
||||||
|
assert!(page.allows(TreeShellAction::CreateChild));
|
||||||
|
assert!(!page.allows(TreeShellAction::ResourceUpload));
|
||||||
|
|
||||||
|
let filetree = TreeShellActionRegistry::file_tree();
|
||||||
|
assert!(filetree.allows(TreeShellAction::ResourceMove));
|
||||||
|
assert!(filetree.allows(TreeShellAction::AssetOpen));
|
||||||
|
assert!(!filetree.allows(TreeShellAction::Rename));
|
||||||
|
|
||||||
|
let picker = TreeShellActionRegistry::picker();
|
||||||
|
assert!(picker.allows(TreeShellAction::Pick));
|
||||||
|
assert!(!picker.allows(TreeShellAction::Open));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TreeShellDragEffect {
|
||||||
|
Copy,
|
||||||
|
Move,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TreeShellDragPayload {
|
||||||
|
pub row_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct TreeShellDropTarget {
|
||||||
|
pub row_id: Option<String>,
|
||||||
|
pub document_id: Option<String>,
|
||||||
|
pub asset_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_drag_effect(has_external_files: bool, alt_key: bool) -> TreeShellDragEffect {
|
||||||
|
if has_external_files || alt_key {
|
||||||
|
TreeShellDragEffect::Copy
|
||||||
|
} else {
|
||||||
|
TreeShellDragEffect::Move
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_drag_payload(row_ids: &[String]) -> Option<TreeShellDragPayload> {
|
||||||
|
let mut normalized = Vec::new();
|
||||||
|
for row_id in row_ids {
|
||||||
|
let row_id = row_id.trim();
|
||||||
|
if row_id.is_empty() || normalized.iter().any(|existing| existing == row_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalized.push(row_id.to_string());
|
||||||
|
}
|
||||||
|
if normalized.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(TreeShellDragPayload {
|
||||||
|
row_ids: normalized,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{normalize_drag_payload, resolve_drag_effect, TreeShellDragEffect};
|
||||||
|
|
||||||
|
fn ids(values: &[&str]) -> Vec<String> {
|
||||||
|
values.iter().map(|value| (*value).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_drag_drop_state_normalizes_payload_and_effect() {
|
||||||
|
let payload = normalize_drag_payload(&ids(&[" doc:a ", "asset:b", "doc:a", ""])).unwrap();
|
||||||
|
assert_eq!(payload.row_ids, ids(&["doc:a", "asset:b"]));
|
||||||
|
assert_eq!(resolve_drag_effect(true, false), TreeShellDragEffect::Copy);
|
||||||
|
assert_eq!(resolve_drag_effect(false, true), TreeShellDragEffect::Copy);
|
||||||
|
assert_eq!(resolve_drag_effect(false, false), TreeShellDragEffect::Move);
|
||||||
|
assert!(normalize_drag_payload(&ids(&["", " "])).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct TreeShellExpansionState {
|
||||||
|
pub expanded_ids: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeShellExpansionState {
|
||||||
|
pub fn from_defaults(default_expanded_ids: &[String]) -> Self {
|
||||||
|
Self {
|
||||||
|
expanded_ids: default_expanded_ids.iter().cloned().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle(&self, node_id: &str) -> Self {
|
||||||
|
let mut expanded_ids = self.expanded_ids.clone();
|
||||||
|
if !expanded_ids.insert(node_id.to_string()) {
|
||||||
|
expanded_ids.remove(node_id);
|
||||||
|
}
|
||||||
|
Self { expanded_ids }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expand_ancestors(
|
||||||
|
&self,
|
||||||
|
node_id: &str,
|
||||||
|
parent_by_id: &BTreeMap<String, Option<String>>,
|
||||||
|
) -> Self {
|
||||||
|
let mut expanded_ids = self.expanded_ids.clone();
|
||||||
|
let mut current = parent_by_id.get(node_id).and_then(Clone::clone);
|
||||||
|
while let Some(parent_id) = current {
|
||||||
|
expanded_ids.insert(parent_id.clone());
|
||||||
|
current = parent_by_id.get(&parent_id).and_then(Clone::clone);
|
||||||
|
}
|
||||||
|
Self { expanded_ids }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::TreeShellExpansionState;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
fn ids(values: &[&str]) -> Vec<String> {
|
||||||
|
values.iter().map(|value| (*value).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_expansion_state_toggles_and_expands_active_ancestors() {
|
||||||
|
let state = TreeShellExpansionState::from_defaults(&ids(&["doc:root"]));
|
||||||
|
assert!(state.expanded_ids.contains("doc:root"));
|
||||||
|
|
||||||
|
let state = state.toggle("doc:root");
|
||||||
|
assert!(!state.expanded_ids.contains("doc:root"));
|
||||||
|
|
||||||
|
let parent_by_id = BTreeMap::from([
|
||||||
|
("doc:root".into(), None),
|
||||||
|
("doc:child".into(), Some("doc:root".into())),
|
||||||
|
("doc:leaf".into(), Some("doc:child".into())),
|
||||||
|
]);
|
||||||
|
let state = state.expand_ancestors("doc:leaf", &parent_by_id);
|
||||||
|
assert_eq!(
|
||||||
|
state.expanded_ids,
|
||||||
|
BTreeSet::from(["doc:root".into(), "doc:child".into()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
use super::protocol;
|
use super::protocol;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct FileTreeRenderRow {
|
pub struct FileTreeRenderRow {
|
||||||
pub row_id: String,
|
pub row_id: String,
|
||||||
pub row_kind: String,
|
pub row_kind: String,
|
||||||
|
pub node_id: String,
|
||||||
|
pub parent_node_id: Option<String>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
pub depth: u32,
|
||||||
|
pub expandable: bool,
|
||||||
|
pub expanded: bool,
|
||||||
|
pub icon_kind: String,
|
||||||
|
pub document_id: Option<String>,
|
||||||
|
pub asset_id: Option<String>,
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct FileTreeInitialRenderInput {
|
||||||
|
pub rows: Vec<FileTreeRenderRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, String)> {
|
pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str, String)> {
|
||||||
@@ -19,3 +34,139 @@ pub fn build_filetree_testids(rows: &[FileTreeRenderRow]) -> Vec<(&'static str,
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn escape_html(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_test_id(row_kind: &str) -> &'static str {
|
||||||
|
match row_kind {
|
||||||
|
"document" => protocol::TEST_ID_FILETREE_DOC_ROW,
|
||||||
|
"index" => protocol::TEST_ID_FILETREE_INDEX_ROW,
|
||||||
|
_ => protocol::TEST_ID_FILETREE_ASSET_ROW,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_filetree_row(
|
||||||
|
html: &mut String,
|
||||||
|
row: &FileTreeRenderRow,
|
||||||
|
children_by_parent: &BTreeMap<Option<String>, Vec<FileTreeRenderRow>>,
|
||||||
|
) {
|
||||||
|
html.push_str(&format!(
|
||||||
|
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="filetree" data-testid="{test_id}" data-row-id="{row_id}" data-row-kind="{row_kind}" data-node-id="{node_id}" data-document-id="{document_id}" data-asset-id="{asset_id}" data-shell-mode="filetree" data-selected="{selected}" data-active="false"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="{icon_kind}" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="{row_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="{row_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||||
|
node_id = escape_html(&row.node_id),
|
||||||
|
aria_level = row.depth + 1,
|
||||||
|
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||||
|
test_id = row_test_id(&row.row_kind),
|
||||||
|
row_id = escape_html(&row.row_id),
|
||||||
|
row_kind = escape_html(&row.row_kind),
|
||||||
|
document_id = escape_html(row.document_id.as_deref().unwrap_or_default()),
|
||||||
|
asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()),
|
||||||
|
selected = row.selected,
|
||||||
|
icon_kind = escape_html(&row.icon_kind),
|
||||||
|
title = escape_html(&row.title),
|
||||||
|
));
|
||||||
|
if row.expandable && row.expanded {
|
||||||
|
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
|
||||||
|
html.push_str(r#"<ul class="tree-children">"#);
|
||||||
|
for child in children {
|
||||||
|
render_filetree_row(html, child, children_by_parent);
|
||||||
|
}
|
||||||
|
html.push_str("</ul>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str("</li>");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> String {
|
||||||
|
let mut html = String::from(
|
||||||
|
r#"<ul class="tree-root" role="tree" data-rust-filetree-renderer="initial_v1">"#,
|
||||||
|
);
|
||||||
|
if input.rows.is_empty() {
|
||||||
|
html.push_str(
|
||||||
|
r#"<li class="tree-empty" data-rust-rendered-row="filetree-empty">当前 file tree 没有可渲染的页面。</li>"#,
|
||||||
|
);
|
||||||
|
html.push_str("</ul>");
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids = input
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.node_id.clone())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let mut children_by_parent = BTreeMap::<Option<String>, Vec<FileTreeRenderRow>>::new();
|
||||||
|
for row in &input.rows {
|
||||||
|
let parent_id = row
|
||||||
|
.parent_node_id
|
||||||
|
.as_ref()
|
||||||
|
.filter(|parent_id| ids.contains(*parent_id))
|
||||||
|
.cloned();
|
||||||
|
children_by_parent
|
||||||
|
.entry(parent_id)
|
||||||
|
.or_default()
|
||||||
|
.push(row.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(roots) = children_by_parent.get(&None).cloned() {
|
||||||
|
for root in &roots {
|
||||||
|
render_filetree_row(&mut html, root, &children_by_parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str("</ul>");
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_filetree_renderer_outputs_initial_nested_html_contract() {
|
||||||
|
let html = render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||||
|
rows: vec![
|
||||||
|
FileTreeRenderRow {
|
||||||
|
row_id: "doc:page_root".into(),
|
||||||
|
row_kind: "document".into(),
|
||||||
|
node_id: "page_root".into(),
|
||||||
|
parent_node_id: None,
|
||||||
|
title: "首页 <安全>".into(),
|
||||||
|
depth: 0,
|
||||||
|
expandable: true,
|
||||||
|
expanded: true,
|
||||||
|
icon_kind: "page".into(),
|
||||||
|
document_id: Some("page_root".into()),
|
||||||
|
asset_id: None,
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
FileTreeRenderRow {
|
||||||
|
row_id: "index:page_root".into(),
|
||||||
|
row_kind: "index".into(),
|
||||||
|
node_id: "index:page_root".into(),
|
||||||
|
parent_node_id: Some("page_root".into()),
|
||||||
|
title: "index.md".into(),
|
||||||
|
depth: 1,
|
||||||
|
expandable: false,
|
||||||
|
expanded: false,
|
||||||
|
icon_kind: "index".into(),
|
||||||
|
document_id: Some("page_root".into()),
|
||||||
|
asset_id: None,
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(html.contains("data-rust-filetree-renderer=\"initial_v1\""));
|
||||||
|
assert!(html.contains("data-rust-rendered-row=\"filetree\""));
|
||||||
|
assert!(html.contains("data-testid=\"filetree-doc-row\""));
|
||||||
|
assert!(html.contains("data-testid=\"filetree-index-row\""));
|
||||||
|
assert!(html.contains("tree-children"));
|
||||||
|
assert!(html.contains("首页 <安全>"));
|
||||||
|
assert!(html.contains("data-selected=\"true\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
pub const FILETREE_SELECTION_REDUCER_CONTRACT_NAME: &str = "rust_filetree_selection_reducer_v1";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileTreeSelectionReducerContract {
|
||||||
|
pub contract_name: &'static str,
|
||||||
|
pub actions: BTreeSet<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FileTreeSelectionReducerContract {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
contract_name: FILETREE_SELECTION_REDUCER_CONTRACT_NAME,
|
||||||
|
actions: BTreeSet::from([
|
||||||
|
"select_row",
|
||||||
|
"select_context_row",
|
||||||
|
"normalize_visible_rows",
|
||||||
|
"clear",
|
||||||
|
"resolve_drag_rows",
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileTreeSelectionModifiers {
|
||||||
|
pub shift_key: bool,
|
||||||
|
pub ctrl_key: bool,
|
||||||
|
pub meta_key: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileTreeSelectionState {
|
||||||
|
pub selected_row_ids: BTreeSet<String>,
|
||||||
|
pub anchor_row_id: Option<String>,
|
||||||
|
pub focused_row_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileTreeSelectionState {
|
||||||
|
pub fn from_selected(row_ids: &[String]) -> Self {
|
||||||
|
Self {
|
||||||
|
selected_row_ids: row_ids.iter().cloned().collect(),
|
||||||
|
anchor_row_id: row_ids.first().cloned(),
|
||||||
|
focused_row_id: row_ids.first().cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_row(
|
||||||
|
&self,
|
||||||
|
row_id: &str,
|
||||||
|
visible_row_ids: &[String],
|
||||||
|
modifiers: FileTreeSelectionModifiers,
|
||||||
|
) -> Self {
|
||||||
|
let toggle_selection = modifiers.ctrl_key || modifiers.meta_key;
|
||||||
|
|
||||||
|
if modifiers.shift_key {
|
||||||
|
let anchor = self
|
||||||
|
.anchor_row_id
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.focused_row_id.clone())
|
||||||
|
.unwrap_or_else(|| row_id.to_string());
|
||||||
|
let mut selected_row_ids = if toggle_selection {
|
||||||
|
self.selected_row_ids.clone()
|
||||||
|
} else {
|
||||||
|
BTreeSet::new()
|
||||||
|
};
|
||||||
|
for range_row_id in range_row_ids(visible_row_ids, &anchor, row_id) {
|
||||||
|
selected_row_ids.insert(range_row_id);
|
||||||
|
}
|
||||||
|
return Self {
|
||||||
|
selected_row_ids,
|
||||||
|
anchor_row_id: self.anchor_row_id.clone().or(Some(anchor)),
|
||||||
|
focused_row_id: Some(row_id.to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if toggle_selection {
|
||||||
|
let mut selected_row_ids = self.selected_row_ids.clone();
|
||||||
|
if selected_row_ids.contains(row_id) {
|
||||||
|
selected_row_ids.remove(row_id);
|
||||||
|
} else {
|
||||||
|
selected_row_ids.insert(row_id.to_string());
|
||||||
|
}
|
||||||
|
return Self {
|
||||||
|
selected_row_ids,
|
||||||
|
anchor_row_id: Some(row_id.to_string()),
|
||||||
|
focused_row_id: Some(row_id.to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
selected_row_ids: BTreeSet::from([row_id.to_string()]),
|
||||||
|
anchor_row_id: Some(row_id.to_string()),
|
||||||
|
focused_row_id: Some(row_id.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_context_row(&self, row_id: &str) -> Self {
|
||||||
|
if self.selected_row_ids.contains(row_id) {
|
||||||
|
return Self {
|
||||||
|
selected_row_ids: self.selected_row_ids.clone(),
|
||||||
|
anchor_row_id: self.anchor_row_id.clone(),
|
||||||
|
focused_row_id: Some(row_id.to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
selected_row_ids: BTreeSet::from([row_id.to_string()]),
|
||||||
|
anchor_row_id: Some(row_id.to_string()),
|
||||||
|
focused_row_id: Some(row_id.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_for_visible_rows(&self, visible_row_ids: &[String]) -> Self {
|
||||||
|
let visible = visible_row_ids.iter().collect::<BTreeSet<_>>();
|
||||||
|
Self {
|
||||||
|
selected_row_ids: self
|
||||||
|
.selected_row_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|row_id| visible.contains(row_id))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
anchor_row_id: self
|
||||||
|
.anchor_row_id
|
||||||
|
.as_ref()
|
||||||
|
.filter(|row_id| visible.contains(row_id))
|
||||||
|
.cloned(),
|
||||||
|
focused_row_id: self
|
||||||
|
.focused_row_id
|
||||||
|
.as_ref()
|
||||||
|
.filter(|row_id| visible.contains(row_id))
|
||||||
|
.cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&self) -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_drag_row_ids(&self, row_id: &str) -> Vec<String> {
|
||||||
|
if self.selected_row_ids.contains(row_id) {
|
||||||
|
return self.selected_row_ids.iter().cloned().collect();
|
||||||
|
}
|
||||||
|
vec![row_id.to_string()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range_row_ids(visible_row_ids: &[String], from_id: &str, to_id: &str) -> Vec<String> {
|
||||||
|
let from_index = visible_row_ids.iter().position(|row_id| row_id == from_id);
|
||||||
|
let to_index = visible_row_ids.iter().position(|row_id| row_id == to_id);
|
||||||
|
let (Some(from_index), Some(to_index)) = (from_index, to_index) else {
|
||||||
|
return vec![to_id.to_string()];
|
||||||
|
};
|
||||||
|
let low = from_index.min(to_index);
|
||||||
|
let high = from_index.max(to_index);
|
||||||
|
visible_row_ids[low..=high].to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
FileTreeSelectionModifiers, FileTreeSelectionReducerContract, FileTreeSelectionState,
|
||||||
|
FILETREE_SELECTION_REDUCER_CONTRACT_NAME,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn ids(values: &[&str]) -> Vec<String> {
|
||||||
|
values.iter().map(|value| (*value).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_filetree_selection_click_toggle_and_range_follow_contract() {
|
||||||
|
let visible = ids(&["doc:a", "doc:b", "asset:c", "asset:d"]);
|
||||||
|
let mut state = FileTreeSelectionState::default();
|
||||||
|
|
||||||
|
state = state.select_row(
|
||||||
|
"doc:b",
|
||||||
|
&visible,
|
||||||
|
FileTreeSelectionModifiers::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(state.selected_row_ids, ids(&["doc:b"]).into_iter().collect());
|
||||||
|
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:b"));
|
||||||
|
assert_eq!(state.focused_row_id.as_deref(), Some("doc:b"));
|
||||||
|
|
||||||
|
state = state.select_row(
|
||||||
|
"asset:d",
|
||||||
|
&visible,
|
||||||
|
FileTreeSelectionModifiers {
|
||||||
|
shift_key: true,
|
||||||
|
..FileTreeSelectionModifiers::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.selected_row_ids,
|
||||||
|
ids(&["doc:b", "asset:c", "asset:d"]).into_iter().collect()
|
||||||
|
);
|
||||||
|
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:b"));
|
||||||
|
assert_eq!(state.focused_row_id.as_deref(), Some("asset:d"));
|
||||||
|
|
||||||
|
state = state.select_row(
|
||||||
|
"doc:a",
|
||||||
|
&visible,
|
||||||
|
FileTreeSelectionModifiers {
|
||||||
|
ctrl_key: true,
|
||||||
|
..FileTreeSelectionModifiers::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.selected_row_ids,
|
||||||
|
ids(&["doc:a", "doc:b", "asset:c", "asset:d"]).into_iter().collect()
|
||||||
|
);
|
||||||
|
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a"));
|
||||||
|
assert_eq!(state.focused_row_id.as_deref(), Some("doc:a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_filetree_selection_context_clear_normalize_and_drag_rows_are_stable() {
|
||||||
|
let visible = ids(&["doc:a", "doc:b", "asset:c"]);
|
||||||
|
let mut state = FileTreeSelectionState::from_selected(&ids(&["doc:a", "asset:missing"]));
|
||||||
|
state.focused_row_id = Some("asset:missing".into());
|
||||||
|
|
||||||
|
state = state.normalize_for_visible_rows(&visible);
|
||||||
|
assert_eq!(state.selected_row_ids, ids(&["doc:a"]).into_iter().collect());
|
||||||
|
assert_eq!(state.anchor_row_id.as_deref(), Some("doc:a"));
|
||||||
|
assert_eq!(state.focused_row_id, None);
|
||||||
|
|
||||||
|
state = state.select_context_row("asset:c");
|
||||||
|
assert_eq!(state.selected_row_ids, ids(&["asset:c"]).into_iter().collect());
|
||||||
|
assert_eq!(state.anchor_row_id.as_deref(), Some("asset:c"));
|
||||||
|
assert_eq!(state.focused_row_id.as_deref(), Some("asset:c"));
|
||||||
|
|
||||||
|
assert_eq!(state.resolve_drag_row_ids("asset:c"), ids(&["asset:c"]));
|
||||||
|
assert_eq!(state.resolve_drag_row_ids("doc:b"), ids(&["doc:b"]));
|
||||||
|
|
||||||
|
state = state.clear();
|
||||||
|
assert!(state.selected_row_ids.is_empty());
|
||||||
|
assert_eq!(state.anchor_row_id, None);
|
||||||
|
assert_eq!(state.focused_row_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_filetree_selection_reducer_contract_exposes_supported_actions() {
|
||||||
|
let contract = FileTreeSelectionReducerContract::default();
|
||||||
|
assert_eq!(
|
||||||
|
contract.contract_name,
|
||||||
|
FILETREE_SELECTION_REDUCER_CONTRACT_NAME
|
||||||
|
);
|
||||||
|
assert!(contract.actions.contains("select_row"));
|
||||||
|
assert!(contract.actions.contains("select_context_row"));
|
||||||
|
assert!(contract.actions.contains("normalize_visible_rows"));
|
||||||
|
assert!(contract.actions.contains("clear"));
|
||||||
|
assert!(contract.actions.contains("resolve_drag_rows"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
pub const PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME: &str =
|
||||||
|
"rust_page_focus_keyboard_reducer_v1";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PageFocusKeyboardReducerContract {
|
||||||
|
pub contract_name: &'static str,
|
||||||
|
pub actions: BTreeSet<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PageFocusKeyboardReducerContract {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
contract_name: PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME,
|
||||||
|
actions: BTreeSet::from([
|
||||||
|
"normalize",
|
||||||
|
"focus",
|
||||||
|
"move_next",
|
||||||
|
"move_previous",
|
||||||
|
"move_home",
|
||||||
|
"move_end",
|
||||||
|
"expand",
|
||||||
|
"collapse",
|
||||||
|
"open",
|
||||||
|
"context_menu",
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct TreeShellFocusState {
|
||||||
|
pub focused_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeShellFocusState {
|
||||||
|
pub fn normalize(&self, visible_ids: &[String]) -> Self {
|
||||||
|
if let Some(focused_id) = self.focused_id.as_deref() {
|
||||||
|
if visible_ids.iter().any(|id| id == focused_id) {
|
||||||
|
return self.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
focused_id: visible_ids.first().cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_next(&self, visible_ids: &[String]) -> Self {
|
||||||
|
self.move_by(visible_ids, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_previous(&self, visible_ids: &[String]) -> Self {
|
||||||
|
self.move_by(visible_ids, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_home(&self, visible_ids: &[String]) -> Self {
|
||||||
|
Self {
|
||||||
|
focused_id: visible_ids.first().cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_end(&self, visible_ids: &[String]) -> Self {
|
||||||
|
Self {
|
||||||
|
focused_id: visible_ids.last().cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_by(&self, visible_ids: &[String], offset: isize) -> Self {
|
||||||
|
if visible_ids.is_empty() {
|
||||||
|
return Self::default();
|
||||||
|
}
|
||||||
|
let current_index = self
|
||||||
|
.focused_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|focused_id| visible_ids.iter().position(|id| id == focused_id))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let next_index = (current_index as isize + offset)
|
||||||
|
.clamp(0, (visible_ids.len() - 1) as isize) as usize;
|
||||||
|
Self {
|
||||||
|
focused_id: Some(visible_ids[next_index].clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
PageFocusKeyboardReducerContract, TreeShellFocusState,
|
||||||
|
PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn ids(values: &[&str]) -> Vec<String> {
|
||||||
|
values.iter().map(|value| (*value).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_focus_state_normalizes_and_moves_within_visible_rows() {
|
||||||
|
let visible_ids = ids(&["doc:a", "doc:b", "doc:c"]);
|
||||||
|
let state = TreeShellFocusState {
|
||||||
|
focused_id: Some("missing".into()),
|
||||||
|
}
|
||||||
|
.normalize(&visible_ids);
|
||||||
|
assert_eq!(state.focused_id.as_deref(), Some("doc:a"));
|
||||||
|
|
||||||
|
let state = state.move_next(&visible_ids).move_next(&visible_ids).move_next(&visible_ids);
|
||||||
|
assert_eq!(state.focused_id.as_deref(), Some("doc:c"));
|
||||||
|
assert_eq!(
|
||||||
|
state.move_previous(&visible_ids).focused_id.as_deref(),
|
||||||
|
Some("doc:b")
|
||||||
|
);
|
||||||
|
assert_eq!(state.move_home(&visible_ids).focused_id.as_deref(), Some("doc:a"));
|
||||||
|
assert_eq!(state.move_end(&visible_ids).focused_id.as_deref(), Some("doc:c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_focus_state_empty_rows_clear_focus() {
|
||||||
|
let state = TreeShellFocusState {
|
||||||
|
focused_id: Some("doc:a".into()),
|
||||||
|
}
|
||||||
|
.normalize(&[]);
|
||||||
|
assert_eq!(state.focused_id, None);
|
||||||
|
assert_eq!(state.move_next(&[]).focused_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_page_focus_keyboard_reducer_contract_exposes_supported_actions() {
|
||||||
|
let contract = PageFocusKeyboardReducerContract::default();
|
||||||
|
assert_eq!(
|
||||||
|
contract.contract_name,
|
||||||
|
PAGE_FOCUS_KEYBOARD_REDUCER_CONTRACT_NAME
|
||||||
|
);
|
||||||
|
assert!(contract.actions.contains("focus"));
|
||||||
|
assert!(contract.actions.contains("move_next"));
|
||||||
|
assert!(contract.actions.contains("move_previous"));
|
||||||
|
assert!(contract.actions.contains("move_home"));
|
||||||
|
assert!(contract.actions.contains("move_end"));
|
||||||
|
assert!(contract.actions.contains("expand"));
|
||||||
|
assert!(contract.actions.contains("collapse"));
|
||||||
|
assert!(contract.actions.contains("open"));
|
||||||
|
assert!(contract.actions.contains("context_menu"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TreeShellKeyboardIntent {
|
||||||
|
MoveNext,
|
||||||
|
MovePrevious,
|
||||||
|
MoveHome,
|
||||||
|
MoveEnd,
|
||||||
|
Expand,
|
||||||
|
Collapse,
|
||||||
|
Open,
|
||||||
|
ContextMenu,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct TreeShellKeyboardModifiers {
|
||||||
|
pub shift_key: bool,
|
||||||
|
pub ctrl_key: bool,
|
||||||
|
pub meta_key: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_tree_shell_keyboard_intent(
|
||||||
|
key: &str,
|
||||||
|
modifiers: TreeShellKeyboardModifiers,
|
||||||
|
) -> TreeShellKeyboardIntent {
|
||||||
|
match key {
|
||||||
|
"ArrowDown" => TreeShellKeyboardIntent::MoveNext,
|
||||||
|
"ArrowUp" => TreeShellKeyboardIntent::MovePrevious,
|
||||||
|
"Home" => TreeShellKeyboardIntent::MoveHome,
|
||||||
|
"End" => TreeShellKeyboardIntent::MoveEnd,
|
||||||
|
"ArrowRight" => TreeShellKeyboardIntent::Expand,
|
||||||
|
"ArrowLeft" => TreeShellKeyboardIntent::Collapse,
|
||||||
|
"Enter" => TreeShellKeyboardIntent::Open,
|
||||||
|
"ContextMenu" => TreeShellKeyboardIntent::ContextMenu,
|
||||||
|
"F10" if modifiers.shift_key && !modifiers.ctrl_key && !modifiers.meta_key => {
|
||||||
|
TreeShellKeyboardIntent::ContextMenu
|
||||||
|
}
|
||||||
|
_ => TreeShellKeyboardIntent::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
resolve_tree_shell_keyboard_intent, TreeShellKeyboardIntent, TreeShellKeyboardModifiers,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_keyboard_state_maps_navigation_open_and_context_menu_intents() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_tree_shell_keyboard_intent("ArrowDown", TreeShellKeyboardModifiers::default()),
|
||||||
|
TreeShellKeyboardIntent::MoveNext
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_tree_shell_keyboard_intent("ArrowUp", TreeShellKeyboardModifiers::default()),
|
||||||
|
TreeShellKeyboardIntent::MovePrevious
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_tree_shell_keyboard_intent("Home", TreeShellKeyboardModifiers::default()),
|
||||||
|
TreeShellKeyboardIntent::MoveHome
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_tree_shell_keyboard_intent("End", TreeShellKeyboardModifiers::default()),
|
||||||
|
TreeShellKeyboardIntent::MoveEnd
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_tree_shell_keyboard_intent("Enter", TreeShellKeyboardModifiers::default()),
|
||||||
|
TreeShellKeyboardIntent::Open
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_tree_shell_keyboard_intent(
|
||||||
|
"F10",
|
||||||
|
TreeShellKeyboardModifiers {
|
||||||
|
shift_key: true,
|
||||||
|
..TreeShellKeyboardModifiers::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TreeShellKeyboardIntent::ContextMenu
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
|
pub mod action_registry;
|
||||||
|
pub mod drag_drop_state;
|
||||||
pub mod dispatcher;
|
pub mod dispatcher;
|
||||||
|
pub mod expansion_state;
|
||||||
pub mod filetree_renderer;
|
pub mod filetree_renderer;
|
||||||
|
pub mod filetree_selection;
|
||||||
|
pub mod focus_state;
|
||||||
|
pub mod keyboard_state;
|
||||||
pub mod loader;
|
pub mod loader;
|
||||||
pub mod page_renderer;
|
pub mod page_renderer;
|
||||||
pub mod picker_renderer;
|
pub mod picker_renderer;
|
||||||
|
pub mod picker_state;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
pub mod renderer_input;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
use super::protocol;
|
use super::protocol;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PageTreeRenderRow {
|
pub struct PageTreeRenderRow {
|
||||||
pub node_id: String,
|
pub node_id: String,
|
||||||
|
pub parent_node_id: Option<String>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub depth: u32,
|
pub depth: u32,
|
||||||
pub expandable: bool,
|
pub expandable: bool,
|
||||||
|
pub expanded: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -20,6 +23,13 @@ pub struct PageTreeDomRow {
|
|||||||
pub expandable: bool,
|
pub expandable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PageTreeInitialRenderInput {
|
||||||
|
pub rows: Vec<PageTreeRenderRow>,
|
||||||
|
pub active_node_id: Option<String>,
|
||||||
|
pub focused_node_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRow> {
|
pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRow> {
|
||||||
rows.iter()
|
rows.iter()
|
||||||
.map(|row| PageTreeDomRow {
|
.map(|row| PageTreeDomRow {
|
||||||
@@ -35,24 +45,127 @@ pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRo
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn escape_html(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_page_row(
|
||||||
|
html: &mut String,
|
||||||
|
row: &PageTreeRenderRow,
|
||||||
|
children_by_parent: &BTreeMap<Option<String>, Vec<PageTreeRenderRow>>,
|
||||||
|
input: &PageTreeInitialRenderInput,
|
||||||
|
) {
|
||||||
|
let dom_rows = build_page_tree_dom_rows(&[row.clone()]);
|
||||||
|
if let Some(row) = dom_rows.first() {
|
||||||
|
let active = input
|
||||||
|
.active_node_id
|
||||||
|
.as_deref()
|
||||||
|
.map(|active_node_id| active_node_id == row.node_id)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let focused = input
|
||||||
|
.focused_node_id
|
||||||
|
.as_deref()
|
||||||
|
.map(|focused_node_id| focused_node_id == row.node_id)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let expanded = input
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.find(|source| source.node_id == row.node_id)
|
||||||
|
.map(|source| source.expanded)
|
||||||
|
.unwrap_or(false);
|
||||||
|
html.push_str(&format!(
|
||||||
|
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="{test_id}" data-node-id="{node_id}" data-shell-mode="page" data-active="{active}" data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button><button type="button" class="tree-action" data-testid="tree-action-rename" data-rust-action="rename" data-node-id="{node_id}" aria-label="重命名">✎</button><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button></div></div>"#,
|
||||||
|
node_id = escape_html(&row.node_id),
|
||||||
|
aria_level = row.depth + 1,
|
||||||
|
expanded_attr = if row.expandable && expanded { "true" } else { "false" },
|
||||||
|
test_id = row.test_id,
|
||||||
|
active = active,
|
||||||
|
focused = focused,
|
||||||
|
tab_index = if focused { "0" } else { "-1" },
|
||||||
|
title = escape_html(&row.title),
|
||||||
|
));
|
||||||
|
if row.expandable && expanded {
|
||||||
|
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
|
||||||
|
html.push_str(r#"<ul class="tree-children">"#);
|
||||||
|
for child in children {
|
||||||
|
render_page_row(html, child, children_by_parent, input);
|
||||||
|
}
|
||||||
|
html.push_str("</ul>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str("</li>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_initial_page_tree_html(input: &PageTreeInitialRenderInput) -> String {
|
||||||
|
let mut html = String::from(
|
||||||
|
r#"<ul class="tree-root" role="tree" data-rust-page-renderer="initial_v1">"#,
|
||||||
|
);
|
||||||
|
if input.rows.is_empty() {
|
||||||
|
html.push_str(
|
||||||
|
r#"<li class="tree-empty" data-rust-rendered-row="page-empty">当前 projection 没有可渲染的页面。</li>"#,
|
||||||
|
);
|
||||||
|
html.push_str("</ul>");
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids = input
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.node_id.clone())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
let mut children_by_parent = BTreeMap::<Option<String>, Vec<PageTreeRenderRow>>::new();
|
||||||
|
for row in &input.rows {
|
||||||
|
let parent_id = row
|
||||||
|
.parent_node_id
|
||||||
|
.as_ref()
|
||||||
|
.filter(|parent_id| ids.contains(*parent_id))
|
||||||
|
.cloned();
|
||||||
|
children_by_parent
|
||||||
|
.entry(parent_id)
|
||||||
|
.or_default()
|
||||||
|
.push(row.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(roots) = children_by_parent.get(&None).cloned() {
|
||||||
|
for root in &roots {
|
||||||
|
render_page_row(&mut html, root, &children_by_parent, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str("</ul>");
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{build_page_tree_dom_rows, PageTreeRenderRow};
|
use super::{
|
||||||
|
build_page_tree_dom_rows, render_initial_page_tree_html, PageTreeInitialRenderInput,
|
||||||
|
PageTreeRenderRow,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tree_shell_page_renderer_builds_rows_with_stable_testids() {
|
fn tree_shell_page_renderer_builds_rows_with_stable_testids() {
|
||||||
let rows = build_page_tree_dom_rows(&[
|
let rows = build_page_tree_dom_rows(&[
|
||||||
PageTreeRenderRow {
|
PageTreeRenderRow {
|
||||||
node_id: "page_root".into(),
|
node_id: "page_root".into(),
|
||||||
|
parent_node_id: None,
|
||||||
title: "首页".into(),
|
title: "首页".into(),
|
||||||
depth: 0,
|
depth: 0,
|
||||||
expandable: true,
|
expandable: true,
|
||||||
|
expanded: false,
|
||||||
},
|
},
|
||||||
PageTreeRenderRow {
|
PageTreeRenderRow {
|
||||||
node_id: "page_child".into(),
|
node_id: "page_child".into(),
|
||||||
|
parent_node_id: Some("page_root".into()),
|
||||||
title: "子页".into(),
|
title: "子页".into(),
|
||||||
depth: 1,
|
depth: 1,
|
||||||
expandable: false,
|
expandable: false,
|
||||||
|
expanded: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -63,4 +176,42 @@ mod tests {
|
|||||||
assert_eq!(rows[0].action_move_up_test_id, "tree-action-move-up");
|
assert_eq!(rows[0].action_move_up_test_id, "tree-action-move-up");
|
||||||
assert_eq!(rows[1].depth, 1);
|
assert_eq!(rows[1].depth, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_page_renderer_outputs_initial_html_contract() {
|
||||||
|
let html = render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
||||||
|
rows: vec![
|
||||||
|
PageTreeRenderRow {
|
||||||
|
node_id: "page_root".into(),
|
||||||
|
parent_node_id: None,
|
||||||
|
title: "首页 <安全>".into(),
|
||||||
|
depth: 0,
|
||||||
|
expandable: true,
|
||||||
|
expanded: true,
|
||||||
|
},
|
||||||
|
PageTreeRenderRow {
|
||||||
|
node_id: "page_child".into(),
|
||||||
|
parent_node_id: Some("page_root".into()),
|
||||||
|
title: "子页".into(),
|
||||||
|
depth: 1,
|
||||||
|
expandable: false,
|
||||||
|
expanded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
active_node_id: Some("page_root".into()),
|
||||||
|
focused_node_id: Some("page_root".into()),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(html.contains("data-rust-page-renderer=\"initial_v1\""));
|
||||||
|
assert!(html.contains("data-rust-rendered-row=\"page\""));
|
||||||
|
assert!(html.contains("data-shell-mode=\"page\""));
|
||||||
|
assert!(html.contains("data-rust-action=\"open\""));
|
||||||
|
assert!(html.contains("data-rust-action=\"create\""));
|
||||||
|
assert!(html.contains("draggable=\"true\""));
|
||||||
|
assert!(html.contains("tree-children"));
|
||||||
|
assert!(html.contains("子页"));
|
||||||
|
assert!(html.contains("首页 <安全>"));
|
||||||
|
assert!(html.contains("data-active=\"true\""));
|
||||||
|
assert!(html.contains("data-focused=\"true\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
use super::protocol;
|
use super::protocol;
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PickerRenderRow {
|
pub struct PickerRenderRow {
|
||||||
pub node_id: String,
|
pub node_id: String,
|
||||||
|
pub parent_node_id: Option<String>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
pub depth: u32,
|
||||||
|
pub expandable: bool,
|
||||||
|
pub expanded: bool,
|
||||||
|
pub active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -13,6 +19,13 @@ pub struct PickerRenderResult {
|
|||||||
pub allow_root_pick: bool,
|
pub allow_root_pick: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PickerInitialRenderInput {
|
||||||
|
pub rows: Vec<PickerRenderRow>,
|
||||||
|
pub allow_root_pick: bool,
|
||||||
|
pub root_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_picker_render_result(
|
pub fn build_picker_render_result(
|
||||||
rows: &[PickerRenderRow],
|
rows: &[PickerRenderRow],
|
||||||
allow_root_pick: bool,
|
allow_root_pick: bool,
|
||||||
@@ -24,10 +37,84 @@ pub fn build_picker_render_result(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn escape_html(input: &str) -> String {
|
||||||
|
input
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_picker_row(
|
||||||
|
html: &mut String,
|
||||||
|
row: &PickerRenderRow,
|
||||||
|
children_by_parent: &BTreeMap<Option<String>, Vec<PickerRenderRow>>,
|
||||||
|
) {
|
||||||
|
html.push_str(&format!(
|
||||||
|
r#"<li class="tree-node" data-node-id="{node_id}"><button type="button" class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="picker" data-testid="tree-picker-row" data-node-id="{node_id}" data-shell-mode="picker" data-focused="{active}" data-rust-action="pick"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="page" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">{title}</span></span></button>"#,
|
||||||
|
node_id = escape_html(&row.node_id),
|
||||||
|
aria_level = row.depth + 1,
|
||||||
|
expanded_attr = if row.expandable && row.expanded { "true" } else { "false" },
|
||||||
|
active = row.active,
|
||||||
|
title = escape_html(&row.title),
|
||||||
|
));
|
||||||
|
if row.expandable && row.expanded {
|
||||||
|
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
|
||||||
|
html.push_str(r#"<ul class="tree-children">"#);
|
||||||
|
for child in children {
|
||||||
|
render_picker_row(html, child, children_by_parent);
|
||||||
|
}
|
||||||
|
html.push_str("</ul>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str("</li>");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_initial_picker_html(input: &PickerInitialRenderInput) -> String {
|
||||||
|
let mut html = String::from(
|
||||||
|
r#"<ul class="tree-root" role="tree" data-rust-picker-renderer="initial_v1">"#,
|
||||||
|
);
|
||||||
|
if input.allow_root_pick {
|
||||||
|
html.push_str(&format!(
|
||||||
|
r#"<li class="tree-node" data-node-id="__root__"><button type="button" class="tree-row" data-testid="tree-picker-root" data-rust-rendered-row="picker-root" data-rust-action="pick-root" data-focused="{focused}"><span class="tree-spacer" aria-hidden="true"></span><span class="tree-link"><span class="tree-link-title">根目录</span></span></button></li>"#,
|
||||||
|
focused = input.root_active,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids = input
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| row.node_id.clone())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let mut children_by_parent = BTreeMap::<Option<String>, Vec<PickerRenderRow>>::new();
|
||||||
|
for row in &input.rows {
|
||||||
|
let parent_id = row
|
||||||
|
.parent_node_id
|
||||||
|
.as_ref()
|
||||||
|
.filter(|parent_id| ids.contains(*parent_id))
|
||||||
|
.cloned();
|
||||||
|
children_by_parent
|
||||||
|
.entry(parent_id)
|
||||||
|
.or_default()
|
||||||
|
.push(row.clone());
|
||||||
|
}
|
||||||
|
if let Some(roots) = children_by_parent.get(&None).cloned() {
|
||||||
|
for root in &roots {
|
||||||
|
render_picker_row(&mut html, root, &children_by_parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html.push_str("</ul>");
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::super::filetree_renderer::{build_filetree_testids, FileTreeRenderRow};
|
use super::super::filetree_renderer::{build_filetree_testids, FileTreeRenderRow};
|
||||||
use super::{build_picker_render_result, PickerRenderRow};
|
use super::{
|
||||||
|
build_picker_render_result, render_initial_picker_html, PickerInitialRenderInput,
|
||||||
|
PickerRenderRow,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tree_shell_filetree_picker_builds_file_rows_and_picker_mode() {
|
fn tree_shell_filetree_picker_builds_file_rows_and_picker_mode() {
|
||||||
@@ -35,18 +122,41 @@ mod tests {
|
|||||||
FileTreeRenderRow {
|
FileTreeRenderRow {
|
||||||
row_id: "doc:page_root".into(),
|
row_id: "doc:page_root".into(),
|
||||||
row_kind: "document".into(),
|
row_kind: "document".into(),
|
||||||
|
node_id: "page_root".into(),
|
||||||
|
parent_node_id: None,
|
||||||
title: "首页".into(),
|
title: "首页".into(),
|
||||||
|
depth: 0,
|
||||||
|
expandable: false,
|
||||||
|
expanded: false,
|
||||||
|
icon_kind: "page".into(),
|
||||||
|
document_id: Some("page_root".into()),
|
||||||
|
asset_id: None,
|
||||||
|
selected: false,
|
||||||
},
|
},
|
||||||
FileTreeRenderRow {
|
FileTreeRenderRow {
|
||||||
row_id: "asset:asset_1".into(),
|
row_id: "asset:asset_1".into(),
|
||||||
row_kind: "asset".into(),
|
row_kind: "asset".into(),
|
||||||
|
node_id: "asset:asset_1".into(),
|
||||||
|
parent_node_id: Some("page_root".into()),
|
||||||
title: "附件".into(),
|
title: "附件".into(),
|
||||||
|
depth: 1,
|
||||||
|
expandable: false,
|
||||||
|
expanded: false,
|
||||||
|
icon_kind: "file".into(),
|
||||||
|
document_id: Some("page_root".into()),
|
||||||
|
asset_id: Some("asset_1".into()),
|
||||||
|
selected: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
let picker = build_picker_render_result(
|
let picker = build_picker_render_result(
|
||||||
&[PickerRenderRow {
|
&[PickerRenderRow {
|
||||||
node_id: "page_root".into(),
|
node_id: "page_root".into(),
|
||||||
|
parent_node_id: None,
|
||||||
title: "首页".into(),
|
title: "首页".into(),
|
||||||
|
depth: 0,
|
||||||
|
expandable: false,
|
||||||
|
expanded: false,
|
||||||
|
active: false,
|
||||||
}],
|
}],
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -56,4 +166,29 @@ mod tests {
|
|||||||
assert_eq!(picker.root_test_id, "tree-picker-root");
|
assert_eq!(picker.root_test_id, "tree-picker-root");
|
||||||
assert!(picker.allow_root_pick);
|
assert!(picker.allow_root_pick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_picker_renderer_outputs_initial_html_contract() {
|
||||||
|
let html = render_initial_picker_html(&PickerInitialRenderInput {
|
||||||
|
allow_root_pick: true,
|
||||||
|
root_active: false,
|
||||||
|
rows: vec![PickerRenderRow {
|
||||||
|
node_id: "page_root".into(),
|
||||||
|
parent_node_id: None,
|
||||||
|
title: "首页 <安全>".into(),
|
||||||
|
depth: 0,
|
||||||
|
expandable: false,
|
||||||
|
expanded: false,
|
||||||
|
active: true,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(html.contains("data-rust-picker-renderer=\"initial_v1\""));
|
||||||
|
assert!(html.contains("data-rust-rendered-row=\"picker-root\""));
|
||||||
|
assert!(html.contains("data-rust-rendered-row=\"picker\""));
|
||||||
|
assert!(html.contains("data-testid=\"tree-picker-root\""));
|
||||||
|
assert!(html.contains("data-testid=\"tree-picker-row\""));
|
||||||
|
assert!(html.contains("首页 <安全>"));
|
||||||
|
assert!(html.contains("data-focused=\"true\""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
pub const PICKER_STATE_REDUCER_CONTRACT_NAME: &str = "rust_picker_state_reducer_v1";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PickerStateReducerContract {
|
||||||
|
pub contract_name: &'static str,
|
||||||
|
pub actions: BTreeSet<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PickerStateReducerContract {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
contract_name: PICKER_STATE_REDUCER_CONTRACT_NAME,
|
||||||
|
actions: BTreeSet::from([
|
||||||
|
"normalize",
|
||||||
|
"focus",
|
||||||
|
"next",
|
||||||
|
"previous",
|
||||||
|
"home",
|
||||||
|
"end",
|
||||||
|
"pick",
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PickerItem {
|
||||||
|
pub item_key: String,
|
||||||
|
pub document_id: Option<String>,
|
||||||
|
pub pickable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct PickerState {
|
||||||
|
pub active_item_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PickerState {
|
||||||
|
pub fn normalize(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
|
||||||
|
if let Some(active_item_key) = self.active_item_key.as_deref() {
|
||||||
|
if is_pickable_item_key(items, excluded_ids, active_item_key) {
|
||||||
|
return self.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
active_item_key: first_pickable_item_key(items, excluded_ids),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_next(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
|
||||||
|
self.move_by(items, excluded_ids, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_previous(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
|
||||||
|
self.move_by(items, excluded_ids, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_home(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
active_item_key: first_pickable_item_key(items, excluded_ids),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn move_end(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
active_item_key: pickable_items(items, excluded_ids).last().map(|item| item.item_key.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pick(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Option<String> {
|
||||||
|
let active = self.active_item_key.as_deref()?;
|
||||||
|
pickable_items(items, excluded_ids)
|
||||||
|
.find(|item| item.item_key == active)
|
||||||
|
.and_then(|item| item.document_id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_by(&self, items: &[PickerItem], excluded_ids: &BTreeSet<String>, offset: isize) -> Self {
|
||||||
|
let pickable = pickable_items(items, excluded_ids).collect::<Vec<_>>();
|
||||||
|
if pickable.is_empty() {
|
||||||
|
return Self::default();
|
||||||
|
}
|
||||||
|
let current_index = self
|
||||||
|
.active_item_key
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|active| pickable.iter().position(|item| item.item_key == active))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let next_index = (current_index as isize + offset)
|
||||||
|
.clamp(0, (pickable.len() - 1) as isize) as usize;
|
||||||
|
Self {
|
||||||
|
active_item_key: Some(pickable[next_index].item_key.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_item_excluded(item: &PickerItem, excluded_ids: &BTreeSet<String>) -> bool {
|
||||||
|
item.document_id
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|document_id| excluded_ids.contains(document_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pickable_items<'a>(
|
||||||
|
items: &'a [PickerItem],
|
||||||
|
excluded_ids: &'a BTreeSet<String>,
|
||||||
|
) -> impl DoubleEndedIterator<Item = &'a PickerItem> + 'a {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter(move |item| item.pickable && !is_item_excluded(item, excluded_ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_pickable_item_key(items: &[PickerItem], excluded_ids: &BTreeSet<String>) -> Option<String> {
|
||||||
|
pickable_items(items, excluded_ids).next().map(|item| item.item_key.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_pickable_item_key(
|
||||||
|
items: &[PickerItem],
|
||||||
|
excluded_ids: &BTreeSet<String>,
|
||||||
|
item_key: &str,
|
||||||
|
) -> bool {
|
||||||
|
pickable_items(items, excluded_ids).any(|item| item.item_key == item_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
PickerItem, PickerState, PickerStateReducerContract, PICKER_STATE_REDUCER_CONTRACT_NAME,
|
||||||
|
};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
fn excluded(values: &[&str]) -> BTreeSet<String> {
|
||||||
|
values.iter().map(|value| (*value).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item(item_key: &str, document_id: Option<&str>, pickable: bool) -> PickerItem {
|
||||||
|
PickerItem {
|
||||||
|
item_key: item_key.into(),
|
||||||
|
document_id: document_id.map(ToOwned::to_owned),
|
||||||
|
pickable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_picker_state_skips_excluded_items_and_picks_active_document() {
|
||||||
|
let items = vec![
|
||||||
|
item("root", None, true),
|
||||||
|
item("doc:a", Some("doc:a"), true),
|
||||||
|
item("doc:b", Some("doc:b"), true),
|
||||||
|
item("doc:c", Some("doc:c"), true),
|
||||||
|
];
|
||||||
|
let excluded_ids = excluded(&["doc:b"]);
|
||||||
|
|
||||||
|
let state = PickerState {
|
||||||
|
active_item_key: Some("doc:b".into()),
|
||||||
|
}
|
||||||
|
.normalize(&items, &excluded_ids);
|
||||||
|
assert_eq!(state.active_item_key.as_deref(), Some("root"));
|
||||||
|
|
||||||
|
let state = state.move_next(&items, &excluded_ids).move_next(&items, &excluded_ids);
|
||||||
|
assert_eq!(state.active_item_key.as_deref(), Some("doc:c"));
|
||||||
|
assert_eq!(state.pick(&items, &excluded_ids).as_deref(), Some("doc:c"));
|
||||||
|
|
||||||
|
let state = state.move_previous(&items, &excluded_ids);
|
||||||
|
assert_eq!(state.active_item_key.as_deref(), Some("doc:a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_picker_state_home_end_and_empty_cases_are_stable() {
|
||||||
|
let items = vec![
|
||||||
|
item("doc:a", Some("doc:a"), true),
|
||||||
|
item("doc:b", Some("doc:b"), false),
|
||||||
|
item("doc:c", Some("doc:c"), true),
|
||||||
|
];
|
||||||
|
let excluded_ids = excluded(&[]);
|
||||||
|
let state = PickerState::default().move_end(&items, &excluded_ids);
|
||||||
|
assert_eq!(state.active_item_key.as_deref(), Some("doc:c"));
|
||||||
|
assert_eq!(
|
||||||
|
state.move_home(&items, &excluded_ids).active_item_key.as_deref(),
|
||||||
|
Some("doc:a")
|
||||||
|
);
|
||||||
|
|
||||||
|
let empty = PickerState::default().normalize(&items, &excluded(&["doc:a", "doc:c"]));
|
||||||
|
assert_eq!(empty.active_item_key, None);
|
||||||
|
assert_eq!(empty.pick(&items, &excluded(&["doc:a", "doc:c"])), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_picker_state_reducer_contract_exposes_supported_actions() {
|
||||||
|
let contract = PickerStateReducerContract::default();
|
||||||
|
assert_eq!(contract.contract_name, PICKER_STATE_REDUCER_CONTRACT_NAME);
|
||||||
|
assert!(contract.actions.contains("normalize"));
|
||||||
|
assert!(contract.actions.contains("focus"));
|
||||||
|
assert!(contract.actions.contains("next"));
|
||||||
|
assert!(contract.actions.contains("previous"));
|
||||||
|
assert!(contract.actions.contains("home"));
|
||||||
|
assert!(contract.actions.contains("end"));
|
||||||
|
assert!(contract.actions.contains("pick"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
use super::filetree_selection::{FileTreeSelectionReducerContract, FileTreeSelectionState};
|
||||||
|
use super::focus_state::PageFocusKeyboardReducerContract;
|
||||||
|
use super::picker_state::PickerStateReducerContract;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum TreeShellRendererMode {
|
||||||
|
Page,
|
||||||
|
FileTree,
|
||||||
|
Picker,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TreeShellRendererInput {
|
||||||
|
pub mode: TreeShellRendererMode,
|
||||||
|
pub projection_item_ids: Vec<String>,
|
||||||
|
pub expanded_ids: BTreeSet<String>,
|
||||||
|
pub focused_id: Option<String>,
|
||||||
|
pub page_focus_keyboard_reducer: Option<PageFocusKeyboardReducerContract>,
|
||||||
|
pub filetree_selection: FileTreeSelectionState,
|
||||||
|
pub filetree_selection_reducer: Option<FileTreeSelectionReducerContract>,
|
||||||
|
pub active_picker_item: Option<String>,
|
||||||
|
pub excluded_picker_ids: BTreeSet<String>,
|
||||||
|
pub picker_state_reducer: Option<PickerStateReducerContract>,
|
||||||
|
pub command_dispatcher: TreeShellCommandDispatcher,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TreeShellCommandDispatcher {
|
||||||
|
pub channel: String,
|
||||||
|
pub command_names: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeShellRendererInput {
|
||||||
|
pub fn page(input: PageTreeRendererInput) -> Self {
|
||||||
|
Self {
|
||||||
|
mode: TreeShellRendererMode::Page,
|
||||||
|
projection_item_ids: input.projection_item_ids,
|
||||||
|
expanded_ids: input.expanded_ids,
|
||||||
|
focused_id: input.focused_id,
|
||||||
|
page_focus_keyboard_reducer: Some(PageFocusKeyboardReducerContract::default()),
|
||||||
|
filetree_selection: FileTreeSelectionState::default(),
|
||||||
|
filetree_selection_reducer: None,
|
||||||
|
active_picker_item: None,
|
||||||
|
excluded_picker_ids: BTreeSet::new(),
|
||||||
|
picker_state_reducer: None,
|
||||||
|
command_dispatcher: input.command_dispatcher,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filetree(input: FileTreeRendererInput) -> Self {
|
||||||
|
Self {
|
||||||
|
mode: TreeShellRendererMode::FileTree,
|
||||||
|
projection_item_ids: input.projection_item_ids,
|
||||||
|
expanded_ids: input.expanded_ids,
|
||||||
|
focused_id: input.filetree_selection.focused_row_id.clone(),
|
||||||
|
page_focus_keyboard_reducer: None,
|
||||||
|
filetree_selection: input.filetree_selection,
|
||||||
|
filetree_selection_reducer: Some(FileTreeSelectionReducerContract::default()),
|
||||||
|
active_picker_item: None,
|
||||||
|
excluded_picker_ids: BTreeSet::new(),
|
||||||
|
picker_state_reducer: None,
|
||||||
|
command_dispatcher: input.command_dispatcher,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn picker(input: PickerRendererInput) -> Self {
|
||||||
|
Self {
|
||||||
|
mode: TreeShellRendererMode::Picker,
|
||||||
|
projection_item_ids: input.projection_item_ids,
|
||||||
|
expanded_ids: input.expanded_ids,
|
||||||
|
focused_id: input.active_picker_item.clone(),
|
||||||
|
page_focus_keyboard_reducer: None,
|
||||||
|
filetree_selection: FileTreeSelectionState::default(),
|
||||||
|
filetree_selection_reducer: None,
|
||||||
|
active_picker_item: input.active_picker_item,
|
||||||
|
excluded_picker_ids: input.excluded_picker_ids,
|
||||||
|
picker_state_reducer: Some(PickerStateReducerContract::default()),
|
||||||
|
command_dispatcher: input.command_dispatcher,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PageTreeRendererInput {
|
||||||
|
pub projection_item_ids: Vec<String>,
|
||||||
|
pub expanded_ids: BTreeSet<String>,
|
||||||
|
pub focused_id: Option<String>,
|
||||||
|
pub command_dispatcher: TreeShellCommandDispatcher,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct FileTreeRendererInput {
|
||||||
|
pub projection_item_ids: Vec<String>,
|
||||||
|
pub expanded_ids: BTreeSet<String>,
|
||||||
|
pub filetree_selection: FileTreeSelectionState,
|
||||||
|
pub command_dispatcher: TreeShellCommandDispatcher,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PickerRendererInput {
|
||||||
|
pub projection_item_ids: Vec<String>,
|
||||||
|
pub expanded_ids: BTreeSet<String>,
|
||||||
|
pub active_picker_item: Option<String>,
|
||||||
|
pub excluded_picker_ids: BTreeSet<String>,
|
||||||
|
pub command_dispatcher: TreeShellCommandDispatcher,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
|
||||||
|
TreeShellRendererInput, TreeShellRendererMode,
|
||||||
|
};
|
||||||
|
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
fn set(values: &[&str]) -> BTreeSet<String> {
|
||||||
|
values.iter().map(|value| (*value).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatcher() -> TreeShellCommandDispatcher {
|
||||||
|
TreeShellCommandDispatcher {
|
||||||
|
channel: "mnote.tree.shell".into(),
|
||||||
|
command_names: set(&["tree.node.create", "tree.resource.move"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_shell_renderer_input_contract_covers_page_filetree_and_picker_state() {
|
||||||
|
let page = TreeShellRendererInput::page(PageTreeRendererInput {
|
||||||
|
projection_item_ids: vec!["doc:root".into()],
|
||||||
|
expanded_ids: set(&["doc:root"]),
|
||||||
|
focused_id: Some("doc:root".into()),
|
||||||
|
command_dispatcher: dispatcher(),
|
||||||
|
});
|
||||||
|
assert_eq!(page.mode, TreeShellRendererMode::Page);
|
||||||
|
assert_eq!(page.focused_id.as_deref(), Some("doc:root"));
|
||||||
|
assert_eq!(page.command_dispatcher.channel, "mnote.tree.shell");
|
||||||
|
assert_eq!(
|
||||||
|
page.page_focus_keyboard_reducer
|
||||||
|
.as_ref()
|
||||||
|
.map(|contract| contract.contract_name),
|
||||||
|
Some("rust_page_focus_keyboard_reducer_v1")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut selection = FileTreeSelectionState::from_selected(&["asset:a".into()]);
|
||||||
|
selection.focused_row_id = Some("asset:a".into());
|
||||||
|
let filetree = TreeShellRendererInput::filetree(FileTreeRendererInput {
|
||||||
|
projection_item_ids: vec!["doc:root".into(), "asset:a".into()],
|
||||||
|
expanded_ids: set(&["doc:root"]),
|
||||||
|
filetree_selection: selection,
|
||||||
|
command_dispatcher: dispatcher(),
|
||||||
|
});
|
||||||
|
assert_eq!(filetree.mode, TreeShellRendererMode::FileTree);
|
||||||
|
assert_eq!(filetree.focused_id.as_deref(), Some("asset:a"));
|
||||||
|
assert!(filetree.filetree_selection.selected_row_ids.contains("asset:a"));
|
||||||
|
assert!(filetree.page_focus_keyboard_reducer.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
filetree
|
||||||
|
.filetree_selection_reducer
|
||||||
|
.as_ref()
|
||||||
|
.map(|contract| contract.contract_name),
|
||||||
|
Some("rust_filetree_selection_reducer_v1")
|
||||||
|
);
|
||||||
|
|
||||||
|
let picker = TreeShellRendererInput::picker(PickerRendererInput {
|
||||||
|
projection_item_ids: vec!["doc:root".into(), "doc:child".into()],
|
||||||
|
expanded_ids: set(&["doc:root"]),
|
||||||
|
active_picker_item: Some("doc:child".into()),
|
||||||
|
excluded_picker_ids: set(&["doc:archived"]),
|
||||||
|
command_dispatcher: dispatcher(),
|
||||||
|
});
|
||||||
|
assert_eq!(picker.mode, TreeShellRendererMode::Picker);
|
||||||
|
assert_eq!(picker.focused_id.as_deref(), Some("doc:child"));
|
||||||
|
assert!(picker.excluded_picker_ids.contains("doc:archived"));
|
||||||
|
assert!(picker.page_focus_keyboard_reducer.is_none());
|
||||||
|
assert!(picker.filetree_selection_reducer.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
picker
|
||||||
|
.picker_state_reducer
|
||||||
|
.as_ref()
|
||||||
|
.map(|contract| contract.contract_name),
|
||||||
|
Some("rust_picker_state_reducer_v1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,13 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
|
|||||||
"tree.node.purge" => "documents:purge",
|
"tree.node.purge" => "documents:purge",
|
||||||
"tree.subtree.move" => "documents:move",
|
"tree.subtree.move" => "documents:move",
|
||||||
"tree.subtree.copy" => "documents:copyTree",
|
"tree.subtree.copy" => "documents:copyTree",
|
||||||
|
"tree.resource.copy" => "mediaAssets:batchCopy",
|
||||||
|
"tree.resource.move" => "mediaAssets:batchMove",
|
||||||
|
"tree.resource.upload" => "mediaAssets:createWithStorage",
|
||||||
|
"tree.filetree.drop.preflight" => "tree:fileTreeDropPreflight",
|
||||||
|
"tree.filetree.delete.preflight" => "tree:fileTreeDeletePreflight",
|
||||||
|
"tree.filetree.paste.preflight" => "tree:fileTreePastePreflight",
|
||||||
|
"tree.filetree.upload-target.preflight" => "tree:fileTreeUploadTargetPreflight",
|
||||||
"tree.node.embed" => "documents:updateContent",
|
"tree.node.embed" => "documents:updateContent",
|
||||||
"documents.create" => "documents:createWithParentReference",
|
"documents.create" => "documents:createWithParentReference",
|
||||||
"documents.move" => "documents:move",
|
"documents.move" => "documents:move",
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
export type DocumentMoveOrderDocument = {
|
||||||
|
id: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
sort_order?: number | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentMoveOrderPatch = {
|
||||||
|
documentId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
moved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentMoveOrderPlan = {
|
||||||
|
documentId: string;
|
||||||
|
fromParentId: string | null;
|
||||||
|
toParentId: string | null;
|
||||||
|
requestedSortOrder: number;
|
||||||
|
normalizedSortOrder: number;
|
||||||
|
patches: DocumentMoveOrderPatch[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeParentId(value: string | null | undefined): string | null {
|
||||||
|
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSortOrder(value: number | null | undefined): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
return Math.floor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDocumentMoveOrder(a: DocumentMoveOrderDocument, b: DocumentMoveOrderDocument): number {
|
||||||
|
const orderA = normalizeSortOrder(a.sort_order);
|
||||||
|
const orderB = normalizeSortOrder(b.sort_order);
|
||||||
|
if (orderA !== orderB) return orderA - orderB;
|
||||||
|
const createdA = String(a.created_at ?? "");
|
||||||
|
const createdB = String(b.created_at ?? "");
|
||||||
|
if (createdA !== createdB) return createdA.localeCompare(createdB);
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampMoveIndex(raw: number, max: number): number {
|
||||||
|
const value = Number.isFinite(raw) ? Math.floor(raw) : 0;
|
||||||
|
if (value < 0) return 0;
|
||||||
|
if (value > max) return max;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendOrderPatches(
|
||||||
|
patches: DocumentMoveOrderPatch[],
|
||||||
|
ordered: DocumentMoveOrderDocument[],
|
||||||
|
parentId: string | null,
|
||||||
|
movedDocumentId: string,
|
||||||
|
) {
|
||||||
|
ordered.forEach((document, index) => {
|
||||||
|
const moved = document.id === movedDocumentId;
|
||||||
|
if (normalizeParentId(document.parent_id) === parentId && normalizeSortOrder(document.sort_order) === index && !moved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
patches.push({
|
||||||
|
documentId: document.id,
|
||||||
|
parentId,
|
||||||
|
sortOrder: index,
|
||||||
|
moved,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDocumentMoveOrderPlanFromDocuments(input: {
|
||||||
|
documents: readonly DocumentMoveOrderDocument[];
|
||||||
|
documentId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}): DocumentMoveOrderPlan {
|
||||||
|
const source = input.documents.find((document) => document.id === input.documentId);
|
||||||
|
if (!source) {
|
||||||
|
throw new Error("源页面不存在或无权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromParentId = normalizeParentId(source.parent_id);
|
||||||
|
const toParentId = normalizeParentId(input.parentId);
|
||||||
|
const siblingsByParent = new Map<string | null, DocumentMoveOrderDocument[]>();
|
||||||
|
input.documents.forEach((document) => {
|
||||||
|
const parentId = normalizeParentId(document.parent_id);
|
||||||
|
const bucket = siblingsByParent.get(parentId);
|
||||||
|
if (bucket) bucket.push(document);
|
||||||
|
else siblingsByParent.set(parentId, [document]);
|
||||||
|
});
|
||||||
|
siblingsByParent.forEach((siblings) => siblings.sort(compareDocumentMoveOrder));
|
||||||
|
|
||||||
|
const patches: DocumentMoveOrderPatch[] = [];
|
||||||
|
let normalizedSortOrder = 0;
|
||||||
|
|
||||||
|
if (fromParentId === toParentId) {
|
||||||
|
const siblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||||
|
normalizedSortOrder = clampMoveIndex(input.sortOrder, siblings.length);
|
||||||
|
siblings.splice(normalizedSortOrder, 0, source);
|
||||||
|
appendOrderPatches(patches, siblings, toParentId, input.documentId);
|
||||||
|
} else {
|
||||||
|
const oldSiblings = [...(siblingsByParent.get(fromParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||||
|
appendOrderPatches(patches, oldSiblings, fromParentId, input.documentId);
|
||||||
|
|
||||||
|
const newSiblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||||
|
normalizedSortOrder = clampMoveIndex(input.sortOrder, newSiblings.length);
|
||||||
|
newSiblings.splice(normalizedSortOrder, 0, source);
|
||||||
|
appendOrderPatches(patches, newSiblings, toParentId, input.documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentId: input.documentId,
|
||||||
|
fromParentId,
|
||||||
|
toParentId,
|
||||||
|
requestedSortOrder: input.sortOrder,
|
||||||
|
normalizedSortOrder,
|
||||||
|
patches,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlan(value: unknown): DocumentMoveOrderPlan | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const record = value as Partial<DocumentMoveOrderPlan>;
|
||||||
|
if (
|
||||||
|
typeof record.documentId !== "string" ||
|
||||||
|
typeof record.requestedSortOrder !== "number" ||
|
||||||
|
typeof record.normalizedSortOrder !== "number" ||
|
||||||
|
!Array.isArray(record.patches)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
documentId: record.documentId,
|
||||||
|
fromParentId: normalizeParentId(record.fromParentId),
|
||||||
|
toParentId: normalizeParentId(record.toParentId),
|
||||||
|
requestedSortOrder: record.requestedSortOrder,
|
||||||
|
normalizedSortOrder: record.normalizedSortOrder,
|
||||||
|
patches: record.patches.map((patch) => {
|
||||||
|
const item = patch as Partial<DocumentMoveOrderPatch>;
|
||||||
|
return {
|
||||||
|
documentId: String(item.documentId ?? ""),
|
||||||
|
parentId: normalizeParentId(item.parentId),
|
||||||
|
sortOrder: typeof item.sortOrder === "number" && Number.isFinite(item.sortOrder) ? Math.floor(item.sortOrder) : -1,
|
||||||
|
moved: Boolean(item.moved),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: DocumentMoveOrderPlan) {
|
||||||
|
const normalizedExpected = normalizePlan(expected);
|
||||||
|
if (!normalizedExpected) {
|
||||||
|
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(normalizedExpected) !== JSON.stringify(actual)) {
|
||||||
|
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ import { v } from "convex/values";
|
|||||||
import { requireUserId } from "./_utils/auth";
|
import { requireUserId } from "./_utils/auth";
|
||||||
import { nowIso } from "./_utils/time";
|
import { nowIso } from "./_utils/time";
|
||||||
import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree";
|
import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree";
|
||||||
|
import {
|
||||||
|
assertDocumentMoveOrderPlanMatches,
|
||||||
|
buildDocumentMoveOrderPlanFromDocuments,
|
||||||
|
} from "./_utils/documentMoveOrder";
|
||||||
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
|
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
|
||||||
import { extractTextFromDocumentContent } from "./_utils/text";
|
import { extractTextFromDocumentContent } from "./_utils/text";
|
||||||
import {
|
import {
|
||||||
@@ -216,6 +220,21 @@ async function createDocumentRecord(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toDocumentDeltaRecord(doc: any) {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
workspace_id: doc.workspace_id,
|
||||||
|
title: doc.title ?? null,
|
||||||
|
parent_id: doc.parent_id ?? null,
|
||||||
|
sort_order: doc.sort_order ?? null,
|
||||||
|
is_starred: doc.is_starred ?? false,
|
||||||
|
access_scope: (doc.access_scope ?? "private") as "private" | "shared" | "public",
|
||||||
|
is_template: Boolean(doc.is_template),
|
||||||
|
created_at: doc.created_at ?? nowIso(),
|
||||||
|
updated_at: doc.updated_at ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function updateDocumentContentRecord(
|
async function updateDocumentContentRecord(
|
||||||
ctx: any,
|
ctx: any,
|
||||||
args: {
|
args: {
|
||||||
@@ -1198,7 +1217,7 @@ export const create = mutation({
|
|||||||
deleted_by: null,
|
deleted_by: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const document = {
|
||||||
id: args.id,
|
id: args.id,
|
||||||
title,
|
title,
|
||||||
parent_id: args.parentId,
|
parent_id: args.parentId,
|
||||||
@@ -1210,6 +1229,10 @@ export const create = mutation({
|
|||||||
access_scope: args.accessScope,
|
access_scope: args.accessScope,
|
||||||
is_template: false,
|
is_template: false,
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
|
...document,
|
||||||
|
document: toDocumentDeltaRecord(document),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1376,6 +1399,7 @@ export const move = mutation({
|
|||||||
id: v.string(),
|
id: v.string(),
|
||||||
parentId: v.union(v.string(), v.null()),
|
parentId: v.union(v.string(), v.null()),
|
||||||
sortOrder: v.number(),
|
sortOrder: v.number(),
|
||||||
|
normalizedMove: v.optional(v.any()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const userId = await requireUserId(ctx);
|
const userId = await requireUserId(ctx);
|
||||||
@@ -1386,6 +1410,14 @@ export const move = mutation({
|
|||||||
if (doc.user_id !== userId) throw new Error("无权限");
|
if (doc.user_id !== userId) throw new Error("无权限");
|
||||||
|
|
||||||
const toParentId = args.parentId;
|
const toParentId = args.parentId;
|
||||||
|
const workspaceDocs = await ctx.db
|
||||||
|
.query("documents")
|
||||||
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
|
||||||
|
.collect();
|
||||||
|
const canonicalWorkspaceDocs = pickCanonicalDocumentRecordsByBusinessId(workspaceDocs)
|
||||||
|
.filter((row) => row.user_id === userId)
|
||||||
|
.filter((row) => row.deleted_at == null);
|
||||||
|
|
||||||
if (toParentId === doc.id) {
|
if (toParentId === doc.id) {
|
||||||
throw new Error("不能把页面移动到自身下面");
|
throw new Error("不能把页面移动到自身下面");
|
||||||
}
|
}
|
||||||
@@ -1398,19 +1430,24 @@ export const move = mutation({
|
|||||||
throw new Error("暂不支持跨工作空间移动页面");
|
throw new Error("暂不支持跨工作空间移动页面");
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceDocs = await ctx.db
|
|
||||||
.query("documents")
|
|
||||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
|
|
||||||
.collect();
|
|
||||||
const canonicalWorkspaceDocs = pickCanonicalDocumentRecordsByBusinessId(workspaceDocs)
|
|
||||||
.filter((row) => row.user_id === userId)
|
|
||||||
.filter((row) => row.deleted_at == null);
|
|
||||||
const parentById = buildParentById(canonicalWorkspaceDocs);
|
const parentById = buildParentById(canonicalWorkspaceDocs);
|
||||||
if (isAncestorOf(doc.id, toParentId, parentById)) {
|
if (isAncestorOf(doc.id, toParentId, parentById)) {
|
||||||
throw new Error("不能把页面移动到自己的后代下面");
|
throw new Error("不能把页面移动到自己的后代下面");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.normalizedMove != null) {
|
||||||
|
assertDocumentMoveOrderPlanMatches(
|
||||||
|
args.normalizedMove,
|
||||||
|
buildDocumentMoveOrderPlanFromDocuments({
|
||||||
|
documents: canonicalWorkspaceDocs,
|
||||||
|
documentId: doc.id,
|
||||||
|
parentId: toParentId,
|
||||||
|
sortOrder: args.sortOrder,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order,
|
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order,
|
||||||
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
|
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
|
||||||
// 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。
|
// 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。
|
||||||
@@ -1565,7 +1602,18 @@ export const restore = mutation({
|
|||||||
await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts });
|
await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return {
|
||||||
|
ok: true,
|
||||||
|
updated_at: ts,
|
||||||
|
document: toDocumentDeltaRecord({
|
||||||
|
...doc,
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
parent_id: null,
|
||||||
|
access_scope: "private",
|
||||||
|
updated_at: ts,
|
||||||
|
}),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1801,8 +1849,10 @@ export const duplicate = mutation({
|
|||||||
title,
|
title,
|
||||||
parent_id: source.parent_id ?? null,
|
parent_id: source.parent_id ?? null,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
|
is_starred: false,
|
||||||
workspace_id: source.workspace_id,
|
workspace_id: source.workspace_id,
|
||||||
access_scope: source.access_scope,
|
access_scope: source.access_scope,
|
||||||
|
is_template: false,
|
||||||
created_at: ts,
|
created_at: ts,
|
||||||
updated_at: ts,
|
updated_at: ts,
|
||||||
};
|
};
|
||||||
@@ -1921,7 +1971,12 @@ export const copyTree = mutation({
|
|||||||
throw new Error("没有可复制的页面");
|
throw new Error("没有可复制的页面");
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertedDocs: Array<{ oldId: string; newId: string; title: string }> = [];
|
const insertedDocs: Array<{
|
||||||
|
oldId: string;
|
||||||
|
newId: string;
|
||||||
|
title: string;
|
||||||
|
document: ReturnType<typeof toDocumentDeltaRecord>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
for (const item of copyQueue) {
|
for (const item of copyQueue) {
|
||||||
const newId = newIdByOldId.get(item.old.id)!;
|
const newId = newIdByOldId.get(item.old.id)!;
|
||||||
@@ -1935,7 +1990,7 @@ export const copyTree = mutation({
|
|||||||
|
|
||||||
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
||||||
|
|
||||||
await createDocumentRecord(ctx, {
|
const created = await createDocumentRecord(ctx, {
|
||||||
id: newId,
|
id: newId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
parentId,
|
parentId,
|
||||||
@@ -1945,7 +2000,12 @@ export const copyTree = mutation({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await copyMindmapsForDocument(ctx, item.old.id, newId);
|
await copyMindmapsForDocument(ctx, item.old.id, newId);
|
||||||
insertedDocs.push({ oldId: item.old.id, newId, title: newTitle });
|
insertedDocs.push({
|
||||||
|
oldId: item.old.id,
|
||||||
|
newId,
|
||||||
|
title: newTitle,
|
||||||
|
document: toDocumentDeltaRecord(created),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -72,6 +72,201 @@ function shouldExtractAttachmentText(args: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitExtension(fileName: string): { base: string; ext: string } {
|
||||||
|
const safe = fileName.trim();
|
||||||
|
const lastDot = safe.lastIndexOf(".");
|
||||||
|
if (lastDot <= 0 || lastDot === safe.length - 1) {
|
||||||
|
return { base: safe, ext: "" };
|
||||||
|
}
|
||||||
|
return { base: safe.slice(0, lastDot), ext: safe.slice(lastDot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUniqueFileName(fileName: string, existing: Set<string>): string {
|
||||||
|
const safe = (fileName.trim() || "附件").replace(/[\\/]/g, "_");
|
||||||
|
if (!existing.has(safe)) {
|
||||||
|
existing.add(safe);
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
const { base, ext } = splitExtension(safe);
|
||||||
|
const first = `${base} 副本${ext}`;
|
||||||
|
if (!existing.has(first)) {
|
||||||
|
existing.add(first);
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
for (let i = 2; i < 1000; i += 1) {
|
||||||
|
const candidate = `${base} 副本 ${i}${ext}`;
|
||||||
|
if (!existing.has(candidate)) {
|
||||||
|
existing.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fallback = `${base} 副本 ${Date.now()}${ext}`;
|
||||||
|
existing.add(fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values: string[]): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const normalized = String(value ?? "").trim();
|
||||||
|
if (!normalized || out.includes(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(normalized);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateResourceTransferPlan(args: {
|
||||||
|
action: "copy" | "move";
|
||||||
|
assetIds: string[];
|
||||||
|
targetDocumentId: string;
|
||||||
|
targetSubPath?: string | null;
|
||||||
|
resourceTransferPlan?: any;
|
||||||
|
}) {
|
||||||
|
const plan = args.resourceTransferPlan;
|
||||||
|
if (!plan) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.action !== args.action) {
|
||||||
|
throw new Error("资源操作计划不一致");
|
||||||
|
}
|
||||||
|
if (String(plan.targetDocumentId ?? "").trim() !== args.targetDocumentId) {
|
||||||
|
throw new Error("资源目标页面计划不一致");
|
||||||
|
}
|
||||||
|
const plannedSubPath = String(plan.targetSubPath ?? "").trim();
|
||||||
|
const actualSubPath = String(args.targetSubPath ?? "").trim();
|
||||||
|
if (plannedSubPath !== actualSubPath) {
|
||||||
|
throw new Error("资源目标子路径计划不一致");
|
||||||
|
}
|
||||||
|
const plannedAssetIds = Array.isArray(plan.assetIds)
|
||||||
|
? uniqueStrings(plan.assetIds.map((value: unknown) => String(value ?? "")))
|
||||||
|
: [];
|
||||||
|
if (plannedAssetIds.length !== args.assetIds.length) {
|
||||||
|
throw new Error("资源列表计划不一致");
|
||||||
|
}
|
||||||
|
for (let i = 0; i < args.assetIds.length; i += 1) {
|
||||||
|
if (plannedAssetIds[i] !== args.assetIds[i]) {
|
||||||
|
throw new Error("资源列表计划不一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateResourceUploadPlan(args: {
|
||||||
|
asset: {
|
||||||
|
id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
document_id: string;
|
||||||
|
asset_type: string;
|
||||||
|
file_name?: string | null;
|
||||||
|
file_size?: number | null;
|
||||||
|
mime_type?: string | null;
|
||||||
|
};
|
||||||
|
targetSubPath?: string | null;
|
||||||
|
resourceUploadPlan?: any;
|
||||||
|
}) {
|
||||||
|
const plan = args.resourceUploadPlan;
|
||||||
|
if (!plan || typeof plan !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (plan.action !== "upload") {
|
||||||
|
throw new Error("Rust resource upload plan action 不一致");
|
||||||
|
}
|
||||||
|
if (String(plan.assetId ?? "").trim() !== args.asset.id) {
|
||||||
|
throw new Error("Rust resource upload plan assetId 不一致");
|
||||||
|
}
|
||||||
|
if (String(plan.workspaceId ?? "").trim() !== args.asset.workspace_id) {
|
||||||
|
throw new Error("Rust resource upload plan workspaceId 不一致");
|
||||||
|
}
|
||||||
|
if (String(plan.targetDocumentId ?? "").trim() !== args.asset.document_id) {
|
||||||
|
throw new Error("Rust resource upload plan targetDocumentId 不一致");
|
||||||
|
}
|
||||||
|
const plannedSubPath = String(plan.targetSubPath ?? "").trim();
|
||||||
|
const actualSubPath = String(args.targetSubPath ?? "").trim();
|
||||||
|
if (plannedSubPath !== actualSubPath) {
|
||||||
|
throw new Error("Rust resource upload plan targetSubPath 不一致");
|
||||||
|
}
|
||||||
|
if (String(plan.assetType ?? "").trim() !== args.asset.asset_type) {
|
||||||
|
throw new Error("Rust resource upload plan assetType 不一致");
|
||||||
|
}
|
||||||
|
const plannedName = String(plan.fileName ?? "").trim();
|
||||||
|
const actualName = String(args.asset.file_name ?? "").trim();
|
||||||
|
if (plannedName !== actualName) {
|
||||||
|
throw new Error("Rust resource upload plan fileName 不一致");
|
||||||
|
}
|
||||||
|
if (typeof plan.fileSize === "number" && plan.fileSize !== args.asset.file_size) {
|
||||||
|
throw new Error("Rust resource upload plan fileSize 不一致");
|
||||||
|
}
|
||||||
|
const plannedMime = String(plan.mimeType ?? "").trim();
|
||||||
|
const actualMime = String(args.asset.mime_type ?? "").trim();
|
||||||
|
if (plannedMime !== actualMime) {
|
||||||
|
throw new Error("Rust resource upload plan mimeType 不一致");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTransferAssets(ctx: MutationCtx, userId: string, assetIds: string[]) {
|
||||||
|
const assets: any[] = [];
|
||||||
|
for (const assetId of assetIds) {
|
||||||
|
const row = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_asset_id", (q) => q.eq("id", assetId))
|
||||||
|
.first();
|
||||||
|
if (!row || row.deleted_at || row.purged_at) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await assertWorkspaceMember(ctx, userId, row.workspace_id);
|
||||||
|
assets.push(row);
|
||||||
|
}
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExistingNames(ctx: MutationCtx, documentId: string) {
|
||||||
|
const existingRows = await ctx.db
|
||||||
|
.query("media_assets")
|
||||||
|
.withIndex("by_document", (q) => q.eq("document_id", documentId))
|
||||||
|
.collect();
|
||||||
|
return new Set<string>(
|
||||||
|
existingRows.map((row) => String(row.file_name ?? "")).filter((name) => name.length > 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTransferTarget(ctx: MutationCtx, userId: string, targetDocumentId: string) {
|
||||||
|
const targetDoc = await getCanonicalDocumentByBusinessId<any>(ctx, targetDocumentId);
|
||||||
|
if (!targetDoc) {
|
||||||
|
throw new Error("目标页面不存在");
|
||||||
|
}
|
||||||
|
await assertWorkspaceMember(ctx, userId, targetDoc.workspace_id);
|
||||||
|
return targetDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTransferredAssetResult(row: any) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workspace_id: row.workspace_id,
|
||||||
|
document_id: row.document_id,
|
||||||
|
asset_type: row.asset_type,
|
||||||
|
file_url: row.file_url ?? null,
|
||||||
|
thumbnail_url: row.thumbnail_url ?? row.file_url ?? null,
|
||||||
|
storage_id: row.storage_id ?? null,
|
||||||
|
bucket: row.bucket ?? null,
|
||||||
|
storage_path: row.storage_path ?? null,
|
||||||
|
file_name: row.file_name ?? null,
|
||||||
|
file_size: row.file_size ?? null,
|
||||||
|
mime_type: row.mime_type ?? null,
|
||||||
|
ocr_text: row.ocr_text ?? null,
|
||||||
|
ocr_status: row.ocr_status ?? null,
|
||||||
|
ocr_payload: row.ocr_payload,
|
||||||
|
ocr_strategy: row.ocr_strategy ?? null,
|
||||||
|
deleted_at: row.deleted_at ?? null,
|
||||||
|
deleted_by: row.deleted_by ?? null,
|
||||||
|
purged_at: row.purged_at ?? null,
|
||||||
|
signed_url: row.signed_url ?? null,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const getById = query({
|
export const getById = query({
|
||||||
args: { userId: v.string(), id: v.string() },
|
args: { userId: v.string(), id: v.string() },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -290,6 +485,8 @@ export const createWithStorage = mutation({
|
|||||||
args: {
|
args: {
|
||||||
userId: v.string(),
|
userId: v.string(),
|
||||||
storageId: v.id("_storage"),
|
storageId: v.id("_storage"),
|
||||||
|
targetSubPath: v.optional(v.union(v.string(), v.null())),
|
||||||
|
resourceUploadPlan: v.optional(v.any()),
|
||||||
asset: v.object({
|
asset: v.object({
|
||||||
id: v.string(),
|
id: v.string(),
|
||||||
workspace_id: v.string(),
|
workspace_id: v.string(),
|
||||||
@@ -302,6 +499,11 @@ export const createWithStorage = mutation({
|
|||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id);
|
await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id);
|
||||||
|
validateResourceUploadPlan({
|
||||||
|
asset: args.asset,
|
||||||
|
targetSubPath: args.targetSubPath,
|
||||||
|
resourceUploadPlan: args.resourceUploadPlan,
|
||||||
|
});
|
||||||
|
|
||||||
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.asset.document_id);
|
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.asset.document_id);
|
||||||
|
|
||||||
@@ -324,7 +526,7 @@ export const createWithStorage = mutation({
|
|||||||
thumbnail_url: url,
|
thumbnail_url: url,
|
||||||
storage_id: args.storageId,
|
storage_id: args.storageId,
|
||||||
bucket: null,
|
bucket: null,
|
||||||
storage_path: null,
|
storage_path: args.targetSubPath ? `${args.targetSubPath}/${args.asset.file_name ?? args.asset.id}` : null,
|
||||||
file_name: args.asset.file_name,
|
file_name: args.asset.file_name,
|
||||||
file_size: args.asset.file_size,
|
file_size: args.asset.file_size,
|
||||||
mime_type: args.asset.mime_type,
|
mime_type: args.asset.mime_type,
|
||||||
@@ -360,6 +562,128 @@ export const createWithStorage = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const batchCopy = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
assetIds: v.array(v.string()),
|
||||||
|
targetDocumentId: v.string(),
|
||||||
|
targetSubPath: v.optional(v.union(v.string(), v.null())),
|
||||||
|
resourceTransferPlan: v.optional(v.any()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const assetIds = uniqueStrings(args.assetIds);
|
||||||
|
if (assetIds.length === 0) {
|
||||||
|
throw new Error("缺少附件");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDoc = await resolveTransferTarget(ctx, args.userId, args.targetDocumentId);
|
||||||
|
validateResourceTransferPlan({
|
||||||
|
action: "copy",
|
||||||
|
assetIds,
|
||||||
|
targetDocumentId: args.targetDocumentId,
|
||||||
|
targetSubPath: args.targetSubPath,
|
||||||
|
resourceTransferPlan: args.resourceTransferPlan,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assets = await loadTransferAssets(ctx, args.userId, assetIds);
|
||||||
|
const existingNames = await loadExistingNames(ctx, args.targetDocumentId);
|
||||||
|
const items: any[] = [];
|
||||||
|
const ts = nowIso();
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
const storageId = (asset.storage_id as any) ?? null;
|
||||||
|
if (!storageId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id =
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
const fileName = makeUniqueFileName(String(asset.file_name ?? "附件"), existingNames);
|
||||||
|
const row = {
|
||||||
|
id,
|
||||||
|
workspace_id: String(targetDoc.workspace_id),
|
||||||
|
document_id: String(args.targetDocumentId),
|
||||||
|
asset_type: String(asset.asset_type ?? "file"),
|
||||||
|
file_url: asset.file_url ?? null,
|
||||||
|
thumbnail_url: asset.thumbnail_url ?? asset.file_url ?? null,
|
||||||
|
storage_id: storageId,
|
||||||
|
bucket: asset.bucket ?? null,
|
||||||
|
storage_path: asset.storage_path ?? null,
|
||||||
|
file_name: fileName,
|
||||||
|
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
|
||||||
|
mime_type: (asset.mime_type ?? null) as any,
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: shouldExtractAttachmentText({
|
||||||
|
assetType: asset.asset_type,
|
||||||
|
mimeType: asset.mime_type,
|
||||||
|
fileName,
|
||||||
|
})
|
||||||
|
? "queued"
|
||||||
|
: null,
|
||||||
|
ocr_payload: undefined,
|
||||||
|
ocr_strategy: null,
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_by: null,
|
||||||
|
purged_at: null,
|
||||||
|
created_by: args.userId,
|
||||||
|
created_at: ts,
|
||||||
|
updated_at: ts,
|
||||||
|
};
|
||||||
|
await ctx.db.insert("media_assets", row);
|
||||||
|
if (row.ocr_status === "queued") {
|
||||||
|
await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: id, debounceMs: 800 });
|
||||||
|
}
|
||||||
|
items.push(buildTransferredAssetResult(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const batchMove = mutation({
|
||||||
|
args: {
|
||||||
|
userId: v.string(),
|
||||||
|
assetIds: v.array(v.string()),
|
||||||
|
targetDocumentId: v.string(),
|
||||||
|
targetSubPath: v.optional(v.union(v.string(), v.null())),
|
||||||
|
resourceTransferPlan: v.optional(v.any()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const assetIds = uniqueStrings(args.assetIds);
|
||||||
|
if (assetIds.length === 0) {
|
||||||
|
throw new Error("缺少附件");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDoc = await resolveTransferTarget(ctx, args.userId, args.targetDocumentId);
|
||||||
|
validateResourceTransferPlan({
|
||||||
|
action: "move",
|
||||||
|
assetIds,
|
||||||
|
targetDocumentId: args.targetDocumentId,
|
||||||
|
targetSubPath: args.targetSubPath,
|
||||||
|
resourceTransferPlan: args.resourceTransferPlan,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assets = await loadTransferAssets(ctx, args.userId, assetIds);
|
||||||
|
const existingNames = await loadExistingNames(ctx, args.targetDocumentId);
|
||||||
|
const items: any[] = [];
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
const fileName = makeUniqueFileName(String(asset.file_name ?? "附件"), existingNames);
|
||||||
|
const patch = {
|
||||||
|
workspace_id: String(targetDoc.workspace_id),
|
||||||
|
document_id: String(args.targetDocumentId),
|
||||||
|
file_name: fileName,
|
||||||
|
updated_at: nowIso(),
|
||||||
|
};
|
||||||
|
await ctx.db.patch(asset._id, patch);
|
||||||
|
items.push(buildTransferredAssetResult({ ...asset, ...patch }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const patchById = mutation({
|
export const patchById = mutation({
|
||||||
args: {
|
args: {
|
||||||
userId: v.string(),
|
userId: v.string(),
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockIsConvexEnabled = vi.fn(() => true);
|
||||||
|
const mockRequireAuthContext = vi.fn();
|
||||||
|
const mockGetConvexAuthedHttpClient = vi.fn();
|
||||||
|
const mockBuildDocumentBridgeContext = vi.fn();
|
||||||
|
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||||
|
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||||
|
const mockExecuteRustBridgeMutationTransport = vi.fn();
|
||||||
|
const mockRecordRustBridgeCommandArtifacts = vi.fn();
|
||||||
|
const mockMaterializeRustTreeStreamDelta = vi.fn();
|
||||||
|
const mockMaterializeRustTreeDomainEventPlan = vi.fn();
|
||||||
|
const mockReadRustTreeDomainEventType = vi.fn();
|
||||||
|
const mockRecordBridgeCommandArtifacts = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/enabled", () => ({
|
||||||
|
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/authContext", () => ({
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requireAuthContext: () => mockRequireAuthContext(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/api", () => ({
|
||||||
|
api: {
|
||||||
|
mediaAssets: {
|
||||||
|
listByIds: "mediaAssets:listByIds",
|
||||||
|
patchById: "mediaAssets:patchById",
|
||||||
|
},
|
||||||
|
documents: {
|
||||||
|
getMeta: "documents:getMeta",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/server", () => ({
|
||||||
|
getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
|
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
|
||||||
|
buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
|
||||||
|
executeRustBridgeMutationTransport: (...args: unknown[]) =>
|
||||||
|
mockExecuteRustBridgeMutationTransport(...args),
|
||||||
|
recordRustBridgeCommandArtifacts: (...args: unknown[]) =>
|
||||||
|
mockRecordRustBridgeCommandArtifacts(...args),
|
||||||
|
materializeRustTreeStreamDelta: (...args: unknown[]) =>
|
||||||
|
mockMaterializeRustTreeStreamDelta(...args),
|
||||||
|
materializeRustTreeDomainEventPlan: (...args: unknown[]) =>
|
||||||
|
mockMaterializeRustTreeDomainEventPlan(...args),
|
||||||
|
readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||||
|
recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/url/proxyForBrowser", () => ({
|
||||||
|
maybeProxyForBrowserUrl: (_request: Request, url: string) => url,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("/api/media/batch route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
mockIsConvexEnabled.mockReset().mockReturnValue(true);
|
||||||
|
mockRequireAuthContext.mockReset().mockResolvedValue({
|
||||||
|
userId: "user_1",
|
||||||
|
});
|
||||||
|
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
actor: { actorType: "user", actorId: "user_1", sessionId: null },
|
||||||
|
source: { channel: "next-route", client: "vitest" },
|
||||||
|
tenantId: null,
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
validateOnly: false,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({
|
||||||
|
...(input as Record<string, unknown>),
|
||||||
|
commandId: "cmd_asset_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
}));
|
||||||
|
mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.resource.move",
|
||||||
|
commandId: "cmd_asset_1",
|
||||||
|
functionName: "mediaAssets:batchMove",
|
||||||
|
argsJson: {
|
||||||
|
domainEventPlan: {
|
||||||
|
family: "tree",
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: "tree.resource.moved",
|
||||||
|
},
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "tree.resource.moved",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockExecuteRustBridgeMutationTransport.mockReset().mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "asset_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
document_id: "doc_target",
|
||||||
|
asset_type: "file",
|
||||||
|
file_url: "/file.pdf",
|
||||||
|
thumbnail_url: "/file.pdf",
|
||||||
|
file_name: "file.pdf",
|
||||||
|
file_size: 1024,
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
created_at: "2026-04-26T00:00:00Z",
|
||||||
|
updated_at: "2026-04-26T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
|
||||||
|
mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({
|
||||||
|
op: "upsert_assets",
|
||||||
|
upsertAssets: [{ id: "asset_1" }],
|
||||||
|
});
|
||||||
|
mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({
|
||||||
|
family: "tree",
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: "tree.resource.moved",
|
||||||
|
streamDelta: {
|
||||||
|
op: "upsert_assets",
|
||||||
|
upsertAssets: [{ id: "asset_1" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.moved");
|
||||||
|
mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copy/move 应进入 Rust resource command plan 并记录资源 delta artifact", async () => {
|
||||||
|
const client = {
|
||||||
|
query: vi.fn(async (name: string, args: Record<string, unknown>) => {
|
||||||
|
if (name === "mediaAssets:listByIds") {
|
||||||
|
expect(args).toEqual({ userId: "user_1", ids: ["asset_1"] });
|
||||||
|
return [{ id: "asset_1", workspace_id: "ws_1" }];
|
||||||
|
}
|
||||||
|
if (name === "documents:getMeta") {
|
||||||
|
expect(args).toEqual({ id: "doc_target" });
|
||||||
|
return { id: "doc_target", workspace_id: "ws_1" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
mutation: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
|
||||||
|
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://localhost/api/media/batch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "move",
|
||||||
|
assetIds: ["asset_1"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "asset_1",
|
||||||
|
document_id: "doc_target",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "tree.resource.move",
|
||||||
|
payload: {
|
||||||
|
assetIds: ["asset_1"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pageId: "doc_target",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockExecuteRustBridgeMutationTransport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
client,
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
functionName: "mediaAssets:batchMove",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "tree.resource.move",
|
||||||
|
}),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
commandName: "tree.resource.move",
|
||||||
|
functionName: "mediaAssets:batchMove",
|
||||||
|
}),
|
||||||
|
result: expect.objectContaining({
|
||||||
|
items: [expect.objectContaining({ id: "asset_1" })],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { makeUniqueFileName } from "@/lib/file-tree/naming";
|
|
||||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
recordRustBridgeCommandArtifacts,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -20,41 +26,6 @@ interface BatchPayload {
|
|||||||
newName?: string;
|
newName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "workspace";
|
|
||||||
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
|
|
||||||
|
|
||||||
const parseStoragePath = (fileUrl: string) => {
|
|
||||||
try {
|
|
||||||
const url = new URL(fileUrl);
|
|
||||||
const segments = url.pathname.split("/").filter(Boolean);
|
|
||||||
const objectIdx = segments.findIndex((seg) => seg === "object");
|
|
||||||
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
|
|
||||||
if (segments[objectIdx + 1] === "public") {
|
|
||||||
const bucket = segments[objectIdx + 2];
|
|
||||||
const path = segments.slice(objectIdx + 3).join("/");
|
|
||||||
return { bucket, path };
|
|
||||||
}
|
|
||||||
if (segments[objectIdx + 1] === "sign") {
|
|
||||||
const bucket = segments[objectIdx + 2];
|
|
||||||
const path = segments.slice(objectIdx + 3).join("/");
|
|
||||||
return { bucket, path };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveAssetLocation(asset: any): { bucket: string; path: string } | null {
|
|
||||||
if (asset?.storage_path) {
|
|
||||||
return { bucket: asset.bucket || BUCKET, path: asset.storage_path };
|
|
||||||
}
|
|
||||||
if (asset?.file_url) {
|
|
||||||
return parseStoragePath(asset.file_url);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeSubPath(input: string | undefined): string {
|
function sanitizeSubPath(input: string | undefined): string {
|
||||||
const raw = typeof input === "string" ? input : "";
|
const raw = typeof input === "string" ? input : "";
|
||||||
const cleaned = raw
|
const cleaned = raw
|
||||||
@@ -67,6 +38,14 @@ function sanitizeSubPath(input: string | undefined): string {
|
|||||||
return cleaned.join("/");
|
return cleaned.join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeTransferredAssetForBrowser(request: Request, asset: any) {
|
||||||
|
return {
|
||||||
|
...asset,
|
||||||
|
file_url: maybeProxyForBrowserUrl(request, String(asset?.file_url ?? "")),
|
||||||
|
thumbnail_url: maybeProxyForBrowserUrl(request, String(asset?.thumbnail_url ?? asset?.file_url ?? "")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
let auth;
|
let auth;
|
||||||
@@ -149,64 +128,56 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
|
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = (await client.query(api.mediaAssets.listByDocument, {
|
const workspaceId = String(targetDoc.workspace_id ?? "").trim();
|
||||||
userId: auth.userId,
|
if (!workspaceId) {
|
||||||
documentId: payload.targetDocumentId,
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
limit: 500,
|
}
|
||||||
})) as any[];
|
|
||||||
const existingNames = new Set<string>(
|
const context = await buildDocumentBridgeContext({
|
||||||
(existing ?? []).map((r) => (r?.file_name ?? "").toString()).filter(Boolean),
|
request,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const commandName =
|
||||||
|
payload.action === "copy" ? "tree.resource.copy" : "tree.resource.move";
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: commandName,
|
||||||
|
payload: {
|
||||||
|
assetIds: payload.assetIds,
|
||||||
|
targetDocumentId: payload.targetDocumentId,
|
||||||
|
targetSubPath: sanitizeSubPath(payload.targetSubPath),
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: payload.targetDocumentId,
|
||||||
|
},
|
||||||
|
reason: `media-batch ${commandName}`,
|
||||||
|
refs: ["file-tree-resource-command"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const result = await executeRustBridgeMutationTransport<{ items?: any[] }>({
|
||||||
|
client: client as never,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client: client as never,
|
||||||
|
plan,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[media.batch] Rust bridge artifacts skipped:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeItems = (result.items ?? []).map((item: any) =>
|
||||||
|
sanitizeTransferredAssetForBrowser(request, item),
|
||||||
);
|
);
|
||||||
|
|
||||||
const results: any[] = [];
|
|
||||||
|
|
||||||
for (const asset of assets) {
|
|
||||||
const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_");
|
|
||||||
const storageId = (asset as { storage_id?: string | null })?.storage_id ?? null;
|
|
||||||
if (!storageId) continue;
|
|
||||||
|
|
||||||
if (payload.action === "copy") {
|
|
||||||
const newId =
|
|
||||||
typeof crypto.randomUUID === "function"
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
||||||
|
|
||||||
const created = await client.mutation(api.mediaAssets.createWithStorage, {
|
|
||||||
userId: auth.userId,
|
|
||||||
storageId: storageId as any,
|
|
||||||
asset: {
|
|
||||||
id: newId,
|
|
||||||
workspace_id: String(targetDoc.workspace_id),
|
|
||||||
document_id: String(payload.targetDocumentId),
|
|
||||||
asset_type: String(asset.asset_type ?? "file"),
|
|
||||||
file_name: fileName,
|
|
||||||
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
|
|
||||||
mime_type: (asset.mime_type ?? null) as any,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
results.push(created);
|
|
||||||
} else {
|
|
||||||
await client.mutation(api.mediaAssets.patchById, {
|
|
||||||
userId: auth.userId,
|
|
||||||
id: String(asset.id),
|
|
||||||
patch: {
|
|
||||||
workspace_id: String(targetDoc.workspace_id),
|
|
||||||
document_id: String(payload.targetDocumentId),
|
|
||||||
file_name: fileName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
results.push({ ...asset, workspace_id: String(targetDoc.workspace_id), document_id: String(payload.targetDocumentId), file_name: fileName });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeItems = (results ?? []).map((a: any) => ({
|
|
||||||
...a,
|
|
||||||
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
|
|
||||||
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({ items: safeItems });
|
return NextResponse.json({ items: safeItems });
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockIsConvexEnabled = vi.fn(() => true);
|
||||||
|
const mockRequireAuthContext = vi.fn();
|
||||||
|
const mockGetConvexAuthedHttpClient = vi.fn();
|
||||||
|
const mockBuildDocumentBridgeContext = vi.fn();
|
||||||
|
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||||
|
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||||
|
const mockRecordRustBridgeCommandArtifacts = vi.fn();
|
||||||
|
const mockMaterializeRustTreeStreamDelta = vi.fn();
|
||||||
|
const mockMaterializeRustTreeDomainEventPlan = vi.fn();
|
||||||
|
const mockReadRustTreeDomainEventType = vi.fn();
|
||||||
|
const mockRecordBridgeCommandArtifacts = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/enabled", () => ({
|
||||||
|
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/authContext", () => ({
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requireAuthContext: () => mockRequireAuthContext(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/api", () => ({
|
||||||
|
api: {
|
||||||
|
mediaAssets: {
|
||||||
|
generateUploadUrl: "mediaAssets:generateUploadUrl",
|
||||||
|
createWithStorage: "mediaAssets:createWithStorage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/server", () => ({
|
||||||
|
getConvexAuthedHttpClient: () => mockGetConvexAuthedHttpClient(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
|
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
|
||||||
|
buildDocumentCommandEnvelope: (...args: unknown[]) => mockBuildDocumentCommandEnvelope(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
|
||||||
|
recordRustBridgeCommandArtifacts: (...args: unknown[]) =>
|
||||||
|
mockRecordRustBridgeCommandArtifacts(...args),
|
||||||
|
materializeRustTreeStreamDelta: (...args: unknown[]) =>
|
||||||
|
mockMaterializeRustTreeStreamDelta(...args),
|
||||||
|
materializeRustTreeDomainEventPlan: (...args: unknown[]) =>
|
||||||
|
mockMaterializeRustTreeDomainEventPlan(...args),
|
||||||
|
readRustTreeDomainEventType: (...args: unknown[]) => mockReadRustTreeDomainEventType(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||||
|
recordBridgeCommandArtifacts: (...args: unknown[]) => mockRecordBridgeCommandArtifacts(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/url/proxyForBrowser", () => ({
|
||||||
|
maybeProxyForBrowserUrl: (_request: Request, url: string) => url,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("/api/media/upload route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
mockIsConvexEnabled.mockReset().mockReturnValue(true);
|
||||||
|
mockRequireAuthContext.mockReset().mockResolvedValue({
|
||||||
|
userId: "user_1",
|
||||||
|
});
|
||||||
|
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
actor: { actorType: "user", actorId: "user_1", sessionId: null },
|
||||||
|
source: { channel: "next-route", client: "vitest" },
|
||||||
|
tenantId: null,
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
validateOnly: false,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => ({
|
||||||
|
...(input as Record<string, unknown>),
|
||||||
|
commandId: "cmd_upload_asset",
|
||||||
|
idempotencyKey: null,
|
||||||
|
}));
|
||||||
|
mockResolveRustBridgeCommandPlan.mockReset().mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.resource.upload",
|
||||||
|
commandId: "cmd_upload_asset",
|
||||||
|
functionName: "mediaAssets:createWithStorage",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
domainEventPlan: {
|
||||||
|
family: "tree",
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: "tree.resource.uploaded",
|
||||||
|
},
|
||||||
|
assetId: "asset_upload_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
fileName: "demo.pdf",
|
||||||
|
fileSize: 7,
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
assetType: "file",
|
||||||
|
resourceUploadPlan: {
|
||||||
|
action: "upload",
|
||||||
|
assetId: "asset_upload_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
fileName: "demo.pdf",
|
||||||
|
fileSize: 7,
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
assetType: "file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockMaterializeRustTreeStreamDelta.mockReset().mockReturnValue({
|
||||||
|
op: "upsert_assets",
|
||||||
|
upsertAssets: [{ id: "asset_upload_1" }],
|
||||||
|
});
|
||||||
|
mockMaterializeRustTreeDomainEventPlan.mockReset().mockReturnValue({
|
||||||
|
family: "tree",
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: "tree.resource.uploaded",
|
||||||
|
streamDelta: {
|
||||||
|
op: "upsert_assets",
|
||||||
|
upsertAssets: [{ id: "asset_upload_1" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockReadRustTreeDomainEventType.mockReset().mockReturnValue("tree.resource.uploaded");
|
||||||
|
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
|
||||||
|
mockRecordBridgeCommandArtifacts.mockReset().mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("上传应通过 tree.resource.upload plan 写入资源元数据并记录资源 delta artifact", async () => {
|
||||||
|
const createdAsset = {
|
||||||
|
id: "asset_upload_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
document_id: "doc_target",
|
||||||
|
asset_type: "file",
|
||||||
|
file_url: "/files/demo.pdf",
|
||||||
|
thumbnail_url: "/files/demo.pdf",
|
||||||
|
file_name: "demo.pdf",
|
||||||
|
file_size: 7,
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
created_at: "2026-04-26T00:00:00Z",
|
||||||
|
updated_at: "2026-04-26T00:00:00Z",
|
||||||
|
};
|
||||||
|
const client = {
|
||||||
|
mutation: vi.fn(async (name: string, args: Record<string, unknown>) => {
|
||||||
|
if (name === "mediaAssets:generateUploadUrl") {
|
||||||
|
expect(args).toEqual({ userId: "user_1" });
|
||||||
|
return "https://convex.test/upload";
|
||||||
|
}
|
||||||
|
if (name === "mediaAssets:createWithStorage") {
|
||||||
|
expect(args).toMatchObject({
|
||||||
|
userId: "user_1",
|
||||||
|
storageId: "storage_1",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
resourceUploadPlan: {
|
||||||
|
action: "upload",
|
||||||
|
assetId: "asset_upload_1",
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
},
|
||||||
|
asset: {
|
||||||
|
id: "asset_upload_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
document_id: "doc_target",
|
||||||
|
file_name: "demo.pdf",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return createdAsset;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
mockGetConvexAuthedHttpClient.mockResolvedValue(client);
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ storageId: "storage_1" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", new Blob(["content"], { type: "application/pdf" }), "demo.pdf");
|
||||||
|
formData.set("workspaceId", "ws_1");
|
||||||
|
formData.set("documentId", "doc_target");
|
||||||
|
formData.set("mindmapId", "mind_1");
|
||||||
|
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://localhost/api/media/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
asset: {
|
||||||
|
id: "asset_upload_1",
|
||||||
|
document_id: "doc_target",
|
||||||
|
},
|
||||||
|
mindmapUrl: "asset:asset_upload_1",
|
||||||
|
});
|
||||||
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "tree.resource.upload",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
fileName: expect.any(String),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "tree.resource.upload",
|
||||||
|
}),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
commandName: "tree.resource.upload",
|
||||||
|
functionName: "mediaAssets:createWithStorage",
|
||||||
|
}),
|
||||||
|
result: expect.objectContaining({
|
||||||
|
items: [expect.objectContaining({ id: "asset_upload_1" })],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { MediaAsset } from "@/types/media";
|
import type { MediaAsset } from "@/types/media";
|
||||||
import { extname } from "path";
|
|
||||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
recordRustBridgeCommandArtifacts,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
|
|
||||||
|
|
||||||
const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => {
|
const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" => {
|
||||||
if (mime.startsWith("image/")) return "image";
|
if (mime.startsWith("image/")) return "image";
|
||||||
if (mime.startsWith("video/")) return "video";
|
if (mime.startsWith("video/")) return "video";
|
||||||
@@ -20,6 +23,26 @@ const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" =>
|
|||||||
return "file";
|
return "file";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isUploadFile(value: FormDataEntryValue | null): value is File {
|
||||||
|
return Boolean(
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
(typeof File === "undefined" || value instanceof File || "arrayBuffer" in value) &&
|
||||||
|
typeof (value as File).arrayBuffer === "function" &&
|
||||||
|
typeof (value as File).name === "string",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalStringArg(args: Record<string, unknown>, key: string): string | null {
|
||||||
|
const value = args[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumberArg(args: Record<string, unknown>, key: string): number | null {
|
||||||
|
const value = args[key];
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (isConvexEnabled()) {
|
if (isConvexEnabled()) {
|
||||||
let auth;
|
let auth;
|
||||||
@@ -38,7 +61,7 @@ export async function POST(request: Request) {
|
|||||||
const documentId = String(formData.get("documentId") ?? "");
|
const documentId = String(formData.get("documentId") ?? "");
|
||||||
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
|
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
|
||||||
|
|
||||||
if (!(file instanceof File) || !workspaceId || !documentId) {
|
if (!isUploadFile(file) || !workspaceId || !documentId) {
|
||||||
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
|
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +74,35 @@ export async function POST(request: Request) {
|
|||||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
const assetType = resolveAssetType(file.type || "");
|
const assetType = resolveAssetType(file.type || "");
|
||||||
const client = await getConvexAuthedHttpClient();
|
const client = await getConvexAuthedHttpClient();
|
||||||
|
const targetSubPath = mindmapIdRaw ? `mindmaps/${mindmapIdRaw}` : undefined;
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "tree.resource.upload",
|
||||||
|
payload: {
|
||||||
|
assetId,
|
||||||
|
workspaceId,
|
||||||
|
targetDocumentId: documentId,
|
||||||
|
targetSubPath,
|
||||||
|
fileName: file.name || null,
|
||||||
|
fileSize: file.size,
|
||||||
|
mimeType: file.type || null,
|
||||||
|
assetType,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: documentId,
|
||||||
|
},
|
||||||
|
reason: "media-upload tree.resource.upload",
|
||||||
|
refs: ["file-tree-resource-upload"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
|
||||||
// 1) 获取 Convex 的上传 URL(短时有效)
|
// 1) 获取 Convex 的上传 URL(短时有效)
|
||||||
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
|
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
|
||||||
@@ -78,16 +130,32 @@ export async function POST(request: Request) {
|
|||||||
const created = await client.mutation(api.mediaAssets.createWithStorage, {
|
const created = await client.mutation(api.mediaAssets.createWithStorage, {
|
||||||
userId: auth.userId,
|
userId: auth.userId,
|
||||||
storageId: storageId as any,
|
storageId: storageId as any,
|
||||||
|
targetSubPath: readOptionalStringArg(plan.argsJson, "targetSubPath"),
|
||||||
|
resourceUploadPlan:
|
||||||
|
plan.argsJson.resourceUploadPlan && typeof plan.argsJson.resourceUploadPlan === "object"
|
||||||
|
? plan.argsJson.resourceUploadPlan
|
||||||
|
: undefined,
|
||||||
asset: {
|
asset: {
|
||||||
id: assetId,
|
id: readOptionalStringArg(plan.argsJson, "assetId") ?? assetId,
|
||||||
workspace_id: workspaceId,
|
workspace_id: readOptionalStringArg(plan.argsJson, "workspaceId") ?? workspaceId,
|
||||||
document_id: documentId,
|
document_id: readOptionalStringArg(plan.argsJson, "targetDocumentId") ?? documentId,
|
||||||
asset_type: assetType,
|
asset_type: readOptionalStringArg(plan.argsJson, "assetType") ?? assetType,
|
||||||
file_name: file.name || null,
|
file_name: readOptionalStringArg(plan.argsJson, "fileName"),
|
||||||
file_size: file.size,
|
file_size: readNumberArg(plan.argsJson, "fileSize"),
|
||||||
mime_type: file.type || null,
|
mime_type: readOptionalStringArg(plan.argsJson, "mimeType"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client: client as never,
|
||||||
|
plan,
|
||||||
|
result: { items: [created] },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[media.upload] Rust bridge artifacts skipped:", error);
|
||||||
|
}
|
||||||
|
|
||||||
const asset = created as unknown as MediaAsset;
|
const asset = created as unknown as MediaAsset;
|
||||||
const safeAsset = {
|
const safeAsset = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const mockBuildDocumentBridgeContext = vi.fn();
|
|||||||
const mockBuildDocumentCommandEnvelope = vi.fn();
|
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||||
const mockResolveRustBridgeCommandPlan = vi.fn();
|
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||||
const mockExecuteRustBridgeMutationTransport = vi.fn();
|
const mockExecuteRustBridgeMutationTransport = vi.fn();
|
||||||
|
const mockRecordRustBridgeCommandArtifacts = vi.fn();
|
||||||
const mockRecordBridgeCommandArtifacts = vi.fn();
|
const mockRecordBridgeCommandArtifacts = vi.fn();
|
||||||
const mockRecordBridgeCommandFailureArtifacts = vi.fn();
|
const mockRecordBridgeCommandFailureArtifacts = vi.fn();
|
||||||
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||||
@@ -62,12 +63,112 @@ vi.mock("@/lib/documents/bridge", () => ({
|
|||||||
},
|
},
|
||||||
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||||
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
||||||
documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args),
|
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
|
||||||
|
mockDocumentBridgeErrorResponse(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
resolveRustBridgeCommandPlan: (...args: unknown[]) => mockResolveRustBridgeCommandPlan(...args),
|
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
|
||||||
executeRustBridgeMutationTransport: (...args: unknown[]) => mockExecuteRustBridgeMutationTransport(...args),
|
mockResolveRustBridgeCommandPlan(...args),
|
||||||
|
executeRustBridgeMutationTransport: (...args: Parameters<typeof mockExecuteRustBridgeMutationTransport>) =>
|
||||||
|
mockExecuteRustBridgeMutationTransport(...args),
|
||||||
|
recordRustBridgeCommandArtifacts: (...args: Parameters<typeof mockRecordRustBridgeCommandArtifacts>) =>
|
||||||
|
mockRecordRustBridgeCommandArtifacts(...args),
|
||||||
|
readRustTreeDomainEventType: (plan: { argsJson?: Record<string, unknown> }) => {
|
||||||
|
const eventPlan = plan.argsJson?.domainEventPlan as
|
||||||
|
| {
|
||||||
|
family?: string;
|
||||||
|
eventType?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (eventPlan?.family === "tree" && typeof eventPlan.eventType === "string") {
|
||||||
|
return eventPlan.eventType;
|
||||||
|
}
|
||||||
|
const hint = plan.argsJson?.domainEventHint as
|
||||||
|
| {
|
||||||
|
family?: string;
|
||||||
|
eventType?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
return hint?.family === "tree" && typeof hint.eventType === "string" ? hint.eventType : null;
|
||||||
|
},
|
||||||
|
materializeRustTreeDomainEventPlan: (input: {
|
||||||
|
plan: { argsJson?: Record<string, unknown> };
|
||||||
|
streamDelta?: Record<string, unknown> | null;
|
||||||
|
}) => {
|
||||||
|
const eventPlan = input.plan.argsJson?.domainEventPlan as
|
||||||
|
| {
|
||||||
|
family?: string;
|
||||||
|
schema?: string;
|
||||||
|
schemaVersion?: number;
|
||||||
|
eventType?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (
|
||||||
|
eventPlan?.family !== "tree" ||
|
||||||
|
eventPlan.schema !== "mnote.tree.domain_event" ||
|
||||||
|
eventPlan.schemaVersion !== 1 ||
|
||||||
|
typeof eventPlan.eventType !== "string"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
family: "tree",
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: eventPlan.eventType,
|
||||||
|
...(input.streamDelta ? { streamDelta: input.streamDelta } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
materializeRustTreeStreamDelta: (input: { plan: { argsJson?: Record<string, unknown> }; result: unknown }) => {
|
||||||
|
const hint = input.plan.argsJson?.streamDeltaHint as
|
||||||
|
| {
|
||||||
|
family?: string;
|
||||||
|
kind?: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (hint?.family !== "tree" || !hint.kind) return null;
|
||||||
|
const args = hint.args ?? {};
|
||||||
|
const result = input.result as Record<string, unknown>;
|
||||||
|
if (hint.kind === "document_result") {
|
||||||
|
return result.document ? { op: "upsert_document", document: result.document } : null;
|
||||||
|
}
|
||||||
|
if (hint.kind === "upsert_document_patch") {
|
||||||
|
return {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: {
|
||||||
|
id: args.documentId,
|
||||||
|
...(args.patch as Record<string, unknown>),
|
||||||
|
updated_at: result.updated_at ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (hint.kind === "move_document") {
|
||||||
|
return {
|
||||||
|
op: "move_document",
|
||||||
|
documentId: args.documentId,
|
||||||
|
parentId: result.parent_id ?? args.parentId ?? null,
|
||||||
|
sortOrder: result.sort_order ?? args.sortOrder,
|
||||||
|
updatedAt: result.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (hint.kind === "remove_document") {
|
||||||
|
return { op: "remove_document", documentId: args.documentId };
|
||||||
|
}
|
||||||
|
if (hint.kind === "noop") {
|
||||||
|
return { op: "noop" };
|
||||||
|
}
|
||||||
|
if (hint.kind === "copy_result") {
|
||||||
|
return {
|
||||||
|
op: "upsert_documents",
|
||||||
|
upsertDocuments: Array.isArray(result.items)
|
||||||
|
? result.items.map((item) => item?.document).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||||
@@ -92,6 +193,7 @@ describe("/api/tree/commands route", () => {
|
|||||||
mockBuildDocumentCommandEnvelope.mockReset();
|
mockBuildDocumentCommandEnvelope.mockReset();
|
||||||
mockResolveRustBridgeCommandPlan.mockReset();
|
mockResolveRustBridgeCommandPlan.mockReset();
|
||||||
mockExecuteRustBridgeMutationTransport.mockReset();
|
mockExecuteRustBridgeMutationTransport.mockReset();
|
||||||
|
mockRecordRustBridgeCommandArtifacts.mockReset().mockResolvedValue(null);
|
||||||
mockRecordBridgeCommandArtifacts.mockReset();
|
mockRecordBridgeCommandArtifacts.mockReset();
|
||||||
mockRecordBridgeCommandFailureArtifacts.mockReset();
|
mockRecordBridgeCommandFailureArtifacts.mockReset();
|
||||||
mockEnsureDocumentScaffold.mockReset();
|
mockEnsureDocumentScaffold.mockReset();
|
||||||
@@ -146,7 +248,17 @@ describe("/api/tree/commands route", () => {
|
|||||||
actorId: "user_1",
|
actorId: "user_1",
|
||||||
idempotencyKey: null,
|
idempotencyKey: null,
|
||||||
payloadJson: "{}",
|
payloadJson: "{}",
|
||||||
argsJson: {},
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "document_result",
|
||||||
|
args: { documentField: "document" },
|
||||||
|
},
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "tree.node.created",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||||
id: "doc_new",
|
id: "doc_new",
|
||||||
@@ -158,6 +270,18 @@ describe("/api/tree/commands route", () => {
|
|||||||
is_template: false,
|
is_template: false,
|
||||||
created_at: "2026-04-23T00:00:00Z",
|
created_at: "2026-04-23T00:00:00Z",
|
||||||
updated_at: "2026-04-23T00:00:00Z",
|
updated_at: "2026-04-23T00:00:00Z",
|
||||||
|
document: {
|
||||||
|
id: "doc_new",
|
||||||
|
workspace_id: "ws_root",
|
||||||
|
title: "无标题",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
is_starred: false,
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-23T00:00:00Z",
|
||||||
|
updated_at: "2026-04-23T00:00:00Z",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { POST } = await import("./route");
|
const { POST } = await import("./route");
|
||||||
@@ -196,25 +320,26 @@ describe("/api/tree/commands route", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_new", "无标题");
|
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_new", "无标题");
|
||||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
context: expect.objectContaining({
|
||||||
|
requestId: "req_tree_1",
|
||||||
|
traceId: "trace_tree_1",
|
||||||
|
}),
|
||||||
envelope: expect.objectContaining({
|
envelope: expect.objectContaining({
|
||||||
name: "tree.node.create",
|
name: "tree.node.create",
|
||||||
}),
|
}),
|
||||||
commandPayload: expect.objectContaining({
|
plan: expect.objectContaining({
|
||||||
streamDelta: {
|
commandName: "tree.node.create",
|
||||||
op: "upsert_document",
|
functionName: "documents:createWithParentReference",
|
||||||
document: expect.objectContaining({
|
}),
|
||||||
|
result: expect.objectContaining({
|
||||||
id: "doc_new",
|
id: "doc_new",
|
||||||
workspace_id: "ws_root",
|
workspace_id: "ws_root",
|
||||||
title: "无标题",
|
|
||||||
parent_id: null,
|
|
||||||
sort_order: 0,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(mockRecordBridgeCommandArtifacts).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("move action 走 tree.subtree.move,并把 position 归一化为 sortOrder", async () => {
|
it("move action 走 tree.subtree.move,并把 position 归一化为 sortOrder", async () => {
|
||||||
@@ -283,7 +408,21 @@ describe("/api/tree/commands route", () => {
|
|||||||
actorId: "user_1",
|
actorId: "user_1",
|
||||||
idempotencyKey: null,
|
idempotencyKey: null,
|
||||||
payloadJson: "{}",
|
payloadJson: "{}",
|
||||||
argsJson: {},
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "move_document",
|
||||||
|
args: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "tree.subtree.moved",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -392,27 +531,18 @@ describe("/api/tree/commands route", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
|
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
|
||||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
envelope: expect.objectContaining({
|
envelope: expect.objectContaining({
|
||||||
name: "tree.subtree.move",
|
name: "tree.subtree.move",
|
||||||
}),
|
}),
|
||||||
commandPayload: expect.objectContaining({
|
plan: expect.objectContaining({
|
||||||
streamDelta: {
|
commandName: "tree.subtree.move",
|
||||||
op: "replace_documents",
|
functionName: "documents:move",
|
||||||
documents: [
|
}),
|
||||||
{
|
result: expect.objectContaining({
|
||||||
id: "doc_1",
|
parent_id: "parent_1",
|
||||||
workspace_id: "ws_1",
|
sort_order: 1,
|
||||||
parent_id: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "parent_1",
|
|
||||||
workspace_id: "ws_1",
|
|
||||||
parent_id: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -482,7 +612,21 @@ describe("/api/tree/commands route", () => {
|
|||||||
actorId: "user_1",
|
actorId: "user_1",
|
||||||
idempotencyKey: null,
|
idempotencyKey: null,
|
||||||
payloadJson: "{}",
|
payloadJson: "{}",
|
||||||
argsJson: {},
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "move_document",
|
||||||
|
args: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "tree.subtree.moved",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -902,7 +1046,20 @@ describe("/api/tree/commands route", () => {
|
|||||||
actorId: "user_1",
|
actorId: "user_1",
|
||||||
idempotencyKey: null,
|
idempotencyKey: null,
|
||||||
payloadJson: "{}",
|
payloadJson: "{}",
|
||||||
argsJson: {},
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "upsert_document_patch",
|
||||||
|
args: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
patch: { title: "新标题" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "tree.node.renamed",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -949,19 +1106,16 @@ describe("/api/tree/commands route", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
|
expect(mockEnsureDocumentScaffold).not.toHaveBeenCalled();
|
||||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
envelope: expect.objectContaining({
|
envelope: expect.objectContaining({
|
||||||
name: "tree.node.rename",
|
name: "tree.node.rename",
|
||||||
}),
|
}),
|
||||||
commandPayload: expect.objectContaining({
|
plan: expect.objectContaining({
|
||||||
streamDelta: {
|
commandName: "tree.node.rename",
|
||||||
op: "upsert_document",
|
|
||||||
document: expect.objectContaining({
|
|
||||||
id: "doc_1",
|
|
||||||
title: "新标题",
|
|
||||||
}),
|
}),
|
||||||
},
|
result: expect.objectContaining({
|
||||||
|
updated_at: "2026-04-23T00:00:00Z",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1000,7 +1154,17 @@ describe("/api/tree/commands route", () => {
|
|||||||
actorId: "user_1",
|
actorId: "user_1",
|
||||||
idempotencyKey: null,
|
idempotencyKey: null,
|
||||||
payloadJson: "{}",
|
payloadJson: "{}",
|
||||||
argsJson: {},
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "remove_document",
|
||||||
|
args: { documentId: "doc_1" },
|
||||||
|
},
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "tree.node.archived",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -1042,17 +1206,15 @@ describe("/api/tree/commands route", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
envelope: expect.objectContaining({
|
envelope: expect.objectContaining({
|
||||||
name: "tree.node.archive",
|
name: "tree.node.archive",
|
||||||
}),
|
}),
|
||||||
commandPayload: expect.objectContaining({
|
plan: expect.objectContaining({
|
||||||
streamDelta: {
|
commandName: "tree.node.archive",
|
||||||
op: "remove_document",
|
|
||||||
documentId: "doc_1",
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
result: expect.any(Object),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1112,7 +1274,17 @@ describe("/api/tree/commands route", () => {
|
|||||||
actorId: "user_1",
|
actorId: "user_1",
|
||||||
idempotencyKey: null,
|
idempotencyKey: null,
|
||||||
payloadJson: "{}",
|
payloadJson: "{}",
|
||||||
argsJson: {},
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "noop",
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "tree.node.embedded",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||||
revision: 8,
|
revision: 8,
|
||||||
@@ -1170,16 +1342,15 @@ describe("/api/tree/commands route", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
envelope: expect.objectContaining({
|
envelope: expect.objectContaining({
|
||||||
name: "tree.node.embed",
|
name: "tree.node.embed",
|
||||||
}),
|
}),
|
||||||
commandPayload: expect.objectContaining({
|
plan: expect.objectContaining({
|
||||||
streamDelta: {
|
commandName: "tree.node.embed",
|
||||||
op: "noop",
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
result: expect.any(Object),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1222,7 +1393,17 @@ describe("/api/tree/commands route", () => {
|
|||||||
actorId: "user_1",
|
actorId: "user_1",
|
||||||
idempotencyKey: null,
|
idempotencyKey: null,
|
||||||
payloadJson: "{}",
|
payloadJson: "{}",
|
||||||
argsJson: {},
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "copy_result",
|
||||||
|
args: { itemsField: "items", documentField: "document" },
|
||||||
|
},
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "tree.subtree.copied",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||||
items: [
|
items: [
|
||||||
@@ -1230,6 +1411,18 @@ describe("/api/tree/commands route", () => {
|
|||||||
oldId: "doc_1",
|
oldId: "doc_1",
|
||||||
newId: "doc_2",
|
newId: "doc_2",
|
||||||
title: "复制页面",
|
title: "复制页面",
|
||||||
|
document: {
|
||||||
|
id: "doc_2",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "复制页面",
|
||||||
|
parent_id: "parent_1",
|
||||||
|
sort_order: 0,
|
||||||
|
is_starred: false,
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-24T00:00:00Z",
|
||||||
|
updated_at: "2026-04-24T00:00:00Z",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -1302,22 +1495,22 @@ describe("/api/tree/commands route", () => {
|
|||||||
);
|
);
|
||||||
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_2", "复制页面");
|
expect(mockEnsureDocumentScaffold).toHaveBeenCalledWith("doc_2", "复制页面");
|
||||||
expect(mockCopyMindmapFilesIfExists).toHaveBeenCalledWith("doc_1", "doc_2");
|
expect(mockCopyMindmapFilesIfExists).toHaveBeenCalledWith("doc_1", "doc_2");
|
||||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
envelope: expect.objectContaining({
|
envelope: expect.objectContaining({
|
||||||
name: "tree.subtree.copy",
|
name: "tree.subtree.copy",
|
||||||
}),
|
}),
|
||||||
commandPayload: expect.objectContaining({
|
plan: expect.objectContaining({
|
||||||
streamDelta: {
|
commandName: "tree.subtree.copy",
|
||||||
op: "replace_documents",
|
}),
|
||||||
documents: [],
|
result: expect.objectContaining({
|
||||||
},
|
items: [expect.objectContaining({ newId: "doc_2" })],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restore action 走 tree.node.restore,并附带 replace_documents delta", async () => {
|
it("restore action 走 tree.node.restore,并附带 upsert_document delta", async () => {
|
||||||
const client = {
|
const client = {
|
||||||
mutation: vi.fn(),
|
mutation: vi.fn(),
|
||||||
query: vi.fn(async () => ({
|
query: vi.fn(async () => ({
|
||||||
@@ -1350,10 +1543,32 @@ describe("/api/tree/commands route", () => {
|
|||||||
actorId: "user_1",
|
actorId: "user_1",
|
||||||
idempotencyKey: null,
|
idempotencyKey: null,
|
||||||
payloadJson: "{}",
|
payloadJson: "{}",
|
||||||
argsJson: {},
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "document_result",
|
||||||
|
args: { documentField: "document" },
|
||||||
|
},
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "tree.node.restored",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
mockExecuteRustBridgeMutationTransport.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
document: {
|
||||||
|
id: "doc_restore_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "恢复页面",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
is_starred: false,
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-23T00:00:00Z",
|
||||||
|
updated_at: "2026-04-24T00:00:00Z",
|
||||||
|
},
|
||||||
updated_at: "2026-04-24T00:00:00Z",
|
updated_at: "2026-04-24T00:00:00Z",
|
||||||
});
|
});
|
||||||
mockLoadSidebarDataFromConvex.mockResolvedValue({
|
mockLoadSidebarDataFromConvex.mockResolvedValue({
|
||||||
@@ -1412,16 +1627,16 @@ describe("/api/tree/commands route", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mockRecordBridgeCommandArtifacts).toHaveBeenCalledWith(
|
expect(mockRecordRustBridgeCommandArtifacts).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
envelope: expect.objectContaining({
|
envelope: expect.objectContaining({
|
||||||
name: "tree.node.restore",
|
name: "tree.node.restore",
|
||||||
}),
|
}),
|
||||||
commandPayload: expect.objectContaining({
|
plan: expect.objectContaining({
|
||||||
streamDelta: {
|
commandName: "tree.node.restore",
|
||||||
op: "replace_documents",
|
}),
|
||||||
documents: [],
|
result: expect.objectContaining({
|
||||||
},
|
document: expect.objectContaining({ id: "doc_restore_1" }),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1476,7 +1691,17 @@ describe("/api/tree/commands route", () => {
|
|||||||
actorId: "user_1",
|
actorId: "user_1",
|
||||||
idempotencyKey: null,
|
idempotencyKey: null,
|
||||||
payloadJson: "{}",
|
payloadJson: "{}",
|
||||||
argsJson: {},
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "remove_document",
|
||||||
|
args: { documentId: "doc_1" },
|
||||||
|
},
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "tree.node.archived",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
mockExecuteRustBridgeMutationTransport.mockRejectedValue(new Error("archive failed"));
|
mockExecuteRustBridgeMutationTransport.mockRejectedValue(new Error("archive failed"));
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
documentBridgeErrorResponse,
|
documentBridgeErrorResponse,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import {
|
import {
|
||||||
recordBridgeCommandArtifacts,
|
|
||||||
recordBridgeCommandFailureArtifacts,
|
recordBridgeCommandFailureArtifacts,
|
||||||
} from "@/lib/documents/bridge-log";
|
} from "@/lib/documents/bridge-log";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +23,7 @@ import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
|||||||
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
|
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
|
||||||
import {
|
import {
|
||||||
executeRustBridgeMutationTransport,
|
executeRustBridgeMutationTransport,
|
||||||
|
recordRustBridgeCommandArtifacts,
|
||||||
resolveRustBridgeCommandPlan,
|
resolveRustBridgeCommandPlan,
|
||||||
} from "@/lib/documents/rust-runtime";
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
@@ -57,6 +57,26 @@ type TreeCommandPayload = {
|
|||||||
items?: TreeCopyItem[] | null;
|
items?: TreeCopyItem[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TreeDeltaDocument = {
|
||||||
|
id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
title: string | null;
|
||||||
|
parent_id: string | null;
|
||||||
|
sort_order: number | null;
|
||||||
|
is_starred: boolean | null;
|
||||||
|
access_scope: "private" | "shared" | "public";
|
||||||
|
is_template: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TreeMutationResult<TResult> = {
|
||||||
|
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
|
||||||
|
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
|
||||||
|
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
|
||||||
|
result: TResult;
|
||||||
|
};
|
||||||
|
|
||||||
function trimOrNull(value: unknown) {
|
function trimOrNull(value: unknown) {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -76,37 +96,23 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
|
|
||||||
if (!streamDelta) {
|
|
||||||
return commandPayload;
|
|
||||||
}
|
|
||||||
if (isRecord(commandPayload)) {
|
|
||||||
return {
|
|
||||||
...commandPayload,
|
|
||||||
streamDelta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
payload: commandPayload,
|
|
||||||
streamDelta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recordTreeCommandSuccess(args: {
|
async function recordTreeCommandSuccess(args: {
|
||||||
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
|
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
|
||||||
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
|
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
|
||||||
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
||||||
commandPayload?: unknown;
|
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
|
||||||
|
result: unknown;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
context: args.context,
|
context: args.context,
|
||||||
envelope: args.envelope,
|
envelope: args.envelope,
|
||||||
client: args.client,
|
client: args.client,
|
||||||
commandPayload: args.commandPayload,
|
plan: args.plan,
|
||||||
|
result: args.result,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[tree.commands] bridge success artifacts skipped:", error);
|
console.warn("[tree.commands] Rust bridge success artifacts skipped:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,24 +142,6 @@ async function loadTreeCommandSidebarSnapshot(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTreeCommandSnapshotDelta(
|
|
||||||
sidebarSnapshot: unknown,
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
if (!isRecord(sidebarSnapshot)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (Array.isArray(sidebarSnapshot.documents)) {
|
|
||||||
return {
|
|
||||||
op: "replace_documents",
|
|
||||||
documents: sidebarSnapshot.documents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
op: "replace_sidebar",
|
|
||||||
sidebar: sidebarSnapshot,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTreeMovePreflightDataFromSidebarSnapshot(
|
function buildTreeMovePreflightDataFromSidebarSnapshot(
|
||||||
sidebarSnapshot: unknown,
|
sidebarSnapshot: unknown,
|
||||||
): Record<string, unknown> | null {
|
): Record<string, unknown> | null {
|
||||||
@@ -203,6 +191,7 @@ async function resolveTreeMutationResult<TResult>(args: {
|
|||||||
return {
|
return {
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
|
plan,
|
||||||
result,
|
result,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -275,8 +264,9 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
|
|||||||
|
|
||||||
const documentId = trimOrNull(payload.documentId) ?? randomUUID();
|
const documentId = trimOrNull(payload.documentId) ?? randomUUID();
|
||||||
const title = normalizeTitle(payload.title);
|
const title = normalizeTitle(payload.title);
|
||||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
const mutation = await resolveTreeMutationResult<{
|
||||||
id: string;
|
id: string;
|
||||||
|
document?: TreeDeltaDocument | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
parent_id: string | null;
|
parent_id: string | null;
|
||||||
sort_order: number | null;
|
sort_order: number | null;
|
||||||
@@ -300,27 +290,15 @@ async function handleCreate(request: Request, payload: TreeCommandPayload) {
|
|||||||
pageId: documentId,
|
pageId: documentId,
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
|
const { context, envelope, result } = mutation;
|
||||||
|
|
||||||
await ensureDocumentScaffold(result.id, result.title ?? title);
|
await ensureDocumentScaffold(result.id, result.title ?? title);
|
||||||
await recordTreeCommandSuccess({
|
await recordTreeCommandSuccess({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
client,
|
||||||
commandPayload: attachStreamDelta(envelope.payload, {
|
plan: mutation.plan,
|
||||||
op: "upsert_document",
|
result,
|
||||||
document: {
|
|
||||||
id: result.id,
|
|
||||||
workspace_id: result.workspace_id,
|
|
||||||
title: result.title ?? title,
|
|
||||||
parent_id: result.parent_id ?? parentId,
|
|
||||||
sort_order: result.sort_order ?? 0,
|
|
||||||
access_scope: result.access_scope,
|
|
||||||
is_starred: false,
|
|
||||||
is_template: result.is_template,
|
|
||||||
created_at: result.created_at,
|
|
||||||
updated_at: result.updated_at,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -356,7 +334,7 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
const movePreflightData = buildTreeMovePreflightDataFromSidebarSnapshot(sidebarSnapshot);
|
const movePreflightData = buildTreeMovePreflightDataFromSidebarSnapshot(sidebarSnapshot);
|
||||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
const mutation = await resolveTreeMutationResult<{
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
parent_id?: string | null;
|
parent_id?: string | null;
|
||||||
sort_order?: number | null;
|
sort_order?: number | null;
|
||||||
@@ -375,19 +353,13 @@ async function handleMove(request: Request, payload: TreeCommandPayload) {
|
|||||||
pageId: documentId,
|
pageId: documentId,
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
const postMutationSidebarSnapshot = await loadTreeCommandSidebarSnapshot({
|
const { context, envelope, result } = mutation;
|
||||||
client,
|
|
||||||
auth,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
await recordTreeCommandSuccess({
|
await recordTreeCommandSuccess({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
client,
|
||||||
commandPayload: attachStreamDelta(
|
plan: mutation.plan,
|
||||||
envelope.payload,
|
result,
|
||||||
buildTreeCommandSnapshotDelta(postMutationSidebarSnapshot),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -426,8 +398,9 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
|
|||||||
|
|
||||||
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||||
const title = assertTitle(payload.title ?? null);
|
const title = assertTitle(payload.title ?? null);
|
||||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
const mutation = await resolveTreeMutationResult<{
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
|
document?: TreeDeltaDocument | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
}>({
|
}>({
|
||||||
request,
|
request,
|
||||||
@@ -441,18 +414,13 @@ async function handleRename(request: Request, payload: TreeCommandPayload) {
|
|||||||
pageId: documentId,
|
pageId: documentId,
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
|
const { context, envelope, result } = mutation;
|
||||||
await recordTreeCommandSuccess({
|
await recordTreeCommandSuccess({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
client,
|
||||||
commandPayload: attachStreamDelta(envelope.payload, {
|
plan: mutation.plan,
|
||||||
op: "upsert_document",
|
result,
|
||||||
document: {
|
|
||||||
id: documentId,
|
|
||||||
title,
|
|
||||||
updated_at: result?.updated_at ?? null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -478,8 +446,9 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
const mutation = await resolveTreeMutationResult<{
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
|
document?: TreeDeltaDocument | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
}>({
|
}>({
|
||||||
request,
|
request,
|
||||||
@@ -492,14 +461,13 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
|
|||||||
pageId: documentId,
|
pageId: documentId,
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
|
const { context, envelope, result } = mutation;
|
||||||
await recordTreeCommandSuccess({
|
await recordTreeCommandSuccess({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
client,
|
||||||
commandPayload: attachStreamDelta(envelope.payload, {
|
plan: mutation.plan,
|
||||||
op: "remove_document",
|
result,
|
||||||
documentId,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -516,7 +484,7 @@ async function handleArchive(request: Request, payload: TreeCommandPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRestore(request: Request, payload: TreeCommandPayload) {
|
async function handleRestore(request: Request, payload: TreeCommandPayload) {
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { client } = await getAuthedConvexClient();
|
||||||
const documentId = assertDocumentId(payload.documentId ?? null);
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
||||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
if (!sourceDoc) {
|
if (!sourceDoc) {
|
||||||
@@ -524,8 +492,9 @@ async function handleRestore(request: Request, payload: TreeCommandPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
const mutation = await resolveTreeMutationResult<{
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
|
document?: TreeDeltaDocument | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
}>({
|
}>({
|
||||||
request,
|
request,
|
||||||
@@ -538,19 +507,13 @@ async function handleRestore(request: Request, payload: TreeCommandPayload) {
|
|||||||
pageId: documentId,
|
pageId: documentId,
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
|
const { context, envelope, result } = mutation;
|
||||||
client,
|
|
||||||
auth,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
await recordTreeCommandSuccess({
|
await recordTreeCommandSuccess({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
client,
|
||||||
commandPayload: attachStreamDelta(
|
plan: mutation.plan,
|
||||||
envelope.payload,
|
result,
|
||||||
buildTreeCommandSnapshotDelta(sidebarSnapshot),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -575,7 +538,7 @@ async function handlePurge(request: Request, payload: TreeCommandPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
||||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
const mutation = await resolveTreeMutationResult<{
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
purged?: boolean;
|
purged?: boolean;
|
||||||
purged_at?: string | null;
|
purged_at?: string | null;
|
||||||
@@ -589,14 +552,13 @@ async function handlePurge(request: Request, payload: TreeCommandPayload) {
|
|||||||
pageId: documentId,
|
pageId: documentId,
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
|
const { context, envelope, result } = mutation;
|
||||||
await recordTreeCommandSuccess({
|
await recordTreeCommandSuccess({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
client,
|
||||||
commandPayload: attachStreamDelta(envelope.payload, {
|
plan: mutation.plan,
|
||||||
op: "remove_document",
|
result,
|
||||||
documentId,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -659,7 +621,7 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
|
|||||||
trimOrNull(sourceDoc.workspace_id) ??
|
trimOrNull(sourceDoc.workspace_id) ??
|
||||||
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
|
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
|
||||||
|
|
||||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
const mutation = await resolveTreeMutationResult<{
|
||||||
revision?: number | null;
|
revision?: number | null;
|
||||||
conflict_detection_key?: string | null;
|
conflict_detection_key?: string | null;
|
||||||
}>({
|
}>({
|
||||||
@@ -688,13 +650,13 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
|
|||||||
pageId: targetId,
|
pageId: targetId,
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
|
const { context, envelope, result } = mutation;
|
||||||
await recordTreeCommandSuccess({
|
await recordTreeCommandSuccess({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
client,
|
||||||
commandPayload: attachStreamDelta(envelope.payload, {
|
plan: mutation.plan,
|
||||||
op: "noop",
|
result,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -712,7 +674,7 @@ async function handleEmbed(request: Request, payload: TreeCommandPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCopy(request: Request, payload: TreeCommandPayload) {
|
async function handleCopy(request: Request, payload: TreeCommandPayload) {
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { client } = await getAuthedConvexClient();
|
||||||
const normalizedItems = (payload.items ?? [])
|
const normalizedItems = (payload.items ?? [])
|
||||||
.filter((item) => item?.documentId)
|
.filter((item) => item?.documentId)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
@@ -747,11 +709,12 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
|
|||||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { context, envelope, result } = await resolveTreeMutationResult<{
|
const mutation = await resolveTreeMutationResult<{
|
||||||
items: Array<{
|
items: Array<{
|
||||||
oldId: string;
|
oldId: string;
|
||||||
newId: string;
|
newId: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
|
document?: TreeDeltaDocument | null;
|
||||||
}>;
|
}>;
|
||||||
}>({
|
}>({
|
||||||
request,
|
request,
|
||||||
@@ -765,6 +728,7 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
|
|||||||
pageId: targetParentId,
|
pageId: targetParentId,
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
|
const { context, envelope, result } = mutation;
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
(result.items ?? []).map(async (item) => {
|
(result.items ?? []).map(async (item) => {
|
||||||
@@ -772,19 +736,12 @@ async function handleCopy(request: Request, payload: TreeCommandPayload) {
|
|||||||
await copyMindmapFilesIfExists(item.oldId, item.newId);
|
await copyMindmapFilesIfExists(item.oldId, item.newId);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
|
|
||||||
client,
|
|
||||||
auth,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
await recordTreeCommandSuccess({
|
await recordTreeCommandSuccess({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
client,
|
||||||
commandPayload: attachStreamDelta(
|
plan: mutation.plan,
|
||||||
envelope.payload,
|
result,
|
||||||
buildTreeCommandSnapshotDelta(sidebarSnapshot),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockIsConvexEnabled = vi.fn(() => true);
|
||||||
|
const mockBuildDocumentBridgeContext = vi.fn();
|
||||||
|
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||||
|
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||||
|
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||||
|
Response.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status:
|
||||||
|
typeof (error as { status?: unknown })?.status === "number"
|
||||||
|
? ((error as { status: number }).status ?? 500)
|
||||||
|
: 500,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/enabled", () => ({
|
||||||
|
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
|
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
|
||||||
|
mockDocumentBridgeErrorResponse(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
|
||||||
|
mockResolveRustBridgeCommandPlan(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("/api/tree/filetree/delete-preflight route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockIsConvexEnabled.mockReturnValue(true);
|
||||||
|
mockBuildDocumentBridgeContext.mockReset();
|
||||||
|
mockBuildDocumentCommandEnvelope.mockReset();
|
||||||
|
mockResolveRustBridgeCommandPlan.mockReset();
|
||||||
|
mockDocumentBridgeErrorResponse.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应通过 Rust tree.filetree.delete.preflight 返回规范化 delete plan", async () => {
|
||||||
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||||
|
requestId: "req_filetree_delete_1",
|
||||||
|
traceId: "trace_filetree_delete_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
tenantId: null,
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
validateOnly: true,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
||||||
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.filetree.delete.preflight",
|
||||||
|
commandId: "cmd_filetree_delete_1",
|
||||||
|
functionName: "tree:fileTreeDeletePreflight",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_filetree_delete_1",
|
||||||
|
traceId: "trace_filetree_delete_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
fileTreeDeletePlan: {
|
||||||
|
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||||
|
docIds: ["doc_1"],
|
||||||
|
assetIds: ["asset_1"],
|
||||||
|
assetDocumentIds: ["doc_other"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://127.0.0.1:3000/api/tree/filetree/delete-preflight", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||||
|
rows: [],
|
||||||
|
documentParents: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
requestId: "req_filetree_delete_1",
|
||||||
|
traceId: "trace_filetree_delete_1",
|
||||||
|
plan: {
|
||||||
|
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||||
|
docIds: ["doc_1"],
|
||||||
|
assetIds: ["asset_1"],
|
||||||
|
assetDocumentIds: ["doc_other"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "tree.filetree.delete.preflight",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||||
|
}),
|
||||||
|
reason: "filetree-delete-preflight tree.filetree.delete.preflight",
|
||||||
|
refs: ["file-tree-shell"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
|
type FileTreeDeletePreflightPayload = {
|
||||||
|
workspaceId?: string | null;
|
||||||
|
rowIds?: string[];
|
||||||
|
rows?: unknown[];
|
||||||
|
documentParents?: Array<{ documentId?: string | null; parentId?: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function trimOrNull(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileTreeDeletePlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
|
||||||
|
const value = plan.argsJson.fileTreeDeletePlan;
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error("Rust runtime 未返回 fileTreeDeletePlan");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isConvexEnabled()) {
|
||||||
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as FileTreeDeletePreflightPayload;
|
||||||
|
const workspaceId = trimOrNull(payload.workspaceId);
|
||||||
|
const normalizedPayload = {
|
||||||
|
workspaceId,
|
||||||
|
rowIds: normalizeStringArray(payload.rowIds),
|
||||||
|
rows: Array.isArray(payload.rows) ? payload.rows : [],
|
||||||
|
documentParents: Array.isArray(payload.documentParents)
|
||||||
|
? payload.documentParents.map((item) => ({
|
||||||
|
documentId: trimOrNull(item?.documentId),
|
||||||
|
parentId: trimOrNull(item?.parentId),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
validateOnly: true,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "tree.filetree.delete.preflight",
|
||||||
|
payload: normalizedPayload,
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
reason: "filetree-delete-preflight tree.filetree.delete.preflight",
|
||||||
|
refs: ["file-tree-shell"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
plan: readFileTreeDeletePlan(plan),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockIsConvexEnabled = vi.fn(() => true);
|
||||||
|
const mockBuildDocumentBridgeContext = vi.fn();
|
||||||
|
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||||
|
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||||
|
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||||
|
Response.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status:
|
||||||
|
typeof (error as { status?: unknown })?.status === "number"
|
||||||
|
? ((error as { status: number }).status ?? 500)
|
||||||
|
: 500,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/enabled", () => ({
|
||||||
|
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
|
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
|
||||||
|
mockDocumentBridgeErrorResponse(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
|
||||||
|
mockResolveRustBridgeCommandPlan(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("/api/tree/filetree/drop-preflight route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockIsConvexEnabled.mockReturnValue(true);
|
||||||
|
mockBuildDocumentBridgeContext.mockReset();
|
||||||
|
mockBuildDocumentCommandEnvelope.mockReset();
|
||||||
|
mockResolveRustBridgeCommandPlan.mockReset();
|
||||||
|
mockDocumentBridgeErrorResponse.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应通过 Rust tree.filetree.drop.preflight 返回规范化 drop plan", async () => {
|
||||||
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||||
|
requestId: "req_filetree_drop_1",
|
||||||
|
traceId: "trace_filetree_drop_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
tenantId: null,
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
validateOnly: true,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
||||||
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.filetree.drop.preflight",
|
||||||
|
commandId: "cmd_filetree_drop_1",
|
||||||
|
functionName: "tree:fileTreeDropPreflight",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_filetree_drop_1",
|
||||||
|
traceId: "trace_filetree_drop_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
fileTreeDropPlan: {
|
||||||
|
copy: false,
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetMindmapId: null,
|
||||||
|
targetSubPath: null,
|
||||||
|
rowIds: ["doc:doc_1"],
|
||||||
|
docIds: ["doc_1"],
|
||||||
|
topLevelDocIds: ["doc_1"],
|
||||||
|
copyableAssetIds: [],
|
||||||
|
sourceAssetDocumentIds: [],
|
||||||
|
documentTransferPlan: {
|
||||||
|
action: "move",
|
||||||
|
targetParentId: "doc_target",
|
||||||
|
documentIds: ["doc_1"],
|
||||||
|
topLevelDocumentIds: ["doc_1"],
|
||||||
|
copyItems: [{ documentId: "doc_1", recursive: true }],
|
||||||
|
},
|
||||||
|
resourceTransferPlan: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://127.0.0.1:3000/api/tree/filetree/drop-preflight", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
copy: false,
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetRowId: null,
|
||||||
|
focusedRowId: null,
|
||||||
|
activeDocumentId: null,
|
||||||
|
rowIds: ["doc:doc_1"],
|
||||||
|
rows: [],
|
||||||
|
documentParents: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
requestId: "req_filetree_drop_1",
|
||||||
|
traceId: "trace_filetree_drop_1",
|
||||||
|
plan: {
|
||||||
|
copy: false,
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetMindmapId: null,
|
||||||
|
targetSubPath: null,
|
||||||
|
rowIds: ["doc:doc_1"],
|
||||||
|
docIds: ["doc_1"],
|
||||||
|
topLevelDocIds: ["doc_1"],
|
||||||
|
copyableAssetIds: [],
|
||||||
|
sourceAssetDocumentIds: [],
|
||||||
|
documentTransferPlan: {
|
||||||
|
action: "move",
|
||||||
|
targetParentId: "doc_target",
|
||||||
|
documentIds: ["doc_1"],
|
||||||
|
topLevelDocumentIds: ["doc_1"],
|
||||||
|
copyItems: [{ documentId: "doc_1", recursive: true }],
|
||||||
|
},
|
||||||
|
resourceTransferPlan: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockBuildDocumentBridgeContext).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
validateOnly: true,
|
||||||
|
dryRun: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "tree.filetree.drop.preflight",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
rowIds: ["doc:doc_1"],
|
||||||
|
}),
|
||||||
|
reason: "filetree-drop-preflight tree.filetree.drop.preflight",
|
||||||
|
refs: ["file-tree-shell"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
|
type FileTreeDropPreflightPayload = {
|
||||||
|
workspaceId?: string | null;
|
||||||
|
copy?: boolean;
|
||||||
|
targetDocumentId?: string | null;
|
||||||
|
targetRowId?: string | null;
|
||||||
|
focusedRowId?: string | null;
|
||||||
|
activeDocumentId?: string | null;
|
||||||
|
rowIds?: string[];
|
||||||
|
rows?: unknown[];
|
||||||
|
documentParents?: Array<{ documentId?: string | null; parentId?: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function trimOrNull(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileTreeDropPlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
|
||||||
|
const value = plan.argsJson.fileTreeDropPlan;
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error("Rust runtime 未返回 fileTreeDropPlan");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isConvexEnabled()) {
|
||||||
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as FileTreeDropPreflightPayload;
|
||||||
|
const workspaceId = trimOrNull(payload.workspaceId);
|
||||||
|
const normalizedPayload = {
|
||||||
|
workspaceId,
|
||||||
|
copy: Boolean(payload.copy),
|
||||||
|
targetDocumentId: trimOrNull(payload.targetDocumentId),
|
||||||
|
targetRowId: trimOrNull(payload.targetRowId),
|
||||||
|
focusedRowId: trimOrNull(payload.focusedRowId),
|
||||||
|
activeDocumentId: trimOrNull(payload.activeDocumentId),
|
||||||
|
rowIds: normalizeStringArray(payload.rowIds),
|
||||||
|
rows: Array.isArray(payload.rows) ? payload.rows : [],
|
||||||
|
documentParents: Array.isArray(payload.documentParents)
|
||||||
|
? payload.documentParents.map((item) => ({
|
||||||
|
documentId: trimOrNull(item?.documentId),
|
||||||
|
parentId: trimOrNull(item?.parentId),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
validateOnly: true,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "tree.filetree.drop.preflight",
|
||||||
|
payload: normalizedPayload,
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: normalizedPayload.targetDocumentId ?? undefined,
|
||||||
|
},
|
||||||
|
reason: "filetree-drop-preflight tree.filetree.drop.preflight",
|
||||||
|
refs: ["file-tree-shell"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
plan: readFileTreeDropPlan(plan),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockIsConvexEnabled = vi.fn(() => true);
|
||||||
|
const mockBuildDocumentBridgeContext = vi.fn();
|
||||||
|
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||||
|
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||||
|
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||||
|
Response.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status:
|
||||||
|
typeof (error as { status?: unknown })?.status === "number"
|
||||||
|
? ((error as { status: number }).status ?? 500)
|
||||||
|
: 500,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/enabled", () => ({
|
||||||
|
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
|
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
|
||||||
|
mockDocumentBridgeErrorResponse(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
|
||||||
|
mockResolveRustBridgeCommandPlan(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("/api/tree/filetree/paste-preflight route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockIsConvexEnabled.mockReturnValue(true);
|
||||||
|
mockBuildDocumentBridgeContext.mockReset();
|
||||||
|
mockBuildDocumentCommandEnvelope.mockReset();
|
||||||
|
mockResolveRustBridgeCommandPlan.mockReset();
|
||||||
|
mockDocumentBridgeErrorResponse.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应通过 Rust tree.filetree.paste.preflight 返回规范化 paste plan", async () => {
|
||||||
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||||
|
requestId: "req_filetree_paste_1",
|
||||||
|
traceId: "trace_filetree_paste_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
tenantId: null,
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
validateOnly: true,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
||||||
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.filetree.paste.preflight",
|
||||||
|
commandId: "cmd_filetree_paste_1",
|
||||||
|
functionName: "tree:fileTreePastePreflight",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_filetree_paste_1",
|
||||||
|
traceId: "trace_filetree_paste_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
fileTreePastePlan: {
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetMindmapId: "mind_1",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||||
|
docItems: [{ documentId: "doc_1", recursive: false }],
|
||||||
|
copyableAssetIds: ["asset_1"],
|
||||||
|
resourceTransferPlan: {
|
||||||
|
action: "copy",
|
||||||
|
assetIds: ["asset_1"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://127.0.0.1:3000/api/tree/filetree/paste-preflight", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: null,
|
||||||
|
focusedRowId: "asset-folder:mind_1",
|
||||||
|
activeDocumentId: "doc_active",
|
||||||
|
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||||
|
rows: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
requestId: "req_filetree_paste_1",
|
||||||
|
traceId: "trace_filetree_paste_1",
|
||||||
|
plan: {
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetMindmapId: "mind_1",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||||
|
docItems: [{ documentId: "doc_1", recursive: false }],
|
||||||
|
copyableAssetIds: ["asset_1"],
|
||||||
|
resourceTransferPlan: {
|
||||||
|
action: "copy",
|
||||||
|
assetIds: ["asset_1"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "tree.filetree.paste.preflight",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
focusedRowId: "asset-folder:mind_1",
|
||||||
|
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||||
|
}),
|
||||||
|
reason: "filetree-paste-preflight tree.filetree.paste.preflight",
|
||||||
|
refs: ["file-tree-shell"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
|
type FileTreePastePreflightPayload = {
|
||||||
|
workspaceId?: string | null;
|
||||||
|
targetDocumentId?: string | null;
|
||||||
|
focusedRowId?: string | null;
|
||||||
|
activeDocumentId?: string | null;
|
||||||
|
rowIds?: string[];
|
||||||
|
rows?: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function trimOrNull(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileTreePastePlan(plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>) {
|
||||||
|
const value = plan.argsJson.fileTreePastePlan;
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error("Rust runtime 未返回 fileTreePastePlan");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isConvexEnabled()) {
|
||||||
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as FileTreePastePreflightPayload;
|
||||||
|
const workspaceId = trimOrNull(payload.workspaceId);
|
||||||
|
const normalizedPayload = {
|
||||||
|
workspaceId,
|
||||||
|
targetDocumentId: trimOrNull(payload.targetDocumentId),
|
||||||
|
focusedRowId: trimOrNull(payload.focusedRowId),
|
||||||
|
activeDocumentId: trimOrNull(payload.activeDocumentId),
|
||||||
|
rowIds: normalizeStringArray(payload.rowIds),
|
||||||
|
rows: Array.isArray(payload.rows) ? payload.rows : [],
|
||||||
|
};
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
validateOnly: true,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "tree.filetree.paste.preflight",
|
||||||
|
payload: normalizedPayload,
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: normalizedPayload.targetDocumentId ?? undefined,
|
||||||
|
},
|
||||||
|
reason: "filetree-paste-preflight tree.filetree.paste.preflight",
|
||||||
|
refs: ["file-tree-shell"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
plan: readFileTreePastePlan(plan),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockIsConvexEnabled = vi.fn(() => true);
|
||||||
|
const mockBuildDocumentBridgeContext = vi.fn();
|
||||||
|
const mockBuildDocumentCommandEnvelope = vi.fn();
|
||||||
|
const mockResolveRustBridgeCommandPlan = vi.fn();
|
||||||
|
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||||
|
Response.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status:
|
||||||
|
typeof (error as { status?: unknown })?.status === "number"
|
||||||
|
? ((error as { status: number }).status ?? 500)
|
||||||
|
: 500,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/enabled", () => ({
|
||||||
|
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
|
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope: mockBuildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse: (...args: Parameters<typeof mockDocumentBridgeErrorResponse>) =>
|
||||||
|
mockDocumentBridgeErrorResponse(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
resolveRustBridgeCommandPlan: (...args: Parameters<typeof mockResolveRustBridgeCommandPlan>) =>
|
||||||
|
mockResolveRustBridgeCommandPlan(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("/api/tree/filetree/upload-target-preflight route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockIsConvexEnabled.mockReturnValue(true);
|
||||||
|
mockBuildDocumentBridgeContext.mockReset();
|
||||||
|
mockBuildDocumentCommandEnvelope.mockReset();
|
||||||
|
mockResolveRustBridgeCommandPlan.mockReset();
|
||||||
|
mockDocumentBridgeErrorResponse.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应通过 Rust tree.filetree.upload-target.preflight 返回规范化 upload target plan", async () => {
|
||||||
|
mockBuildDocumentBridgeContext.mockResolvedValue({
|
||||||
|
requestId: "req_filetree_upload_target_1",
|
||||||
|
traceId: "trace_filetree_upload_target_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "wolai-frontend",
|
||||||
|
},
|
||||||
|
tenantId: null,
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: null,
|
||||||
|
validateOnly: true,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
mockBuildDocumentCommandEnvelope.mockImplementation((input: unknown) => input);
|
||||||
|
mockResolveRustBridgeCommandPlan.mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.filetree.upload-target.preflight",
|
||||||
|
commandId: "cmd_filetree_upload_target_1",
|
||||||
|
functionName: "tree:fileTreeUploadTargetPreflight",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_filetree_upload_target_1",
|
||||||
|
traceId: "trace_filetree_upload_target_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
fileTreeUploadTargetPlan: {
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetMindmapId: "mind_1",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
const response = await POST(
|
||||||
|
new Request("http://127.0.0.1:3000/api/tree/filetree/upload-target-preflight", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId: "ws_fallback",
|
||||||
|
targetDocumentId: null,
|
||||||
|
targetRowId: "asset:asset_child_1",
|
||||||
|
focusedRowId: null,
|
||||||
|
activeDocumentId: "doc_active",
|
||||||
|
rows: [],
|
||||||
|
documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
requestId: "req_filetree_upload_target_1",
|
||||||
|
traceId: "trace_filetree_upload_target_1",
|
||||||
|
plan: {
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetMindmapId: "mind_1",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockBuildDocumentCommandEnvelope).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "tree.filetree.upload-target.preflight",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
workspaceId: "ws_fallback",
|
||||||
|
targetRowId: "asset:asset_child_1",
|
||||||
|
activeDocumentId: "doc_active",
|
||||||
|
}),
|
||||||
|
reason: "filetree-upload-target-preflight tree.filetree.upload-target.preflight",
|
||||||
|
refs: ["file-tree-shell"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentCommandEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import { resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
|
type FileTreeUploadTargetPreflightPayload = {
|
||||||
|
workspaceId?: string | null;
|
||||||
|
targetDocumentId?: string | null;
|
||||||
|
targetRowId?: string | null;
|
||||||
|
focusedRowId?: string | null;
|
||||||
|
activeDocumentId?: string | null;
|
||||||
|
rows?: unknown[];
|
||||||
|
documentWorkspaces?: Array<{ documentId?: string | null; workspaceId?: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function trimOrNull(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileTreeUploadTargetPlan(
|
||||||
|
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>,
|
||||||
|
) {
|
||||||
|
const value = plan.argsJson.fileTreeUploadTargetPlan;
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error("Rust runtime 未返回 fileTreeUploadTargetPlan");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isConvexEnabled()) {
|
||||||
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as FileTreeUploadTargetPreflightPayload;
|
||||||
|
const workspaceId = trimOrNull(payload.workspaceId);
|
||||||
|
const normalizedPayload = {
|
||||||
|
workspaceId,
|
||||||
|
targetDocumentId: trimOrNull(payload.targetDocumentId),
|
||||||
|
targetRowId: trimOrNull(payload.targetRowId),
|
||||||
|
focusedRowId: trimOrNull(payload.focusedRowId),
|
||||||
|
activeDocumentId: trimOrNull(payload.activeDocumentId),
|
||||||
|
rows: Array.isArray(payload.rows) ? payload.rows : [],
|
||||||
|
documentWorkspaces: Array.isArray(payload.documentWorkspaces)
|
||||||
|
? payload.documentWorkspaces.map((item) => ({
|
||||||
|
documentId: trimOrNull(item?.documentId),
|
||||||
|
workspaceId: trimOrNull(item?.workspaceId),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
validateOnly: true,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
|
name: "tree.filetree.upload-target.preflight",
|
||||||
|
payload: normalizedPayload,
|
||||||
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
pageId: normalizedPayload.targetDocumentId ?? undefined,
|
||||||
|
},
|
||||||
|
reason: "filetree-upload-target-preflight tree.filetree.upload-target.preflight",
|
||||||
|
refs: ["file-tree-shell"],
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
plan: readFileTreeUploadTargetPlan(plan),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockIsConvexEnabled = vi.fn();
|
||||||
|
const mockGetAuthedConvexClient = vi.fn();
|
||||||
|
const mockBuildDocumentBridgeContext = vi.fn();
|
||||||
|
const mockBuildDocumentQueryEnvelope = vi.fn();
|
||||||
|
const mockResolveRustBridgeQueryPlan = vi.fn();
|
||||||
|
const mockExecuteRustBridgeQueryTransport = vi.fn();
|
||||||
|
const mockResolveKernelFileTreeProjection = vi.fn();
|
||||||
|
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
|
||||||
|
Response.json(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/enabled", () => ({
|
||||||
|
isConvexEnabled: () => mockIsConvexEnabled(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/route", () => ({
|
||||||
|
getAuthedConvexClient: () => mockGetAuthedConvexClient(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
|
buildDocumentBridgeContext: (...args: unknown[]) => mockBuildDocumentBridgeContext(...args),
|
||||||
|
buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args),
|
||||||
|
documentBridgeErrorResponse: (...args: unknown[]) => mockDocumentBridgeErrorResponse(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
executeRustBridgeQueryTransport: (...args: unknown[]) =>
|
||||||
|
mockExecuteRustBridgeQueryTransport(...args),
|
||||||
|
resolveRustBridgeQueryPlan: (...args: unknown[]) => mockResolveRustBridgeQueryPlan(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/server/kernel-file-tree", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@/lib/server/kernel-file-tree")>(
|
||||||
|
"@/lib/server/kernel-file-tree",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveKernelFileTreeProjection: (...args: unknown[]) =>
|
||||||
|
mockResolveKernelFileTreeProjection(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/api/tree/projections/file route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
mockIsConvexEnabled.mockReset().mockReturnValue(true);
|
||||||
|
mockGetAuthedConvexClient.mockReset().mockResolvedValue({
|
||||||
|
auth: {
|
||||||
|
userId: "user_1",
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
query: vi.fn(),
|
||||||
|
mutation: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockBuildDocumentBridgeContext.mockReset().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
});
|
||||||
|
mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input);
|
||||||
|
mockResolveRustBridgeQueryPlan.mockReset().mockResolvedValue({
|
||||||
|
functionName: "sidebar:datasetList",
|
||||||
|
argsJson: {
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockExecuteRustBridgeQueryTransport.mockReset().mockResolvedValue({
|
||||||
|
active_workspace_id: "ws_1",
|
||||||
|
documents: [],
|
||||||
|
media_assets: [],
|
||||||
|
mindmap_assets: [],
|
||||||
|
table_assets: [],
|
||||||
|
mindmap_asset_children: {},
|
||||||
|
});
|
||||||
|
mockResolveKernelFileTreeProjection.mockReset().mockResolvedValue({
|
||||||
|
projectionId: "kernel_projection:file_tree:page_root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: "page_root",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
rowId: "doc:page_root",
|
||||||
|
nodeId: "page_root",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
rowKind: "document",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset:table_1",
|
||||||
|
nodeId: "asset:table_1",
|
||||||
|
projectionKind: "file_tree",
|
||||||
|
rowKind: "asset",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
mockDocumentBridgeErrorResponse.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("通过 3000 同源 route 返回 Rust file_tree 搜索 projection", async () => {
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||||
|
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET(
|
||||||
|
new Request(
|
||||||
|
"http://127.0.0.1:3000/api/tree/projections/file?workspaceId=ws_1&rootNodeId=page_root&depth=3&query=%E9%A2%84%E7%AE%97&maxResults=12",
|
||||||
|
{ method: "GET" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(mockResolveKernelFileTreeProjection).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rootNodeId: "page_root",
|
||||||
|
depth: 3,
|
||||||
|
query: "预算",
|
||||||
|
maxResults: 12,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: "page_root",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(body.result.items.map((item: { rowId: string }) => item.rowId)).toEqual([
|
||||||
|
"doc:page_root",
|
||||||
|
"asset:table_1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Convex 未启用时返回 501", async () => {
|
||||||
|
mockIsConvexEnabled.mockReturnValue(false);
|
||||||
|
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET(
|
||||||
|
new Request("http://127.0.0.1:3000/api/tree/projections/file?workspaceId=ws_1"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(501);
|
||||||
|
expect(await response.json()).toEqual({ error: "当前仅支持 Convex 模式" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import {
|
||||||
|
buildDocumentBridgeContext,
|
||||||
|
buildDocumentQueryEnvelope,
|
||||||
|
documentBridgeErrorResponse,
|
||||||
|
} from "@/lib/documents/bridge";
|
||||||
|
import {
|
||||||
|
executeRustBridgeQueryTransport,
|
||||||
|
resolveRustBridgeQueryPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
|
||||||
|
import { resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function readNumberParam(url: URL, name: string): number | null {
|
||||||
|
const raw = url.searchParams.get(name);
|
||||||
|
if (!raw?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(raw);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
if (!isConvexEnabled()) {
|
||||||
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
||||||
|
if (!workspaceId) {
|
||||||
|
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
const context = await buildDocumentBridgeContext({
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
const envelope = buildDocumentQueryEnvelope({
|
||||||
|
name: "sidebar.dataset.list",
|
||||||
|
payload: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plan = await resolveRustBridgeQueryPlan({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
});
|
||||||
|
const dataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
|
||||||
|
client,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
const projection = await resolveKernelFileTreeProjection({
|
||||||
|
client,
|
||||||
|
request,
|
||||||
|
workspaceId,
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: auth.userId,
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
dataset,
|
||||||
|
rootNodeId: url.searchParams.get("rootNodeId")?.trim() || null,
|
||||||
|
depth: readNumberParam(url, "depth"),
|
||||||
|
query: url.searchParams.get("query")?.trim() || null,
|
||||||
|
maxResults: readNumberParam(url, "maxResults"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
requestId: context.requestId,
|
||||||
|
traceId: context.traceId,
|
||||||
|
result: projection,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return documentBridgeErrorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,23 @@ describe("/api/tree/shell route", () => {
|
|||||||
vi.stubGlobal("fetch", mockFetch);
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
|
it("未显式 debug 时不应再代理 3104 tree shell", async () => {
|
||||||
|
const { GET } = await import("./route");
|
||||||
|
const response = await GET(
|
||||||
|
new Request(
|
||||||
|
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockResolveMnoteWebInternalUrl).not.toHaveBeenCalled();
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("显式 debug 时才通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
|
||||||
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
|
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
|
||||||
mockBuildForwardHeaders.mockResolvedValue(
|
mockBuildForwardHeaders.mockResolvedValue(
|
||||||
new Headers({
|
new Headers({
|
||||||
@@ -43,7 +59,7 @@ describe("/api/tree/shell route", () => {
|
|||||||
const { GET } = await import("./route");
|
const { GET } = await import("./route");
|
||||||
const response = await GET(
|
const response = await GET(
|
||||||
new Request(
|
new Request(
|
||||||
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
|
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -54,7 +70,7 @@ describe("/api/tree/shell route", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
|
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: expect.any(Headers),
|
headers: expect.any(Headers),
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ const stripHopByHopHeaders = (headers: Headers) => {
|
|||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const requestUrl = new URL(request.url);
|
const requestUrl = new URL(request.url);
|
||||||
|
const debugEnabled =
|
||||||
|
requestUrl.searchParams.get("debug") === "1" ||
|
||||||
|
requestUrl.searchParams.get("internal") === "1" ||
|
||||||
|
process.env.MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES === "1";
|
||||||
|
if (!debugEnabled) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const internalBaseUrl = await resolveMnoteWebInternalUrl();
|
const internalBaseUrl = await resolveMnoteWebInternalUrl();
|
||||||
const targetUrl = new URL("/tree", `${internalBaseUrl}/`);
|
const targetUrl = new URL("/tree", `${internalBaseUrl}/`);
|
||||||
targetUrl.search = requestUrl.search;
|
targetUrl.search = requestUrl.search;
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ describe("MoveEmbedPickerDialog", () => {
|
|||||||
|
|
||||||
const emptySurface = container.querySelector('[data-testid="tree-picker-surface"]');
|
const emptySurface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||||
expect(emptySurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
expect(emptySurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
expect(emptySurface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
|
||||||
expect(
|
expect(
|
||||||
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
|
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
@@ -494,7 +494,7 @@ describe("MoveEmbedPickerDialog", () => {
|
|||||||
|
|
||||||
const resultSurface = container.querySelector('[data-testid="tree-picker-surface"]');
|
const resultSurface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||||
expect(resultSurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
expect(resultSurface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
expect(resultSurface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
|
||||||
expect(
|
expect(
|
||||||
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
|
container.querySelector('[data-testid="tree-picker-surface-rust-host"]'),
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
@@ -658,7 +658,7 @@ describe("MoveEmbedPickerDialog", () => {
|
|||||||
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||||
|
|
||||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
|
||||||
expect(iframe).not.toBeNull();
|
expect(iframe).not.toBeNull();
|
||||||
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
|
||||||
|
|
||||||
|
describe("sidebar file tree delete preflight source", () => {
|
||||||
|
it("rust_family 删除链应走 Rust delete preflight,而不是本地 delete target helper", () => {
|
||||||
|
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
|
||||||
|
|
||||||
|
expect(source).toContain("preflightFileTreeDelete(");
|
||||||
|
expect(source).toContain("buildFileTreeShellDeletePreflightPayload(");
|
||||||
|
expect(source).not.toContain("computeFileTreeShellDeleteTargets(");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
|
||||||
|
|
||||||
|
describe("sidebar file tree paste preflight source", () => {
|
||||||
|
it("rust_family 粘贴链应走 Rust paste preflight,而不是本地 shell row 语义推导", () => {
|
||||||
|
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
|
||||||
|
const preflightIndex = source.indexOf("preflightFileTreePaste(");
|
||||||
|
const rustBranchStart = source.lastIndexOf("if (isRustFamilyTreeRenderer) {", preflightIndex);
|
||||||
|
const legacyBranchStart = source.indexOf("const targetDocId = inferPasteTargetDocId", preflightIndex);
|
||||||
|
|
||||||
|
expect(preflightIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(rustBranchStart).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(legacyBranchStart).toBeGreaterThan(rustBranchStart);
|
||||||
|
const rustPasteBranch = source.slice(rustBranchStart, legacyBranchStart);
|
||||||
|
|
||||||
|
expect(rustPasteBranch).toContain("preflightFileTreePaste(");
|
||||||
|
expect(rustPasteBranch).toContain("buildFileTreeShellPastePreflightPayload(");
|
||||||
|
expect(rustPasteBranch).toContain("pastePlan.docItems");
|
||||||
|
expect(rustPasteBranch).toContain("pastePlan.resourceTransferPlan");
|
||||||
|
expect(rustPasteBranch).not.toContain("docItemsMap");
|
||||||
|
expect(rustPasteBranch).not.toContain("copyableAssetIds");
|
||||||
|
expect(source).not.toContain("getOrderedFileTreeShellRows");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
|
||||||
|
|
||||||
|
describe("sidebar file tree selection source", () => {
|
||||||
|
it("rust_family renderer selection snapshot 只能由 filetree selection event 写入", () => {
|
||||||
|
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
|
||||||
|
const writes = source.match(/setResourceRendererSelection\(/g) ?? [];
|
||||||
|
|
||||||
|
expect(writes).toHaveLength(1);
|
||||||
|
expect(source).toContain("const [resourceRendererSelection, setResourceRendererSelection]");
|
||||||
|
expect(source).toContain("const handleFileTreeShellSelectionChange = useCallback");
|
||||||
|
expect(source).toContain("materializeRendererSelectionSnapshot");
|
||||||
|
expect(source).not.toContain("selectedRowIds={resourceSelection.selectedRowIds}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const SIDEBAR_SOURCE = path.join(process.cwd(), "src/components/sidebar/sidebar.tsx");
|
||||||
|
|
||||||
|
describe("sidebar file tree upload target preflight source", () => {
|
||||||
|
it("外部上传链应走 Rust upload target preflight,而不是在 Sidebar 解释目标行与工作区", () => {
|
||||||
|
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
|
||||||
|
const handlerStart = source.indexOf("const handleResourcePaneDropFiles = useCallback");
|
||||||
|
const handlerEnd = source.indexOf("const handleResourcePaneInternalDrop = useCallback");
|
||||||
|
|
||||||
|
expect(handlerStart).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(handlerEnd).toBeGreaterThan(handlerStart);
|
||||||
|
const handlerSource = source.slice(handlerStart, handlerEnd);
|
||||||
|
|
||||||
|
expect(handlerSource).toContain("preflightFileTreeUploadTarget(");
|
||||||
|
expect(handlerSource).toContain("buildFileTreeShellUploadTargetPreflightPayload(");
|
||||||
|
expect(handlerSource).toContain("uploadTargetPlan.workspaceId");
|
||||||
|
expect(handlerSource).toContain("uploadTargetPlan.targetDocumentId");
|
||||||
|
expect(handlerSource).toContain("uploadTargetPlan.targetMindmapId");
|
||||||
|
expect(handlerSource).not.toContain("resolveFileTreeShellMindmapTargetId");
|
||||||
|
expect(handlerSource).not.toContain("inferFileTreeShellTargetDocumentId");
|
||||||
|
expect(handlerSource).not.toContain("sidebarData.documents.find");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,18 +54,38 @@ import {
|
|||||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||||
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "@/lib/file-tree/rows";
|
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||||
import { buildParentById, filterTopLevelDocIds } from "@/lib/file-tree/dnd";
|
import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||||
|
import { fetchKernelFileTreeProjection } from "@/lib/file-tree/projection-client";
|
||||||
|
import {
|
||||||
|
copyFileTreeResourceAssets,
|
||||||
|
deleteFileTreeResourceAssets,
|
||||||
|
moveFileTreeResourceAssets,
|
||||||
|
preflightFileTreeDelete,
|
||||||
|
preflightFileTreeInternalDrop,
|
||||||
|
preflightFileTreePaste,
|
||||||
|
preflightFileTreeUploadTarget,
|
||||||
|
renameFileTreeResourceAsset,
|
||||||
|
restoreFileTreeResourceAssets,
|
||||||
|
uploadFileTreeResourceAsset,
|
||||||
|
} from "@/lib/file-tree/resource-command-client";
|
||||||
|
import { buildParentById } from "@/lib/file-tree/dnd";
|
||||||
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
||||||
import {
|
import {
|
||||||
computeFileTreeShellDeleteTargets,
|
buildFileTreeShellDeletePreflightPayload,
|
||||||
|
buildFileTreeShellInternalDropPreflightPayload,
|
||||||
|
buildFileTreeShellPastePreflightPayload,
|
||||||
|
buildFileTreeShellUploadTargetPreflightPayload,
|
||||||
buildFileTreeShellRowById,
|
buildFileTreeShellRowById,
|
||||||
buildFileTreeShellVisibleRowIds,
|
buildFileTreeShellVisibleRowIds,
|
||||||
|
collectFileTreeShellAssetHints,
|
||||||
type FileTreeShellRow,
|
type FileTreeShellRow,
|
||||||
inferFileTreeShellTargetDocumentId,
|
|
||||||
getOrderedFileTreeShellRows,
|
|
||||||
resolveFileTreeShellMindmapTargetId,
|
|
||||||
} from "@/lib/file-tree/shell";
|
} from "@/lib/file-tree/shell";
|
||||||
|
import {
|
||||||
|
createEmptyFileTreeSelectionState,
|
||||||
|
materializeRendererSelectionSnapshot,
|
||||||
|
resolveActiveFileTreeSelection,
|
||||||
|
} from "@/lib/file-tree/selection-source";
|
||||||
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
|
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
|
||||||
import {
|
import {
|
||||||
computeTreePaneDeleteTargets,
|
computeTreePaneDeleteTargets,
|
||||||
@@ -263,11 +283,15 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [resourceSelection, setResourceSelection] = useState<TreePaneSelectionState>(() => ({
|
const [legacyResourceSelection, setLegacyResourceSelection] = useState<TreePaneSelectionState>(
|
||||||
selectedRowIds: new Set(),
|
() => createEmptyFileTreeSelectionState(),
|
||||||
anchorRowId: null,
|
);
|
||||||
focusedRowId: null,
|
const [resourceRendererSelection, setResourceRendererSelection] = useState<TreePaneSelectionState>(
|
||||||
}));
|
() => createEmptyFileTreeSelectionState(),
|
||||||
|
);
|
||||||
|
const [searchFileTreeProjection, setSearchFileTreeProjection] =
|
||||||
|
useState<KernelFileTreeProjection | null>(null);
|
||||||
|
const [searchFileTreeProjectionKey, setSearchFileTreeProjectionKey] = useState<string | null>(null);
|
||||||
const [sidebarHydrated, setSidebarHydrated] = useState(false);
|
const [sidebarHydrated, setSidebarHydrated] = useState(false);
|
||||||
const treeSyncKeyRef = useRef<string>(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree));
|
const treeSyncKeyRef = useRef<string>(buildSidebarTreeSyncKey(sidebarData.kernelSidebarTree));
|
||||||
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
|
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
|
||||||
@@ -297,6 +321,42 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
pageTreeFocusedDocumentIdRef.current = activeId || null;
|
pageTreeFocusedDocumentIdRef.current = activeId || null;
|
||||||
}, [activeId]);
|
}, [activeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = filter.trim();
|
||||||
|
const workspaceId = sidebarData.activeWorkspaceId?.trim();
|
||||||
|
if (!query || !workspaceId) {
|
||||||
|
setSearchFileTreeProjection(null);
|
||||||
|
setSearchFileTreeProjectionKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestKey = `${workspaceId}:${query}`;
|
||||||
|
let cancelled = false;
|
||||||
|
setSearchFileTreeProjectionKey(requestKey);
|
||||||
|
setSearchFileTreeProjection(null);
|
||||||
|
void fetchKernelFileTreeProjection({
|
||||||
|
workspaceId,
|
||||||
|
query,
|
||||||
|
maxResults: 80,
|
||||||
|
})
|
||||||
|
.then((projection) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchFileTreeProjection(projection);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchFileTreeProjection(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [filter, sidebarData.activeWorkspaceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextAssets = sidebarData.mediaAssets ?? [];
|
const nextAssets = sidebarData.mediaAssets ?? [];
|
||||||
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
|
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
|
||||||
@@ -594,22 +654,21 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
|
|
||||||
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
|
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
const normalizedFileTreeSearchQuery = filter.trim();
|
||||||
|
const expectedSearchFileTreeProjectionKey =
|
||||||
|
normalizedFileTreeSearchQuery && sidebarData.activeWorkspaceId
|
||||||
|
? `${sidebarData.activeWorkspaceId}:${normalizedFileTreeSearchQuery}`
|
||||||
|
: null;
|
||||||
const resourceTreeShellItems = useMemo(
|
const resourceTreeShellItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filter.trim().length === 0
|
expectedSearchFileTreeProjectionKey &&
|
||||||
? undefined
|
searchFileTreeProjectionKey === expectedSearchFileTreeProjectionKey
|
||||||
: filterKernelFileTreeProjectionItems({
|
? (searchFileTreeProjection?.items ?? [])
|
||||||
fileTreeItems: sidebarData.kernelFileTreeProjection.items,
|
: undefined,
|
||||||
visibleDocumentIds: new Set(visibleFilteredPrivatePageRows.map((item) => item.nodeId)),
|
|
||||||
expandedDocumentIds: expanded,
|
|
||||||
expandedAssetFolderIds: expandedAssetFolders,
|
|
||||||
}),
|
|
||||||
[
|
[
|
||||||
expanded,
|
expectedSearchFileTreeProjectionKey,
|
||||||
expandedAssetFolders,
|
searchFileTreeProjection,
|
||||||
sidebarData.kernelFileTreeProjection.items,
|
searchFileTreeProjectionKey,
|
||||||
visibleFilteredPrivatePageRows,
|
|
||||||
filter,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -619,8 +678,15 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
);
|
);
|
||||||
|
|
||||||
const effectiveResourceTreeShellItems = useMemo(
|
const effectiveResourceTreeShellItems = useMemo(
|
||||||
() => resourceTreeShellItems ?? sidebarData.kernelFileTreeProjection.items,
|
() =>
|
||||||
[resourceTreeShellItems, sidebarData.kernelFileTreeProjection.items],
|
normalizedFileTreeSearchQuery.length > 0
|
||||||
|
? (resourceTreeShellItems ?? [])
|
||||||
|
: sidebarData.kernelFileTreeProjection.items,
|
||||||
|
[
|
||||||
|
normalizedFileTreeSearchQuery,
|
||||||
|
resourceTreeShellItems,
|
||||||
|
sidebarData.kernelFileTreeProjection.items,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resourceShellVisibleRowIds = useMemo(
|
const resourceShellVisibleRowIds = useMemo(
|
||||||
@@ -663,12 +729,24 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
const resourceSelectionVisibleRowIds = isRustFamilyTreeRenderer
|
const resourceSelectionVisibleRowIds = isRustFamilyTreeRenderer
|
||||||
? resourceShellVisibleRowIds
|
? resourceShellVisibleRowIds
|
||||||
: resourceVisibleRowIds;
|
: resourceVisibleRowIds;
|
||||||
|
const resourceSelection = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveActiveFileTreeSelection({
|
||||||
|
preferRendererSnapshot: isRustFamilyTreeRenderer,
|
||||||
|
legacySelection: legacyResourceSelection,
|
||||||
|
rendererSelection: resourceRendererSelection,
|
||||||
|
}),
|
||||||
|
[isRustFamilyTreeRenderer, legacyResourceSelection, resourceRendererSelection],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setResourceSelection((prev) =>
|
if (isRustFamilyTreeRenderer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLegacyResourceSelection((prev) =>
|
||||||
normalizeTreePaneSelectionForVisibleRows(prev, resourceSelectionVisibleRowIds),
|
normalizeTreePaneSelectionForVisibleRows(prev, resourceSelectionVisibleRowIds),
|
||||||
);
|
);
|
||||||
}, [resourceSelectionVisibleRowIds]);
|
}, [isRustFamilyTreeRenderer, resourceSelectionVisibleRowIds]);
|
||||||
|
|
||||||
const docParentById = useMemo(
|
const docParentById = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -680,6 +758,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
),
|
),
|
||||||
[sidebarData.documents],
|
[sidebarData.documents],
|
||||||
);
|
);
|
||||||
|
const documentWorkspaceById = useMemo(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
(sidebarData.documents ?? []).map((doc) => [
|
||||||
|
doc.id,
|
||||||
|
typeof doc.workspace_id === "string" ? doc.workspace_id : null,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
[sidebarData.documents],
|
||||||
|
);
|
||||||
|
|
||||||
const childrenCountByParentId = useMemo(() => {
|
const childrenCountByParentId = useMemo(() => {
|
||||||
const map = new Map<string | null, number>();
|
const map = new Map<string | null, number>();
|
||||||
@@ -868,12 +956,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
}, [activeId, editorBridge, router, setOpen]);
|
}, [activeId, editorBridge, router, setOpen]);
|
||||||
|
|
||||||
const handleResourcePaneBlankMouseDown = useCallback(() => {
|
const handleResourcePaneBlankMouseDown = useCallback(() => {
|
||||||
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleResourceRowClick = useCallback(
|
const handleResourceRowClick = useCallback(
|
||||||
(row: TreePaneRow, event: React.MouseEvent) => {
|
(row: TreePaneRow, event: React.MouseEvent) => {
|
||||||
setResourceSelection((prev) =>
|
setLegacyResourceSelection((prev) =>
|
||||||
reduceTreePaneSelection(prev, {
|
reduceTreePaneSelection(prev, {
|
||||||
type: "click",
|
type: "click",
|
||||||
rowId: row.rowId,
|
rowId: row.rowId,
|
||||||
@@ -902,7 +990,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleResourceRowDragStart = useCallback((row: TreePaneRow) => {
|
const handleResourceRowDragStart = useCallback((row: TreePaneRow) => {
|
||||||
setResourceSelection((prev) => {
|
setLegacyResourceSelection((prev) => {
|
||||||
if (prev.selectedRowIds.has(row.rowId)) return prev;
|
if (prev.selectedRowIds.has(row.rowId)) return prev;
|
||||||
return reduceTreePaneSelection(prev, {
|
return reduceTreePaneSelection(prev, {
|
||||||
type: "click",
|
type: "click",
|
||||||
@@ -928,7 +1016,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
(row: TreePaneRow, event: React.MouseEvent) => {
|
(row: TreePaneRow, event: React.MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setResourceSelection((prev) =>
|
setLegacyResourceSelection((prev) =>
|
||||||
reduceTreePaneSelection(prev, { type: "contextmenu", rowId: row.rowId }),
|
reduceTreePaneSelection(prev, { type: "contextmenu", rowId: row.rowId }),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1047,25 +1135,14 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
anchorRowId: string | null;
|
anchorRowId: string | null;
|
||||||
focusedRowId: string | null;
|
focusedRowId: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const normalized = normalizeTreePaneSelectionForVisibleRows(
|
setResourceRendererSelection(
|
||||||
{
|
materializeRendererSelectionSnapshot({
|
||||||
selectedRowIds: new Set(
|
payload,
|
||||||
payload.selectedRowIds.filter((rowId) => resourceShellRowById.has(rowId)),
|
hasRowId: (rowId) => resourceShellRowById.has(rowId),
|
||||||
),
|
}),
|
||||||
anchorRowId:
|
|
||||||
payload.anchorRowId && resourceShellRowById.has(payload.anchorRowId)
|
|
||||||
? payload.anchorRowId
|
|
||||||
: null,
|
|
||||||
focusedRowId:
|
|
||||||
payload.focusedRowId && resourceShellRowById.has(payload.focusedRowId)
|
|
||||||
? payload.focusedRowId
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
resourceShellVisibleRowIds,
|
|
||||||
);
|
);
|
||||||
setResourceSelection(normalized);
|
|
||||||
},
|
},
|
||||||
[resourceShellRowById, resourceShellVisibleRowIds],
|
[resourceShellRowById],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileTreeShellAssetOpen = useCallback(
|
const handleFileTreeShellAssetOpen = useCallback(
|
||||||
@@ -1132,13 +1209,59 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetDocId = isRustFamilyTreeRenderer
|
if (isRustFamilyTreeRenderer) {
|
||||||
? inferFileTreeShellTargetDocumentId({
|
let pastePlan;
|
||||||
|
try {
|
||||||
|
pastePlan = await preflightFileTreePaste(
|
||||||
|
buildFileTreeShellPastePreflightPayload({
|
||||||
|
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||||
|
targetDocumentId: null,
|
||||||
focusedRowId: resourceSelection.focusedRowId,
|
focusedRowId: resourceSelection.focusedRowId,
|
||||||
rowById: resourceShellRowById,
|
|
||||||
activeDocId: activeId || null,
|
activeDocId: activeId || null,
|
||||||
})
|
rowIds: payload.rowIds,
|
||||||
: inferPasteTargetDocId({
|
rowById: resourceShellRowById,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "文件树粘贴预检失败";
|
||||||
|
setTimeout(() => window.alert(message), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pastePlan.docItems.length > 0) {
|
||||||
|
try {
|
||||||
|
await copyTreeCommand({
|
||||||
|
items: pastePlan.docItems,
|
||||||
|
targetParentId: pastePlan.targetDocumentId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "粘贴页面失败";
|
||||||
|
setTimeout(() => window.alert(message), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sidebarQuery.refetch();
|
||||||
|
emitDocumentsChanged(pastePlan.targetDocumentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pastePlan.resourceTransferPlan && pastePlan.resourceTransferPlan.assetIds.length > 0) {
|
||||||
|
try {
|
||||||
|
await copyFileTreeResourceAssets({
|
||||||
|
assetIds: pastePlan.resourceTransferPlan.assetIds,
|
||||||
|
targetDocumentId: pastePlan.resourceTransferPlan.targetDocumentId,
|
||||||
|
targetSubPath: pastePlan.resourceTransferPlan.targetSubPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "粘贴附件失败";
|
||||||
|
setTimeout(() => window.alert(message), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sidebarQuery.refetch();
|
||||||
|
emitAssetsChanged(pastePlan.resourceTransferPlan.targetDocumentId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDocId = inferPasteTargetDocId({
|
||||||
focusedRowId: resourceSelection.focusedRowId,
|
focusedRowId: resourceSelection.focusedRowId,
|
||||||
rowById: resourceRowById,
|
rowById: resourceRowById,
|
||||||
activeDocId: activeId || null,
|
activeDocId: activeId || null,
|
||||||
@@ -1151,27 +1274,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
const docItemsMap = new Map<string, boolean>();
|
const docItemsMap = new Map<string, boolean>();
|
||||||
const copyableAssetIds: string[] = [];
|
const copyableAssetIds: string[] = [];
|
||||||
|
|
||||||
if (isRustFamilyTreeRenderer) {
|
|
||||||
const rows = getOrderedFileTreeShellRows({
|
|
||||||
rowIds: payload.rowIds,
|
|
||||||
visibleRowIds: resourceShellVisibleRowIds,
|
|
||||||
rowById: resourceShellRowById,
|
|
||||||
});
|
|
||||||
|
|
||||||
rows.forEach((row) => {
|
|
||||||
if (row.rowKind === "doc") {
|
|
||||||
docItemsMap.set(row.documentId, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (row.rowKind === "index" && !docItemsMap.has(row.documentId)) {
|
|
||||||
docItemsMap.set(row.documentId, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (row.rowKind === "asset" && row.asset && isRealFileAsset(row.asset)) {
|
|
||||||
copyableAssetIds.push(row.asset.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const rows = payload.rowIds
|
const rows = payload.rowIds
|
||||||
.map((rowId) => resourceRowById.get(rowId as any))
|
.map((rowId) => resourceRowById.get(rowId as any))
|
||||||
.filter(Boolean) as TreePaneRow[];
|
.filter(Boolean) as TreePaneRow[];
|
||||||
@@ -1193,7 +1295,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
.forEach((asset) => {
|
.forEach((asset) => {
|
||||||
copyableAssetIds.push(asset.id);
|
copyableAssetIds.push(asset.id);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (docItemsMap.size > 0) {
|
if (docItemsMap.size > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -1214,18 +1315,14 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (copyableAssetIds.length > 0) {
|
if (copyableAssetIds.length > 0) {
|
||||||
const resp = await fetch("/api/media/batch", {
|
try {
|
||||||
method: "POST",
|
await copyFileTreeResourceAssets({
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
action: "copy",
|
|
||||||
assetIds: copyableAssetIds,
|
assetIds: copyableAssetIds,
|
||||||
targetDocumentId: targetDocId,
|
targetDocumentId: targetDocId,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
} catch (error) {
|
||||||
const data = await resp.json().catch(() => ({}));
|
const message = error instanceof Error ? error.message : "粘贴附件失败";
|
||||||
setTimeout(() => window.alert(data?.error ?? "粘贴附件失败"), 0);
|
setTimeout(() => window.alert(message), 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sidebarQuery.refetch();
|
await sidebarQuery.refetch();
|
||||||
@@ -1370,14 +1467,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
const input = window.prompt("输入新文件名", asset.file_name ?? "");
|
const input = window.prompt("输入新文件名", asset.file_name ?? "");
|
||||||
if (!input || !input.trim()) return;
|
if (!input || !input.trim()) return;
|
||||||
const newName = input.trim();
|
const newName = input.trim();
|
||||||
const resp = await fetch("/api/media/batch", {
|
try {
|
||||||
method: "POST",
|
await renameFileTreeResourceAsset({ assetId: asset.id, newName });
|
||||||
headers: { "Content-Type": "application/json" },
|
} catch (error) {
|
||||||
body: JSON.stringify({ action: "rename", assetIds: [asset.id], newName }),
|
window.alert(error instanceof Error ? error.message : "重命名失败");
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const payload = await resp.json().catch(() => ({}));
|
|
||||||
window.alert(payload?.error ?? "重命名失败");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sidebarQuery.refetch();
|
await sidebarQuery.refetch();
|
||||||
@@ -1399,18 +1492,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
}
|
}
|
||||||
const target = window.prompt("输入目标页面 ID", asset.document_id);
|
const target = window.prompt("输入目标页面 ID", asset.document_id);
|
||||||
if (!target || !target.trim()) return;
|
if (!target || !target.trim()) return;
|
||||||
const resp = await fetch("/api/media/batch", {
|
try {
|
||||||
method: "POST",
|
await moveFileTreeResourceAssets({
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
action: "move",
|
|
||||||
assetIds: [asset.id],
|
assetIds: [asset.id],
|
||||||
targetDocumentId: target.trim(),
|
targetDocumentId: target.trim(),
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
} catch (error) {
|
||||||
const payload = await resp.json().catch(() => ({}));
|
window.alert(error instanceof Error ? error.message : "移动失败");
|
||||||
window.alert(payload?.error ?? "移动失败");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sidebarQuery.refetch();
|
await sidebarQuery.refetch();
|
||||||
@@ -1475,14 +1563,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fileAssetIds.length > 0) {
|
if (fileAssetIds.length > 0) {
|
||||||
const resp = await fetch("/api/media/batch", {
|
try {
|
||||||
method: "POST",
|
await deleteFileTreeResourceAssets(fileAssetIds);
|
||||||
headers: { "Content-Type": "application/json" },
|
} catch (error) {
|
||||||
body: JSON.stringify({ action: "delete", assetIds: fileAssetIds }),
|
window.alert(error instanceof Error ? error.message : "删除失败");
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const payload = await resp.json().catch(() => ({}));
|
|
||||||
window.alert(payload?.error ?? "删除失败");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1504,14 +1588,35 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteResourceSelection = useCallback(async () => {
|
const handleDeleteResourceSelection = useCallback(async () => {
|
||||||
const shellDeleteTargets = isRustFamilyTreeRenderer
|
const selectedRowIds = Array.from(resourceSelection.selectedRowIds);
|
||||||
? computeFileTreeShellDeleteTargets({
|
let shellDeleteTargets: {
|
||||||
visibleRowIds: resourceShellVisibleRowIds,
|
docIds: string[];
|
||||||
|
assetIds: string[];
|
||||||
|
assetHints: MediaAsset[];
|
||||||
|
} | null = null;
|
||||||
|
if (isRustFamilyTreeRenderer) {
|
||||||
|
try {
|
||||||
|
const deletePlan = await preflightFileTreeDelete(
|
||||||
|
buildFileTreeShellDeletePreflightPayload({
|
||||||
|
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||||
|
rowIds: selectedRowIds,
|
||||||
rowById: resourceShellRowById,
|
rowById: resourceShellRowById,
|
||||||
selectedRowIds: resourceSelection.selectedRowIds,
|
|
||||||
parentById: docParentById,
|
parentById: docParentById,
|
||||||
})
|
}),
|
||||||
: null;
|
);
|
||||||
|
shellDeleteTargets = {
|
||||||
|
docIds: deletePlan.docIds,
|
||||||
|
assetIds: deletePlan.assetIds,
|
||||||
|
assetHints: collectFileTreeShellAssetHints({
|
||||||
|
rowById: resourceShellRowById,
|
||||||
|
assetIds: deletePlan.assetIds,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
window.alert(error instanceof Error ? error.message : "文件树删除预检失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const legacyDeleteTargets = !isRustFamilyTreeRenderer
|
const legacyDeleteTargets = !isRustFamilyTreeRenderer
|
||||||
? computeTreePaneDeleteTargets({
|
? computeTreePaneDeleteTargets({
|
||||||
visibleRows: resourceRows,
|
visibleRows: resourceRows,
|
||||||
@@ -1595,7 +1700,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
|
|
||||||
await refreshTree();
|
await refreshTree();
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
if (!isRustFamilyTreeRenderer) {
|
||||||
|
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error instanceof Error ? error.message : "删除失败");
|
window.alert(error instanceof Error ? error.message : "删除失败");
|
||||||
}
|
}
|
||||||
@@ -1605,11 +1712,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
isRustFamilyTreeRenderer,
|
isRustFamilyTreeRenderer,
|
||||||
resourceRows,
|
resourceRows,
|
||||||
resourceShellRowById,
|
resourceShellRowById,
|
||||||
resourceShellVisibleRowIds,
|
|
||||||
resourceSelection.selectedRowIds,
|
resourceSelection.selectedRowIds,
|
||||||
handleDeleteAssets,
|
handleDeleteAssets,
|
||||||
refreshTree,
|
refreshTree,
|
||||||
router,
|
router,
|
||||||
|
sidebarData.activeWorkspaceId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleResizeStart = useCallback(
|
const handleResizeStart = useCallback(
|
||||||
@@ -1764,59 +1871,43 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const droppedFiles = Array.from(payload.files ?? []);
|
const droppedFiles = Array.from(payload.files ?? []);
|
||||||
if (droppedFiles.length === 0) return;
|
if (droppedFiles.length === 0) return;
|
||||||
const targetRow =
|
|
||||||
payload.targetRowId
|
|
||||||
? (resourceShellRowById.get(payload.targetRowId) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow);
|
let uploadTargetPlan;
|
||||||
|
try {
|
||||||
if (targetMindmapId) {
|
uploadTargetPlan = await preflightFileTreeUploadTarget(
|
||||||
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
|
buildFileTreeShellUploadTargetPreflightPayload({
|
||||||
}
|
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||||
|
targetDocumentId: payload.targetDocumentId,
|
||||||
const inferredTargetDocId =
|
targetRowId: payload.targetRowId,
|
||||||
payload.targetDocumentId ||
|
|
||||||
inferFileTreeShellTargetDocumentId({
|
|
||||||
focusedRowId: resourceSelection.focusedRowId,
|
focusedRowId: resourceSelection.focusedRowId,
|
||||||
rowById: resourceShellRowById,
|
|
||||||
activeDocId: activeId || null,
|
activeDocId: activeId || null,
|
||||||
}) ||
|
rowById: resourceShellRowById,
|
||||||
"";
|
documentWorkspaceById,
|
||||||
|
}),
|
||||||
if (!inferredTargetDocId) {
|
);
|
||||||
setTimeout(() => window.alert("请选择一个目标页面后再拖入文件"), 0);
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "文件树上传目标预检失败";
|
||||||
|
setTimeout(() => window.alert(message), 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetDoc = sidebarData.documents.find((doc) => doc.id === inferredTargetDocId) ?? null;
|
if (uploadTargetPlan.targetMindmapId) {
|
||||||
const workspaceId = targetDoc?.workspace_id ?? sidebarData.activeWorkspaceId ?? "";
|
setExpandedAssetFolders((prev) => new Set(prev).add(uploadTargetPlan.targetMindmapId));
|
||||||
if (!workspaceId) {
|
|
||||||
setTimeout(() => window.alert("无法识别当前工作区,上传失败"), 0);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
for (const file of droppedFiles) {
|
for (const file of droppedFiles) {
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const payload = await uploadFileTreeResourceAsset({
|
||||||
form.append("file", file);
|
file,
|
||||||
form.append("workspaceId", workspaceId);
|
workspaceId: uploadTargetPlan.workspaceId,
|
||||||
form.append("documentId", inferredTargetDocId);
|
documentId: uploadTargetPlan.targetDocumentId,
|
||||||
if (targetMindmapId) {
|
mindmapId: uploadTargetPlan.targetMindmapId,
|
||||||
form.append("mindmapId", targetMindmapId);
|
});
|
||||||
}
|
|
||||||
const resp = await fetch("/api/media/upload", { method: "POST", body: form });
|
|
||||||
if (!resp.ok) {
|
|
||||||
const payload = await resp.json().catch(() => ({}));
|
|
||||||
errors.push(`${file.name}: ${payload?.error ?? "上传失败"}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const payload = (await resp.json()) as { asset?: MediaAsset };
|
|
||||||
if (payload.asset?.id) {
|
if (payload.asset?.id) {
|
||||||
emitAssetsChanged(inferredTargetDocId, payload.asset);
|
emitAssetsChanged(uploadTargetPlan.targetDocumentId, payload.asset);
|
||||||
// 拖拽文件进文件树:如果落点就是当前打开的页面,则把文件也插入到主编辑区
|
// 拖拽文件进文件树:如果落点就是当前打开的页面,则把文件也插入到主编辑区
|
||||||
if (inferredTargetDocId === activeId && !targetMindmapId) {
|
if (uploadTargetPlan.targetDocumentId === activeId && !uploadTargetPlan.targetMindmapId) {
|
||||||
editorBridge?.insertMediaAsset?.(payload.asset);
|
editorBridge?.insertMediaAsset?.(payload.asset);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1843,11 +1934,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeId,
|
activeId,
|
||||||
|
documentWorkspaceById,
|
||||||
editorBridge,
|
editorBridge,
|
||||||
resourceShellRowById,
|
resourceShellRowById,
|
||||||
resourceSelection.focusedRowId,
|
resourceSelection.focusedRowId,
|
||||||
sidebarData.activeWorkspaceId,
|
sidebarData.activeWorkspaceId,
|
||||||
sidebarData.documents,
|
|
||||||
sidebarQuery,
|
sidebarQuery,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1862,64 +1953,44 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
copy: boolean;
|
copy: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const targetRow =
|
const preflightPayload = buildFileTreeShellInternalDropPreflightPayload({
|
||||||
payload.targetRowId
|
workspaceId: sidebarData.activeWorkspaceId ?? null,
|
||||||
? (resourceShellRowById.get(payload.targetRowId) ?? null)
|
copy: payload.copy,
|
||||||
: null;
|
targetDocumentId: payload.targetDocumentId,
|
||||||
const targetDocId =
|
targetRowId: payload.targetRowId,
|
||||||
payload.targetDocumentId ??
|
rowIds: payload.rowIds,
|
||||||
targetRow?.documentId ??
|
|
||||||
inferFileTreeShellTargetDocumentId({
|
|
||||||
focusedRowId: resourceSelection.focusedRowId,
|
|
||||||
rowById: resourceShellRowById,
|
rowById: resourceShellRowById,
|
||||||
|
focusedRowId: resourceSelection.focusedRowId,
|
||||||
activeDocId: activeId || null,
|
activeDocId: activeId || null,
|
||||||
|
parentById: docParentById,
|
||||||
});
|
});
|
||||||
if (!targetDocId) {
|
let dropPlan;
|
||||||
setTimeout(() => window.alert("无法识别拖拽目标页面"), 0);
|
try {
|
||||||
|
dropPlan = await preflightFileTreeInternalDrop(preflightPayload);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "文件树拖放预检失败";
|
||||||
|
setTimeout(() => window.alert(message), 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow);
|
const {
|
||||||
|
targetDocumentId: targetDocId,
|
||||||
const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined;
|
targetMindmapId,
|
||||||
|
documentTransferPlan,
|
||||||
|
resourceTransferPlan,
|
||||||
|
sourceAssetDocumentIds,
|
||||||
|
} = dropPlan;
|
||||||
|
|
||||||
if (targetMindmapId) {
|
if (targetMindmapId) {
|
||||||
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
|
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueRowIds: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
payload.rowIds.forEach((id) => {
|
|
||||||
if (!id || seen.has(id)) return;
|
|
||||||
seen.add(id);
|
|
||||||
uniqueRowIds.push(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = uniqueRowIds
|
|
||||||
.map((rowId) => resourceShellRowById.get(rowId) ?? null)
|
|
||||||
.filter((row): row is FileTreeShellRow => Boolean(row));
|
|
||||||
|
|
||||||
const docIds = rows.filter((row) => row.rowKind === "doc").map((row) => row.documentId);
|
|
||||||
const assetRows = rows.filter(
|
|
||||||
(row): row is FileTreeShellRow & { rowKind: "asset"; asset: MediaAsset } =>
|
|
||||||
row.rowKind === "asset" && Boolean(row.asset),
|
|
||||||
);
|
|
||||||
const copyableAssetIds = assetRows
|
|
||||||
.map((row) => row.asset)
|
|
||||||
.filter((asset) => isRealFileAsset(asset))
|
|
||||||
.map((asset) => asset.id);
|
|
||||||
|
|
||||||
if (docIds.length === 0 && copyableAssetIds.length === 0) {
|
|
||||||
setTimeout(() => window.alert("没有可拖拽的对象(虚拟附件暂不支持)"), 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.copy) {
|
if (payload.copy) {
|
||||||
if (docIds.length > 0) {
|
if (documentTransferPlan && documentTransferPlan.copyItems.length > 0) {
|
||||||
try {
|
try {
|
||||||
await copyTreeCommand({
|
await copyTreeCommand({
|
||||||
items: docIds.map((documentId) => ({ documentId, recursive: true })),
|
items: documentTransferPlan.copyItems,
|
||||||
targetParentId: targetDocId,
|
targetParentId: documentTransferPlan.targetParentId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "复制页面失败";
|
const message = error instanceof Error ? error.message : "复制页面失败";
|
||||||
@@ -1930,20 +2001,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
emitDocumentsChanged(targetDocId);
|
emitDocumentsChanged(targetDocId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyableAssetIds.length > 0) {
|
if (resourceTransferPlan && resourceTransferPlan.assetIds.length > 0) {
|
||||||
const resp = await fetch("/api/media/batch", {
|
try {
|
||||||
method: "POST",
|
await copyFileTreeResourceAssets({
|
||||||
headers: { "Content-Type": "application/json" },
|
assetIds: resourceTransferPlan.assetIds,
|
||||||
body: JSON.stringify({
|
targetDocumentId: resourceTransferPlan.targetDocumentId,
|
||||||
action: "copy",
|
targetSubPath: resourceTransferPlan.targetSubPath,
|
||||||
assetIds: copyableAssetIds,
|
|
||||||
targetDocumentId: targetDocId,
|
|
||||||
targetSubPath,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
} catch (error) {
|
||||||
const payload = await resp.json().catch(() => ({}));
|
const message = error instanceof Error ? error.message : "复制附件失败";
|
||||||
setTimeout(() => window.alert(payload?.error ?? "复制附件失败"), 0);
|
setTimeout(() => window.alert(message), 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sidebarQuery.refetch();
|
await sidebarQuery.refetch();
|
||||||
@@ -1953,23 +2020,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById);
|
if (documentTransferPlan && documentTransferPlan.topLevelDocumentIds.length > 0) {
|
||||||
if (topLevelDocIds.length > 0) {
|
const topLevelDocIds = documentTransferPlan.topLevelDocumentIds;
|
||||||
const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0;
|
const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0;
|
||||||
setTree((prev) => {
|
setTree((prev) => {
|
||||||
let next = prev;
|
let next = prev;
|
||||||
topLevelDocIds.forEach((id, offset) => {
|
topLevelDocIds.forEach((id, offset) => {
|
||||||
next = moveLocalNode(next, id, targetDocId, baseIndex + offset);
|
next = moveLocalNode(next, id, documentTransferPlan.targetParentId, baseIndex + offset);
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setExpanded((prev) => new Set(prev).add(targetDocId));
|
setExpanded((prev) => new Set(prev).add(documentTransferPlan.targetParentId));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < topLevelDocIds.length; i += 1) {
|
for (let i = 0; i < topLevelDocIds.length; i += 1) {
|
||||||
await moveDocumentCommand({
|
await moveDocumentCommand({
|
||||||
documentId: topLevelDocIds[i],
|
documentId: topLevelDocIds[i],
|
||||||
parentId: targetDocId,
|
parentId: documentTransferPlan.targetParentId,
|
||||||
position: baseIndex + i,
|
position: baseIndex + i,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1983,29 +2050,20 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
emitDocumentsChanged(targetDocId);
|
emitDocumentsChanged(targetDocId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copyableAssetIds.length > 0) {
|
if (resourceTransferPlan && resourceTransferPlan.assetIds.length > 0) {
|
||||||
const resp = await fetch("/api/media/batch", {
|
try {
|
||||||
method: "POST",
|
await moveFileTreeResourceAssets({
|
||||||
headers: { "Content-Type": "application/json" },
|
assetIds: resourceTransferPlan.assetIds,
|
||||||
body: JSON.stringify({
|
targetDocumentId: resourceTransferPlan.targetDocumentId,
|
||||||
action: "move",
|
targetSubPath: resourceTransferPlan.targetSubPath,
|
||||||
assetIds: copyableAssetIds,
|
|
||||||
targetDocumentId: targetDocId,
|
|
||||||
targetSubPath,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
} catch (error) {
|
||||||
const payload = await resp.json().catch(() => ({}));
|
const message = error instanceof Error ? error.message : "移动附件失败";
|
||||||
setTimeout(() => window.alert(payload?.error ?? "移动附件失败"), 0);
|
setTimeout(() => window.alert(message), 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sidebarQuery.refetch();
|
await sidebarQuery.refetch();
|
||||||
const sourceDocIds = new Set(
|
sourceAssetDocumentIds.forEach((id) => emitAssetsChanged(id));
|
||||||
assetRows
|
|
||||||
.map((row) => row.asset?.document_id ?? null)
|
|
||||||
.filter((documentId): documentId is string => Boolean(documentId)),
|
|
||||||
);
|
|
||||||
sourceDocIds.forEach((id) => emitAssetsChanged(id));
|
|
||||||
emitAssetsChanged(targetDocId);
|
emitAssetsChanged(targetDocId);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -2013,6 +2071,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
[
|
[
|
||||||
childrenCountByParentId,
|
childrenCountByParentId,
|
||||||
docParentById,
|
docParentById,
|
||||||
|
sidebarData.activeWorkspaceId,
|
||||||
resourceSelection.focusedRowId,
|
resourceSelection.focusedRowId,
|
||||||
activeId,
|
activeId,
|
||||||
resourceShellRowById,
|
resourceShellRowById,
|
||||||
@@ -2091,12 +2150,14 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await handleDeleteAssets(uniqueAssetIds, assetHint);
|
await handleDeleteAssets(uniqueAssetIds, assetHint);
|
||||||
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
if (!isRustFamilyTreeRenderer) {
|
||||||
|
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(error instanceof Error ? error.message : "删除失败");
|
window.alert(error instanceof Error ? error.message : "删除失败");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleDeleteAssets, mediaAssets, mindmapAssets, tableAssets],
|
[handleDeleteAssets, isRustFamilyTreeRenderer, mediaAssets, mindmapAssets, tableAssets],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConvertToChild = useCallback(
|
const handleConvertToChild = useCallback(
|
||||||
@@ -2182,14 +2243,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
(filteredTrashedMediaAssets ?? []).find((a) => a.id === assetId) ??
|
(filteredTrashedMediaAssets ?? []).find((a) => a.id === assetId) ??
|
||||||
(sidebarData.trashedMediaAssets ?? []).find((a) => a.id === assetId) ??
|
(sidebarData.trashedMediaAssets ?? []).find((a) => a.id === assetId) ??
|
||||||
null;
|
null;
|
||||||
const response = await fetch("/api/media/batch", {
|
try {
|
||||||
method: "POST",
|
await restoreFileTreeResourceAssets([assetId]);
|
||||||
headers: { "Content-Type": "application/json" },
|
} catch (error) {
|
||||||
body: JSON.stringify({ action: "restore", assetIds: [assetId] }),
|
window.alert(error instanceof Error ? error.message : "恢复附件失败,请稍后再试");
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
window.alert(payload?.error ?? "恢复附件失败,请稍后再试");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||||
@@ -2822,7 +2879,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
|||||||
rows={isRustFamilyTreeRenderer ? undefined : resourceRows}
|
rows={isRustFamilyTreeRenderer ? undefined : resourceRows}
|
||||||
treeShellItems={effectiveResourceTreeShellItems}
|
treeShellItems={effectiveResourceTreeShellItems}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
selectedRowIds={resourceSelection.selectedRowIds}
|
|
||||||
onRowClick={handleResourceRowClick}
|
onRowClick={handleResourceRowClick}
|
||||||
onRowDoubleClick={(row, event) => handleResourceRowDoubleClick(row, event)}
|
onRowDoubleClick={(row, event) => handleResourceRowDoubleClick(row, event)}
|
||||||
onRowContextMenu={handleResourceRowContextMenu}
|
onRowContextMenu={handleResourceRowContextMenu}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export type TreeRendererFamily = "react" | "rust_family";
|
|||||||
|
|
||||||
export type TreeShellHostMode = "page" | "filetree" | "picker";
|
export type TreeShellHostMode = "page" | "filetree" | "picker";
|
||||||
|
|
||||||
|
const RUST_RENDERER_CONTRACT = "rust_renderer_input_v1";
|
||||||
|
|
||||||
export type TreeShellPickerCommand = {
|
export type TreeShellPickerCommand = {
|
||||||
kind: "next" | "previous" | "home" | "end" | "pick";
|
kind: "next" | "previous" | "home" | "end" | "pick";
|
||||||
seq: number;
|
seq: number;
|
||||||
@@ -118,8 +120,9 @@ export function TreeShellHost({
|
|||||||
const useRustHost = rendererFamily === "rust_family";
|
const useRustHost = rendererFamily === "rust_family";
|
||||||
const useIframeHost = useRustHost && Boolean(workspaceId?.trim());
|
const useIframeHost = useRustHost && Boolean(workspaceId?.trim());
|
||||||
const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react";
|
const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react";
|
||||||
|
const rendererContract = useRustHost ? RUST_RENDERER_CONTRACT : undefined;
|
||||||
const implementation = useIframeHost
|
const implementation = useIframeHost
|
||||||
? "mnote_web_iframe_proxy"
|
? "rust_inline_compat_host"
|
||||||
: fallbackImplementation ??
|
: fallbackImplementation ??
|
||||||
(rendererFamily === "rust_family" ? "react_fallback" : "react_primary");
|
(rendererFamily === "rust_family" ? "react_fallback" : "react_primary");
|
||||||
|
|
||||||
@@ -130,6 +133,7 @@ export function TreeShellHost({
|
|||||||
data-renderer-family={rendererFamily}
|
data-renderer-family={rendererFamily}
|
||||||
data-tree-host-kind={hostKind}
|
data-tree-host-kind={hostKind}
|
||||||
data-tree-host-implementation={implementation}
|
data-tree-host-implementation={implementation}
|
||||||
|
data-tree-renderer-contract={rendererContract}
|
||||||
data-page-tree-focused-id={mode === "page" ? (focusedDocumentId ?? "") : undefined}
|
data-page-tree-focused-id={mode === "page" ? (focusedDocumentId ?? "") : undefined}
|
||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
>
|
>
|
||||||
@@ -139,6 +143,7 @@ export function TreeShellHost({
|
|||||||
data-tree-host-mode={mode}
|
data-tree-host-mode={mode}
|
||||||
data-tree-host-kind="rust_family"
|
data-tree-host-kind="rust_family"
|
||||||
data-tree-host-implementation={implementation}
|
data-tree-host-implementation={implementation}
|
||||||
|
data-tree-renderer-contract={rendererContract}
|
||||||
className="contents"
|
className="contents"
|
||||||
>
|
>
|
||||||
{useIframeHost && workspaceId ? (
|
{useIframeHost && workspaceId ? (
|
||||||
|
|||||||
@@ -17,6 +17,24 @@ import {
|
|||||||
|
|
||||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function readTreeShellState(srcDoc: string | null | undefined) {
|
||||||
|
const match = (srcDoc ?? "").match(
|
||||||
|
/<script id="tree-shell-state" type="application\/json">([^<]*)<\/script>/,
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("missing tree shell state");
|
||||||
|
}
|
||||||
|
return JSON.parse(match[1] ?? "{}") as {
|
||||||
|
items?: unknown[];
|
||||||
|
rendererInput?: {
|
||||||
|
projectionItemIds?: string[];
|
||||||
|
expandedIds?: string[];
|
||||||
|
activePickerItem?: string | null;
|
||||||
|
excludedPickerIds?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("tree-shell-iframe-host", () => {
|
describe("tree-shell-iframe-host", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
@@ -61,6 +79,33 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
expect(url.searchParams.get("host")).toBe("tree-picker-surface");
|
expect(url.searchParams.get("host")).toBe("tree-picker-surface");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("未提供 inline projection 时也应使用本地 srcDoc,避免默认回源 3104 显示 fetch failed", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("fetch failed"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TreeShellIframeHost
|
||||||
|
mode="page"
|
||||||
|
surfaceTestId="sidebar-page-tree-shell"
|
||||||
|
workspaceId="ws_1"
|
||||||
|
activeDocumentId="doc_1"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(iframe?.getAttribute("src")).toBeNull();
|
||||||
|
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).not.toContain("<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ =");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-page-renderer="initial_v1"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).not.toContain("fetch failed");
|
||||||
|
});
|
||||||
|
|
||||||
it("应能把 picker 搜索结果转换为 inline shell items 并注入到 HTML", () => {
|
it("应能把 picker 搜索结果转换为 inline shell items 并注入到 HTML", () => {
|
||||||
const pickerItems = buildTreeShellInlinePickerItems([
|
const pickerItems = buildTreeShellInlinePickerItems([
|
||||||
{ kind: "doc", id: "doc_target", title: "目标页面", depth: 0 },
|
{ kind: "doc", id: "doc_target", title: "目标页面", depth: 0 },
|
||||||
@@ -179,12 +224,19 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||||
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
expect(iframe?.getAttribute("srcdoc")).not.toContain("<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ =");
|
||||||
expect(iframe?.getAttribute("srcdoc")).toContain("父页面");
|
expect(iframe?.getAttribute("srcdoc")).toContain("父页面");
|
||||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-menu");
|
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-menu");
|
||||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-create");
|
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-create");
|
||||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-rename");
|
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-rename");
|
||||||
expect(iframe?.getAttribute("srcdoc")).toContain("/api/tree/commands");
|
expect(iframe?.getAttribute("srcdoc")).toContain("/api/tree/commands");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("patchPageTreeActiveDom();");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_page_focus_keyboard_reducer_v1"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("applyPageKeyboardAction");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "page" && usedRustInitialRenderer) {patchPageTreeActiveDom();if (focusedNodeId) focusRowElement(focusedNodeId);return;}');
|
||||||
|
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([
|
||||||
|
expect.objectContaining({ nodeId: "doc_parent" }),
|
||||||
|
]);
|
||||||
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
|
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,7 +308,8 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||||
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
expect(iframe?.getAttribute("srcdoc")).not.toContain("<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ =");
|
||||||
|
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("file tree 提供应直接消费的 kernel items 时,应本地生成 shell 并注入正式 item contract", async () => {
|
it("file tree 提供应直接消费的 kernel items 时,应本地生成 shell 并注入正式 item contract", async () => {
|
||||||
@@ -347,9 +400,33 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
|
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||||
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
expect(iframe?.getAttribute("srcdoc")).not.toContain("<script>window.__MNOTE_TREE_SHELL_OVERRIDE__ =");
|
||||||
expect(iframe?.getAttribute("srcdoc")).toContain('"rowKind":"asset"');
|
expect(iframe?.getAttribute("srcdoc")).toContain('"rowKind":"asset"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-filetree-renderer="initial_v1"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-rendered-row="filetree"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("hydrateInitialFileTree");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("const usedRustInitialRenderer = hydrateInitialRenderer();");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("patchFileTreeActiveDom();");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('if (mode === "filetree" && usedRustInitialRenderer) {patchFileTreeActiveDom();syncFileTreeSelectionDom();return;}');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"rendererInput"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"mode":"fileTree"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"expandedIds":["doc_a"]');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("const rendererExpandedIds = normalizeStringArray(rendererInput.expandedIds);");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"filetreeSelection"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["doc:doc_a","index:doc_a"]');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_filetree_selection_reducer_v1"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("applyFileTreeSelectionAction");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("const rendererFiletreeSelection =");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("rendererFiletreeSelection.selectedRowIds");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("computeFileTreeSelectionActionResult");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("const selectFileTreeContextRow = (rowId) =>");
|
||||||
expect(iframe?.getAttribute("srcdoc")).toContain("guide.pdf");
|
expect(iframe?.getAttribute("srcdoc")).toContain("guide.pdf");
|
||||||
|
const state = readTreeShellState(iframe?.getAttribute("srcdoc"));
|
||||||
|
expect(state.items).toEqual([
|
||||||
|
expect.objectContaining({ rowId: "doc:doc_a", nodeId: "doc_a" }),
|
||||||
|
expect.objectContaining({ rowId: "asset:asset_pdf", nodeId: "asset:asset_pdf" }),
|
||||||
|
]);
|
||||||
|
expect(state.rendererInput?.projectionItemIds).toEqual(["doc:doc_a", "asset:asset_pdf"]);
|
||||||
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
|
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -370,6 +447,7 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
workspaceId="ws_1"
|
workspaceId="ws_1"
|
||||||
activeDocumentId="doc_1"
|
activeDocumentId="doc_1"
|
||||||
activePickerItemKey="doc_1"
|
activePickerItemKey="doc_1"
|
||||||
|
excludeIds={["doc_hidden"]}
|
||||||
pickerItems={pickerItems}
|
pickerItems={pickerItems}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -380,6 +458,20 @@ describe("tree-shell-iframe-host", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-picker-renderer="initial_v1"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('data-rust-rendered-row="picker"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("hydrateInitialPickerTree");
|
||||||
|
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).items).toEqual([
|
||||||
|
expect.objectContaining({ nodeId: "doc_1" }),
|
||||||
|
]);
|
||||||
|
expect(readTreeShellState(iframe?.getAttribute("srcdoc")).rendererInput?.activePickerItem).toBe("doc_1");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"activePickerItem":"doc_1"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"excludedPickerIds":["doc_hidden"]');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_picker_state_reducer_v1"');
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("applyPickerStateAction");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("patchPickerActiveDom");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.activePickerItem");
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain("rendererInput.excludedPickerIds");
|
||||||
const postMessage = vi.fn();
|
const postMessage = vi.fn();
|
||||||
Object.defineProperty(iframe, "contentWindow", {
|
Object.defineProperty(iframe, "contentWindow", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -54,20 +54,25 @@ describe("tree-shell-surface", () => {
|
|||||||
|
|
||||||
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
||||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
|
||||||
|
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
|
||||||
|
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
|
||||||
expect(rustHost).not.toBeNull();
|
expect(rustHost).not.toBeNull();
|
||||||
expect(iframe).not.toBeNull();
|
expect(iframe).not.toBeNull();
|
||||||
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
|
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("page tree surface 在 rust_family 下应把 focusedDocumentId 透传到 iframe", () => {
|
it("page tree surface 在 rust_family 下应把 focusedDocumentId 作为宿主状态暴露,并使用 postMessage patch 同步 iframe", () => {
|
||||||
renderPageSurface("rust_family", "doc_focus");
|
renderPageSurface("rust_family", "doc_focus");
|
||||||
|
|
||||||
const iframe = container.querySelector(
|
const iframe = container.querySelector(
|
||||||
'[data-testid="sidebar-page-tree-shell-rust-iframe"]',
|
'[data-testid="sidebar-page-tree-shell-rust-iframe"]',
|
||||||
) as HTMLIFrameElement | null;
|
) as HTMLIFrameElement | null;
|
||||||
|
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
||||||
|
|
||||||
expect(iframe?.getAttribute("src")).toContain("focusedDocumentId=doc_focus");
|
expect(surface?.getAttribute("data-page-tree-focused-id")).toBe("doc_focus");
|
||||||
|
expect(iframe?.getAttribute("src")).toBeNull();
|
||||||
|
expect(iframe?.getAttribute("srcdoc")).toContain('"focusedDocumentId":"doc_focus"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
|
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
|
||||||
@@ -90,7 +95,7 @@ describe("tree-shell-surface", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
||||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
|
||||||
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).not.toBeNull();
|
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).not.toBeNull();
|
||||||
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
|
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -129,7 +134,6 @@ describe("tree-shell-surface", () => {
|
|||||||
treeShellEnabled
|
treeShellEnabled
|
||||||
rows={[]}
|
rows={[]}
|
||||||
activeId=""
|
activeId=""
|
||||||
selectedRowIds={new Set<string>()}
|
|
||||||
onRowClick={() => undefined}
|
onRowClick={() => undefined}
|
||||||
onRowDoubleClick={() => undefined}
|
onRowDoubleClick={() => undefined}
|
||||||
onRowContextMenu={() => undefined}
|
onRowContextMenu={() => undefined}
|
||||||
@@ -147,7 +151,9 @@ describe("tree-shell-surface", () => {
|
|||||||
|
|
||||||
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
||||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
|
||||||
|
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
|
||||||
|
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
|
||||||
expect(rustHost).not.toBeNull();
|
expect(rustHost).not.toBeNull();
|
||||||
expect(iframe).not.toBeNull();
|
expect(iframe).not.toBeNull();
|
||||||
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
|
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
|
||||||
@@ -163,7 +169,6 @@ describe("tree-shell-surface", () => {
|
|||||||
treeShellEnabled={false}
|
treeShellEnabled={false}
|
||||||
rows={[]}
|
rows={[]}
|
||||||
activeId=""
|
activeId=""
|
||||||
selectedRowIds={new Set<string>()}
|
|
||||||
onRowClick={() => undefined}
|
onRowClick={() => undefined}
|
||||||
onRowDoubleClick={() => undefined}
|
onRowDoubleClick={() => undefined}
|
||||||
onRowContextMenu={() => undefined}
|
onRowContextMenu={() => undefined}
|
||||||
@@ -174,7 +179,7 @@ describe("tree-shell-surface", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
|
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
|
||||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
|
||||||
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).not.toBeNull();
|
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).not.toBeNull();
|
||||||
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
|
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -189,7 +194,6 @@ describe("tree-shell-surface", () => {
|
|||||||
treeShellEnabled
|
treeShellEnabled
|
||||||
rows={[]}
|
rows={[]}
|
||||||
activeId=""
|
activeId=""
|
||||||
selectedRowIds={new Set<string>()}
|
|
||||||
onRowClick={() => undefined}
|
onRowClick={() => undefined}
|
||||||
onRowDoubleClick={() => undefined}
|
onRowDoubleClick={() => undefined}
|
||||||
onRowContextMenu={() => undefined}
|
onRowContextMenu={() => undefined}
|
||||||
@@ -218,7 +222,6 @@ describe("tree-shell-surface", () => {
|
|||||||
treeShellEnabled
|
treeShellEnabled
|
||||||
rows={[]}
|
rows={[]}
|
||||||
activeId=""
|
activeId=""
|
||||||
selectedRowIds={new Set<string>()}
|
|
||||||
onRowClick={() => undefined}
|
onRowClick={() => undefined}
|
||||||
onRowDoubleClick={() => undefined}
|
onRowDoubleClick={() => undefined}
|
||||||
onRowContextMenu={() => undefined}
|
onRowContextMenu={() => undefined}
|
||||||
@@ -309,7 +312,9 @@ describe("tree-shell-surface", () => {
|
|||||||
|
|
||||||
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
expect(surface?.getAttribute("data-renderer-family")).toBe("rust_family");
|
||||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
|
||||||
|
expect(surface?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
|
||||||
|
expect(rustHost?.getAttribute("data-tree-renderer-contract")).toBe("rust_renderer_input_v1");
|
||||||
expect(rustHost).not.toBeNull();
|
expect(rustHost).not.toBeNull();
|
||||||
expect(iframe).not.toBeNull();
|
expect(iframe).not.toBeNull();
|
||||||
expect(onPick).not.toHaveBeenCalled();
|
expect(onPick).not.toHaveBeenCalled();
|
||||||
@@ -339,7 +344,7 @@ describe("tree-shell-surface", () => {
|
|||||||
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
|
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
|
||||||
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||||
|
|
||||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("rust_inline_compat_host");
|
||||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||||
expect(rustHost).not.toBeNull();
|
expect(rustHost).not.toBeNull();
|
||||||
expect(iframe).not.toBeNull();
|
expect(iframe).not.toBeNull();
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ type SidebarFileTreeSurfaceProps = {
|
|||||||
rows?: FileTreeRow[];
|
rows?: FileTreeRow[];
|
||||||
treeShellItems?: KernelFileTreeProjectionItem[];
|
treeShellItems?: KernelFileTreeProjectionItem[];
|
||||||
activeId: string;
|
activeId: string;
|
||||||
selectedRowIds: Set<string>;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
onRowClick: (row: FileTreeRow, event: MouseEvent) => void;
|
onRowClick: (row: FileTreeRow, event: MouseEvent) => void;
|
||||||
onRowDoubleClick: (row: FileTreeRow, event: MouseEvent) => void;
|
onRowDoubleClick: (row: FileTreeRow, event: MouseEvent) => void;
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
|
||||||
|
vi.mock("server-only", () => ({}), { virtual: true });
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/authContext", () => ({
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requireAuthContext: vi.fn(async () => ({
|
||||||
|
userId: "user_1",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-utils", () => ({
|
||||||
|
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
|
||||||
|
message,
|
||||||
|
status,
|
||||||
|
details,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/api", () => ({
|
||||||
|
api: {
|
||||||
|
documents: {
|
||||||
|
getMeta: "documents:getMeta",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/route", () => ({
|
||||||
|
getAuthedConvexClient: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||||
|
recordRustBridgeCommandArtifacts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
resolveRustBridgeCommandPlan: vi.fn(),
|
||||||
|
resolveRustBridgeQueryPlan: vi.fn(),
|
||||||
|
executeRustBridgeQueryTransport: vi.fn(),
|
||||||
|
executeRustBridgeMutationTransport: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/blocks", () => ({
|
||||||
|
findBlockInTree: vi.fn(),
|
||||||
|
getBlocksFromDocumentContent: vi.fn(),
|
||||||
|
removeBlockSubtree: vi.fn(),
|
||||||
|
replaceBlockInTree: vi.fn(),
|
||||||
|
withBlocksWrittenBack: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("blocks/block-command-adapter", () => {
|
||||||
|
it("executeBlockPatchCommand 应通过 Rust artifact writer 记录外层 blocks.patch", async () => {
|
||||||
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
|
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
|
const {
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
resolveRustBridgeQueryPlan,
|
||||||
|
executeRustBridgeQueryTransport,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
} = await import("@/lib/documents/rust-runtime");
|
||||||
|
const {
|
||||||
|
getBlocksFromDocumentContent,
|
||||||
|
replaceBlockInTree,
|
||||||
|
withBlocksWrittenBack,
|
||||||
|
} = await import("@/lib/blocks");
|
||||||
|
const { executeBlockPatchCommand } = await import("./block-command-adapter");
|
||||||
|
|
||||||
|
const query = vi.fn(async (name: string) => {
|
||||||
|
if (name === "documents:getMeta") {
|
||||||
|
return {
|
||||||
|
id: "doc_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
embed_default_block_id: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||||
|
auth: { userId: "user_1" },
|
||||||
|
client: {
|
||||||
|
query,
|
||||||
|
mutation: vi.fn(),
|
||||||
|
} as unknown as ConvexHttpClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(resolveRustBridgeQueryPlan).mockResolvedValue({
|
||||||
|
kind: "query",
|
||||||
|
queryName: "documents.content.get",
|
||||||
|
functionName: "documents:getContent",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
payloadJson: "{\"kind\":\"query\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeQueryTransport).mockResolvedValue({
|
||||||
|
content: { type: "doc" },
|
||||||
|
revision: 7,
|
||||||
|
conflict_detection_key: "doc_1:7",
|
||||||
|
});
|
||||||
|
vi.mocked(getBlocksFromDocumentContent).mockReturnValue([{ id: "blk_1" }] as never);
|
||||||
|
vi.mocked(replaceBlockInTree).mockReturnValue({
|
||||||
|
ok: true,
|
||||||
|
nextBlocks: [{ id: "blk_1", type: "paragraph" }],
|
||||||
|
} as never);
|
||||||
|
vi.mocked(withBlocksWrittenBack).mockReturnValue({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ id: "blk_1", type: "paragraph" }],
|
||||||
|
} as never);
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "blocks.patch",
|
||||||
|
commandId: "cmd_block_patch_1",
|
||||||
|
functionName: "documents:updateContent",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
blockId: "blk_1",
|
||||||
|
nextBlock: { id: "blk_1", type: "paragraph" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "documents.save",
|
||||||
|
commandId: "cmd_save_1",
|
||||||
|
functionName: "documents:updateContent",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
content: { type: "doc", content: [{ id: "blk_1", type: "paragraph" }] },
|
||||||
|
expectedRevision: 7,
|
||||||
|
conflictDetectionKey: "doc_1:7",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||||
|
revision: 8,
|
||||||
|
conflict_detection_key: "doc_1:8",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeBlockPatchCommand({
|
||||||
|
request: new Request("http://127.0.0.1:3000/api/blocks/patch", {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
sourceDocumentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
blockId: "blk_1",
|
||||||
|
nextBlock: {
|
||||||
|
id: "blk_1",
|
||||||
|
type: "paragraph",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.commandName).toBe("blocks.patch");
|
||||||
|
expect(result.result).toEqual({
|
||||||
|
revision: 8,
|
||||||
|
conflict_detection_key: "doc_1:8",
|
||||||
|
});
|
||||||
|
expect(vi.mocked(resolveRustBridgeCommandPlan).mock.calls).toHaveLength(2);
|
||||||
|
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
|
context: expect.objectContaining({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
}),
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "blocks.patch",
|
||||||
|
}),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
commandName: "blocks.patch",
|
||||||
|
}),
|
||||||
|
result: {
|
||||||
|
revision: 8,
|
||||||
|
conflict_detection_key: "doc_1:8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
type BridgeContext,
|
type BridgeContext,
|
||||||
type CommandEnvelope,
|
type CommandEnvelope,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
import { recordRustBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||||
import {
|
import {
|
||||||
executeRustBridgeMutationTransport,
|
executeRustBridgeMutationTransport,
|
||||||
executeRustBridgeQueryTransport,
|
executeRustBridgeQueryTransport,
|
||||||
@@ -143,16 +143,19 @@ async function resolveBlockCommandEnvelope<TPayload>(input: {
|
|||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
envelope: CommandEnvelope<TPayload>;
|
envelope: CommandEnvelope<TPayload>;
|
||||||
}) {
|
}) {
|
||||||
await resolveRustBridgeCommandPlan({
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
meta: {
|
||||||
requestId: input.context.requestId,
|
requestId: input.context.requestId,
|
||||||
traceId: input.context.traceId,
|
traceId: input.context.traceId,
|
||||||
commandId: input.envelope.commandId,
|
commandId: input.envelope.commandId,
|
||||||
commandName: input.envelope.name,
|
commandName: input.envelope.name,
|
||||||
} satisfies BlockCommandMeta;
|
} satisfies BlockCommandMeta,
|
||||||
|
plan,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeDocumentSaveTransport(input: {
|
async function executeDocumentSaveTransport(input: {
|
||||||
@@ -196,10 +199,12 @@ async function executeDocumentSaveTransport(input: {
|
|||||||
plan,
|
plan,
|
||||||
});
|
});
|
||||||
if (input.recordArtifacts !== false) {
|
if (input.recordArtifacts !== false) {
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
client: input.client,
|
client: input.client,
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
|
plan,
|
||||||
|
result,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -290,7 +295,7 @@ export async function executeBlockPatchCommand(input: {
|
|||||||
blockId,
|
blockId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const meta = await resolveBlockCommandEnvelope({
|
const { meta, plan } = await resolveBlockCommandEnvelope({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
});
|
});
|
||||||
@@ -304,10 +309,12 @@ export async function executeBlockPatchCommand(input: {
|
|||||||
conflictDetectionKey: state.conflictDetectionKey,
|
conflictDetectionKey: state.conflictDetectionKey,
|
||||||
recordArtifacts: false,
|
recordArtifacts: false,
|
||||||
});
|
});
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
client,
|
client,
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
|
plan,
|
||||||
|
result: save.result,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
@@ -373,7 +380,7 @@ export async function executeBlockMoveCommand(input: {
|
|||||||
blockId,
|
blockId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const meta = await resolveBlockCommandEnvelope({
|
const { meta, plan } = await resolveBlockCommandEnvelope({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
});
|
});
|
||||||
@@ -397,18 +404,21 @@ export async function executeBlockMoveCommand(input: {
|
|||||||
conflictDetectionKey: targetState.conflictDetectionKey,
|
conflictDetectionKey: targetState.conflictDetectionKey,
|
||||||
recordArtifacts: false,
|
recordArtifacts: false,
|
||||||
});
|
});
|
||||||
await recordBridgeCommandArtifacts({
|
const moveResult = {
|
||||||
client,
|
|
||||||
context,
|
|
||||||
envelope,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...meta,
|
|
||||||
result: {
|
|
||||||
ok: true,
|
ok: true,
|
||||||
sourceRevision: sourceSave.result.revision ?? null,
|
sourceRevision: sourceSave.result.revision ?? null,
|
||||||
targetRevision: targetSave.result.revision ?? null,
|
targetRevision: targetSave.result.revision ?? null,
|
||||||
},
|
};
|
||||||
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
plan,
|
||||||
|
result: moveResult,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
result: moveResult,
|
||||||
} satisfies BlockCommandResult<{
|
} satisfies BlockCommandResult<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
sourceRevision: number | null;
|
sourceRevision: number | null;
|
||||||
@@ -487,7 +497,7 @@ export async function executeBlockEmbedCommand(input: {
|
|||||||
blockId,
|
blockId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const meta = await resolveBlockCommandEnvelope({
|
const { meta, plan } = await resolveBlockCommandEnvelope({
|
||||||
context,
|
context,
|
||||||
envelope,
|
envelope,
|
||||||
});
|
});
|
||||||
@@ -501,18 +511,21 @@ export async function executeBlockEmbedCommand(input: {
|
|||||||
conflictDetectionKey: targetState.conflictDetectionKey,
|
conflictDetectionKey: targetState.conflictDetectionKey,
|
||||||
recordArtifacts: false,
|
recordArtifacts: false,
|
||||||
});
|
});
|
||||||
await recordBridgeCommandArtifacts({
|
const embedResult = {
|
||||||
client,
|
|
||||||
context,
|
|
||||||
envelope,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...meta,
|
|
||||||
result: {
|
|
||||||
ok: true,
|
ok: true,
|
||||||
revision: save.result.revision ?? null,
|
revision: save.result.revision ?? null,
|
||||||
referenceBlockId: String(referenceBlock.id),
|
referenceBlockId: String(referenceBlock.id),
|
||||||
},
|
};
|
||||||
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
plan,
|
||||||
|
result: embedResult,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
result: embedResult,
|
||||||
} satisfies BlockCommandResult<{
|
} satisfies BlockCommandResult<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
revision: number | null;
|
revision: number | null;
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/authContext", () => ({
|
||||||
|
HttpError: class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requireAuthContext: vi.fn(async () => ({
|
||||||
|
userId: "user_1",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-utils", () => ({
|
||||||
|
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
|
||||||
|
message,
|
||||||
|
status,
|
||||||
|
details,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/api", () => ({
|
||||||
|
api: {
|
||||||
|
documents: {
|
||||||
|
getContent: "documents:getContent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/route", () => ({
|
||||||
|
getAuthedConvexClient: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||||
|
recordBridgeCommandArtifacts: vi.fn(),
|
||||||
|
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||||
|
recordRustBridgeCommandArtifacts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
resolveRustBridgeCommandPlan: vi.fn(),
|
||||||
|
resolveRustBridgeQueryPlan: vi.fn(),
|
||||||
|
executeRustBridgeQueryTransport: vi.fn(),
|
||||||
|
executeRustBridgeMutationTransport: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/document-content", () => ({
|
||||||
|
extractBlocksFromContent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("documents/block-command-adapter", () => {
|
||||||
|
it("executeBlockPatchBridgeCommand 应改走 Rust artifact writer", async () => {
|
||||||
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
|
const {
|
||||||
|
recordBridgeCommandArtifacts,
|
||||||
|
recordRustBridgeCommandArtifacts,
|
||||||
|
} = await import("@/lib/documents/bridge-log");
|
||||||
|
const {
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
} = await import("@/lib/documents/rust-runtime");
|
||||||
|
const { extractBlocksFromContent } = await import("@/lib/document-content");
|
||||||
|
const { executeBlockPatchBridgeCommand } = await import("./block-command-adapter");
|
||||||
|
|
||||||
|
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||||
|
auth: { userId: "user_1" },
|
||||||
|
client: {
|
||||||
|
query: vi.fn().mockResolvedValue({
|
||||||
|
content: { type: "doc" },
|
||||||
|
}),
|
||||||
|
mutation: vi.fn(),
|
||||||
|
} as unknown as ConvexHttpClient,
|
||||||
|
});
|
||||||
|
vi.mocked(extractBlocksFromContent).mockReturnValue([
|
||||||
|
{
|
||||||
|
id: "blk_1",
|
||||||
|
type: "paragraph",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "blocks.patch",
|
||||||
|
commandId: "cmd_block_patch_1",
|
||||||
|
functionName: "documents:updateContent",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
blockId: "blk_1",
|
||||||
|
nextBlock: {
|
||||||
|
id: "blk_1",
|
||||||
|
type: "heading",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||||
|
revision: 8,
|
||||||
|
conflict_detection_key: "doc_1:8",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeBlockPatchBridgeCommand({
|
||||||
|
request: new Request("http://127.0.0.1:3000/api/documents/blocks/patch", {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
sourceDocumentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
blockId: "blk_1",
|
||||||
|
nextBlock: {
|
||||||
|
id: "blk_1",
|
||||||
|
type: "heading",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.commandName).toBe("blocks.patch");
|
||||||
|
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
|
context: expect.objectContaining({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
}),
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "blocks.patch",
|
||||||
|
}),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
commandName: "blocks.patch",
|
||||||
|
}),
|
||||||
|
result: {
|
||||||
|
revision: 8,
|
||||||
|
conflict_detection_key: "doc_1:8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(recordBridgeCommandArtifacts).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
type BridgeContext,
|
type BridgeContext,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import {
|
import {
|
||||||
recordBridgeCommandArtifacts,
|
|
||||||
recordBridgeCommandFailureArtifacts,
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
recordRustBridgeCommandArtifacts,
|
||||||
} from "@/lib/documents/bridge-log";
|
} from "@/lib/documents/bridge-log";
|
||||||
import {
|
import {
|
||||||
executeRustBridgeMutationTransport,
|
executeRustBridgeMutationTransport,
|
||||||
@@ -164,11 +164,17 @@ export async function executeBlockPatchBridgeCommand(input: {
|
|||||||
throw new Error("块不存在或无权限");
|
throw new Error("块不存在或无权限");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await executeRustBridgeMutationTransport({
|
const transportResult = await executeRustBridgeMutationTransport({
|
||||||
client,
|
client,
|
||||||
plan,
|
plan,
|
||||||
});
|
});
|
||||||
await recordBridgeCommandArtifacts({ context, envelope });
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
plan,
|
||||||
|
result: transportResult,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
client,
|
client,
|
||||||
@@ -217,8 +223,14 @@ export async function executeBlockMoveBridgeCommand(input: {
|
|||||||
});
|
});
|
||||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||||
try {
|
try {
|
||||||
await executeRustBridgeMutationTransport({ client, plan });
|
const transportResult = await executeRustBridgeMutationTransport({ client, plan });
|
||||||
await recordBridgeCommandArtifacts({ context, envelope });
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
plan,
|
||||||
|
result: transportResult,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
client,
|
client,
|
||||||
@@ -276,8 +288,14 @@ export async function executeBlockEmbedBridgeCommand(input: {
|
|||||||
});
|
});
|
||||||
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||||||
try {
|
try {
|
||||||
await executeRustBridgeMutationTransport({ client, plan });
|
const transportResult = await executeRustBridgeMutationTransport({ client, plan });
|
||||||
await recordBridgeCommandArtifacts({ context, envelope });
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
plan,
|
||||||
|
result: transportResult,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
client,
|
client,
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
import type { BridgeContext, CommandEnvelope } from "@/lib/documents/bridge";
|
||||||
|
import { recordBridgeCommandArtifacts } from "@/lib/documents/bridge-log";
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/api", () => ({
|
||||||
|
api: {
|
||||||
|
bridgeLogs: {
|
||||||
|
recordCommandLog: "bridgeLogs.recordCommandLog",
|
||||||
|
recordDomainEvent: "bridgeLogs.recordDomainEvent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/route", () => ({
|
||||||
|
getAuthedConvexClient: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
|
DocumentBridgeError: class DocumentBridgeError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly code: string,
|
||||||
|
public readonly details?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "DocumentBridgeError";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const context: BridgeContext = {
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: "sess_1",
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "vitest",
|
||||||
|
},
|
||||||
|
tenantId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
validateOnly: false,
|
||||||
|
dryRun: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const envelope: CommandEnvelope<{ documentId: string }> = {
|
||||||
|
name: "tree.node.archive",
|
||||||
|
commandId: "cmd_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
actor: context.actor,
|
||||||
|
source: context.source,
|
||||||
|
target: {
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pageId: "page_1",
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
documentId: "page_1",
|
||||||
|
},
|
||||||
|
reason: null,
|
||||||
|
refs: ["test"],
|
||||||
|
dryRun: false,
|
||||||
|
validateOnly: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("bridge-log", () => {
|
||||||
|
it("记录成功 artifact 时应把 streamDelta 同步写入 domain event payload", async () => {
|
||||||
|
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client: { mutation } as unknown as ConvexHttpClient,
|
||||||
|
now: "2026-04-26T00:00:00.000Z",
|
||||||
|
domainEventType: "tree.node.archived",
|
||||||
|
commandPayload: {
|
||||||
|
documentId: "page_1",
|
||||||
|
streamDelta: {
|
||||||
|
op: "remove_document",
|
||||||
|
documentId: "page_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mutation).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"bridgeLogs.recordDomainEvent",
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: "tree.node.archived",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: "tree.node.archived",
|
||||||
|
aggregate: {
|
||||||
|
type: "page",
|
||||||
|
id: "page_1",
|
||||||
|
},
|
||||||
|
command_id: "cmd_1",
|
||||||
|
command_name: "tree.node.archive",
|
||||||
|
command: {
|
||||||
|
id: "cmd_1",
|
||||||
|
name: "tree.node.archive",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
},
|
||||||
|
trace: {
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
streamDelta: {
|
||||||
|
op: "remove_document",
|
||||||
|
documentId: "page_1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("记录 artifact 时应优先消费 Rust domainEventPlan", async () => {
|
||||||
|
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
|
||||||
|
await recordBridgeCommandArtifacts({
|
||||||
|
context,
|
||||||
|
envelope,
|
||||||
|
client: { mutation } as unknown as ConvexHttpClient,
|
||||||
|
now: "2026-04-26T00:00:00.000Z",
|
||||||
|
domainEventPlan: {
|
||||||
|
family: "tree",
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: "tree.subtree.moved",
|
||||||
|
streamDelta: {
|
||||||
|
op: "move_document",
|
||||||
|
documentId: "page_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
domainEventType: "legacy.should_not_win",
|
||||||
|
commandPayload: {
|
||||||
|
documentId: "page_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mutation).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"bridgeLogs.recordDomainEvent",
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: "tree.subtree.moved",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: "tree.subtree.moved",
|
||||||
|
streamDelta: {
|
||||||
|
op: "move_document",
|
||||||
|
documentId: "page_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type CommandEnvelope,
|
type CommandEnvelope,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
|
import type { RustTreeDomainEventPlan } from "@/lib/documents/rust-runtime";
|
||||||
import type { ConvexHttpClient } from "convex/browser";
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
|
||||||
export type BridgeCommandLogStatus = "pending" | "succeeded" | "failed" | "rolled_back";
|
export type BridgeCommandLogStatus = "pending" | "succeeded" | "failed" | "rolled_back";
|
||||||
@@ -19,11 +20,74 @@ function normalizeWorkspaceId(context: BridgeContext, target?: BridgeTarget | nu
|
|||||||
return target?.workspaceId?.trim() || context.workspaceId?.trim() || null;
|
return target?.workspaceId?.trim() || context.workspaceId?.trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStreamDelta(payload: unknown): unknown {
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return payload.streamDelta ?? payload.stream_delta ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDomainEventType(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTreeDomainEventPayload(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
commandName: string;
|
||||||
|
commandId: string;
|
||||||
|
idempotencyKey?: string | null;
|
||||||
|
eventType: string;
|
||||||
|
aggregateType: string;
|
||||||
|
aggregateId: string;
|
||||||
|
streamDelta: unknown;
|
||||||
|
error?: string | null;
|
||||||
|
}) {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: input.eventType,
|
||||||
|
aggregate: {
|
||||||
|
type: input.aggregateType,
|
||||||
|
id: input.aggregateId,
|
||||||
|
},
|
||||||
|
trace: {
|
||||||
|
requestId: input.context.requestId,
|
||||||
|
traceId: input.context.traceId,
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
id: input.commandId,
|
||||||
|
name: input.commandName,
|
||||||
|
idempotencyKey: input.idempotencyKey ?? null,
|
||||||
|
},
|
||||||
|
error: input.error ?? null,
|
||||||
|
// 兼容既有观测与 SSE 解析字段,正式消费者应优先使用上面的结构化字段。
|
||||||
|
request_id: input.context.requestId,
|
||||||
|
trace_id: input.context.traceId,
|
||||||
|
command_id: input.commandId,
|
||||||
|
command_name: input.commandName,
|
||||||
|
idempotency_key: input.idempotencyKey ?? null,
|
||||||
|
};
|
||||||
|
if (input.streamDelta) {
|
||||||
|
payload.streamDelta = input.streamDelta;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
export async function recordBridgeCommandArtifacts<T>(input: {
|
export async function recordBridgeCommandArtifacts<T>(input: {
|
||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
envelope: CommandEnvelope<T>;
|
envelope: CommandEnvelope<T>;
|
||||||
client?: ConvexHttpClient;
|
client?: ConvexHttpClient;
|
||||||
commandPayload?: unknown;
|
commandPayload?: unknown;
|
||||||
|
domainEventPlan?: RustTreeDomainEventPlan | null;
|
||||||
|
domainEventType?: string | null;
|
||||||
status?: BridgeCommandLogStatus;
|
status?: BridgeCommandLogStatus;
|
||||||
eventStatus?: BridgeDomainEventStatus;
|
eventStatus?: BridgeDomainEventStatus;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
@@ -44,6 +108,10 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
|||||||
const aggregateType = input.envelope.target?.blockId ? "block" : input.envelope.target?.pageId ? "page" : "workspace";
|
const aggregateType = input.envelope.target?.blockId ? "block" : input.envelope.target?.pageId ? "page" : "workspace";
|
||||||
const aggregateId =
|
const aggregateId =
|
||||||
input.envelope.target?.blockId ?? input.envelope.target?.pageId ?? workspaceId;
|
input.envelope.target?.blockId ?? input.envelope.target?.pageId ?? workspaceId;
|
||||||
|
const streamDelta = input.domainEventPlan?.streamDelta ?? readStreamDelta(payload);
|
||||||
|
const eventType =
|
||||||
|
normalizeDomainEventType(input.domainEventPlan?.eventType) ??
|
||||||
|
normalizeDomainEventType(input.domainEventType) ?? `${input.envelope.name}.requested`;
|
||||||
|
|
||||||
await client.mutation(api.bridgeLogs.recordCommandLog, {
|
await client.mutation(api.bridgeLogs.recordCommandLog, {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -75,20 +143,23 @@ export async function recordBridgeCommandArtifacts<T>(input: {
|
|||||||
traceId: input.context.traceId,
|
traceId: input.context.traceId,
|
||||||
commandId: input.envelope.commandId,
|
commandId: input.envelope.commandId,
|
||||||
commandLogId,
|
commandLogId,
|
||||||
eventType: `${input.envelope.name}.requested`,
|
eventType,
|
||||||
aggregateType,
|
aggregateType,
|
||||||
aggregateId,
|
aggregateId,
|
||||||
eventVersion: 1,
|
eventVersion: 1,
|
||||||
status: eventStatus,
|
status: eventStatus,
|
||||||
actorType: input.context.actor.actorType,
|
actorType: input.context.actor.actorType,
|
||||||
payload: {
|
payload: buildTreeDomainEventPayload({
|
||||||
request_id: input.context.requestId,
|
context: input.context,
|
||||||
trace_id: input.context.traceId,
|
commandName: input.envelope.name,
|
||||||
command_id: input.envelope.commandId,
|
commandId: input.envelope.commandId,
|
||||||
command_name: input.envelope.name,
|
idempotencyKey: input.envelope.idempotencyKey,
|
||||||
idempotency_key: input.envelope.idempotencyKey,
|
eventType,
|
||||||
|
aggregateType,
|
||||||
|
aggregateId,
|
||||||
|
streamDelta,
|
||||||
error: input.error ?? null,
|
error: input.error ?? null,
|
||||||
},
|
}),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ vi.mock("@/lib/convex/route", () => ({
|
|||||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||||
recordBridgeCommandArtifacts: vi.fn(),
|
recordBridgeCommandArtifacts: vi.fn(),
|
||||||
recordBridgeCommandFailureArtifacts: vi.fn(),
|
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||||
|
recordRustBridgeCommandArtifacts: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
@@ -478,14 +479,43 @@ describe("documents bridge helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("executeMetadataBridgeCommand routes options update through adapter", async () => {
|
it("executeMetadataBridgeCommand routes options update through adapter", async () => {
|
||||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
|
||||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
|
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
|
const {
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
} = await import("@/lib/documents/rust-runtime");
|
||||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||||
auth: { userId: "user_1" },
|
auth: { userId: "user_1" },
|
||||||
client: {
|
client: {
|
||||||
mutation,
|
mutation: vi.fn(),
|
||||||
} as unknown as ConvexHttpClient,
|
} as unknown as ConvexHttpClient,
|
||||||
});
|
});
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "documents.options.update",
|
||||||
|
commandId: "cmd_options_1",
|
||||||
|
functionName: "documents:updateOptions",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
options: {
|
||||||
|
showToc: true,
|
||||||
|
layoutDensity: "compact",
|
||||||
|
embedDefaultBlockId: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
updated_at: "2026-04-26T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
const result = await executeMetadataBridgeCommand({
|
const result = await executeMetadataBridgeCommand({
|
||||||
context: mockContext,
|
context: mockContext,
|
||||||
@@ -505,31 +535,119 @@ describe("documents bridge helpers", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mutation).toHaveBeenCalledTimes(1);
|
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||||
expect(mutation.mock.calls[0]?.[1]).toEqual({
|
context: mockContext,
|
||||||
id: "doc_1",
|
envelope: expect.objectContaining({
|
||||||
options: {
|
name: "documents.options.update",
|
||||||
wideLayout: undefined,
|
}),
|
||||||
smallText: undefined,
|
});
|
||||||
showHeadingNumbers: undefined,
|
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||||
showToc: true,
|
client: expect.any(Object),
|
||||||
showStructure: undefined,
|
context: mockContext,
|
||||||
protectEditing: undefined,
|
envelope: expect.objectContaining({
|
||||||
showWordCount: undefined,
|
name: "documents.options.update",
|
||||||
collapseBacklinks: undefined,
|
}),
|
||||||
pageFont: undefined,
|
plan: expect.objectContaining({
|
||||||
layoutDensity: "compact",
|
commandName: "documents.options.update",
|
||||||
hideChildPages: undefined,
|
functionName: "documents:updateOptions",
|
||||||
showBlockRefCount: undefined,
|
}),
|
||||||
embedDefaultBlockId: null,
|
result: {
|
||||||
|
ok: true,
|
||||||
|
updated_at: "2026-04-26T00:00:00.000Z",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.commandName).toBe("documents.options.update");
|
expect(result.commandName).toBe("documents.options.update");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("executeMetadataBridgeCommand routes stats update through rust runtime", async () => {
|
||||||
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
|
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
|
const {
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
} = await import("@/lib/documents/rust-runtime");
|
||||||
|
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||||
|
auth: { userId: "user_1" },
|
||||||
|
client: {
|
||||||
|
mutation: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
updated_at: "2026-04-26T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
} as unknown as ConvexHttpClient,
|
||||||
|
});
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "documents.stats.update",
|
||||||
|
commandId: "cmd_stats_1",
|
||||||
|
functionName: "documents:updateStats",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
wordCount: 12,
|
||||||
|
characterCount: 34,
|
||||||
|
blockCount: 5,
|
||||||
|
todoTotal: 6,
|
||||||
|
todoDone: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
updated_at: "2026-04-26T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeMetadataBridgeCommand({
|
||||||
|
context: mockContext,
|
||||||
|
envelope: buildDocumentCommandEnvelope({
|
||||||
|
name: "documents.stats.update",
|
||||||
|
payload: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
stats: {
|
||||||
|
wordCount: 12,
|
||||||
|
characterCount: 34,
|
||||||
|
blockCount: 5,
|
||||||
|
todoTotal: 6,
|
||||||
|
todoDone: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
context: mockContext,
|
||||||
|
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||||
|
context: mockContext,
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "documents.stats.update",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
|
context: mockContext,
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "documents.stats.update",
|
||||||
|
}),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
commandName: "documents.stats.update",
|
||||||
|
functionName: "documents:updateStats",
|
||||||
|
}),
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
updated_at: "2026-04-26T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.commandName).toBe("documents.stats.update");
|
||||||
|
});
|
||||||
|
|
||||||
it("executeSaveBridgeCommand routes documents.save through adapter", async () => {
|
it("executeSaveBridgeCommand routes documents.save through adapter", async () => {
|
||||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
const {
|
const {
|
||||||
resolveRustBridgeCommandPlan,
|
resolveRustBridgeCommandPlan,
|
||||||
executeRustBridgeMutationTransport,
|
executeRustBridgeMutationTransport,
|
||||||
@@ -540,7 +658,7 @@ describe("documents bridge helpers", () => {
|
|||||||
mutation: vi.fn(),
|
mutation: vi.fn(),
|
||||||
} as unknown as ConvexHttpClient,
|
} as unknown as ConvexHttpClient,
|
||||||
});
|
});
|
||||||
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
kind: "command",
|
kind: "command",
|
||||||
commandName: "documents.save",
|
commandName: "documents.save",
|
||||||
@@ -563,7 +681,7 @@ describe("documents bridge helpers", () => {
|
|||||||
revision: 7,
|
revision: 7,
|
||||||
conflict_detection_key: "conflict_1",
|
conflict_detection_key: "conflict_1",
|
||||||
});
|
});
|
||||||
const previousBridgeArtifactCalls = vi.mocked(recordBridgeCommandArtifacts).mock.calls.length;
|
const previousBridgeArtifactCalls = vi.mocked(recordRustBridgeCommandArtifacts).mock.calls.length;
|
||||||
|
|
||||||
const payload = buildDocumentSavePayload({
|
const payload = buildDocumentSavePayload({
|
||||||
documentId: "doc_1",
|
documentId: "doc_1",
|
||||||
@@ -603,16 +721,25 @@ describe("documents bridge helpers", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(vi.mocked(recordBridgeCommandArtifacts).mock.calls.length).toBe(
|
expect(vi.mocked(recordRustBridgeCommandArtifacts).mock.calls.length).toBe(
|
||||||
previousBridgeArtifactCalls + 1,
|
previousBridgeArtifactCalls + 1,
|
||||||
);
|
);
|
||||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
context: mockContext,
|
context: mockContext,
|
||||||
envelope: expect.objectContaining({
|
envelope: expect.objectContaining({
|
||||||
name: "documents.save",
|
name: "documents.save",
|
||||||
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
target: { workspaceId: "ws_1", pageId: "doc_1" },
|
||||||
payload,
|
payload,
|
||||||
}),
|
}),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
commandName: "documents.save",
|
||||||
|
functionName: "documents:updateContent",
|
||||||
|
}),
|
||||||
|
result: {
|
||||||
|
revision: 7,
|
||||||
|
conflict_detection_key: "conflict_1",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(result.requestId).toBe("req_1");
|
expect(result.requestId).toBe("req_1");
|
||||||
expect(result.traceId).toBe("trace_1");
|
expect(result.traceId).toBe("trace_1");
|
||||||
@@ -680,7 +807,7 @@ describe("documents bridge helpers", () => {
|
|||||||
|
|
||||||
it("executePageLifecycleBridgeCommand routes page mutation through rust runtime", async () => {
|
it("executePageLifecycleBridgeCommand routes page mutation through rust runtime", async () => {
|
||||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
const {
|
const {
|
||||||
resolveRustBridgeCommandPlan,
|
resolveRustBridgeCommandPlan,
|
||||||
executeRustBridgeMutationTransport,
|
executeRustBridgeMutationTransport,
|
||||||
@@ -691,7 +818,7 @@ describe("documents bridge helpers", () => {
|
|||||||
mutation: vi.fn(),
|
mutation: vi.fn(),
|
||||||
} as unknown as ConvexHttpClient,
|
} as unknown as ConvexHttpClient,
|
||||||
});
|
});
|
||||||
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||||
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
kind: "command",
|
kind: "command",
|
||||||
commandName: "documents.create",
|
commandName: "documents.create",
|
||||||
@@ -746,12 +873,20 @@ describe("documents bridge helpers", () => {
|
|||||||
functionName: "documents:createWithParentReference",
|
functionName: "documents:createWithParentReference",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||||
client: expect.any(Object),
|
client: expect.any(Object),
|
||||||
context: mockContext,
|
context: mockContext,
|
||||||
envelope: expect.objectContaining({
|
envelope: expect.objectContaining({
|
||||||
name: "documents.create",
|
name: "documents.create",
|
||||||
}),
|
}),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
commandName: "documents.create",
|
||||||
|
functionName: "documents:createWithParentReference",
|
||||||
|
}),
|
||||||
|
result: {
|
||||||
|
id: "doc_1",
|
||||||
|
title: "无标题",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(result.result).toEqual({
|
expect(result.result).toEqual({
|
||||||
id: "doc_1",
|
id: "doc_1",
|
||||||
@@ -760,13 +895,39 @@ describe("documents bridge helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("executeMediaAssetWritebackBridgeCommand routes callback writeback through adapter", async () => {
|
it("executeMediaAssetWritebackBridgeCommand routes callback writeback through adapter", async () => {
|
||||||
const mutation = vi.fn().mockResolvedValue({ ok: true, fileUrl: "https://example.com/file.docx" });
|
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
const {
|
||||||
vi.mocked(recordBridgeCommandArtifacts).mockResolvedValue(undefined);
|
resolveRustBridgeCommandPlan,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
} = await import("@/lib/documents/rust-runtime");
|
||||||
|
vi.mocked(recordRustBridgeCommandArtifacts).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "media.assets.replace_storage",
|
||||||
|
commandId: "cmd_asset_1",
|
||||||
|
functionName: "mediaAssets:replaceStorageFromUpload",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
userId: "user_1",
|
||||||
|
id: "asset_1",
|
||||||
|
documentId: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
storageId: "storage_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
fileUrl: "https://example.com/file.docx",
|
||||||
|
});
|
||||||
|
|
||||||
await executeMediaAssetWritebackBridgeCommand({
|
await executeMediaAssetWritebackBridgeCommand({
|
||||||
client: {
|
client: {
|
||||||
mutation,
|
mutation: vi.fn(),
|
||||||
} as unknown as ConvexHttpClient,
|
} as unknown as ConvexHttpClient,
|
||||||
context: mockContext,
|
context: mockContext,
|
||||||
envelope: buildDocumentCommandEnvelope({
|
envelope: buildDocumentCommandEnvelope({
|
||||||
@@ -783,18 +944,26 @@ describe("documents bridge helpers", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mutation).toHaveBeenCalledTimes(1);
|
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||||
expect(mutation.mock.calls[0]?.[1]).toEqual({
|
|
||||||
userId: "user_1",
|
|
||||||
id: "asset_1",
|
|
||||||
storageId: "storage_1",
|
|
||||||
});
|
|
||||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
|
||||||
client: expect.any(Object),
|
|
||||||
context: mockContext,
|
context: mockContext,
|
||||||
envelope: expect.objectContaining({
|
envelope: expect.objectContaining({
|
||||||
name: "media.assets.replace_storage",
|
name: "media.assets.replace_storage",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
|
context: mockContext,
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "media.assets.replace_storage",
|
||||||
|
}),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
commandName: "media.assets.replace_storage",
|
||||||
|
functionName: "mediaAssets:replaceStorageFromUpload",
|
||||||
|
}),
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
fileUrl: "https://example.com/file.docx",
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
assertDocumentMoveOrderPlanMatches,
|
||||||
|
buildDocumentMoveOrderPlanFromDocuments,
|
||||||
|
} from "../../../convex/_utils/documentMoveOrder";
|
||||||
|
|
||||||
|
describe("documentMoveOrder", () => {
|
||||||
|
it("按 Rust canonical move order plan 计算跨父移动和越界 clamp", () => {
|
||||||
|
const plan = buildDocumentMoveOrderPlanFromDocuments({
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
id: "target",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: "2026-04-25T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_a",
|
||||||
|
parent_id: "source",
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: "2026-04-25T00:00:01Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_b",
|
||||||
|
parent_id: "source",
|
||||||
|
sort_order: 1,
|
||||||
|
created_at: "2026-04-25T00:00:02Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_c",
|
||||||
|
parent_id: "target",
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: "2026-04-25T00:00:03Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
documentId: "doc_b",
|
||||||
|
parentId: "target",
|
||||||
|
sortOrder: -2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan).toEqual({
|
||||||
|
documentId: "doc_b",
|
||||||
|
fromParentId: "source",
|
||||||
|
toParentId: "target",
|
||||||
|
requestedSortOrder: -2,
|
||||||
|
normalizedSortOrder: 0,
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
documentId: "doc_b",
|
||||||
|
parentId: "target",
|
||||||
|
sortOrder: 0,
|
||||||
|
moved: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
documentId: "doc_c",
|
||||||
|
parentId: "target",
|
||||||
|
sortOrder: 1,
|
||||||
|
moved: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizedMove 与当前排序状态不一致时拒绝执行", () => {
|
||||||
|
const actual = buildDocumentMoveOrderPlanFromDocuments({
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
id: "doc_a",
|
||||||
|
parent_id: "source",
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: "2026-04-25T00:00:01Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_b",
|
||||||
|
parent_id: "source",
|
||||||
|
sort_order: 1,
|
||||||
|
created_at: "2026-04-25T00:00:02Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc_c",
|
||||||
|
parent_id: "target",
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: "2026-04-25T00:00:03Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
documentId: "doc_b",
|
||||||
|
parentId: "target",
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
assertDocumentMoveOrderPlanMatches(
|
||||||
|
{
|
||||||
|
...actual,
|
||||||
|
patches: actual.patches.map((patch) =>
|
||||||
|
patch.documentId === "doc_c" ? { ...patch, sortOrder: 9 } : patch,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
actual,
|
||||||
|
),
|
||||||
|
).toThrow("Rust move plan 与 Convex 当前排序状态不一致");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { ConvexHttpClient } from "convex/browser";
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
import type { Id } from "../../../convex/_generated/dataModel";
|
|
||||||
import { api } from "@/lib/convex/api";
|
|
||||||
import {
|
import {
|
||||||
buildDocumentBridgeMutationRequest,
|
|
||||||
executeDocumentBridgeMutationRequest,
|
|
||||||
type CommandEnvelope,
|
type CommandEnvelope,
|
||||||
type BridgeContext,
|
type BridgeContext,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import {
|
import {
|
||||||
recordBridgeCommandArtifacts,
|
|
||||||
recordBridgeCommandFailureArtifacts,
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
recordRustBridgeCommandArtifacts,
|
||||||
} from "@/lib/documents/bridge-log";
|
} from "@/lib/documents/bridge-log";
|
||||||
|
import {
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
} from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
export type MediaAssetReplaceStoragePayload = {
|
export type MediaAssetReplaceStoragePayload = {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
@@ -32,21 +32,21 @@ export async function executeMediaAssetWritebackBridgeCommand(input: {
|
|||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
envelope: CommandEnvelope<MediaAssetReplaceStoragePayload>;
|
envelope: CommandEnvelope<MediaAssetReplaceStoragePayload>;
|
||||||
}): Promise<MediaAssetWritebackExecutionResult> {
|
}): Promise<MediaAssetWritebackExecutionResult> {
|
||||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
mapConvexArgs: (payload) => ({
|
|
||||||
userId: payload.userId,
|
|
||||||
id: payload.assetId,
|
|
||||||
storageId: payload.storageId as Id<"_storage">,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await executeDocumentBridgeMutationRequest({
|
const transportResult = await executeRustBridgeMutationTransport({
|
||||||
client: input.client,
|
client: input.client,
|
||||||
mutation: api.mediaAssets.replaceStorageFromUpload,
|
plan,
|
||||||
request: mutationRequest,
|
});
|
||||||
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client: input.client,
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
plan,
|
||||||
|
result: transportResult,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
@@ -58,17 +58,6 @@ export async function executeMediaAssetWritebackBridgeCommand(input: {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await recordBridgeCommandArtifacts({
|
|
||||||
client: input.client,
|
|
||||||
context: input.context,
|
|
||||||
envelope: input.envelope,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// 说明:OnlyOffice callback 写回的主事实是附件存储替换;日志落账失败不应反向导致保存失败。
|
|
||||||
console.warn("[onlyoffice/callback] bridge log write skipped:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestId: input.context.requestId,
|
requestId: input.context.requestId,
|
||||||
traceId: input.context.traceId,
|
traceId: input.context.traceId,
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { api } from "@/lib/convex/api";
|
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import {
|
import {
|
||||||
buildDocumentBridgeMutationRequest,
|
|
||||||
executeDocumentBridgeMutationRequest,
|
|
||||||
type CommandEnvelope,
|
type CommandEnvelope,
|
||||||
type BridgeContext,
|
type BridgeContext,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import {
|
import {
|
||||||
recordBridgeCommandArtifacts,
|
|
||||||
recordBridgeCommandFailureArtifacts,
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
recordRustBridgeCommandArtifacts,
|
||||||
} from "@/lib/documents/bridge-log";
|
} from "@/lib/documents/bridge-log";
|
||||||
import {
|
import {
|
||||||
executeRustBridgeMutationTransport,
|
executeRustBridgeMutationTransport,
|
||||||
@@ -47,111 +44,27 @@ export type MetadataCommandExecutionResult = {
|
|||||||
commandName: string;
|
commandName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MetadataMutationArgs = Record<string, unknown>;
|
|
||||||
|
|
||||||
type MetadataWriteAdapter<TPayload> = {
|
|
||||||
convexMutation: unknown;
|
|
||||||
mapConvexArgs: (payload: TPayload) => MetadataMutationArgs;
|
|
||||||
};
|
|
||||||
|
|
||||||
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
|
|
||||||
return {
|
|
||||||
id: payload.documentId,
|
|
||||||
options: {
|
|
||||||
wideLayout: payload.options.wideLayout,
|
|
||||||
smallText: payload.options.smallText,
|
|
||||||
showHeadingNumbers: payload.options.showHeadingNumbers,
|
|
||||||
showToc: payload.options.showToc,
|
|
||||||
showStructure: payload.options.showStructure,
|
|
||||||
protectEditing: payload.options.protectEditing,
|
|
||||||
showWordCount: payload.options.showWordCount,
|
|
||||||
collapseBacklinks: payload.options.collapseBacklinks,
|
|
||||||
pageFont: payload.options.pageFont,
|
|
||||||
layoutDensity: payload.options.layoutDensity,
|
|
||||||
hideChildPages: payload.options.hideChildPages,
|
|
||||||
showBlockRefCount: payload.options.showBlockRefCount,
|
|
||||||
embedDefaultBlockId:
|
|
||||||
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
|
|
||||||
"documents.title.update": {
|
|
||||||
convexMutation: api.documents.updateTitle,
|
|
||||||
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
|
|
||||||
id: payload.documentId,
|
|
||||||
title: payload.title,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"page.head.updateTitle": {
|
|
||||||
convexMutation: api.documents.updateTitle,
|
|
||||||
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
|
|
||||||
id: payload.documentId,
|
|
||||||
title: payload.title,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"documents.stats.update": {
|
|
||||||
convexMutation: api.documents.updateStats,
|
|
||||||
mapConvexArgs: (payload: DocumentStatsUpdatePayload) => ({
|
|
||||||
id: payload.documentId,
|
|
||||||
wordCount: payload.stats.wordCount,
|
|
||||||
characterCount: payload.stats.characterCount,
|
|
||||||
blockCount: payload.stats.blockCount,
|
|
||||||
todoTotal: payload.stats.todoTotal,
|
|
||||||
todoDone: payload.stats.todoDone,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
"documents.options.update": {
|
|
||||||
convexMutation: api.documents.updateOptions,
|
|
||||||
mapConvexArgs: mapDocumentOptionsToConvexArgs,
|
|
||||||
},
|
|
||||||
"page.layout.updateOptions": {
|
|
||||||
convexMutation: api.documents.updateOptions,
|
|
||||||
mapConvexArgs: mapDocumentOptionsToConvexArgs,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getMetadataWriteAdapter<TPayload>(commandName: string): MetadataWriteAdapter<TPayload> {
|
|
||||||
const adapter = metadataWriteAdapters[commandName];
|
|
||||||
if (!adapter) {
|
|
||||||
throw new Error(`未注册页面元信息命令适配器: ${commandName}`);
|
|
||||||
}
|
|
||||||
return adapter as MetadataWriteAdapter<TPayload>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function executeMetadataBridgeCommand<TPayload>(input: {
|
export async function executeMetadataBridgeCommand<TPayload>(input: {
|
||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
envelope: CommandEnvelope<TPayload>;
|
envelope: CommandEnvelope<TPayload>;
|
||||||
}): Promise<MetadataCommandExecutionResult> {
|
}): Promise<MetadataCommandExecutionResult> {
|
||||||
const { client } = await getAuthedConvexClient();
|
const { client } = await getAuthedConvexClient();
|
||||||
try {
|
try {
|
||||||
if (
|
|
||||||
input.envelope.name === "documents.title.update" ||
|
|
||||||
input.envelope.name === "page.head.updateTitle"
|
|
||||||
) {
|
|
||||||
const plan = await resolveRustBridgeCommandPlan({
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
});
|
});
|
||||||
await executeRustBridgeMutationTransport({
|
const transportResult = await executeRustBridgeMutationTransport({
|
||||||
client,
|
client,
|
||||||
plan,
|
plan,
|
||||||
});
|
});
|
||||||
} else {
|
await recordRustBridgeCommandArtifacts({
|
||||||
const adapter = getMetadataWriteAdapter<TPayload>(input.envelope.name);
|
client,
|
||||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
mapConvexArgs: adapter.mapConvexArgs,
|
plan,
|
||||||
|
result: transportResult,
|
||||||
});
|
});
|
||||||
|
|
||||||
await executeDocumentBridgeMutationRequest({
|
|
||||||
client,
|
|
||||||
mutation: adapter.convexMutation,
|
|
||||||
request: mutationRequest,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
context: input.context,
|
context: input.context,
|
||||||
@@ -161,10 +74,6 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
|
|||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await recordBridgeCommandArtifacts({
|
|
||||||
context: input.context,
|
|
||||||
envelope: input.envelope,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestId: input.context.requestId,
|
requestId: input.context.requestId,
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
type CommandEnvelope,
|
type CommandEnvelope,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import {
|
import {
|
||||||
recordBridgeCommandArtifacts,
|
|
||||||
recordBridgeCommandFailureArtifacts,
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
recordRustBridgeCommandArtifacts,
|
||||||
} from "@/lib/documents/bridge-log";
|
} from "@/lib/documents/bridge-log";
|
||||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||||
import {
|
import {
|
||||||
@@ -147,10 +147,12 @@ export async function executePageLifecycleBridgeCommand<TPayload, TResult>(input
|
|||||||
plan,
|
plan,
|
||||||
});
|
});
|
||||||
|
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
client,
|
plan,
|
||||||
|
result,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ vi.mock("@/lib/documents/bridge", () => ({
|
|||||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||||
recordBridgeCommandArtifacts: vi.fn(),
|
recordBridgeCommandArtifacts: vi.fn(),
|
||||||
recordBridgeCommandFailureArtifacts: vi.fn(),
|
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||||
|
recordRustBridgeCommandArtifacts: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
type CommandEnvelope,
|
type CommandEnvelope,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import {
|
import {
|
||||||
recordBridgeCommandArtifacts,
|
|
||||||
recordBridgeCommandFailureArtifacts,
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
recordRustBridgeCommandArtifacts,
|
||||||
} from "@/lib/documents/bridge-log";
|
} from "@/lib/documents/bridge-log";
|
||||||
import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
import { executeRustBridgeMutationTransport, resolveRustBridgeCommandPlan } from "@/lib/documents/rust-runtime";
|
||||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||||
@@ -257,9 +257,20 @@ async function handleLifecycleError(error: unknown) {
|
|||||||
|
|
||||||
export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> {
|
export async function handleDocumentCreateRequest(request: Request): Promise<NextResponse> {
|
||||||
assertServerEnvironment();
|
assertServerEnvironment();
|
||||||
|
let failureClient: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"] | null = null;
|
||||||
|
let failureContext: BridgeContext | null = null;
|
||||||
|
let failureEnvelope: CommandEnvelope<{
|
||||||
|
documentId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
title: string;
|
||||||
|
accessScope: "private" | "shared" | "public";
|
||||||
|
content: unknown[];
|
||||||
|
}> | null = null;
|
||||||
try {
|
try {
|
||||||
const payload = (await request.json()) as CreatePayload;
|
const payload = (await request.json()) as CreatePayload;
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
|
failureClient = client;
|
||||||
|
|
||||||
const parentId = trimOrNull(payload.parentId);
|
const parentId = trimOrNull(payload.parentId);
|
||||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||||
@@ -304,6 +315,8 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
|
|||||||
pageId: documentId,
|
pageId: documentId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
failureContext = context;
|
||||||
|
failureEnvelope = envelope;
|
||||||
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
const { context: runtimeContext, plan } = await resolveCommandPlan({
|
||||||
request,
|
request,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -331,38 +344,22 @@ export async function handleDocumentCreateRequest(request: Request): Promise<Nex
|
|||||||
await ensureDocumentScaffold(created.id, created.title ?? "无标题");
|
await ensureDocumentScaffold(created.id, created.title ?? "无标题");
|
||||||
}
|
}
|
||||||
|
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
context: runtimeContext,
|
context: runtimeContext,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
plan,
|
||||||
|
result: created,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(created);
|
return NextResponse.json(created);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
const payload = (await request.clone().json().catch(() => ({}))) as MovePayload;
|
if (failureClient && failureContext && failureEnvelope) {
|
||||||
const documentId = trimOrNull(payload.documentId);
|
|
||||||
if (documentId) {
|
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
|
||||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
|
||||||
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
|
||||||
name: "documents.move",
|
|
||||||
payload: {
|
|
||||||
documentId,
|
|
||||||
parentId: trimOrNull(payload.parentId),
|
|
||||||
sortOrder: Number.isFinite(payload.position ?? Number.NaN) ? Math.floor(payload.position ?? 0) : 0,
|
|
||||||
},
|
|
||||||
context,
|
|
||||||
target: {
|
|
||||||
workspaceId: sourceDoc?.workspace_id ?? null,
|
|
||||||
pageId: documentId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
client,
|
client: failureClient,
|
||||||
context,
|
context: failureContext,
|
||||||
envelope,
|
envelope: failureEnvelope,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -427,14 +424,16 @@ export async function handleDocumentMoveRequest(request: Request): Promise<NextR
|
|||||||
authUserId: auth.userId,
|
authUserId: auth.userId,
|
||||||
envelope,
|
envelope,
|
||||||
});
|
});
|
||||||
await executeRustBridgeMutationTransport({
|
const transportResult = await executeRustBridgeMutationTransport({
|
||||||
client,
|
client,
|
||||||
plan,
|
plan,
|
||||||
});
|
});
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
context: runtimeContext,
|
context: runtimeContext,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
plan,
|
||||||
|
result: transportResult,
|
||||||
});
|
});
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -529,14 +528,16 @@ export async function handleDocumentDeleteRequest(request: Request): Promise<Nex
|
|||||||
authUserId: auth.userId,
|
authUserId: auth.userId,
|
||||||
envelope,
|
envelope,
|
||||||
});
|
});
|
||||||
await executeRustBridgeMutationTransport({
|
const transportResult = await executeRustBridgeMutationTransport({
|
||||||
client,
|
client,
|
||||||
plan,
|
plan,
|
||||||
});
|
});
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
context: runtimeContext,
|
context: runtimeContext,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
plan,
|
||||||
|
result: transportResult,
|
||||||
});
|
});
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -548,7 +549,7 @@ export async function handleDocumentDeleteRequest(request: Request): Promise<Nex
|
|||||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
const context = await buildBridgeContext(request, sourceDoc?.workspace_id ?? null, auth.userId);
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
name: "documents.restore",
|
name: "documents.delete",
|
||||||
payload: { documentId },
|
payload: { documentId },
|
||||||
context,
|
context,
|
||||||
target: {
|
target: {
|
||||||
@@ -600,14 +601,16 @@ export async function handleDocumentRestoreRequest(request: Request): Promise<Ne
|
|||||||
authUserId: auth.userId,
|
authUserId: auth.userId,
|
||||||
envelope,
|
envelope,
|
||||||
});
|
});
|
||||||
await executeRustBridgeMutationTransport({
|
const transportResult = await executeRustBridgeMutationTransport({
|
||||||
client,
|
client,
|
||||||
plan,
|
plan,
|
||||||
});
|
});
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
context: runtimeContext,
|
context: runtimeContext,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
plan,
|
||||||
|
result: transportResult,
|
||||||
});
|
});
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -620,12 +623,8 @@ export async function handleDocumentRestoreRequest(request: Request): Promise<Ne
|
|||||||
const workspaceId = sourceDoc?.workspace_id ?? null;
|
const workspaceId = sourceDoc?.workspace_id ?? null;
|
||||||
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
name: "documents.duplicate",
|
name: "documents.restore",
|
||||||
payload: {
|
payload: { documentId },
|
||||||
sourceDocumentId: documentId,
|
|
||||||
newDocumentId: "duplicate_failed",
|
|
||||||
title: sourceDoc?.title ?? null,
|
|
||||||
},
|
|
||||||
context,
|
context,
|
||||||
target: {
|
target: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -689,8 +688,10 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
parent_id: string | null;
|
parent_id: string | null;
|
||||||
sort_order: number | null;
|
sort_order: number | null;
|
||||||
|
is_starred: boolean;
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
access_scope: "private" | "shared" | "public";
|
access_scope: "private" | "shared" | "public";
|
||||||
|
is_template: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}>({
|
}>({
|
||||||
@@ -701,10 +702,12 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
|
|||||||
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
||||||
await copyMindmapIfExists(documentId, duplicated.id);
|
await copyMindmapIfExists(documentId, duplicated.id);
|
||||||
|
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
context: runtimeContext,
|
context: runtimeContext,
|
||||||
envelope,
|
envelope,
|
||||||
client,
|
plan,
|
||||||
|
result: duplicated,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -715,19 +718,24 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
const payload = (await request.clone().json().catch(() => ({}))) as CopyTreePayload;
|
const payload = (await request.clone().json().catch(() => ({}))) as DuplicatePayload;
|
||||||
const { auth, client } = await getAuthedConvexClient();
|
const { auth, client } = await getAuthedConvexClient();
|
||||||
const context = await buildBridgeContext(request, null, auth.userId);
|
const documentId = trimOrNull(payload.documentId);
|
||||||
|
if (documentId) {
|
||||||
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||||
|
const workspaceId = sourceDoc?.workspace_id ?? null;
|
||||||
|
const context = await buildBridgeContext(request, workspaceId, auth.userId);
|
||||||
const envelope = buildDocumentCommandEnvelope({
|
const envelope = buildDocumentCommandEnvelope({
|
||||||
name: "documents.copy_tree",
|
name: "documents.duplicate",
|
||||||
payload: {
|
payload: {
|
||||||
items: (payload.items ?? []).map((item) => ({
|
sourceDocumentId: documentId,
|
||||||
documentId: trimOrNull(item.documentId) ?? "unknown",
|
newDocumentId: "duplicate_failed",
|
||||||
recursive: Boolean(item.recursive),
|
title: sourceDoc?.title ?? null,
|
||||||
})),
|
|
||||||
targetParentId: trimOrNull(payload.targetParentId),
|
|
||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
|
target: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
client,
|
client,
|
||||||
@@ -735,6 +743,7 @@ export async function handleDocumentDuplicateRequest(request: Request): Promise<
|
|||||||
envelope,
|
envelope,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 忽略日志写失败,保留原始错误返回
|
// 忽略日志写失败,保留原始错误返回
|
||||||
}
|
}
|
||||||
@@ -809,10 +818,12 @@ export async function handleDocumentCopyTreeRequest(request: Request): Promise<N
|
|||||||
await copyMindmapIfExists(item.oldId, item.newId);
|
await copyMindmapIfExists(item.oldId, item.newId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
context: runtimeContext,
|
context: runtimeContext,
|
||||||
envelope: outerEnvelope,
|
envelope: outerEnvelope,
|
||||||
client,
|
plan,
|
||||||
|
result,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ vi.mock("@/lib/convex/route", () => ({
|
|||||||
vi.mock("@/lib/documents/bridge-log", () => ({
|
vi.mock("@/lib/documents/bridge-log", () => ({
|
||||||
recordBridgeCommandArtifacts: vi.fn(),
|
recordBridgeCommandArtifacts: vi.fn(),
|
||||||
recordBridgeCommandFailureArtifacts: vi.fn(),
|
recordBridgeCommandFailureArtifacts: vi.fn(),
|
||||||
|
recordRustBridgeCommandArtifacts: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
@@ -69,7 +70,7 @@ const mockContext: BridgeContext = {
|
|||||||
describe("page-write-command-adapter", () => {
|
describe("page-write-command-adapter", () => {
|
||||||
it("标题命令应走 rust bridge transport", async () => {
|
it("标题命令应走 rust bridge transport", async () => {
|
||||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
const { recordBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
const {
|
const {
|
||||||
resolveRustBridgeCommandPlan,
|
resolveRustBridgeCommandPlan,
|
||||||
executeRustBridgeMutationTransport,
|
executeRustBridgeMutationTransport,
|
||||||
@@ -120,36 +121,60 @@ describe("page-write-command-adapter", () => {
|
|||||||
name: "page.head.updateTitle",
|
name: "page.head.updateTitle",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(recordBridgeCommandArtifacts).toHaveBeenCalledWith({
|
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
context: mockContext,
|
context: mockContext,
|
||||||
envelope: expect.objectContaining({
|
envelope: expect.objectContaining({
|
||||||
name: "page.head.updateTitle",
|
name: "page.head.updateTitle",
|
||||||
}),
|
}),
|
||||||
commandPayload: {
|
plan: expect.objectContaining({
|
||||||
documentId: "doc_1",
|
commandName: "page.head.updateTitle",
|
||||||
workspaceId: "ws_1",
|
}),
|
||||||
title: "新标题",
|
result: {
|
||||||
streamDelta: {
|
ok: true,
|
||||||
op: "upsert_document",
|
|
||||||
document: {
|
|
||||||
id: "doc_1",
|
|
||||||
title: "新标题",
|
|
||||||
updated_at: "2026-04-24T00:00:00.000Z",
|
updated_at: "2026-04-24T00:00:00.000Z",
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(result.commandName).toBe("page.head.updateTitle");
|
expect(result.commandName).toBe("page.head.updateTitle");
|
||||||
expect(result.revision).toBeNull();
|
expect(result.revision).toBeNull();
|
||||||
expect(result.conflictDetectionKey).toBeNull();
|
expect(result.conflictDetectionKey).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("页面设置命令应走 bridge mutation request", async () => {
|
it("页面设置命令应走 rust bridge transport", async () => {
|
||||||
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
|
||||||
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
const { getAuthedConvexClient } = await import("@/lib/convex/route");
|
||||||
|
const { recordRustBridgeCommandArtifacts } = await import("@/lib/documents/bridge-log");
|
||||||
|
const {
|
||||||
|
resolveRustBridgeCommandPlan,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
} = await import("@/lib/documents/rust-runtime");
|
||||||
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
vi.mocked(getAuthedConvexClient).mockResolvedValue({
|
||||||
auth: { userId: "user_1" },
|
auth: { userId: "user_1" },
|
||||||
client: { mutation } as unknown as ConvexHttpClient,
|
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
|
||||||
|
});
|
||||||
|
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
|
||||||
|
kind: "command",
|
||||||
|
commandName: "page.layout.updateOptions",
|
||||||
|
commandId: "cmd_options_1",
|
||||||
|
functionName: "documents:updateOptions",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{\"kind\":\"command\"}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
options: {
|
||||||
|
showToc: true,
|
||||||
|
layoutDensity: "compact",
|
||||||
|
embedDefaultBlockId: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
updated_at: "2026-04-26T00:00:00.000Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await executePageWriteBridgeCommand({
|
const result = await executePageWriteBridgeCommand({
|
||||||
@@ -170,7 +195,27 @@ describe("page-write-command-adapter", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mutation).toHaveBeenCalledTimes(1);
|
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
|
||||||
|
context: mockContext,
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "page.layout.updateOptions",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
|
||||||
|
client: expect.any(Object),
|
||||||
|
context: mockContext,
|
||||||
|
envelope: expect.objectContaining({
|
||||||
|
name: "page.layout.updateOptions",
|
||||||
|
}),
|
||||||
|
plan: expect.objectContaining({
|
||||||
|
commandName: "page.layout.updateOptions",
|
||||||
|
functionName: "documents:updateOptions",
|
||||||
|
}),
|
||||||
|
result: {
|
||||||
|
ok: true,
|
||||||
|
updated_at: "2026-04-26T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(result.commandName).toBe("page.layout.updateOptions");
|
expect(result.commandName).toBe("page.layout.updateOptions");
|
||||||
expect(result.revision).toBeNull();
|
expect(result.revision).toBeNull();
|
||||||
expect(result.conflictDetectionKey).toBeNull();
|
expect(result.conflictDetectionKey).toBeNull();
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { api } from "@/lib/convex/api";
|
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import {
|
import {
|
||||||
buildDocumentBridgeMutationRequest,
|
|
||||||
executeDocumentBridgeMutationRequest,
|
|
||||||
type BridgeContext,
|
type BridgeContext,
|
||||||
type CommandEnvelope,
|
type CommandEnvelope,
|
||||||
DocumentBridgeError,
|
DocumentBridgeError,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import {
|
import {
|
||||||
recordBridgeCommandArtifacts,
|
|
||||||
recordBridgeCommandFailureArtifacts,
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
recordRustBridgeCommandArtifacts,
|
||||||
} from "@/lib/documents/bridge-log";
|
} from "@/lib/documents/bridge-log";
|
||||||
import type { DocumentOptionsUpdatePayload, DocumentTitleUpdatePayload } from "@/lib/documents/metadata-command-adapter";
|
import type { DocumentOptionsUpdatePayload, DocumentTitleUpdatePayload } from "@/lib/documents/metadata-command-adapter";
|
||||||
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
|
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
|
||||||
@@ -23,14 +20,6 @@ type PageWritePayload =
|
|||||||
| DocumentOptionsUpdatePayload
|
| DocumentOptionsUpdatePayload
|
||||||
| DocumentSavePayload;
|
| DocumentSavePayload;
|
||||||
|
|
||||||
type MetadataMutationArgs = Record<string, unknown>;
|
|
||||||
|
|
||||||
type PageWriteAdapter<TPayload> = {
|
|
||||||
kind: "rust_transport" | "convex_mutation";
|
|
||||||
convexMutation?: unknown;
|
|
||||||
mapConvexArgs?: (payload: TPayload) => MetadataMutationArgs;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PageWriteCommandExecutionResult = {
|
export type PageWriteCommandExecutionResult = {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
@@ -40,70 +29,6 @@ export type PageWriteCommandExecutionResult = {
|
|||||||
conflictDetectionKey: string | null;
|
conflictDetectionKey: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachStreamDelta(commandPayload: unknown, streamDelta?: Record<string, unknown> | null) {
|
|
||||||
if (!streamDelta) {
|
|
||||||
return commandPayload;
|
|
||||||
}
|
|
||||||
if (isRecord(commandPayload)) {
|
|
||||||
return {
|
|
||||||
...commandPayload,
|
|
||||||
streamDelta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
payload: commandPayload,
|
|
||||||
streamDelta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
|
|
||||||
return {
|
|
||||||
id: payload.documentId,
|
|
||||||
options: {
|
|
||||||
wideLayout: payload.options.wideLayout,
|
|
||||||
smallText: payload.options.smallText,
|
|
||||||
showHeadingNumbers: payload.options.showHeadingNumbers,
|
|
||||||
showToc: payload.options.showToc,
|
|
||||||
showStructure: payload.options.showStructure,
|
|
||||||
protectEditing: payload.options.protectEditing,
|
|
||||||
showWordCount: payload.options.showWordCount,
|
|
||||||
collapseBacklinks: payload.options.collapseBacklinks,
|
|
||||||
pageFont: payload.options.pageFont,
|
|
||||||
layoutDensity: payload.options.layoutDensity,
|
|
||||||
hideChildPages: payload.options.hideChildPages,
|
|
||||||
showBlockRefCount: payload.options.showBlockRefCount,
|
|
||||||
embedDefaultBlockId:
|
|
||||||
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageWriteAdapters: Record<string, PageWriteAdapter<unknown>> = {
|
|
||||||
"page.head.updateTitle": {
|
|
||||||
kind: "rust_transport",
|
|
||||||
},
|
|
||||||
"page.layout.updateOptions": {
|
|
||||||
kind: "convex_mutation",
|
|
||||||
convexMutation: api.documents.updateOptions,
|
|
||||||
mapConvexArgs: mapDocumentOptionsToConvexArgs,
|
|
||||||
},
|
|
||||||
"page.body.save": {
|
|
||||||
kind: "rust_transport",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getPageWriteAdapter<TPayload>(commandName: string): PageWriteAdapter<TPayload> {
|
|
||||||
const adapter = pageWriteAdapters[commandName];
|
|
||||||
if (!adapter) {
|
|
||||||
throw new Error(`未注册页面写命令适配器: ${commandName}`);
|
|
||||||
}
|
|
||||||
return adapter as PageWriteAdapter<TPayload>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecutionResult, "revision" | "conflictDetectionKey"> {
|
function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecutionResult, "revision" | "conflictDetectionKey"> {
|
||||||
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : null;
|
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : null;
|
||||||
return {
|
return {
|
||||||
@@ -118,42 +43,13 @@ function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecution
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUpdatedAt(result: unknown): string | null {
|
|
||||||
const record = isRecord(result) ? result : null;
|
|
||||||
const updatedAt = record?.updated_at;
|
|
||||||
return typeof updatedAt === "string" && updatedAt.trim() ? updatedAt.trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPageWriteCommandPayload<TPayload extends PageWritePayload>(input: {
|
|
||||||
envelope: CommandEnvelope<TPayload>;
|
|
||||||
transportResult?: unknown;
|
|
||||||
}) {
|
|
||||||
if (input.envelope.name !== "page.head.updateTitle") {
|
|
||||||
return input.envelope.payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = input.envelope.payload as DocumentTitleUpdatePayload;
|
|
||||||
const updatedAt = normalizeUpdatedAt(input.transportResult);
|
|
||||||
|
|
||||||
return attachStreamDelta(payload, {
|
|
||||||
op: "upsert_document",
|
|
||||||
document: {
|
|
||||||
id: payload.documentId,
|
|
||||||
title: payload.title,
|
|
||||||
...(updatedAt ? { updated_at: updatedAt } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
|
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
|
||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
envelope: CommandEnvelope<TPayload>;
|
envelope: CommandEnvelope<TPayload>;
|
||||||
}): Promise<PageWriteCommandExecutionResult> {
|
}): Promise<PageWriteCommandExecutionResult> {
|
||||||
const { client } = await getAuthedConvexClient();
|
const { client } = await getAuthedConvexClient();
|
||||||
const adapter = getPageWriteAdapter<TPayload>(input.envelope.name);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (adapter.kind === "rust_transport") {
|
|
||||||
const plan = await resolveRustBridgeCommandPlan({
|
const plan = await resolveRustBridgeCommandPlan({
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
@@ -164,13 +60,12 @@ export async function executePageWriteBridgeCommand<TPayload extends PageWritePa
|
|||||||
});
|
});
|
||||||
const persistedMeta = normalizePersistedMeta(transportResult);
|
const persistedMeta = normalizePersistedMeta(transportResult);
|
||||||
|
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
commandPayload: buildPageWriteCommandPayload({
|
plan,
|
||||||
envelope: input.envelope,
|
result: transportResult,
|
||||||
transportResult,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -181,33 +76,6 @@ export async function executePageWriteBridgeCommand<TPayload extends PageWritePa
|
|||||||
revision: persistedMeta.revision,
|
revision: persistedMeta.revision,
|
||||||
conflictDetectionKey: persistedMeta.conflictDetectionKey,
|
conflictDetectionKey: persistedMeta.conflictDetectionKey,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const mutationRequest = buildDocumentBridgeMutationRequest({
|
|
||||||
context: input.context,
|
|
||||||
envelope: input.envelope,
|
|
||||||
mapConvexArgs: adapter.mapConvexArgs!,
|
|
||||||
});
|
|
||||||
|
|
||||||
await executeDocumentBridgeMutationRequest({
|
|
||||||
client,
|
|
||||||
mutation: adapter.convexMutation!,
|
|
||||||
request: mutationRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
await recordBridgeCommandArtifacts({
|
|
||||||
context: input.context,
|
|
||||||
envelope: input.envelope,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestId: input.context.requestId,
|
|
||||||
traceId: input.context.traceId,
|
|
||||||
commandId: input.envelope.commandId,
|
|
||||||
commandName: input.envelope.name,
|
|
||||||
revision: null,
|
|
||||||
conflictDetectionKey: null,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await recordBridgeCommandFailureArtifacts({
|
await recordBridgeCommandFailureArtifacts({
|
||||||
context: input.context,
|
context: input.context,
|
||||||
|
|||||||
@@ -1,4 +1,43 @@
|
|||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
import {
|
||||||
|
buildRustBridgeCommandArtifactPlan,
|
||||||
|
executeRustBridgeMutationTransport,
|
||||||
|
materializeRustTreeStreamDelta,
|
||||||
|
readRustTreeDomainEventPlan,
|
||||||
|
readRustTreeDomainEventType,
|
||||||
|
type RustBridgeCommandPlan,
|
||||||
|
} from "./rust-runtime";
|
||||||
|
|
||||||
|
vi.mock("@/lib/convex/api", () => ({
|
||||||
|
api: {
|
||||||
|
documents: {
|
||||||
|
move: "documents.move",
|
||||||
|
copyTree: "documents.copyTree",
|
||||||
|
updateStats: "documents.updateStats",
|
||||||
|
},
|
||||||
|
mediaAssets: {
|
||||||
|
batchCopy: "mediaAssets.batchCopy",
|
||||||
|
batchMove: "mediaAssets.batchMove",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
|
DocumentBridgeError: class DocumentBridgeError extends Error {
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
details?: unknown;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, code: string, details?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = "DocumentBridgeError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
let runtimeSelection: Record<string, unknown> = {};
|
let runtimeSelection: Record<string, unknown> = {};
|
||||||
|
|
||||||
@@ -41,3 +80,609 @@ describe("shouldUseBuiltBridgeRuntimeBinary", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("executeRustBridgeMutationTransport", () => {
|
||||||
|
it("documents.move 应把 Rust normalizedMove 透传给 Convex 可选校验", async () => {
|
||||||
|
const normalizedMove = {
|
||||||
|
documentId: "doc_b",
|
||||||
|
fromParentId: "source",
|
||||||
|
toParentId: "target",
|
||||||
|
requestedSortOrder: 0,
|
||||||
|
normalizedSortOrder: 0,
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
documentId: "doc_b",
|
||||||
|
parentId: "target",
|
||||||
|
sortOrder: 0,
|
||||||
|
moved: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const mutation = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
const plan: RustBridgeCommandPlan = {
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.subtree.move",
|
||||||
|
commandId: "cmd_move",
|
||||||
|
functionName: "documents:move",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_b",
|
||||||
|
parentId: "target",
|
||||||
|
sortOrder: 0,
|
||||||
|
normalizedMove,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await executeRustBridgeMutationTransport({
|
||||||
|
client: { mutation } as unknown as ConvexHttpClient,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mutation).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mutation.mock.calls[0]?.[1]).toMatchObject({
|
||||||
|
id: "doc_b",
|
||||||
|
parentId: "target",
|
||||||
|
sortOrder: 0,
|
||||||
|
normalizedMove,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("documents.copyTree 应注册为 Rust tree.subtree.copy 的 transport", async () => {
|
||||||
|
const mutation = vi.fn().mockResolvedValue({ items: [] });
|
||||||
|
const plan: RustBridgeCommandPlan = {
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.subtree.copy",
|
||||||
|
commandId: "cmd_copy",
|
||||||
|
functionName: "documents:copyTree",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
targetParentId: "parent_1",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
documentId: "doc_1",
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await executeRustBridgeMutationTransport({
|
||||||
|
client: { mutation } as unknown as ConvexHttpClient,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mutation).toHaveBeenCalledWith("documents.copyTree", {
|
||||||
|
targetParentId: "parent_1",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
documentId: "doc_1",
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("documents.updateStats 应注册为 Rust metadata transport", async () => {
|
||||||
|
const mutation = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
updated_at: "2026-04-26T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
const plan: RustBridgeCommandPlan = {
|
||||||
|
kind: "command",
|
||||||
|
commandName: "documents.stats.update",
|
||||||
|
commandId: "cmd_stats",
|
||||||
|
functionName: "documents:updateStats",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
id: "doc_1",
|
||||||
|
wordCount: 12,
|
||||||
|
characterCount: 34,
|
||||||
|
blockCount: 5,
|
||||||
|
todoTotal: 6,
|
||||||
|
todoDone: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await executeRustBridgeMutationTransport({
|
||||||
|
client: { mutation } as unknown as ConvexHttpClient,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mutation).toHaveBeenCalledWith("documents.updateStats", {
|
||||||
|
id: "doc_1",
|
||||||
|
wordCount: 12,
|
||||||
|
characterCount: 34,
|
||||||
|
blockCount: 5,
|
||||||
|
todoTotal: 6,
|
||||||
|
todoDone: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tree.resource.copy/move 应把 Rust resourceTransferPlan 透传给媒体 transport", async () => {
|
||||||
|
const mutation = vi.fn().mockResolvedValue({ items: [] });
|
||||||
|
const basePlan: RustBridgeCommandPlan = {
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.resource.copy",
|
||||||
|
commandId: "cmd_asset_copy",
|
||||||
|
functionName: "mediaAssets:batchCopy",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
assetIds: ["asset_1", "asset_2"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
resourceTransferPlan: {
|
||||||
|
action: "copy",
|
||||||
|
assetIds: ["asset_1", "asset_2"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await executeRustBridgeMutationTransport({
|
||||||
|
client: { mutation } as unknown as ConvexHttpClient,
|
||||||
|
plan: basePlan,
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeRustBridgeMutationTransport({
|
||||||
|
client: { mutation } as unknown as ConvexHttpClient,
|
||||||
|
plan: {
|
||||||
|
...basePlan,
|
||||||
|
commandName: "tree.resource.move",
|
||||||
|
commandId: "cmd_asset_move",
|
||||||
|
functionName: "mediaAssets:batchMove",
|
||||||
|
argsJson: {
|
||||||
|
...basePlan.argsJson,
|
||||||
|
resourceTransferPlan: {
|
||||||
|
action: "move",
|
||||||
|
assetIds: ["asset_1", "asset_2"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mutation).toHaveBeenNthCalledWith(1, "mediaAssets.batchCopy", {
|
||||||
|
userId: "user_1",
|
||||||
|
assetIds: ["asset_1", "asset_2"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
resourceTransferPlan: {
|
||||||
|
action: "copy",
|
||||||
|
assetIds: ["asset_1", "asset_2"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mutation).toHaveBeenNthCalledWith(2, "mediaAssets.batchMove", {
|
||||||
|
userId: "user_1",
|
||||||
|
assetIds: ["asset_1", "asset_2"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
resourceTransferPlan: {
|
||||||
|
action: "move",
|
||||||
|
assetIds: ["asset_1", "asset_2"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("materializeRustTreeStreamDelta", () => {
|
||||||
|
it("应按 Rust move_document hint 与 mutation canonical 结果生成细粒度 delta", () => {
|
||||||
|
const plan: RustBridgeCommandPlan = {
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.subtree.move",
|
||||||
|
commandId: "cmd_move",
|
||||||
|
functionName: "documents:move",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "move_document",
|
||||||
|
args: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_requested",
|
||||||
|
sortOrder: 9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
materializeRustTreeStreamDelta({
|
||||||
|
plan,
|
||||||
|
result: {
|
||||||
|
parent_id: "parent_actual",
|
||||||
|
sort_order: 2,
|
||||||
|
updated_at: "2026-04-26T00:00:00Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
op: "move_document",
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_actual",
|
||||||
|
sortOrder: 2,
|
||||||
|
updatedAt: "2026-04-26T00:00:00Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应按 Rust copy_result hint 从结果 items 中生成 upsert_documents delta", () => {
|
||||||
|
const document = {
|
||||||
|
id: "copy_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "复制页面",
|
||||||
|
parent_id: "parent_1",
|
||||||
|
sort_order: 0,
|
||||||
|
is_starred: false,
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-26T00:00:00Z",
|
||||||
|
updated_at: "2026-04-26T00:00:00Z",
|
||||||
|
};
|
||||||
|
const plan: RustBridgeCommandPlan = {
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.subtree.copy",
|
||||||
|
commandId: "cmd_copy",
|
||||||
|
functionName: "documents:copyTree",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "copy_result",
|
||||||
|
args: {
|
||||||
|
itemsField: "items",
|
||||||
|
documentField: "document",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
materializeRustTreeStreamDelta({
|
||||||
|
plan,
|
||||||
|
result: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
oldId: "doc_1",
|
||||||
|
newId: "copy_1",
|
||||||
|
document,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
op: "upsert_documents",
|
||||||
|
upsertDocuments: [document],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应按 Rust result_document hint 从根结果生成 upsert_document delta", () => {
|
||||||
|
const document = {
|
||||||
|
id: "copy_2",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "复制页面 2",
|
||||||
|
parent_id: "parent_1",
|
||||||
|
sort_order: 1,
|
||||||
|
is_starred: false,
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-26T00:00:00Z",
|
||||||
|
updated_at: "2026-04-26T00:00:00Z",
|
||||||
|
};
|
||||||
|
const plan: RustBridgeCommandPlan = {
|
||||||
|
kind: "command",
|
||||||
|
commandName: "documents.duplicate",
|
||||||
|
commandId: "cmd_duplicate",
|
||||||
|
functionName: "documents:duplicateWithMindmaps",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "result_document",
|
||||||
|
args: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
materializeRustTreeStreamDelta({
|
||||||
|
plan,
|
||||||
|
result: document,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
op: "upsert_document",
|
||||||
|
document,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应按 Rust asset_result hint 从结果 items 中生成 upsert_assets delta", () => {
|
||||||
|
const asset = {
|
||||||
|
id: "asset_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
document_id: "doc_target",
|
||||||
|
asset_type: "file",
|
||||||
|
file_url: "/file.pdf",
|
||||||
|
thumbnail_url: "/file.pdf",
|
||||||
|
file_name: "file.pdf",
|
||||||
|
file_size: 1024,
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
created_at: "2026-04-26T00:00:00Z",
|
||||||
|
updated_at: "2026-04-26T00:00:00Z",
|
||||||
|
};
|
||||||
|
const plan: RustBridgeCommandPlan = {
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.resource.move",
|
||||||
|
commandId: "cmd_asset_move",
|
||||||
|
functionName: "mediaAssets:batchMove",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "asset_result",
|
||||||
|
args: {
|
||||||
|
itemsField: "items",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
materializeRustTreeStreamDelta({
|
||||||
|
plan,
|
||||||
|
result: {
|
||||||
|
items: [asset],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
op: "upsert_assets",
|
||||||
|
upsertAssets: [asset],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readRustTreeDomainEventType", () => {
|
||||||
|
it("应从 Rust domainEventHint 读取正式树域 event type", () => {
|
||||||
|
const plan: RustBridgeCommandPlan = {
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.node.archive",
|
||||||
|
commandId: "cmd_archive",
|
||||||
|
functionName: "documents:softDelete",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "tree.node.archived",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(readRustTreeDomainEventType(plan)).toBe("tree.node.archived");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应优先读取 Rust domainEventPlan 作为正式树域事件计划", () => {
|
||||||
|
const plan: RustBridgeCommandPlan = {
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.subtree.move",
|
||||||
|
commandId: "cmd_move",
|
||||||
|
functionName: "documents:move",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: null,
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
domainEventPlan: {
|
||||||
|
family: "tree",
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: "tree.subtree.moved",
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "move_document",
|
||||||
|
args: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
domainEventHint: {
|
||||||
|
family: "tree",
|
||||||
|
eventType: "legacy.should_not_win",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(readRustTreeDomainEventType(plan)).toBe("tree.subtree.moved");
|
||||||
|
expect(readRustTreeDomainEventPlan(plan)).toEqual({
|
||||||
|
family: "tree",
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: "tree.subtree.moved",
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "move_document",
|
||||||
|
args: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildRustBridgeCommandArtifactPlan", () => {
|
||||||
|
it("应通过 Rust runtime commandArtifact 输入生成 artifact plan", async () => {
|
||||||
|
const artifactPlan = await buildRustBridgeCommandArtifactPlan({
|
||||||
|
context: {
|
||||||
|
deploymentId: null,
|
||||||
|
projectId: null,
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_artifact_1",
|
||||||
|
traceId: "trace_artifact_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "vitest",
|
||||||
|
},
|
||||||
|
tenantId: null,
|
||||||
|
authToken: null,
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
validateOnly: false,
|
||||||
|
dryRun: false,
|
||||||
|
},
|
||||||
|
envelope: {
|
||||||
|
name: "tree.subtree.move",
|
||||||
|
commandId: "cmd_artifact_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
channel: "next-route",
|
||||||
|
client: "vitest",
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pageId: "doc_1",
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
preflightData: null,
|
||||||
|
reason: "test",
|
||||||
|
refs: ["test"],
|
||||||
|
dryRun: false,
|
||||||
|
validateOnly: false,
|
||||||
|
},
|
||||||
|
plan: {
|
||||||
|
kind: "command",
|
||||||
|
commandName: "tree.subtree.move",
|
||||||
|
commandId: "cmd_artifact_1",
|
||||||
|
functionName: "documents:move",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
requestId: "req_artifact_1",
|
||||||
|
traceId: "trace_artifact_1",
|
||||||
|
actorId: "user_1",
|
||||||
|
idempotencyKey: "idem_1",
|
||||||
|
payloadJson: "{}",
|
||||||
|
argsJson: {
|
||||||
|
domainEventPlan: {
|
||||||
|
family: "tree",
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: "tree.subtree.moved",
|
||||||
|
streamDeltaHint: {
|
||||||
|
family: "tree",
|
||||||
|
kind: "move_document",
|
||||||
|
args: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
parent_id: "parent_1",
|
||||||
|
sort_order: 2,
|
||||||
|
updated_at: "2026-04-26T10:00:00Z",
|
||||||
|
},
|
||||||
|
now: "2026-04-26T10:00:01Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(artifactPlan?.commandLog).toMatchObject({
|
||||||
|
id: "clog_cmd_artifact_1",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
commandName: "tree.subtree.move",
|
||||||
|
payload: {
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 0,
|
||||||
|
streamDelta: {
|
||||||
|
op: "move_document",
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 2,
|
||||||
|
updatedAt: "2026-04-26T10:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(artifactPlan?.domainEvent).toMatchObject({
|
||||||
|
id: "evt_cmd_artifact_1",
|
||||||
|
eventType: "tree.subtree.moved",
|
||||||
|
payload: {
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
streamDelta: {
|
||||||
|
op: "move_document",
|
||||||
|
documentId: "doc_1",
|
||||||
|
parentId: "parent_1",
|
||||||
|
sortOrder: 2,
|
||||||
|
updatedAt: "2026-04-26T10:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs";
|
|||||||
import { access, readdir, stat } from "node:fs/promises";
|
import { access, readdir, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ConvexHttpClient } from "convex/browser";
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
import type { Id } from "../../../convex/_generated/dataModel";
|
||||||
import { api } from "@/lib/convex/api";
|
import { api } from "@/lib/convex/api";
|
||||||
import {
|
import {
|
||||||
DocumentBridgeError,
|
DocumentBridgeError,
|
||||||
@@ -37,6 +38,10 @@ type RustRuntimeResponse =
|
|||||||
plan: RustBridgeQueryPlan | RustBridgeCommandPlan | RustBridgeToolPlan | RustBridgeBuiltinToolPlan;
|
plan: RustBridgeQueryPlan | RustBridgeCommandPlan | RustBridgeToolPlan | RustBridgeBuiltinToolPlan;
|
||||||
}
|
}
|
||||||
| RustRuntimeExecutedQuery
|
| RustRuntimeExecutedQuery
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
artifacts: RustBridgeCommandArtifactPlan | null;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
ok: false;
|
ok: false;
|
||||||
error: RustRuntimeErrorPayload;
|
error: RustRuntimeErrorPayload;
|
||||||
@@ -68,6 +73,88 @@ export type RustBridgeCommandPlan = {
|
|||||||
argsJson: Record<string, unknown>;
|
argsJson: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RustTreeDomainEventPlan = {
|
||||||
|
family: "tree";
|
||||||
|
schema: "mnote.tree.domain_event";
|
||||||
|
schemaVersion: 1;
|
||||||
|
eventType: string;
|
||||||
|
streamDeltaHint?: Record<string, unknown>;
|
||||||
|
streamDelta?: RustTreeStreamDelta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RustTreeStreamDelta =
|
||||||
|
| {
|
||||||
|
op: "noop";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
op: "upsert_document";
|
||||||
|
document: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
op: "upsert_documents";
|
||||||
|
upsertDocuments: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
op: "remove_document";
|
||||||
|
documentId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
op: "move_document";
|
||||||
|
documentId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
op: "upsert_assets";
|
||||||
|
upsertAssets: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RustBridgeCommandLogArtifactPlan = {
|
||||||
|
workspaceId: string;
|
||||||
|
id: string;
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
commandId: string;
|
||||||
|
commandName: string;
|
||||||
|
actorId: string;
|
||||||
|
actorType: string;
|
||||||
|
sourceChannel: string;
|
||||||
|
sourceClient: string;
|
||||||
|
status: string;
|
||||||
|
targetPageId: string | null;
|
||||||
|
targetBlockId: string | null;
|
||||||
|
payload: unknown;
|
||||||
|
payloadSummary: string;
|
||||||
|
refs: string[];
|
||||||
|
idempotencyKey: string | null;
|
||||||
|
error: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RustBridgeDomainEventArtifactPlan = {
|
||||||
|
workspaceId: string;
|
||||||
|
id: string;
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
commandId: string;
|
||||||
|
commandLogId: string;
|
||||||
|
eventType: string;
|
||||||
|
aggregateType: string;
|
||||||
|
aggregateId: string;
|
||||||
|
eventVersion: number;
|
||||||
|
status: string;
|
||||||
|
actorType: string;
|
||||||
|
payload: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RustBridgeCommandArtifactPlan = {
|
||||||
|
commandLog: RustBridgeCommandLogArtifactPlan;
|
||||||
|
domainEvent: RustBridgeDomainEventArtifactPlan | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type RustBridgeToolPlanStep = {
|
export type RustBridgeToolPlanStep = {
|
||||||
kind: string;
|
kind: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -433,6 +520,371 @@ function readRequiredNumberArg(argsJson: Record<string, unknown>, field: string)
|
|||||||
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
|
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStringArrayArg(argsJson: Record<string, unknown>, field: string): string[] {
|
||||||
|
const value = argsJson[field];
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new DocumentBridgeError(`Rust runtime 缺少 ${field}`, 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecordField(source: unknown, field: string) {
|
||||||
|
if (!isRecord(source)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = source[field];
|
||||||
|
return isRecord(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalRecordArg(argsJson: Record<string, unknown>, field: string) {
|
||||||
|
const value = argsJson[field];
|
||||||
|
return isRecord(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalBooleanField(source: Record<string, unknown>, field: string) {
|
||||||
|
const value = source[field];
|
||||||
|
return typeof value === "boolean" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalNonEmptyStringField(source: Record<string, unknown>, field: string) {
|
||||||
|
const value = source[field];
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNullableStringField(source: Record<string, unknown>, field: string) {
|
||||||
|
const value = source[field];
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTrimmedStringField(source: unknown, field: string) {
|
||||||
|
if (!isRecord(source)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = source[field];
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalParentIdFromResult(result: unknown, fallback: unknown): string | null {
|
||||||
|
if (isRecord(result) && "parent_id" in result) {
|
||||||
|
return readTrimmedStringField(result, "parent_id");
|
||||||
|
}
|
||||||
|
if (typeof fallback === "string") {
|
||||||
|
const trimmed = fallback.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFiniteNumberField(source: unknown, field: string) {
|
||||||
|
if (!isRecord(source)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = source[field];
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTreeDeltaDocument(value: unknown): value is Record<string, unknown> {
|
||||||
|
return (
|
||||||
|
isRecord(value) &&
|
||||||
|
typeof value.id === "string" &&
|
||||||
|
typeof value.workspace_id === "string" &&
|
||||||
|
typeof value.access_scope === "string" &&
|
||||||
|
typeof value.is_template === "boolean" &&
|
||||||
|
typeof value.created_at === "string" &&
|
||||||
|
"title" in value &&
|
||||||
|
"parent_id" in value &&
|
||||||
|
"sort_order" in value &&
|
||||||
|
"is_starred" in value &&
|
||||||
|
"updated_at" in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTreeDeltaAsset(value: unknown): value is Record<string, unknown> {
|
||||||
|
return (
|
||||||
|
isRecord(value) &&
|
||||||
|
typeof value.id === "string" &&
|
||||||
|
typeof value.workspace_id === "string" &&
|
||||||
|
typeof value.document_id === "string" &&
|
||||||
|
typeof value.asset_type === "string" &&
|
||||||
|
typeof value.created_at === "string" &&
|
||||||
|
typeof value.updated_at === "string" &&
|
||||||
|
"file_name" in value &&
|
||||||
|
"file_url" in value &&
|
||||||
|
"thumbnail_url" in value &&
|
||||||
|
"file_size" in value &&
|
||||||
|
"mime_type" in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStreamDeltaHint(plan: RustBridgeCommandPlan) {
|
||||||
|
const hint = plan.argsJson.streamDeltaHint;
|
||||||
|
if (!isRecord(hint) || hint.family !== "tree" || typeof hint.kind !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: hint.kind,
|
||||||
|
args: isRecord(hint.args) ? hint.args : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRustTreeDomainEventType(plan: RustBridgeCommandPlan): string | null {
|
||||||
|
const eventPlan = readRustTreeDomainEventPlan(plan);
|
||||||
|
if (eventPlan) {
|
||||||
|
return eventPlan.eventType;
|
||||||
|
}
|
||||||
|
const hint = plan.argsJson.domainEventHint;
|
||||||
|
if (!isRecord(hint) || hint.family !== "tree") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return readTrimmedStringField(hint, "eventType");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRustTreeDomainEventPlan(plan: RustBridgeCommandPlan): RustTreeDomainEventPlan | null {
|
||||||
|
const eventPlan = plan.argsJson.domainEventPlan;
|
||||||
|
if (!isRecord(eventPlan) || eventPlan.family !== "tree") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (eventPlan.schema !== "mnote.tree.domain_event" || eventPlan.schemaVersion !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const eventType = readTrimmedStringField(eventPlan, "eventType");
|
||||||
|
if (!eventType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const streamDeltaHint = readRecordField(eventPlan, "streamDeltaHint") ?? undefined;
|
||||||
|
const streamDelta = readRecordField(eventPlan, "streamDelta") as RustTreeStreamDelta | null;
|
||||||
|
return {
|
||||||
|
family: "tree",
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType,
|
||||||
|
...(streamDeltaHint ? { streamDeltaHint } : {}),
|
||||||
|
...(streamDelta ? { streamDelta } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeRustTreeDomainEventPlan(input: {
|
||||||
|
plan: RustBridgeCommandPlan;
|
||||||
|
result: unknown;
|
||||||
|
streamDelta?: RustTreeStreamDelta | null;
|
||||||
|
}): RustTreeDomainEventPlan | null {
|
||||||
|
const eventPlan = readRustTreeDomainEventPlan(input.plan);
|
||||||
|
if (!eventPlan) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const streamDelta =
|
||||||
|
input.streamDelta ??
|
||||||
|
materializeRustTreeStreamDelta({
|
||||||
|
plan: input.plan,
|
||||||
|
result: input.result,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...eventPlan,
|
||||||
|
...(streamDelta ? { streamDelta } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeRustTreeStreamDelta(input: {
|
||||||
|
plan: RustBridgeCommandPlan;
|
||||||
|
result: unknown;
|
||||||
|
}): RustTreeStreamDelta | null {
|
||||||
|
const hint = readStreamDeltaHint(input.plan);
|
||||||
|
if (!hint) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hint.kind === "noop") {
|
||||||
|
return { op: "noop" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hint.kind === "remove_document") {
|
||||||
|
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||||
|
return documentId ? { op: "remove_document", documentId } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hint.kind === "upsert_document_patch") {
|
||||||
|
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||||
|
const patch = readRecordField(hint.args, "patch");
|
||||||
|
if (!documentId || !patch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const updatedAt = readTrimmedStringField(input.result, "updated_at");
|
||||||
|
return {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: {
|
||||||
|
id: documentId,
|
||||||
|
...patch,
|
||||||
|
...(updatedAt && !("updated_at" in patch) ? { updated_at: updatedAt } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hint.kind === "document_result") {
|
||||||
|
const documentField = readTrimmedStringField(hint.args, "documentField") ?? "document";
|
||||||
|
const document = readRecordField(input.result, documentField);
|
||||||
|
if (!isTreeDeltaDocument(document)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
op: "upsert_document",
|
||||||
|
document,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hint.kind === "result_document") {
|
||||||
|
if (!isTreeDeltaDocument(input.result)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: input.result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hint.kind === "copy_result") {
|
||||||
|
const itemsField = readTrimmedStringField(hint.args, "itemsField") ?? "items";
|
||||||
|
const documentField = readTrimmedStringField(hint.args, "documentField") ?? "document";
|
||||||
|
const items = isRecord(input.result) && Array.isArray(input.result[itemsField]) ? input.result[itemsField] : [];
|
||||||
|
const upsertDocuments = items
|
||||||
|
.map((item) => readRecordField(item, documentField))
|
||||||
|
.filter(isTreeDeltaDocument);
|
||||||
|
return upsertDocuments.length > 0
|
||||||
|
? {
|
||||||
|
op: "upsert_documents",
|
||||||
|
upsertDocuments,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hint.kind === "asset_result") {
|
||||||
|
const itemsField = readTrimmedStringField(hint.args, "itemsField") ?? "items";
|
||||||
|
const items = isRecord(input.result) && Array.isArray(input.result[itemsField]) ? input.result[itemsField] : [];
|
||||||
|
const upsertAssets = items.filter(isTreeDeltaAsset);
|
||||||
|
return upsertAssets.length > 0
|
||||||
|
? {
|
||||||
|
op: "upsert_assets",
|
||||||
|
upsertAssets,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hint.kind === "move_document") {
|
||||||
|
const documentId = readTrimmedStringField(hint.args, "documentId");
|
||||||
|
const fallbackSortOrder = readFiniteNumberField(hint.args, "sortOrder");
|
||||||
|
const sortOrder = readFiniteNumberField(input.result, "sort_order") ?? fallbackSortOrder;
|
||||||
|
if (!documentId || typeof sortOrder !== "number") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const updatedAt = readTrimmedStringField(input.result, "updated_at");
|
||||||
|
return {
|
||||||
|
op: "move_document",
|
||||||
|
documentId,
|
||||||
|
parentId: readOptionalParentIdFromResult(input.result, hint.args.parentId),
|
||||||
|
sortOrder,
|
||||||
|
...(updatedAt ? { updatedAt } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowIsoForRustArtifact() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildRustBridgeCommandArtifactPlan(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
envelope: CommandEnvelope<unknown>;
|
||||||
|
plan: RustBridgeCommandPlan;
|
||||||
|
result: unknown;
|
||||||
|
now?: string;
|
||||||
|
}): Promise<RustBridgeCommandArtifactPlan | null> {
|
||||||
|
const response = await runRustRuntime({
|
||||||
|
kind: "commandArtifact",
|
||||||
|
context: input.context,
|
||||||
|
command: {
|
||||||
|
...input.envelope,
|
||||||
|
preflightData: input.envelope.preflightData ?? null,
|
||||||
|
},
|
||||||
|
plan: input.plan,
|
||||||
|
result: input.result,
|
||||||
|
now: input.now ?? nowIsoForRustArtifact(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!("artifacts" in response)) {
|
||||||
|
throw new DocumentBridgeError("Rust runtime 未返回 command artifact plan", 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
return response.artifacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistRustBridgeCommandArtifacts(input: {
|
||||||
|
client: ConvexHttpClient;
|
||||||
|
artifacts: RustBridgeCommandArtifactPlan | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const artifacts = input.artifacts;
|
||||||
|
if (!artifacts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bridgeLogsApi = api as typeof api & {
|
||||||
|
bridgeLogs: {
|
||||||
|
recordCommandLog: unknown;
|
||||||
|
recordDomainEvent: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const mutation = input.client.mutation.bind(input.client) as (
|
||||||
|
mutationReference: unknown,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
await mutation(bridgeLogsApi.bridgeLogs.recordCommandLog, artifacts.commandLog as unknown as Record<string, unknown>);
|
||||||
|
if (artifacts.domainEvent) {
|
||||||
|
await mutation(bridgeLogsApi.bridgeLogs.recordDomainEvent, artifacts.domainEvent as unknown as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordRustBridgeCommandArtifacts(input: {
|
||||||
|
context: BridgeContext;
|
||||||
|
envelope: CommandEnvelope<unknown>;
|
||||||
|
client: ConvexHttpClient;
|
||||||
|
plan: RustBridgeCommandPlan;
|
||||||
|
result: unknown;
|
||||||
|
now?: string;
|
||||||
|
}): Promise<RustBridgeCommandArtifactPlan | null> {
|
||||||
|
const artifacts = await buildRustBridgeCommandArtifactPlan({
|
||||||
|
context: input.context,
|
||||||
|
envelope: input.envelope,
|
||||||
|
plan: input.plan,
|
||||||
|
result: input.result,
|
||||||
|
now: input.now,
|
||||||
|
});
|
||||||
|
await persistRustBridgeCommandArtifacts({
|
||||||
|
client: input.client,
|
||||||
|
artifacts,
|
||||||
|
});
|
||||||
|
return artifacts;
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveRustBridgeQueryPlan<TPayload>(input: {
|
export async function resolveRustBridgeQueryPlan<TPayload>(input: {
|
||||||
context: BridgeContext;
|
context: BridgeContext;
|
||||||
envelope: QueryEnvelope<TPayload>;
|
envelope: QueryEnvelope<TPayload>;
|
||||||
@@ -653,6 +1105,12 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
|||||||
mutationReference: unknown,
|
mutationReference: unknown,
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
) => Promise<TResult>;
|
) => Promise<TResult>;
|
||||||
|
const runtimeApi = api as typeof api & {
|
||||||
|
mediaAssets: {
|
||||||
|
batchCopy: unknown;
|
||||||
|
batchMove: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
switch (input.plan.functionName) {
|
switch (input.plan.functionName) {
|
||||||
case "documents:createWithParentReference":
|
case "documents:createWithParentReference":
|
||||||
@@ -669,6 +1127,9 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
|||||||
id: assertStringArg(input.plan.argsJson, "id"),
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
|
parentId: readOptionalStringArg(input.plan.argsJson, "parentId"),
|
||||||
sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"),
|
sortOrder: readRequiredNumberArg(input.plan.argsJson, "sortOrder"),
|
||||||
|
...("normalizedMove" in input.plan.argsJson
|
||||||
|
? { normalizedMove: input.plan.argsJson.normalizedMove }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
case "documents:softDelete":
|
case "documents:softDelete":
|
||||||
return mutation(api.documents.softDelete, {
|
return mutation(api.documents.softDelete, {
|
||||||
@@ -684,6 +1145,77 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
|
|||||||
newId: assertStringArg(input.plan.argsJson, "newId"),
|
newId: assertStringArg(input.plan.argsJson, "newId"),
|
||||||
title: readOptionalStringArg(input.plan.argsJson, "title"),
|
title: readOptionalStringArg(input.plan.argsJson, "title"),
|
||||||
});
|
});
|
||||||
|
case "documents:updateStats":
|
||||||
|
return mutation(api.documents.updateStats, {
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
wordCount: readRequiredNumberArg(input.plan.argsJson, "wordCount"),
|
||||||
|
characterCount: readRequiredNumberArg(input.plan.argsJson, "characterCount"),
|
||||||
|
blockCount: readRequiredNumberArg(input.plan.argsJson, "blockCount"),
|
||||||
|
todoTotal: readRequiredNumberArg(input.plan.argsJson, "todoTotal"),
|
||||||
|
todoDone: readRequiredNumberArg(input.plan.argsJson, "todoDone"),
|
||||||
|
});
|
||||||
|
case "documents:updateOptions": {
|
||||||
|
const options = readOptionalRecordArg(input.plan.argsJson, "options");
|
||||||
|
if (!options) {
|
||||||
|
throw new DocumentBridgeError("Rust runtime 缺少 options", 500, "TRANSPORT_ERROR");
|
||||||
|
}
|
||||||
|
return mutation(api.documents.updateOptions, {
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
options: {
|
||||||
|
wideLayout: readOptionalBooleanField(options, "wideLayout"),
|
||||||
|
smallText: readOptionalBooleanField(options, "smallText"),
|
||||||
|
showHeadingNumbers: readOptionalBooleanField(options, "showHeadingNumbers"),
|
||||||
|
showToc: readOptionalBooleanField(options, "showToc"),
|
||||||
|
showStructure: readOptionalBooleanField(options, "showStructure"),
|
||||||
|
protectEditing: readOptionalBooleanField(options, "protectEditing"),
|
||||||
|
showWordCount: readOptionalBooleanField(options, "showWordCount"),
|
||||||
|
collapseBacklinks: readOptionalBooleanField(options, "collapseBacklinks"),
|
||||||
|
pageFont: readOptionalNonEmptyStringField(options, "pageFont"),
|
||||||
|
layoutDensity: readOptionalNonEmptyStringField(options, "layoutDensity"),
|
||||||
|
hideChildPages: readOptionalBooleanField(options, "hideChildPages"),
|
||||||
|
showBlockRefCount: readOptionalBooleanField(options, "showBlockRefCount"),
|
||||||
|
embedDefaultBlockId: readNullableStringField(options, "embedDefaultBlockId"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "documents:copyTree": {
|
||||||
|
const rawItems = input.plan.argsJson.items;
|
||||||
|
const items = Array.isArray(rawItems)
|
||||||
|
? rawItems
|
||||||
|
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
||||||
|
.map((item) => ({
|
||||||
|
documentId: assertStringArg(item, "documentId"),
|
||||||
|
recursive: Boolean(item.recursive),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
return mutation(api.documents.copyTree, {
|
||||||
|
items,
|
||||||
|
targetParentId: readOptionalStringArg(input.plan.argsJson, "targetParentId"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "mediaAssets:batchCopy":
|
||||||
|
case "mediaAssets:batchMove":
|
||||||
|
return mutation(
|
||||||
|
input.plan.functionName === "mediaAssets:batchCopy"
|
||||||
|
? runtimeApi.mediaAssets.batchCopy
|
||||||
|
: runtimeApi.mediaAssets.batchMove,
|
||||||
|
{
|
||||||
|
userId: input.plan.actorId,
|
||||||
|
assetIds: readStringArrayArg(input.plan.argsJson, "assetIds"),
|
||||||
|
targetDocumentId: assertStringArg(input.plan.argsJson, "targetDocumentId"),
|
||||||
|
targetSubPath: readOptionalStringArg(input.plan.argsJson, "targetSubPath"),
|
||||||
|
resourceTransferPlan: readOptionalRecordArg(
|
||||||
|
input.plan.argsJson,
|
||||||
|
"resourceTransferPlan",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
case "mediaAssets:replaceStorageFromUpload":
|
||||||
|
return mutation(api.mediaAssets.replaceStorageFromUpload, {
|
||||||
|
userId: assertStringArg(input.plan.argsJson, "userId"),
|
||||||
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
storageId: assertStringArg(input.plan.argsJson, "storageId") as Id<"_storage">,
|
||||||
|
});
|
||||||
case "documents:setTemplate":
|
case "documents:setTemplate":
|
||||||
return mutation(api.documents.setTemplate, {
|
return mutation(api.documents.setTemplate, {
|
||||||
id: assertStringArg(input.plan.argsJson, "id"),
|
id: assertStringArg(input.plan.argsJson, "id"),
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
type BridgeContext,
|
type BridgeContext,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
import {
|
import {
|
||||||
recordBridgeCommandArtifacts,
|
|
||||||
recordBridgeCommandFailureArtifacts,
|
recordBridgeCommandFailureArtifacts,
|
||||||
|
recordRustBridgeCommandArtifacts,
|
||||||
} from "@/lib/documents/bridge-log";
|
} from "@/lib/documents/bridge-log";
|
||||||
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
|
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
|
||||||
import { DocumentBridgeError } from "@/lib/documents/bridge";
|
import { DocumentBridgeError } from "@/lib/documents/bridge";
|
||||||
@@ -58,9 +58,12 @@ export async function executeSaveBridgeCommand(input: {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await recordBridgeCommandArtifacts({
|
await recordRustBridgeCommandArtifacts({
|
||||||
|
client,
|
||||||
context: input.context,
|
context: input.context,
|
||||||
envelope: input.envelope,
|
envelope: input.envelope,
|
||||||
|
plan,
|
||||||
|
result: mutationResult,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fetchKernelFileTreeProjection } from "./projection-client";
|
||||||
|
|
||||||
|
describe("fetchKernelFileTreeProjection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("通过 3000 同源 file_tree projection endpoint 请求 Rust 搜索 projection", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
projectionId: "kernel_projection:file_tree:page_root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: "page_root",
|
||||||
|
items: [{ rowId: "asset:table_1" }],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await fetchKernelFileTreeProjection({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rootNodeId: "page_root",
|
||||||
|
depth: 3,
|
||||||
|
query: " 预算 ",
|
||||||
|
maxResults: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/tree/projections/file?workspaceId=ws_1&rootNodeId=page_root&depth=3&query=%E9%A2%84%E7%AE%97&maxResults=12",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("失败时透出服务端错误消息", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ error: "获取 projection 失败" }), { status: 502 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fetchKernelFileTreeProjection({ workspaceId: "ws_1" })).rejects.toThrow(
|
||||||
|
"获取 projection 失败",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||||
|
|
||||||
|
export type FetchKernelFileTreeProjectionInput = {
|
||||||
|
workspaceId: string;
|
||||||
|
rootNodeId?: string | null;
|
||||||
|
depth?: number | null;
|
||||||
|
query?: string | null;
|
||||||
|
maxResults?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchKernelFileTreeProjection(
|
||||||
|
input: FetchKernelFileTreeProjectionInput,
|
||||||
|
): Promise<KernelFileTreeProjection> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("workspaceId", input.workspaceId);
|
||||||
|
const rootNodeId = input.rootNodeId?.trim();
|
||||||
|
if (rootNodeId) {
|
||||||
|
params.set("rootNodeId", rootNodeId);
|
||||||
|
}
|
||||||
|
if (typeof input.depth === "number" && Number.isFinite(input.depth)) {
|
||||||
|
params.set("depth", String(input.depth));
|
||||||
|
}
|
||||||
|
const query = input.query?.trim();
|
||||||
|
if (query) {
|
||||||
|
params.set("query", query);
|
||||||
|
}
|
||||||
|
if (typeof input.maxResults === "number" && Number.isFinite(input.maxResults)) {
|
||||||
|
params.set("maxResults", String(Math.max(1, Math.floor(input.maxResults))));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/tree/projections/file?${params.toString()}`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
const message =
|
||||||
|
typeof payload?.error === "string" ? payload.error : "获取 file_tree projection 失败";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { result?: KernelFileTreeProjection };
|
||||||
|
if (!payload.result) {
|
||||||
|
throw new Error("file_tree projection 响应缺少 result");
|
||||||
|
}
|
||||||
|
return payload.result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
copyFileTreeResourceAssets,
|
||||||
|
deleteFileTreeResourceAssets,
|
||||||
|
preflightFileTreeDelete,
|
||||||
|
preflightFileTreeInternalDrop,
|
||||||
|
preflightFileTreePaste,
|
||||||
|
preflightFileTreeUploadTarget,
|
||||||
|
moveFileTreeResourceAssets,
|
||||||
|
renameFileTreeResourceAsset,
|
||||||
|
restoreFileTreeResourceAssets,
|
||||||
|
uploadFileTreeResourceAsset,
|
||||||
|
} from "./resource-command-client";
|
||||||
|
|
||||||
|
describe("file-tree resource command client", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copy/move 应通过统一资源 command client 发送到 media batch route", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ items: [{ id: "asset_1" }] }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await copyFileTreeResourceAssets({
|
||||||
|
assetIds: ["asset_1", "asset_1", " asset_2 "],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
});
|
||||||
|
await moveFileTreeResourceAssets({
|
||||||
|
assetIds: ["asset_3"],
|
||||||
|
targetDocumentId: "doc_target_2",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"/api/media/batch",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "copy",
|
||||||
|
assetIds: ["asset_1", "asset_2"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"/api/media/batch",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "move",
|
||||||
|
assetIds: ["asset_3"],
|
||||||
|
targetDocumentId: "doc_target_2",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rename/delete/restore 也应复用同一 batch transport 边界", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ ok: true }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await renameFileTreeResourceAsset({ assetId: "asset_1", newName: "新文件.pdf" });
|
||||||
|
await deleteFileTreeResourceAssets(["asset_1", "asset_2"]);
|
||||||
|
await restoreFileTreeResourceAssets(["asset_3"]);
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body)))).toEqual([
|
||||||
|
{ action: "rename", assetIds: ["asset_1"], newName: "新文件.pdf" },
|
||||||
|
{ action: "delete", assetIds: ["asset_1", "asset_2"] },
|
||||||
|
{ action: "restore", assetIds: ["asset_3"] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("后端返回错误时应抛出稳定 fallback 或服务端消息", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ error: "Rust resource preflight rejected" }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
moveFileTreeResourceAssets({
|
||||||
|
assetIds: ["asset_1"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Rust resource preflight rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upload 应通过统一资源 command client 构造 FormData transport", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ asset: { id: "asset_upload_1" } }),
|
||||||
|
} as Response);
|
||||||
|
const file = new File(["content"], "demo.pdf", { type: "application/pdf" });
|
||||||
|
|
||||||
|
await uploadFileTreeResourceAsset({
|
||||||
|
file,
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
documentId: "doc_1",
|
||||||
|
mindmapId: "mind_1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/media/upload",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
body: expect.any(FormData),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const body = fetchMock.mock.calls[0]?.[1]?.body as FormData;
|
||||||
|
expect(body.get("file")).toBe(file);
|
||||||
|
expect(body.get("workspaceId")).toBe("ws_1");
|
||||||
|
expect(body.get("documentId")).toBe("doc_1");
|
||||||
|
expect(body.get("mindmapId")).toBe("mind_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("internal drop preflight 应发送到 tree filetree drop route 并返回 Rust plan", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
plan: {
|
||||||
|
copy: false,
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetMindmapId: null,
|
||||||
|
targetSubPath: null,
|
||||||
|
rowIds: ["doc:doc_1"],
|
||||||
|
docIds: ["doc_1"],
|
||||||
|
topLevelDocIds: ["doc_1"],
|
||||||
|
copyableAssetIds: [],
|
||||||
|
sourceAssetDocumentIds: [],
|
||||||
|
documentTransferPlan: {
|
||||||
|
action: "move",
|
||||||
|
targetParentId: "doc_target",
|
||||||
|
documentIds: ["doc_1"],
|
||||||
|
topLevelDocumentIds: ["doc_1"],
|
||||||
|
copyItems: [{ documentId: "doc_1", recursive: true }],
|
||||||
|
},
|
||||||
|
resourceTransferPlan: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const plan = await preflightFileTreeInternalDrop({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
copy: false,
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetRowId: null,
|
||||||
|
focusedRowId: null,
|
||||||
|
activeDocumentId: null,
|
||||||
|
rowIds: ["doc:doc_1"],
|
||||||
|
rows: [],
|
||||||
|
documentParents: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan.topLevelDocIds).toEqual(["doc_1"]);
|
||||||
|
expect(plan.documentTransferPlan?.topLevelDocumentIds).toEqual(["doc_1"]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/tree/filetree/drop-preflight",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
copy: false,
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetRowId: null,
|
||||||
|
focusedRowId: null,
|
||||||
|
activeDocumentId: null,
|
||||||
|
rowIds: ["doc:doc_1"],
|
||||||
|
rows: [],
|
||||||
|
documentParents: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delete preflight 应发送到 tree filetree delete route 并返回 Rust plan", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
plan: {
|
||||||
|
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||||
|
docIds: ["doc_1"],
|
||||||
|
assetIds: ["asset_1"],
|
||||||
|
assetDocumentIds: ["doc_other"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const plan = await preflightFileTreeDelete({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||||
|
rows: [],
|
||||||
|
documentParents: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan.docIds).toEqual(["doc_1"]);
|
||||||
|
expect(plan.assetIds).toEqual(["asset_1"]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/tree/filetree/delete-preflight",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rowIds: ["doc:doc_1", "asset:asset_1"],
|
||||||
|
rows: [],
|
||||||
|
documentParents: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paste preflight 应发送到 tree filetree paste route 并返回 Rust plan", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
plan: {
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetMindmapId: "mind_1",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||||
|
docItems: [{ documentId: "doc_1", recursive: false }],
|
||||||
|
copyableAssetIds: ["asset_1"],
|
||||||
|
resourceTransferPlan: {
|
||||||
|
action: "copy",
|
||||||
|
assetIds: ["asset_1"],
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const plan = await preflightFileTreePaste({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: null,
|
||||||
|
focusedRowId: "asset-folder:mind_1",
|
||||||
|
activeDocumentId: "doc_active",
|
||||||
|
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||||
|
rows: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan.docItems).toEqual([{ documentId: "doc_1", recursive: false }]);
|
||||||
|
expect(plan.resourceTransferPlan?.targetSubPath).toBe("mindmaps/mind_1");
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/tree/filetree/paste-preflight",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: null,
|
||||||
|
focusedRowId: "asset-folder:mind_1",
|
||||||
|
activeDocumentId: "doc_active",
|
||||||
|
rowIds: ["index:doc_1", "asset:asset_1"],
|
||||||
|
rows: [],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upload target preflight 应发送到 tree filetree upload-target route 并返回 Rust plan", async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
plan: {
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetMindmapId: "mind_1",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const plan = await preflightFileTreeUploadTarget({
|
||||||
|
workspaceId: "ws_fallback",
|
||||||
|
targetDocumentId: null,
|
||||||
|
targetRowId: "asset:asset_child_1",
|
||||||
|
focusedRowId: null,
|
||||||
|
activeDocumentId: "doc_active",
|
||||||
|
rows: [],
|
||||||
|
documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(plan).toEqual({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: "doc_target",
|
||||||
|
targetMindmapId: "mind_1",
|
||||||
|
targetSubPath: "mindmaps/mind_1",
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/tree/filetree/upload-target-preflight",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId: "ws_fallback",
|
||||||
|
targetDocumentId: null,
|
||||||
|
targetRowId: "asset:asset_child_1",
|
||||||
|
focusedRowId: null,
|
||||||
|
activeDocumentId: "doc_active",
|
||||||
|
rows: [],
|
||||||
|
documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { MediaAsset } from "@/types/media";
|
||||||
|
import type {
|
||||||
|
FileTreeShellDeletePreflightPayload,
|
||||||
|
FileTreeShellInternalDropPreflightPayload,
|
||||||
|
FileTreeShellPastePreflightPayload,
|
||||||
|
FileTreeShellUploadTargetPreflightPayload,
|
||||||
|
} from "@/lib/file-tree/shell";
|
||||||
|
|
||||||
|
type ResourceCommandAction = "copy" | "move" | "rename" | "delete" | "restore";
|
||||||
|
|
||||||
|
type ResourceCommandErrorPayload = {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResourceCommandResponse = {
|
||||||
|
ok?: boolean;
|
||||||
|
items?: MediaAsset[];
|
||||||
|
asset?: MediaAsset;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TransferResourceAssetsInput = {
|
||||||
|
assetIds: readonly string[];
|
||||||
|
targetDocumentId: string;
|
||||||
|
targetSubPath?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RenameResourceAssetInput = {
|
||||||
|
assetId: string;
|
||||||
|
newName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadResourceAssetInput = {
|
||||||
|
file: File;
|
||||||
|
workspaceId: string;
|
||||||
|
documentId: string;
|
||||||
|
mindmapId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeInternalDropPreflightPlan = {
|
||||||
|
copy: boolean;
|
||||||
|
targetDocumentId: string;
|
||||||
|
targetMindmapId: string | null;
|
||||||
|
targetSubPath?: string | null;
|
||||||
|
rowIds: string[];
|
||||||
|
docIds: string[];
|
||||||
|
topLevelDocIds: string[];
|
||||||
|
copyableAssetIds: string[];
|
||||||
|
sourceAssetDocumentIds: string[];
|
||||||
|
documentTransferPlan?: {
|
||||||
|
action: "copy" | "move";
|
||||||
|
targetParentId: string;
|
||||||
|
documentIds: string[];
|
||||||
|
topLevelDocumentIds: string[];
|
||||||
|
copyItems: Array<{ documentId: string; recursive: boolean }>;
|
||||||
|
} | null;
|
||||||
|
resourceTransferPlan?: {
|
||||||
|
action: "copy" | "move";
|
||||||
|
assetIds: string[];
|
||||||
|
targetDocumentId: string;
|
||||||
|
targetSubPath?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileTreeInternalDropPreflightResponse = {
|
||||||
|
plan?: FileTreeInternalDropPreflightPlan;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeDeletePreflightPlan = {
|
||||||
|
rowIds: string[];
|
||||||
|
docIds: string[];
|
||||||
|
assetIds: string[];
|
||||||
|
assetDocumentIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileTreeDeletePreflightResponse = {
|
||||||
|
plan?: FileTreeDeletePreflightPlan;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreePastePreflightPlan = {
|
||||||
|
targetDocumentId: string;
|
||||||
|
targetMindmapId: string | null;
|
||||||
|
targetSubPath?: string | null;
|
||||||
|
rowIds: string[];
|
||||||
|
docItems: Array<{ documentId: string; recursive: boolean }>;
|
||||||
|
copyableAssetIds: string[];
|
||||||
|
resourceTransferPlan?: {
|
||||||
|
action: "copy";
|
||||||
|
assetIds: string[];
|
||||||
|
targetDocumentId: string;
|
||||||
|
targetSubPath?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileTreePastePreflightResponse = {
|
||||||
|
plan?: FileTreePastePreflightPlan;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeUploadTargetPreflightPlan = {
|
||||||
|
workspaceId: string;
|
||||||
|
targetDocumentId: string;
|
||||||
|
targetMindmapId: string | null;
|
||||||
|
targetSubPath?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileTreeUploadTargetPreflightResponse = {
|
||||||
|
plan?: FileTreeUploadTargetPreflightPlan;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeAssetIds(assetIds: readonly string[]): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
assetIds
|
||||||
|
.map((assetId) => (typeof assetId === "string" ? assetId.trim() : ""))
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postResourceCommand<TResult>(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
fallbackMessage: string,
|
||||||
|
path = "/api/media/batch",
|
||||||
|
): Promise<TResult> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| TResult
|
||||||
|
| ResourceCommandErrorPayload
|
||||||
|
| null;
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||||
|
? body.error
|
||||||
|
: fallbackMessage;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body as TResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postResourceForm<TResult>(
|
||||||
|
path: string,
|
||||||
|
formData: FormData,
|
||||||
|
fallbackMessage: string,
|
||||||
|
): Promise<TResult> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| TResult
|
||||||
|
| ResourceCommandErrorPayload
|
||||||
|
| null;
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||||
|
? body.error
|
||||||
|
: fallbackMessage;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body as TResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTransferPayload(
|
||||||
|
action: Extract<ResourceCommandAction, "copy" | "move">,
|
||||||
|
input: TransferResourceAssetsInput,
|
||||||
|
) {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
action,
|
||||||
|
assetIds: normalizeAssetIds(input.assetIds),
|
||||||
|
targetDocumentId: input.targetDocumentId,
|
||||||
|
};
|
||||||
|
const targetSubPath = input.targetSubPath?.trim();
|
||||||
|
if (targetSubPath) {
|
||||||
|
payload.targetSubPath = targetSubPath;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyFileTreeResourceAssets(
|
||||||
|
input: TransferResourceAssetsInput,
|
||||||
|
): Promise<ResourceCommandResponse> {
|
||||||
|
return postResourceCommand<ResourceCommandResponse>(
|
||||||
|
buildTransferPayload("copy", input),
|
||||||
|
"复制附件失败",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveFileTreeResourceAssets(
|
||||||
|
input: TransferResourceAssetsInput,
|
||||||
|
): Promise<ResourceCommandResponse> {
|
||||||
|
return postResourceCommand<ResourceCommandResponse>(
|
||||||
|
buildTransferPayload("move", input),
|
||||||
|
"移动附件失败",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameFileTreeResourceAsset(
|
||||||
|
input: RenameResourceAssetInput,
|
||||||
|
): Promise<ResourceCommandResponse> {
|
||||||
|
return postResourceCommand<ResourceCommandResponse>(
|
||||||
|
{
|
||||||
|
action: "rename",
|
||||||
|
assetIds: normalizeAssetIds([input.assetId]),
|
||||||
|
newName: input.newName,
|
||||||
|
},
|
||||||
|
"重命名失败",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFileTreeResourceAssets(
|
||||||
|
assetIds: readonly string[],
|
||||||
|
): Promise<ResourceCommandResponse> {
|
||||||
|
return postResourceCommand<ResourceCommandResponse>(
|
||||||
|
{
|
||||||
|
action: "delete",
|
||||||
|
assetIds: normalizeAssetIds(assetIds),
|
||||||
|
},
|
||||||
|
"删除失败",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreFileTreeResourceAssets(
|
||||||
|
assetIds: readonly string[],
|
||||||
|
): Promise<ResourceCommandResponse> {
|
||||||
|
return postResourceCommand<ResourceCommandResponse>(
|
||||||
|
{
|
||||||
|
action: "restore",
|
||||||
|
assetIds: normalizeAssetIds(assetIds),
|
||||||
|
},
|
||||||
|
"恢复附件失败,请稍后再试",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFileTreeResourceAsset(
|
||||||
|
input: UploadResourceAssetInput,
|
||||||
|
): Promise<ResourceCommandResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", input.file);
|
||||||
|
formData.append("workspaceId", input.workspaceId);
|
||||||
|
formData.append("documentId", input.documentId);
|
||||||
|
const mindmapId = input.mindmapId?.trim();
|
||||||
|
if (mindmapId) {
|
||||||
|
formData.append("mindmapId", mindmapId);
|
||||||
|
}
|
||||||
|
return postResourceForm<ResourceCommandResponse>(
|
||||||
|
"/api/media/upload",
|
||||||
|
formData,
|
||||||
|
"上传失败",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preflightFileTreeInternalDrop(
|
||||||
|
input: FileTreeShellInternalDropPreflightPayload,
|
||||||
|
): Promise<FileTreeInternalDropPreflightPlan> {
|
||||||
|
const response = await postResourceCommand<FileTreeInternalDropPreflightResponse>(
|
||||||
|
input,
|
||||||
|
"文件树拖放预检失败",
|
||||||
|
"/api/tree/filetree/drop-preflight",
|
||||||
|
);
|
||||||
|
if (!response.plan) {
|
||||||
|
throw new Error("文件树拖放预检失败");
|
||||||
|
}
|
||||||
|
return response.plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preflightFileTreeDelete(
|
||||||
|
input: FileTreeShellDeletePreflightPayload,
|
||||||
|
): Promise<FileTreeDeletePreflightPlan> {
|
||||||
|
const response = await postResourceCommand<FileTreeDeletePreflightResponse>(
|
||||||
|
input,
|
||||||
|
"文件树删除预检失败",
|
||||||
|
"/api/tree/filetree/delete-preflight",
|
||||||
|
);
|
||||||
|
if (!response.plan) {
|
||||||
|
throw new Error("文件树删除预检失败");
|
||||||
|
}
|
||||||
|
return response.plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preflightFileTreePaste(
|
||||||
|
input: FileTreeShellPastePreflightPayload,
|
||||||
|
): Promise<FileTreePastePreflightPlan> {
|
||||||
|
const response = await postResourceCommand<FileTreePastePreflightResponse>(
|
||||||
|
input,
|
||||||
|
"文件树粘贴预检失败",
|
||||||
|
"/api/tree/filetree/paste-preflight",
|
||||||
|
);
|
||||||
|
if (!response.plan) {
|
||||||
|
throw new Error("文件树粘贴预检失败");
|
||||||
|
}
|
||||||
|
return response.plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preflightFileTreeUploadTarget(
|
||||||
|
input: FileTreeShellUploadTargetPreflightPayload,
|
||||||
|
): Promise<FileTreeUploadTargetPreflightPlan> {
|
||||||
|
const response = await postResourceCommand<FileTreeUploadTargetPreflightResponse>(
|
||||||
|
input,
|
||||||
|
"文件树上传目标预检失败",
|
||||||
|
"/api/tree/filetree/upload-target-preflight",
|
||||||
|
);
|
||||||
|
if (!response.plan) {
|
||||||
|
throw new Error("文件树上传目标预检失败");
|
||||||
|
}
|
||||||
|
return response.plan;
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
|
import { buildVisibleRows } from "./rows";
|
||||||
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "./rows";
|
|
||||||
import { parseFileTreeRowId } from "./types";
|
import { parseFileTreeRowId } from "./types";
|
||||||
|
|
||||||
describe("buildVisibleRows", () => {
|
describe("buildVisibleRows", () => {
|
||||||
it("按展开状态稳定生成可见行", () => {
|
it("缺少 kernel file_tree items 时不再回退 pageRows + assets 重建对象语义", () => {
|
||||||
const a = {
|
const a = {
|
||||||
access_scope: "private" as const,
|
access_scope: "private" as const,
|
||||||
id: "a",
|
id: "a",
|
||||||
@@ -34,7 +33,17 @@ describe("buildVisibleRows", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rows = buildVisibleRows({
|
const rows = buildVisibleRows({
|
||||||
pageRows: buildPageTreeProjectionItems([a]),
|
pageRows: [
|
||||||
|
{
|
||||||
|
nodeId: a.id,
|
||||||
|
parentNodeId: null,
|
||||||
|
depth: 0,
|
||||||
|
childCount: 1,
|
||||||
|
position: 0,
|
||||||
|
title: a.title,
|
||||||
|
node: a,
|
||||||
|
},
|
||||||
|
],
|
||||||
expanded: new Set(["a"]),
|
expanded: new Set(["a"]),
|
||||||
assetsByDoc: {
|
assetsByDoc: {
|
||||||
a: [
|
a: [
|
||||||
@@ -82,40 +91,7 @@ describe("buildVisibleRows", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(rows.map((r) => `${r.kind}:${r.depth}:${r.rowId}`)).toEqual([
|
expect(rows).toEqual([]);
|
||||||
"doc:0:doc:a",
|
|
||||||
"index:1:index:a",
|
|
||||||
"asset:1:asset:x",
|
|
||||||
"asset:1:asset:y",
|
|
||||||
"doc:1:doc:b",
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("输入树包含重复 docId 时应自动去重", () => {
|
|
||||||
const a = {
|
|
||||||
access_scope: "private" as const,
|
|
||||||
id: "a",
|
|
||||||
workspace_id: "w",
|
|
||||||
title: "A",
|
|
||||||
parent_id: null,
|
|
||||||
sort_order: 0,
|
|
||||||
is_starred: null,
|
|
||||||
is_template: false,
|
|
||||||
created_at: "",
|
|
||||||
updated_at: null,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows = buildVisibleRows({
|
|
||||||
pageRows: buildPageTreeProjectionItems([a, a]),
|
|
||||||
expanded: new Set(["a"]),
|
|
||||||
assetsByDoc: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(rows.filter((r) => r.rowId === "doc:a").length).toBe(1);
|
|
||||||
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("优先消费 Rust file_tree projection 并保留 index 与资源文件夹语义", () => {
|
it("优先消费 Rust file_tree projection 并保留 index 与资源文件夹语义", () => {
|
||||||
@@ -284,175 +260,6 @@ describe("buildVisibleRows", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("过滤态应优先从 kernel file_tree items 收敛可见行,而不是回退 pageRows + assets 二次重建", () => {
|
|
||||||
const fileTreeItems = [
|
|
||||||
{
|
|
||||||
rowId: "doc:page_root",
|
|
||||||
rowKind: "document",
|
|
||||||
nodeId: "page_root",
|
|
||||||
parentNodeId: null,
|
|
||||||
nodeType: "page",
|
|
||||||
projectionKind: "file_tree",
|
|
||||||
title: "根页面",
|
|
||||||
depth: 0,
|
|
||||||
position: 0,
|
|
||||||
childCount: 3,
|
|
||||||
expandable: true,
|
|
||||||
expandedByDefault: true,
|
|
||||||
capabilities: ["expand", "open", "select"],
|
|
||||||
resourceMeta: {
|
|
||||||
resourceKind: "document",
|
|
||||||
documentId: "page_root",
|
|
||||||
workspaceId: "ws_1",
|
|
||||||
iconHint: "page",
|
|
||||||
},
|
|
||||||
iconHint: "page",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rowId: "index:page_root",
|
|
||||||
rowKind: "index",
|
|
||||||
nodeId: "index:page_root",
|
|
||||||
parentNodeId: "page_root",
|
|
||||||
nodeType: "index",
|
|
||||||
projectionKind: "file_tree",
|
|
||||||
title: "index.md",
|
|
||||||
depth: 1,
|
|
||||||
position: 0,
|
|
||||||
childCount: 0,
|
|
||||||
expandable: false,
|
|
||||||
expandedByDefault: false,
|
|
||||||
capabilities: ["open", "select"],
|
|
||||||
resourceMeta: {
|
|
||||||
resourceKind: "index",
|
|
||||||
documentId: "page_root",
|
|
||||||
workspaceId: "ws_1",
|
|
||||||
iconHint: "index",
|
|
||||||
},
|
|
||||||
iconHint: "index",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rowId: "asset-folder:mind_1",
|
|
||||||
rowKind: "asset_folder",
|
|
||||||
nodeId: "asset-folder:mind_1",
|
|
||||||
parentNodeId: "page_root",
|
|
||||||
nodeType: "mindmap",
|
|
||||||
projectionKind: "file_tree",
|
|
||||||
title: "头脑风暴",
|
|
||||||
depth: 1,
|
|
||||||
position: 1,
|
|
||||||
childCount: 1,
|
|
||||||
expandable: true,
|
|
||||||
expandedByDefault: false,
|
|
||||||
capabilities: ["expand", "open-asset", "select"],
|
|
||||||
resourceMeta: {
|
|
||||||
resourceKind: "mindmap",
|
|
||||||
documentId: "page_root",
|
|
||||||
assetId: "mind_1",
|
|
||||||
workspaceId: "ws_1",
|
|
||||||
assetKind: "mindmap",
|
|
||||||
iconHint: "mindmap",
|
|
||||||
},
|
|
||||||
iconHint: "mindmap",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rowId: "asset:asset_child_1",
|
|
||||||
rowKind: "asset",
|
|
||||||
nodeId: "asset:asset_child_1",
|
|
||||||
parentNodeId: "asset-folder:mind_1",
|
|
||||||
nodeType: "asset",
|
|
||||||
projectionKind: "file_tree",
|
|
||||||
title: "节点图片.png",
|
|
||||||
depth: 2,
|
|
||||||
position: 0,
|
|
||||||
childCount: 0,
|
|
||||||
expandable: false,
|
|
||||||
expandedByDefault: false,
|
|
||||||
capabilities: ["open-asset", "select"],
|
|
||||||
resourceMeta: {
|
|
||||||
resourceKind: "asset",
|
|
||||||
documentId: "page_root",
|
|
||||||
assetId: "asset_child_1",
|
|
||||||
workspaceId: "ws_1",
|
|
||||||
assetKind: "image",
|
|
||||||
iconHint: "image",
|
|
||||||
},
|
|
||||||
iconHint: "image",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rowId: "doc:page_child",
|
|
||||||
rowKind: "document",
|
|
||||||
nodeId: "page_child",
|
|
||||||
parentNodeId: "page_root",
|
|
||||||
nodeType: "page",
|
|
||||||
projectionKind: "file_tree",
|
|
||||||
title: "子页面",
|
|
||||||
depth: 1,
|
|
||||||
position: 2,
|
|
||||||
childCount: 1,
|
|
||||||
expandable: true,
|
|
||||||
expandedByDefault: false,
|
|
||||||
capabilities: ["expand", "open", "select"],
|
|
||||||
resourceMeta: {
|
|
||||||
resourceKind: "document",
|
|
||||||
documentId: "page_child",
|
|
||||||
workspaceId: "ws_1",
|
|
||||||
iconHint: "page",
|
|
||||||
},
|
|
||||||
iconHint: "page",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rowId: "index:page_child",
|
|
||||||
rowKind: "index",
|
|
||||||
nodeId: "index:page_child",
|
|
||||||
parentNodeId: "page_child",
|
|
||||||
nodeType: "index",
|
|
||||||
projectionKind: "file_tree",
|
|
||||||
title: "index.md",
|
|
||||||
depth: 2,
|
|
||||||
position: 0,
|
|
||||||
childCount: 0,
|
|
||||||
expandable: false,
|
|
||||||
expandedByDefault: false,
|
|
||||||
capabilities: ["open", "select"],
|
|
||||||
resourceMeta: {
|
|
||||||
resourceKind: "index",
|
|
||||||
documentId: "page_child",
|
|
||||||
workspaceId: "ws_1",
|
|
||||||
iconHint: "index",
|
|
||||||
},
|
|
||||||
iconHint: "index",
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const filteredItems = filterKernelFileTreeProjectionItems({
|
|
||||||
fileTreeItems: [...fileTreeItems],
|
|
||||||
visibleDocumentIds: new Set(["page_root", "page_child"]),
|
|
||||||
expandedDocumentIds: new Set(["page_root"]),
|
|
||||||
expandedAssetFolderIds: new Set(["mind_1"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(filteredItems.map((item) => item.rowId)).toEqual([
|
|
||||||
"doc:page_root",
|
|
||||||
"index:page_root",
|
|
||||||
"asset-folder:mind_1",
|
|
||||||
"asset:asset_child_1",
|
|
||||||
"doc:page_child",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const rows = buildVisibleRows({
|
|
||||||
fileTreeItems: filteredItems,
|
|
||||||
expanded: new Set(["page_root"]),
|
|
||||||
expandedAssetFolderIds: new Set(["mind_1"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(rows.map((row) => `${row.kind}:${row.rowId}`)).toEqual([
|
|
||||||
"doc:doc:page_root",
|
|
||||||
"index:index:page_root",
|
|
||||||
"asset-folder:asset-folder:mind_1",
|
|
||||||
"asset:asset:asset_child_1",
|
|
||||||
"doc:doc:page_child",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseFileTreeRowId", () => {
|
describe("parseFileTreeRowId", () => {
|
||||||
|
|||||||
@@ -12,40 +12,6 @@ import type { MediaAsset } from "@/types/media";
|
|||||||
import type { FileTreeRow } from "./types";
|
import type { FileTreeRow } from "./types";
|
||||||
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
||||||
|
|
||||||
export function filterKernelFileTreeProjectionItems(input: {
|
|
||||||
fileTreeItems: KernelFileTreeProjectionItem[];
|
|
||||||
visibleDocumentIds: ReadonlySet<string>;
|
|
||||||
expandedDocumentIds: ReadonlySet<string>;
|
|
||||||
expandedAssetFolderIds?: ReadonlySet<string>;
|
|
||||||
}): KernelFileTreeProjectionItem[] {
|
|
||||||
const expandedAssetFolderIds = input.expandedAssetFolderIds ?? new Set<string>();
|
|
||||||
|
|
||||||
return input.fileTreeItems.filter((item) => {
|
|
||||||
const docId = getDocIdFromFileTreeItem(item);
|
|
||||||
if (!input.visibleDocumentIds.has(docId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (item.rowKind) {
|
|
||||||
case "document":
|
|
||||||
return true;
|
|
||||||
case "index":
|
|
||||||
case "asset_folder":
|
|
||||||
return input.expandedDocumentIds.has(docId);
|
|
||||||
case "asset": {
|
|
||||||
if (!input.expandedDocumentIds.has(docId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const parentNodeId = String(item.parentNodeId ?? "").trim();
|
|
||||||
if (parentNodeId.startsWith("asset-folder:")) {
|
|
||||||
return expandedAssetFolderIds.has(parentNodeId.slice("asset-folder:".length));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRowsFromKernelFileTreeProjection(input: {
|
function buildRowsFromKernelFileTreeProjection(input: {
|
||||||
fileTreeItems: KernelFileTreeProjectionItem[];
|
fileTreeItems: KernelFileTreeProjectionItem[];
|
||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
@@ -124,16 +90,13 @@ function buildRowsFromKernelFileTreeProjection(input: {
|
|||||||
|
|
||||||
export function buildVisibleRows({
|
export function buildVisibleRows({
|
||||||
fileTreeItems,
|
fileTreeItems,
|
||||||
pageRows,
|
|
||||||
expanded,
|
expanded,
|
||||||
assetsByDoc,
|
|
||||||
assetChildrenByAssetId,
|
|
||||||
expandedAssetFolderIds,
|
expandedAssetFolderIds,
|
||||||
nodeById,
|
nodeById,
|
||||||
assetById,
|
assetById,
|
||||||
}: {
|
}: {
|
||||||
fileTreeItems?: KernelFileTreeProjectionItem[];
|
fileTreeItems?: KernelFileTreeProjectionItem[];
|
||||||
// 只消费统一 page_tree projection;结构真相不再由文件树自行定义。
|
// 兼容旧调用签名;主路径必须提供 kernel file_tree items,不能再从这些字段重建对象语义。
|
||||||
pageRows?: PageTreeProjectionItem[];
|
pageRows?: PageTreeProjectionItem[];
|
||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
assetsByDoc?: Record<string, MediaAsset[]>;
|
assetsByDoc?: Record<string, MediaAsset[]>;
|
||||||
@@ -152,77 +115,5 @@ export function buildVisibleRows({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const safePageRows = pageRows ?? [];
|
return [];
|
||||||
const safeAssetsByDoc = assetsByDoc ?? {};
|
|
||||||
const rows: FileTreeRow[] = [];
|
|
||||||
|
|
||||||
safePageRows.forEach((item) => {
|
|
||||||
const assets = safeAssetsByDoc[item.nodeId] ?? [];
|
|
||||||
const hasChildren = item.childCount > 0 || assets.length > 0;
|
|
||||||
const isExpanded = expanded.has(item.nodeId);
|
|
||||||
rows.push({
|
|
||||||
kind: "doc",
|
|
||||||
rowId: makeDocRowId(item.nodeId),
|
|
||||||
depth: item.depth,
|
|
||||||
docId: item.nodeId,
|
|
||||||
parentDocId: item.parentNodeId,
|
|
||||||
node: item.node,
|
|
||||||
hasChildren,
|
|
||||||
isExpanded,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isExpanded) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
kind: "index",
|
|
||||||
rowId: makeIndexRowId(item.nodeId),
|
|
||||||
depth: item.depth + 1,
|
|
||||||
docId: item.nodeId,
|
|
||||||
parentDocId: item.nodeId,
|
|
||||||
node: item.node,
|
|
||||||
});
|
|
||||||
|
|
||||||
assets.forEach((asset) => {
|
|
||||||
if (asset.asset_type === "mindmap") {
|
|
||||||
const children = assetChildrenByAssetId?.[asset.id] ?? [];
|
|
||||||
const hasChildren = children.length > 0;
|
|
||||||
const isExpanded = expandedAssetFolderIds?.has(asset.id) ?? false;
|
|
||||||
rows.push({
|
|
||||||
kind: "asset-folder",
|
|
||||||
rowId: makeAssetFolderRowId(asset.id),
|
|
||||||
depth: item.depth + 1,
|
|
||||||
docId: item.nodeId,
|
|
||||||
parentDocId: item.nodeId,
|
|
||||||
asset,
|
|
||||||
hasChildren,
|
|
||||||
isExpanded,
|
|
||||||
});
|
|
||||||
if (hasChildren && isExpanded) {
|
|
||||||
children.forEach((child) => {
|
|
||||||
rows.push({
|
|
||||||
kind: "asset",
|
|
||||||
rowId: makeAssetRowId(child.id),
|
|
||||||
depth: item.depth + 2,
|
|
||||||
docId: item.nodeId,
|
|
||||||
parentDocId: item.nodeId,
|
|
||||||
asset: child,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
kind: "asset",
|
|
||||||
rowId: makeAssetRowId(asset.id),
|
|
||||||
depth: item.depth + 1,
|
|
||||||
docId: item.nodeId,
|
|
||||||
parentDocId: item.nodeId,
|
|
||||||
asset,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { FileTreeSelectionState } from "./selection";
|
||||||
|
import {
|
||||||
|
createEmptyFileTreeSelectionState,
|
||||||
|
materializeRendererSelectionSnapshot,
|
||||||
|
resolveActiveFileTreeSelection,
|
||||||
|
} from "./selection-source";
|
||||||
|
|
||||||
|
function selection(
|
||||||
|
selectedRowIds: string[],
|
||||||
|
anchorRowId: string | null,
|
||||||
|
focusedRowId: string | null,
|
||||||
|
): FileTreeSelectionState {
|
||||||
|
return {
|
||||||
|
selectedRowIds: new Set(selectedRowIds),
|
||||||
|
anchorRowId,
|
||||||
|
focusedRowId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("selection-source", () => {
|
||||||
|
it("rust_family 应优先消费 renderer selection snapshot", () => {
|
||||||
|
const legacySelection = selection(["legacy"], "legacy", "legacy");
|
||||||
|
const rendererSelection = selection(["renderer"], "renderer", "renderer");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveActiveFileTreeSelection({
|
||||||
|
preferRendererSnapshot: true,
|
||||||
|
legacySelection,
|
||||||
|
rendererSelection,
|
||||||
|
}),
|
||||||
|
).toBe(rendererSelection);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveActiveFileTreeSelection({
|
||||||
|
preferRendererSnapshot: false,
|
||||||
|
legacySelection,
|
||||||
|
rendererSelection,
|
||||||
|
}),
|
||||||
|
).toBe(legacySelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renderer event snapshot 只过滤未知 row,不在宿主侧重算 focus/anchor", () => {
|
||||||
|
const snapshot = materializeRendererSelectionSnapshot({
|
||||||
|
payload: {
|
||||||
|
selectedRowIds: ["doc:a", "missing"],
|
||||||
|
anchorRowId: "missing",
|
||||||
|
focusedRowId: "doc:a",
|
||||||
|
},
|
||||||
|
hasRowId: (rowId) => rowId === "doc:a",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Array.from(snapshot.selectedRowIds)).toEqual(["doc:a"]);
|
||||||
|
expect(snapshot.anchorRowId).toBeNull();
|
||||||
|
expect(snapshot.focusedRowId).toBe("doc:a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空 selection 工厂应返回互不共享的 Set 实例", () => {
|
||||||
|
const a = createEmptyFileTreeSelectionState();
|
||||||
|
const b = createEmptyFileTreeSelectionState();
|
||||||
|
|
||||||
|
a.selectedRowIds.add("doc:a");
|
||||||
|
|
||||||
|
expect(a.selectedRowIds.has("doc:a")).toBe(true);
|
||||||
|
expect(b.selectedRowIds.has("doc:a")).toBe(false);
|
||||||
|
expect(a.selectedRowIds).not.toBe(b.selectedRowIds);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { FileTreeSelectionState } from "./selection";
|
||||||
|
|
||||||
|
export type FileTreeSelectionSnapshotPayload = {
|
||||||
|
selectedRowIds: readonly string[];
|
||||||
|
anchorRowId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createEmptyFileTreeSelectionState(): FileTreeSelectionState {
|
||||||
|
return {
|
||||||
|
selectedRowIds: new Set<string>(),
|
||||||
|
anchorRowId: null,
|
||||||
|
focusedRowId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeRendererSelectionSnapshot(input: {
|
||||||
|
payload: FileTreeSelectionSnapshotPayload;
|
||||||
|
hasRowId: (rowId: string) => boolean;
|
||||||
|
}): FileTreeSelectionState {
|
||||||
|
const selectedRowIds = new Set(
|
||||||
|
input.payload.selectedRowIds.filter((rowId) => input.hasRowId(rowId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedRowIds,
|
||||||
|
anchorRowId:
|
||||||
|
input.payload.anchorRowId && input.hasRowId(input.payload.anchorRowId)
|
||||||
|
? input.payload.anchorRowId
|
||||||
|
: null,
|
||||||
|
focusedRowId:
|
||||||
|
input.payload.focusedRowId && input.hasRowId(input.payload.focusedRowId)
|
||||||
|
? input.payload.focusedRowId
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveActiveFileTreeSelection(input: {
|
||||||
|
preferRendererSnapshot: boolean;
|
||||||
|
legacySelection: FileTreeSelectionState;
|
||||||
|
rendererSelection: FileTreeSelectionState;
|
||||||
|
}): FileTreeSelectionState {
|
||||||
|
return input.preferRendererSnapshot
|
||||||
|
? input.rendererSelection
|
||||||
|
: input.legacySelection;
|
||||||
|
}
|
||||||
@@ -3,9 +3,13 @@ import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
|||||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
import type { MediaAsset } from "@/types/media";
|
import type { MediaAsset } from "@/types/media";
|
||||||
import {
|
import {
|
||||||
|
buildFileTreeShellDeletePreflightPayload,
|
||||||
|
buildFileTreeShellInternalDropPreflightPayload,
|
||||||
|
buildFileTreeShellPastePreflightPayload,
|
||||||
|
buildFileTreeShellUploadTargetPreflightPayload,
|
||||||
buildFileTreeShellRowById,
|
buildFileTreeShellRowById,
|
||||||
buildFileTreeShellVisibleRowIds,
|
buildFileTreeShellVisibleRowIds,
|
||||||
computeFileTreeShellDeleteTargets,
|
collectFileTreeShellAssetHints,
|
||||||
getOrderedFileTreeShellRows,
|
getOrderedFileTreeShellRows,
|
||||||
inferFileTreeShellTargetDocumentId,
|
inferFileTreeShellTargetDocumentId,
|
||||||
resolveFileTreeShellMindmapTargetId,
|
resolveFileTreeShellMindmapTargetId,
|
||||||
@@ -273,22 +277,247 @@ describe("file-tree shell helpers", () => {
|
|||||||
).toEqual(["doc:doc_root", "asset:pdf_1"]);
|
).toEqual(["doc:doc_root", "asset:pdf_1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("删除目标计算应跳过被父页面覆盖的附件", () => {
|
it("内部拖放 preflight payload 应只收集 Rust 所需的行与父子快照", () => {
|
||||||
const rowById = buildFileTreeShellRowById({
|
const rowById = buildFileTreeShellRowById({
|
||||||
fileTreeItems: [...fileTreeItems],
|
fileTreeItems: [...fileTreeItems],
|
||||||
nodeById,
|
nodeById,
|
||||||
assetById,
|
assetById,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = computeFileTreeShellDeleteTargets({
|
const result = buildFileTreeShellInternalDropPreflightPayload({
|
||||||
visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
|
workspaceId: "ws_1",
|
||||||
|
copy: false,
|
||||||
|
targetDocumentId: null,
|
||||||
|
targetRowId: "asset-folder:mind_1",
|
||||||
|
focusedRowId: null,
|
||||||
|
activeDocId: null,
|
||||||
|
rowIds: ["asset:asset_child_1", "doc:doc_root", "asset:pdf_1", "asset:pdf_1"],
|
||||||
rowById,
|
rowById,
|
||||||
selectedRowIds: new Set(["doc:doc_root", "asset:pdf_1", "asset-folder:mind_1"]),
|
parentById: new Map([
|
||||||
parentById: new Map([["doc_root", null]]),
|
["doc_root", null],
|
||||||
|
["doc_child", "doc_root"],
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.docIds).toEqual(["doc_root"]);
|
expect(result).toEqual({
|
||||||
expect(result.assetIds).toEqual([]);
|
workspaceId: "ws_1",
|
||||||
expect(result.assetHints).toEqual([]);
|
copy: false,
|
||||||
|
targetDocumentId: null,
|
||||||
|
targetRowId: "asset-folder:mind_1",
|
||||||
|
focusedRowId: null,
|
||||||
|
activeDocumentId: null,
|
||||||
|
rowIds: ["asset:asset_child_1", "doc:doc_root", "asset:pdf_1", "asset:pdf_1"],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
rowId: "asset-folder:mind_1",
|
||||||
|
rowKind: "asset-folder",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "mind_1",
|
||||||
|
assetDocumentId: "doc_root",
|
||||||
|
assetType: "mindmap",
|
||||||
|
storagePath: "mindmaps/mind_1/mindmap.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset:asset_child_1",
|
||||||
|
rowKind: "asset",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "asset_child_1",
|
||||||
|
assetDocumentId: "doc_root",
|
||||||
|
assetType: "file",
|
||||||
|
storagePath: "mindmaps/mind_1/assets/node.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "doc:doc_root",
|
||||||
|
rowKind: "doc",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: null,
|
||||||
|
assetDocumentId: null,
|
||||||
|
assetType: null,
|
||||||
|
storagePath: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset:pdf_1",
|
||||||
|
rowKind: "asset",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "pdf_1",
|
||||||
|
assetDocumentId: "doc_root",
|
||||||
|
assetType: "file",
|
||||||
|
storagePath: "uploads/guide.pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
documentParents: [
|
||||||
|
{ documentId: "doc_root", parentId: null },
|
||||||
|
{ documentId: "doc_child", parentId: "doc_root" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("删除 preflight payload 应只收集选中行与父子快照", () => {
|
||||||
|
const rowById = buildFileTreeShellRowById({
|
||||||
|
fileTreeItems: [...fileTreeItems],
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildFileTreeShellDeletePreflightPayload({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rowIds: ["doc:doc_root", "asset:pdf_1", "missing"],
|
||||||
|
rowById,
|
||||||
|
parentById: new Map([
|
||||||
|
["doc_root", null],
|
||||||
|
["doc_child", "doc_root"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rowIds: ["doc:doc_root", "asset:pdf_1", "missing"],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
rowId: "doc:doc_root",
|
||||||
|
rowKind: "doc",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: null,
|
||||||
|
assetDocumentId: null,
|
||||||
|
assetType: null,
|
||||||
|
storagePath: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset:pdf_1",
|
||||||
|
rowKind: "asset",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "pdf_1",
|
||||||
|
assetDocumentId: "doc_root",
|
||||||
|
assetType: "file",
|
||||||
|
storagePath: "uploads/guide.pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
documentParents: [
|
||||||
|
{ documentId: "doc_root", parentId: null },
|
||||||
|
{ documentId: "doc_child", parentId: "doc_root" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("粘贴 preflight payload 应只收集剪贴板行与当前 focused 目标行", () => {
|
||||||
|
const rowById = buildFileTreeShellRowById({
|
||||||
|
fileTreeItems: [...fileTreeItems],
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildFileTreeShellPastePreflightPayload({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: null,
|
||||||
|
focusedRowId: "asset-folder:mind_1",
|
||||||
|
activeDocId: "doc_active",
|
||||||
|
rowIds: ["index:doc_root", "asset:pdf_1", "missing"],
|
||||||
|
rowById,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
targetDocumentId: null,
|
||||||
|
focusedRowId: "asset-folder:mind_1",
|
||||||
|
activeDocumentId: "doc_active",
|
||||||
|
rowIds: ["index:doc_root", "asset:pdf_1", "missing"],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
rowId: "asset-folder:mind_1",
|
||||||
|
rowKind: "asset-folder",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "mind_1",
|
||||||
|
assetDocumentId: "doc_root",
|
||||||
|
assetType: "mindmap",
|
||||||
|
storagePath: "mindmaps/mind_1/mindmap.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "index:doc_root",
|
||||||
|
rowKind: "index",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: null,
|
||||||
|
assetDocumentId: null,
|
||||||
|
assetType: null,
|
||||||
|
storagePath: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset:pdf_1",
|
||||||
|
rowKind: "asset",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "pdf_1",
|
||||||
|
assetDocumentId: "doc_root",
|
||||||
|
assetType: "file",
|
||||||
|
storagePath: "uploads/guide.pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("上传目标 preflight payload 应只收集目标行、focused 行与文档工作区快照", () => {
|
||||||
|
const rowById = buildFileTreeShellRowById({
|
||||||
|
fileTreeItems: [...fileTreeItems],
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = buildFileTreeShellUploadTargetPreflightPayload({
|
||||||
|
workspaceId: "ws_fallback",
|
||||||
|
targetDocumentId: null,
|
||||||
|
targetRowId: "asset:asset_child_1",
|
||||||
|
focusedRowId: "asset-folder:mind_1",
|
||||||
|
activeDocId: "doc_active",
|
||||||
|
rowById,
|
||||||
|
documentWorkspaceById: new Map([
|
||||||
|
["doc_root", "ws_1"],
|
||||||
|
["doc_active", "ws_active"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
workspaceId: "ws_fallback",
|
||||||
|
targetDocumentId: null,
|
||||||
|
targetRowId: "asset:asset_child_1",
|
||||||
|
focusedRowId: "asset-folder:mind_1",
|
||||||
|
activeDocumentId: "doc_active",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
rowId: "asset:asset_child_1",
|
||||||
|
rowKind: "asset",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "asset_child_1",
|
||||||
|
assetDocumentId: "doc_root",
|
||||||
|
assetType: "file",
|
||||||
|
storagePath: "mindmaps/mind_1/assets/node.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowId: "asset-folder:mind_1",
|
||||||
|
rowKind: "asset-folder",
|
||||||
|
documentId: "doc_root",
|
||||||
|
assetId: "mind_1",
|
||||||
|
assetDocumentId: "doc_root",
|
||||||
|
assetType: "mindmap",
|
||||||
|
storagePath: "mindmaps/mind_1/mindmap.json",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
documentWorkspaces: [
|
||||||
|
{ documentId: "doc_root", workspaceId: "ws_1" },
|
||||||
|
{ documentId: "doc_active", workspaceId: "ws_active" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("应能按 assetId 从 shell row map 回填 asset 与 asset-folder 的提示元数据", () => {
|
||||||
|
const rowById = buildFileTreeShellRowById({
|
||||||
|
fileTreeItems: [...fileTreeItems],
|
||||||
|
nodeById,
|
||||||
|
assetById,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
collectFileTreeShellAssetHints({
|
||||||
|
rowById,
|
||||||
|
assetIds: ["mind_1", "pdf_1", "missing"],
|
||||||
|
}).map((asset) => asset.id),
|
||||||
|
).toEqual(["mind_1", "pdf_1"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
} from "@/lib/kernel-file-tree";
|
} from "@/lib/kernel-file-tree";
|
||||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
import type { MediaAsset } from "@/types/media";
|
import type { MediaAsset } from "@/types/media";
|
||||||
import { filterTopLevelDocIds } from "./dnd";
|
|
||||||
|
|
||||||
export type FileTreeShellRowKind = "doc" | "index" | "asset" | "asset-folder";
|
export type FileTreeShellRowKind = "doc" | "index" | "asset" | "asset-folder";
|
||||||
|
|
||||||
@@ -21,10 +20,52 @@ export type FileTreeShellRow = {
|
|||||||
asset: MediaAsset | null;
|
asset: MediaAsset | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FileTreeShellDeleteTargets = {
|
export type FileTreeShellInternalDropPreflightRow = {
|
||||||
docIds: string[];
|
rowId: string;
|
||||||
assetIds: string[];
|
rowKind: FileTreeShellRowKind;
|
||||||
assetHints: MediaAsset[];
|
documentId: string | null;
|
||||||
|
assetId: string | null;
|
||||||
|
assetDocumentId: string | null;
|
||||||
|
assetType: string | null;
|
||||||
|
storagePath: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeShellInternalDropPreflightPayload = {
|
||||||
|
workspaceId: string | null;
|
||||||
|
copy: boolean;
|
||||||
|
targetDocumentId: string | null;
|
||||||
|
targetRowId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
activeDocumentId: string | null;
|
||||||
|
rowIds: string[];
|
||||||
|
rows: FileTreeShellInternalDropPreflightRow[];
|
||||||
|
documentParents: Array<{ documentId: string; parentId: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeShellDeletePreflightPayload = {
|
||||||
|
workspaceId: string | null;
|
||||||
|
rowIds: string[];
|
||||||
|
rows: FileTreeShellInternalDropPreflightRow[];
|
||||||
|
documentParents: Array<{ documentId: string; parentId: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeShellPastePreflightPayload = {
|
||||||
|
workspaceId: string | null;
|
||||||
|
targetDocumentId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
activeDocumentId: string | null;
|
||||||
|
rowIds: string[];
|
||||||
|
rows: FileTreeShellInternalDropPreflightRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeShellUploadTargetPreflightPayload = {
|
||||||
|
workspaceId: string | null;
|
||||||
|
targetDocumentId: string | null;
|
||||||
|
targetRowId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
activeDocumentId: string | null;
|
||||||
|
rows: FileTreeShellInternalDropPreflightRow[];
|
||||||
|
documentWorkspaces: Array<{ documentId: string; workspaceId: string | null }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toShellRowKind(item: KernelFileTreeProjectionItem): FileTreeShellRowKind {
|
function toShellRowKind(item: KernelFileTreeProjectionItem): FileTreeShellRowKind {
|
||||||
@@ -133,65 +174,6 @@ export function resolveFileTreeShellMindmapTargetId(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeFileTreeShellDeleteTargets(input: {
|
|
||||||
visibleRowIds: readonly string[];
|
|
||||||
rowById: Map<string, FileTreeShellRow>;
|
|
||||||
selectedRowIds: ReadonlySet<string>;
|
|
||||||
parentById: Map<string, string | null>;
|
|
||||||
}): FileTreeShellDeleteTargets {
|
|
||||||
const rows = getOrderedFileTreeShellRows({
|
|
||||||
rowIds: input.selectedRowIds,
|
|
||||||
visibleRowIds: input.visibleRowIds,
|
|
||||||
rowById: input.rowById,
|
|
||||||
});
|
|
||||||
|
|
||||||
const docCandidates: string[] = [];
|
|
||||||
const assetCandidates: string[] = [];
|
|
||||||
const assetDocIdByAssetId = new Map<string, string>();
|
|
||||||
const assetHintById = new Map<string, MediaAsset>();
|
|
||||||
|
|
||||||
rows.forEach((row) => {
|
|
||||||
if (row.rowKind === "doc" || row.rowKind === "index") {
|
|
||||||
docCandidates.push(row.documentId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((row.rowKind === "asset" || row.rowKind === "asset-folder") && row.assetId) {
|
|
||||||
assetCandidates.push(row.assetId);
|
|
||||||
assetDocIdByAssetId.set(row.assetId, row.documentId);
|
|
||||||
if (row.asset) {
|
|
||||||
assetHintById.set(row.assetId, row.asset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const docIds = filterTopLevelDocIds(docCandidates, input.parentById);
|
|
||||||
const docIdSet = new Set(docIds);
|
|
||||||
const seenAssets = new Set<string>();
|
|
||||||
const assetIds: string[] = [];
|
|
||||||
const assetHints: MediaAsset[] = [];
|
|
||||||
|
|
||||||
assetCandidates.forEach((assetId) => {
|
|
||||||
if (!assetId || seenAssets.has(assetId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seenAssets.add(assetId);
|
|
||||||
|
|
||||||
const ownerDocId = assetDocIdByAssetId.get(assetId);
|
|
||||||
if (ownerDocId && docIdSet.has(ownerDocId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
assetIds.push(assetId);
|
|
||||||
const assetHint = assetHintById.get(assetId);
|
|
||||||
if (assetHint) {
|
|
||||||
assetHints.push(assetHint);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { docIds, assetIds, assetHints };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function inferFileTreeShellTargetDocumentId(input: {
|
export function inferFileTreeShellTargetDocumentId(input: {
|
||||||
focusedRowId: string | null;
|
focusedRowId: string | null;
|
||||||
rowById: Map<string, FileTreeShellRow>;
|
rowById: Map<string, FileTreeShellRow>;
|
||||||
@@ -205,3 +187,202 @@ export function inferFileTreeShellTargetDocumentId(input: {
|
|||||||
}
|
}
|
||||||
return input.activeDocId || null;
|
return input.activeDocId || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeShellText(value: string | null | undefined): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFileTreeShellDropPreflightRow(
|
||||||
|
row: FileTreeShellRow,
|
||||||
|
): FileTreeShellInternalDropPreflightRow {
|
||||||
|
return {
|
||||||
|
rowId: row.rowId,
|
||||||
|
rowKind: row.rowKind,
|
||||||
|
documentId: normalizeShellText(row.documentId),
|
||||||
|
assetId: normalizeShellText(row.assetId),
|
||||||
|
assetDocumentId: normalizeShellText(row.asset?.document_id ?? null),
|
||||||
|
assetType: normalizeShellText(row.asset?.asset_type ?? null),
|
||||||
|
storagePath: normalizeShellText(row.asset?.storage_path ?? null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileTreeShellInternalDropPreflightPayload(input: {
|
||||||
|
workspaceId: string | null;
|
||||||
|
copy: boolean;
|
||||||
|
targetDocumentId: string | null;
|
||||||
|
targetRowId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
activeDocId: string | null;
|
||||||
|
rowIds: readonly string[];
|
||||||
|
rowById: Map<string, FileTreeShellRow>;
|
||||||
|
parentById: Map<string, string | null>;
|
||||||
|
}): FileTreeShellInternalDropPreflightPayload {
|
||||||
|
const rowIdSet = new Set<string>();
|
||||||
|
const appendRowId = (value: string | null | undefined) => {
|
||||||
|
const rowId = normalizeShellText(value);
|
||||||
|
if (rowId) {
|
||||||
|
rowIdSet.add(rowId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
|
||||||
|
const targetRowId = normalizeShellText(input.targetRowId);
|
||||||
|
const focusedRowId = normalizeShellText(input.focusedRowId);
|
||||||
|
appendRowId(targetRowId);
|
||||||
|
appendRowId(focusedRowId);
|
||||||
|
rowIds.forEach(appendRowId);
|
||||||
|
|
||||||
|
const rows = Array.from(rowIdSet)
|
||||||
|
.map((rowId) => input.rowById.get(rowId) ?? null)
|
||||||
|
.filter((row): row is FileTreeShellRow => Boolean(row))
|
||||||
|
.map(buildFileTreeShellDropPreflightRow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: normalizeShellText(input.workspaceId),
|
||||||
|
copy: input.copy,
|
||||||
|
targetDocumentId: normalizeShellText(input.targetDocumentId),
|
||||||
|
targetRowId,
|
||||||
|
focusedRowId,
|
||||||
|
activeDocumentId: normalizeShellText(input.activeDocId),
|
||||||
|
rowIds,
|
||||||
|
rows,
|
||||||
|
documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
|
||||||
|
documentId,
|
||||||
|
parentId: normalizeShellText(parentId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileTreeShellDeletePreflightPayload(input: {
|
||||||
|
workspaceId: string | null;
|
||||||
|
rowIds: readonly string[];
|
||||||
|
rowById: Map<string, FileTreeShellRow>;
|
||||||
|
parentById: Map<string, string | null>;
|
||||||
|
}): FileTreeShellDeletePreflightPayload {
|
||||||
|
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
|
||||||
|
const rowIdSet = new Set<string>();
|
||||||
|
rowIds.forEach((rowId) => {
|
||||||
|
const normalized = normalizeShellText(rowId);
|
||||||
|
if (normalized) {
|
||||||
|
rowIdSet.add(normalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = Array.from(rowIdSet)
|
||||||
|
.map((rowId) => input.rowById.get(rowId) ?? null)
|
||||||
|
.filter((row): row is FileTreeShellRow => Boolean(row))
|
||||||
|
.map(buildFileTreeShellDropPreflightRow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: normalizeShellText(input.workspaceId),
|
||||||
|
rowIds,
|
||||||
|
rows,
|
||||||
|
documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
|
||||||
|
documentId,
|
||||||
|
parentId: normalizeShellText(parentId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileTreeShellPastePreflightPayload(input: {
|
||||||
|
workspaceId: string | null;
|
||||||
|
targetDocumentId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
activeDocId: string | null;
|
||||||
|
rowIds: readonly string[];
|
||||||
|
rowById: Map<string, FileTreeShellRow>;
|
||||||
|
}): FileTreeShellPastePreflightPayload {
|
||||||
|
const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
|
||||||
|
const rowIdSet = new Set<string>();
|
||||||
|
const focusedRowId = normalizeShellText(input.focusedRowId);
|
||||||
|
if (focusedRowId) {
|
||||||
|
rowIdSet.add(focusedRowId);
|
||||||
|
}
|
||||||
|
rowIds.forEach((rowId) => {
|
||||||
|
const normalized = normalizeShellText(rowId);
|
||||||
|
if (normalized) {
|
||||||
|
rowIdSet.add(normalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = Array.from(rowIdSet)
|
||||||
|
.map((rowId) => input.rowById.get(rowId) ?? null)
|
||||||
|
.filter((row): row is FileTreeShellRow => Boolean(row))
|
||||||
|
.map(buildFileTreeShellDropPreflightRow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: normalizeShellText(input.workspaceId),
|
||||||
|
targetDocumentId: normalizeShellText(input.targetDocumentId),
|
||||||
|
focusedRowId,
|
||||||
|
activeDocumentId: normalizeShellText(input.activeDocId),
|
||||||
|
rowIds,
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileTreeShellUploadTargetPreflightPayload(input: {
|
||||||
|
workspaceId: string | null;
|
||||||
|
targetDocumentId: string | null;
|
||||||
|
targetRowId: string | null;
|
||||||
|
focusedRowId: string | null;
|
||||||
|
activeDocId: string | null;
|
||||||
|
rowById: Map<string, FileTreeShellRow>;
|
||||||
|
documentWorkspaceById: Map<string, string | null>;
|
||||||
|
}): FileTreeShellUploadTargetPreflightPayload {
|
||||||
|
const rowIdSet = new Set<string>();
|
||||||
|
const appendRowId = (value: string | null | undefined) => {
|
||||||
|
const rowId = normalizeShellText(value);
|
||||||
|
if (rowId) {
|
||||||
|
rowIdSet.add(rowId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const targetRowId = normalizeShellText(input.targetRowId);
|
||||||
|
const focusedRowId = normalizeShellText(input.focusedRowId);
|
||||||
|
appendRowId(targetRowId);
|
||||||
|
appendRowId(focusedRowId);
|
||||||
|
|
||||||
|
const rows = Array.from(rowIdSet)
|
||||||
|
.map((rowId) => input.rowById.get(rowId) ?? null)
|
||||||
|
.filter((row): row is FileTreeShellRow => Boolean(row))
|
||||||
|
.map(buildFileTreeShellDropPreflightRow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: normalizeShellText(input.workspaceId),
|
||||||
|
targetDocumentId: normalizeShellText(input.targetDocumentId),
|
||||||
|
targetRowId,
|
||||||
|
focusedRowId,
|
||||||
|
activeDocumentId: normalizeShellText(input.activeDocId),
|
||||||
|
rows,
|
||||||
|
documentWorkspaces: Array.from(input.documentWorkspaceById.entries()).map(
|
||||||
|
([documentId, workspaceId]) => ({
|
||||||
|
documentId,
|
||||||
|
workspaceId: normalizeShellText(workspaceId),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectFileTreeShellAssetHints(input: {
|
||||||
|
rowById: Map<string, FileTreeShellRow>;
|
||||||
|
assetIds: readonly string[];
|
||||||
|
}): MediaAsset[] {
|
||||||
|
const hints: MediaAsset[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
input.assetIds.forEach((assetId) => {
|
||||||
|
const normalized = normalizeShellText(assetId);
|
||||||
|
if (!normalized || seen.has(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const asset =
|
||||||
|
input.rowById.get(`asset:${normalized}`)?.asset ??
|
||||||
|
input.rowById.get(`asset-folder:${normalized}`)?.asset ??
|
||||||
|
null;
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(normalized);
|
||||||
|
hints.push(asset);
|
||||||
|
});
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ConvexHttpClient } from "convex/browser";
|
||||||
|
import { resolveKernelFileTreeProjection } from "./kernel-file-tree";
|
||||||
|
|
||||||
|
const mockBuildDocumentBridgeContextWithActor = vi.fn();
|
||||||
|
const mockBuildDocumentQueryEnvelope = vi.fn();
|
||||||
|
const mockExecuteRustBridgeQuery = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/bridge", () => ({
|
||||||
|
buildDocumentBridgeContextWithActor: (...args: unknown[]) =>
|
||||||
|
mockBuildDocumentBridgeContextWithActor(...args),
|
||||||
|
buildDocumentQueryEnvelope: (...args: unknown[]) => mockBuildDocumentQueryEnvelope(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||||
|
executeRustBridgeQuery: (...args: unknown[]) => mockExecuteRustBridgeQuery(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("resolveKernelFileTreeProjection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockBuildDocumentBridgeContextWithActor.mockReset().mockReturnValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
});
|
||||||
|
mockBuildDocumentQueryEnvelope.mockReset().mockImplementation((input) => input);
|
||||||
|
mockExecuteRustBridgeQuery.mockReset().mockResolvedValue({
|
||||||
|
projectionId: "kernel_projection:file_tree:page_root",
|
||||||
|
projection: "file_tree",
|
||||||
|
rootNodeId: "page_root",
|
||||||
|
items: [],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("把搜索词传入 Rust kernel.project_view,而不是交给宿主裁剪", async () => {
|
||||||
|
await resolveKernelFileTreeProjection({
|
||||||
|
client: { query: vi.fn() } as unknown as ConvexHttpClient,
|
||||||
|
request: new Request("http://127.0.0.1:3000/api/tree/projections/file"),
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
actor: {
|
||||||
|
actorType: "user",
|
||||||
|
actorId: "user_1",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
dataset: {
|
||||||
|
active_workspace_id: "ws_1",
|
||||||
|
documents: [],
|
||||||
|
},
|
||||||
|
rootNodeId: "page_root",
|
||||||
|
depth: 2,
|
||||||
|
query: " 预算 ",
|
||||||
|
maxResults: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockBuildDocumentQueryEnvelope).toHaveBeenCalledWith({
|
||||||
|
name: "kernel.project_view",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
projection: "file_tree",
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
rootNodeId: "page_root",
|
||||||
|
depth: 2,
|
||||||
|
query: "预算",
|
||||||
|
maxResults: 12,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(mockExecuteRustBridgeQuery).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,12 +16,19 @@ export async function resolveKernelFileTreeProjection(input: {
|
|||||||
dataset: SidebarDatasetListQueryResult;
|
dataset: SidebarDatasetListQueryResult;
|
||||||
rootNodeId?: string | null;
|
rootNodeId?: string | null;
|
||||||
depth?: number | null;
|
depth?: number | null;
|
||||||
|
query?: string | null;
|
||||||
|
maxResults?: number | null;
|
||||||
}): Promise<KernelFileTreeProjection> {
|
}): Promise<KernelFileTreeProjection> {
|
||||||
const context = buildDocumentBridgeContextWithActor({
|
const context = buildDocumentBridgeContextWithActor({
|
||||||
request: input.request,
|
request: input.request,
|
||||||
actor: input.actor,
|
actor: input.actor,
|
||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
});
|
});
|
||||||
|
const query = input.query?.trim() || null;
|
||||||
|
const maxResults =
|
||||||
|
typeof input.maxResults === "number" && Number.isFinite(input.maxResults)
|
||||||
|
? Math.max(1, Math.floor(input.maxResults))
|
||||||
|
: null;
|
||||||
|
|
||||||
return executeRustBridgeQuery<KernelFileTreeProjection>({
|
return executeRustBridgeQuery<KernelFileTreeProjection>({
|
||||||
context,
|
context,
|
||||||
@@ -32,6 +39,8 @@ export async function resolveKernelFileTreeProjection(input: {
|
|||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
rootNodeId: input.rootNodeId ?? null,
|
rootNodeId: input.rootNodeId ?? null,
|
||||||
depth: input.depth ?? null,
|
depth: input.depth ?? null,
|
||||||
|
query,
|
||||||
|
maxResults,
|
||||||
includeEdges: true,
|
includeEdges: true,
|
||||||
includeContent: false,
|
includeContent: false,
|
||||||
nodeTypes: ["page"],
|
nodeTypes: ["page"],
|
||||||
|
|||||||
@@ -266,6 +266,458 @@ describe("tree-stream/server", () => {
|
|||||||
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("单条新 domain event 携带 streamDelta 时,应直接发 delta", async () => {
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
command_logs: [],
|
||||||
|
domain_events: [
|
||||||
|
{
|
||||||
|
event_id: "evt_1",
|
||||||
|
created_at: "2026-04-24T00:00:01Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
generated_at: "2026-04-24T00:00:01Z",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
command_logs: [],
|
||||||
|
domain_events: [
|
||||||
|
{
|
||||||
|
event_id: "evt_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
payload: {
|
||||||
|
command_name: "tree.node.rename",
|
||||||
|
streamDelta: {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: {
|
||||||
|
id: "page_2",
|
||||||
|
title: "新标题",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_id: "evt_1",
|
||||||
|
created_at: "2026-04-24T00:00:01Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
generated_at: "2026-04-24T00:00:02Z",
|
||||||
|
});
|
||||||
|
const loadSnapshot = vi.fn().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "delta",
|
||||||
|
payload: {
|
||||||
|
kind: "delta",
|
||||||
|
cursor: JSON.stringify({
|
||||||
|
createdAt: "2026-04-24T00:00:02Z",
|
||||||
|
id: "domain_event:evt_2",
|
||||||
|
}),
|
||||||
|
data: {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: {
|
||||||
|
id: "page_2",
|
||||||
|
title: "新标题",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("单条资源 domain event 携带 upsert_assets 时,应直接发 delta", async () => {
|
||||||
|
const streamDelta = {
|
||||||
|
op: "upsert_assets",
|
||||||
|
upsertAssets: [
|
||||||
|
{
|
||||||
|
id: "asset_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
document_id: "doc_target",
|
||||||
|
asset_type: "file",
|
||||||
|
file_url: "/file.pdf",
|
||||||
|
thumbnail_url: "/file.pdf",
|
||||||
|
file_name: "file.pdf",
|
||||||
|
file_size: 1024,
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
created_at: "2026-04-26T00:00:00Z",
|
||||||
|
updated_at: "2026-04-26T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
command_logs: [],
|
||||||
|
domain_events: [
|
||||||
|
{
|
||||||
|
event_id: "evt_1",
|
||||||
|
created_at: "2026-04-24T00:00:01Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
generated_at: "2026-04-24T00:00:01Z",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
command_logs: [],
|
||||||
|
domain_events: [
|
||||||
|
{
|
||||||
|
event_id: "evt_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
payload: {
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
eventType: "tree.resource.moved",
|
||||||
|
streamDelta,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_id: "evt_1",
|
||||||
|
created_at: "2026-04-24T00:00:01Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
generated_at: "2026-04-24T00:00:02Z",
|
||||||
|
});
|
||||||
|
const loadSnapshot = vi.fn().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "delta",
|
||||||
|
payload: {
|
||||||
|
kind: "delta",
|
||||||
|
data: streamDelta,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("单条新 domain event 缺少可识别 streamDelta 时,应保守回退 resync", async () => {
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
command_logs: [],
|
||||||
|
domain_events: [
|
||||||
|
{
|
||||||
|
event_id: "evt_1",
|
||||||
|
created_at: "2026-04-24T00:00:01Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
generated_at: "2026-04-24T00:00:01Z",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
command_logs: [],
|
||||||
|
domain_events: [
|
||||||
|
{
|
||||||
|
event_id: "evt_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
payload: {
|
||||||
|
schema: "mnote.tree.domain_event",
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: "tree.node.unknown",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_id: "evt_1",
|
||||||
|
created_at: "2026-04-24T00:00:01Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
generated_at: "2026-04-24T00:00:02Z",
|
||||||
|
});
|
||||||
|
const loadSnapshot = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
requestId: "req_2",
|
||||||
|
traceId: "trace_2",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_2" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_2" }] }, tree: { items: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "resync",
|
||||||
|
payload: {
|
||||||
|
kind: "resync",
|
||||||
|
cursor: JSON.stringify({
|
||||||
|
createdAt: "2026-04-24T00:00:02Z",
|
||||||
|
id: "domain_event:evt_2",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("同一命令的 command log 与 domain event 同时推进且 delta 一致时,应发一次 delta", async () => {
|
||||||
|
const streamDelta = {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: { id: "page_2", title: "同一标题" },
|
||||||
|
};
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
command_logs: [
|
||||||
|
{
|
||||||
|
id: "clog_2",
|
||||||
|
command_id: "cmd_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
command_name: "tree.node.rename",
|
||||||
|
payload: { streamDelta },
|
||||||
|
},
|
||||||
|
{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" },
|
||||||
|
],
|
||||||
|
domain_events: [
|
||||||
|
{
|
||||||
|
event_id: "evt_2",
|
||||||
|
command_id: "cmd_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
payload: { streamDelta },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
generated_at: "2026-04-24T00:00:02Z",
|
||||||
|
});
|
||||||
|
const loadSnapshot = vi.fn().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "delta",
|
||||||
|
payload: {
|
||||||
|
kind: "delta",
|
||||||
|
cursor: JSON.stringify({
|
||||||
|
createdAt: "2026-04-24T00:00:02Z",
|
||||||
|
id: "clog_2",
|
||||||
|
}),
|
||||||
|
data: streamDelta,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("上一帧 cursor 来自 command log 时,应按时间排除旧 domain event 后再做双写去重", async () => {
|
||||||
|
const streamDelta = {
|
||||||
|
op: "move_document",
|
||||||
|
documentId: "page_2",
|
||||||
|
parentId: "page_1",
|
||||||
|
sortOrder: 2,
|
||||||
|
};
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
command_logs: [
|
||||||
|
{
|
||||||
|
id: "clog_2",
|
||||||
|
command_id: "cmd_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
command_name: "tree.subtree.move",
|
||||||
|
payload: { streamDelta },
|
||||||
|
},
|
||||||
|
{ id: "clog_1", command_id: "cmd_1", created_at: "2026-04-24T00:00:01Z" },
|
||||||
|
],
|
||||||
|
domain_events: [
|
||||||
|
{
|
||||||
|
event_id: "evt_2",
|
||||||
|
command_id: "cmd_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
payload: { streamDelta },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_id: "evt_1",
|
||||||
|
command_id: "cmd_1",
|
||||||
|
created_at: "2026-04-24T00:00:01Z",
|
||||||
|
payload: {
|
||||||
|
streamDelta: {
|
||||||
|
op: "noop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
generated_at: "2026-04-24T00:00:02Z",
|
||||||
|
});
|
||||||
|
const loadSnapshot = vi.fn().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "delta",
|
||||||
|
payload: {
|
||||||
|
kind: "delta",
|
||||||
|
data: streamDelta,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("command log 与 domain event 同时推进且 delta 不一致时,应回退 resync 避免漏发", async () => {
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
command_logs: [
|
||||||
|
{
|
||||||
|
id: "clog_2",
|
||||||
|
created_at: "2026-04-24T00:00:03Z",
|
||||||
|
command_name: "tree.node.rename",
|
||||||
|
payload: {
|
||||||
|
streamDelta: {
|
||||||
|
op: "upsert_document",
|
||||||
|
document: { id: "page_2", title: "命令标题" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||||
|
],
|
||||||
|
domain_events: [
|
||||||
|
{
|
||||||
|
event_id: "evt_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
payload: {
|
||||||
|
streamDelta: {
|
||||||
|
op: "remove_document",
|
||||||
|
documentId: "page_3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
has_more: false,
|
||||||
|
next_cursor: null,
|
||||||
|
generated_at: "2026-04-24T00:00:03Z",
|
||||||
|
});
|
||||||
|
const loadSnapshot = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_1" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_1" }] }, tree: { items: [] } },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
requestId: "req_2",
|
||||||
|
traceId: "trace_2",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [{ id: "page_2" }] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [{ id: "page_2" }] }, tree: { items: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames).toHaveLength(2);
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "resync",
|
||||||
|
payload: {
|
||||||
|
kind: "resync",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("单条新命令缺少可稳定解释的 streamDelta 时,应回退 resync", async () => {
|
it("单条新命令缺少可稳定解释的 streamDelta 时,应回退 resync", async () => {
|
||||||
const loadOverview = vi
|
const loadOverview = vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -374,7 +826,7 @@ describe("tree-stream/server", () => {
|
|||||||
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("move 这类附带 replace_documents 的新命令应直接发 delta,而不是触发 resync", async () => {
|
it("move 这类附带 move_document 的新命令应直接发 delta,而不是触发 resync", async () => {
|
||||||
const sidebarSnapshot = {
|
const sidebarSnapshot = {
|
||||||
activeWorkspaceId: "ws_1",
|
activeWorkspaceId: "ws_1",
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
@@ -428,8 +880,11 @@ describe("tree-stream/server", () => {
|
|||||||
payload: {
|
payload: {
|
||||||
documentId: "page_1",
|
documentId: "page_1",
|
||||||
streamDelta: {
|
streamDelta: {
|
||||||
op: "replace_documents",
|
op: "move_document",
|
||||||
documents: sidebarSnapshot.documents,
|
documentId: "page_1",
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
updatedAt: "2026-04-24T00:01:00Z",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -460,12 +915,82 @@ describe("tree-stream/server", () => {
|
|||||||
payload: {
|
payload: {
|
||||||
kind: "delta",
|
kind: "delta",
|
||||||
data: {
|
data: {
|
||||||
op: "replace_documents",
|
op: "move_document",
|
||||||
documents: expect.arrayContaining([
|
documentId: "page_1",
|
||||||
expect.objectContaining({
|
parentId: null,
|
||||||
id: "page_1",
|
sortOrder: 0,
|
||||||
}),
|
updatedAt: "2026-04-24T00:01:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copy 这类附带 upsert_documents 的新命令应保留批量文档字段", async () => {
|
||||||
|
const loadOverview = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" }]),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildOverview([
|
||||||
|
{
|
||||||
|
id: "clog_2",
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
command_name: "tree.subtree.copy",
|
||||||
|
payload: {
|
||||||
|
streamDelta: {
|
||||||
|
op: "upsert_documents",
|
||||||
|
upsertDocuments: [
|
||||||
|
{
|
||||||
|
id: "copy_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "Copy",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 2,
|
||||||
|
is_starred: false,
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-24T00:00:02Z",
|
||||||
|
updated_at: "2026-04-24T00:00:02Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: "clog_1", created_at: "2026-04-24T00:00:01Z" },
|
||||||
]),
|
]),
|
||||||
|
);
|
||||||
|
const loadSnapshot = vi.fn().mockResolvedValue({
|
||||||
|
requestId: "req_1",
|
||||||
|
traceId: "trace_1",
|
||||||
|
data: { activeWorkspaceId: "ws_1", documents: [] },
|
||||||
|
snapshot: { dataset: { active_workspace_id: "ws_1", documents: [] }, tree: { items: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const frames = await collectFrames(
|
||||||
|
streamTreeFrames({
|
||||||
|
workspaceId: "ws_1",
|
||||||
|
pollMs: 1,
|
||||||
|
maxPolls: 1,
|
||||||
|
loadOverview,
|
||||||
|
loadSnapshot,
|
||||||
|
sleep: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(frames[1]).toMatchObject({
|
||||||
|
event: "delta",
|
||||||
|
payload: {
|
||||||
|
kind: "delta",
|
||||||
|
data: {
|
||||||
|
op: "upsert_documents",
|
||||||
|
upsertDocuments: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "copy_1",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type TreeStreamEventName = "snapshot" | "delta" | "resync";
|
|||||||
|
|
||||||
export interface TreeStreamCommandLogCursorRow {
|
export interface TreeStreamCommandLogCursorRow {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
|
command_id?: string | null;
|
||||||
|
commandId?: string | null;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
command_name?: string | null;
|
command_name?: string | null;
|
||||||
commandName?: string | null;
|
commandName?: string | null;
|
||||||
@@ -65,8 +67,11 @@ type DecodedTreeStreamCursor = {
|
|||||||
type TreeStreamDomainEventCursorRow = {
|
type TreeStreamDomainEventCursorRow = {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
event_id?: string | null;
|
event_id?: string | null;
|
||||||
|
command_id?: string | null;
|
||||||
|
commandId?: string | null;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
|
payload?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TREE_STREAM_NOOP_COMMANDS = new Set([
|
const TREE_STREAM_NOOP_COMMANDS = new Set([
|
||||||
@@ -219,11 +224,110 @@ function readCommandPayloadDelta(row: TreeStreamCommandLogCursorRow): TreeStream
|
|||||||
node: isRecord(candidate.node) ? (candidate.node as TreeStreamDeltaEvent["node"]) : null,
|
node: isRecord(candidate.node) ? (candidate.node as TreeStreamDeltaEvent["node"]) : null,
|
||||||
document: isRecord(candidate.document) ? (candidate.document as TreeStreamDeltaEvent["document"]) : null,
|
document: isRecord(candidate.document) ? (candidate.document as TreeStreamDeltaEvent["document"]) : null,
|
||||||
documentId: typeof candidate.documentId === "string" ? candidate.documentId : null,
|
documentId: typeof candidate.documentId === "string" ? candidate.documentId : null,
|
||||||
|
parentId: typeof candidate.parentId === "string" ? candidate.parentId : candidate.parentId === null ? null : undefined,
|
||||||
|
sortOrder:
|
||||||
|
typeof candidate.sortOrder === "number" && Number.isFinite(candidate.sortOrder)
|
||||||
|
? candidate.sortOrder
|
||||||
|
: undefined,
|
||||||
|
updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : null,
|
||||||
|
upsertDocuments: Array.isArray(candidate.upsertDocuments)
|
||||||
|
? (candidate.upsertDocuments as TreeStreamDeltaEvent["upsertDocuments"])
|
||||||
|
: null,
|
||||||
|
upsertAssets: Array.isArray(candidate.upsertAssets)
|
||||||
|
? (candidate.upsertAssets as TreeStreamDeltaEvent["upsertAssets"])
|
||||||
|
: null,
|
||||||
documents: Array.isArray(candidate.documents) ? (candidate.documents as TreeStreamDeltaEvent["documents"]) : null,
|
documents: Array.isArray(candidate.documents) ? (candidate.documents as TreeStreamDeltaEvent["documents"]) : null,
|
||||||
sidebar: isRecord(candidate.sidebar) ? (candidate.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
|
sidebar: isRecord(candidate.sidebar) ? (candidate.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStreamDeltaCandidate(candidate: unknown): TreeStreamDeltaEvent | null {
|
||||||
|
if (!isRecord(candidate) || typeof candidate.op !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: candidate.op as TreeStreamDeltaEvent["op"],
|
||||||
|
node: isRecord(candidate.node) ? (candidate.node as TreeStreamDeltaEvent["node"]) : null,
|
||||||
|
document: isRecord(candidate.document) ? (candidate.document as TreeStreamDeltaEvent["document"]) : null,
|
||||||
|
documentId: typeof candidate.documentId === "string" ? candidate.documentId : null,
|
||||||
|
parentId: typeof candidate.parentId === "string" ? candidate.parentId : candidate.parentId === null ? null : undefined,
|
||||||
|
sortOrder:
|
||||||
|
typeof candidate.sortOrder === "number" && Number.isFinite(candidate.sortOrder)
|
||||||
|
? candidate.sortOrder
|
||||||
|
: undefined,
|
||||||
|
updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : null,
|
||||||
|
upsertDocuments: Array.isArray(candidate.upsertDocuments)
|
||||||
|
? (candidate.upsertDocuments as TreeStreamDeltaEvent["upsertDocuments"])
|
||||||
|
: null,
|
||||||
|
upsertAssets: Array.isArray(candidate.upsertAssets)
|
||||||
|
? (candidate.upsertAssets as TreeStreamDeltaEvent["upsertAssets"])
|
||||||
|
: null,
|
||||||
|
documents: Array.isArray(candidate.documents) ? (candidate.documents as TreeStreamDeltaEvent["documents"]) : null,
|
||||||
|
sidebar: isRecord(candidate.sidebar) ? (candidate.sidebar as TreeStreamDeltaEvent["sidebar"]) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDomainEventPayloadDelta(row: TreeStreamDomainEventCursorRow): TreeStreamDeltaEvent | null {
|
||||||
|
if (!isRecord(row.payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return readStreamDeltaCandidate(row.payload.streamDelta ?? row.payload.stream_delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCommandRowCommandId(row: TreeStreamCommandLogCursorRow): string | null {
|
||||||
|
const raw =
|
||||||
|
typeof row.command_id === "string"
|
||||||
|
? row.command_id
|
||||||
|
: typeof row.commandId === "string"
|
||||||
|
? row.commandId
|
||||||
|
: typeof row.id === "string"
|
||||||
|
? row.id
|
||||||
|
: "";
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDomainEventCommandId(row: TreeStreamDomainEventCursorRow): string | null {
|
||||||
|
const raw =
|
||||||
|
typeof row.command_id === "string"
|
||||||
|
? row.command_id
|
||||||
|
: typeof row.commandId === "string"
|
||||||
|
? row.commandId
|
||||||
|
: "";
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMatchingCommandDomainEventDelta(input: {
|
||||||
|
commandRows: TreeStreamCommandLogCursorRow[];
|
||||||
|
domainEventRows: TreeStreamDomainEventCursorRow[];
|
||||||
|
commandDrifted: boolean;
|
||||||
|
domainEventDrifted: boolean;
|
||||||
|
}): TreeStreamDeltaEvent | null {
|
||||||
|
if (
|
||||||
|
input.commandDrifted ||
|
||||||
|
input.commandRows.length !== 1 ||
|
||||||
|
input.domainEventRows.length !== 1
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandId = readCommandRowCommandId(input.commandRows[0] ?? {});
|
||||||
|
const eventCommandId = readDomainEventCommandId(input.domainEventRows[0] ?? {});
|
||||||
|
if (!commandId || commandId !== eventCommandId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandDelta = readCommandPayloadDelta(input.commandRows[0] ?? {});
|
||||||
|
const eventDelta = readDomainEventPayloadDelta(input.domainEventRows[0] ?? {});
|
||||||
|
if (!commandDelta || !eventDelta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(commandDelta) === JSON.stringify(eventDelta) ? eventDelta : null;
|
||||||
|
}
|
||||||
|
|
||||||
function collectNewCommandLogs(input: {
|
function collectNewCommandLogs(input: {
|
||||||
rows: TreeStreamCommandLogCursorRow[];
|
rows: TreeStreamCommandLogCursorRow[];
|
||||||
previousCursor: string | null;
|
previousCursor: string | null;
|
||||||
@@ -249,6 +353,77 @@ function collectNewCommandLogs(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newerRows = input.rows.filter((row) => {
|
||||||
|
const createdAt = typeof row.created_at === "string" ? row.created_at.trim() : "";
|
||||||
|
return createdAt > previousCursor.createdAt;
|
||||||
|
});
|
||||||
|
if (newerRows.length < input.rows.length) {
|
||||||
|
return {
|
||||||
|
rows: newerRows,
|
||||||
|
drifted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: input.rows,
|
||||||
|
drifted: input.rows.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNewDomainEvents(input: {
|
||||||
|
rows: TreeStreamDomainEventCursorRow[];
|
||||||
|
previousCursor: string | null;
|
||||||
|
}) {
|
||||||
|
const previousCursor = decodeTreeStreamCursor(input.previousCursor);
|
||||||
|
if (!previousCursor) {
|
||||||
|
return {
|
||||||
|
rows: input.rows,
|
||||||
|
drifted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousId = previousCursor.id.startsWith("domain_event:")
|
||||||
|
? previousCursor.id.slice("domain_event:".length)
|
||||||
|
: previousCursor.id;
|
||||||
|
const previousIndex = input.rows.findIndex((row) => {
|
||||||
|
const id =
|
||||||
|
typeof row.event_id === "string"
|
||||||
|
? row.event_id.trim()
|
||||||
|
: typeof row.id === "string"
|
||||||
|
? row.id.trim()
|
||||||
|
: "";
|
||||||
|
const createdAt =
|
||||||
|
typeof row.created_at === "string"
|
||||||
|
? row.created_at.trim()
|
||||||
|
: typeof row.createdAt === "string"
|
||||||
|
? row.createdAt.trim()
|
||||||
|
: "";
|
||||||
|
return id === previousId && createdAt === previousCursor.createdAt;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previousIndex >= 0) {
|
||||||
|
return {
|
||||||
|
rows: input.rows.slice(0, previousIndex),
|
||||||
|
drifted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newerRows = input.rows.filter((row) => {
|
||||||
|
const createdAt =
|
||||||
|
typeof row.created_at === "string"
|
||||||
|
? row.created_at.trim()
|
||||||
|
: typeof row.createdAt === "string"
|
||||||
|
? row.createdAt.trim()
|
||||||
|
: "";
|
||||||
|
return createdAt > previousCursor.createdAt;
|
||||||
|
});
|
||||||
|
if (newerRows.length < input.rows.length) {
|
||||||
|
return {
|
||||||
|
rows: newerRows,
|
||||||
|
drifted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: input.rows,
|
rows: input.rows,
|
||||||
drifted: input.rows.length > 0,
|
drifted: input.rows.length > 0,
|
||||||
@@ -353,6 +528,56 @@ export async function* streamTreeFrames(
|
|||||||
rows,
|
rows,
|
||||||
previousCursor: cursor,
|
previousCursor: cursor,
|
||||||
});
|
});
|
||||||
|
const eventRows = Array.isArray(overview.domain_events)
|
||||||
|
? (overview.domain_events as TreeStreamDomainEventCursorRow[])
|
||||||
|
: [];
|
||||||
|
const newEventRows = collectNewDomainEvents({
|
||||||
|
rows: eventRows,
|
||||||
|
previousCursor: cursor,
|
||||||
|
});
|
||||||
|
const hasNewCommandRows = newRows.rows.length > 0;
|
||||||
|
const hasNewDomainEventRows = newEventRows.rows.length > 0;
|
||||||
|
if (hasNewCommandRows && hasNewDomainEventRows) {
|
||||||
|
const delta = resolveMatchingCommandDomainEventDelta({
|
||||||
|
commandRows: newRows.rows,
|
||||||
|
domainEventRows: newEventRows.rows,
|
||||||
|
commandDrifted: newRows.drifted,
|
||||||
|
domainEventDrifted: newEventRows.drifted,
|
||||||
|
});
|
||||||
|
if (delta) {
|
||||||
|
cursor = nextCursor;
|
||||||
|
yield {
|
||||||
|
event: "delta",
|
||||||
|
payload: buildTreeStreamDeltaEnvelope({
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
rootNodeId: contract.rootNodeId,
|
||||||
|
projection: contract.projection,
|
||||||
|
cursor,
|
||||||
|
overview,
|
||||||
|
snapshot,
|
||||||
|
delta,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = await input.loadSnapshot();
|
||||||
|
cursor = nextCursor;
|
||||||
|
yield {
|
||||||
|
event: "resync",
|
||||||
|
payload: buildTreeStreamEnvelope({
|
||||||
|
kind: "resync",
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
rootNodeId: contract.rootNodeId,
|
||||||
|
projection: contract.projection,
|
||||||
|
cursor,
|
||||||
|
overview,
|
||||||
|
snapshot,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!newRows.drifted && newRows.rows.length === 1) {
|
if (!newRows.drifted && newRows.rows.length === 1) {
|
||||||
const delta = readCommandPayloadDelta(newRows.rows[0] ?? {});
|
const delta = readCommandPayloadDelta(newRows.rows[0] ?? {});
|
||||||
if (delta) {
|
if (delta) {
|
||||||
@@ -373,6 +598,26 @@ export async function* streamTreeFrames(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!newEventRows.drifted && newRows.rows.length === 0 && newEventRows.rows.length === 1) {
|
||||||
|
const delta = readDomainEventPayloadDelta(newEventRows.rows[0] ?? {});
|
||||||
|
if (delta) {
|
||||||
|
cursor = nextCursor;
|
||||||
|
yield {
|
||||||
|
event: "delta",
|
||||||
|
payload: buildTreeStreamDeltaEnvelope({
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
rootNodeId: contract.rootNodeId,
|
||||||
|
projection: contract.projection,
|
||||||
|
cursor,
|
||||||
|
overview,
|
||||||
|
snapshot,
|
||||||
|
delta,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
snapshot = await input.loadSnapshot();
|
snapshot = await input.loadSnapshot();
|
||||||
cursor = nextCursor;
|
cursor = nextCursor;
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,84 @@ describe("tree-stream/tree-delta", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("支持 upsert_documents 批量新增复制出的子树", () => {
|
||||||
|
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||||
|
op: "upsert_documents",
|
||||||
|
upsertDocuments: [
|
||||||
|
{
|
||||||
|
id: "copy_root",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "Copy Root",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 2,
|
||||||
|
is_starred: false,
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-18T00:10:00Z",
|
||||||
|
updated_at: "2026-04-18T00:10:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "copy_child",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
title: "Copy Child",
|
||||||
|
parent_id: "copy_root",
|
||||||
|
sort_order: 0,
|
||||||
|
is_starred: false,
|
||||||
|
access_scope: "private",
|
||||||
|
is_template: false,
|
||||||
|
created_at: "2026-04-18T00:10:00Z",
|
||||||
|
updated_at: "2026-04-18T00:10:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.documents.map((item) => item.id)).toEqual([
|
||||||
|
"root",
|
||||||
|
"child",
|
||||||
|
"copy_root",
|
||||||
|
"copy_child",
|
||||||
|
]);
|
||||||
|
expect(next.kernelSidebarProjection.items.map((item) => item.nodeId)).toEqual([
|
||||||
|
"root",
|
||||||
|
"child",
|
||||||
|
"copy_root",
|
||||||
|
"copy_child",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
next.kernelSidebarProjection.items.find((item) => item.nodeId === "copy_child"),
|
||||||
|
).toMatchObject({
|
||||||
|
parentNodeId: "copy_root",
|
||||||
|
depth: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("支持 move_document 细粒度更新父节点与排序字段", () => {
|
||||||
|
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||||
|
op: "move_document",
|
||||||
|
documentId: "child",
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
updatedAt: "2026-04-18T00:10:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.documents.find((item) => item.id === "child")).toMatchObject({
|
||||||
|
id: "child",
|
||||||
|
parent_id: null,
|
||||||
|
sort_order: 0,
|
||||||
|
updated_at: "2026-04-18T00:10:00Z",
|
||||||
|
});
|
||||||
|
expect(next.kernelSidebarProjection.items.map((item) => item.nodeId)).toEqual([
|
||||||
|
"root",
|
||||||
|
"child",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
next.kernelSidebarProjection.items.find((item) => item.nodeId === "child"),
|
||||||
|
).toMatchObject({
|
||||||
|
parentNodeId: null,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("支持 replace_sidebar 直接切换到 resync snapshot", () => {
|
it("支持 replace_sidebar 直接切换到 resync snapshot", () => {
|
||||||
const next = applyTreeStreamDelta(baseSidebarData, {
|
const next = applyTreeStreamDelta(baseSidebarData, {
|
||||||
op: "replace_sidebar",
|
op: "replace_sidebar",
|
||||||
@@ -478,4 +556,47 @@ describe("tree-stream/tree-delta", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("支持 upsert_assets 更新资源归属并重建 file_tree projection", () => {
|
||||||
|
const next = applyTreeStreamDeltaToProjectionState({
|
||||||
|
projection: "file_tree",
|
||||||
|
base: fileTreeProjectionBase,
|
||||||
|
event: {
|
||||||
|
op: "upsert_assets",
|
||||||
|
upsertAssets: [
|
||||||
|
{
|
||||||
|
id: "asset_pdf",
|
||||||
|
workspace_id: "ws_1",
|
||||||
|
document_id: "child",
|
||||||
|
asset_type: "file",
|
||||||
|
file_url: "/manual.pdf",
|
||||||
|
thumbnail_url: "/manual.pdf",
|
||||||
|
bucket: null,
|
||||||
|
storage_path: "documents/child/manual.pdf",
|
||||||
|
file_name: "manual.pdf",
|
||||||
|
file_size: 1024,
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
ocr_text: null,
|
||||||
|
ocr_status: null,
|
||||||
|
created_at: "2026-04-18T00:00:00Z",
|
||||||
|
updated_at: "2026-04-26T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.sidebar.mediaAssets?.find((asset) => asset.id === "asset_pdf")).toMatchObject({
|
||||||
|
document_id: "child",
|
||||||
|
updated_at: "2026-04-26T00:00:00Z",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
next.fileTreeItems.find((item) => item.resourceMeta.assetId === "asset_pdf"),
|
||||||
|
).toMatchObject({
|
||||||
|
parentNodeId: "child",
|
||||||
|
resourceMeta: expect.objectContaining({
|
||||||
|
documentId: "child",
|
||||||
|
resourceKind: "pdf",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export type TreeStreamDocumentPatch =
|
|||||||
export type TreeStreamDeltaOp =
|
export type TreeStreamDeltaOp =
|
||||||
| "noop"
|
| "noop"
|
||||||
| "upsert_document"
|
| "upsert_document"
|
||||||
|
| "upsert_documents"
|
||||||
|
| "upsert_assets"
|
||||||
| "remove_document"
|
| "remove_document"
|
||||||
|
| "move_document"
|
||||||
| "replace_documents"
|
| "replace_documents"
|
||||||
| "replace_sidebar";
|
| "replace_sidebar";
|
||||||
|
|
||||||
@@ -33,6 +36,11 @@ export type TreeStreamDeltaEvent = {
|
|||||||
node?: TreeStreamDocumentPatch | null;
|
node?: TreeStreamDocumentPatch | null;
|
||||||
document?: TreeStreamDocumentPatch | null;
|
document?: TreeStreamDocumentPatch | null;
|
||||||
documentId?: string | null;
|
documentId?: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
upsertDocuments?: TreeStreamDocumentPatch[] | null;
|
||||||
|
upsertAssets?: MediaAsset[] | null;
|
||||||
documents?: DocumentRecord[] | null;
|
documents?: DocumentRecord[] | null;
|
||||||
sidebar?: SidebarDatasetListQueryResult | SidebarInitialData | null;
|
sidebar?: SidebarDatasetListQueryResult | SidebarInitialData | null;
|
||||||
};
|
};
|
||||||
@@ -161,6 +169,42 @@ function isCompleteDocumentRecord(value: TreeStreamDocumentPatch): value is Docu
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCompleteMediaAsset(value: unknown): value is MediaAsset {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const asset = value as MediaAsset;
|
||||||
|
return (
|
||||||
|
typeof asset.id === "string" &&
|
||||||
|
typeof asset.workspace_id === "string" &&
|
||||||
|
typeof asset.document_id === "string" &&
|
||||||
|
typeof asset.asset_type === "string" &&
|
||||||
|
typeof asset.created_at === "string" &&
|
||||||
|
typeof asset.updated_at === "string" &&
|
||||||
|
"file_name" in value &&
|
||||||
|
"file_url" in value &&
|
||||||
|
"thumbnail_url" in value &&
|
||||||
|
"file_size" in value &&
|
||||||
|
"mime_type" in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSidebarFromAssets(input: {
|
||||||
|
base: SidebarInitialData;
|
||||||
|
mediaAssets: MediaAsset[];
|
||||||
|
}): SidebarInitialData {
|
||||||
|
const next = cloneSidebarData(input.base);
|
||||||
|
next.mediaAssets = [...input.mediaAssets];
|
||||||
|
next.kernelFileTreeProjection = buildKernelFileTreeProjection({
|
||||||
|
documents: next.documents,
|
||||||
|
mediaAssets: next.mediaAssets,
|
||||||
|
mindmapAssets: next.mindmapAssets,
|
||||||
|
tableAssets: next.tableAssets,
|
||||||
|
mindmapAssetChildren: next.mindmapAssetChildren,
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export function deriveTreeRendererDeltaState(input: {
|
export function deriveTreeRendererDeltaState(input: {
|
||||||
projection: TreeRendererProjection;
|
projection: TreeRendererProjection;
|
||||||
sidebar: SidebarInitialData;
|
sidebar: SidebarInitialData;
|
||||||
@@ -230,6 +274,45 @@ export function applyTreeStreamDelta(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.op === "upsert_documents") {
|
||||||
|
const patches = Array.isArray(event.upsertDocuments) ? event.upsertDocuments : [];
|
||||||
|
if (patches.length === 0) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const nextDocuments = [...base.documents];
|
||||||
|
for (const rawPatch of patches) {
|
||||||
|
if (!rawPatch || typeof rawPatch.id !== "string" || !rawPatch.id.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const documentPatch = {
|
||||||
|
...rawPatch,
|
||||||
|
id: rawPatch.id.trim(),
|
||||||
|
} as TreeStreamDocumentPatch;
|
||||||
|
const existingIndex = nextDocuments.findIndex((item) => item.id === documentPatch.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
nextDocuments[existingIndex] = {
|
||||||
|
...nextDocuments[existingIndex],
|
||||||
|
...documentPatch,
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isCompleteDocumentRecord(documentPatch)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextDocuments.push(documentPatch);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!changed) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return buildSidebarFromDocuments({
|
||||||
|
base,
|
||||||
|
documents: nextDocuments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (event.op === "remove_document") {
|
if (event.op === "remove_document") {
|
||||||
const documentId = normalizeDocumentId(event);
|
const documentId = normalizeDocumentId(event);
|
||||||
if (!documentId) {
|
if (!documentId) {
|
||||||
@@ -252,6 +335,66 @@ export function applyTreeStreamDelta(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.op === "move_document") {
|
||||||
|
const documentId = normalizeDocumentId(event);
|
||||||
|
if (!documentId) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const existingIndex = base.documents.findIndex((item) => item.id === documentId);
|
||||||
|
if (existingIndex < 0) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const nextDocuments = [...base.documents];
|
||||||
|
const existing = nextDocuments[existingIndex]!;
|
||||||
|
nextDocuments[existingIndex] = {
|
||||||
|
...existing,
|
||||||
|
parent_id: "parentId" in event ? (event.parentId ?? null) : existing.parent_id,
|
||||||
|
sort_order:
|
||||||
|
typeof event.sortOrder === "number" && Number.isFinite(event.sortOrder)
|
||||||
|
? event.sortOrder
|
||||||
|
: existing.sort_order,
|
||||||
|
updated_at:
|
||||||
|
typeof event.updatedAt === "string" && event.updatedAt.trim()
|
||||||
|
? event.updatedAt.trim()
|
||||||
|
: existing.updated_at,
|
||||||
|
};
|
||||||
|
return buildSidebarFromDocuments({
|
||||||
|
base,
|
||||||
|
documents: nextDocuments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.op === "upsert_assets") {
|
||||||
|
const assets = Array.isArray(event.upsertAssets) ? event.upsertAssets : [];
|
||||||
|
if (assets.length === 0) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const nextAssets = [...(base.mediaAssets ?? [])];
|
||||||
|
for (const asset of assets) {
|
||||||
|
if (!isCompleteMediaAsset(asset)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existingIndex = nextAssets.findIndex((item) => item.id === asset.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
nextAssets[existingIndex] = {
|
||||||
|
...nextAssets[existingIndex],
|
||||||
|
...asset,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
nextAssets.push(asset);
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!changed) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return buildSidebarFromAssets({
|
||||||
|
base,
|
||||||
|
mediaAssets: nextAssets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user