Repository navigation
feat: the conversation file says when its backup was made, and the later backup decides a message's mark and text - #1966
Conversation
CONTEXT.md names the operation Export and lists Pull under its Avoid, but the crate that runs an Export from a server, its types, the desktop command, the state file and the recorded tool name all said Pull. - The crate is message-crate-export at crates/libs/export/ (library message_crate_export), with ExportConfig, ExportReport, the private Export type, ExportJournalEvent, ExportJournalState and EXPORT_JOURNAL_NAME. - The state file is .message-crate-export-state.jsonl. The old file is not read. - The server records the tool as message-crate-export. The old name is not mapped. - The desktop command is export (src-tauri/src/commands/export.rs, ExportArgs), and the web app calls it as invokeExport. - CLAUDE.md, AGENTS.md, the developer pages, the Export guide, the OpenAPI document and the crate READMEs name the new crate. ADR 0001 and ADR 0012 keep their text and gain a dated note. Closes #1906 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md keeps file paths, tool names and crate names out of CHANGELOG entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Export's own working directory and the step that fills it said pull, which CONTEXT.md avoids for Export. The directory is .exported (EXPORTED, the exported field on ExportDir), and the step is the fetch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fetch already names fetching an Asset, so the step before the format step is the export step, after the invokeExport call it makes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExportMeta gains backup_taken_at_unix_ms, and schema_version goes from 10 to 11; a version-10 file is refused by its version. Each exporter fills the date from its source: an iPhone backup's Manifest.plist date for Apple Messages and WhatsApp, a Mac chat.db's modification time, the backup_date attribute of an SMS Backup & Restore file, the newest modification time of the files read for iMazing, OpenExtract, GO SMS Pro, SMS Backup+ and an Android WhatsApp database. The demo seed dates its backups at its reference time. ir-format carries the date in every format it writes and reads: JSON and JSON Lines through serde, CSV in a backup_taken_at_unix_ms column, EML and MBOX in an X-ME-Backup-Taken-At-Unix-Ms header. A value that is not a whole number is refused rather than read as no date. Part of #1924. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Staging keeps each staged row's backup date (staging_messages .backup_taken_at), and messages keeps the date of the backup that decided the stored copy. Between two copies of one message from one source, when both backups have a date, the copy from the later backup gives the message its deletion mark, mark or no mark, and its text and earlier versions, whatever the versions' times say; a copy from an earlier or the same backup changes neither. This holds in one import in either file order and across imports in either order, so the duplicate flag, which follows the text, does too. Where either backup has no date, the rules for files without one hold: a mark adds, now also from the second file of one import, and an edit is taken when its newest earlier version is newer. The Import Run records the newest backup date its files name, and the HTTP API answers it as backup_taken_at on ImportRun and ImportRunSummary. A message answers its own backup_taken_at, which an Export Run writes back into the conversation file as the newest date of the conversation's messages. The schema change rebuilds the database; nothing migrates. Part of #1924, #1741, #1804. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…made Settings → Storage → Import history opens a run's details with a Backup line: the backup the desktop app recorded for the run, and when that backup was made, from the run's new backup_taken_at. A backup that records no date says so. The owner's view of another account's run carries neither and shows nothing. Part of #1924. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The common message page documents export.backup_taken_at_unix_ms, where each exporter reads it from, and the rule the import applies; the CSV and mail references list the new column and header; the import and storage pages say which backup wins and where the date shows. The CHANGELOG entry for 0.11.0 says files at schema version 10 are refused. Closes #1924. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o feat/1924-backup-taken-at
…x row back #1957 reindexes a stored message only when a staged row carries a mark, because a promotion never cleared one. With #1924 a later backup can clear an Unsent mark, and that message stayed out of the search index. The promotion now names every message whose mark it changes, set or cleared, in _promote_mark_map, and the reindex set reads that table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Findings with no line in the diff (review of 10a0946).
|
…out one Two reads of one Mac's chat.db within one second carry the same date, and the later-backup rule read them as the same backup, so an unsend or an edit between them was lost. Equal dates now cannot decide, in SQL (later_backup_sql) and in Rust (the new later_backup helper, which the in-import copy rule uses instead of restating the comparison), and the undated rules hold: they change nothing for the same backup read again. add_staged_copy_mark no longer returns a flag its caller drops. The architecture doc says the date is kept to the second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Messages keeps chat.db in write-ahead mode, so a row written since the last checkpoint moves only chat.db-wal's time, and two reads of one Mac could carry the same date or an iPhone backup could read as later than a deletion the Mac already held. The date is now the newest modification time of chat.db, chat.db-wal and chat.db-shm. The format doc also says equal dates fall back to the rules for files without one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sages share it An Export Run stamped every message of a conversation file with the newest backup date of any of them. Imported elsewhere, a message decided by an older backup then claimed the newest date and ignored a backup made between the two, and a message with no date gained one. The file carries one date for all its messages, so it now names one only when every message has the same, and none otherwise, which keeps the rules for files without a date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SMS Backup & Restore writer left backup_date off the root element, so an Export or conversion to XML read back in was dated by when Message Crate wrote the file and read as the newest backup. The writer now writes the newest backup date of the conversations it holds, the rule the reader follows for a conversation read from two files, and leaves the attribute out when none has one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 0.11.0 entries said "schema version 10", which the changelog rule leaves out; they now say which files are refused in the reader's words. The group title rule's reason no longer says the backup's date is not recorded: it says why the date the file now carries still does not decide a title. http-api.md says the owner's view of an Import Run carries the run's own times and nothing of the backup it read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…5-tuple Adding the backup date made the result of reading the source a 5-tuple and reindented the whole branch. ReadJson names each value, and the branch is back at its old indentation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
later_backup returns a BackupOrder (Later with the date, Earlier, Undecided) rather than an Option<bool> its caller had to unpack again. The SBR writer writes its root element once, and notes a conversation's backup date only when the conversation writes an SMS or MMS, so a WhatsApp or iMessage conversation left out does not move the file's date. The WhatsApp run's ConversionInput is destructured where it is built. Two doc comments are rewrapped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n-at #1963 made every stored time three-digit milliseconds and changed format_utc_timestamp to take milliseconds. The backup date now goes to it whole, so it is stored to the millisecond like every other time, and the docs that said "to the second" say so. CHANGELOG keeps both sides' Upgrading entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two paragraphs the merge of #1963 edited ran past the wrap width. Test fixtures that stand for a stored backup date now use the three-digit millisecond form the server writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Answers to the top-level findings:
Fixed in 973edb5 and f9bac68:
Fixed in edeada4: the reason now says the file's backup date is missing where a source records none and for several sources is a modification time that copying can change, so it does not decide a title. |
Review summaryReviewed against the spec (#1924, with the #1741 and #1804 decisions it implements) and the standards (CLAUDE.md, AGENTS.md, writing style, CONTEXT.md,
Merges of the base:
No commits for CI failures. No Spec skip. No user threads. |
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 <noreply@anthropic.com>
…nt part included (#1741, #1804) (#1983) * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Revert "fix(import): a mixed import keeps the dated file's backup date in either order" This reverts commit 6c96678. 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Feature Description
Every conversation file now says when the backup it was read from was made, and the import uses that date to decide between two copies of one message from one source. The copy from the later backup gives the message its deletion mark (mark or no mark), its text and its earlier versions, in one import in either file order and across imports in either order. An older backup appended after a newer one changes nothing. A file that does not say keeps today's rules: marks add, edits compare their version times. Import details under Settings → Storage show the backup a run read and when it was made.
This is the decision of 2026-10-05 on #1741 (option 3) and #1804 (option 1).
User Story
As a person importing more than one backup of the same phone, I want the newer backup to decide a message that changed between them (recovered after deletion, unsent, edited) so that what Message Crate shows matches my phone now, whichever backup I import first.
Implementation Details
Conversation file (schema 10 → 11).
ExportMetagainsbackup_taken_at_unix_ms: Option<i64>.SCHEMA_VERSIONis 11, andcheck_schema_versionrefuses a version-10 file by name, with a test (refuses_a_version_10_file_by_name). Nothing is upgraded.Where each exporter reads the date.
Manifest.plistDate(newios_backup::ios_backup_date_unix_ms; the plist is readable in an encrypted backup, so the Apple Messages Reader protocol is unchanged)chat.dbManifest.plistDatemsgstore.db.crypt15/msgstore.db(the form's file wins)result.jsonbackup_dateattribute, else the file's modification time; a conversation read from two files takes the newermessage-crate-export)backup_taken_atof the conversation's messagesFormats. JSON/JSONL through serde; CSV gains a
backup_taken_at_unix_mscolumn; EML and MBOX gain anX-ME-Backup-Taken-At-Unix-Msheader (the formats' existingX-ME-*prefix). A value that is not a whole number is refused.Import. Staging stores the date on each staged row (
staging_messages.backup_taken_at), andmessages.backup_taken_atkeeps the date of the backup that decided the stored copy. One SQL rule,later_backup_sqlindb/staging.rs, says whether a staged copy is from a later backup (NULL when either side has no date):promote_deletion_marks: a later-backup row sets its mark or clears the held one; an earlier/same-backup row changes nothing; undated falls back to "a mark adds".write_edit_map: a later-backup row goes into the edit map when its text or version list differs (compared whole withjson_group_array(... ORDER BY id)), whatever the version times say; undated falls back tolater_edit_sql.promote_backup_dates(new phase after the edits) moves the stored date forward.add_staged_copyapplies the same rule to the second copy of a staged message (take_staged_copy_from_later_backup); undated keepstake_later_staged_copyand now also adds the copy's mark rather than dropping it toON CONFLICT DO NOTHING.The duplicate flag follows the text, because the dedupe compares the text; a test covers it in one import with dedupe on. #1805 (an append with dedupe off leaves a changed message's flag) is not part of this change.
The Import Run records the newest date its files name (
db::imports::note_backup_taken_at, per staged conversation).Key Files Changed
crates/libs/ir/src/lib.rs,schema_version.rs- the field, version 11, the refusalcrates/libs/ir-format/src/{write,read_csv,read_mail}.rs,crates/libs/mail/src/*- CSV column and mail headercrates/libs/ios-backup/src/backup.rs-ios_backup_date_unix_mscrates/core/message-crate-core/src/pipeline.rs-export_metatakes the date;file_modified_unix_ms,newest_file_modified_unix_mscrates/exporters/*andcrates/libs/sbr/src/read.rs- each exporter fills the datecrates/libs/export/src/{project,run}.rs- an Export Run writes the stored date backcrates/server/server/src/db/staging.rs,imports_api/{staging,promote}.rs- the later-backup rulecrates/server/server/src/imports_api/tests/backup_dates.rs- promotion and API testsweb/src/screens/settings/storage/ImportDetailPanel.tsx,storageUtils.ts- the Backup linedocs/architecture/contacts-identities-and-messages.md- the rule and its reasonHTTP API Changes
Message.backup_taken_at(string, RFC 3339 UTC, nullable) on every route that answers a message (GET /v1/messages, conversation messages, and the export read path): when the backup that decided the message's mark and text was made.ImportRunSummary.backup_taken_at/ImportRun.backup_taken_at(string, nullable) onGET /v1/imports,GET /v1/imports/{id},GET /v1/accounts/{id}/imports[/{id}]and the run answers ofPATCH/complete: the newest backup date the run's files name. Not onOwnerImportRun: the owner's view of another account's run carries nothing of its backup.AccountImportRun's two variants are boxed in Rust (Clippylarge_enum_variant); the JSON is unchanged.docs/src/assets/openapi.json) and web types regenerated (web/src/lib/serverApi.types.ts).Database Changes
schema/sql/*.sqlchanged (every database rebuilds empty and re-imports; the fingerprint is computed, nothing to bump)staging_messages.backup_taken_at,messages.backup_taken_at,imports.backup_taken_at(allTEXT, the formtimestampholds). The conversation file format changes too: schema version 11, and version 10 is refused.How to Test
cargo test -p message-crate-server --lib backup_dates— the two-backup cases: the deletion mark and the text (the An append keeps the older text when a later backup only unsent a part #1804 scenario) in one import in both file orders and across two imports in both orders, the dates swapped, an append of the older backup changing nothing, the duplicate flag, the no-date fallback, the Import Run's and a message'sbackup_taken_at.cargo test -p message-ir-format backup— every format (JSON, JSONL, CSV, EML, MBOX) keeps the date, and CSV refuses a non-number;cargo test -p message-ir schema_version— version 10 refused.ios-backup(the_backup_date_is_the_manifest_s_date, fixturetests/fixtures/dated-backup/Manifest.plist),imessage-ir-exporter(an_iphone_backup_is_dated_by_its_manifest,a_mac_chat_db_is_dated_by_its_modification_time, and through the real readerevery_conversation_file_says_when_the_chat_db_was_last_written),whatsapp-exporter(an_iphone_backup_is_dated_by_its_manifest,an_android_backup_is_dated_by_its_database_file),sms-backup-restore-exporter(the_backup_date_is_the_files_backup_date_attributewith fixturetests/fixtures/dated_backup.xml, the modification-time fallback, two backups in one directory),imazing-exporter,openextract-exporter,go-sms-pro-exporter,sms-backup-plus-exporter(modification time),message-crate-export(a_document_says_the_newest_backup_its_messages_came_from).cd web && npx vitest run src/screens/settings/storage— the Backup line in Import details andimportBackup.Each new test fails without the fix, checked by hand: with
later_backup_sqlforced to NULL and the in-import copy rule and additive copy mark switched off, 5 of the 7 promotion tests fail (the other two cover the new API fields, which do not exist without the change); with the date reads infile_modified_unix_ms,sbr::read::backup_dateandios_backup_date_unix_msswitched off, every exporter test above fails; with the Backup line removed fromImportDetailPanel, both new Vitest cases fail.Checklist
./scripts/check-pr.shpasses and formatter rewrites are committedRun locally after merging the rename's head:
./scripts/check-pr.sh,cargo test --workspace --no-fail-fast,cargo test --manifest-path src-tauri/Cargo.toml,cd web && npm test && npm run build,./scripts/check-generated-api-types.sh,cd docs && npm run check && npm run build.Related Issues
Closes #1924
Unblocks #1741 and #1804, whose decisions this implements; their own acceptance tests and closing are left to them. Related: #1805 (an append with dedupe off still leaves a changed message's duplicate flag), #1923 (the next schema bump), #1906 / #1961 (the base).
Screenshots / Demo
Not captured. Import details gains one line under the figures: Backup, the path the desktop app recorded, and "Made " or "The backup does not say when it was made".
Deployment Notes
The database rebuilds empty on first start (schema change). Conversation files at schema version 10 are refused; export the backup again with this build. CHANGELOG entry under 0.11.0 (Features and Upgrading).
The SMS Backup & Restore XML writer does not write
backup_dateback yet, so an Export to XML read back in is dated by the file's modification time.🤖 Generated with Claude Code