feat: consolidate local-first mnote web runtime
This commit is contained in:
@@ -3,24 +3,25 @@ use crate::context::RequestContext;
|
||||
use crate::document_buffer_store::{self, BufferKey, BufferStore};
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
||||
execute_runtime_command_via_legacy_cloud,
|
||||
execute_runtime_command_via_legacy_cloud_with_artifacts,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
||||
execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -48,6 +49,17 @@ pub struct BufferStateQuery {
|
||||
pub relative_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BufferDirtyRequest {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub relative_path: Option<String>,
|
||||
pub content_hash: Option<String>,
|
||||
}
|
||||
|
||||
fn find_document_buffer_state(
|
||||
buffer_store: &BufferStore,
|
||||
query: &BufferStateQuery,
|
||||
@@ -104,6 +116,20 @@ fn find_document_buffer_state(
|
||||
None
|
||||
}
|
||||
|
||||
fn buffer_state_payload(buf: core_protocol::DocumentBuffer) -> Value {
|
||||
json!({
|
||||
"documentId": buf.workspace_path.object_identity.document_id,
|
||||
"workspaceId": buf.workspace_path.workspace_id,
|
||||
"dirtyState": format!("{:?}", buf.dirty_state),
|
||||
"fileVersion": buf.file_version,
|
||||
"baseContentHash": buf.base_content_hash,
|
||||
"currentContentHash": buf.current_content_hash,
|
||||
"externalActor": buf.external_actor,
|
||||
"lastLoadedAt": buf.last_loaded_at,
|
||||
"lastSavedAt": buf.last_saved_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentSaveRequest {
|
||||
@@ -114,6 +140,8 @@ pub struct DocumentSaveRequest {
|
||||
pub revision: Option<u64>,
|
||||
pub conflict_detection_key: Option<String>,
|
||||
pub expected_file_version: Option<String>,
|
||||
pub write_intent_id: Option<String>,
|
||||
pub save_operation_id: Option<String>,
|
||||
pub base_content_hash: Option<String>,
|
||||
pub content_format: Option<String>,
|
||||
pub editor_source: Option<String>,
|
||||
@@ -179,7 +207,9 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMa
|
||||
)
|
||||
}
|
||||
|
||||
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
|
||||
fn execution_artifacts_json(
|
||||
execution: &crate::transport::convex::RetiredCloudCommandExecution,
|
||||
) -> Value {
|
||||
execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
@@ -480,6 +510,8 @@ async fn proxy_next_documents_save(
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"expectedFileVersion": body.expected_file_version,
|
||||
"writeIntentId": body.write_intent_id,
|
||||
"saveOperationId": body.save_operation_id,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
@@ -522,7 +554,7 @@ pub async fn load_document_meta_result(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
fetch_documents_meta_via_convex(
|
||||
fetch_documents_meta_via_legacy_cloud(
|
||||
state.config(),
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -553,7 +585,7 @@ pub async fn load_document_content_result(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
execute_runtime_query_via_convex(
|
||||
execute_runtime_query_via_legacy_cloud(
|
||||
state.config(),
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -599,20 +631,7 @@ pub async fn buffer_state(
|
||||
let result = find_document_buffer_state(&state.buffer_store, &query);
|
||||
|
||||
match result {
|
||||
Some(buf) => Ok(ok_response(
|
||||
&context,
|
||||
json!({
|
||||
"documentId": buf.workspace_path.object_identity.document_id,
|
||||
"workspaceId": buf.workspace_path.workspace_id,
|
||||
"dirtyState": format!("{:?}", buf.dirty_state),
|
||||
"fileVersion": buf.file_version,
|
||||
"baseContentHash": buf.base_content_hash,
|
||||
"currentContentHash": buf.current_content_hash,
|
||||
"externalActor": buf.external_actor,
|
||||
"lastLoadedAt": buf.last_loaded_at,
|
||||
"lastSavedAt": buf.last_saved_at,
|
||||
}),
|
||||
)),
|
||||
Some(buf) => Ok(ok_response(&context, buffer_state_payload(buf))),
|
||||
None => Err(WebError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"buffer_state_not_found",
|
||||
@@ -622,6 +641,51 @@ pub async fn buffer_state(
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记指定打开文档的 BufferStore 状态为 Dirty。
|
||||
///
|
||||
/// 浏览器仍负责具体保存调度;Rust BufferStore 只承担跨 watcher、AI、保存冲突的统一打开态仲裁。
|
||||
pub async fn mark_buffer_dirty(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<BufferDirtyRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let document_id = body.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(WebError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"document_id_required",
|
||||
"documentId 不能为空",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let query = BufferStateQuery {
|
||||
document_id: Some(document_id.to_string()),
|
||||
workspace_id: body.workspace_id.clone(),
|
||||
source_kind: body.source_kind.clone(),
|
||||
root_uri: body.root_uri.clone(),
|
||||
relative_path: body.relative_path.clone(),
|
||||
};
|
||||
let existing = find_document_buffer_state(&state.buffer_store, &query).ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"buffer_state_not_found",
|
||||
"未找到该文档的 buffer 状态",
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let content_hash = body
|
||||
.content_hash
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("browser-dirty")
|
||||
.to_string();
|
||||
let marked = state
|
||||
.buffer_store
|
||||
.mark_dirty(&existing.workspace_path, content_hash)
|
||||
.unwrap_or(existing);
|
||||
Ok(ok_response(&context, buffer_state_payload(marked)))
|
||||
}
|
||||
|
||||
pub async fn page_body_write(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -690,6 +754,8 @@ pub async fn save(
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.to_string(),
|
||||
expected_file_version: expected_file_version.map(ToOwned::to_owned),
|
||||
write_intent_id: body.write_intent_id.clone(),
|
||||
save_operation_id: body.save_operation_id.clone(),
|
||||
base_content_hash: body.base_content_hash.clone(),
|
||||
content_format: body
|
||||
.content_format
|
||||
@@ -740,6 +806,8 @@ pub async fn save(
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"expectedFileVersion": body.expected_file_version,
|
||||
"writeIntentId": body.write_intent_id,
|
||||
"saveOperationId": body.save_operation_id,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
@@ -752,7 +820,7 @@ pub async fn save(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -815,7 +883,7 @@ pub async fn purge(
|
||||
validate_only: false,
|
||||
};
|
||||
let result =
|
||||
execute_runtime_command_via_convex(state.config(), &context, None, command).await?;
|
||||
execute_runtime_command_via_legacy_cloud(state.config(), &context, None, command).await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -865,7 +933,7 @@ pub async fn empty_trash(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
Some(workspace_id),
|
||||
@@ -968,13 +1036,14 @@ pub async fn title(
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web page title update".into()),
|
||||
refs: vec![body
|
||||
.command_name
|
||||
.unwrap_or_else(|| "page.head.updateTitle".into())],
|
||||
refs: vec![
|
||||
body.command_name
|
||||
.unwrap_or_else(|| "page.head.updateTitle".into()),
|
||||
],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -1072,13 +1141,14 @@ pub async fn options(
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web page layout update".into()),
|
||||
refs: vec![body
|
||||
.command_name
|
||||
.unwrap_or_else(|| "page.layout.updateOptions".into())],
|
||||
refs: vec![
|
||||
body.command_name
|
||||
.unwrap_or_else(|| "page.layout.updateOptions".into()),
|
||||
],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -1111,9 +1181,9 @@ pub async fn options(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::document_buffer_store::{BufferStore, build_local_folder_workspace_path};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
@@ -1445,8 +1515,7 @@ mod tests {
|
||||
"upsert_document"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]
|
||||
["title"],
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]["title"],
|
||||
"服务端页面(改名)"
|
||||
);
|
||||
assert_eq!(payload["meta"]["artifactError"], Value::Null);
|
||||
@@ -1727,15 +1796,19 @@ mod tests {
|
||||
payload["details"]["conflict"]["editorBaseVersion"].as_str(),
|
||||
Some(stale_file_version.as_str())
|
||||
);
|
||||
assert!(payload["details"]["conflict"]["currentDiskVersion"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.starts_with("local-md:local-md:README.md:"));
|
||||
assert!(payload["details"]["conflict"]["suggestedActions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|action| action.as_str() == Some("merge")));
|
||||
assert!(
|
||||
payload["details"]["conflict"]["currentDiskVersion"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.starts_with("local-md:local-md:README.md:")
|
||||
);
|
||||
assert!(
|
||||
payload["details"]["conflict"]["suggestedActions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|action| action.as_str() == Some("merge"))
|
||||
);
|
||||
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
assert!(markdown.contains("# External"));
|
||||
assert!(!markdown.contains("# Editor"));
|
||||
|
||||
Reference in New Issue
Block a user