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
2 changes: 1 addition & 1 deletion crates/utopia-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ struct ManifestDataDir {
/// not a side effect of a code change.
// 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current`
// 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71
const CURRENT_SCHEMA_VERSION: u32 = 94;
const CURRENT_SCHEMA_VERSION: u32 = 95;

fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
Expand Down
62 changes: 62 additions & 0 deletions crates/utopia-server/src/time_attestation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,68 @@ async fn section_dates_reconcile_values_materialized_before_attestation() -> any
run
}

/// 每条证据记下它那一节的日期和名字,没说日期的那一节记成空;文档再解析时一条日期都没说,
/// 上一遍记下的清掉。删文档之后按还在的证据重算见证(`documents::reattest_tx`)读的就是这两列
#[tokio::test]
async fn each_piece_of_evidence_records_the_date_its_section_speaks_from() -> anyhow::Result<()> {
let Some(fixture) = fixture().await? else {
return Ok(());
};
let pool = &fixture.state.pool;
let run = async {
let (earlier, _) = chapter(&fixture, 0, "Earlier", "desk", None).await?;
let (later, _) = chapter(&fixture, 1, "Later", "shelf", None).await?;
let (appendix, _) = chapter(&fixture, 2, "Appendix", "floor", None).await?;
let context: DocumentDating = serde_json::from_value(json!({"entries": [
{"kind":"now","name":"Report date","words":"2015-01-01",
"from":{"year":2015,"month":1,"day":1},"scope":["Earlier"]},
{"kind":"now","name":"","words":"2026-01-01",
"from":{"year":2026,"month":1,"day":1},"scope":["Later"]}
]}))?;
let doc = utopia_store::documents::get(pool, fixture.doc).await?;
attest_statements(&fixture.state, &doc, &context).await?;
type Witness = (Option<DateTime<Utc>>, Option<String>);
let witness = |statement: Uuid| async move {
sqlx::query_as::<_, Witness>(
"SELECT attested_at, attested_by FROM fact_evidence WHERE fact_id=$1",
)
.bind(statement)
.fetch_one(pool)
.await
};
assert_eq!(
witness(earlier).await?,
(Some(day(2015)), Some("Report date 2015-01-01".into()))
);
assert_eq!(
witness(later).await?,
(Some(day(2026)), Some("2026-01-01".into()))
);
assert_eq!(
witness(appendix).await?,
(None, None),
"no date governs this section"
);

let silent: DocumentDating = serde_json::from_value(json!({"entries": []}))?;
attest_statements(&fixture.state, &doc, &silent).await?;
for statement in [earlier, later, appendix] {
assert_eq!(
witness(statement).await?,
(None, None),
"the document now states no date"
);
}
anyhow::Ok(())
}
.await;
sqlx::query("DELETE FROM organizations WHERE id=$1")
.bind(fixture.org)
.execute(pool)
.await?;
run
}

#[tokio::test]
async fn current_context_does_not_reattest_superseded_chunks() -> anyhow::Result<()> {
let Some(fixture) = fixture().await? else {
Expand Down
46 changes: 32 additions & 14 deletions crates/utopia-server/src/time_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -652,12 +652,20 @@ async fn attest_statements(
doc: &utopia_core::models::Document,
context: &DocumentDating,
) -> anyhow::Result<()> {
let pool = &state.pool;
if !context.entries.iter().any(|e| e.kind == "now") {
// 这一遍文档一条日期都没说:上一遍记在证据上的也不算数了
sqlx::query(
"UPDATE fact_evidence SET attested_at = NULL, attested_by = NULL
WHERE document_id = $1 AND attested_at IS NOT NULL",
)
.bind(doc.id)
.execute(pool)
.await?;
return Ok(());
}
let pool = &state.pool;
let rows: Vec<(Uuid, String, Option<i32>)> = sqlx::query_as(
"SELECT fe.fact_id, c.text, fe.quote_start
let rows: Vec<(Uuid, Uuid, String, Option<i32>)> = sqlx::query_as(
"SELECT fe.fact_id, fe.chunk_id, c.text, fe.quote_start
FROM fact_evidence fe
JOIN facts f ON f.id = fe.fact_id
JOIN chunks c ON c.id = fe.chunk_id
Expand All @@ -668,25 +676,35 @@ async fn attest_statements(
.fetch_all(pool)
.await?;
let mut moved = 0usize;
for (fact_id, text, start) in rows {
for (fact_id, chunk_id, text, start) in rows {
let path = headings_at(&text, usize::try_from(start.unwrap_or(0)).unwrap_or(0));
let Some(now) = now_in_force(&context.entries, &path) else {
continue;
};
let Some((at, _)) = parts_to_time(&now.from) else {
let witness = now_in_force(&context.entries, &path).and_then(|now| {
let (at, _) = parts_to_time(&now.from)?;
let by = if now.name.is_empty() {
now.words.clone()
} else {
format!("{} {}", now.name, now.words)
};
Some((at, by))
});
// 每条证据记下自己这一节的日期(没说的记成空):事实上只留最早的那一个,证据所在
// 的文档删了之后要照还在的证据重算,得知道每一条各是哪天
utopia_store::graph::witness_evidence(
pool,
fact_id,
chunk_id,
witness.as_ref().map(|(at, by)| (*at, by.as_str())),
)
.await?;
let Some((at, by)) = witness else {
continue;
};
let by = if now.name.is_empty() {
now.words.clone()
} else {
format!("{} {}", now.name, now.words)
};
if utopia_store::graph::attest_statement(pool, fact_id, at, &by).await? {
moved += 1;
}
}
// 已经物化出来的、规则算出来的类型化行跟着它们的陈述走
let typed = utopia_store::materialize::sync_typed_attestation(pool, doc.kb_id).await?;
let typed = utopia_store::materialize::sync_typed_attestation(pool, doc.kb_id, None).await?;
// 见证动了的行换了它在唯一性时间线上的位置:只把这些行所在的时间线重算一遍,不是整个库
// ——每篇文档都把全库的时间线各开一个事务重算一遍,库一大就是每篇几千个事务。
// 先提交同步,再拿时间线锁,不在持有 UPDATE 行锁时等咨询锁(与物化提交后对账的顺序一致)
Expand Down
56 changes: 47 additions & 9 deletions crates/utopia-store/src/documents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1003,7 +1003,7 @@ pub async fn delete(
.await?;
let chunk_ids: Vec<Uuid> = chunks.into_iter().map(|(c,)| c).collect();
let fact_ids: Vec<Uuid> = facts.into_iter().map(|(f,)| f).collect();
reattest_tx(&mut tx, &cited).await?;
reattest_tx(&mut tx, kb_id, &cited).await?;
crate::temporal::tidy_timelines_tx(&mut tx, kb_id, &timelines).await?;
let deletion_id = Uuid::now_v7();
sqlx::query(
Expand All @@ -1029,16 +1029,52 @@ pub async fn delete(
})
}

/// 证据文档变了(删了一篇、撤销了删除),把这些事实的「最早证据日期」按还在的文档重算。
/// 证据文档变了(删了一篇、撤销了删除),把这些事实的见证按还在的证据重算。
///
/// 读路径拿它当没起点的事实从哪天起成立(`facts_holds_from`),引擎排时间线用的是同一个
/// 日期(`temporal::DATED_AT`)。删掉最早那份文档之后引擎的锚点挪到了第二份,这里不跟着
/// 挪的话,前任读到新锚点为止、它却还从旧日期读起,两段叠了一年(#679 第四轮评审)。
/// 一份带日期的文档都不剩的,保留原值——那是别的来源(人写的)给的日期
async fn reattest_tx(tx: &mut Transaction<'_, Postgres>, fact_ids: &[Uuid]) -> AppResult<()> {
/// 读路径拿见证当没起点的事实从哪天起成立(`facts_holds_from`),引擎排时间线用的是同一个
/// 日期(`temporal::DATED_AT`)。删掉最早那份文档之后,见证不跟着挪到还在的证据上的话,
/// 前任读到新锚点为止、它却还从旧日期读起,两段叠了一年(#679 第四轮评审)。
///
/// 三种行,三个来处(0064 决定 3、5;0022 修订 2026-10-03):
///
/// - **开放陈述**:还在的证据里最早的那一条,连同那条日期的名字。每条证据记着它那段原文
/// 说话的那一刻(`fact_evidence.attested_at`,时间解析写下的章节日期);没记的(这一列
/// 之前写下的、那篇文档还没再解析过的)按文档自己的日期读,口径同从前的 `DATED_AT`。
/// 还在的证据一条带日期的都没有,见证就是空——删掉的那篇文档的日期不再给它作证;
/// - **跟着陈述走的类型化行**(物化出来的、规则算出来的):来源陈述里最早的那个见证,
/// 和物化时同一句(`materialize::sync_typed_attestation`)。它们抄了陈述的证据,所以也在
/// 这批里,时间线已经由调用方锁上;
/// - **其余**(人写的、这套分层之前写下的):还在的文档里最早的文档日期;一份带日期的
/// 文档都不剩的保留原值——那是别的来源(人写的)给的日期
async fn reattest_tx(
tx: &mut Transaction<'_, Postgres>,
kb_id: Uuid,
fact_ids: &[Uuid],
) -> AppResult<()> {
if fact_ids.is_empty() {
return Ok(());
}
sqlx::query(
"UPDATE facts f SET attested_from = w.at, attested_by = w.by
FROM (SELECT DISTINCT ON (fe.fact_id) fe.fact_id,
COALESCE(fe.attested_at, own.at) AS at,
CASE WHEN fe.attested_at IS NOT NULL THEN fe.attested_by END AS by
FROM fact_evidence fe
JOIN documents d ON d.id = fe.document_id
LEFT JOIN document_versions v ON v.document_id = fe.document_id
AND v.version = fe.doc_version
CROSS JOIN LATERAL (
SELECT CASE WHEN d.doc_time_source IN ('content', 'source')
THEN COALESCE(v.doc_time, d.doc_time) END AS at) own
WHERE fe.fact_id = ANY($1) AND d.deleted_at IS NULL
ORDER BY fe.fact_id, COALESCE(fe.attested_at, own.at) NULLS LAST, fe.chunk_id) w
WHERE f.id = w.fact_id AND f.layer = 'open' AND f.invalidated_at IS NULL
AND (f.attested_from IS DISTINCT FROM w.at OR f.attested_by IS DISTINCT FROM w.by)",
)
.bind(fact_ids)
.execute(&mut **tx)
.await?;
crate::materialize::sync_typed_attestation(&mut **tx, kb_id, Some(fact_ids)).await?;
sqlx::query(
"UPDATE facts f SET attested_from = e.first
FROM (SELECT fe.fact_id, min(d.doc_time) AS first
Expand All @@ -1047,7 +1083,9 @@ async fn reattest_tx(tx: &mut Transaction<'_, Postgres>, fact_ids: &[Uuid]) -> A
JOIN documents d ON d.id = c.document_id
WHERE fe.fact_id = ANY($1) AND d.deleted_at IS NULL AND d.doc_time IS NOT NULL
GROUP BY fe.fact_id) e
WHERE f.id = e.fact_id AND f.invalidated_at IS NULL
WHERE f.id = e.fact_id AND f.invalidated_at IS NULL AND f.layer <> 'open'
AND NOT (f.implied
OR EXISTS (SELECT 1 FROM typed_fact_sources ts WHERE ts.fact_id = f.id))
AND f.attested_from IS DISTINCT FROM e.first",
)
.bind(fact_ids)
Expand Down Expand Up @@ -1194,7 +1232,7 @@ async fn restore_tx(
.execute(&mut **tx)
.await?;
let touched: Vec<Uuid> = cited.iter().chain(&restored).copied().collect();
reattest_tx(tx, &touched).await?;
reattest_tx(tx, kb_id, &touched).await?;
crate::temporal::tidy_timelines_tx(tx, kb_id, &timelines).await?;
sqlx::query("UPDATE document_deletions SET reverted_at = now() WHERE id = $1")
.bind(deletion_id)
Expand Down
26 changes: 26 additions & 0 deletions crates/utopia-store/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,32 @@ pub async fn attest_statement(
Ok(moved.rows_affected() > 0)
}

/// 一条证据记下它那段原文说话的那一刻,连同那条日期的名字;那一节没说日期的记成空。
///
/// [`attest_statement`] 只把事实的见证往早挪,哪份证据给的、别的证据各是哪天都不留。证据
/// 所在的文档删了、删除撤销了,事实的见证要照还在的证据重算(`documents::reattest_tx`),
/// 靠的就是这里记下的每一条
pub async fn witness_evidence(
pool: &PgPool,
fact_id: Uuid,
chunk_id: Uuid,
witness: Option<(chrono::DateTime<chrono::Utc>, &str)>,
) -> AppResult<()> {
let (at, by) = witness.unzip();
sqlx::query(
"UPDATE fact_evidence SET attested_at = $3, attested_by = $4
WHERE fact_id = $1 AND chunk_id = $2
AND (attested_at IS DISTINCT FROM $3 OR attested_by IS DISTINCT FROM $4)",
)
.bind(fact_id)
.bind(chunk_id)
.bind(at)
.bind(by)
.execute(pool)
.await?;
Ok(())
}

/// 字面值宾语的事实(object_value 通道,问数映射首个消费者)。
/// 去重:同 (S,P) 且 object_value 完全相等的 live 事实只存一条。
#[allow(clippy::too_many_arguments)]
Expand Down
11 changes: 9 additions & 2 deletions crates/utopia-store/src/materialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ async fn materialize_in_tx(
// 用的写入口在陈述没有见证时填的是此刻(那是给人写的事实用的缺省),这里改回陈述的。
// 排在 3a 之后:规则算出的行也是这样写下的
// 见证动了的已有行和新写下的行一起,提交后对账:见证是时间线排序用的时刻
written.extend(sync_typed_attestation(&mut **tx, kb_id).await?);
written.extend(sync_typed_attestation(&mut **tx, kb_id, None).await?);
Ok((
Outcome {
retired,
Expand All @@ -445,9 +445,14 @@ async fn materialize_in_tx(
/// 回见证动了的那些行。见证是唯一性时间线给没起点的行排序用的时刻(`temporal::DATED_AT`),
/// 所以调用方提交之后要拿这些行去对账(`temporal::reconcile_moved_facts`)——只对这些行
/// 所在的时间线,不是整个库
///
/// 作废了的来源陈述不作证:物化自己会先把它们从来源里清掉,删文档那条路
/// (`documents::reattest_tx`)却是在物化之前跑的,陈述刚随文档作废、来源表还没动。
/// `only`:只动这些行(删文档时只动引用了那篇文档的,它们的时间线已经锁上);`None` 是整个库
pub async fn sync_typed_attestation<'e>(
ex: impl sqlx::Executor<'e, Database = sqlx::Postgres>,
kb_id: Uuid,
only: Option<&[Uuid]>,
) -> AppResult<Vec<Uuid>> {
let moved = sqlx::query_scalar(
"UPDATE facts t
Expand All @@ -463,17 +468,19 @@ pub async fn sync_typed_attestation<'e>(
SELECT i.fact_id, i.statement_id
FROM implied_fact_sources i
JOIN implication_rules r ON r.id = i.rule_id AND r.kb_id = $1) u
LEFT JOIN facts s ON s.id = u.statement_id
LEFT JOIN facts s ON s.id = u.statement_id AND s.invalidated_at IS NULL
GROUP BY u.fact_id) src
WHERE t.id = src.fact_id AND t.kb_id = $1 AND t.layer = 'typed'
AND t.invalidated_at IS NULL
AND ($2::uuid[] IS NULL OR t.id = ANY($2))
AND (t.implied
OR EXISTS (SELECT 1 FROM typed_fact_sources ts WHERE ts.fact_id = t.id))
AND (t.attested_from IS DISTINCT FROM src.at
OR t.attested_by IS DISTINCT FROM src.by)
RETURNING t.id",
)
.bind(kb_id)
.bind(only)
.fetch_all(ex)
.await?;
Ok(moved)
Expand Down
Loading
Loading