Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/utopia-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ pub struct Document {
pub external_key: Option<String>,
/// watch_folder 同步时发现源文件已消失(默认保留文档,仅标记)
pub missing_since: Option<DateTime<Utc>>,
/// 墓碑(#268):删除是认知轴上的一个事件。行、分块、证据、原始文件都留着,
/// 只是不再算活的;撤销、同步撞见、同内容重传都能把它复活
pub deleted_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
Expand Down Expand Up @@ -799,6 +802,8 @@ pub struct EvidenceView {
pub doc_version: i32,
/// 文档已有更新的版本(证据停留在旧版;不代表事实失效)
pub stale: bool,
/// 这条证据所在的文档已被删除(#268)。事实若还活着,是因为它另有出处
pub document_deleted: bool,
}

/// 时态冲突(S3 自动闭合拿不准的那些):旧事实 vs 新事实,Review 页人裁。
Expand Down
69 changes: 68 additions & 1 deletion crates/utopia-server/src/api/documents_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,26 +223,93 @@ pub async fn delete(
let doc = utopia_store::documents::get(&state.pool, id).await?;
utopia_store::access::require_kb(&state.pool, &user, doc.kb_id, Role::Editor).await?;

utopia_store::documents::delete(&state.pool, id).await?;
// 墓碑,不是减法(#268):文档、分块、证据、原始文件都留着;只作废没有别的出处的事实
let report = utopia_store::documents::delete(&state.pool, doc.kb_id, id, Some(user.id)).await?;
let search = state.search.clone();
let did = id.to_string();
tokio::task::spawn_blocking(move || search.delete_document(&did))
.await
.map_err(|e| AppError::Other(e.into()))?
.map_err(AppError::Other)?;
// 前提作废了,靠它推出来的派生随之失效——不等下一次定时重推
settle_derivations(&state, doc.kb_id).await?;
let _ = utopia_store::audit::record(
&state.pool,
Some(doc.kb_id),
user.id,
"document.deleted",
"document",
Some(id),
json!({
"filename": doc.filename,
"deletion_id": report.deletion_id,
"invalidated_facts": report.invalidated_facts,
}),
)
.await;
state.emit_document(doc.kb_id, id);
Ok(Json(json!({
"ok": true,
"deletion_id": report.deletion_id,
"invalidated_facts": report.invalidated_facts,
})))
}

/// 撤销删除:文档、分块、这次作废的事实原路复活,索引重建。
/// 同步撞见墓碑与同内容重传走的是同一个 store 函数,这里只是人按的那一条路
pub async fn restore(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let doc = utopia_store::documents::get(&state.pool, id).await?;
utopia_store::access::require_kb(&state.pool, &user, doc.kb_id, Role::Editor).await?;
let doc = utopia_store::documents::restore(&state.pool, doc.kb_id, id).await?;
reindex(&state, &doc).await?;
settle_derivations(&state, doc.kb_id).await?;
let _ = utopia_store::audit::record(
&state.pool,
Some(doc.kb_id),
user.id,
"document.restored",
"document",
Some(id),
json!({ "filename": doc.filename }),
)
.await;
state.emit_document(doc.kb_id, id);
Ok(Json(json!({ "ok": true })))
}

/// 复活的文档回到全文索引:分块的正文一直都在,只是重写一遍索引条目
pub async fn reindex(state: &AppState, doc: &Document) -> utopia_core::AppResult<()> {
let chunks =
utopia_store::documents::chunks_in_document(&state.pool, doc.kb_id, doc.id).await?;
let pairs: Vec<(String, String)> = chunks
.into_iter()
.map(|c| (c.id.to_string(), c.text))
.collect();
let search = state.search.clone();
let (kb, did) = (doc.kb_id.to_string(), doc.id.to_string());
tokio::task::spawn_blocking(move || search.reindex_document(&kb, &did, &pairs))
.await
.map_err(|e| AppError::Other(e.into()))?
.map_err(AppError::Other)?;
Ok(())
}

/// 前提变了就重推一遍,让派生跟上——开关关着的库不推。删除、撤销、同步复活三条路共用
pub(crate) async fn settle_derivations(
state: &AppState,
kb_id: Uuid,
) -> utopia_core::AppResult<()> {
let kb = utopia_store::kbs::get(&state.pool, kb_id).await?;
if kb.materialize_inferences {
utopia_store::reasoning::materialize(&state.pool, kb_id).await?;
}
Ok(())
}

/// 重新处理(解析器升级/失败重试)。
pub async fn reprocess(
State(state): State<AppState>,
Expand Down
4 changes: 3 additions & 1 deletion crates/utopia-server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ mod alerts_routes;
mod auth_routes;
mod chat;
mod datasource_routes;
mod documents_routes;
pub(crate) mod documents_routes;
mod events_routes;
mod graph_routes;
mod jobs_routes;
Expand Down Expand Up @@ -276,6 +276,8 @@ pub fn router(state: AppState, cfg: &AppConfig) -> Router {
"/documents/{id}",
get(documents_routes::detail).delete(documents_routes::delete),
)
// 撤销删除(#268):删除是墓碑,所以有得撤
.route("/documents/{id}/restore", post(documents_routes::restore))
.route("/documents/{id}/extract", post(graph_routes::extract))
.route("/kbs/{id}/graph/overview", get(graph_routes::overview))
.route(
Expand Down
2 changes: 1 addition & 1 deletion crates/utopia-server/src/api/sources_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ pub async fn cleanup_missing(
source_in_kb(&state, kb_id, source_id).await?;
let ids = utopia_store::documents::list_missing(&state.pool, source_id).await?;
for id in &ids {
utopia_store::documents::delete(&state.pool, *id).await?;
utopia_store::documents::delete(&state.pool, kb_id, *id, Some(user.id)).await?;
let search = state.search.clone();
let did = id.to_string();
tokio::task::spawn_blocking(move || search.delete_document(&did))
Expand Down
23 changes: 23 additions & 0 deletions crates/utopia-server/src/ingest_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,30 @@ pub async fn ingest_item(
}

if let Some(doc) = existing {
// 同步撞见墓碑:源头还有这篇,本地删除对同步源本来就是暂时的——复活它,
// 分块与这次作废的事实原路回来,索引重建(#268)
let revived = doc.deleted_at.is_some();
if revived {
let doc = utopia_store::documents::restore(&state.pool, kb_id, doc.id).await?;
crate::api::documents_routes::reindex(state, &doc).await?;
crate::api::documents_routes::settle_derivations(state, kb_id).await?;
// 引擎做的,没有人:actor 留空,台账写「engine」
let _ = utopia_store::audit::record_opt(
&state.pool,
Some(kb_id),
None,
"document.restored",
"document",
Some(doc.id),
serde_json::json!({ "filename": doc.filename, "via": "sync" }),
)
.await;
}
if doc.sha256 == sha256 {
if revived {
state.emit_document(kb_id, doc.id);
return Ok(IngestAction::Updated);
}
return Ok(IngestAction::Unchanged);
}
// 变更:原地替换,旧版本入 document_versions(blob 内容寻址不删,回放有料)
Expand Down
3 changes: 2 additions & 1 deletion crates/utopia-store/src/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@ pub async fn my_kb_infos(
m.role AS member_role,
m.created_at AS joined_at,
inviter.display_name AS added_by_name,
(SELECT count(*) FROM documents d WHERE d.kb_id = k.id) AS doc_count,
(SELECT count(*) FROM documents d
WHERE d.kb_id = k.id AND d.deleted_at IS NULL) AS doc_count,
(SELECT count(*) FROM kb_members mm WHERE mm.kb_id = k.id) AS member_count
FROM knowledge_bases k
LEFT JOIN kb_members m ON m.kb_id = k.id AND m.user_id = $2
Expand Down
Loading