use crate::app::AppConfig; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::{ execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id, }; use crate::routes::snapshot_support::{ execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, ProjectionSnapshotSpec, }; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; use serde_json::{json, Value}; const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [ "page.body.save", "page.layout.updateOptions", "documents.stats.update", "blocks.patch", "blocks.move", "blocks.embed", ]; #[derive(Debug, Clone, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct StreamSnapshotQuery { pub workspace_id: Option, pub root_node_id: Option, pub depth: Option, pub cursor: Option, pub limit: Option, pub poll_ms: Option, pub max_polls: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StreamSnapshotScope { Workspace, Subtree, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum StreamChangeKind { Delta, Resync, } #[derive(Debug, Clone, PartialEq)] pub struct StreamChange { pub kind: StreamChangeKind, pub cursor: Option, pub delta: Option, } impl StreamSnapshotScope { pub fn as_str(self) -> &'static str { match self { Self::Workspace => "workspace", Self::Subtree => "subtree", } } pub fn projection(self) -> &'static str { match self { Self::Workspace => "sidebar_tree", Self::Subtree => "page_tree", } } } #[derive(Debug, Clone, PartialEq, Eq)] struct DecodedStreamCursor { created_at: String, id: String, } pub fn resolve_stream_scope(query: &StreamSnapshotQuery) -> StreamSnapshotScope { if query .root_node_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .is_some() { StreamSnapshotScope::Subtree } else { StreamSnapshotScope::Workspace } } fn normalize_root_node_id(query: &StreamSnapshotQuery) -> Option { query .root_node_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn workspace_overview_query( workspace_id: &str, query: &StreamSnapshotQuery, ) -> RuntimeQueryEnvelopeWire { RuntimeQueryEnvelopeWire { name: "bridge.workspace.overview".into(), payload: json!({ "workspaceId": workspace_id, "cursor": query.cursor, "limit": query.limit.unwrap_or(20), "commandStatus": Value::Null, "eventStatus": Value::Null, "targetPageId": Value::Null, "targetBlockId": Value::Null, "aggregateType": Value::Null, "aggregateId": query.root_node_id, }), } } fn is_record(value: &Value) -> bool { value.is_object() } fn read_string_field(value: &Value, keys: &[&str]) -> Option { let map = value.as_object()?; for key in keys { let candidate = map .get(*key) .and_then(Value::as_str) .map(str::trim) .unwrap_or(""); if !candidate.is_empty() { return Some(candidate.to_string()); } } None } fn read_array_field<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Vec> { let map = value.as_object()?; for key in keys { if let Some(items) = map.get(*key).and_then(Value::as_array) { return Some(items); } } None } fn encode_stream_cursor(id: &str, created_at: &str) -> Option { let id = id.trim(); let created_at = created_at.trim(); if id.is_empty() || created_at.is_empty() { return None; } Some( json!({ "createdAt": created_at, "id": id, }) .to_string(), ) } fn encode_command_cursor(row: &Value) -> Option { let id = read_string_field(row, &["id", "command_id", "commandId"])?; let created_at = read_string_field( row, &["created_at", "createdAt", "finished_at", "finishedAt"], )?; encode_stream_cursor(&id, &created_at) } fn encode_domain_event_cursor(row: &Value) -> Option { let id = read_string_field(row, &["event_id", "eventId", "id"])?; let created_at = read_string_field( row, &["created_at", "createdAt", "finished_at", "finishedAt"], )?; encode_stream_cursor(&format!("domain_event:{id}"), &created_at) } fn decode_stream_cursor(raw: &str) -> Option { let parsed = serde_json::from_str::(raw).ok()?; Some(DecodedStreamCursor { created_at: read_string_field(&parsed, &["createdAt"])?, id: read_string_field(&parsed, &["id"])?, }) } pub fn resolve_stream_cursor(overview: Option<&Value>, fallback: Option<&str>) -> Option { let fallback = fallback .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let Some(overview) = overview else { return fallback; }; let command_cursor = read_array_field(overview, &["command_logs", "commandLogs"]) .and_then(|rows| rows.first()) .and_then(encode_command_cursor); let domain_event_cursor = read_array_field(overview, &["domain_events", "domainEvents"]) .and_then(|rows| rows.first()) .and_then(encode_domain_event_cursor); match (command_cursor, domain_event_cursor) { (None, None) => fallback, (Some(cursor), None) => Some(cursor), (None, Some(cursor)) => Some(cursor), (Some(command_cursor), Some(domain_event_cursor)) => { let decoded_command = decode_stream_cursor(&command_cursor); let decoded_domain_event = decode_stream_cursor(&domain_event_cursor); match (decoded_command, decoded_domain_event) { (Some(command), Some(event)) => { if event.created_at > command.created_at { Some(domain_event_cursor) } else { Some(command_cursor) } } (Some(_), None) => Some(command_cursor), (None, Some(_)) => Some(domain_event_cursor), (None, None) => fallback, } } } } fn collect_new_command_logs(rows: &[Value], previous_cursor: Option<&str>) -> (Vec, bool) { let Some(previous_cursor) = previous_cursor.and_then(decode_stream_cursor) else { return (rows.to_vec(), false); }; let previous_index = rows.iter().position(|row| { let id = read_string_field(row, &["id", "command_id", "commandId"]).unwrap_or_default(); let created_at = read_string_field( row, &["created_at", "createdAt", "finished_at", "finishedAt"], ) .unwrap_or_default(); id == previous_cursor.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::>(); if newer_rows.len() < rows.len() { return (newer_rows, false); } (rows.to_vec(), !rows.is_empty()) } } fn collect_new_domain_events(rows: &[Value], previous_cursor: Option<&str>) -> (Vec, 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::>(); if newer_rows.len() < rows.len() { return (newer_rows, false); } (rows.to_vec(), !rows.is_empty()) } } fn read_stream_delta_candidate(candidate: &Value) -> Option { 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 { let command_name = read_string_field(row, &["command_name", "commandName"]).unwrap_or_default(); if TREE_STREAM_NOOP_COMMANDS.contains(&command_name.as_str()) { return Some(json!({ "op": "noop" })); } 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_domain_event_payload_delta(row: &Value) -> Option { 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 { read_string_field(row, &["command_id", "commandId", "id"]) } fn read_domain_event_command_id(row: &Value) -> Option { 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 { 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 } } pub fn resolve_stream_change( overview: &Value, previous_cursor: Option<&str>, ) -> Option { let next_cursor = resolve_stream_cursor(Some(overview), previous_cursor); let previous_cursor = previous_cursor .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); if next_cursor == previous_cursor { return None; } 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 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 let Some(delta) = read_command_payload_delta(&new_rows[0]) { return Some(StreamChange { kind: StreamChangeKind::Delta, cursor: next_cursor, delta: Some(delta), }); } } 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 { kind: StreamChangeKind::Resync, cursor: next_cursor, delta: None, }) } pub fn read_stream_cursor_from_payload(payload: &Value) -> Option { read_string_field(payload, &["cursor"]) } fn attach_workspace_dataset_projections( mut dataset: Value, sidebar_projection: Value, file_tree_projection: Value, ) -> Value { let Some(map) = dataset.as_object_mut() else { return dataset; }; map.insert( "kernel_sidebar_projection".into(), sidebar_projection.clone(), ); map.insert("kernelSidebarProjection".into(), sidebar_projection); map.insert( "kernel_file_tree_projection".into(), file_tree_projection.clone(), ); map.insert("kernelFileTreeProjection".into(), file_tree_projection); dataset } async fn load_workspace_stream_snapshot( config: &AppConfig, context: &RequestContext, workspace_id: &str, depth: Option, ) -> Result { let sidebar_loaded = load_projection_snapshot( config, context, &ProjectionSnapshotSpec { workspace_id, root_node_id: None, depth, query: None, max_results: None, projection: KernelProjectionKind::SidebarTree, }, ) .await?; let file_tree_loaded = load_projection_snapshot( config, context, &ProjectionSnapshotSpec { workspace_id, root_node_id: None, depth, query: None, max_results: None, projection: KernelProjectionKind::FileTree, }, ) .await?; let dataset = attach_workspace_dataset_projections( sidebar_loaded.dataset, sidebar_loaded.projection.clone(), file_tree_loaded.projection, ); Ok(json!({ "dataset": dataset, "tree": sidebar_loaded.projection, })) } fn delta_document_patch_is_title_only(candidate: &Value) -> bool { let Some(map) = candidate.as_object() else { return false; }; map.keys().all(|key| { matches!( key.as_str(), "id" | "documentId" | "title" | "updatedAt" | "updated_at" ) }) } fn delta_requires_projection_snapshot(delta: &Value) -> bool { let Some(map) = delta.as_object() else { return false; }; let op = map .get("op") .and_then(Value::as_str) .map(str::trim) .unwrap_or(""); if op.is_empty() || op == "noop" { return false; } if op == "upsert_document" { return !map .get("node") .or_else(|| map.get("document")) .is_some_and(delta_document_patch_is_title_only); } if op == "upsert_documents" { let Some(items) = map .get("upsertDocuments") .or_else(|| map.get("upsert_documents")) .and_then(Value::as_array) else { return true; }; return !items.iter().all(delta_document_patch_is_title_only); } true } pub fn with_stream_kind(payload: &Value, kind: &str) -> Value { if let Some(mut map) = payload.as_object().cloned() { map.insert("kind".into(), Value::String(kind.into())); return Value::Object(map); } json!({ "kind": kind, "data": payload, }) } pub fn build_stream_push_delta_hint(payload: &Value, request_id: &str, trace_id: &str) -> Value { json!({ "kind": "delta", "hint": "command_committed", "commandName": payload.get("commandName"), "commandId": payload.get("commandId"), "workspaceId": payload.get("workspaceId"), "requestId": request_id, "traceId": trace_id, }) } pub async fn build_stream_delta_payload( config: &AppConfig, context: &RequestContext, query: &StreamSnapshotQuery, workspace_id: &str, overview: &Value, cursor: Option, delta: Value, ) -> Result { let scope = resolve_stream_scope(query); let snapshot = if scope == StreamSnapshotScope::Workspace && delta_requires_projection_snapshot(&delta) { load_workspace_stream_snapshot(config, context, workspace_id, query.depth).await? } else { Value::Null }; Ok(json!({ "kind": "delta", "revision": cursor.clone().unwrap_or_else(|| "0".into()), "stream": scope.as_str(), "projection": scope.projection(), "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "workspaceId": workspace_id, "rootNodeId": normalize_root_node_id(query), "depth": query.depth, "cursor": cursor, "data": delta, "snapshot": snapshot, "overview": overview, })) } pub async fn load_stream_overview( config: &AppConfig, context: &RequestContext, query: &StreamSnapshotQuery, ) -> Result<(String, Value), WebError> { // resolve_effective_workspace_id(require=true) 在缺失时已返回 Err,不再用 expect panic。 let effective_workspace_id = resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?.ok_or_else( || { WebError::bad_request_code( "workspace_required", "缺少 workspaceId,请在 query 或请求头中提供有效工作区", ) .with_context(context) }, )?; let overview = execute_runtime_query_via_legacy_cloud( config, context, Some(&effective_workspace_id), workspace_overview_query(&effective_workspace_id, query), ) .await?; Ok((effective_workspace_id, overview)) } pub async fn load_stream_snapshot( config: &AppConfig, context: &RequestContext, query: &StreamSnapshotQuery, ) -> Result { let effective_workspace_id = resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?.ok_or_else( || { WebError::bad_request_code( "workspace_required", "缺少 workspaceId,请在 query 或请求头中提供有效工作区", ) .with_context(context) }, )?; let scope = resolve_stream_scope(query); let snapshot = match scope { StreamSnapshotScope::Workspace => { load_workspace_stream_snapshot(config, context, &effective_workspace_id, query.depth) .await? } StreamSnapshotScope::Subtree => { let root_node_id = normalize_root_node_id(query).ok_or_else(|| { WebError::bad_request_code( "stream_root_node_required", "subtree 流需要 rootNodeId", ) .with_context(context) })?; let dataset = load_sidebar_dataset(config, context, &effective_workspace_id).await?; let tree = execute_kernel_query( context, &effective_workspace_id, subtree_query(&effective_workspace_id, &root_node_id, query.depth), dataset.clone(), )?; json!({ "dataset": dataset, "tree": tree, }) } }; let overview = execute_runtime_query_via_legacy_cloud( config, context, Some(&effective_workspace_id), workspace_overview_query(&effective_workspace_id, query), ) .await .ok(); let cursor = resolve_stream_cursor(overview.as_ref(), query.cursor.as_deref()); Ok(json!({ "kind": "snapshot", "revision": cursor.clone().unwrap_or_else(|| "0".into()), "scope": scope.as_str(), "stream": scope.as_str(), "projection": scope.projection(), "cursor": cursor, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "workspaceId": effective_workspace_id, "rootNodeId": normalize_root_node_id(query), "depth": query.depth, "data": snapshot, "snapshot": snapshot, "overview": overview, })) } #[cfg(test)] mod tests { use super::{ delta_requires_projection_snapshot, resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, }; use serde_json::json; #[test] fn stream_scope_defaults_to_workspace() { assert_eq!( resolve_stream_scope(&StreamSnapshotQuery::default()), StreamSnapshotScope::Workspace ); } #[test] fn stream_scope_switches_to_subtree_when_root_exists() { assert_eq!( resolve_stream_scope(&StreamSnapshotQuery { root_node_id: Some("page_root".into()), ..StreamSnapshotQuery::default() }), StreamSnapshotScope::Subtree ); } #[test] fn stream_cursor_prefers_newer_domain_event() { let overview = json!({ "command_logs": [ { "command_id": "cmd_1", "created_at": "2026-04-25T10:00:00Z" } ], "domain_events": [ { "event_id": "evt_2", "created_at": "2026-04-25T10:00:01Z" } ] }); assert_eq!( resolve_stream_cursor(Some(&overview), None), Some(r#"{"createdAt":"2026-04-25T10:00:01Z","id":"domain_event:evt_2"}"#.into()) ); } #[test] fn stream_change_detects_delta_from_single_new_command() { let overview = json!({ "command_logs": [ { "id": "clog_2", "command_id": "cmd_2", "created_at": "2026-04-25T10:00:02Z", "command_name": "tree.node.archive", "payload": { "streamDelta": { "op": "remove_document", "documentId": "page_2" } } }, { "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.cursor, Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"clog_2"}"#.into()) ); assert_eq!( change.delta, Some(json!({ "op": "remove_document", "documentId": "page_2" })) ); } #[test] fn stream_change_preserves_resync_required_delta_contract() { let overview = json!({ "command_logs": [ { "id": "clog_2", "command_id": "cmd_2", "created_at": "2026-04-25T10:00:02Z", "command_name": "documents.emptyTrashByWorkspace", "payload": { "streamDelta": { "op": "resync_required", "reason": "documents_empty_trash", "documentId": null, "blockId": null } } }, { "id": "clog_1", "command_id": "cmd_1", "created_at": "2026-04-25T10:00:01Z" } ], "domain_events": [ { "id": "evt_2", "event_id": "evt_2", "command_id": "cmd_2", "created_at": "2026-04-25T10:00:02Z", "payload": { "streamDelta": { "op": "resync_required", "reason": "documents_empty_trash", "documentId": null, "blockId": null } } }, { "id": "evt_1", "event_id": "evt_1", "command_id": "cmd_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("应识别到变化"); assert_eq!(change.kind, StreamChangeKind::Delta); assert_eq!( change.cursor, Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"clog_2"}"#.into()) ); assert_eq!( change.delta, Some(json!({ "op": "resync_required", "reason": "documents_empty_trash", "documentId": null, "blockId": null })) ); } #[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] fn stream_change_preserves_remove_asset_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.archive", "payload": { "streamDelta": { "op": "remove_asset", "assetId": "asset_1", "documentId": "doc_target", "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": "remove_asset", "assetId": "asset_1", "documentId": "doc_target", "updatedAt": "2026-04-25T10:00:02Z" })) ); } #[test] fn stream_change_detects_noop_delta_for_non_tree_mutating_command() { let overview = json!({ "command_logs": [ { "id": "clog_2", "command_id": "cmd_2", "created_at": "2026-04-25T10:00:02Z", "command_name": "page.body.save", "payload": { "documentId": "page_1" } }, { "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": "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] fn stream_change_falls_back_to_resync_when_delta_is_unstable() { 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": { "documentId": "page_2" } }, { "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::Resync); assert_eq!(change.delta, None); } #[test] fn title_only_delta_does_not_require_projection_snapshot() { assert!(!delta_requires_projection_snapshot(&json!({ "op": "upsert_document", "document": { "id": "page_1", "title": "新标题" } }))); assert!(!delta_requires_projection_snapshot(&json!({ "op": "upsert_documents", "upsertDocuments": [ { "id": "page_1", "title": "新标题" } ] }))); } #[test] fn structural_delta_requires_projection_snapshot() { assert!(delta_requires_projection_snapshot(&json!({ "op": "move_document", "documentId": "page_1", "parentId": "page_2" }))); assert!(delta_requires_projection_snapshot(&json!({ "op": "upsert_assets", "upsertAssets": [ { "id": "asset_1" } ] }))); assert!(delta_requires_projection_snapshot(&json!({ "op": "remove_asset", "assetId": "asset_1" }))); } }