`complete_reminder` cleared `remind_at` and said so in its own comment — "(Recurrence advancement is a later refinement.)". So Done on a daily reminder was quietly the last time it ever fired. Reminder notifications made that much easier to hit, because Done is now a button in the notification shade. **The server already had this.** `src/thoughtsync/notes/recurrence.py` has done it correctly all along, which means the web behaved one way and the desktop and Android the other, on the same note, in the same account. This is a port of that file rather than a fresh implementation, kept behaviourally identical rather than merely similar: the same reminder can be completed from a browser or a client, and a disagreement would move it depending on which one you happened to use. The seven new tests in `core/src/local/recur.rs` mirror the Python suite case for case, including the one that matters most in practice — 31 January plus a month is 28 February, and the step after that is 28 March rather than back to the 31st. That clamp is sticky, and it is now asserted on both sides so a future "fix" to either has to change both. Advancement is measured from the reminder's own time, never from now, which is what keeps a 09:00 daily reminder at 09:00 when it is dealt with at 09:47. A phone left in a drawer for a fortnight rolls forward to tomorrow rather than arriving at fourteen pending occurrences of the same thing. Also matched from the server, and a latent bug of its own: the non-recurring branch now clears `recurrence` as well as `remind_at`. Before, completing a note that carried a rule left the rule behind with no reminder attached — invisible in every UI, since they only render recurrence when there is a reminder to recur from, and waiting to surprise whoever next set a time on that note. Documented rather than hidden, and shared with the server: the arithmetic is in UTC and a note carries no timezone, so a daily reminder crossing a DST boundary keeps its UTC time and shifts by an hour locally. Fixing that means a zone per note, which is a wire-format change. Verified in the CI image before pushing: fmt, clippy --all-targets -D warnings, and the full suite — core 89 to 96, ffi 11 to 12. The new FFI test walks the path the notification's Done button actually takes.
80 lines
3.0 KiB
Rust
80 lines
3.0 KiB
Rust
//! The local-first store: on-device SQLite, and the source of truth for every
|
|
//! client. A client built on this is fully usable with no server and no account.
|
|
//!
|
|
//! Framework-free on purpose. The desktop reaches it through Tauri commands and
|
|
//! Android through uniffi, but neither of those concerns appears in here.
|
|
|
|
pub mod derive;
|
|
pub mod models;
|
|
pub mod recur;
|
|
pub mod retention;
|
|
pub mod schema;
|
|
pub mod store;
|
|
|
|
use std::path::Path;
|
|
use std::sync::Mutex;
|
|
|
|
use rusqlite::Connection;
|
|
|
|
/// 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
|
|
/// it in the uniffi object.
|
|
pub struct Db(pub Mutex<Connection>);
|
|
|
|
impl Db {
|
|
/// Lock the store, reporting a poisoned lock as a message rather than a panic.
|
|
///
|
|
/// Every consumer was writing `db.0.lock().map_err(|e| e.to_string())?` at each
|
|
/// call site. Beyond the repetition, that spelling forces the caller to NAME
|
|
/// `rusqlite::Connection` in any helper that returns the guard — which would make
|
|
/// rusqlite a dependency of a layer whose whole point is not to know what the
|
|
/// store is made of. Returning it from here means callers can bind the guard by
|
|
/// inference and never name the type.
|
|
///
|
|
/// A poisoned lock means some earlier call panicked while holding it. The store
|
|
/// is not necessarily corrupt, but this connection can't be trusted blind, so it
|
|
/// surfaces as an error the UI can show instead of a second panic.
|
|
pub fn conn(&self) -> Result<std::sync::MutexGuard<'_, Connection>, String> {
|
|
self.0
|
|
.lock()
|
|
.map_err(|_| "the local store lock was poisoned by an earlier panic".to_string())
|
|
}
|
|
}
|
|
|
|
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
|
pub fn open(path: &Path) -> rusqlite::Result<Db> {
|
|
let conn = Connection::open(path)?;
|
|
schema::migrate(&conn)?;
|
|
Ok(Db(Mutex::new(conn)))
|
|
}
|
|
|
|
/// Open a migrated, in-memory store.
|
|
///
|
|
/// Exists so a CONSUMER can test against a real schema without taking a rusqlite
|
|
/// dependency of its own just to build a `Db` — which is exactly what the desktop
|
|
/// crate was doing before the core was extracted. The Android bindings will want the
|
|
/// same thing.
|
|
pub fn open_in_memory() -> rusqlite::Result<Db> {
|
|
let conn = Connection::open_in_memory()?;
|
|
schema::migrate(&conn)?;
|
|
Ok(Db(Mutex::new(conn)))
|
|
}
|
|
|
|
/// A one-line count summary of the store, for the startup log.
|
|
pub fn summary(db: &Db) -> String {
|
|
let conn = match db.0.lock() {
|
|
Ok(c) => c,
|
|
Err(_) => return "counts unavailable (lock poisoned)".to_string(),
|
|
};
|
|
let count = |sql: &str| {
|
|
conn.query_row(sql, [], |r| r.get::<_, i64>(0))
|
|
.unwrap_or(-1)
|
|
};
|
|
format!(
|
|
"{} notes, {} labels",
|
|
count("SELECT COUNT(*) FROM notes"),
|
|
count("SELECT COUNT(*) FROM labels"),
|
|
)
|
|
}
|