4-26 树rust-2

This commit is contained in:
lix-2026
2026-04-26 04:29:23 +08:00
parent 94631f3636
commit 338bb2e20f
58 changed files with 11718 additions and 1256 deletions
@@ -13,6 +13,15 @@ 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 {
@@ -21,6 +30,8 @@ pub struct StreamSnapshotQuery {
pub depth: Option<u32>,
pub cursor: Option<String>,
pub limit: Option<u32>,
pub poll_ms: Option<u64>,
pub max_polls: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -29,6 +40,19 @@ pub enum StreamSnapshotScope {
Subtree,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamChangeKind {
Delta,
Resync,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StreamChange {
pub kind: StreamChangeKind,
pub cursor: Option<String>,
pub delta: Option<Value>,
}
impl StreamSnapshotScope {
pub fn as_str(self) -> &'static str {
match self {
@@ -36,6 +60,19 @@ impl StreamSnapshotScope {
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 {
@@ -52,6 +89,15 @@ pub fn resolve_stream_scope(query: &StreamSnapshotQuery) -> StreamSnapshotScope
}
}
fn normalize_root_node_id(query: &StreamSnapshotQuery) -> Option<String> {
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,
@@ -72,6 +118,246 @@ fn workspace_overview_query(
}
}
fn is_record(value: &Value) -> bool {
value.is_object()
}
fn read_string_field(value: &Value, keys: &[&str]) -> Option<String> {
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<Value>> {
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<String> {
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<String> {
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<String> {
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<DecodedStreamCursor> {
let parsed = serde_json::from_str::<Value>(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<String> {
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<Value>, 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 {
(rows.to_vec(), !rows.is_empty())
}
}
fn read_command_payload_delta(row: &Value) -> Option<Value> {
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")))?;
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
}
pub fn resolve_stream_change(
overview: &Value,
previous_cursor: Option<&str>,
) -> Option<StreamChange> {
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());
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),
});
}
}
Some(StreamChange {
kind: StreamChangeKind::Resync,
cursor: next_cursor,
delta: None,
})
}
pub fn read_stream_cursor_from_payload(payload: &Value) -> Option<String> {
read_string_field(payload, &["cursor"])
}
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_delta_payload(
context: &RequestContext,
query: &StreamSnapshotQuery,
workspace_id: &str,
overview: &Value,
cursor: Option<String>,
delta: Value,
) -> Value {
let scope = resolve_stream_scope(query);
json!({
"kind": "delta",
"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": Value::Null,
"overview": overview,
})
}
pub async fn load_stream_overview(
config: &AppConfig,
context: &RequestContext,
query: &StreamSnapshotQuery,
) -> Result<(String, Value), WebError> {
let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在");
let overview = execute_runtime_query_via_convex(
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,
@@ -102,17 +388,13 @@ pub async fn load_stream_snapshot(
})
}
StreamSnapshotScope::Subtree => {
let root_node_id = query
.root_node_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
let root_node_id = normalize_root_node_id(query)
.expect("subtree scope 已确保 rootNodeId 存在");
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),
subtree_query(&effective_workspace_id, &root_node_id, query.depth),
dataset.clone(),
)?;
@@ -131,15 +413,20 @@ pub async fn load_stream_snapshot(
)
.await
.ok();
let cursor = resolve_stream_cursor(overview.as_ref(), query.cursor.as_deref());
Ok(json!({
"kind": "snapshot",
"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": query.root_node_id,
"rootNodeId": normalize_root_node_id(query),
"depth": query.depth,
"data": snapshot,
"snapshot": snapshot,
"overview": overview,
}))
@@ -147,7 +434,11 @@ pub async fn load_stream_snapshot(
#[cfg(test)]
mod tests {
use super::{resolve_stream_scope, StreamSnapshotQuery, StreamSnapshotScope};
use super::{
resolve_stream_change, resolve_stream_cursor, resolve_stream_scope,
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
};
use serde_json::json;
#[test]
fn stream_scope_defaults_to_workspace() {
@@ -167,4 +458,130 @@ mod tests {
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": [
{
"command_id": "cmd_2",
"created_at": "2026-04-25T10:00:02Z",
"command_name": "tree.node.archive",
"payload": {
"streamDelta": {
"op": "remove_document",
"documentId": "page_2"
}
}
},
{
"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":"cmd_1"}"#),
)
.expect("应识别到变化");
assert_eq!(change.kind, StreamChangeKind::Delta);
assert_eq!(
change.cursor,
Some(r#"{"createdAt":"2026-04-25T10:00:02Z","id":"cmd_2"}"#.into())
);
assert_eq!(
change.delta,
Some(json!({
"op": "remove_document",
"documentId": "page_2"
}))
);
}
#[test]
fn stream_change_detects_noop_delta_for_non_tree_mutating_command() {
let overview = json!({
"command_logs": [
{
"command_id": "cmd_2",
"created_at": "2026-04-25T10:00:02Z",
"command_name": "page.body.save",
"payload": {
"documentId": "page_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":"cmd_1"}"#),
)
.expect("应识别到变化");
assert_eq!(change.kind, StreamChangeKind::Delta);
assert_eq!(change.delta, Some(json!({ "op": "noop" })));
}
#[test]
fn stream_change_falls_back_to_resync_when_delta_is_unstable() {
let overview = json!({
"command_logs": [
{
"command_id": "cmd_2",
"created_at": "2026-04-25T10:00:02Z",
"command_name": "tree.subtree.move",
"payload": {
"documentId": "page_2"
}
},
{
"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":"cmd_1"}"#),
)
.expect("应识别到变化");
assert_eq!(change.kind, StreamChangeKind::Resync);
assert_eq!(change.delta, None);
}
}