From 0101b054873b03b5181e27c0207e7b0a3793997a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 14:18:33 -0400 Subject: [PATCH] Stored timestamps have one writer: local::iso and local::now The store's time format (RFC 3339, UTC, milliseconds, Z) is what makes lexical order chronological. It was spelled out ten times as to_rfc3339_opts(SecondsFormat::Millis, true), with two private now() copies (store, pull). local::iso(t) and local::now() now hold it; store, pull, engine and portable call them. DRY pass #2, batch 2, F5 (#5372). Co-Authored-By: Claude Opus 5.5 --- core/src/local/mod.rs | 12 ++++++++++++ core/src/local/portable.rs | 17 ++++++----------- core/src/local/store.rs | 14 +++++--------- core/src/sync/engine.rs | 6 ++---- core/src/sync/pull.rs | 7 +------ 5 files changed, 26 insertions(+), 30 deletions(-) diff --git a/core/src/local/mod.rs b/core/src/local/mod.rs index 47a9b4d..fa0fffb 100644 --- a/core/src/local/mod.rs +++ b/core/src/local/mod.rs @@ -15,6 +15,7 @@ pub mod store; use std::path::Path; use std::sync::Mutex; +use chrono::{DateTime, SecondsFormat, Utc}; use rusqlite::Connection; /// The store's file inside a client's data directory. The layout is the same on @@ -23,6 +24,17 @@ pub const DB_FILE: &str = "inkwell.db"; /// Attachment bytes, filed by content hash, beside [`DB_FILE`]. pub const BLOBS_DIR: &str = "blobs"; +/// An instant in the store's own form: RFC 3339, UTC, milliseconds, `Z`. Every +/// stored time is written this way, which is what makes lexical order chronological. +pub fn iso(t: DateTime) -> String { + t.to_rfc3339_opts(SecondsFormat::Millis, true) +} + +/// Now, in the store's own form ([`iso`]). +pub fn now() -> String { + iso(Utc::now()) +} + /// The shared database handle. rusqlite connections aren't `Sync`, so a `Mutex` /// serializes access — fine, since operations are quick and a client is single-user. /// How it is held is the caller's business: Tauri manages it as state, Android holds diff --git a/core/src/local/portable.rs b/core/src/local/portable.rs index aebef20..7d3f744 100644 --- a/core/src/local/portable.rs +++ b/core/src/local/portable.rs @@ -13,7 +13,7 @@ use std::io::{Cursor, Read, Write}; -use chrono::{DateTime, SecondsFormat, Utc}; +use chrono::{DateTime, Utc}; use rusqlite::{params, Connection}; use serde::Serialize; use serde_json::{json, Map, Value}; @@ -21,7 +21,7 @@ use zip::write::SimpleFileOptions; use zip::{CompressionMethod, ZipArchive, ZipWriter}; use super::models::{Note, NoteCreateInput}; -use super::{derive, recur, store}; +use super::{derive, iso, now, recur, store}; use crate::sync::blobs::BlobStore; /// The `app` an export's notes.json carries. "thoughtsync" is what every export @@ -128,7 +128,7 @@ pub fn export_zip(conn: &Connection, blobs: &BlobStore) -> Result, Strin let doc = json!({ "app": "inkwell", "version": 1, - "exported_at": Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + "exported_at": now(), "labels": labels, "notes": notes, }); @@ -508,9 +508,7 @@ fn create_imported( // Last, because adding an attachment touches the note: the source's own times are // what this note should carry, not the moment it was imported. - let trashed_at = spec - .trashed - .then(|| Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)); + let trashed_at = spec.trashed.then(now); conn.execute( "UPDATE notes SET pinned = ?1, archived = ?2, remind_at = ?3, recurrence = ?4, trashed = ?5, trashed_at = ?6, @@ -561,16 +559,13 @@ fn strings(v: Option<&Value>) -> Vec { /// Any RFC 3339 instant, in the store's own form (UTC, milliseconds, `Z`). Anything /// else is treated as absent, as the server's `parse_dt` does. fn instant(raw: &str) -> Option { - DateTime::parse_from_rfc3339(raw).ok().map(|t| { - t.with_timezone(&Utc) - .to_rfc3339_opts(SecondsFormat::Millis, true) - }) + DateTime::parse_from_rfc3339(raw).ok().map(|t| iso(t.with_timezone(&Utc))) } /// Keep's timestamps: integer microseconds since the epoch. fn usec_instant(v: &Value) -> Option { let usec = v.as_i64().or_else(|| v.as_str()?.parse().ok())?; - DateTime::from_timestamp_micros(usec).map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true)) + DateTime::from_timestamp_micros(usec).map(iso) } /// The image types the server infers from a name when the source gave no mime. diff --git a/core/src/local/store.rs b/core/src/local/store.rs index 777f4e7..a8ef48a 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -7,7 +7,7 @@ //! ("YYYY-MM-DDTHH:MM:SS.sssZ") so string ordering and date-range comparisons line //! up with the values the frontend sends. -use chrono::{DateTime, Duration, SecondsFormat, Utc}; +use chrono::{DateTime, Duration, Utc}; use rusqlite::{params, params_from_iter, Connection, OptionalExtension, Params}; use serde_json::{json, Value}; use uuid::Uuid; @@ -15,10 +15,7 @@ use uuid::Uuid; use crate::local::derive; use crate::local::models::*; use crate::local::recur; - -fn now() -> String { - Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) -} +use crate::local::{iso, now}; fn new_id() -> String { Uuid::new_v4().to_string() @@ -571,8 +568,7 @@ fn should_snapshot(conn: &Connection, id: &str, new_body: &str) -> rusqlite::Res } // String comparison, not date maths: timestamps are RFC3339 UTC with a fixed // millisecond field (see the module header), so lexical order IS chronological. - let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES)) - .to_rfc3339_opts(SecondsFormat::Millis, true); + let cutoff = iso(Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES)); let recent: i64 = conn.query_row( "SELECT COUNT(*) FROM note_revisions WHERE note_id = ?1 AND created_at >= ?2", params![id, cutoff], @@ -700,7 +696,7 @@ pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result match next { Some(at) => conn.execute( "UPDATE notes SET remind_at = ?1 WHERE id = ?2", - params![at.to_rfc3339_opts(SecondsFormat::Millis, true), id], + params![iso(at), id], )?, None => conn.execute( "UPDATE notes SET remind_at = NULL, recurrence = NULL WHERE id = ?1", @@ -719,7 +715,7 @@ pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::R require_owner(conn, id)?; // 0 or less would set a reminder in the past; a huge value overflows `Duration`. let minutes = minutes.clamp(1, SNOOZE_MAX_MINUTES); - let t = (Utc::now() + Duration::minutes(minutes)).to_rfc3339_opts(SecondsFormat::Millis, true); + let t = iso(Utc::now() + Duration::minutes(minutes)); conn.execute( "UPDATE notes SET remind_at = ?1 WHERE id = ?2", params![t, id], diff --git a/core/src/sync/engine.rs b/core/src/sync/engine.rs index 0e95a3c..d3a3df7 100644 --- a/core/src/sync/engine.rs +++ b/core/src/sync/engine.rs @@ -4,14 +4,13 @@ //! own inside this crate, but exposing them separately would let a caller pull //! without pushing, which quietly overwrites unsent local edits. -use chrono::{SecondsFormat, Utc}; use serde::Serialize; use super::blobs::BlobStore; use super::pull; use super::push; use super::state; -use crate::local::Db; +use crate::local::{self, Db}; #[derive(Debug, Clone, Serialize)] pub struct SyncOutcome { @@ -102,8 +101,7 @@ pub async fn run_cycle( } // Stamped only here, after BOTH halves succeeded. A timestamp written after a // partial cycle would tell the user they're up to date when they aren't. - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); - state::mark_synced(&conn, &now).map_err(|e| e.to_string())?; + state::mark_synced(&conn, &local::now()).map_err(|e| e.to_string())?; state::status(&conn).map_err(|e| e.to_string())? }; diff --git a/core/src/sync/pull.rs b/core/src/sync/pull.rs index 86d310f..363770e 100644 --- a/core/src/sync/pull.rs +++ b/core/src/sync/pull.rs @@ -6,7 +6,6 @@ //! transaction** as the page it describes — a cursor committed ahead of its data //! would silently skip those rows forever, which reads as a clean sync. -use chrono::{SecondsFormat, Utc}; use rusqlite::{params, Connection, OptionalExtension}; use serde::Serialize; @@ -14,7 +13,7 @@ use super::blobs::BlobStore; use super::client; use super::state; use super::wire; -use crate::local::Db; +use crate::local::{now, Db}; /// Backstop against a server that never stops saying `has_more`. At the server's /// 1000-row page cap this is 10M rows — far past any real store, so hitting it means @@ -108,10 +107,6 @@ async fn download_missing_blobs( Ok((downloaded, failed)) } -fn now() -> String { - Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) -} - /// Apply one page and advance the cursor, atomically. /// /// Labels are applied before notes so a membership never references a label row that