Repository navigation
fix(server): keep a message's time to the millisecond - #1963
Conversation
The server stored a message's time as whole seconds, dropping the milliseconds WhatsApp, Apple Messages and SMS Backup & Restore record, so two messages in one second were ordered by sort_order alone. messages.timestamp (and staging_messages.timestamp and message_versions.edited_at) now hold RFC 3339 UTC with three fractional digits, such as 2015-03-12T18:04:22.250Z, always in that one form so the text still sorts in time order. Lists order by it as before, and the API returns it in Message.timestamp and edited_at. The content key and the near-time pass still match at whole seconds. A search's day bounds are written in the same millisecond form, since a bound without a fraction sorts after a stored time of the same second. The search for the end of a time zone's gap now steps in whole seconds, because a half second left in it would now show in the bound. Tests: a fixture with sub-second times keeps them through import and the API, two messages 300 ms apart list in time order on both message routes, and a day begins at its first millisecond in search. Each fails without the change. The schema change rebuilds an existing database. Closes #1096 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
mbeisser1
left a comment
There was a problem hiding this comment.
Review of 850b1cb (main merged in; the only conflict was CHANGELOG.md, both entries kept). Standards: 2 findings (below). Spec: no findings. Correctness: no findings. Merge review of the CHANGELOG resolution: no findings.
The search day bound, format_utc_timestamp and the tests that build stored times now all call models::utc_timestamp_text, so the form that text comparison relies on is defined once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
mbeisser1
left a comment
There was a problem hiding this comment.
Re-review of the fix commits 87c5cf0 and 2d1f638 (Standards: 3 findings, below; Correctness: none), and merge review of fc1ca2b, which merges main (#1957, #1960) with a CHANGELOG.md conflict resolved by keeping main's Unsent entry and this PR's entry under one "#### Browsing and search" heading (Correctness: none).
Review summaryFindings per axis (first review pinned to 850b1cb, re-review of the fix commits 87c5cf0 and 2d1f638):
Totals: 4 Fixed, 0 Declined, 1 Deferred (#1965). Merges of the base
CI: run 37411093248 on fc1ca2b, No Spec skip. No user thread open. |
…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>
…ter backup decides a message's mark and text (#1966) * refactor(export): rename message-crate-pull to message-crate-export 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> * docs(changelog): say the Export record rename in plain words AGENTS.md keeps file paths, tool names and crate names out of CHANGELOG entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(adr): head the rename notes Amended, like ADR 0011 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(agents): the layout tree names export, not pull Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(export): the JSON Lines an Export writes go in .exported 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> * refactor(export): say Export where comments and tests still said pull Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(export): call the step that writes JSON Lines the export step 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> * test(web): the Export job test says Export complete, not Pull Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ir): the conversation file says when its backup was made 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> * feat(import): the later backup decides a message's mark and text 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> * feat(web): Import details show the backup a run read and when it was 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> * docs: describe when a backup was made and how the import uses it 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> * test(sms-backup-restore): a JSON export is at schema version 11 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(search): a later backup that clears an Unsent mark gives the index 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> * fix(import): equal backup dates fall back to the rules for files without 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> * fix(imessage): a Mac read is dated by chat.db and its write-ahead log 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> * fix(export): a conversation file names a backup only when all its messages 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> * fix(sbr): an XML export writes the backup date back 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> * docs: the changelog names no schema version, and two reasons catch up 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> * refactor(whatsapp): the JSON a run converts is a named struct, not a 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> * refactor: name the backup order, and date an XML file by what it writes 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> * style: rewrap two paragraphs, and fixtures carry the stored date form 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> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…e-second twin is a duplicate (#1968) * refactor(export): rename message-crate-pull to message-crate-export 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> * docs(changelog): say the Export record rename in plain words AGENTS.md keeps file paths, tool names and crate names out of CHANGELOG entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(adr): head the rename notes Amended, like ADR 0011 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(agents): the layout tree names export, not pull Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(export): the JSON Lines an Export writes go in .exported 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> * refactor(export): say Export where comments and tests still said pull Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(server): keep a message's time to the millisecond The server stored a message's time as whole seconds, dropping the milliseconds WhatsApp, Apple Messages and SMS Backup & Restore record, so two messages in one second were ordered by sort_order alone. messages.timestamp (and staging_messages.timestamp and message_versions.edited_at) now hold RFC 3339 UTC with three fractional digits, such as 2015-03-12T18:04:22.250Z, always in that one form so the text still sorts in time order. Lists order by it as before, and the API returns it in Message.timestamp and edited_at. The content key and the near-time pass still match at whole seconds. A search's day bounds are written in the same millisecond form, since a bound without a fraction sorts after a stored time of the same second. The search for the end of a time zone's gap now steps in whole seconds, because a half second left in it would now show in the bound. Tests: a fixture with sub-second times keeps them through import and the API, two messages 300 ms apart list in time order on both message routes, and a day begins at its first millisecond in search. Each fails without the change. The schema change rebuilds an existing database. Closes #1096 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(export): call the step that writes JSON Lines the export step 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> * test(web): the Export job test says Export complete, not Pull Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(server): one function writes the stored message time form The search day bound, format_utc_timestamp and the tests that build stored times now all call models::utc_timestamp_text, so the form that text comparison relies on is defined once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(server): name the stored time writer once, and where it lives Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ir): the conversation file says when its backup was made 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> * feat(import): the later backup decides a message's mark and text 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> * feat(web): Import details show the backup a run read and when it was 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> * docs: describe when a backup was made and how the import uses it 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> * test(sms-backup-restore): a JSON export is at schema version 11 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(search): a later backup that clears an Unsent mark gives the index 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> * fix(import): keep a backup's date to the millisecond, as a message time The merge of #1924 with #1096 left the backup date going through the millisecond time writer as seconds, so every backup read as 1970. It now passes milliseconds, and the stored date takes the three-digit form every stored time takes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): equal backup dates fall back to the rules for files without 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> * fix(imessage): a Mac read is dated by chat.db and its write-ahead log 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> * fix(export): a conversation file names a backup only when all its messages 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> * fix(sbr): an XML export writes the backup date back 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> * docs: the changelog names no schema version, and two reasons catch up 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> * refactor(whatsapp): the JSON a run converts is a named struct, not a 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> * refactor: name the backup order, and date an XML file by what it writes 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> * feat(ir): each message says whether its time has milliseconds A message in the conversation file gains a required time_precision, seconds or milliseconds, and schema_version goes from 11 to 12; a version-11 file is refused by its version. The flag, never the time, says whether a time has milliseconds, since a millisecond time can end in .000. Every exporter writes the precision it already knew: iMazing, OpenExtract, GO SMS Pro's PDU files and SMS Backup+ without X-smssync-date write seconds; Apple Messages, WhatsApp, SMS Backup & Restore, GO SMS Pro's XML and SMS Backup+ with X-smssync-date write milliseconds. When an exporter keeps one copy of a message, a whole-second copy that takes a millisecond copy's time takes its precision too. The demo seed writes milliseconds. ir-format carries the field in every format: JSON and JSON Lines through serde, CSV in a time_precision column, EML and MBOX in an X-ME-Time-Precision header. A blank or unknown value is refused rather than guessed. Part of #1923. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style: rewrap two paragraphs, and fixtures carry the stored date form 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> * feat(server): store a message's time precision and answer it staging_messages and messages gain time_precision, seconds or milliseconds, from the conversation file, and the HTTP API answers it as time_precision on a Message. An Export Run writes the stored value back into the conversation file. The schema change rebuilds the database; nothing migrates. Part of #1923. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(dedupe): a whole-second copy of a message is the duplicate of its millisecond twin Within one source, a message whose time is in whole seconds is hidden as the duplicate of a message that matches it in everything else and has milliseconds in the same second, so an SMS Backup+ message imported once from a mail timed by its Date header and once from one timed by X-smssync-date is shown once, with its milliseconds. Before, the exact pass hid a duplicate only when another source held it. The content key stays at whole seconds, and the stored flag decides, never the time: a millisecond time that ends in .000 is not taken for a whole second. Part of #1923. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(api): regenerate the OpenAPI document and web types for time_precision Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: describe a message's time precision and the whole-second twin rule The common message page, the export structure, the CSV columns, the mail archive headers and the message transfer page describe time_precision at schema version 12. The architecture note gains the rule that, within one source, a whole-second message is the duplicate of its millisecond twin, with its reason. The changelog says version-11 files are refused. Part of #1923. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: name schema version 12 where the docs still named an older one Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(export): each projection function keeps its own doc comment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): the time precision entry names no mail headers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(ir, api-types): both TimePrecision enums parse the same way Each gains ALL and parses by finding the variant whose name matches, as Deletion does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(dedupe): a stored time_precision the dedupe cannot read fails the pass It was compared as text, so an unknown value counted as milliseconds, where the message read path refuses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(dedupe): the twin split moves each message instead of cloning it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(dedupe): a twin is shown with milliseconds unless another source's copy wins The twin is hidden under the cross-source winner, which pick_winner ranks by attachments and import order, not precision. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(common-message): SMS Backup & Restore XML cannot carry the time precision Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): one message held at whole seconds and at .000 milliseconds says milliseconds The two copies share a guid, so the copy stored first kept its flag and a whole-second copy stored first stayed seconds for good. A staged or promoted copy that says milliseconds now raises the stored flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(import): a millisecond copy raises the flag only at the stored message's time A copy cut to the second elsewhere that kept the guid of a message with other milliseconds was marked milliseconds at a .000 time it never had. promote_time_precision returns nothing, and its phase names what it does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: the twin rule's caveat reaches the CHANGELOG, and its passages are rewrapped Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bug Description
Expected: A message whose backup records its time to the millisecond is stored and returned by the API with those milliseconds, and two messages in one conversation sent 300 ms apart are listed in the order they were sent.
Actual: The server stored every message time as whole seconds (
2020-01-06T11:10:00Z). The milliseconds that WhatsApp, Apple Messages and SMS Backup & Restore record were dropped, and two messages in one second were ordered bysort_orderalone, that is by their place in the file.Root Cause
models::message_from_irandearlier_version_from_irdividedtimestamp_unix_msandedited_at_unix_msby 1000 (div_euclid(1000)) andformat_utc_timestampwroteSecondsFormat::Secs, somessages.timestamp(andmessage_versions.edited_at) had no place for milliseconds.Fix Description
Decision on #1096 (2026-10-05): option 3, milliseconds now; the precision flag and the whole-second twin dedupe are #1923.
schema/sql/messages.sql,schema/sql/staging.sql):messages.timestamp,staging_messages.timestampandmessage_versions.edited_atstayTEXTRFC 3339 UTC, now always with three fractional digits (2015-03-12T18:04:22.250Z,.000for a whole-second source). One fixed form keeps the text sorting in time order, so everyORDER BY m.timestamp, m.sort_order, m.id, theMIN/MAXaggregates andgroup_title_atwork unchanged. The schema fingerprint changes, so an existing database is rebuilt empty (no migration, per CLAUDE.md).Message.timestampandEarlierVersion.edited_atnow carry milliseconds; the dates derived from message times (contactstart_date/end_date/last_heard_at, identitystart_date/end_date, conversation first/last) carry them too. The doc comments incrates/libs/api-typessay so;docs/src/assets/openapi.jsonandweb/src/lib/serverApi.types.tsare regenerated (comment text only). The web app already parses the string withDate.parse/Intl, so no web code changed.search/value.rs): a day's start is written in the same millisecond form. A bound like…T00:00:00Zsorts after a stored…T00:00:00.000Z, so without this a message at the first instant of a day fell on the day before. The binary search for the end of a time zone's gap now steps in whole seconds, because a half second it used to leave (hidden by the seconds format) would now show (1994-12-31T10:00:00.439Zfor Kiritimati).dedupe::parse_rfc3339_utc_secsstill reads whole seconds, so the content key and the near-time pass match at whole seconds as before.message-crate-pullalready parsed RFC 3339 with milliseconds (timestamp_millis), and the export formats carrytimestamp_unix_ms(CSV column, mailX-MEheader).docs/architecture/contacts-identities-and-messages.md: titles are compared to the millisecond. CHANGELOG entry under 0.11.0 (Fixes, and an Upgrading note for API readers).MessageRowand friends) moved to the millisecond form, and assertions on returned times were updated.How to Reproduce (Before Fix)
crates/server/server/tests/fixtures/apple-messages-sub-second-times.jsonl(two messages at…00.550and…00.250, the later one listed first).GET /v1/messages?sort=date.2020-01-06T11:10:00Z, andguid-300-ms-lateris listed beforeguid-first.How to Verify (After Fix)
cargo test -p message-crate-server --lib -- a_message_time_keeps_its_milliseconds_through_import_and_the_api two_messages_300_ms_apart_are_ordered_by_their_times a_day_begins_at_its_first_millisecondcargo test -p message-crate-server --lib -- a_conversation_split_across_batches_keeps_its_order_within_a_second(the Messages of one second come out of order where a conversation is split across batches #1168 test of messages 100 ms apart).2020-01-06T11:10:00.250Zand…00.550Z, in that order, on/v1/messagesand/v1/conversations/{id}/messages.Each new test fails without the fix, checked locally:
a_message_time_keeps_its_milliseconds_through_import_and_the_apiandtwo_messages_300_ms_apart_are_ordered_by_their_timesfail withmodels::format_utc_timestampput back to whole seconds ("2020-01-06T11:10:00Z"against"…00.250Z"; order["guid-300-ms-later", "guid-first"]).a_day_begins_at_its_first_millisecondfails with the search bound put back toSecondsFormat::Secs(date:2013-05-01finds nothing).every_skipped_day_begins_where_the_next_day_beginsfails without the whole-second step in the gap search.The #1168 100 ms test passes both with and without this change: #1168 was already fixed by numbering
sort_orderacross batches, so it does not fail before this PR.Impact Assessment
timestampfrom the API see a new string form.Regression Risk
messages.timestampmust use the same three-digit form. The only one built from a date is the search day bound, changed here. A time written in the old form would sort after every millisecond time of its second; only test rows inserted by hand could do that, and those were moved to the new form where they meet a comparison.…SSZtext exactly would see…SS.sssZ. No compatibility is kept, per CLAUDE.md.format_utc_timestampis the one writer.Checklist
./scripts/check-pr.sh,cargo test --workspace,cargo test -p message-crate-server, webnpm testandnpm run build,./scripts/check-generated-api-types.sh, docsnpm run checkandnpm run build)Related Issues
Closes #1096
The precision flag and the whole-second / millisecond twin dedupe follow in #1923. #1168 is updated in a comment.
🤖 Generated with Claude Code