Skip to content

fix(server): keep a message's time to the millisecond - #1963

Merged
mbeisser1 merged 5 commits into
mainfrom
fix/1096-millisecond-message-times
Oct 6, 2026
Merged

mbeisser1 merged 5 commits into
mainfrom
fix/1096-millisecond-message-times

Conversation

@mbeisser1

Copy link
Copy Markdown
Member

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 by sort_order alone, that is by their place in the file.

Root Cause

models::message_from_ir and earlier_version_from_ir divided timestamp_unix_ms and edited_at_unix_ms by 1000 (div_euclid(1000)) and format_utc_timestamp wrote SecondsFormat::Secs, so messages.timestamp (and message_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 change (schema/sql/messages.sql, schema/sql/staging.sql): messages.timestamp, staging_messages.timestamp and message_versions.edited_at stay TEXT RFC 3339 UTC, now always with three fractional digits (2015-03-12T18:04:22.250Z, .000 for a whole-second source). One fixed form keeps the text sorting in time order, so every ORDER BY m.timestamp, m.sort_order, m.id, the MIN/MAX aggregates and group_title_at work unchanged. The schema fingerprint changes, so an existing database is rebuilt empty (no migration, per CLAUDE.md).
  • API change: no field renamed. Message.timestamp and EarlierVersion.edited_at now carry milliseconds; the dates derived from message times (contact start_date/end_date/last_heard_at, identity start_date/end_date, conversation first/last) carry them too. The doc comments in crates/libs/api-types say so; docs/src/assets/openapi.json and web/src/lib/serverApi.types.ts are regenerated (comment text only). The web app already parses the string with Date.parse/Intl, so no web code changed.
  • Search (search/value.rs): a day's start is written in the same millisecond form. A bound like …T00:00:00Z sorts 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.439Z for Kiritimati).
  • Content key unchanged: dedupe::parse_rfc3339_utc_secs still reads whole seconds, so the content key and the near-time pass match at whole seconds as before.
  • Nothing else truncated on the way through: the demo seed goes through the import path, message-crate-pull already parsed RFC 3339 with milliseconds (timestamp_millis), and the export formats carry timestamp_unix_ms (CSV column, mail X-ME header).
  • 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).
  • Test literals that insert message rows by hand (MessageRow and friends) moved to the millisecond form, and assertions on returned times were updated.

How to Reproduce (Before Fix)

  1. Import crates/server/server/tests/fixtures/apple-messages-sub-second-times.jsonl (two messages at …00.550 and …00.250, the later one listed first).
  2. GET /v1/messages?sort=date.
  3. Both messages show 2020-01-06T11:10:00Z, and guid-300-ms-later is listed before guid-first.

How to Verify (After Fix)

  1. 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_millisecond
  2. cargo 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).
  3. The same import as above returns 2020-01-06T11:10:00.250Z and …00.550Z, in that order, on /v1/messages and /v1/conversations/{id}/messages.

Each new test fails without the fix, checked locally:

  • a_message_time_keeps_its_milliseconds_through_import_and_the_api and two_messages_300_ms_apart_are_ordered_by_their_times fail with models::format_utc_timestamp put 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_millisecond fails with the search bound put back to SecondsFormat::Secs (date:2013-05-01 finds nothing).
  • The existing every_skipped_day_begins_where_the_next_day_begins fails 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_order across batches, so it does not fail before this PR.

Impact Assessment

  • Severity: Medium
  • Users affected: Subset: anyone who imported WhatsApp, Apple Messages or SMS Backup & Restore backups with several messages in one second; programs reading timestamp from the API see a new string form.
  • Duration: Since the server first stored message times.

Regression Risk

  • Every string compared with messages.timestamp must 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.
  • The schema change rebuilds an existing database empty on first start; its messages must be imported again (already the case for 0.11.0, see Upgrading).
  • An API client that matched the old …SSZ text exactly would see …SS.sssZ. No compatibility is kept, per CLAUDE.md.
  • No CHECK constraint enforces the form in SQL; format_utc_timestamp is the one writer.

Checklist

  • Root cause identified and documented above
  • Fix addresses the root cause (not just symptoms)
  • Added test to prevent regression
  • Existing tests pass locally (./scripts/check-pr.sh, cargo test --workspace, cargo test -p message-crate-server, web npm test and npm run build, ./scripts/check-generated-api-types.sh, docs npm run check and npm run build)
  • Tested the specific reproduction steps

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

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>

@mbeisser1 mbeisser1 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/server/server/src/search/value.rs Outdated
Comment thread crates/server/server/src/messages_api/tests.rs Outdated
mbeisser1 and others added 2 commits October 5, 2026 23:46
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>
@mbeisser1
mbeisser1 marked this pull request as ready for review October 6, 2026 03:53

@mbeisser1 mbeisser1 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread crates/server/server/src/dedupe.rs
Comment thread crates/server/server/src/models.rs
Comment thread crates/server/server/src/models.rs
@mbeisser1

Copy link
Copy Markdown
Member Author

Review summary

Findings 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, success. No commits were needed for CI failures.

No Spec skip. No user thread open.

@mbeisser1
mbeisser1 merged commit 8900d28 into main Oct 6, 2026
28 checks passed
@mbeisser1
mbeisser1 deleted the fix/1096-millisecond-message-times branch October 6, 2026 04:08
mbeisser1 added a commit that referenced this pull request Oct 6, 2026
…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>
mbeisser1 added a commit that referenced this pull request Oct 6, 2026
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>
mbeisser1 added a commit that referenced this pull request Oct 6, 2026
…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>
mbeisser1 added a commit that referenced this pull request Oct 6, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The server stores message times in whole seconds and drops the milliseconds a backup records

1 participant