max / goingson
19 files changed,
+414 insertions,
-75 deletions
| @@ -1880,6 +1880,7 @@ version = "0.4.0" | |||
| 1880 | 1880 | dependencies = [ | |
| 1881 | 1881 | "async-trait", | |
| 1882 | 1882 | "chrono", | |
| 1883 | + | "chrono-tz", | |
| 1883 | 1884 | "serde", | |
| 1884 | 1885 | "serde_json", | |
| 1885 | 1886 | "sha2", | |
| @@ -1922,6 +1923,7 @@ dependencies = [ | |||
| 1922 | 1923 | "goingson-core", | |
| 1923 | 1924 | "goingson-db-sqlite", | |
| 1924 | 1925 | "goingson-plugin-runtime", | |
| 1926 | + | "iana-time-zone", | |
| 1925 | 1927 | "ical", | |
| 1926 | 1928 | "icalendar", | |
| 1927 | 1929 | "keyring", |
| @@ -8,6 +8,7 @@ sqlx-sqlite = ["dep:sqlx"] | |||
| 8 | 8 | ||
| 9 | 9 | [dependencies] | |
| 10 | 10 | chrono = { workspace = true } | |
| 11 | + | chrono-tz = { workspace = true } | |
| 11 | 12 | uuid = { workspace = true } | |
| 12 | 13 | serde = { workspace = true } | |
| 13 | 14 | serde_json = { workspace = true } |
| @@ -80,7 +80,7 @@ pub use models::{ | |||
| 80 | 80 | pub use parser::{parse_quick_add, parse_quick_add_with_warnings, ParsedTask, ParseResult}; | |
| 81 | 81 | pub use date_parser::parse_natural_date; | |
| 82 | 82 | pub use day_planning::{Conflict, TimelineItem, detect_conflicts}; | |
| 83 | - | pub use recurrence::{calculate_next_due, calculate_next_due_with_day, calculate_next_due_rich, expand_recurrence, should_recur}; | |
| 83 | + | pub use recurrence::{calculate_next_due, calculate_next_due_in_tz, calculate_next_due_with_day, calculate_next_due_with_day_in_tz, calculate_next_due_rich, calculate_next_due_rich_in_tz, expand_recurrence, expand_recurrence_in_tz, should_recur}; | |
| 84 | 84 | pub use repository::*; | |
| 85 | 85 | pub use urgency::calculate_urgency; | |
| 86 | 86 | pub use plugin::{ |
| @@ -1,32 +1,56 @@ | |||
| 1 | 1 | //! Recurring task and event scheduling logic. | |
| 2 | - | ||
| 3 | - | use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc}; | |
| 2 | + | //! | |
| 3 | + | //! Recurrence preserves the **local wall-clock time-of-day** in a target time | |
| 4 | + | //! zone, not the UTC instant. A task set to recur "every day at 09:00" must stay | |
| 5 | + | //! at 09:00 local across a daylight-saving transition; doing the arithmetic in | |
| 6 | + | //! UTC would drift it by an hour twice a year (ultra-fuzz Run #28 MINOR). Every | |
| 7 | + | //! computation therefore takes a `chrono_tz::Tz`: field extraction and result | |
| 8 | + | //! construction happen in that zone, and only the boundaries convert to/from UTC. | |
| 9 | + | //! | |
| 10 | + | //! The `*_in_tz` functions are canonical. The legacy non-`tz` wrappers pass | |
| 11 | + | //! `Tz::UTC` (no DST), preserving their original behavior for callers and tests | |
| 12 | + | //! that operate purely in UTC; application code resolves the system zone and | |
| 13 | + | //! calls the `*_in_tz` variants. | |
| 14 | + | ||
| 15 | + | use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Timelike, Utc}; | |
| 16 | + | use chrono_tz::Tz; | |
| 4 | 17 | use uuid::Uuid; | |
| 5 | 18 | use crate::models::{Event, Recurrence, RecurrenceRule, MonthlySpec}; | |
| 6 | 19 | ||
| 7 | - | /// Calculate the next due date based on recurrence type. | |
| 20 | + | /// Calculate the next due date based on recurrence type (UTC; no DST handling). | |
| 21 | + | /// | |
| 22 | + | /// Thin wrapper over [`calculate_next_due_in_tz`] with `Tz::UTC`. Application | |
| 23 | + | /// code should call the `_in_tz` form with the user's zone so a fixed local | |
| 24 | + | /// time-of-day survives DST transitions. | |
| 25 | + | pub fn calculate_next_due( | |
| 26 | + | current_due: Option<&DateTime<Utc>>, | |
| 27 | + | recurrence: &Recurrence, | |
| 28 | + | ) -> Option<DateTime<Utc>> { | |
| 29 | + | calculate_next_due_in_tz(current_due, recurrence, Tz::UTC) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Calculate the next due date based on recurrence type, in `tz`. | |
| 8 | 33 | /// | |
| 9 | 34 | /// The next occurrence is calculated from the original due date, | |
| 10 | 35 | /// not from when the task was completed. This ensures consistent | |
| 11 | - | /// scheduling (e.g., "every Monday" stays on Mondays). | |
| 12 | - | /// | |
| 13 | - | /// For monthly recurrence, `original_day` can specify the intended | |
| 14 | - | /// day-of-month to prevent drift when months have fewer days | |
| 15 | - | /// (e.g., Jan 31 -> Feb 28 -> Mar 31 instead of Mar 28). | |
| 16 | - | pub fn calculate_next_due( | |
| 36 | + | /// scheduling (e.g., "every Monday" stays on Mondays) and keeps the | |
| 37 | + | /// local time-of-day stable across DST. | |
| 38 | + | pub fn calculate_next_due_in_tz( | |
| 17 | 39 | current_due: Option<&DateTime<Utc>>, | |
| 18 | 40 | recurrence: &Recurrence, | |
| 41 | + | tz: Tz, | |
| 19 | 42 | ) -> Option<DateTime<Utc>> { | |
| 20 | 43 | // For monthly recurrence, detect end-of-month dates and preserve intent. | |
| 21 | 44 | // If the due date falls on the last day of its month AND the day is >= 29, | |
| 22 | 45 | // treat the target as day 31 so it snaps to end-of-month in longer months. | |
| 23 | 46 | // The >= 29 guard prevents false positives: Feb 28 in a non-leap year is | |
| 24 | 47 | // ambiguous (user may have meant "the 28th"), but days 29-30 at month-end | |
| 25 | - | // clearly indicate end-of-month intent. | |
| 48 | + | // clearly indicate end-of-month intent. Day/month are read in `tz`. | |
| 26 | 49 | let target_day = if matches!(recurrence, Recurrence::Monthly) { | |
| 27 | 50 | current_due.and_then(|dt| { | |
| 28 | - | let day = dt.day(); | |
| 29 | - | let month_len = days_in_month(dt.year(), dt.month()); | |
| 51 | + | let local = dt.with_timezone(&tz); | |
| 52 | + | let day = local.day(); | |
| 53 | + | let month_len = days_in_month(local.year(), local.month()); | |
| 30 | 54 | if day == month_len && day >= 29 && day < 31 { | |
| 31 | 55 | Some(31) | |
| 32 | 56 | } else { | |
| @@ -36,30 +60,56 @@ pub fn calculate_next_due( | |||
| 36 | 60 | } else { | |
| 37 | 61 | None | |
| 38 | 62 | }; | |
| 39 | - | calculate_next_due_with_day(current_due, recurrence, target_day) | |
| 63 | + | calculate_next_due_with_day_in_tz(current_due, recurrence, target_day, tz) | |
| 40 | 64 | } | |
| 41 | 65 | ||
| 42 | 66 | /// Like `calculate_next_due` but accepts an explicit target day-of-month | |
| 43 | - | /// for monthly recurrence to prevent day drift across short months. | |
| 67 | + | /// for monthly recurrence to prevent day drift across short months (UTC wrapper). | |
| 44 | 68 | pub fn calculate_next_due_with_day( | |
| 45 | 69 | current_due: Option<&DateTime<Utc>>, | |
| 46 | 70 | recurrence: &Recurrence, | |
| 47 | 71 | original_day: Option<u32>, | |
| 48 | 72 | ) -> Option<DateTime<Utc>> { | |
| 73 | + | calculate_next_due_with_day_in_tz(current_due, recurrence, original_day, Tz::UTC) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// `calculate_next_due_with_day` evaluated in `tz`. Daily/weekly advance by whole | |
| 77 | + | /// civil days in the zone (so the local time-of-day is stable across DST), not by | |
| 78 | + | /// fixed 24h/168h UTC spans. | |
| 79 | + | pub fn calculate_next_due_with_day_in_tz( | |
| 80 | + | current_due: Option<&DateTime<Utc>>, | |
| 81 | + | recurrence: &Recurrence, | |
| 82 | + | original_day: Option<u32>, | |
| 83 | + | tz: Tz, | |
| 84 | + | ) -> Option<DateTime<Utc>> { | |
| 49 | 85 | let base_date = current_due.copied().unwrap_or_else(Utc::now); | |
| 50 | 86 | ||
| 51 | 87 | match recurrence { | |
| 52 | - | Recurrence::Daily => Some(base_date + Duration::days(1)), | |
| 53 | - | Recurrence::Weekly => Some(base_date + Duration::weeks(1)), | |
| 88 | + | Recurrence::Daily => Some(add_civil_days(base_date, 1, tz)), | |
| 89 | + | Recurrence::Weekly => Some(add_civil_days(base_date, 7, tz)), | |
| 54 | 90 | Recurrence::Monthly => { | |
| 55 | - | let next = add_months(base_date, 1, original_day); | |
| 91 | + | let next = add_months(base_date, 1, original_day, tz); | |
| 56 | 92 | Some(next) | |
| 57 | 93 | } | |
| 58 | 94 | Recurrence::None => None, | |
| 59 | 95 | } | |
| 60 | 96 | } | |
| 61 | 97 | ||
| 62 | - | /// Add months to a DateTime, handling edge cases like month-end dates. | |
| 98 | + | /// Advance `dt` by `days` whole **civil** days in `tz`, holding the local | |
| 99 | + | /// wall-clock time-of-day fixed, then convert back to UTC. This is what keeps a | |
| 100 | + | /// "every day at 09:00 local" task at 09:00 across a DST boundary (a fixed | |
| 101 | + | /// `Duration::days` would shift it by the offset change). Falls back to absolute | |
| 102 | + | /// addition if the reconstructed local time lands in a DST gap/fold. | |
| 103 | + | fn add_civil_days(dt: DateTime<Utc>, days: i64, tz: Tz) -> DateTime<Utc> { | |
| 104 | + | let local = dt.with_timezone(&tz); | |
| 105 | + | let new_date = local.date_naive() + Duration::days(days); | |
| 106 | + | tz.from_local_datetime(&new_date.and_time(local.time())) | |
| 107 | + | .single() | |
| 108 | + | .map(|t| t.with_timezone(&Utc)) | |
| 109 | + | .unwrap_or(dt + Duration::days(days)) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// Add months to a DateTime in `tz`, handling edge cases like month-end dates. | |
| 63 | 113 | /// | |
| 64 | 114 | /// Uses absolute month counting (year*12 + month) to add/subtract months, then | |
| 65 | 115 | /// clamps the day to the target month's length. Examples: | |
| @@ -67,16 +117,17 @@ pub fn calculate_next_due_with_day( | |||
| 67 | 117 | /// Mar 31 + 1 month → Apr 30 | |
| 68 | 118 | /// | |
| 69 | 119 | /// When `target_day` is provided, uses that as the intended day-of-month | |
| 70 | - | /// instead of `dt.day()`, preventing drift across short months: | |
| 120 | + | /// instead of the zone-local day, preventing drift across short months: | |
| 71 | 121 | /// Jan 31 (target=31) + 1 → Feb 28, then Feb 28 (target=31) + 1 → Mar 31 | |
| 72 | 122 | /// | |
| 73 | - | /// Preserves the original hour/minute/second. Falls back to the input datetime | |
| 74 | - | /// if the target date is ambiguous (e.g., DST gap via `with_ymd_and_hms`). | |
| 75 | - | fn add_months(dt: DateTime<Utc>, months: i32, target_day: Option<u32>) -> DateTime<Utc> { | |
| 76 | - | ||
| 77 | - | let year = dt.year(); | |
| 78 | - | let month = dt.month() as i32; | |
| 79 | - | let day = target_day.unwrap_or(dt.day()); | |
| 123 | + | /// Reads the date fields and rebuilds the result in `tz`, preserving the local | |
| 124 | + | /// hour/minute/second. Falls back to the input datetime if the target local time | |
| 125 | + | /// is ambiguous (e.g., a DST gap via `with_ymd_and_hms`). | |
| 126 | + | fn add_months(dt: DateTime<Utc>, months: i32, target_day: Option<u32>, tz: Tz) -> DateTime<Utc> { | |
| 127 | + | let local = dt.with_timezone(&tz); | |
| 128 | + | let year = local.year(); | |
| 129 | + | let month = local.month() as i32; | |
| 130 | + | let day = target_day.unwrap_or(local.day()); | |
| 80 | 131 | ||
| 81 | 132 | let total_months = year * 12 + month - 1 + months; | |
| 82 | 133 | let new_year = total_months.div_euclid(12); | |
| @@ -86,9 +137,10 @@ fn add_months(dt: DateTime<Utc>, months: i32, target_day: Option<u32>) -> DateTi | |||
| 86 | 137 | let days_in_new_month = days_in_month(new_year, new_month); | |
| 87 | 138 | let new_day = day.min(days_in_new_month); | |
| 88 | 139 | ||
| 89 | - | Utc.with_ymd_and_hms(new_year, new_month, new_day, | |
| 90 | - | dt.hour(), dt.minute(), dt.second()) | |
| 140 | + | tz.with_ymd_and_hms(new_year, new_month, new_day, | |
| 141 | + | local.hour(), local.minute(), local.second()) | |
| 91 | 142 | .single() | |
| 143 | + | .map(|t| t.with_timezone(&Utc)) | |
| 92 | 144 | .unwrap_or(dt) | |
| 93 | 145 | } | |
| 94 | 146 | ||
| @@ -121,30 +173,41 @@ const RECURRENCE_NS: Uuid = Uuid::from_bytes([ | |||
| 121 | 173 | 0xb1, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, | |
| 122 | 174 | ]); | |
| 123 | 175 | ||
| 124 | - | /// Calculate the next due date using a rich recurrence rule. | |
| 125 | - | /// | |
| 126 | - | /// Handles intervals, weekday selection, and monthly specifications. | |
| 127 | - | /// Falls back to simple recurrence for rules with interval=1 and no extras. | |
| 176 | + | /// Calculate the next due date using a rich recurrence rule (UTC wrapper). | |
| 128 | 177 | pub fn calculate_next_due_rich( | |
| 129 | 178 | current_due: Option<&DateTime<Utc>>, | |
| 130 | 179 | rule: &RecurrenceRule, | |
| 131 | 180 | ) -> Option<DateTime<Utc>> { | |
| 181 | + | calculate_next_due_rich_in_tz(current_due, rule, Tz::UTC) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// Calculate the next due date using a rich recurrence rule, in `tz`. | |
| 185 | + | /// | |
| 186 | + | /// Handles intervals, weekday selection, and monthly specifications. Weekday and | |
| 187 | + | /// date fields are read in `tz`, and daily/weekly advance by whole civil days so | |
| 188 | + | /// the local time-of-day is stable across DST. | |
| 189 | + | pub fn calculate_next_due_rich_in_tz( | |
| 190 | + | current_due: Option<&DateTime<Utc>>, | |
| 191 | + | rule: &RecurrenceRule, | |
| 192 | + | tz: Tz, | |
| 193 | + | ) -> Option<DateTime<Utc>> { | |
| 132 | 194 | if matches!(rule.pattern, Recurrence::None) { | |
| 133 | 195 | return None; | |
| 134 | 196 | } | |
| 135 | 197 | let base = current_due.copied().unwrap_or_else(Utc::now); | |
| 198 | + | let local = base.with_timezone(&tz); | |
| 136 | 199 | let interval = rule.interval.max(1) as i64; | |
| 137 | 200 | ||
| 138 | 201 | match rule.pattern { | |
| 139 | 202 | Recurrence::Daily => { | |
| 140 | - | Some(base + Duration::days(interval)) | |
| 203 | + | Some(add_civil_days(base, interval, tz)) | |
| 141 | 204 | } | |
| 142 | 205 | Recurrence::Weekly => { | |
| 143 | 206 | if rule.weekdays.is_empty() { | |
| 144 | - | return Some(base + Duration::weeks(interval)); | |
| 207 | + | return Some(add_civil_days(base, interval * 7, tz)); | |
| 145 | 208 | } | |
| 146 | - | // Find next matching weekday | |
| 147 | - | let current_wd = base.weekday().num_days_from_monday() as u8; | |
| 209 | + | // Find next matching weekday (weekday read in `tz`). | |
| 210 | + | let current_wd = local.weekday().num_days_from_monday() as u8; | |
| 148 | 211 | let mut sorted_days = rule.weekdays.clone(); | |
| 149 | 212 | sorted_days.sort_unstable(); | |
| 150 | 213 | sorted_days.dedup(); | |
| @@ -152,38 +215,40 @@ pub fn calculate_next_due_rich( | |||
| 152 | 215 | // Look for next day in the current week (after current weekday) | |
| 153 | 216 | if let Some(&next_wd) = sorted_days.iter().find(|&&d| d > current_wd) { | |
| 154 | 217 | let diff = (next_wd - current_wd) as i64; | |
| 155 | - | return Some(base + Duration::days(diff)); | |
| 218 | + | return Some(add_civil_days(base, diff, tz)); | |
| 156 | 219 | } | |
| 157 | 220 | // Wrap to first day of next interval-week | |
| 158 | 221 | let first_wd = sorted_days[0]; | |
| 159 | 222 | let days_to_end = 7 - current_wd as i64; | |
| 160 | 223 | let skip_weeks = (interval - 1) * 7; | |
| 161 | 224 | let days = days_to_end + skip_weeks + first_wd as i64; | |
| 162 | - | Some(base + Duration::days(days)) | |
| 225 | + | Some(add_civil_days(base, days, tz)) | |
| 163 | 226 | } | |
| 164 | 227 | Recurrence::Monthly => { | |
| 165 | 228 | match &rule.monthly_spec { | |
| 166 | 229 | Some(MonthlySpec::DayOfMonth { day }) => { | |
| 167 | - | let next = add_months(base, interval as i32, Some(*day)); | |
| 230 | + | let next = add_months(base, interval as i32, Some(*day), tz); | |
| 168 | 231 | Some(next) | |
| 169 | 232 | } | |
| 170 | 233 | Some(MonthlySpec::NthWeekday { week, weekday }) => { | |
| 171 | - | let next_base = add_months(base, interval as i32, None); | |
| 234 | + | let next_base = add_months(base, interval as i32, None, tz); | |
| 235 | + | let next_local = next_base.with_timezone(&tz); | |
| 172 | 236 | let target = nth_weekday_in_month( | |
| 173 | - | next_base.year(), next_base.month(), | |
| 237 | + | next_local.year(), next_local.month(), | |
| 174 | 238 | *week, *weekday, | |
| 175 | - | next_base.hour(), next_base.minute(), next_base.second(), | |
| 239 | + | next_local.hour(), next_local.minute(), next_local.second(), | |
| 240 | + | tz, | |
| 176 | 241 | ); | |
| 177 | 242 | Some(target.unwrap_or(next_base)) | |
| 178 | 243 | } | |
| 179 | 244 | None => { | |
| 180 | - | // Same as legacy monthly | |
| 245 | + | // Same as legacy monthly (end-of-month intent read in `tz`) | |
| 181 | 246 | let target_day = { | |
| 182 | - | let day = base.day(); | |
| 183 | - | let month_len = days_in_month(base.year(), base.month()); | |
| 247 | + | let day = local.day(); | |
| 248 | + | let month_len = days_in_month(local.year(), local.month()); | |
| 184 | 249 | if day == month_len && day >= 29 && day < 31 { Some(31) } else { None } | |
| 185 | 250 | }; | |
| 186 | - | Some(add_months(base, interval as i32, target_day)) | |
| 251 | + | Some(add_months(base, interval as i32, target_day, tz)) | |
| 187 | 252 | } | |
| 188 | 253 | } | |
| 189 | 254 | } | |
| @@ -191,16 +256,16 @@ pub fn calculate_next_due_rich( | |||
| 191 | 256 | } | |
| 192 | 257 | } | |
| 193 | 258 | ||
| 194 | - | /// Find the Nth weekday in a given month. | |
| 259 | + | /// Find the Nth weekday in a given month, constructing the result in `tz`. | |
| 195 | 260 | /// `week`: 1-4 for ordinal, -1 for last. | |
| 196 | 261 | /// `weekday`: 0=Mon..6=Sun. | |
| 262 | + | /// `year`/`month`/`hour`/`minute`/`second` are civil fields in `tz`. | |
| 197 | 263 | fn nth_weekday_in_month( | |
| 198 | 264 | year: i32, month: u32, | |
| 199 | 265 | week: i8, weekday: u8, | |
| 200 | 266 | hour: u32, minute: u32, second: u32, | |
| 267 | + | tz: Tz, | |
| 201 | 268 | ) -> Option<DateTime<Utc>> { | |
| 202 | - | use chrono::NaiveDate; | |
| 203 | - | ||
| 204 | 269 | let weekday_chrono = match weekday { | |
| 205 | 270 | 0 => chrono::Weekday::Mon, | |
| 206 | 271 | 1 => chrono::Weekday::Tue, | |
| @@ -220,7 +285,9 @@ fn nth_weekday_in_month( | |||
| 220 | 285 | while d.weekday() != weekday_chrono { | |
| 221 | 286 | d = d.pred_opt()?; | |
| 222 | 287 | } | |
| 223 | - | Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single() | |
| 288 | + | tz.with_ymd_and_hms(year, month, d.day(), hour, minute, second) | |
| 289 | + | .single() | |
| 290 | + | .map(|t| t.with_timezone(&Utc)) | |
| 224 | 291 | } else if (1..=5).contains(&week) { | |
| 225 | 292 | // Nth occurrence: start from day 1, find first matching weekday, skip N-1 | |
| 226 | 293 | let first = NaiveDate::from_ymd_opt(year, month, 1)?; | |
| @@ -233,7 +300,9 @@ fn nth_weekday_in_month( | |||
| 233 | 300 | if d.month() != month { | |
| 234 | 301 | return None; // e.g., 5th Monday doesn't exist | |
| 235 | 302 | } | |
| 236 | - | Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single() | |
| 303 | + | tz.with_ymd_and_hms(year, month, d.day(), hour, minute, second) | |
| 304 | + | .single() | |
| 305 | + | .map(|t| t.with_timezone(&Utc)) | |
| 237 | 306 | } else { | |
| 238 | 307 | None | |
| 239 | 308 | } | |
| @@ -249,6 +318,17 @@ pub fn expand_recurrence( | |||
| 249 | 318 | range_start: DateTime<Utc>, | |
| 250 | 319 | range_end: DateTime<Utc>, | |
| 251 | 320 | ) -> Vec<Event> { | |
| 321 | + | expand_recurrence_in_tz(event, range_start, range_end, Tz::UTC) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | /// Expand a recurring event into virtual instances within a date range, advancing | |
| 325 | + | /// occurrences in `tz` so the local time-of-day is stable across DST. | |
| 326 | + | pub fn expand_recurrence_in_tz( | |
| 327 | + | event: &Event, | |
| 328 | + | range_start: DateTime<Utc>, | |
| 329 | + | range_end: DateTime<Utc>, | |
| 330 | + | tz: Tz, | |
| 331 | + | ) -> Vec<Event> { | |
| 252 | 332 | let rule = match event.effective_recurrence_rule() { | |
| 253 | 333 | Some(r) => r, | |
| 254 | 334 | None => return vec![], | |
| @@ -285,7 +365,7 @@ pub fn expand_recurrence( | |||
| 285 | 365 | } | |
| 286 | 366 | ||
| 287 | 367 | // Advance to next occurrence | |
| 288 | - | match calculate_next_due_rich(Some(&cursor), &rule) { | |
| 368 | + | match calculate_next_due_rich_in_tz(Some(&cursor), &rule, tz) { | |
| 289 | 369 | Some(next) if next > cursor => cursor = next, | |
| 290 | 370 | _ => break, // prevent infinite loop | |
| 291 | 371 | } | |
| @@ -710,4 +790,69 @@ mod tests { | |||
| 710 | 790 | assert_eq!(a.id, b.id); | |
| 711 | 791 | } | |
| 712 | 792 | } | |
| 793 | + | ||
| 794 | + | // ============ DST / time-zone-aware recurrence (Run #28) ============ | |
| 795 | + | ||
| 796 | + | #[test] | |
| 797 | + | fn test_daily_recurrence_holds_local_time_across_spring_forward() { | |
| 798 | + | use chrono_tz::America::New_York; | |
| 799 | + | // 2026-03-08 is US spring-forward (02:00 -> 03:00). A task at 09:00 local on | |
| 800 | + | // Mar 7 must land at 09:00 local on Mar 8 — not 10:00 as fixed-24h-UTC would give. | |
| 801 | + | let start = New_York.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).single().unwrap() | |
| 802 | + | .with_timezone(&Utc); | |
| 803 | + | let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap(); | |
| 804 | + | let next_local = next.with_timezone(&New_York); | |
| 805 | + | assert_eq!(next_local.day(), 8); | |
| 806 | + | assert_eq!(next_local.hour(), 9, "local hour must stay 09:00 across DST"); | |
| 807 | + | // The UTC instant shifts by 23h (a short civil day), proving DST was honored. | |
| 808 | + | assert_eq!((next - start).num_hours(), 23); | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | #[test] | |
| 812 | + | fn test_daily_recurrence_holds_local_time_across_fall_back() { | |
| 813 | + | use chrono_tz::America::New_York; | |
| 814 | + | // 2026-11-01 is US fall-back (02:00 -> 01:00). 09:00 local Oct 31 -> 09:00 local Nov 1. | |
| 815 | + | let start = New_York.with_ymd_and_hms(2026, 10, 31, 9, 0, 0).single().unwrap() | |
| 816 | + | .with_timezone(&Utc); | |
| 817 | + | let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap(); | |
| 818 | + | let next_local = next.with_timezone(&New_York); | |
| 819 | + | assert_eq!(next_local.day(), 1); | |
| 820 | + | assert_eq!(next_local.hour(), 9); | |
| 821 | + | assert_eq!((next - start).num_hours(), 25, "a long civil day spans the fall-back"); | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | #[test] | |
| 825 | + | fn test_weekly_recurrence_holds_local_time_across_dst() { | |
| 826 | + | use chrono_tz::America::New_York; | |
| 827 | + | // Mar 5 (Thu) 08:00 local -> Mar 12, still 08:00 local, despite the Mar 8 transition. | |
| 828 | + | let start = New_York.with_ymd_and_hms(2026, 3, 5, 8, 0, 0).single().unwrap() | |
| 829 | + | .with_timezone(&Utc); | |
| 830 | + | let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Weekly, New_York).unwrap(); | |
| 831 | + | let next_local = next.with_timezone(&New_York); | |
| 832 | + | assert_eq!(next_local.day(), 12); | |
| 833 | + | assert_eq!(next_local.hour(), 8); | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | #[test] | |
| 837 | + | fn test_monthly_recurrence_holds_local_time_across_dst() { | |
| 838 | + | use chrono_tz::America::New_York; | |
| 839 | + | // Feb 20 09:00 local -> Mar 20 09:00 local, crossing the Mar 8 spring-forward. | |
| 840 | + | let start = New_York.with_ymd_and_hms(2026, 2, 20, 9, 0, 0).single().unwrap() | |
| 841 | + | .with_timezone(&Utc); | |
| 842 | + | let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Monthly, New_York).unwrap(); | |
| 843 | + | let next_local = next.with_timezone(&New_York); | |
| 844 | + | assert_eq!(next_local.month(), 3); | |
| 845 | + | assert_eq!(next_local.day(), 20); | |
| 846 | + | assert_eq!(next_local.hour(), 9); | |
| 847 | + | } | |
| 848 | + | ||
| 849 | + | #[test] | |
| 850 | + | fn test_utc_wrapper_unaffected_by_dst_logic() { | |
| 851 | + | // The legacy UTC entry points must still add a fixed 24h (no zone involved), | |
| 852 | + | // so existing callers and instants are unchanged. | |
| 853 | + | let start = Utc.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).unwrap(); | |
| 854 | + | let next = calculate_next_due(Some(&start), &Recurrence::Daily).unwrap(); | |
| 855 | + | assert_eq!((next - start).num_hours(), 24); | |
| 856 | + | assert_eq!(next.hour(), 9); | |
| 857 | + | } | |
| 713 | 858 | } |
| @@ -62,6 +62,7 @@ pub const EXCLUDED_TABLES: &[&str] = &[ | |||
| 62 | 62 | "imap_folder_sync_state", // sync-transient IMAP state, re-derived on next sync | |
| 63 | 63 | "sync_changelog", // sync internals, regenerated | |
| 64 | 64 | "sync_state", // sync cursors/flags, internal | |
| 65 | + | "hlc_state", // hybrid logical clock, device-local sync internal | |
| 65 | 66 | ]; | |
| 66 | 67 | ||
| 67 | 68 | /// Restore a backup into the database as a single transaction. | |
| @@ -724,10 +725,13 @@ async fn restore_weekly_reviews( | |||
| 724 | 725 | result: &mut RestoreResult, | |
| 725 | 726 | ) -> Result<()> { | |
| 726 | 727 | for review in &input.weekly_reviews { | |
| 728 | + | // Restore vacation_days verbatim. Earlier code silently dropped any value > 6, | |
| 729 | + | // which violates this module's "every row inserted exactly as backed up" contract | |
| 730 | + | // (ultra-fuzz Run #28 MINOR): a backup is a faithful snapshot, not a place to | |
| 731 | + | // re-validate. The 0-6 weekday domain is enforced on the write path, not here. | |
| 727 | 732 | let vacation_days = review | |
| 728 | 733 | .vacation_days | |
| 729 | 734 | .iter() | |
| 730 | - | .filter(|&&d| d <= 6) | |
| 731 | 735 | .map(|d| d.to_string()) | |
| 732 | 736 | .collect::<Vec<_>>() | |
| 733 | 737 | .join(","); |
| @@ -0,0 +1,23 @@ | |||
| 1 | + | -- Hybrid logical clock for cross-device sync conflict resolution (ultra-fuzz Run #28). | |
| 2 | + | -- | |
| 3 | + | -- Conflict resolution previously ordered by wall-clock `client_timestamp`, which let a | |
| 4 | + | -- device with a skewed-fast clock win every conflict and made DELETE unconditionally | |
| 5 | + | -- beat a strictly-newer edit (permanent data loss). SyncKit now orders by an HLC that | |
| 6 | + | -- travels inside the E2E-encrypted payload. This migration adds the local clock state | |
| 7 | + | -- and per-changelog-row HLC stamps. | |
| 8 | + | -- | |
| 9 | + | -- The HLC's node component is always this device's id, so only (wall_ms, counter) are | |
| 10 | + | -- persisted here. Rows are stamped lazily when first read for push or conflict | |
| 11 | + | -- detection (see src-tauri/src/sync_service/hlc.rs), so the changelog triggers stay | |
| 12 | + | -- unchanged. | |
| 13 | + | ||
| 14 | + | ALTER TABLE sync_changelog ADD COLUMN hlc_wall INTEGER; | |
| 15 | + | ALTER TABLE sync_changelog ADD COLUMN hlc_counter INTEGER; | |
| 16 | + | ||
| 17 | + | CREATE TABLE IF NOT EXISTS hlc_state ( | |
| 18 | + | id INTEGER PRIMARY KEY CHECK (id = 1), | |
| 19 | + | wall_ms INTEGER NOT NULL DEFAULT 0, | |
| 20 | + | counter INTEGER NOT NULL DEFAULT 0 | |
| 21 | + | ); | |
| 22 | + | ||
| 23 | + | INSERT OR IGNORE INTO hlc_state (id, wall_ms, counter) VALUES (1, 0, 0); |
| @@ -38,6 +38,7 @@ serde_json = { workspace = true } | |||
| 38 | 38 | # Utilities | |
| 39 | 39 | chrono = { workspace = true } | |
| 40 | 40 | chrono-tz = { workspace = true } | |
| 41 | + | iana-time-zone = "0.1" | |
| 41 | 42 | uuid = { workspace = true } | |
| 42 | 43 | ||
| 43 | 44 |
| @@ -9,7 +9,7 @@ use std::sync::Arc; | |||
| 9 | 9 | use tauri::State; | |
| 10 | 10 | use tracing::instrument; | |
| 11 | 11 | ||
| 12 | - | use goingson_core::{Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, detect_conflicts, expand_recurrence}; | |
| 12 | + | use goingson_core::{Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, detect_conflicts, expand_recurrence_in_tz}; | |
| 13 | 13 | use chrono::Datelike; | |
| 14 | 14 | ||
| 15 | 15 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| @@ -116,7 +116,7 @@ pub async fn get_day_planning( | |||
| 116 | 116 | let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect(); | |
| 117 | 117 | for r in recurring { | |
| 118 | 118 | if !existing_ids.contains(&r.id) { | |
| 119 | - | let expanded = expand_recurrence(&r, day_start, day_end); | |
| 119 | + | let expanded = expand_recurrence_in_tz(&r, day_start, day_end, crate::tz::system_tz()); | |
| 120 | 120 | events.extend(expanded); | |
| 121 | 121 | // Check if the original also falls on this day | |
| 122 | 122 | let effective_end = r.end_time.unwrap_or(r.start_time + Duration::hours(1)); |
| @@ -9,7 +9,7 @@ use std::sync::Arc; | |||
| 9 | 9 | use tauri::State; | |
| 10 | 10 | use tracing::instrument; | |
| 11 | 11 | ||
| 12 | - | use goingson_core::{BlockType, ContactId, DbValue, Event, EventId, NewEvent, ParseableEnum, ProjectId, Recurrence, RecurrenceRule, TaskId, UpdateEvent, Validate, expand_recurrence}; | |
| 12 | + | use goingson_core::{BlockType, ContactId, DbValue, Event, EventId, NewEvent, ParseableEnum, ProjectId, Recurrence, RecurrenceRule, TaskId, UpdateEvent, Validate, expand_recurrence_in_tz}; | |
| 13 | 13 | ||
| 14 | 14 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 15 | 15 | use super::{ApiError, OptionNotFound}; | |
| @@ -218,7 +218,7 @@ fn expand_and_merge(events: Vec<Event>, range_start: DateTime<Utc>, range_end: D | |||
| 218 | 218 | for event in events { | |
| 219 | 219 | if event.has_recurrence() && !event.is_recurring_instance { | |
| 220 | 220 | // Add virtual instances within the range | |
| 221 | - | let expanded = expand_recurrence(&event, range_start, range_end); | |
| 221 | + | let expanded = expand_recurrence_in_tz(&event, range_start, range_end, crate::tz::system_tz()); | |
| 222 | 222 | result.extend(expanded); | |
| 223 | 223 | // Include the original if it falls within range | |
| 224 | 224 | let effective_end = event.end_time.unwrap_or(event.start_time + Duration::hours(1)); |
| @@ -19,7 +19,8 @@ use tracing::instrument; | |||
| 19 | 19 | ||
| 20 | 20 | use goingson_core::{ | |
| 21 | 21 | Annotation, MilestoneStatus, NewTask, ParseableEnum, Priority, Recurrence, RecurrenceRule, | |
| 22 | - | Subtask, Task, TaskStatus, UpdateTask, Validate, calculate_next_due, calculate_next_due_rich, | |
| 22 | + | Subtask, Task, TaskStatus, UpdateTask, Validate, calculate_next_due_in_tz, | |
| 23 | + | calculate_next_due_rich_in_tz, | |
| 23 | 24 | calculate_urgency, parse_quick_add, TaskId, ProjectId, MilestoneId, ContactId, EmailId, | |
| 24 | 25 | date_utils::{format_relative_date, format_relative_future}, | |
| 25 | 26 | }; | |
| @@ -582,10 +583,11 @@ pub async fn complete_task(state: State<'_, Arc<AppState>>, id: TaskId) -> Resul | |||
| 582 | 583 | ||
| 583 | 584 | // Build next recurring instance if needed | |
| 584 | 585 | let next_new_task = if task.has_recurrence() { | |
| 586 | + | let tz = crate::tz::system_tz(); | |
| 585 | 587 | let next_due = if let Some(ref rule) = task.recurrence_rule { | |
| 586 | - | calculate_next_due_rich(task.due.as_ref(), rule) | |
| 588 | + | calculate_next_due_rich_in_tz(task.due.as_ref(), rule, tz) | |
| 587 | 589 | } else { | |
| 588 | - | calculate_next_due(task.due.as_ref(), &task.recurrence) | |
| 590 | + | calculate_next_due_in_tz(task.due.as_ref(), &task.recurrence, tz) | |
| 589 | 591 | }; | |
| 590 | 592 | let created_at = Utc::now(); | |
| 591 | 593 | let fresh_urgency = calculate_urgency( |
| @@ -12,7 +12,7 @@ use tracing::instrument; | |||
| 12 | 12 | use goingson_core::weekly_review::{ | |
| 13 | 13 | self, EventSummary, ProjectHealth, ProjectSummary, TimelineDayData, WeeklyReviewInput, | |
| 14 | 14 | }; | |
| 15 | - | use goingson_core::{TaskId, WeeklyReview, expand_recurrence}; | |
| 15 | + | use goingson_core::{TaskId, WeeklyReview, expand_recurrence_in_tz}; | |
| 16 | 16 | ||
| 17 | 17 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 18 | 18 | use super::ApiError; | |
| @@ -160,15 +160,16 @@ pub async fn get_weekly_review( | |||
| 160 | 160 | // Expand recurring events into both occurred and upcoming ranges | |
| 161 | 161 | let mut events_occurred = events_occurred?; | |
| 162 | 162 | let past_range_end = now.min(week_end_dt); | |
| 163 | + | let tz = crate::tz::system_tz(); | |
| 163 | 164 | for r in &recurring_events { | |
| 164 | - | let expanded = expand_recurrence(r, week_start_dt, past_range_end); | |
| 165 | + | let expanded = expand_recurrence_in_tz(r, week_start_dt, past_range_end, tz); | |
| 165 | 166 | events_occurred.extend(expanded); | |
| 166 | 167 | } | |
| 167 | 168 | events_occurred.sort_by_key(|e| e.start_time); | |
| 168 | 169 | ||
| 169 | 170 | let mut upcoming_events = upcoming_events?; | |
| 170 | 171 | for r in &recurring_events { | |
| 171 | - | let expanded = expand_recurrence(r, now, next_week_end_dt); | |
| 172 | + | let expanded = expand_recurrence_in_tz(r, now, next_week_end_dt, tz); | |
| 172 | 173 | upcoming_events.extend(expanded); | |
| 173 | 174 | } | |
| 174 | 175 | upcoming_events.sort_by_key(|e| e.start_time); |
| @@ -16,6 +16,7 @@ pub mod oauth; | |||
| 16 | 16 | pub mod state; | |
| 17 | 17 | pub mod sync_scheduler; | |
| 18 | 18 | pub mod sync_service; | |
| 19 | + | pub mod tz; | |
| 19 | 20 | ||
| 20 | 21 | // Desktop-only: file watching for external DB changes | |
| 21 | 22 | #[cfg(not(any(target_os = "ios", target_os = "android")))] |
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | use std::path::Path; | |
| 4 | 4 | ||
| 5 | 5 | use goingson_core::CoreError; | |
| 6 | + | use sha2::{Digest, Sha256}; | |
| 6 | 7 | use sqlx::SqlitePool; | |
| 7 | 8 | use synckit_client::SyncKitClient; | |
| 8 | 9 | use tracing::{debug, info, warn}; | |
| @@ -134,6 +135,22 @@ pub async fn download_missing_blobs( | |||
| 134 | 135 | } | |
| 135 | 136 | }; | |
| 136 | 137 | ||
| 138 | + | // Verify content-addressed integrity before committing the file. The store's | |
| 139 | + | // invariant is "the file at blobs/<hash> hashes to <hash>" — enforced on write | |
| 140 | + | // (add_attachment) but previously only assumed on read. E2E AEAD stops a network | |
| 141 | + | // attacker, but a server-side corruption or mis-bound ciphertext would otherwise | |
| 142 | + | // land wrong bytes under a trusted name and be handed to open::that() | |
| 143 | + | // (ultra-fuzz Run #28 S1). Hashing here, before the rename, keeps the store clean. | |
| 144 | + | let actual = format!("{:x}", Sha256::digest(&data)); | |
| 145 | + | if actual != *hash { | |
| 146 | + | warn!( | |
| 147 | + | "Blob {} failed integrity check (content hashed to {}); discarding download", | |
| 148 | + | short(hash), | |
| 149 | + | short(&actual) | |
| 150 | + | ); | |
| 151 | + | continue; | |
| 152 | + | } | |
| 153 | + | ||
| 137 | 154 | // Write to disk atomically (tmp + rename) to prevent corrupt partial files | |
| 138 | 155 | let tmp_path = path.with_extension("tmp"); | |
| 139 | 156 | if let Err(e) = tokio::fs::write(&tmp_path, &data).await { |
| @@ -0,0 +1,90 @@ | |||
| 1 | + | //! Hybrid logical clock persistence for cross-device sync conflict resolution. | |
| 2 | + | //! | |
| 3 | + | //! SyncKit orders conflicts by an [`Hlc`] rather than a bare wall-clock timestamp | |
| 4 | + | //! (ultra-fuzz Run #28): a skewed-fast device no longer wins every conflict, and a | |
| 5 | + | //! strictly-newer edit beats an older delete. This module owns the device's | |
| 6 | + | //! persistent clock — `(wall_ms, counter)` in the `hlc_state` table; the HLC's node | |
| 7 | + | //! component is always this device's id, so it is not stored. | |
| 8 | + | //! | |
| 9 | + | //! Local changelog rows are stamped **lazily**: when a row is first read for push or | |
| 10 | + | //! conflict detection it gets the next HLC, and the stamp is written back onto the | |
| 11 | + | //! row. This keeps the changelog triggers unchanged and guarantees push and | |
| 12 | + | //! conflict-detection read the same HLC for a given change. | |
| 13 | + | ||
| 14 | + | use chrono::Utc; | |
| 15 | + | use goingson_core::CoreError; | |
| 16 | + | use sqlx::SqlitePool; | |
| 17 | + | use synckit_client::Hlc; | |
| 18 | + | use uuid::Uuid; | |
| 19 | + | ||
| 20 | + | /// Load the persistent clock, reattaching `node` (which is not stored). | |
| 21 | + | async fn load_clock(pool: &SqlitePool, node: Uuid) -> Result<Hlc, CoreError> { | |
| 22 | + | let (wall_ms, counter): (i64, i64) = | |
| 23 | + | sqlx::query_as("SELECT wall_ms, counter FROM hlc_state WHERE id = 1") | |
| 24 | + | .fetch_one(pool) | |
| 25 | + | .await | |
| 26 | + | .map_err(CoreError::database)?; | |
| 27 | + | Ok(Hlc { wall_ms, counter: counter as u32, node }) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// Stamp every unpushed changelog row that lacks an HLC, in id order, advancing the | |
| 31 | + | /// persistent clock once per row. Idempotent — already-stamped rows are skipped — so | |
| 32 | + | /// push and conflict-detection observe identical stamps. Runs in one transaction so a | |
| 33 | + | /// crash mid-stamp leaves the clock and the rows consistent (all-or-nothing). | |
| 34 | + | pub async fn assign_pending_hlcs(pool: &SqlitePool, device_id: Uuid) -> Result<(), CoreError> { | |
| 35 | + | let ids: Vec<(i64,)> = sqlx::query_as( | |
| 36 | + | "SELECT id FROM sync_changelog WHERE pushed = 0 AND hlc_wall IS NULL ORDER BY id ASC", | |
| 37 | + | ) | |
| 38 | + | .fetch_all(pool) | |
| 39 | + | .await | |
| 40 | + | .map_err(CoreError::database)?; | |
| 41 | + | ||
| 42 | + | if ids.is_empty() { | |
| 43 | + | return Ok(()); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | let mut tx = pool.begin().await.map_err(CoreError::database)?; | |
| 47 | + | ||
| 48 | + | let (wall_ms, counter): (i64, i64) = | |
| 49 | + | sqlx::query_as("SELECT wall_ms, counter FROM hlc_state WHERE id = 1") | |
| 50 | + | .fetch_one(&mut *tx) | |
| 51 | + | .await | |
| 52 | + | .map_err(CoreError::database)?; | |
| 53 | + | let mut clock = Hlc { wall_ms, counter: counter as u32, node: device_id }; | |
| 54 | + | ||
| 55 | + | for (id,) in ids { | |
| 56 | + | clock = Hlc::tick(clock, Utc::now().timestamp_millis(), device_id); | |
| 57 | + | sqlx::query("UPDATE sync_changelog SET hlc_wall = ?, hlc_counter = ? WHERE id = ?") | |
| 58 | + | .bind(clock.wall_ms) | |
| 59 | + | .bind(clock.counter as i64) | |
| 60 | + | .bind(id) | |
| 61 | + | .execute(&mut *tx) | |
| 62 | + | .await | |
| 63 | + | .map_err(CoreError::database)?; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | sqlx::query("UPDATE hlc_state SET wall_ms = ?, counter = ? WHERE id = 1") | |
| 67 | + | .bind(clock.wall_ms) | |
| 68 | + | .bind(clock.counter as i64) | |
| 69 | + | .execute(&mut *tx) | |
| 70 | + | .await | |
| 71 | + | .map_err(CoreError::database)?; | |
| 72 | + | ||
| 73 | + | tx.commit().await.map_err(CoreError::database)?; | |
| 74 | + | Ok(()) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// Advance the persistent clock past a remote HLC observed during pull, so that | |
| 78 | + | /// subsequent local changes causally follow it. Monotonic: advancing past the batch | |
| 79 | + | /// maximum is enough to cover every change in the batch. | |
| 80 | + | pub async fn observe_remote(pool: &SqlitePool, remote: Hlc, device_id: Uuid) -> Result<(), CoreError> { | |
| 81 | + | let clock = load_clock(pool, device_id).await?; | |
| 82 | + | let advanced = Hlc::observe(clock, remote, Utc::now().timestamp_millis(), device_id); | |
| 83 | + | sqlx::query("UPDATE hlc_state SET wall_ms = ?, counter = ? WHERE id = 1") | |
| 84 | + | .bind(advanced.wall_ms) | |
| 85 | + | .bind(advanced.counter as i64) | |
| 86 | + | .execute(pool) | |
| 87 | + | .await | |
| 88 | + | .map_err(CoreError::database)?; | |
| 89 | + | Ok(()) | |
| 90 | + | } |
| @@ -21,6 +21,7 @@ | |||
| 21 | 21 | ||
| 22 | 22 | mod apply; | |
| 23 | 23 | pub(crate) mod blob_sync; | |
| 24 | + | mod hlc; | |
| 24 | 25 | mod pull; | |
| 25 | 26 | mod push; | |
| 26 | 27 | mod state; |
| @@ -4,13 +4,14 @@ use chrono::Utc; | |||
| 4 | 4 | use goingson_core::CoreError; | |
| 5 | 5 | use sqlx::{SqliteConnection, SqlitePool}; | |
| 6 | 6 | use synckit_client::{ | |
| 7 | - | ChangeEntry, ChangeOp, Resolution, SyncKitClient, | |
| 7 | + | ChangeEntry, ChangeOp, Hlc, Resolution, SyncKitClient, | |
| 8 | 8 | detect_conflicts, resolve_lww, | |
| 9 | 9 | }; | |
| 10 | 10 | use tracing::{debug, warn}; | |
| 11 | 11 | use uuid::Uuid; | |
| 12 | 12 | ||
| 13 | 13 | use super::state::{get_sync_state, set_sync_state}; | |
| 14 | + | use super::hlc::{assign_pending_hlcs, observe_remote}; | |
| 14 | 15 | use super::{UPSERT_ORDER, DELETE_ORDER}; | |
| 15 | 16 | use super::apply::{apply_upsert, apply_delete}; | |
| 16 | 17 | ||
| @@ -24,8 +25,9 @@ pub async fn pull_changes( | |||
| 24 | 25 | let mut total_applied: i64 = 0; | |
| 25 | 26 | let mut changed_tables: std::collections::HashSet<String> = std::collections::HashSet::new(); | |
| 26 | 27 | ||
| 27 | - | // Read local pending changes once (unpushed changelog entries) | |
| 28 | - | let local_pending = read_local_pending(pool).await?; | |
| 28 | + | // Read local pending changes once (unpushed changelog entries). This also | |
| 29 | + | // stamps them with their HLC so conflict resolution can compare clocks. | |
| 30 | + | let local_pending = read_local_pending(pool, device_id).await?; | |
| 29 | 31 | ||
| 30 | 32 | loop { | |
| 31 | 33 | let (changes, new_cursor, has_more) = client | |
| @@ -38,6 +40,12 @@ pub async fn pull_changes( | |||
| 38 | 40 | break; | |
| 39 | 41 | } | |
| 40 | 42 | ||
| 43 | + | // Advance our clock past everything in this batch, so future local changes | |
| 44 | + | // causally follow the remote ones. Observing the batch maximum suffices. | |
| 45 | + | if let Some(max_remote) = changes.iter().map(|p| p.entry.hlc).max() { | |
| 46 | + | observe_remote(pool, max_remote, device_id).await?; | |
| 47 | + | } | |
| 48 | + | ||
| 41 | 49 | // Detect conflicts between remote changes and local pending | |
| 42 | 50 | let (clean, conflicts) = detect_conflicts(changes, &local_pending, device_id); | |
| 43 | 51 | ||
| @@ -119,9 +127,15 @@ pub struct PullOutcome { | |||
| 119 | 127 | } | |
| 120 | 128 | ||
| 121 | 129 | /// Read unpushed changelog entries as ChangeEntry values for conflict detection. | |
| 122 | - | async fn read_local_pending(pool: &SqlitePool) -> Result<Vec<ChangeEntry>, CoreError> { | |
| 123 | - | let rows: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as( | |
| 124 | - | "SELECT table_name, op, row_id, timestamp, data FROM sync_changelog WHERE pushed = 0 ORDER BY id ASC" | |
| 130 | + | /// | |
| 131 | + | /// Stamps any not-yet-stamped rows with their HLC first, so the clocks used here | |
| 132 | + | /// match the ones the push path will send. | |
| 133 | + | async fn read_local_pending(pool: &SqlitePool, device_id: Uuid) -> Result<Vec<ChangeEntry>, CoreError> { | |
| 134 | + | assign_pending_hlcs(pool, device_id).await?; | |
| 135 | + | ||
| 136 | + | let rows: Vec<(String, String, String, String, Option<String>, Option<i64>, Option<i64>)> = sqlx::query_as( | |
| 137 | + | "SELECT table_name, op, row_id, timestamp, data, hlc_wall, hlc_counter \ | |
| 138 | + | FROM sync_changelog WHERE pushed = 0 ORDER BY id ASC" | |
| 125 | 139 | ) | |
| 126 | 140 | .fetch_all(pool) | |
| 127 | 141 | .await | |
| @@ -129,7 +143,7 @@ async fn read_local_pending(pool: &SqlitePool) -> Result<Vec<ChangeEntry>, CoreE | |||
| 129 | 143 | ||
| 130 | 144 | let entries: Vec<ChangeEntry> = rows | |
| 131 | 145 | .into_iter() | |
| 132 | - | .filter_map(|(table, op, row_id, timestamp, data)| { | |
| 146 | + | .filter_map(|(table, op, row_id, timestamp, data, hlc_wall, hlc_counter)| { | |
| 133 | 147 | let ts = chrono::DateTime::parse_from_rfc3339(×tamp) | |
| 134 | 148 | .map(|dt| dt.with_timezone(&Utc)) | |
| 135 | 149 | .unwrap_or(chrono::DateTime::UNIX_EPOCH); | |
| @@ -137,11 +151,17 @@ async fn read_local_pending(pool: &SqlitePool) -> Result<Vec<ChangeEntry>, CoreE | |||
| 137 | 151 | let change_op = ChangeOp::from_str_opt(&op)?; | |
| 138 | 152 | let json_data = data.and_then(|d| serde_json::from_str(&d).ok()); | |
| 139 | 153 | ||
| 154 | + | let hlc = match (hlc_wall, hlc_counter) { | |
| 155 | + | (Some(wall), Some(counter)) => Hlc { wall_ms: wall, counter: counter as u32, node: device_id }, | |
| 156 | + | _ => Hlc::from_legacy(ts.timestamp_millis(), device_id), | |
| 157 | + | }; | |
| 158 | + | ||
| 140 | 159 | Some(ChangeEntry { | |
| 141 | 160 | table, | |
| 142 | 161 | op: change_op, | |
| 143 | 162 | row_id, | |
| 144 | 163 | timestamp: ts, | |
| 164 | + | hlc, | |
| 145 | 165 | data: json_data, | |
| 146 | 166 | }) | |
| 147 | 167 | }) |
| @@ -3,22 +3,27 @@ | |||
| 3 | 3 | use chrono::Utc; | |
| 4 | 4 | use goingson_core::CoreError; | |
| 5 | 5 | use sqlx::SqlitePool; | |
| 6 | - | use synckit_client::{ChangeEntry, ChangeOp, SyncKitClient}; | |
| 6 | + | use synckit_client::{ChangeEntry, ChangeOp, Hlc, SyncKitClient}; | |
| 7 | 7 | use tracing::{debug, warn}; | |
| 8 | 8 | use uuid::Uuid; | |
| 9 | 9 | ||
| 10 | 10 | use super::PUSH_BATCH_LIMIT; | |
| 11 | + | use super::hlc::assign_pending_hlcs; | |
| 11 | 12 | ||
| 12 | 13 | pub async fn push_changes( | |
| 13 | 14 | pool: &SqlitePool, | |
| 14 | 15 | client: &SyncKitClient, | |
| 15 | 16 | device_id: Uuid, | |
| 16 | 17 | ) -> Result<i64, CoreError> { | |
| 18 | + | // Stamp any unpushed changes with their HLC before reading them for the wire. | |
| 19 | + | assign_pending_hlcs(pool, device_id).await?; | |
| 20 | + | ||
| 17 | 21 | let mut total_pushed: i64 = 0; | |
| 18 | 22 | ||
| 19 | 23 | loop { | |
| 20 | - | let rows: Vec<(i64, String, String, String, String, Option<String>)> = sqlx::query_as( | |
| 21 | - | "SELECT id, table_name, op, row_id, timestamp, data FROM sync_changelog WHERE pushed = 0 ORDER BY id ASC LIMIT ?" | |
| 24 | + | let rows: Vec<(i64, String, String, String, String, Option<String>, Option<i64>, Option<i64>)> = sqlx::query_as( | |
| 25 | + | "SELECT id, table_name, op, row_id, timestamp, data, hlc_wall, hlc_counter \ | |
| 26 | + | FROM sync_changelog WHERE pushed = 0 ORDER BY id ASC LIMIT ?" | |
| 22 | 27 | ) | |
| 23 | 28 | .bind(PUSH_BATCH_LIMIT) | |
| 24 | 29 | .fetch_all(pool) | |
| @@ -33,7 +38,7 @@ pub async fn push_changes( | |||
| 33 | 38 | let mut pushed_ids: Vec<i64> = Vec::new(); | |
| 34 | 39 | let changes: Vec<ChangeEntry> = rows | |
| 35 | 40 | .into_iter() | |
| 36 | - | .filter_map(|(id, table, op, row_id, timestamp, data)| { | |
| 41 | + | .filter_map(|(id, table, op, row_id, timestamp, data, hlc_wall, hlc_counter)| { | |
| 37 | 42 | // Always mark as pushed (even unknown ops) to avoid infinite re-fetch | |
| 38 | 43 | pushed_ids.push(id); | |
| 39 | 44 | ||
| @@ -51,11 +56,19 @@ pub async fn push_changes( | |||
| 51 | 56 | } | |
| 52 | 57 | }; | |
| 53 | 58 | ||
| 59 | + | // Stamped by assign_pending_hlcs above; fall back to a | |
| 60 | + | // timestamp-derived HLC if a row was somehow left unstamped. | |
| 61 | + | let hlc = match (hlc_wall, hlc_counter) { | |
| 62 | + | (Some(wall), Some(counter)) => Hlc { wall_ms: wall, counter: counter as u32, node: device_id }, | |
| 63 | + | _ => Hlc::from_legacy(ts.timestamp_millis(), device_id), | |
| 64 | + | }; | |
| 65 | + | ||
| 54 | 66 | Some(ChangeEntry { | |
| 55 | 67 | table, | |
| 56 | 68 | op: change_op, | |
| 57 | 69 | row_id, | |
| 58 | 70 | timestamp: ts, | |
| 71 | + | hlc, | |
| 59 | 72 | data: json_data, | |
| 60 | 73 | }) | |
| 61 | 74 | }) |
| @@ -45,6 +45,7 @@ use sqlx::SqlitePool; | |||
| 45 | 45 | op, | |
| 46 | 46 | row_id: row_id.to_string(), | |
| 47 | 47 | timestamp: chrono::Utc::now(), | |
| 48 | + | hlc: Default::default(), | |
| 48 | 49 | data, | |
| 49 | 50 | } | |
| 50 | 51 | } |
| @@ -0,0 +1,17 @@ | |||
| 1 | + | //! System time-zone resolution for recurrence. | |
| 2 | + | //! | |
| 3 | + | //! GoingsOn stores instants in UTC and has no per-user time-zone preference, so | |
| 4 | + | //! "the user's zone" is the operating system's zone. Recurrence advances dates in | |
| 5 | + | //! this zone (see `goingson_core::recurrence`) so a fixed local time-of-day — "every | |
| 6 | + | //! day at 09:00" — survives daylight-saving transitions instead of drifting an hour. | |
| 7 | + | ||
| 8 | + | use chrono_tz::Tz; | |
| 9 | + | ||
| 10 | + | /// The user's IANA time zone, resolved from the OS. Falls back to UTC if the OS | |
| 11 | + | /// zone can't be read or doesn't parse as a known IANA name. | |
| 12 | + | pub fn system_tz() -> Tz { | |
| 13 | + | iana_time_zone::get_timezone() | |
| 14 | + | .ok() | |
| 15 | + | .and_then(|name| name.parse().ok()) | |
| 16 | + | .unwrap_or(Tz::UTC) | |
| 17 | + | } |