feat(tree): checkpoint resource lifecycle work

提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。

不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
This commit is contained in:
lix-2026
2026-05-16 07:38:45 +08:00
parent 274779c0d8
commit 384da4e44c
85 changed files with 12570 additions and 394 deletions
@@ -82,6 +82,12 @@ pub struct DocumentPurgeRequest {
pub document_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentEmptyTrashRequest {
pub workspace_id: String,
}
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport";
@@ -651,6 +657,87 @@ pub async fn purge(
Ok(ok_response(&context, result))
}
pub async fn empty_trash(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentEmptyTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let workspace_id = body.workspace_id.trim();
if workspace_id.is_empty() {
return Err(
WebError::bad_request_code("workspace_id_required", "缺少有效 workspaceId")
.with_context(&context),
);
}
let command = RuntimeCommandEnvelopeWire {
name: "documents.emptyTrashByWorkspace".into(),
command_id: format!("documents_empty_trash_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: Some(workspace_id.to_string()),
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some(workspace_id.to_string()),
page_id: None,
block_id: None,
}),
payload: json!({
"workspaceId": workspace_id,
}),
preflight_data: None,
reason: Some("mnote-web documents empty trash".into()),
refs: vec!["mnote-web-documents-trash".into()],
dry_run: false,
validate_only: false,
};
let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(),
&context,
Some(workspace_id),
command,
)
.await?;
let artifacts = execution_artifacts_json(&execution);
let artifact_error = execution.artifact_error.clone();
let mut result = execution.result;
if let Value::Object(map) = &mut result {
map.insert(
"canonicalCommand".into(),
json!("documents.emptyTrashByWorkspace"),
);
map.insert("compatRoute".into(), json!("/api/documents/empty-trash"));
}
let mut headers = HeaderMap::new();
stamp_documents_headers(&mut headers);
Ok((
StatusCode::OK,
headers,
Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"owner": "mnote-web",
"meta": {
"commandName": "documents.emptyTrashByWorkspace",
"canonicalCommand": "documents.emptyTrashByWorkspace",
"artifacts": artifacts,
"artifactError": artifact_error,
},
"result": result,
})),
))
}
pub async fn title(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -926,6 +1013,10 @@ mod tests {
"revision": 8,
"conflict_detection_key": "doc_1:8"
},
"documents:emptyTrashByWorkspace": {
"ok": true,
"deletedCount": 2
},
"bridgeLogs:recordCommandLog": {
"ok": true,
"id": "clog_fixture"
@@ -1063,6 +1154,50 @@ mod tests {
assert_eq!(payload["result"]["canonicalCommand"], "page.body.save");
}
#[tokio::test]
async fn documents_empty_trash_route_executes_workspace_trash_purge() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/empty-trash")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"workspaceId": "ws_demo"
})
.to_string(),
))
.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");
assert_eq!(payload["result"]["deletedCount"], 2);
assert_eq!(
payload["result"]["canonicalCommand"],
"documents.emptyTrashByWorkspace"
);
assert_eq!(
payload["meta"]["artifacts"]["commandLog"]["commandName"],
"documents.emptyTrashByWorkspace"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["eventType"],
"tree.trash.documents.emptied"
);
assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["op"],
"resync_required"
);
assert_eq!(payload["meta"]["artifactError"], Value::Null);
}
#[tokio::test]
async fn documents_title_route_executes_page_head_update_title() {
let response = app()