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
@@ -391,6 +391,15 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
) {
strip_tree_artifact_fields(&mut args);
}
if matches!(
plan.command_name.as_str(),
"tree.resource.archive"
| "tree.resource.restore"
| "tree.resource.rename"
| "tree.resource.purge"
) {
args = convex_resource_lifecycle_args_for_plan(plan, &args);
}
if matches!(plan.command_name.as_str(), "mindmaps.put")
|| matches!(plan.function_name.as_str(), "mindmaps:put")
{
@@ -433,6 +442,69 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
args
}
fn convex_resource_lifecycle_args_for_plan(
plan: &RuntimeCommandExecutionPlan,
args: &Value,
) -> Value {
let lifecycle = args.get("resourceLifecyclePlan").and_then(Value::as_object);
let action = lifecycle
.and_then(|value| value.get("action"))
.and_then(Value::as_str)
.unwrap_or("");
let resource_kind = lifecycle
.and_then(|value| value.get("resourceKind"))
.and_then(Value::as_str)
.unwrap_or("file");
if resource_kind != "file" {
return args.clone();
}
let id = args
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let user_id = args
.get("userId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(plan.actor_id.as_str())
.to_string();
match action {
"archive" => json!({
"userId": user_id,
"id": id,
"patch": {
"deleted_at": now_iso_like(),
"deleted_by": user_id,
"purged_at": null,
},
}),
"restore" => json!({
"userId": user_id,
"id": id,
"patch": {
"deleted_at": null,
"deleted_by": null,
"purged_at": null,
},
}),
"rename" => json!({
"userId": user_id,
"id": id,
"patch": {
"file_name": args.get("newName").and_then(Value::as_str).unwrap_or_default(),
},
}),
"purge" => json!({
"userId": user_id,
"id": id,
"expiredDeletedAt": "2126-01-01T00:00:00Z",
}),
_ => args.clone(),
}
}
fn strip_tree_artifact_fields(args: &mut Value) {
if let Value::Object(map) = args {
map.remove("streamDeltaHint");
@@ -996,6 +1068,42 @@ mod tests {
);
}
#[test]
fn convex_resource_lifecycle_args_keep_effective_user_id() {
let plan = RuntimeCommandExecutionPlan {
command_name: "tree.resource.archive".into(),
command_id: "cmd_resource_archive_1".into(),
function_name: "mediaAssets:patchById".into(),
workspace_id: Some("ws_1".into()),
request_id: "req_1".into(),
trace_id: "trace_1".into(),
actor_id: "anonymous".into(),
idempotency_key: None,
source: json!({}),
payload_json: "{}".into(),
args_json: json!({
"id": "asset_1",
"userId": "convex_user_1",
"resourceKind": "file",
"resourceLifecyclePlan": {
"resourceKind": "file",
"action": "archive",
"assetId": "asset_1"
},
"streamDeltaHint": {"family": "tree", "kind": "remove_asset"},
"domainEventHint": {"eventType": "tree.resource.archived"},
"domainEventPlan": {"eventType": "tree.resource.archived"},
}),
};
let args = convex_command_args_for_plan(&plan);
assert_eq!(args["userId"], "convex_user_1");
assert_eq!(args["id"], "asset_1");
assert!(args.get("resourceLifecyclePlan").is_none());
assert!(args.get("streamDeltaHint").is_none());
}
#[test]
fn convex_command_args_adapts_mindmap_command_apply_for_legacy_mutation() {
let plan = RuntimeCommandExecutionPlan {