From 4f5e2455692a7c90e863bb156a1c8b54c31f4235 Mon Sep 17 00:00:00 2001 From: Matt Beisser <225018+mbeisser1@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:49:12 -0400 Subject: [PATCH 1/7] test(import): the later backup decides a message whose part was unsent The #1924 tests ran the #1804 scenario without its unsent part: backup B carried no mark. The new test gives B the Unsent mark its unsent part brings, and checks every order, one import of both files in either order included: with B the later backup the message holds B's text, versions and mark and search finds neither copy's text; with the dates swapped it holds A's with no mark and search finds A's text; with no dates the version times pick A's text and B's mark adds, as #1801 left them. Part of #1741, #1804. Co-Authored-By: Claude Opus 5.5 --- .../src/imports_api/tests/backup_dates.rs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/crates/server/server/src/imports_api/tests/backup_dates.rs b/crates/server/server/src/imports_api/tests/backup_dates.rs index 4fc078adc..981b3ed8d 100644 --- a/crates/server/server/src/imports_api/tests/backup_dates.rs +++ b/crates/server/server/src/imports_api/tests/backup_dates.rs @@ -277,6 +277,97 @@ async fn the_later_backup_decides_the_text_whatever_the_version_times_say() { } } +/// How many messages a search of the message text for `word` finds in the +/// database at `db`. +async fn text_hits(db: &Path, word: &str) -> i64 { + let (_pool, mut conn) = open_verify(db).await; + sqlx::query_scalar("SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH $1") + .bind(word) + .fetch_one(&mut *conn) + .await + .unwrap() +} + +/// The scenario of #1804 as the issue gives it, unsent part and all. A +/// two-part message is sent at t0; part 1 is edited at t100 and t500; +/// backup A is made; part 1 is unsent at t600, which drops its versions +/// and marks the message Unsent; part 0 is edited at t700; backup B is +/// made. A lists part 1's [x@t0, y@t100] and no mark; B lists part 0's +/// [a@t0] and the Unsent mark. B's newest version is older than A's, so the +/// version times say A is later. +/// +/// With B the later backup, every order, one import of both files in +/// either order included, holds B's text, versions and mark, and search +/// finds neither copy's text. With the dates swapped, every order holds +/// A's text and versions with no mark, and search finds A's text. With no +/// dates, the version times decide the text as #1801 left them, and B's +/// mark adds. +#[tokio::test] +async fn the_later_backup_decides_an_unsent_part_whatever_the_version_times_say() { + let tmp = TempDir::new().unwrap(); + let t0 = 1_426_183_462_000; + let a_versions = [ + edit_version(1, "zqxylo", t0), + edit_version(1, "zqyarrow", t0 + 100_000), + ]; + let b_versions = [edit_version(0, "zqharbor", t0)]; + let a = |backup| Copy { + backup, + text: "zqharbor zqyonder", + versions: &a_versions, + deletion: None, + }; + let b = |backup| Copy { + backup, + text: "zqbeacon", + versions: &b_versions, + deletion: Some(Deletion::Unsent), + }; + let a_held = |deletion: Option<&str>, backup: Option<&str>| Held { + text: "zqharbor zqyonder".into(), + deletion: deletion.map(Into::into), + versions: vec![(1, "zqxylo".into()), (1, "zqyarrow".into())], + backup_taken_at: backup.map(Into::into), + }; + let orders = ["together", "together-reversed", "apart", "apart-reversed"]; + + let a_earlier = backup_file(tmp.path(), "a-earlier.jsonl", &a(Some(EARLIER_BACKUP))); + let b_later = backup_file(tmp.path(), "b-later.jsonl", &b(Some(LATER_BACKUP))); + let held = every_order(tmp.path(), "b-later", [&a_earlier, &b_later]).await; + for (held, order) in held.into_iter().zip(orders) { + assert_eq!( + held, + Held { + text: "zqbeacon".into(), + deletion: Some("unsent".into()), + versions: vec![(0, "zqharbor".into())], + backup_taken_at: Some(LATER_BACKUP_AT.into()), + }, + "{order}" + ); + let db = tmp.path().join(format!("b-later-{order}.db")); + assert_eq!(text_hits(&db, "zqbeacon").await, 0, "{order}"); + assert_eq!(text_hits(&db, "zqyonder").await, 0, "{order}"); + } + + let a_later = backup_file(tmp.path(), "a-later.jsonl", &a(Some(LATER_BACKUP))); + let b_earlier = backup_file(tmp.path(), "b-earlier.jsonl", &b(Some(EARLIER_BACKUP))); + let held = every_order(tmp.path(), "a-later", [&a_later, &b_earlier]).await; + for (held, order) in held.into_iter().zip(orders) { + assert_eq!(held, a_held(None, Some(LATER_BACKUP_AT)), "{order}"); + let db = tmp.path().join(format!("a-later-{order}.db")); + assert_eq!(text_hits(&db, "zqyonder").await, 1, "{order}"); + assert_eq!(text_hits(&db, "zqbeacon").await, 0, "{order}"); + } + + let a_undated = backup_file(tmp.path(), "a-undated.jsonl", &a(None)); + let b_undated = backup_file(tmp.path(), "b-undated.jsonl", &b(None)); + let held = every_order(tmp.path(), "undated", [&a_undated, &b_undated]).await; + for (held, order) in held.into_iter().zip(orders) { + assert_eq!(held, a_held(Some("unsent"), None), "{order}"); + } +} + /// An append of the older backup after the newer one changes nothing: /// not the mark, not the text, not the earlier versions, not the date, /// even though the older copy carries a mark and lists more versions. From 42be2b23dbf409ed00e1e8402b4bf986c085d8bd Mon Sep 17 00:00:00 2001 From: Matt Beisser <225018+mbeisser1@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:49:12 -0400 Subject: [PATCH 2/7] docs(changelog): the later backup decides a message's mark and text Two Fixes entries for the 0.11.0 Importing list: a mark is cleared by a newer backup that no longer has it, and the second file of one import no longer loses its mark (#1741); a newer backup that unsent a part gives the message its text whatever the edit times say (#1804). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7d2ba836..9147d3e44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,22 @@ released versions carry their date on the heading. #### Importing +- 2026-10-07: **A message recovered between two backups loses its Deleted + mark.** A message marked Deleted in the source app or Unsent kept the mark + after a newer backup that no longer marked it was imported, and when one + import held two backups of the same phone, the first file read decided the + mark and the other file's mark was lost. The newer backup now decides the + mark, mark or no mark, in one import or two and in either order. A backup + that records no date still only adds a mark and never clears one. + +- 2026-10-07: **A message unsent in part after the older backup takes the + newer backup's text.** When a newer backup of the same phone showed a part + of a message unsent and another part edited, the message kept the older + backup's text and earlier versions, because their edit times looked newer. + The newer backup now gives the message its text, earlier versions and + Unsent mark, whatever the edit times say. Between backups that record no + date, the copy with the newer edit still gives the text. + - 2026-10-07: **A message an import edits is no longer hidden behind a copy of its old text.** When an import set not to hide duplicates gave a stored message the text of a later edit, or added an attachment to it, the message From 03af31e048035a1c29824fafb472fde85672efec Mon Sep 17 00:00:00 2001 From: Matt Beisser <225018+mbeisser1@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:00:10 -0400 Subject: [PATCH 3/7] docs(changelog): cite #1741 and #1804 on the entry that fixed them The fix landed with #1966, whose Features entry already describes it. Drop the two Fixes bullets that repeated it under a later date. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9147d3e44..8a2440750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,8 @@ released versions carry their date on the heading. backup imported after a newer one changes nothing. Import details under Settings → Storage show the backup each import read and when it was made. Message files exported before they said when their backup was made are - refused, and the backup must be exported again with this build. + refused, and the backup must be exported again with this build (#1741, + #1804). - 2026-10-05: **A WhatsApp reply now names the message it quotes.** When the quoted message is in the same chat of the same backup, the reply is linked to it, as Apple Messages replies already were: a mail export threads @@ -132,22 +133,6 @@ released versions carry their date on the heading. #### Importing -- 2026-10-07: **A message recovered between two backups loses its Deleted - mark.** A message marked Deleted in the source app or Unsent kept the mark - after a newer backup that no longer marked it was imported, and when one - import held two backups of the same phone, the first file read decided the - mark and the other file's mark was lost. The newer backup now decides the - mark, mark or no mark, in one import or two and in either order. A backup - that records no date still only adds a mark and never clears one. - -- 2026-10-07: **A message unsent in part after the older backup takes the - newer backup's text.** When a newer backup of the same phone showed a part - of a message unsent and another part edited, the message kept the older - backup's text and earlier versions, because their edit times looked newer. - The newer backup now gives the message its text, earlier versions and - Unsent mark, whatever the edit times say. Between backups that record no - date, the copy with the newer edit still gives the text. - - 2026-10-07: **A message an import edits is no longer hidden behind a copy of its old text.** When an import set not to hide duplicates gave a stored message the text of a later edit, or added an attachment to it, the message From 28350fd1f63ceeca1580294b472a27695f776835 Mon Sep 17 00:00:00 2001 From: Matt Beisser <225018+mbeisser1@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:02:04 -0400 Subject: [PATCH 4/7] test(import): one #1804 scenario, checked in both search indexes Fold the unsent-part test into the_later_backup_decides_the_text_whatever_the_version_times_say: backup B now carries the Unsent mark, and the test checks search and the undated files there. It also counts earlier versions in message_versions_fts, which keeps them for an Unsent message, so a stale version of backup A or a missing one of backup B fails the test. every_order returns each order's name and database with what it holds, so no test copies its order names or database path. text_hits sits with the other helpers and replaces the inline count in the searchable test. Co-Authored-By: Claude Fable 5.1 --- .../src/imports_api/tests/backup_dates.rs | 188 ++++++++---------- 1 file changed, 81 insertions(+), 107 deletions(-) diff --git a/crates/server/server/src/imports_api/tests/backup_dates.rs b/crates/server/server/src/imports_api/tests/backup_dates.rs index 981b3ed8d..f57a067ce 100644 --- a/crates/server/server/src/imports_api/tests/backup_dates.rs +++ b/crates/server/server/src/imports_api/tests/backup_dates.rs @@ -75,6 +75,31 @@ async fn held(db: &Path) -> Held { } } +/// How many messages a search of the message text for `word` finds in the +/// database at `db`. +async fn text_hits(db: &Path, word: &str) -> i64 { + fts_hits(db, "messages_fts", word).await +} + +/// How many earlier versions a search for `word` finds in the database at +/// `db`. The index keeps them for an Unsent message too. +async fn version_hits(db: &Path, word: &str) -> i64 { + fts_hits(db, "message_versions_fts", word).await +} + +/// How many rows of the search index `table` match `word` in the database +/// at `db`. +async fn fts_hits(db: &Path, table: &str, word: &str) -> i64 { + let (_pool, mut conn) = open_verify(db).await; + sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {table} WHERE {table} MATCH $1" + )) + .bind(word) + .fetch_one(&mut *conn) + .await + .unwrap() +} + /// Import `files` into `db` in one import, appending to what it holds. async fn import(db: &Path, assets: &Path, root: &Path, files: &[PathBuf]) { import_jsonl_files(db, files, &edit_options(assets, root, false)) @@ -82,27 +107,39 @@ async fn import(db: &Path, assets: &Path, root: &Path, files: &[PathBuf]) { .unwrap(); } +/// One way of importing two files, and what it gave `g-backup`. +struct Imported { + /// The way, such as `apart-reversed`. + order: &'static str, + /// The database the files were imported into. + db: PathBuf, + held: Held, +} + /// What `files` give `g-backup`: imported in one import in the order /// given, in one import in the other order, one import after another in /// the order given, and one after another in the other order. The four -/// must agree, so each is returned to compare. -async fn every_order(tmp: &Path, label: &str, files: [&PathBuf; 2]) -> [Held; 4] { +/// must agree, so each is returned to compare, with its database to look +/// into further. +async fn every_order(tmp: &Path, label: &str, files: [&PathBuf; 2]) -> [Imported; 4] { let assets = tmp.join("assets"); let [a, b] = files; let mut out = Vec::new(); - for (name, batches) in [ + for (order, batches) in [ ("together", vec![vec![a.clone(), b.clone()]]), ("together-reversed", vec![vec![b.clone(), a.clone()]]), ("apart", vec![vec![a.clone()], vec![b.clone()]]), ("apart-reversed", vec![vec![b.clone()], vec![a.clone()]]), ] { - let db = tmp.join(format!("{label}-{name}.db")); + let db = tmp.join(format!("{label}-{order}.db")); for batch in batches { import(&db, &assets, tmp, &batch).await; } - out.push(held(&db).await); + let held = held(&db).await; + out.push(Imported { order, db, held }); } - out.try_into().unwrap() + out.try_into() + .unwrap_or_else(|_| unreachable!("four orders")) } /// The stored date of [`LATER_BACKUP`], in the form a timestamp takes. @@ -135,7 +172,7 @@ async fn the_later_backup_decides_the_deletion_mark_in_every_order() { Some(Deletion::DeletedInSourceApp), ); let recovered = file("recovered-later.jsonl", LATER_BACKUP, None); - for held in every_order(tmp.path(), "recovered", [&marked, &recovered]).await { + for Imported { held, .. } in every_order(tmp.path(), "recovered", [&marked, &recovered]).await { assert_eq!(held.deletion, None, "{held:?}"); assert_eq!(held.backup_taken_at.as_deref(), Some(LATER_BACKUP_AT)); } @@ -146,7 +183,7 @@ async fn the_later_backup_decides_the_deletion_mark_in_every_order() { Some(Deletion::DeletedInSourceApp), ); let unmarked = file("unmarked-earlier.jsonl", EARLIER_BACKUP, None); - for held in every_order(tmp.path(), "deleted", [&unmarked, &marked]).await { + for Imported { held, .. } in every_order(tmp.path(), "deleted", [&unmarked, &marked]).await { assert_eq!( held.deletion.as_deref(), Some("deleted_in_source_app"), @@ -181,18 +218,13 @@ async fn a_later_backup_that_clears_an_unsent_mark_makes_the_text_searchable() { Some(Deletion::Unsent), ); let shown = file("shown-later.jsonl", LATER_BACKUP, None); - for held in every_order(tmp.path(), "unsent", [&unsent, &shown]).await { - assert_eq!(held.deletion, None, "{held:?}"); - } - for name in ["together", "together-reversed", "apart", "apart-reversed"] { - let (_pool, mut conn) = open_verify(&tmp.path().join(format!("unsent-{name}.db"))).await; - let hits: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH 'zqlighthouse'", - ) - .fetch_one(&mut *conn) - .await - .unwrap(); - assert_eq!(hits, 1, "{name}: the text is searchable again"); + for Imported { order, db, held } in every_order(tmp.path(), "unsent", [&unsent, &shown]).await { + assert_eq!(held.deletion, None, "{order}: {held:?}"); + assert_eq!( + text_hits(&db, "zqlighthouse").await, + 1, + "{order}: the text is searchable again" + ); } } @@ -217,93 +249,28 @@ async fn equal_backup_dates_fall_back_to_the_rules_for_files_without_one() { }; let unmarked = file("unmarked-same.jsonl", None); let marked = file("marked-same.jsonl", Some(Deletion::Unsent)); - for held in every_order(tmp.path(), "same-date", [&unmarked, &marked]).await { + for Imported { held, .. } in every_order(tmp.path(), "same-date", [&unmarked, &marked]).await { assert_eq!(held.deletion.as_deref(), Some("unsent"), "{held:?}"); assert_eq!(held.backup_taken_at.as_deref(), Some(LATER_BACKUP_AT)); } } -/// The scenario of #1804: backup A lists part 1's earlier versions -/// [x@t0, y@t100]; backup B, made later, after part 1 was unsent (which -/// drops its versions) and part 0 was edited, lists part 0's [a@t0] only. -/// B's newest version is older than A's, so the version times say A is -/// later; the backups' dates say B, and B's text and versions are kept in -/// every order. With the dates swapped, A's are. -#[tokio::test] -async fn the_later_backup_decides_the_text_whatever_the_version_times_say() { - let tmp = TempDir::new().unwrap(); - let t0 = 1_426_183_462_000; - let a_versions = [edit_version(1, "x", t0), edit_version(1, "y", t0 + 100_000)]; - let b_versions = [edit_version(0, "a", t0)]; - let a = |backup| Copy { - backup: Some(backup), - text: "a z", - versions: &a_versions, - deletion: None, - }; - let b = |backup| Copy { - backup: Some(backup), - text: "b", - versions: &b_versions, - deletion: None, - }; - - let a_earlier = backup_file(tmp.path(), "a-earlier.jsonl", &a(EARLIER_BACKUP)); - let b_later = backup_file(tmp.path(), "b-later.jsonl", &b(LATER_BACKUP)); - for held in every_order(tmp.path(), "b-later", [&a_earlier, &b_later]).await { - assert_eq!( - held, - Held { - text: "b".into(), - deletion: None, - versions: vec![(0, "a".into())], - backup_taken_at: Some(LATER_BACKUP_AT.into()), - } - ); - } - - let a_later = backup_file(tmp.path(), "a-later.jsonl", &a(LATER_BACKUP)); - let b_earlier = backup_file(tmp.path(), "b-earlier.jsonl", &b(EARLIER_BACKUP)); - for held in every_order(tmp.path(), "a-later", [&a_later, &b_earlier]).await { - assert_eq!( - held, - Held { - text: "a z".into(), - deletion: None, - versions: vec![(1, "x".into()), (1, "y".into())], - backup_taken_at: Some(LATER_BACKUP_AT.into()), - } - ); - } -} - -/// How many messages a search of the message text for `word` finds in the -/// database at `db`. -async fn text_hits(db: &Path, word: &str) -> i64 { - let (_pool, mut conn) = open_verify(db).await; - sqlx::query_scalar("SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH $1") - .bind(word) - .fetch_one(&mut *conn) - .await - .unwrap() -} - -/// The scenario of #1804 as the issue gives it, unsent part and all. A -/// two-part message is sent at t0; part 1 is edited at t100 and t500; +/// The scenario of #1804. A two-part message is sent at t0; part 1 is +/// edited at t100 and again at t500, which gives it its current text; /// backup A is made; part 1 is unsent at t600, which drops its versions /// and marks the message Unsent; part 0 is edited at t700; backup B is /// made. A lists part 1's [x@t0, y@t100] and no mark; B lists part 0's -/// [a@t0] and the Unsent mark. B's newest version is older than A's, so the -/// version times say A is later. +/// [a@t0] and the Unsent mark. B's newest version is older than A's, so +/// the version times say A is later. /// /// With B the later backup, every order, one import of both files in -/// either order included, holds B's text, versions and mark, and search -/// finds neither copy's text. With the dates swapped, every order holds -/// A's text and versions with no mark, and search finds A's text. With no -/// dates, the version times decide the text as #1801 left them, and B's -/// mark adds. +/// either order included, holds B's text, versions and mark: search finds +/// neither copy's text, finds B's earlier version, and finds none of A's. +/// With the dates swapped, every order holds A's text and versions with no +/// mark, and search finds A's text. With no dates, the version times +/// decide the text as #1801 left them, and B's mark adds. #[tokio::test] -async fn the_later_backup_decides_an_unsent_part_whatever_the_version_times_say() { +async fn the_later_backup_decides_the_text_whatever_the_version_times_say() { let tmp = TempDir::new().unwrap(); let t0 = 1_426_183_462_000; let a_versions = [ @@ -329,12 +296,12 @@ async fn the_later_backup_decides_an_unsent_part_whatever_the_version_times_say( versions: vec![(1, "zqxylo".into()), (1, "zqyarrow".into())], backup_taken_at: backup.map(Into::into), }; - let orders = ["together", "together-reversed", "apart", "apart-reversed"]; let a_earlier = backup_file(tmp.path(), "a-earlier.jsonl", &a(Some(EARLIER_BACKUP))); let b_later = backup_file(tmp.path(), "b-later.jsonl", &b(Some(LATER_BACKUP))); - let held = every_order(tmp.path(), "b-later", [&a_earlier, &b_later]).await; - for (held, order) in held.into_iter().zip(orders) { + for Imported { order, db, held } in + every_order(tmp.path(), "b-later", [&a_earlier, &b_later]).await + { assert_eq!( held, Held { @@ -345,25 +312,28 @@ async fn the_later_backup_decides_an_unsent_part_whatever_the_version_times_say( }, "{order}" ); - let db = tmp.path().join(format!("b-later-{order}.db")); assert_eq!(text_hits(&db, "zqbeacon").await, 0, "{order}"); assert_eq!(text_hits(&db, "zqyonder").await, 0, "{order}"); + assert_eq!(version_hits(&db, "zqharbor").await, 1, "{order}"); + assert_eq!(version_hits(&db, "zqxylo").await, 0, "{order}"); + assert_eq!(version_hits(&db, "zqyarrow").await, 0, "{order}"); } let a_later = backup_file(tmp.path(), "a-later.jsonl", &a(Some(LATER_BACKUP))); let b_earlier = backup_file(tmp.path(), "b-earlier.jsonl", &b(Some(EARLIER_BACKUP))); - let held = every_order(tmp.path(), "a-later", [&a_later, &b_earlier]).await; - for (held, order) in held.into_iter().zip(orders) { + for Imported { order, db, held } in + every_order(tmp.path(), "a-later", [&a_later, &b_earlier]).await + { assert_eq!(held, a_held(None, Some(LATER_BACKUP_AT)), "{order}"); - let db = tmp.path().join(format!("a-later-{order}.db")); assert_eq!(text_hits(&db, "zqyonder").await, 1, "{order}"); assert_eq!(text_hits(&db, "zqbeacon").await, 0, "{order}"); } let a_undated = backup_file(tmp.path(), "a-undated.jsonl", &a(None)); let b_undated = backup_file(tmp.path(), "b-undated.jsonl", &b(None)); - let held = every_order(tmp.path(), "undated", [&a_undated, &b_undated]).await; - for (held, order) in held.into_iter().zip(orders) { + for Imported { order, held, .. } in + every_order(tmp.path(), "undated", [&a_undated, &b_undated]).await + { assert_eq!(held, a_held(Some("unsent"), None), "{order}"); } } @@ -438,7 +408,9 @@ async fn without_a_backup_date_marks_add_and_edits_compare_their_times() { ); // Undated against dated: the mark adds, and the copy with the later edit // keeps its text, whichever is the dated one. - for held in every_order(tmp.path(), "mixed", [&unmarked_undated, &marked_dated]).await { + for Imported { held, .. } in + every_order(tmp.path(), "mixed", [&unmarked_undated, &marked_dated]).await + { assert_eq!(held.deletion.as_deref(), Some("unsent"), "{held:?}"); assert_eq!(held.text, "see you at seven", "{held:?}"); assert_eq!(held.versions, vec![(0, "see you at six".into())]); @@ -456,7 +428,9 @@ async fn without_a_backup_date_marks_add_and_edits_compare_their_times() { deletion: Some(Deletion::DeletedInSourceApp), }, ); - for held in every_order(tmp.path(), "undated", [&unmarked_undated, &marked_undated]).await { + for Imported { held, .. } in + every_order(tmp.path(), "undated", [&unmarked_undated, &marked_undated]).await + { assert_eq!( held.deletion.as_deref(), Some("deleted_in_source_app"), From 6c966784aa0f355d9fb4edb4baf7424b5ce5e23c Mon Sep 17 00:00:00 2001 From: Matt Beisser <225018+mbeisser1@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:04:35 -0400 Subject: [PATCH 5/7] fix(import): a mixed import keeps the dated file's backup date in either order When one import held a dated file and an undated one, the staged message kept the date only when the dated file was read first. Read second, the dates could not decide, nothing set the date, and a later import fell back to adding marks: a backup made later without the mark left the message Unsent. The staged message now takes the dated copy's date when it has none. Co-Authored-By: Claude Fable 5.1 --- crates/server/server/src/db/staging.rs | 26 ++++++++++ .../server/server/src/imports_api/staging.rs | 9 +++- .../src/imports_api/tests/backup_dates.rs | 49 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/crates/server/server/src/db/staging.rs b/crates/server/server/src/db/staging.rs index 880f356b6..16b695c3c 100644 --- a/crates/server/server/src/db/staging.rs +++ b/crates/server/server/src/db/staging.rs @@ -632,6 +632,32 @@ pub async fn take_staged_copy_from_later_backup( Ok(()) } +/// Give the staged message `staged`, staged from a file without a backup +/// date, the date `backup_taken_at` of another copy of it from the same +/// import. The two dates cannot decide which copy is later ([`later_backup`]), +/// but the message keeps the one date it has whichever file was read first, +/// so a later import compares its backup with that date in either file +/// order. +/// +/// # Errors +/// +/// Returns an error when the update fails. +pub async fn add_staged_copy_backup_date( + conn: &mut SqliteConnection, + staged: i64, + backup_taken_at: &str, +) -> Result<()> { + sqlx::query( + "UPDATE staging_messages SET backup_taken_at = $1 \ + WHERE id = $2 AND backup_taken_at IS NULL", + ) + .bind(backup_taken_at) + .bind(staged) + .execute(&mut *conn) + .await?; + Ok(()) +} + /// Give the staged message `staged` the mark `deletion` of another copy of /// it from the same import, when the two backups' dates cannot decide /// ([`later_backup`]): a copy that carries a mark adds it, and one with diff --git a/crates/server/server/src/imports_api/staging.rs b/crates/server/server/src/imports_api/staging.rs index e46d14d44..3d8370e07 100644 --- a/crates/server/server/src/imports_api/staging.rs +++ b/crates/server/server/src/imports_api/staging.rs @@ -912,8 +912,10 @@ async fn flush_staging_message_chunk( /// versions and mark, mark or no mark, and one from an earlier backup gives /// neither (#1741, #1804). When either has no date, or the two dates are /// equal ([`db_staging::later_backup`]), the copy gives its text and earlier -/// versions when it records a later edit, and its mark when it carries one. -/// A copy at the staged message's time that has milliseconds marks it +/// versions when it records a later edit, and its mark when it carries one; +/// a dated copy gives a staged message without a date its date +/// ([`db_staging::add_staged_copy_backup_date`]), so a later import compares +/// with that date whichever file came first. A copy at the staged message's time that has milliseconds marks it /// `milliseconds` ([`db_staging::add_staged_copy_milliseconds`]). One import /// of two backups then stores what two separate imports of them store, in /// either file order (#1806, #1837). @@ -962,6 +964,9 @@ async fn add_staged_copy( } BackupOrder::Earlier => {} BackupOrder::Undecided => { + if let (None, Some(copy_backup)) = (&held_backup, staged_source.backup_taken_at) { + db_staging::add_staged_copy_backup_date(tx, staged, copy_backup).await?; + } if !row.msg.earlier_versions.is_empty() { db_staging::take_later_staged_copy( tx, diff --git a/crates/server/server/src/imports_api/tests/backup_dates.rs b/crates/server/server/src/imports_api/tests/backup_dates.rs index f57a067ce..3c03832b0 100644 --- a/crates/server/server/src/imports_api/tests/backup_dates.rs +++ b/crates/server/server/src/imports_api/tests/backup_dates.rs @@ -440,6 +440,55 @@ async fn without_a_backup_date_marks_add_and_edits_compare_their_times() { } } +/// One import holds a dated file and an undated one: the staged message +/// keeps the dated file's date in either file order, so a later import +/// compares against it. Import 1 holds backup A, dated earlier with no +/// mark, and a file without a date that marks the message Unsent, so the +/// mark adds. Import 2 is backup C, made later, with no mark: it clears +/// the mark whichever file import 1 read first. +#[tokio::test] +async fn one_import_of_a_dated_and_an_undated_file_keeps_the_date_in_either_order() { + let tmp = TempDir::new().unwrap(); + let assets = tmp.path().join("assets"); + let file = |name: &str, backup: Option, deletion: Option| { + backup_file( + tmp.path(), + name, + &Copy { + backup, + text: "back again", + versions: &[], + deletion, + }, + ) + }; + let dated = file("a-earlier.jsonl", Some(EARLIER_BACKUP), None); + let undated = file("b-undated.jsonl", None, Some(Deletion::Unsent)); + let later = file("c-later.jsonl", Some(LATER_BACKUP), None); + for (name, first) in [ + ("dated-first.db", [dated.clone(), undated.clone()]), + ("undated-first.db", [undated.clone(), dated.clone()]), + ] { + let db = tmp.path().join(name); + import(&db, &assets, tmp.path(), &first).await; + let held_first = held(&db).await; + assert_eq!(held_first.deletion.as_deref(), Some("unsent"), "{name}"); + assert_eq!( + held_first.backup_taken_at.as_deref(), + Some(EARLIER_BACKUP_AT), + "{name}" + ); + import(&db, &assets, tmp.path(), std::slice::from_ref(&later)).await; + let held_after = held(&db).await; + assert_eq!(held_after.deletion, None, "{name}"); + assert_eq!( + held_after.backup_taken_at.as_deref(), + Some(LATER_BACKUP_AT), + "{name}" + ); + } +} + /// Two backups in one import give the message the later backup's text, and /// so the later backup's duplicate flag: with another source holding the /// later text, the dedupe hides one of the two, in either file order, and From 75218e2c930fb138b957481dc0dc1ae7226e4802 Mon Sep 17 00:00:00 2001 From: Matt Beisser <225018+mbeisser1@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:04:47 -0400 Subject: [PATCH 6/7] docs(import): later_edit_sql is the fallback for undated copies Its doc comment still called it the one rule for which copy is the later backup, which later_backup_sql now is. Also part later_backup_sql's two paragraphs, which ran together. Co-Authored-By: Claude Fable 5.1 --- crates/server/server/src/db/staging.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/server/server/src/db/staging.rs b/crates/server/server/src/db/staging.rs index 16b695c3c..3fd4e3c04 100644 --- a/crates/server/server/src/db/staging.rs +++ b/crates/server/server/src/db/staging.rs @@ -1278,6 +1278,7 @@ pub async fn write_message_map( /// copies agree, or two reads of one Mac's `chat.db` that Messages did /// not write between, where it adds a mark and takes a later edit as it /// would with no dates. +/// /// The one rule for which of two copies of a message from one source is /// the later backup, for a stored message ([`promote_deletion_marks`], /// [`write_edit_map`]) and, in Rust ([`later_backup`]), for two copies @@ -1448,9 +1449,11 @@ pub async fn add_staged_copy_milliseconds( /// Whether one copy of a message records a later edit than another, as an /// SQL expression over four SQL values: the copy's earlier-version count /// `n` and newest `edited_at` `newest`, and the other copy's `held_n` and -/// `held_newest`. The one rule for which of two copies of a message is the -/// later backup, for a stored message ([`write_edit_map`]) and for two -/// copies staged in one import ([`take_later_staged_copy`]). +/// `held_newest`. It is the fallback for which of two copies of a message +/// gives its text when either copy's backup has no date, or the two dates +/// are equal: for a stored message ([`write_edit_map`]) and for two copies +/// staged in one import ([`take_later_staged_copy`]). Where the two backups +/// have different dates, [`later_backup_sql`] decides instead. /// /// The copy is the later one when its newest earlier version is newer than /// the other's, or as new and it lists more of them. When either side @@ -1463,8 +1466,7 @@ pub async fn add_staged_copy_milliseconds( /// The newest earlier version is the edit before the last one: the time of /// a part's last edit is recorded nowhere. So a later backup that differs /// only by an unsent part, or by one edit after an unsend, can read as not -/// later (#1804). The rule is used only where one of the two backups has no -/// date; where both have one, [`later_backup_sql`] decides instead. +/// later (#1804), which is why the backups' dates decide wherever they can. /// /// `edited_at` has the one text form of a stored time on both sides /// (`models::utc_timestamp_text`), so the text orders as the time. From 65623839015884b02a87c3e71a63a052d1481365 Mon Sep 17 00:00:00 2001 From: Matt Beisser <225018+mbeisser1@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:11:40 -0400 Subject: [PATCH 7/7] Revert "fix(import): a mixed import keeps the dated file's backup date in either order" This reverts commit 6c966784a. Giving the staged row the dated copy's date whenever an undated copy also contributed makes the dated copy's date decide for the undated copy too at promotion, which drops a mark or an edit the undated copy brought when the stored message came from a later backup. Neither rule stores what two separate imports store in every order, so the order-dependence of a mixed import is filed as its own issue rather than fixed here. Co-Authored-By: Claude Fable 5.1 --- crates/server/server/src/db/staging.rs | 26 ---------- .../server/server/src/imports_api/staging.rs | 9 +--- .../src/imports_api/tests/backup_dates.rs | 49 ------------------- 3 files changed, 2 insertions(+), 82 deletions(-) diff --git a/crates/server/server/src/db/staging.rs b/crates/server/server/src/db/staging.rs index 3fd4e3c04..ee6af28a8 100644 --- a/crates/server/server/src/db/staging.rs +++ b/crates/server/server/src/db/staging.rs @@ -632,32 +632,6 @@ pub async fn take_staged_copy_from_later_backup( Ok(()) } -/// Give the staged message `staged`, staged from a file without a backup -/// date, the date `backup_taken_at` of another copy of it from the same -/// import. The two dates cannot decide which copy is later ([`later_backup`]), -/// but the message keeps the one date it has whichever file was read first, -/// so a later import compares its backup with that date in either file -/// order. -/// -/// # Errors -/// -/// Returns an error when the update fails. -pub async fn add_staged_copy_backup_date( - conn: &mut SqliteConnection, - staged: i64, - backup_taken_at: &str, -) -> Result<()> { - sqlx::query( - "UPDATE staging_messages SET backup_taken_at = $1 \ - WHERE id = $2 AND backup_taken_at IS NULL", - ) - .bind(backup_taken_at) - .bind(staged) - .execute(&mut *conn) - .await?; - Ok(()) -} - /// Give the staged message `staged` the mark `deletion` of another copy of /// it from the same import, when the two backups' dates cannot decide /// ([`later_backup`]): a copy that carries a mark adds it, and one with diff --git a/crates/server/server/src/imports_api/staging.rs b/crates/server/server/src/imports_api/staging.rs index 3d8370e07..e46d14d44 100644 --- a/crates/server/server/src/imports_api/staging.rs +++ b/crates/server/server/src/imports_api/staging.rs @@ -912,10 +912,8 @@ async fn flush_staging_message_chunk( /// versions and mark, mark or no mark, and one from an earlier backup gives /// neither (#1741, #1804). When either has no date, or the two dates are /// equal ([`db_staging::later_backup`]), the copy gives its text and earlier -/// versions when it records a later edit, and its mark when it carries one; -/// a dated copy gives a staged message without a date its date -/// ([`db_staging::add_staged_copy_backup_date`]), so a later import compares -/// with that date whichever file came first. A copy at the staged message's time that has milliseconds marks it +/// versions when it records a later edit, and its mark when it carries one. +/// A copy at the staged message's time that has milliseconds marks it /// `milliseconds` ([`db_staging::add_staged_copy_milliseconds`]). One import /// of two backups then stores what two separate imports of them store, in /// either file order (#1806, #1837). @@ -964,9 +962,6 @@ async fn add_staged_copy( } BackupOrder::Earlier => {} BackupOrder::Undecided => { - if let (None, Some(copy_backup)) = (&held_backup, staged_source.backup_taken_at) { - db_staging::add_staged_copy_backup_date(tx, staged, copy_backup).await?; - } if !row.msg.earlier_versions.is_empty() { db_staging::take_later_staged_copy( tx, diff --git a/crates/server/server/src/imports_api/tests/backup_dates.rs b/crates/server/server/src/imports_api/tests/backup_dates.rs index 3c03832b0..f57a067ce 100644 --- a/crates/server/server/src/imports_api/tests/backup_dates.rs +++ b/crates/server/server/src/imports_api/tests/backup_dates.rs @@ -440,55 +440,6 @@ async fn without_a_backup_date_marks_add_and_edits_compare_their_times() { } } -/// One import holds a dated file and an undated one: the staged message -/// keeps the dated file's date in either file order, so a later import -/// compares against it. Import 1 holds backup A, dated earlier with no -/// mark, and a file without a date that marks the message Unsent, so the -/// mark adds. Import 2 is backup C, made later, with no mark: it clears -/// the mark whichever file import 1 read first. -#[tokio::test] -async fn one_import_of_a_dated_and_an_undated_file_keeps_the_date_in_either_order() { - let tmp = TempDir::new().unwrap(); - let assets = tmp.path().join("assets"); - let file = |name: &str, backup: Option, deletion: Option| { - backup_file( - tmp.path(), - name, - &Copy { - backup, - text: "back again", - versions: &[], - deletion, - }, - ) - }; - let dated = file("a-earlier.jsonl", Some(EARLIER_BACKUP), None); - let undated = file("b-undated.jsonl", None, Some(Deletion::Unsent)); - let later = file("c-later.jsonl", Some(LATER_BACKUP), None); - for (name, first) in [ - ("dated-first.db", [dated.clone(), undated.clone()]), - ("undated-first.db", [undated.clone(), dated.clone()]), - ] { - let db = tmp.path().join(name); - import(&db, &assets, tmp.path(), &first).await; - let held_first = held(&db).await; - assert_eq!(held_first.deletion.as_deref(), Some("unsent"), "{name}"); - assert_eq!( - held_first.backup_taken_at.as_deref(), - Some(EARLIER_BACKUP_AT), - "{name}" - ); - import(&db, &assets, tmp.path(), std::slice::from_ref(&later)).await; - let held_after = held(&db).await; - assert_eq!(held_after.deletion, None, "{name}"); - assert_eq!( - held_after.backup_taken_at.as_deref(), - Some(LATER_BACKUP_AT), - "{name}" - ); - } -} - /// Two backups in one import give the message the later backup's text, and /// so the later backup's duplicate flag: with another source holding the /// later text, the dedupe hides one of the two, in either file order, and