From cbbece2c6203c68aeac0c2213af4d55aa22692c7 Mon Sep 17 00:00:00 2001 From: Matt Beisser <225018+mbeisser1@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:25:26 -0400 Subject: [PATCH 1/3] 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 --- CHANGELOG.md | 15 +++ crates/libs/api-types/src/lib.rs | 13 ++- .../server/server/src/accounts_api/tests.rs | 20 ++-- crates/server/server/src/assets_api/tests.rs | 2 +- .../server/server/src/contacts_api/tests.rs | 30 ++--- .../server/src/conversations_api/tests.rs | 42 +++---- crates/server/server/src/db/staging/tests.rs | 2 +- crates/server/server/src/db/write_guard.rs | 2 +- crates/server/server/src/dedupe.rs | 6 +- crates/server/server/src/dedupe/tests.rs | 58 +++++----- crates/server/server/src/exports_api/tests.rs | 12 +- .../server/server/src/messages_api/tests.rs | 106 ++++++++++++++++-- crates/server/server/src/models.rs | 29 +++-- .../server/src/openapi/credential_matrix.rs | 2 +- crates/server/server/src/reset_demo/tests.rs | 8 +- crates/server/server/src/search/tests.rs | 37 ++++++ crates/server/server/src/search/value.rs | 23 ++-- crates/server/server/src/server_api/tests.rs | 2 +- crates/server/server/src/session_api/tests.rs | 2 +- crates/server/server/src/test_support.rs | 17 +-- crates/server/server/src/trash_api/tests.rs | 2 +- .../apple-messages-sub-second-times.jsonl | 3 + .../contacts-identities-and-messages.md | 2 +- docs/src/assets/openapi.json | 6 +- schema/sql/messages.sql | 11 +- schema/sql/staging.sql | 4 +- web/src/lib/serverApi.types.ts | 18 ++- 27 files changed, 317 insertions(+), 157 deletions(-) create mode 100644 crates/server/server/tests/fixtures/apple-messages-sub-second-times.jsonl diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b30258ba..c1c2561f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,18 @@ released versions carry their date on the heading. the conversation again. An Error about a conversation written before the stop stays, because the resumed Staging does not read it again. +#### Browsing and search + +- 2026-10-05: **A message keeps the milliseconds of its time.** Message + Crate kept a message's time to the whole second and dropped the + milliseconds that WhatsApp, Apple Messages and SMS Backup & Restore + record. Two messages sent within one second could then show + in the wrong order. The time is now kept to the millisecond, a + conversation lists its messages in the order they were sent, and an + export written from Message Crate keeps the milliseconds too. A backup + that records whole seconds, such as iMazing or OpenExtract, lists the + messages of one second in the order the backup gives them, as before. + ### Upgrading - The database format changed. **An existing Message Crate is rebuilt empty @@ -101,6 +113,9 @@ released versions carry their date on the heading. in place of `num_replies`. - A Saved Search that uses `deleted:yes` finds fewer messages than before: it leaves unsent messages out. Add `or unsent:yes` to it to find both. +- If you have a program that reads messages from the HTTP API, a message's + `timestamp` and an earlier version's `edited_at` now carry three digits of + milliseconds, such as `2015-03-12T18:04:22.250Z`, where they had none. ## [0.10.1] - 2026-10-05 diff --git a/crates/libs/api-types/src/lib.rs b/crates/libs/api-types/src/lib.rs index 6fff20fbc..8906a2b07 100644 --- a/crates/libs/api-types/src/lib.rs +++ b/crates/libs/api-types/src/lib.rs @@ -372,8 +372,11 @@ api_shape! { /// Export GUID for replies and grouping. Every message has one, /// because the import refuses a message without one. pub guid: String, - /// The instant the message was sent: RFC 3339 in UTC with a `Z` - /// suffix. A caller shows it in the account's time zone + /// The instant the message was sent, to the millisecond: RFC 3339 + /// in UTC with three fractional digits and a `Z` suffix + /// (`2015-03-12T18:04:22.250Z`; `.000` when the source records + /// whole seconds). Messages are listed in the order of this time. A + /// caller shows it in the account's time zone /// (`Account.time_zone`); the database stores nothing /// about where the phone was. pub timestamp: String, @@ -450,8 +453,8 @@ api_shape! { pub part_index: i64, /// The part's text in this version. pub text: String, - /// When this version was written: RFC 3339 in UTC with a `Z` suffix, - /// as `Message.timestamp`. The original's is when it was sent, a + /// When this version was written, to the millisecond in the form + /// `Message.timestamp` takes. The original's is when it was sent, a /// later version's is when the edit that wrote it was made. `None` /// when the source does not record it. pub edited_at: Option, @@ -603,7 +606,7 @@ mod tests { source: "imessage".into(), service: None, guid: "g1".into(), - timestamp: "2024-01-01T00:00:00Z".into(), + timestamp: "2024-01-01T00:00:00.000Z".into(), sort_order: 0, is_from_me: false, sender: None, diff --git a/crates/server/server/src/accounts_api/tests.rs b/crates/server/server/src/accounts_api/tests.rs index c243e51e6..f50d01dff 100644 --- a/crates/server/server/src/accounts_api/tests.rs +++ b/crates/server/server/src/accounts_api/tests.rs @@ -2069,13 +2069,13 @@ async fn the_identities_route_counts_the_direct_and_group_messages_held_at_each_ let two = [ SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body: "a", }, SeedMessage { source: "imessage", - timestamp: "2020-01-02T00:00:00Z", + timestamp: "2020-01-02T00:00:00.000Z", is_from_me: false, body: "b", }, @@ -2095,19 +2095,19 @@ async fn the_identities_route_counts_the_direct_and_group_messages_held_at_each_ let three = [ SeedMessage { source: "imessage", - timestamp: "2020-02-01T00:00:00Z", + timestamp: "2020-02-01T00:00:00.000Z", is_from_me: true, body: "c", }, SeedMessage { source: "imessage", - timestamp: "2020-02-02T00:00:00Z", + timestamp: "2020-02-02T00:00:00.000Z", is_from_me: false, body: "d", }, SeedMessage { source: "imessage", - timestamp: "2020-02-03T00:00:00Z", + timestamp: "2020-02-03T00:00:00.000Z", is_from_me: false, body: "e", }, @@ -2192,8 +2192,8 @@ async fn the_identities_route_counts_the_direct_and_group_messages_held_at_each_ { "address": "+15555550100", "service": "phone", - "start_date": "2020-01-01T00:00:00Z", - "end_date": "2020-02-02T00:00:00Z", + "start_date": "2020-01-01T00:00:00.000Z", + "end_date": "2020-02-02T00:00:00.000Z", "conversations": 2, "direct_messages": 2, "group_messages": 2 @@ -2201,8 +2201,8 @@ async fn the_identities_route_counts_the_direct_and_group_messages_held_at_each_ { "address": "alice@example.com", "service": "email", - "start_date": "2020-02-03T00:00:00Z", - "end_date": "2020-02-03T00:00:00Z", + "start_date": "2020-02-03T00:00:00.000Z", + "end_date": "2020-02-03T00:00:00.000Z", "conversations": 1, "direct_messages": 0, "group_messages": 1 @@ -2243,7 +2243,7 @@ async fn the_storage_route_sums_attachment_bytes_and_lists_the_largest_first() { source_file: "seed.jsonl", messages: &[SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body: "photos", }], diff --git a/crates/server/server/src/assets_api/tests.rs b/crates/server/server/src/assets_api/tests.rs index 7596faa9f..fbb2b0ea3 100644 --- a/crates/server/server/src/assets_api/tests.rs +++ b/crates/server/server/src/assets_api/tests.rs @@ -1489,7 +1489,7 @@ pub(crate) async fn seed_attachment_with_preview( source_file: "seed.jsonl", messages: &[crate::test_support::SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body: "two photos", }], diff --git a/crates/server/server/src/contacts_api/tests.rs b/crates/server/server/src/contacts_api/tests.rs index d173bc55d..b5bc66637 100644 --- a/crates/server/server/src/contacts_api/tests.rs +++ b/crates/server/server/src/contacts_api/tests.rs @@ -562,8 +562,8 @@ async fn get_contact_detail_counts_direct_group_and_messages() { .await .unwrap(); for (body, ts) in [ - ("hi", "2024-06-01T12:00:00Z"), - ("there", "2024-06-01T13:00:00Z"), + ("hi", "2024-06-01T12:00:00.000Z"), + ("there", "2024-06-01T13:00:00.000Z"), ] { MessageRow { timestamp: ts, @@ -576,7 +576,7 @@ async fn get_contact_detail_counts_direct_group_and_messages() { } // A reply of yours: not a message Sam sent, so not in `total_messages`. MessageRow { - timestamp: "2024-06-01T14:00:00Z", + timestamp: "2024-06-01T14:00:00.000Z", is_from_me: true, body: Some("back at you"), ..MessageRow::new(account, 1) @@ -612,7 +612,7 @@ async fn get_contact_detail_counts_direct_group_and_messages() { .await .unwrap(); MessageRow { - timestamp: "2024-07-01T12:00:00Z", + timestamp: "2024-07-01T12:00:00.000Z", sender_handle_id: Some(peer), body: Some("group hi"), ..MessageRow::new(account, 2) @@ -636,7 +636,7 @@ async fn get_contact_detail_counts_direct_group_and_messages() { .await .unwrap(); MessageRow { - timestamp: "2024-08-01T12:00:00Z", + timestamp: "2024-08-01T12:00:00.000Z", body: Some("nope"), ..MessageRow::new(account, 9) } @@ -665,11 +665,11 @@ async fn get_contact_detail_counts_direct_group_and_messages() { assert_eq!(detail.identities[0].group_messages, 1); assert_eq!( detail.identities[0].start_date.as_deref(), - Some("2024-06-01T12:00:00Z") + Some("2024-06-01T12:00:00.000Z") ); assert_eq!( detail.identities[0].end_date.as_deref(), - Some("2024-07-01T12:00:00Z") + Some("2024-07-01T12:00:00.000Z") ); } @@ -719,8 +719,8 @@ async fn get_contact_summaries_counts_two_contacts_in_one_query() { .await .unwrap(); for (body, ts) in [ - ("hi", "2024-06-01T12:00:00Z"), - ("there", "2024-06-01T13:00:00Z"), + ("hi", "2024-06-01T12:00:00.000Z"), + ("there", "2024-06-01T13:00:00.000Z"), ] { MessageRow { timestamp: ts, @@ -758,7 +758,7 @@ async fn get_contact_summaries_counts_two_contacts_in_one_query() { .await .unwrap(); MessageRow { - timestamp: "2024-07-01T12:00:00Z", + timestamp: "2024-07-01T12:00:00.000Z", sender_handle_id: Some(sam_handle), body: Some("group hi"), ..MessageRow::new(account, 2) @@ -806,7 +806,7 @@ async fn get_contact_summaries_counts_two_contacts_in_one_query() { .await .unwrap(); MessageRow { - timestamp: "2024-05-01T09:00:00Z", + timestamp: "2024-05-01T09:00:00.000Z", sender_handle_id: Some(pat_handle), body: Some("hey"), ..MessageRow::new(account, 3) @@ -827,11 +827,11 @@ async fn get_contact_summaries_counts_two_contacts_in_one_query() { assert_eq!(summaries[0].group_message_count, 1); assert_eq!( summaries[0].start_date.as_deref(), - Some("2024-06-01T12:00:00Z") + Some("2024-06-01T12:00:00.000Z") ); assert_eq!( summaries[0].end_date.as_deref(), - Some("2024-07-01T12:00:00Z") + Some("2024-07-01T12:00:00.000Z") ); assert_eq!(summaries[1].id, pat_id); @@ -842,11 +842,11 @@ async fn get_contact_summaries_counts_two_contacts_in_one_query() { assert_eq!(summaries[1].group_message_count, 0); assert_eq!( summaries[1].start_date.as_deref(), - Some("2024-05-01T09:00:00Z") + Some("2024-05-01T09:00:00.000Z") ); assert_eq!( summaries[1].end_date.as_deref(), - Some("2024-05-01T09:00:00Z") + Some("2024-05-01T09:00:00.000Z") ); } diff --git a/crates/server/server/src/conversations_api/tests.rs b/crates/server/server/src/conversations_api/tests.rs index ced4408b5..fb1943a1b 100644 --- a/crates/server/server/src/conversations_api/tests.rs +++ b/crates/server/server/src/conversations_api/tests.rs @@ -98,7 +98,7 @@ async fn conversations_setup() -> (sqlx::SqlitePool, TestFixture, i64) { .await .unwrap(); MessageRow { - timestamp: "2024-06-01T12:00:00Z", + timestamp: "2024-06-01T12:00:00.000Z", body: Some("hello"), ..MessageRow::new(account, 1) } @@ -189,7 +189,7 @@ async fn list_conversations_finds_a_handle_across_platforms() { .unwrap(); MessageRow { source: "whatsapp", - timestamp: "2024-08-01T12:00:00Z", + timestamp: "2024-08-01T12:00:00.000Z", body: Some("wa hello"), ..MessageRow::new(account, 10) } @@ -251,7 +251,7 @@ async fn list_conversations_sorts_by_date_or_message_count() { .await .unwrap(); MessageRow { - timestamp: "2024-07-01T12:00:00Z", + timestamp: "2024-07-01T12:00:00.000Z", body: Some("newest"), ..MessageRow::new(account, 2) } @@ -349,7 +349,7 @@ async fn list_conversations_paginates() { .await .unwrap(); MessageRow { - timestamp: "2024-07-01T12:00:00Z", + timestamp: "2024-07-01T12:00:00.000Z", body: Some("later"), ..MessageRow::new(account, 2) } @@ -490,7 +490,7 @@ async fn list_conversations_filters_by_contact_and_type() { .await .unwrap(); MessageRow { - timestamp: "2024-08-01T12:00:00Z", + timestamp: "2024-08-01T12:00:00.000Z", body: Some("group"), ..MessageRow::new(account, 9) } @@ -521,7 +521,7 @@ async fn list_conversations_filters_by_contact_and_type() { .await .unwrap(); MessageRow { - timestamp: "2024-09-01T12:00:00Z", + timestamp: "2024-09-01T12:00:00.000Z", body: Some("hi group"), ..MessageRow::new(account, 3) } @@ -689,7 +689,7 @@ async fn list_conversations_filters_by_participant_count() { .await .unwrap(); MessageRow { - timestamp: "2024-10-01T12:00:00Z", + timestamp: "2024-10-01T12:00:00.000Z", body: Some("hi"), ..MessageRow::new(account, 10) } @@ -764,7 +764,7 @@ async fn list_conversations_participants_eq_three_on_built_fixture() { .await .unwrap(); MessageRow { - timestamp: "2024-11-01T12:00:00Z", + timestamp: "2024-11-01T12:00:00.000Z", body: Some("hi trio"), ..MessageRow::new(account, 20) } @@ -865,7 +865,7 @@ async fn list_conversations_filters_by_import_id() { .unwrap(); MessageRow { - timestamp: "2024-06-01T12:00:00Z", + timestamp: "2024-06-01T12:00:00.000Z", body: Some("hello"), import_id: Some(import_a), ..MessageRow::new(account, 1) @@ -873,7 +873,7 @@ async fn list_conversations_filters_by_import_id() { .insert(&mut conn) .await; MessageRow { - timestamp: "2024-07-01T12:00:00Z", + timestamp: "2024-07-01T12:00:00.000Z", body: Some("later"), import_id: Some(import_b), ..MessageRow::new(account, 2) @@ -994,7 +994,7 @@ async fn duplicate_only_threads_have_no_last_message_date_and_sort_last() { // Conversation 4 keeps a real message, and it belongs to the import. let winner_id = MessageRow { - timestamp: "2024-05-01T12:00:00Z", + timestamp: "2024-05-01T12:00:00.000Z", body: Some("canonical"), import_id: Some(import_a), ..MessageRow::new(account, 4) @@ -1005,7 +1005,7 @@ async fn duplicate_only_threads_have_no_last_message_date_and_sort_last() { // Conversation 3's only message is a duplicate, so its last_message_at // is NULL even though its timestamp is the later of the two. MessageRow { - timestamp: "2024-06-01T12:00:00Z", + timestamp: "2024-06-01T12:00:00.000Z", body: Some("dup"), import_id: Some(import_a), duplicate_of: Some(winner_id), @@ -1208,7 +1208,7 @@ async fn list_conversations_import_id_includes_duplicate_only_thread() { .await .unwrap(); let winner_id = MessageRow { - timestamp: "2024-05-01T12:00:00Z", + timestamp: "2024-05-01T12:00:00.000Z", body: Some("canonical"), ..MessageRow::new(account, 4) } @@ -1217,7 +1217,7 @@ async fn list_conversations_import_id_includes_duplicate_only_thread() { // Only message in conversation 3 from import A is a duplicate. MessageRow { - timestamp: "2024-06-01T12:00:00Z", + timestamp: "2024-06-01T12:00:00.000Z", body: Some("dup"), import_id: Some(import_a), duplicate_of: Some(winner_id), @@ -1279,7 +1279,7 @@ async fn the_conversation_list_labels_each_thread_by_its_sources() { let (fixture, user) = fixture_with_account().await; let message = |source| SeedMessage { source, - timestamp: "2024-01-01T00:00:00Z", + timestamp: "2024-01-01T00:00:00.000Z", is_from_me: true, body: "hi", }; @@ -1450,7 +1450,7 @@ async fn conversation_detail_shows_no_chat_id_as_a_group_participant() { source_file: "_import.jsonl", messages: &[crate::test_support::SeedMessage { source: "imessage", - timestamp: "2024-02-01T10:00:00Z", + timestamp: "2024-02-01T10:00:00.000Z", is_from_me: false, body: "hello all", }], @@ -1841,7 +1841,7 @@ async fn conversation_delete_removes_files_only_the_deleted_conversation_used() source_file: "seed.jsonl", messages: &[crate::test_support::SeedMessage { source: "imessage", - timestamp: "2020-01-02T00:00:00Z", + timestamp: "2020-01-02T00:00:00.000Z", is_from_me: false, body: "hi", }], @@ -2062,7 +2062,7 @@ async fn conversation_messages_say_which_were_sent_and_which_received() { ) .await; MessageRow { - timestamp: "2024-01-02T00:00:00Z", + timestamp: "2024-01-02T00:00:00.000Z", body: Some("received"), ..MessageRow::new(user.account_id, conversation_id) } @@ -2181,7 +2181,7 @@ async fn insert_many_messages( for i in 0..count { let body = format!("msg{i}"); MessageRow { - timestamp: "2024-01-01T00:00:00Z", + timestamp: "2024-01-01T00:00:00.000Z", is_from_me: true, sort_order: i, body: Some(&body), @@ -2411,7 +2411,7 @@ async fn a_page_starts_at_one_place_beside_a_message_of_this_conversation() { source_file: "seed.jsonl", messages: &[SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body: "elsewhere", }], @@ -2737,7 +2737,7 @@ async fn the_list_labels_a_group_with_its_trimmed_title_and_a_blank_title_with_n let (fixture, alice) = fixture_with_account().await; let message = SeedMessage { source: "imessage", - timestamp: "2024-01-01T10:00:00Z", + timestamp: "2024-01-01T10:00:00.000Z", is_from_me: false, body: "hello", }; diff --git a/crates/server/server/src/db/staging/tests.rs b/crates/server/server/src/db/staging/tests.rs index f038cac7e..98ed3d733 100644 --- a/crates/server/server/src/db/staging/tests.rs +++ b/crates/server/server/src/db/staging/tests.rs @@ -30,7 +30,7 @@ async fn reset_for_account_leaves_other_accounts() { account_id: account, source: "sms", guid: "g1", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: 0, sender_handle_id: None, owner_handle_id: None, diff --git a/crates/server/server/src/db/write_guard.rs b/crates/server/server/src/db/write_guard.rs index 0edcb758f..8fd2bcc1e 100644 --- a/crates/server/server/src/db/write_guard.rs +++ b/crates/server/server/src/db/write_guard.rs @@ -173,7 +173,7 @@ mod tests { source_file: "guard.jsonl", messages: &[SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: false, body: "kept", }], diff --git a/crates/server/server/src/dedupe.rs b/crates/server/server/src/dedupe.rs index b99cb36bc..d749e6bd7 100644 --- a/crates/server/server/src/dedupe.rs +++ b/crates/server/server/src/dedupe.rs @@ -655,11 +655,13 @@ fn pick_winner(cands: &[Cand], prio: &HashMap<&str, usize>) -> i64 { .map_or(cands[0].id, |c| c.id) } -/// Parse an RFC3339 timestamp into Unix UTC seconds, honoring Z / ±HH:MM offsets. +/// Parse an RFC3339 timestamp into Unix UTC seconds, honoring Z / ±HH:MM +/// offsets and dropping the milliseconds: the content key and the near-time +/// pass match at whole seconds. /// /// Strict RFC3339 is sufficient: `messages.timestamp` /// are only ever written by `models::format_utc_timestamp` (chrono's -/// `to_rfc3339_opts(SecondsFormat::Secs, true)`), so no lenient spellings reach +/// `to_rfc3339_opts(SecondsFormat::Millis, true)`), so no lenient spellings reach /// this path. Unparseable input yields `None`. fn parse_rfc3339_utc_secs(ts: &str) -> Option { chrono::DateTime::parse_from_rfc3339(ts.trim()) diff --git a/crates/server/server/src/dedupe/tests.rs b/crates/server/server/src/dedupe/tests.rs index 5fa840769..cc212dd8f 100644 --- a/crates/server/server/src/dedupe/tests.rs +++ b/crates/server/server/src/dedupe/tests.rs @@ -99,7 +99,7 @@ fn parallel_content_keys_match_serial() { chat_id: "+14075550106".into(), conversation_type: "individual".into(), is_from_me: 1, - timestamp: "2015-03-12T18:04:22Z".into(), + timestamp: "2015-03-12T18:04:22.000Z".into(), body: Some("hi".into()), sender_normalized: None, }, @@ -109,7 +109,7 @@ fn parallel_content_keys_match_serial() { chat_id: "chat-group".into(), conversation_type: "group".into(), is_from_me: 0, - timestamp: "2015-03-12T18:04:23Z".into(), + timestamp: "2015-03-12T18:04:23.000Z".into(), body: Some("yo".into()), sender_normalized: Some("+15555550128".into()), }, @@ -154,7 +154,7 @@ fn a_group_is_keyed_as_a_group_whatever_the_case_of_its_type() { chat_id: "chat-group".to_string(), conversation_type: conversation_type.to_string(), is_from_me: 0, - timestamp: "2015-03-12T18:04:23Z".to_string(), + timestamp: "2015-03-12T18:04:23.000Z".to_string(), body: Some("yo".to_string()), sender_normalized: Some("+15555550128".to_string()), }; @@ -237,7 +237,7 @@ async fn fill_missing_content_keys_skips_rows_that_already_have_keys() { MessageRow { source: "go-sms-pro", guid: Some("g-fill".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, body: Some("Need a key"), sort_order: 0, @@ -278,7 +278,7 @@ async fn fill_missing_content_keys_writes_multiple_rows_in_one_batch() { MessageRow { source: "go-sms-pro", guid: Some(guid.into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, body: Some(body), sort_order, @@ -321,7 +321,7 @@ async fn dedupe_cross_source_does_not_rewrite_unchanged_keys() { MessageRow { source: "go-sms-pro", guid: Some("g-once".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, body: Some("Once"), sort_order: 0, @@ -347,7 +347,7 @@ async fn integration_exact_flags_cross_source() { let a = MessageRow { source: "go-sms-pro", guid: Some("g1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, body: Some("Running late"), sort_order: 0, @@ -394,7 +394,7 @@ async fn integration_near_flags_within_window() { let a = MessageRow { source: "go-sms-pro", guid: Some("g1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: false, body: Some("On my way"), sort_order: 0, @@ -405,7 +405,7 @@ async fn integration_near_flags_within_window() { let b = MessageRow { source: "sms-backup-plus", guid: Some("g2".into()), - timestamp: "2015-03-12T18:04:24Z", + timestamp: "2015-03-12T18:04:24.000Z", is_from_me: false, body: Some("On my way"), sort_order: 1, @@ -435,7 +435,7 @@ async fn integration_negative_far_apart_not_flagged() { MessageRow { source: "go-sms-pro", guid: Some("g1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: false, body: Some("On my way"), sort_order: 0, @@ -446,7 +446,7 @@ async fn integration_negative_far_apart_not_flagged() { MessageRow { source: "sms-backup-plus", guid: Some("g2".into()), - timestamp: "2015-03-12T18:05:22Z", + timestamp: "2015-03-12T18:05:22.000Z", is_from_me: false, body: Some("On my way"), sort_order: 1, @@ -477,7 +477,7 @@ async fn integration_priority_prefers_first_imported_source() { let first_imported = MessageRow { source: "sms-backup-plus", guid: Some("g1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, body: Some("Hello"), sort_order: 0, @@ -488,7 +488,7 @@ async fn integration_priority_prefers_first_imported_source() { let second_imported = MessageRow { source: "go-sms-pro", guid: Some("g2".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, body: Some("Hello"), sort_order: 1, @@ -541,7 +541,7 @@ async fn a_twin_exactly_at_the_window_edge_is_flagged_and_one_past_it_is_not() { let first = MessageRow { source: "go-sms-pro", guid: Some("g1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: false, body: Some("On my way"), sort_order: 0, @@ -602,7 +602,7 @@ async fn two_near_messages_from_one_source_are_both_kept() { MessageRow { source: "go-sms-pro", guid: Some("g1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, body: Some("ok"), sort_order: 0, @@ -613,7 +613,7 @@ async fn two_near_messages_from_one_source_are_both_kept() { let second = MessageRow { source: "go-sms-pro", guid: Some("g2".into()), - timestamp: "2015-03-12T18:04:23Z", + timestamp: "2015-03-12T18:04:23.000Z", is_from_me: true, body: Some("ok"), sort_order: 1, @@ -654,7 +654,7 @@ async fn identical_rows_from_one_source_are_both_kept() { MessageRow { source: "go-sms-pro", guid: Some(guid.into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, body: Some("ok"), sort_order: 0, @@ -692,7 +692,7 @@ async fn an_exact_duplicate_across_three_sources_keeps_one() { MessageRow { source, guid: Some(guid.into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, body: Some("Running late"), sort_order: 0, @@ -849,7 +849,7 @@ async fn the_same_words_from_two_group_members_are_never_near_duplicates() { let from_ann = MessageRow { source: "go-sms-pro", guid: Some("g1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: false, sender_handle_id: Some(ann), body: Some("happy birthday!"), @@ -860,7 +860,7 @@ async fn the_same_words_from_two_group_members_are_never_near_duplicates() { let from_bo = MessageRow { source: "sms-backup-plus", guid: Some("g2".into()), - timestamp: "2015-03-12T18:04:23Z", + timestamp: "2015-03-12T18:04:23.000Z", is_from_me: false, sender_handle_id: Some(bo), body: Some("happy birthday!"), @@ -891,7 +891,7 @@ async fn a_write_that_commits_while_the_pass_reads_does_not_fail_it() { let first = MessageRow { source: "go-sms-pro", guid: Some("g1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, body: Some("Running late"), sort_order: 0, @@ -902,7 +902,7 @@ async fn a_write_that_commits_while_the_pass_reads_does_not_fail_it() { let second = MessageRow { source: "sms-backup-plus", guid: Some("g2".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, body: Some("Running late"), sort_order: 0, @@ -1046,7 +1046,7 @@ async fn an_attachment_added_after_the_first_dedupe_changes_the_content_key() { let first = MessageRow { source: "imessage", guid: Some("a-1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, sender_handle_id: None, body: Some("look"), @@ -1063,7 +1063,7 @@ async fn an_attachment_added_after_the_first_dedupe_changes_the_content_key() { let twin = MessageRow { source: "sms-backup-plus", guid: Some("b-1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, sender_handle_id: None, body: Some("look"), @@ -1103,7 +1103,7 @@ async fn a_participant_added_after_the_first_dedupe_changes_the_group_content_ke let first = MessageRow { source: "imessage", guid: Some("a-1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, sender_handle_id: None, body: Some("dinner at 7?"), @@ -1121,7 +1121,7 @@ async fn a_participant_added_after_the_first_dedupe_changes_the_group_content_ke let twin = MessageRow { source: "sms-backup-plus", guid: Some("b-1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, sender_handle_id: None, body: Some("dinner at 7?"), @@ -1163,7 +1163,7 @@ async fn the_holders_own_address_does_not_change_a_groups_content_key() { MessageRow { source, guid: Some(guid.into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: true, sender_handle_id: None, body: Some("dinner at 7?"), @@ -1195,7 +1195,7 @@ async fn received_notes_to_yourself( let before_link = MessageRow { source: "imazing", guid: Some("a-1".into()), - timestamp: "2015-03-12T18:04:22Z", + timestamp: "2015-03-12T18:04:22.000Z", is_from_me: false, sender_handle_id: Some(holder), body: Some("buy milk"), @@ -1444,7 +1444,7 @@ async fn generate_database(conn: &mut SqliteConnection, seed: u64) -> Vec { guid += 1; let timestamp = chrono::DateTime::from_timestamp(copy_secs, 0) .unwrap() - .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); let conversation_id = chat.conversations[rng.below(chat.conversations.len())]; let source = GEN_SOURCES[rng.below(GEN_SOURCES.len())]; let id = MessageRow { diff --git a/crates/server/server/src/exports_api/tests.rs b/crates/server/server/src/exports_api/tests.rs index 59440ce75..3a81ddb1c 100644 --- a/crates/server/server/src/exports_api/tests.rs +++ b/crates/server/server/src/exports_api/tests.rs @@ -169,7 +169,7 @@ async fn seeded_export_fixture() -> (TestFixture, i64, i64) { id: Some(2), source: "sms", service: Some("sms"), - timestamp: "2020-01-02T00:00:00Z", + timestamp: "2020-01-02T00:00:00.000Z", body: Some("hello two"), ..MessageRow::new(101, conv2) } @@ -296,7 +296,7 @@ async fn a_conversations_query_hides_what_the_conversations_list_hides() { id: Some(9), source: "sms", service: Some("sms"), - timestamp: "2020-01-05T00:00:00Z", + timestamp: "2020-01-05T00:00:00.000Z", body: Some("hello bob"), ..MessageRow::new(202, bobs) } @@ -407,7 +407,7 @@ async fn a_selection_refuses_ids_the_account_does_not_hold_naming_them() { id: Some(99), source: "sms", service: Some("sms"), - timestamp: "2020-02-01T00:00:00Z", + timestamp: "2020-02-01T00:00:00.000Z", body: Some("bob secret"), ..MessageRow::new(102, 99) } @@ -693,13 +693,13 @@ async fn fixture_with_two_conversations() -> (TestFixture, RegisteredAccount, i6 messages: &[ SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body: "pizza tonight", }, SeedMessage { source: "imessage", - timestamp: "2020-01-02T00:00:00Z", + timestamp: "2020-01-02T00:00:00.000Z", is_from_me: false, body: "salad tomorrow", }, @@ -717,7 +717,7 @@ async fn fixture_with_two_conversations() -> (TestFixture, RegisteredAccount, i6 source_file: "backup-a.jsonl", messages: &[SeedMessage { source: "imessage", - timestamp: "2020-01-03T00:00:00Z", + timestamp: "2020-01-03T00:00:00.000Z", is_from_me: false, body: "the menu", }], diff --git a/crates/server/server/src/messages_api/tests.rs b/crates/server/server/src/messages_api/tests.rs index e2d0091a7..1b231c393 100644 --- a/crates/server/server/src/messages_api/tests.rs +++ b/crates/server/server/src/messages_api/tests.rs @@ -24,13 +24,13 @@ async fn seeded() -> (TestFixture, RegisteredAccount, i64, i64) { messages: &[ SeedMessage { source: "imessage", - timestamp: "2024-01-01T10:00:00Z", + timestamp: "2024-01-01T10:00:00.000Z", is_from_me: false, body: "dentist on tuesday", }, SeedMessage { source: "imessage", - timestamp: "2024-01-02T10:00:00Z", + timestamp: "2024-01-02T10:00:00.000Z", is_from_me: true, body: "see you there", }, @@ -48,7 +48,7 @@ async fn seeded() -> (TestFixture, RegisteredAccount, i64, i64) { source_file: "t.json", messages: &[SeedMessage { source: "imessage", - timestamp: "2024-02-01T10:00:00Z", + timestamp: "2024-02-01T10:00:00.000Z", is_from_me: false, body: "the dentist called again", }], @@ -83,7 +83,7 @@ async fn seeded() -> (TestFixture, RegisteredAccount, i64, i64) { source_file: "t.json", messages: &[SeedMessage { source: "imessage", - timestamp: "2024-03-01T10:00:00Z", + timestamp: "2024-03-01T10:00:00.000Z", is_from_me: false, body: "bob's dentist", }], @@ -614,9 +614,9 @@ async fn an_edited_message_is_returned_with_its_earlier_versions_and_their_times edited["earlier_versions"], serde_json::json!([ {"part_index": 0, "text": "Meet at the library", - "edited_at": "2020-01-06T11:10:00Z", "matched": false}, + "edited_at": "2020-01-06T11:10:00.000Z", "matched": false}, {"part_index": 0, "text": "Meet at the museum", - "edited_at": "2020-01-06T11:10:30Z", "matched": false} + "edited_at": "2020-01-06T11:10:30.000Z", "matched": false} ]), "{page}" ); @@ -1605,12 +1605,14 @@ async fn date_today_is_the_day_on_the_accounts_clock() { .single() .unwrap() .with_timezone(&chrono::Utc) - .format("%Y-%m-%dT%H:%M:%SZ") + .format("%Y-%m-%dT%H:%M:%S%.3fZ") .to_string() }; let early_today = local(today, 0, 30); let late_yesterday = local(today.pred_opt().unwrap(), 23, 30); - let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + let now = chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); seed_conversation( &fixture.state, &SeedConversation { @@ -1680,25 +1682,25 @@ async fn seeded_for_relevance() -> (TestFixture, RegisteredAccount) { messages: &[ SeedMessage { source: "imessage", - timestamp: "2024-01-01T10:00:00Z", + timestamp: "2024-01-01T10:00:00.000Z", is_from_me: false, body: "dentist dentist dentist", }, SeedMessage { source: "imessage", - timestamp: "2024-01-02T10:00:00Z", + timestamp: "2024-01-02T10:00:00.000Z", is_from_me: false, body: "after work I will call the office of the dentist about next week", }, SeedMessage { source: "imessage", - timestamp: "2024-01-03T10:00:00Z", + timestamp: "2024-01-03T10:00:00.000Z", is_from_me: true, body: "the dentist moved it", }, SeedMessage { source: "imessage", - timestamp: "2024-01-04T10:00:00Z", + timestamp: "2024-01-04T10:00:00.000Z", is_from_me: true, body: "nothing to see", }, @@ -1895,3 +1897,83 @@ async fn a_conversations_messages_take_no_relevance() { .await; expect_problem(status, &text, ProblemType::ValidationFailed); } + +/// An Apple Messages conversation file whose two messages are 300 ms apart +/// in one second, the later one listed first, and whose earlier message has +/// an earlier version written 125 ms before it was sent (#1096). +fn apple_messages_sub_second_times() -> String { + at_current_schema_version(include_str!( + "../../tests/fixtures/apple-messages-sub-second-times.jsonl" + )) +} + +/// A message time the source records to the millisecond is stored and +/// returned to the millisecond, on the message and on its earlier version +/// (#1096). +#[tokio::test] +async fn a_message_time_keeps_its_milliseconds_through_import_and_the_api() { + let (fixture, alice) = fixture_with_account().await; + let counts = import_conversation_file( + &fixture, + alice.account_id, + "sub-second", + &apple_messages_sub_second_times(), + "imessage", + ) + .await; + assert_eq!(counts.messages, 2); + + let page: serde_json::Value = + get_json(&fixture.state, "/v1/messages?sort=date", &alice.token).await; + let first = message_by_guid(&page, "guid-first"); + assert_eq!(first["timestamp"], "2020-01-06T11:10:00.250Z", "{page}"); + assert_eq!( + first["earlier_versions"][0]["edited_at"], "2020-01-06T11:10:00.125Z", + "{page}" + ); + assert_eq!( + message_by_guid(&page, "guid-300-ms-later")["timestamp"], + "2020-01-06T11:10:00.550Z", + "{page}" + ); +} + +/// Two messages 300 ms apart in one conversation are listed in the order of +/// their times, though the file lists the later one first, on the messages +/// route and on the conversation's own (#1096). +#[tokio::test] +async fn two_messages_300_ms_apart_are_ordered_by_their_times() { + let (fixture, alice) = fixture_with_account().await; + import_conversation_file( + &fixture, + alice.account_id, + "sub-second", + &apple_messages_sub_second_times(), + "imessage", + ) + .await; + + let page: serde_json::Value = + get_json(&fixture.state, "/v1/messages?sort=date", &alice.token).await; + assert_eq!(guids(&page), ["guid-first", "guid-300-ms-later"], "{page}"); + let newest_first: serde_json::Value = + get_json(&fixture.state, "/v1/messages?sort=-date", &alice.token).await; + assert_eq!( + guids(&newest_first), + ["guid-300-ms-later", "guid-first"], + "{newest_first}" + ); + + let conversation_id = &page["items"][0]["conversation"]["id"]; + let thread: serde_json::Value = get_json( + &fixture.state, + &format!("/v1/conversations/{conversation_id}/messages"), + &alice.token, + ) + .await; + assert_eq!( + guids(&thread), + ["guid-first", "guid-300-ms-later"], + "{thread}" + ); +} diff --git a/crates/server/server/src/models.rs b/crates/server/server/src/models.rs index 927fea3dd..2b56a8bde 100644 --- a/crates/server/server/src/models.rs +++ b/crates/server/server/src/models.rs @@ -90,7 +90,9 @@ pub struct MessageRecord { /// The line the message is on in its file or batch, counted from 1 with /// blank lines included, so a refusal of one of its attachments names it. pub line: usize, - /// The instant the message was sent: RFC 3339 in UTC with a `Z` suffix. + /// The instant the message was sent, to the millisecond: RFC 3339 in UTC + /// with three fractional digits and a `Z` suffix + /// (`2015-03-12T18:04:22.250Z`). pub timestamp: String, /// True for messages sent by the account owner. pub is_from_me: bool, @@ -136,8 +138,9 @@ pub struct EarlierVersionRecord { pub part_index: i64, /// The part's text in this version. pub text: String, - /// When this version was written, RFC 3339 in UTC with a `Z` suffix as - /// `MessageRecord::timestamp`; `None` when the source does not record it. + /// When this version was written, to the millisecond in the form + /// `MessageRecord::timestamp` takes; `None` when the source does not + /// record it. pub edited_at: Option, } @@ -336,8 +339,7 @@ fn message_from_ir( header_owner: Option<&str>, line: usize, ) -> Result { - let secs = msg.timestamp_unix_ms.div_euclid(1000); - let timestamp = format_utc_timestamp(secs).with_context(|| { + let timestamp = format_utc_timestamp(msg.timestamp_unix_ms).with_context(|| { format!( "unrepresentable timestamp_unix_ms {}", msg.timestamp_unix_ms @@ -403,7 +405,7 @@ fn earlier_version_from_ir(version: &EarlierVersion) -> Result TapbackRecord { } } -/// The UTC RFC 3339 string (`Z` suffix) for a Unix timestamp, or `None` when -/// it cannot be represented. The server stores the instant and nothing about -/// where the phone was; the account's time zone turns it into a clock reading. -fn format_utc_timestamp(secs: i64) -> Option { +/// The UTC RFC 3339 string for a Unix time in milliseconds, or `None` when it +/// cannot be represented. It always has three fractional digits and a `Z` +/// suffix (`2015-03-12T18:04:22.000Z` for a whole second), so every stored +/// time has one form and sorts as text in time order. The server stores the +/// instant and nothing about where the phone was; the account's time zone +/// turns it into a clock reading. +fn format_utc_timestamp(ms: i64) -> Option { Some( - Utc.timestamp_opt(secs, 0) + Utc.timestamp_millis_opt(ms) .single()? - .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true), ) } diff --git a/crates/server/server/src/openapi/credential_matrix.rs b/crates/server/server/src/openapi/credential_matrix.rs index 47c933398..a060ee33b 100644 --- a/crates/server/server/src/openapi/credential_matrix.rs +++ b/crates/server/server/src/openapi/credential_matrix.rs @@ -445,7 +445,7 @@ impl<'a> World<'a> { source_file: "seed.jsonl", messages: &[SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body: "hello", }], diff --git a/crates/server/server/src/reset_demo/tests.rs b/crates/server/server/src/reset_demo/tests.rs index 03a4faa63..01e1d7d46 100644 --- a/crates/server/server/src/reset_demo/tests.rs +++ b/crates/server/server/src/reset_demo/tests.rs @@ -1140,7 +1140,7 @@ async fn seed_reset_test_account(conn: &mut SqliteConnection, account_id: i64, g .expect("insert reset test conversation"); MessageRow { guid: Some(guid.into()), - timestamp: "2026-01-01T00:00:00Z", + timestamp: "2026-01-01T00:00:00.000Z", body: Some("keep me"), ..MessageRow::new(account_id, conversation_id) } @@ -1909,7 +1909,7 @@ async fn seed_previous_demo(db: &Path, data_dir: &Path) -> PathBuf { MessageRow { source: "whatsapp", guid: Some("previous-demo-message".into()), - timestamp: "2026-01-01T00:00:00Z", + timestamp: "2026-01-01T00:00:00.000Z", body: Some("from the previous demo"), ..MessageRow::new(DEMO_ACCOUNT_ID, conversation_id) } @@ -2705,7 +2705,7 @@ async fn seed_bulky_demo(db: &Path) { MessageRow { source: "whatsapp", guid: Some(format!("previous-{i}")), - timestamp: "2026-01-01T00:00:00Z", + timestamp: "2026-01-01T00:00:00.000Z", body: Some(&body), sort_order: i, ..MessageRow::new(DEMO_ACCOUNT_ID, conversation_id) @@ -2852,7 +2852,7 @@ async fn the_wipe_deletes_duplicates_before_the_messages_they_duplicate() { MessageRow { source: "sms", guid: Some(guid.into()), - timestamp: "2026-01-01T00:00:00Z", + timestamp: "2026-01-01T00:00:00.000Z", body: Some("hello"), duplicate_of, ..MessageRow::new(DEMO_ACCOUNT_ID, conversation_id) diff --git a/crates/server/server/src/search/tests.rs b/crates/server/server/src/search/tests.rs index 6629fd17a..4a7ee12b7 100644 --- a/crates/server/server/src/search/tests.rs +++ b/crates/server/server/src/search/tests.rs @@ -2617,6 +2617,43 @@ mod measure_words { ); } + /// A day begins at its first millisecond: a message sent then is on that + /// day, and one sent a millisecond before is on the day before. The + /// stored time has milliseconds, so the day's start is compared in the + /// same text form (#1096). + #[tokio::test] + async fn a_day_begins_at_its_first_millisecond() { + let (pool, _dir, f) = seeded().await; + let mut conn = pool.acquire().await.unwrap(); + let c = f.ana_direct; + let h = Some(f.ana_handle); + let last_of_april = message( + &mut conn, + ACCOUNT, + msg(c, "2013-04-30T23:59:59.999Z", false, h, "a"), + ) + .await; + let first_of_may = message( + &mut conn, + ACCOUNT, + msg(c, "2013-05-01T00:00:00.000Z", false, h, "b"), + ) + .await; + let m = ListKind::Messages; + assert_eq!( + run(&mut conn, m, "date:2013-05-01").await, + vec![first_of_may] + ); + assert_eq!( + run(&mut conn, m, "date:2013-04-30").await, + vec![last_of_april] + ); + assert_eq!( + run(&mut conn, m, "date:>=2013-05-01 date:<2013-06").await, + vec![first_of_may] + ); + } + /// Year 9999 ends at the start of year 10000, which in UTC or west of it /// is an instant chrono writes `+10000-…`. As text that sorts before every /// stored timestamp, so the comparison went the wrong way (#1205). diff --git a/crates/server/server/src/search/value.rs b/crates/server/server/src/search/value.rs index 372c675b6..686e9ae49 100644 --- a/crates/server/server/src/search/value.rs +++ b/crates/server/server/src/search/value.rs @@ -59,20 +59,20 @@ pub(crate) enum Value { } /// The instant `day` begins in `zone`, as the RFC 3339 UTC text the server -/// stores (`2024-01-01T05:00:00Z`), so a day or a year in the account's time +/// stores (`2024-01-01T05:00:00.000Z`), so a day or a year in the account's time /// zone compares against `messages.timestamp` as text. A day whose midnight /// falls in a daylight-saving gap starts at the first instant after the gap. /// A day the zone skipped whole (Pacific/Apia, 30 December 2011) starts where /// the next day starts, so it holds no instant. /// /// `None` when the instant falls after year 9999: RFC 3339 text for it has a -/// sign and five digits (`+10000-01-01T00:00:00Z`), and `+` sorts before +/// sign and five digits (`+10000-01-01T00:00:00.000Z`), and `+` sorts before /// every digit, so as text it would come before every stored timestamp /// instead of after them all. pub(crate) fn utc_instant(zone: chrono_tz::Tz, day: NaiveDate) -> Option { let midnight = day.and_hms_opt(0, 0, 0).expect("midnight is a valid time"); let instant = first_instant_at_or_after(zone, midnight); - (instant.year() <= 9999).then(|| instant.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) + (instant.year() <= 9999).then(|| instant.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) } /// The first instant whose clock reading in `zone` is `local` or later. @@ -85,11 +85,14 @@ fn first_instant_at_or_after(zone: chrono_tz::Tz, local: NaiveDateTime) -> DateT // Every UTC offset is shorter than a day, so a day before `local` read as // UTC the clock shows an earlier time, and a day after it a later one. // The gap's end lies between. A binary search over whole seconds finds - // it, because every transition falls on a whole second. + // it, because every transition falls on a whole second. Each step is a + // whole number of seconds, so the instant found has no fraction of a + // second: halving an odd number of seconds once left one, and the + // millisecond text the day's start is compared in would carry it. let mut before = local.and_utc() - Duration::days(1); let mut after = local.and_utc() + Duration::days(1); while after - before > Duration::seconds(1) { - let mid = before + (after - before) / 2; + let mid = before + Duration::seconds((after - before).num_seconds() / 2); if mid.with_timezone(&zone).naive_local() >= local { after = mid; } else { @@ -481,19 +484,19 @@ mod tests { let zone = chrono_tz::Pacific::Apia; assert_eq!( utc_instant(zone, d(2011, 12, 29)).as_deref(), - Some("2011-12-29T10:00:00Z") + Some("2011-12-29T10:00:00.000Z") ); assert_eq!( utc_instant(zone, d(2011, 12, 30)).as_deref(), - Some("2011-12-30T10:00:00Z") + Some("2011-12-30T10:00:00.000Z") ); assert_eq!( utc_instant(zone, d(2011, 12, 31)).as_deref(), - Some("2011-12-30T10:00:00Z") + Some("2011-12-30T10:00:00.000Z") ); assert_eq!( utc_instant(zone, d(2012, 1, 1)).as_deref(), - Some("2011-12-31T10:00:00Z") + Some("2011-12-31T10:00:00.000Z") ); } @@ -520,7 +523,7 @@ mod tests { fn a_midnight_in_a_one_hour_gap_starts_the_day_after_the_gap() { assert_eq!( utc_instant(chrono_tz::America::Sao_Paulo, d(2018, 11, 4)).as_deref(), - Some("2018-11-04T03:00:00Z") + Some("2018-11-04T03:00:00.000Z") ); } } diff --git a/crates/server/server/src/server_api/tests.rs b/crates/server/server/src/server_api/tests.rs index 88b34572e..9575ecdbf 100644 --- a/crates/server/server/src/server_api/tests.rs +++ b/crates/server/server/src/server_api/tests.rs @@ -644,7 +644,7 @@ async fn the_owner_reads_the_server_totals_summed_over_every_account() { .iter() .map(|body| SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body, }) diff --git a/crates/server/server/src/session_api/tests.rs b/crates/server/server/src/session_api/tests.rs index 9ea2c26ad..d79f5f5a0 100644 --- a/crates/server/server/src/session_api/tests.rs +++ b/crates/server/server/src/session_api/tests.rs @@ -233,7 +233,7 @@ async fn seed_source(state: &crate::server::AppState, account_id: i64, source: & source_file: "seed.jsonl", messages: &[SeedMessage { source, - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body: "hello", }], diff --git a/crates/server/server/src/test_support.rs b/crates/server/server/src/test_support.rs index 480f157ee..cb84904d0 100644 --- a/crates/server/server/src/test_support.rs +++ b/crates/server/server/src/test_support.rs @@ -770,7 +770,8 @@ pub async fn delete_raw(state: &AppState, path: &str, token: &str) -> (StatusCod pub struct SeedMessage<'a> { /// The `messages.source` slug, such as `imessage`. pub source: &'a str, - /// RFC 3339 timestamp, stored as text the way the importer writes it. + /// RFC 3339 timestamp, stored as text the way the importer writes it: UTC + /// to the millisecond (`2020-01-01T00:00:00.000Z`). pub timestamp: &'a str, /// Whether the account sent it. pub is_from_me: bool, @@ -834,7 +835,7 @@ pub struct MessageRow<'a> { pub source: &'a str, /// `messages.guid`. `None` writes NULL, which the table refuses. pub guid: Option, - /// RFC 3339 in UTC, as the importer writes it. + /// RFC 3339 in UTC to the millisecond, as the importer writes it. pub timestamp: &'a str, /// Whether the account sent it. pub is_from_me: bool, @@ -870,7 +871,7 @@ pub struct MessageRow<'a> { impl MessageRow<'_> { /// A received `imessage` message in `conversation_id` of `account_id`, - /// with a fresh guid, at 2020-01-01T00:00:00Z, and nothing optional set. + /// with a fresh guid, at 2020-01-01T00:00:00.000Z, and nothing optional set. pub fn new(account_id: i64, conversation_id: i64) -> Self { Self { id: None, @@ -878,7 +879,7 @@ impl MessageRow<'_> { account_id, source: "imessage", guid: Some(unique_guid()), - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: false, sender_handle_id: None, owner_handle_id: None, @@ -1069,7 +1070,7 @@ pub async fn seed_one_message(state: &AppState, account_id: i64) { source_file: "seed.jsonl", messages: &[SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body: "hello", }], @@ -1250,13 +1251,13 @@ mod tests { messages: &[ SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body: "first", }, SeedMessage { source: "imessage", - timestamp: "2020-01-02T00:00:00Z", + timestamp: "2020-01-02T00:00:00.000Z", is_from_me: false, body: "second", }, @@ -1301,7 +1302,7 @@ mod tests { source_file: "seed.jsonl", messages: &[SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body: "hello, this is a message long enough to add up", }], diff --git a/crates/server/server/src/trash_api/tests.rs b/crates/server/server/src/trash_api/tests.rs index 92933b138..10d7f9fca 100644 --- a/crates/server/server/src/trash_api/tests.rs +++ b/crates/server/server/src/trash_api/tests.rs @@ -19,7 +19,7 @@ async fn seed(fixture: &TestFixture, account: &RegisteredAccount, handle: &str) source_file: "seed.jsonl", messages: &[SeedMessage { source: "imessage", - timestamp: "2020-01-01T00:00:00Z", + timestamp: "2020-01-01T00:00:00.000Z", is_from_me: true, body: "hello", }], diff --git a/crates/server/server/tests/fixtures/apple-messages-sub-second-times.jsonl b/crates/server/server/tests/fixtures/apple-messages-sub-second-times.jsonl new file mode 100644 index 000000000..04a008e8d --- /dev/null +++ b/crates/server/server/tests/fixtures/apple-messages-sub-second-times.jsonl @@ -0,0 +1,3 @@ +{"schema_version":10,"export":{"source":"imessage","tool":"imessage-ir-exporter","tool_version":"0.1.0","owner_identity":"+15555550106","owner_display_name":null},"conversation":{"chat_identifier":"+15555550107","conversation_type":"individual","group_title":null,"participants":[{"identity":"+15555550107","display_name":null,"identity_type":"phone"}],"stats":{"message_count":2,"attachment_count":0,"first_timestamp_unix_ms":1578309000250,"last_timestamp_unix_ms":1578309000550}}} +{"guid":"guid-300-ms-later","timestamp_unix_ms":1578309000550,"direction":"incoming","service":"imessage","message_kind":"imessage","sender_identity":"+15555550107","sender_display_name":null,"owner_identity":"+15555550106","subject":null,"text":"On my way","attachments":[],"imessage":null,"source":null} +{"guid":"guid-first","timestamp_unix_ms":1578309000250,"direction":"outgoing","service":"imessage","message_kind":"imessage","sender_identity":"+15555550106","sender_display_name":null,"owner_identity":"+15555550106","subject":null,"text":"Meet at the bakery","attachments":[],"edits":[{"part_index":0,"text":"Meet at the library","edited_at_unix_ms":1578309000125}],"imessage":null,"source":null} diff --git a/docs/architecture/contacts-identities-and-messages.md b/docs/architecture/contacts-identities-and-messages.md index 53409dbef..19acb9caf 100644 --- a/docs/architecture/contacts-identities-and-messages.md +++ b/docs/architecture/contacts-identities-and-messages.md @@ -361,7 +361,7 @@ of a group, or one chat id written two ways, merges into the conversation already there. The merged conversation keeps the group title of the copy whose latest message is later. A copy with no title never clears a title, and when both copies' latest messages share one time, the title already stored stays. -Times are compared to the second, the precision a message's time is stored at. +Times are compared to the millisecond, the precision a message's time is stored at. The conversation stores the latest message time of the copy that gave its title (`group_title_at`), and an incoming copy is compared with that, not with the whole conversation: an untitled copy whose messages end last would diff --git a/docs/src/assets/openapi.json b/docs/src/assets/openapi.json index 9169a7b53..e16d3d703 100644 --- a/docs/src/assets/openapi.json +++ b/docs/src/assets/openapi.json @@ -14682,7 +14682,7 @@ "string", "null" ], - "description": "When this version was written: RFC 3339 in UTC with a `Z` suffix,\nas `Message.timestamp`. The original's is when it was sent, a\nlater version's is when the edit that wrote it was made. `None`\nwhen the source does not record it." + "description": "When this version was written, to the millisecond in the form\n`Message.timestamp` takes. The original's is when it was sent, a\nlater version's is when the edit that wrote it was made. `None`\nwhen the source does not record it." }, "matched": { "type": "boolean", @@ -15840,7 +15840,7 @@ }, "timestamp": { "type": "string", - "description": "The instant the message was sent: RFC 3339 in UTC with a `Z`\nsuffix. A caller shows it in the account's time zone\n(`Account.time_zone`); the database stores nothing\nabout where the phone was." + "description": "The instant the message was sent, to the millisecond: RFC 3339\nin UTC with three fractional digits and a `Z` suffix\n(`2015-03-12T18:04:22.250Z`; `.000` when the source records\nwhole seconds). Messages are listed in the order of this time. A\ncaller shows it in the account's time zone\n(`Account.time_zone`); the database stores nothing\nabout where the phone was." } } }, @@ -17867,7 +17867,7 @@ }, "timestamp": { "type": "string", - "description": "The instant the message was sent: RFC 3339 in UTC with a `Z`\nsuffix. A caller shows it in the account's time zone\n(`Account.time_zone`); the database stores nothing\nabout where the phone was." + "description": "The instant the message was sent, to the millisecond: RFC 3339\nin UTC with three fractional digits and a `Z` suffix\n(`2015-03-12T18:04:22.250Z`; `.000` when the source records\nwhole seconds). Messages are listed in the order of this time. A\ncaller shows it in the account's time zone\n(`Account.time_zone`); the database stores nothing\nabout where the phone was." } } }, diff --git a/schema/sql/messages.sql b/schema/sql/messages.sql index 373e1a671..f74d51d45 100644 --- a/schema/sql/messages.sql +++ b/schema/sql/messages.sql @@ -56,8 +56,11 @@ CREATE TABLE IF NOT EXISTS messages ( -- Source-native message id; used for exact dedupe with source. The import -- refuses a message without one. guid TEXT NOT NULL CHECK (guid != ''), - -- The instant the message was sent, RFC 3339 in UTC with a Z suffix. Shown, searched - -- and filed by day and year in the account's time zone (accounts.time_zone). + -- The instant the message was sent, to the millisecond: RFC 3339 in UTC + -- with three fractional digits and a Z suffix (2015-03-12T18:04:22.250Z; + -- .000 for a source that records whole seconds). One fixed form, so the + -- text sorts in time order and lists order by it. Shown, searched and + -- filed by day and year in the account's time zone (accounts.time_zone). timestamp TEXT NOT NULL, -- 1 = sent by the account holder; 0 = received from someone else. is_from_me INTEGER NOT NULL, @@ -229,8 +232,8 @@ CREATE TABLE IF NOT EXISTS message_versions ( part_index INTEGER NOT NULL DEFAULT 0, -- The part's text in this version. text TEXT NOT NULL, - -- When this version was written, RFC 3339 in UTC with a Z suffix, as - -- messages.timestamp: the send time for the original, the edit's time for + -- When this version was written, to the millisecond in the form + -- messages.timestamp holds: the send time for the original, the edit's time for -- a later one. NULL when the source does not record it. edited_at TEXT ); diff --git a/schema/sql/staging.sql b/schema/sql/staging.sql index 84fd99b08..5d015b579 100644 --- a/schema/sql/staging.sql +++ b/schema/sql/staging.sql @@ -46,8 +46,8 @@ CREATE TABLE IF NOT EXISTS staging_messages ( -- The message's id from the export: Apple's own for Apple Messages, otherwise -- the exporter's MessageGuid. Never empty; the import refuses a message without one. guid TEXT NOT NULL CHECK (guid != ''), - -- The instant the message was sent, RFC 3339 in UTC with a Z suffix. Shown, searched - -- and filed by day and year in the account's time zone (accounts.time_zone). + -- The instant the message was sent, in the form messages.timestamp holds: + -- RFC 3339 in UTC to the millisecond (2015-03-12T18:04:22.250Z). timestamp TEXT NOT NULL, -- 1 = sent by the account holder; 0 = received from someone else. is_from_me INTEGER NOT NULL, diff --git a/web/src/lib/serverApi.types.ts b/web/src/lib/serverApi.types.ts index 1b70b933a..ec6c38763 100644 --- a/web/src/lib/serverApi.types.ts +++ b/web/src/lib/serverApi.types.ts @@ -2650,8 +2650,8 @@ export interface components { /** @description One earlier version of one part of an edited message. */ EarlierVersion: { /** - * @description When this version was written: RFC 3339 in UTC with a `Z` suffix, - * as `Message.timestamp`. The original's is when it was sent, a + * @description When this version was written, to the millisecond in the form + * `Message.timestamp` takes. The original's is when it was sent, a * later version's is when the edit that wrote it was made. `None` * when the source does not record it. */ @@ -3367,8 +3367,11 @@ export interface components { /** @description Body text, when present. */ text: string | null; /** - * @description The instant the message was sent: RFC 3339 in UTC with a `Z` - * suffix. A caller shows it in the account's time zone + * @description The instant the message was sent, to the millisecond: RFC 3339 + * in UTC with three fractional digits and a `Z` suffix + * (`2015-03-12T18:04:22.250Z`; `.000` when the source records + * whole seconds). Messages are listed in the order of this time. A + * caller shows it in the account's time zone * (`Account.time_zone`); the database stores nothing * about where the phone was. */ @@ -4420,8 +4423,11 @@ export interface components { /** @description Body text, when present. */ text: string | null; /** - * @description The instant the message was sent: RFC 3339 in UTC with a `Z` - * suffix. A caller shows it in the account's time zone + * @description The instant the message was sent, to the millisecond: RFC 3339 + * in UTC with three fractional digits and a `Z` suffix + * (`2015-03-12T18:04:22.250Z`; `.000` when the source records + * whole seconds). Messages are listed in the order of this time. A + * caller shows it in the account's time zone * (`Account.time_zone`); the database stores nothing * about where the phone was. */ From 87c5cf08159369deec36b5d2447c272ace10c1e3 Mon Sep 17 00:00:00 2001 From: Matt Beisser <225018+mbeisser1@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:46:18 -0400 Subject: [PATCH 2/3] 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 --- crates/server/server/src/dedupe.rs | 2 +- crates/server/server/src/dedupe/tests.rs | 6 ++--- .../server/server/src/messages_api/tests.rs | 12 ++++------ crates/server/server/src/models.rs | 24 ++++++++++--------- crates/server/server/src/search/value.rs | 2 +- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/crates/server/server/src/dedupe.rs b/crates/server/server/src/dedupe.rs index d749e6bd7..59ae45156 100644 --- a/crates/server/server/src/dedupe.rs +++ b/crates/server/server/src/dedupe.rs @@ -660,7 +660,7 @@ fn pick_winner(cands: &[Cand], prio: &HashMap<&str, usize>) -> i64 { /// pass match at whole seconds. /// /// Strict RFC3339 is sufficient: `messages.timestamp` -/// are only ever written by `models::format_utc_timestamp` (chrono's +/// are only ever written by `models::utc_timestamp_text` (chrono's /// `to_rfc3339_opts(SecondsFormat::Millis, true)`), so no lenient spellings reach /// this path. Unparseable input yields `None`. fn parse_rfc3339_utc_secs(ts: &str) -> Option { diff --git a/crates/server/server/src/dedupe/tests.rs b/crates/server/server/src/dedupe/tests.rs index cc212dd8f..7fe715a6a 100644 --- a/crates/server/server/src/dedupe/tests.rs +++ b/crates/server/server/src/dedupe/tests.rs @@ -1442,9 +1442,9 @@ async fn generate_database(conn: &mut SqliteConnection, seed: u64) -> Vec { _ => {} } guid += 1; - let timestamp = chrono::DateTime::from_timestamp(copy_secs, 0) - .unwrap() - .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let timestamp = crate::models::utc_timestamp_text( + chrono::DateTime::from_timestamp(copy_secs, 0).unwrap(), + ); let conversation_id = chat.conversations[rng.below(chat.conversations.len())]; let source = GEN_SOURCES[rng.below(GEN_SOURCES.len())]; let id = MessageRow { diff --git a/crates/server/server/src/messages_api/tests.rs b/crates/server/server/src/messages_api/tests.rs index 1b231c393..081cb9e50 100644 --- a/crates/server/server/src/messages_api/tests.rs +++ b/crates/server/server/src/messages_api/tests.rs @@ -1601,18 +1601,16 @@ async fn date_today_is_the_day_on_the_accounts_clock() { let today = chrono::Utc::now().with_timezone(&zone).date_naive(); let local = |day: chrono::NaiveDate, h: u32, m: u32| { - zone.from_local_datetime(&day.and_hms_opt(h, m, 0).unwrap()) + let instant = zone + .from_local_datetime(&day.and_hms_opt(h, m, 0).unwrap()) .single() .unwrap() - .with_timezone(&chrono::Utc) - .format("%Y-%m-%dT%H:%M:%S%.3fZ") - .to_string() + .with_timezone(&chrono::Utc); + crate::models::utc_timestamp_text(instant) }; let early_today = local(today, 0, 30); let late_yesterday = local(today.pred_opt().unwrap(), 23, 30); - let now = chrono::Utc::now() - .format("%Y-%m-%dT%H:%M:%S%.3fZ") - .to_string(); + let now = crate::models::utc_timestamp_text(chrono::Utc::now()); seed_conversation( &fixture.state, &SeedConversation { diff --git a/crates/server/server/src/models.rs b/crates/server/server/src/models.rs index c81af8212..22661a5bf 100644 --- a/crates/server/server/src/models.rs +++ b/crates/server/server/src/models.rs @@ -1,7 +1,7 @@ //! Import-side records mapped from message-ir JSONL. use anyhow::{Context, Result}; -use chrono::{TimeZone, Utc}; +use chrono::{DateTime, TimeZone, Utc}; use message_ir::{ ConversationHeader, Deletion, EarlierVersion, HandleService, HandleType, IrAttachment, IrDirection, IrMessage, IrMessageKind, IrParticipant, Reaction, ReplyTo, @@ -503,17 +503,19 @@ fn tapback_from_reaction(reaction: &Reaction) -> TapbackRecord { } /// The UTC RFC 3339 string for a Unix time in milliseconds, or `None` when it -/// cannot be represented. It always has three fractional digits and a `Z` -/// suffix (`2015-03-12T18:04:22.000Z` for a whole second), so every stored -/// time has one form and sorts as text in time order. The server stores the -/// instant and nothing about where the phone was; the account's time zone -/// turns it into a clock reading. +/// cannot be represented, in the form `utc_timestamp_text` writes. The server +/// stores the instant and nothing about where the phone was; the account's +/// time zone turns it into a clock reading. fn format_utc_timestamp(ms: i64) -> Option { - Some( - Utc.timestamp_millis_opt(ms) - .single()? - .to_rfc3339_opts(chrono::SecondsFormat::Millis, true), - ) + Some(utc_timestamp_text(Utc.timestamp_millis_opt(ms).single()?)) +} + +/// The one text form of a stored message time: UTC RFC 3339 with three +/// fractional digits and a `Z` suffix (`2015-03-12T18:04:22.000Z` for a whole +/// second). Every stored time and every string compared with one, such as a +/// search day bound, is written here, so they all sort as text in time order. +pub(crate) fn utc_timestamp_text(instant: DateTime) -> String { + instant.to_rfc3339_opts(chrono::SecondsFormat::Millis, true) } #[cfg(test)] diff --git a/crates/server/server/src/search/value.rs b/crates/server/server/src/search/value.rs index 686e9ae49..2637923e5 100644 --- a/crates/server/server/src/search/value.rs +++ b/crates/server/server/src/search/value.rs @@ -72,7 +72,7 @@ pub(crate) enum Value { pub(crate) fn utc_instant(zone: chrono_tz::Tz, day: NaiveDate) -> Option { let midnight = day.and_hms_opt(0, 0, 0).expect("midnight is a valid time"); let instant = first_instant_at_or_after(zone, midnight); - (instant.year() <= 9999).then(|| instant.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) + (instant.year() <= 9999).then(|| crate::models::utc_timestamp_text(instant)) } /// The first instant whose clock reading in `zone` is `local` or later. From 2d1f6386e9cddf801a861a7eb23319a0722624a5 Mon Sep 17 00:00:00 2001 From: Matt Beisser <225018+mbeisser1@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:47:07 -0400 Subject: [PATCH 3/3] docs(server): name the stored time writer once, and where it lives Co-Authored-By: Claude Opus 5.5 --- crates/server/server/src/dedupe.rs | 7 +++---- crates/server/server/src/models.rs | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/server/server/src/dedupe.rs b/crates/server/server/src/dedupe.rs index 59ae45156..3a64ba25b 100644 --- a/crates/server/server/src/dedupe.rs +++ b/crates/server/server/src/dedupe.rs @@ -659,10 +659,9 @@ fn pick_winner(cands: &[Cand], prio: &HashMap<&str, usize>) -> i64 { /// offsets and dropping the milliseconds: the content key and the near-time /// pass match at whole seconds. /// -/// Strict RFC3339 is sufficient: `messages.timestamp` -/// are only ever written by `models::utc_timestamp_text` (chrono's -/// `to_rfc3339_opts(SecondsFormat::Millis, true)`), so no lenient spellings reach -/// this path. Unparseable input yields `None`. +/// Strict RFC3339 is sufficient: `messages.timestamp` is only ever written by +/// `models::utc_timestamp_text`, so no lenient spellings reach this path. +/// Unparseable input yields `None`. fn parse_rfc3339_utc_secs(ts: &str) -> Option { chrono::DateTime::parse_from_rfc3339(ts.trim()) .ok() diff --git a/crates/server/server/src/models.rs b/crates/server/server/src/models.rs index 22661a5bf..3a558d855 100644 --- a/crates/server/server/src/models.rs +++ b/crates/server/server/src/models.rs @@ -1,4 +1,6 @@ -//! Import-side records mapped from message-ir JSONL. +//! Import-side records mapped from message-ir JSONL, and the one text form +//! of a stored message time (`utc_timestamp_text`), which search day bounds +//! use too. use anyhow::{Context, Result}; use chrono::{DateTime, TimeZone, Utc};