Files
thoughtsync/core/src/local/mod.rs
T
bvandeusen f38864088b
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m37s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (debug APK) (push) Successful in 8m9s
core: a completed recurring reminder advances instead of ending
`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.
2026-08-19 21:22:22 -04:00

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"),
)
}