core: extract the store and sync engine into a shared crate (M12 step 1)
Android becomes a native Kotlin client over this same code (Scribe note 2730), so
the local store and sync engine stop being modules of the desktop app and become
`thoughtsync-core`, a crate with no UI framework in it at all.
This is a move, not a rewrite, and the measurement is why: every file in local/
and sync/ already carried ZERO Tauri references — 4,980 of 6,372 lines. The
coupling was 473 lines of command shim, which stays behind in the desktop crate
as src/commands/. Kept as git renames so history follows the files.
The desktop imports them under their old names (`use thoughtsync_core::{local,
sync}`) so every call site reads exactly as before. What moved is where they
live, not what they are.
Two things a workspace changes that are easy to miss, both caught before pushing:
[profile.release] now lives at the workspace ROOT. Cargo silently ignores
profiles declared by a non-root member — leaving it in the desktop crate would
have dropped lto/strip/opt-level from every release build with only a warning.
And a workspace shares ONE target dir, so the bundles moved from
desktop/src-tauri/target to target/. Thirteen references across publish-release,
debundle-graphics, verify.sh, package-prebuilt and the workflow now point there.
Pinning target-dir back would have been the smaller diff, but the Android lane
also produces Rust artifacts and they do not belong under desktop/.
Also retires the Tauri Android lane in the same push rather than leaving a path
that is being replaced: gen/android, android.yml and docs/android-dev.md are
gone, the mobile_entry_point attribute with them, and the lib drops to rlib —
staticlib/cdylib existed for Tauri mobile, and the .so Android loads will be
built from the core crate instead. Rule 22, no parallel path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "thoughtsync-core"
|
||||
version = "0.1.0"
|
||||
description = "ThoughtSync client core — local-first SQLite store and opt-in sync engine"
|
||||
authors = ["bvandeusen"]
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Local-first store (M10.4): bundled = compile SQLite in, so there's no system
|
||||
# libsqlite dependency to vary across the AppImage / native / Windows / Android builds.
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side).
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
# HTTP for the opt-in server handshake (M10.6) and the sync engine (M10.7).
|
||||
#
|
||||
# native-tls, NOT rustls, deliberately: on x86_64-pc-windows-msvc native-tls resolves
|
||||
# to `schannel` — pure-Rust bindings to the OS TLS stack — so nothing C or assembly
|
||||
# has to cross-compile on the Windows lane, which is the fragile one. rustls would
|
||||
# instead pull in ring/aws-lc-rs and their assembler. On Linux native-tls uses
|
||||
# OpenSSL, whose headers (libssl-dev) ci-tauri already ships.
|
||||
# default-features off drops http2/charset we don't need for a JSON API.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||
# Verifying downloaded attachment bytes against the sha256 the server advertised.
|
||||
sha2 = "0.10"
|
||||
|
||||
# Android has no system OpenSSL to link against, and `native-tls` resolves to
|
||||
# OpenSSL there — unlike Windows, where it lands on schannel and costs nothing.
|
||||
# Without this the build dies at `openssl-sys`: "Could not find directory of
|
||||
# OpenSSL installation".
|
||||
#
|
||||
# `vendored` compiles OpenSSL from source with the NDK toolchain. The alternative
|
||||
# was rustls on Android only, which builds faster — but rustls ships its own root
|
||||
# store, so the phone would trust a DIFFERENT set of certificates than the desktop
|
||||
# does. A self-hosted server behind a private or enterprise CA would then work on
|
||||
# one surface and fail on another, and "the surfaces behave the same" is worth more
|
||||
# than build minutes.
|
||||
#
|
||||
# Declared as a direct dependency purely to turn the feature on: cargo's feature
|
||||
# unification applies it to the copy `native-tls` pulls in transitively.
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
openssl-sys = { version = "0.9", features = ["vendored"] }
|
||||
@@ -0,0 +1,14 @@
|
||||
//! ThoughtSync's client core: the on-device SQLite store and the sync engine.
|
||||
//!
|
||||
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
|
||||
//! the Android client binds it through uniffi. Neither owns it, and a change to
|
||||
//! either must not require touching this crate — that separation is the whole point
|
||||
//! (see Scribe note 2730). It was already true before the split: every file here
|
||||
//! carried zero Tauri references, which is what made the extraction a move rather
|
||||
//! than a rewrite.
|
||||
//!
|
||||
//! - `local` — the source of truth. Works with no server and no account.
|
||||
//! - `sync` — entirely opt-in. Nothing in it runs until a server is linked.
|
||||
|
||||
pub mod local;
|
||||
pub mod sync;
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Deriving `[[wiki-links]]` and `#tags` from a note's body — the local mirror of
|
||||
//! what the server computes on save. Pure string scanning (no regex dependency),
|
||||
//! kept in lockstep with the frontend's inline rules (see frontend notes/markdown.ts):
|
||||
//!
|
||||
//! - `[[link]]`: `[[` … `]]` with no brackets inside, inner text trimmed. Used to
|
||||
//! compute backlinks at query time (links are derived, never stored/synced).
|
||||
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
|
||||
//! On save these become labels attached with `via_tag = true`.
|
||||
//!
|
||||
//! Both dedupe case-insensitively, preserving first-seen order.
|
||||
|
||||
/// Extract the trimmed inner text of every `[[wiki-link]]` in `body`.
|
||||
pub fn extract_links(body: &str) -> Vec<String> {
|
||||
let bytes = body.as_bytes();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 1 < bytes.len() {
|
||||
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
|
||||
if let Some(rel) = body[i + 2..].find("]]") {
|
||||
let inner = &body[i + 2..i + 2 + rel];
|
||||
// Mirror the frontend's `[^[\]]+`: no stray brackets inside.
|
||||
if !inner.contains('[') && !inner.contains(']') {
|
||||
let t = inner.trim();
|
||||
if !t.is_empty() {
|
||||
push_unique(&mut out, t);
|
||||
}
|
||||
}
|
||||
i += 2 + rel + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
||||
pub fn extract_tags(body: &str) -> Vec<String> {
|
||||
let chars: Vec<char> = body.chars().collect();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i] == '#' {
|
||||
let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#');
|
||||
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
|
||||
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && is_tag_char(chars[j]) {
|
||||
j += 1;
|
||||
}
|
||||
let tag: String = chars[i + 1..j].iter().collect();
|
||||
push_unique(&mut out, &tag);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_tag_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_' || c == '-'
|
||||
}
|
||||
|
||||
fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
||||
if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) {
|
||||
out.push(candidate.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn links_basic_and_trim() {
|
||||
assert_eq!(
|
||||
extract_links("see [[ Alpha ]] and [[Beta]]"),
|
||||
vec!["Alpha", "Beta"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_dedupe_case_insensitive_first_seen() {
|
||||
assert_eq!(extract_links("[[Note]] then [[note]] again"), vec!["Note"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_ignore_malformed_and_nested_brackets() {
|
||||
assert_eq!(
|
||||
extract_links("[[a[b]] [[]] [ [x] ] plain"),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(extract_links("[[ok]] [[a]b]]"), vec!["ok"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_basic() {
|
||||
assert_eq!(
|
||||
extract_tags("a #todo and #Work-item_2 here"),
|
||||
vec!["todo", "Work-item_2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_require_letter_start_and_boundary() {
|
||||
// "#1" (digit) and an in-word "#" (email-ish) are not tags.
|
||||
assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_dedupe_case_insensitive() {
|
||||
assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body() {
|
||||
assert!(extract_links("").is_empty());
|
||||
assert!(extract_tags("").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! 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 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>);
|
||||
|
||||
/// 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)))
|
||||
}
|
||||
|
||||
/// 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"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Serde shapes for the local store. Output structs mirror the JSON the frontend
|
||||
//! already consumes (frontend/src/stores/*.ts) so the same components render whether
|
||||
//! the data comes from REST or from these Tauri commands. Field names/optionality
|
||||
//! match the TypeScript interfaces exactly.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
/// title if set, else the note's first body line — always present, so body-only
|
||||
/// notes are still nameable and `[[link]]`-able. Derived, never stored.
|
||||
pub display_title: String,
|
||||
pub body: String,
|
||||
pub color: String,
|
||||
pub kind: String,
|
||||
pub position: i64,
|
||||
pub pinned: bool,
|
||||
pub archived: bool,
|
||||
pub trashed: bool,
|
||||
/// When it was trashed (null unless trashed). Named for the server's field so the
|
||||
/// shared frontend counts down the retention window identically either way.
|
||||
pub deleted_at: Option<String>,
|
||||
pub remind_at: Option<String>,
|
||||
pub recurrence: Option<String>,
|
||||
pub labels: Vec<NoteLabel>,
|
||||
pub items: Vec<ChecklistItem>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub previews: Vec<LinkPreview>,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NoteLabel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
/// True when attached because of a `#tag` in the body (kept in sync with the text).
|
||||
pub via_tag: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ChecklistItem {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub checked: bool,
|
||||
pub position: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub filename: Option<String>,
|
||||
pub mime: String,
|
||||
pub size: Option<i64>,
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LinkPreview {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub site_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NoteRevision {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub body: String,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Label {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
// Note count is only meaningful in listings; omitted elsewhere so the frontend
|
||||
// preserves the count it already holds (matches the REST single-label responses).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TitleEntry {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Backlink {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SavedFilter {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Mirrors NoteFacets — stored as a JSON blob, round-tripped opaquely.
|
||||
pub params: serde_json::Value,
|
||||
pub position: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PublicConfig {
|
||||
pub site_name: String,
|
||||
pub allow_registration: bool,
|
||||
pub version: String,
|
||||
pub enable_url_unfurl: bool,
|
||||
pub trash_retention_days: u32,
|
||||
}
|
||||
|
||||
/// The synthetic single user the offline core reports, so the app's auth-gated
|
||||
/// router resolves without a login. There is no real account offline.
|
||||
#[derive(Serialize)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub email_verified: bool,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
fn default_color() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NoteCreateInput {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub items: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// The board query (mirrors adapters/repo.ts NoteListQuery). `labelId` is camelCase
|
||||
/// on the wire; the facet fields are snake_case, matching NoteFacets.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListQuery {
|
||||
pub view: String,
|
||||
#[serde(default)]
|
||||
pub label_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub facets: Option<Facets>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Facets {
|
||||
#[serde(default)]
|
||||
pub q: Option<String>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub label: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub has_reminder: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub has_attachment: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub created_after: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_before: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Trash retention for a device with no server (M11.3).
|
||||
//!
|
||||
//! The server owns this policy whenever there IS one: a linked client learns about
|
||||
//! every permanent deletion from the delta feed, as a tombstone, and does exactly
|
||||
//! what it's told. This module exists for the case the server can't cover — an
|
||||
//! offline-only install, where trash would otherwise sit forever and the attachment
|
||||
//! bytes with it.
|
||||
//!
|
||||
//! Which is why the sweep refuses to run while linked. If it didn't, a device could
|
||||
//! decide on its own that a note had expired, destroy it, and then push that delete
|
||||
//! upstream — overruling a server that was deliberately keeping it (retention off, or
|
||||
//! a longer window than this constant). A client's local policy must never outrank
|
||||
//! the server's.
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::store;
|
||||
use crate::sync::state;
|
||||
|
||||
/// The window an unlinked device uses. Matches the server's default so a device that
|
||||
/// later links doesn't see its trash behave differently from one that always was.
|
||||
pub const LOCAL_RETENTION_DAYS: i64 = 30;
|
||||
|
||||
/// Purge trash older than `retention_days`. Returns how many notes went.
|
||||
///
|
||||
/// `now` is a parameter so the window arithmetic is testable without waiting a month.
|
||||
pub fn sweep_expired_trash(
|
||||
conn: &Connection,
|
||||
retention_days: i64,
|
||||
now: DateTime<Utc>,
|
||||
) -> rusqlite::Result<usize> {
|
||||
if retention_days <= 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let cutoff = now - Duration::days(retention_days);
|
||||
let mut expired: Vec<String> = Vec::new();
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, trashed_at FROM notes WHERE trashed = 1 AND trashed_at IS NOT NULL",
|
||||
)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: String = row.get(0)?;
|
||||
let stamped: String = row.get(1)?;
|
||||
// PARSED, not string-compared. The server writes `+00:00` offsets and this
|
||||
// client writes `Z`, so two timestamps for the same instant don't sort
|
||||
// against each other as text — and the failure would be silent.
|
||||
//
|
||||
// An unparseable stamp means "age unknown", and the only safe reading of
|
||||
// that is to keep the note. Deleting on a guess is the one outcome nobody
|
||||
// can undo.
|
||||
let Ok(trashed_at) = DateTime::parse_from_rfc3339(&stamped) else {
|
||||
continue;
|
||||
};
|
||||
if trashed_at.with_timezone(&Utc) < cutoff {
|
||||
expired.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in &expired {
|
||||
// Through delete_forever, so a `pending_deletes` tombstone is recorded. That's
|
||||
// right even here: while unlinked this device holds the only copy, so if it
|
||||
// links later the server should learn the note was deleted, not re-send it.
|
||||
store::delete_forever(conn, id)?;
|
||||
}
|
||||
Ok(expired.len())
|
||||
}
|
||||
|
||||
/// The startup sweep: runs only on an unlinked device (see the module note).
|
||||
/// Returns `None` when it didn't run because the device is linked.
|
||||
pub fn sweep_if_unlinked(conn: &Connection) -> rusqlite::Result<Option<usize>> {
|
||||
if state::read(conn)?.server_url.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
sweep_expired_trash(conn, LOCAL_RETENTION_DAYS, Utc::now()).map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::local::schema;
|
||||
|
||||
fn db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("in-memory db");
|
||||
schema::migrate(&conn).expect("migrate");
|
||||
conn
|
||||
}
|
||||
|
||||
/// A trashed note of a given age, stamped in the format the CLIENT writes
|
||||
/// (`...Z`, millisecond precision — see `store::now`).
|
||||
fn trashed_note_aged(conn: &Connection, id: &str, age: Duration) {
|
||||
let when = Utc::now() - age;
|
||||
let stamped = when.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES (?1, 'T', 'B', ?2, ?2, 1, ?2)",
|
||||
rusqlite::params![id, stamped],
|
||||
)
|
||||
.expect("insert");
|
||||
}
|
||||
|
||||
fn trashed_note(conn: &Connection, id: &str, days_ago: i64) {
|
||||
trashed_note_aged(conn, id, Duration::days(days_ago));
|
||||
}
|
||||
|
||||
fn sweep(conn: &Connection, days: i64) -> usize {
|
||||
sweep_expired_trash(conn, days, Utc::now()).expect("sweep")
|
||||
}
|
||||
|
||||
fn note_count(conn: &Connection) -> i64 {
|
||||
conn.query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0))
|
||||
.expect("count")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purges_trash_past_the_window_and_keeps_the_rest() {
|
||||
let conn = db();
|
||||
trashed_note(&conn, "old", 40);
|
||||
trashed_note(&conn, "fresh", 3);
|
||||
let purged = sweep(&conn, 30);
|
||||
assert_eq!(purged, 1);
|
||||
assert_eq!(note_count(&conn), 1, "only the expired note should go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_just_inside_the_window_survives() {
|
||||
// The comparison is STRICTLY older than the cutoff, so a note with a minute
|
||||
// of its 30 days still to run is kept. An exact tie isn't testable against a
|
||||
// wall clock — the sweep reads `now` microseconds after the row is stamped,
|
||||
// which is precisely how the first version of this test failed.
|
||||
let conn = db();
|
||||
let almost = Duration::days(30) - Duration::minutes(1);
|
||||
trashed_note_aged(&conn, "boundary", almost);
|
||||
assert_eq!(sweep(&conn, 30), 0);
|
||||
assert_eq!(note_count(&conn), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_off_purges_nothing() {
|
||||
let conn = db();
|
||||
trashed_note(&conn, "ancient", 4000);
|
||||
assert_eq!(sweep(&conn, 0), 0);
|
||||
assert_eq!(sweep(&conn, -1), 0);
|
||||
assert_eq!(note_count(&conn), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untrashed_note_is_never_swept() {
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed)
|
||||
VALUES ('live', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 0)",
|
||||
[],
|
||||
)
|
||||
.expect("insert");
|
||||
assert_eq!(sweep(&conn, 30), 0);
|
||||
assert_eq!(note_count(&conn), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparseable_timestamp_keeps_the_note() {
|
||||
// "Age unknown" must never resolve to "delete it".
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES ('weird', 'T', 'B', '2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z', 1, 'not a date')",
|
||||
[],
|
||||
)
|
||||
.expect("insert");
|
||||
assert_eq!(sweep(&conn, 30), 0);
|
||||
assert_eq!(note_count(&conn), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_style_offset_timestamp_is_understood() {
|
||||
// The server serializes with a `+00:00` offset, not `Z`. Comparing those as
|
||||
// strings would quietly never match — this is the case that catches it.
|
||||
let conn = db();
|
||||
let stamped = (Utc::now() - Duration::days(40)).to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, created_at, updated_at, trashed, trashed_at)
|
||||
VALUES ('server', 'T', 'B', ?1, ?1, 1, ?1)",
|
||||
rusqlite::params![stamped],
|
||||
)
|
||||
.expect("insert");
|
||||
assert_eq!(sweep(&conn, 30), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_purged_note_leaves_a_pending_delete_behind() {
|
||||
// Without the tombstone, linking this device later would let the server
|
||||
// re-send a note the user already destroyed here.
|
||||
let conn = db();
|
||||
trashed_note(&conn, "old", 40);
|
||||
sweep(&conn, 30);
|
||||
let pending: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pending_deletes WHERE entity = 'note' AND id = 'old'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("count");
|
||||
assert_eq!(pending, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_device_does_not_sweep() {
|
||||
// The whole safety rule: with a server present, purging is the server's call.
|
||||
let conn = db();
|
||||
trashed_note(&conn, "old", 400);
|
||||
state::set_link(&conn, "https://notes.example", "token").expect("link");
|
||||
assert_eq!(sweep_if_unlinked(&conn).expect("sweep"), None);
|
||||
assert_eq!(
|
||||
note_count(&conn),
|
||||
1,
|
||||
"the note must survive on a linked device"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlinked_device_sweeps() {
|
||||
let conn = db();
|
||||
trashed_note(&conn, "old", 400);
|
||||
assert_eq!(sweep_if_unlinked(&conn).expect("sweep"), Some(1));
|
||||
assert_eq!(note_count(&conn), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Local SQLite schema + migrations. The schema mirrors the note/label model so an
|
||||
//! offline note can later sync 1:1 with the server. Each syncable row carries local
|
||||
//! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7);
|
||||
//! `[[links]]` are NOT stored (derived at query time), matching docs/sync.md.
|
||||
//!
|
||||
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
const SCHEMA_V1: &str = r#"
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
kind TEXT NOT NULL DEFAULT 'text', -- 'text' | 'list'
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
trashed INTEGER NOT NULL DEFAULT 0,
|
||||
remind_at TEXT,
|
||||
recurrence TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
sync_revision INTEGER NOT NULL DEFAULT 0,
|
||||
dirty INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE labels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
sync_revision INTEGER NOT NULL DEFAULT 0,
|
||||
dirty INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_labels_name ON labels (lower(name));
|
||||
|
||||
CREATE TABLE note_labels (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
|
||||
via_tag INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (note_id, label_id)
|
||||
);
|
||||
CREATE INDEX idx_note_labels_label ON note_labels (label_id);
|
||||
|
||||
CREATE TABLE checklist_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
checked INTEGER NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_items_note ON checklist_items (note_id);
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
filename TEXT,
|
||||
mime TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size INTEGER,
|
||||
sha256 TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_attachments_note ON attachments (note_id);
|
||||
|
||||
CREATE TABLE link_previews (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
site_name TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_previews_note ON link_previews (note_id);
|
||||
|
||||
CREATE TABLE note_revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
title TEXT,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_revisions_note ON note_revisions (note_id, created_at);
|
||||
|
||||
CREATE TABLE saved_filters (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
params TEXT NOT NULL DEFAULT '{}', -- NoteFacets JSON
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Single-row sync bookkeeping (server URL / device token / last-consumed cursor).
|
||||
CREATE TABLE sync_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
server_url TEXT,
|
||||
device_token TEXT,
|
||||
last_cursor TEXT
|
||||
);
|
||||
INSERT INTO sync_state (id) VALUES (1);
|
||||
"#;
|
||||
|
||||
// v2 (M10.7c): local tombstones.
|
||||
//
|
||||
// A permanent delete previously just dropped the row, which left NO record that it
|
||||
// ever existed. Offline, that means the delete can never be pushed — and the next
|
||||
// pull would faithfully resurrect the note from the server. A deletion that undoes
|
||||
// itself is about the worst outcome sync can produce, so deletes are now recorded
|
||||
// here until they've been acknowledged by the server and cleared.
|
||||
const SCHEMA_V2: &str = r#"
|
||||
CREATE TABLE pending_deletes (
|
||||
entity TEXT NOT NULL, -- 'note' | 'label'
|
||||
id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (entity, id)
|
||||
);
|
||||
"#;
|
||||
|
||||
// v3 (M10.7e): when the last successful sync finished.
|
||||
//
|
||||
// The cursor alone can't answer "is this up to date?" — it's a revision watermark,
|
||||
// not a time, and it doesn't move at all when a sync legitimately finds nothing new.
|
||||
// The UI needs a timestamp to say anything honest.
|
||||
const SCHEMA_V3: &str = r#"
|
||||
ALTER TABLE sync_state ADD COLUMN last_sync_at TEXT;
|
||||
"#;
|
||||
|
||||
// v4 (M11.3): WHEN a note was trashed.
|
||||
//
|
||||
// The table only ever recorded THAT a note was trashed, which is enough to draw a
|
||||
// Trash view and nothing else. Retention needs an age: without a timestamp there is
|
||||
// no way to tell a note trashed this morning from one trashed last spring, so an
|
||||
// offline device could never expire its own trash — and the UI couldn't warn anyone
|
||||
// before it did.
|
||||
// It also records the LINKED server's retention window, captured from /api/config.
|
||||
// Once linked, the server's policy is the one that actually applies, so showing this
|
||||
// device's offline default would put a countdown on screen that doesn't match what
|
||||
// happens — a wrong deadline is worse than none.
|
||||
const SCHEMA_V4: &str = r#"
|
||||
ALTER TABLE notes ADD COLUMN trashed_at TEXT;
|
||||
UPDATE notes SET trashed_at = updated_at WHERE trashed = 1;
|
||||
ALTER TABLE sync_state ADD COLUMN server_retention_days INTEGER;
|
||||
"#;
|
||||
|
||||
// v5 (M10.9): small key/value app preferences.
|
||||
//
|
||||
// The first entry is the update channel, which is neither note data nor part of the
|
||||
// server link — so it belongs in neither `notes` nor `sync_state`. Generic on
|
||||
// purpose: the next device-local preference shouldn't need another migration.
|
||||
const SCHEMA_V5: &str = r#"
|
||||
CREATE TABLE prefs (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"#;
|
||||
|
||||
/// Bring the database up to the latest schema. Idempotent.
|
||||
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
if version < 1 {
|
||||
conn.execute_batch(SCHEMA_V1)?;
|
||||
conn.execute_batch("PRAGMA user_version = 1;")?;
|
||||
}
|
||||
if version < 2 {
|
||||
conn.execute_batch(SCHEMA_V2)?;
|
||||
conn.execute_batch("PRAGMA user_version = 2;")?;
|
||||
}
|
||||
if version < 3 {
|
||||
conn.execute_batch(SCHEMA_V3)?;
|
||||
conn.execute_batch("PRAGMA user_version = 3;")?;
|
||||
}
|
||||
if version < 4 {
|
||||
conn.execute_batch(SCHEMA_V4)?;
|
||||
conn.execute_batch("PRAGMA user_version = 4;")?;
|
||||
}
|
||||
if version < 5 {
|
||||
conn.execute_batch(SCHEMA_V5)?;
|
||||
conn.execute_batch("PRAGMA user_version = 5;")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
//! The local SQLite store: every operation the repository seam needs, as plain
|
||||
//! functions over a `&Connection`. The Tauri commands (commands.rs) lock the shared
|
||||
//! connection and call these; keeping the SQL here (off the command layer) makes it
|
||||
//! unit-testable against an in-memory database.
|
||||
//!
|
||||
//! Timestamps are emitted exactly like JS `Date.toISOString()`
|
||||
//! ("YYYY-MM-DDTHH:MM:SS.sssZ") so string ordering and date-range comparisons line
|
||||
//! up with the values the frontend sends.
|
||||
|
||||
use chrono::{Duration, SecondsFormat, Utc};
|
||||
use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::local::derive;
|
||||
use crate::local::models::*;
|
||||
|
||||
fn now() -> String {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
}
|
||||
|
||||
fn new_id() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// title if non-empty, else the first non-blank body line — always a string.
|
||||
fn display_title(title: Option<&str>, body: &str) -> String {
|
||||
if let Some(t) = title {
|
||||
let t = t.trim();
|
||||
if !t.is_empty() {
|
||||
return t.to_string();
|
||||
}
|
||||
}
|
||||
body.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_title(raw: &str) -> Option<String> {
|
||||
let t = raw.trim();
|
||||
if t.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(t.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_like(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
// ---- note assembly ----------------------------------------------------------
|
||||
|
||||
fn load_labels(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<NoteLabel>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT l.id, l.name, l.color, nl.via_tag
|
||||
FROM note_labels nl JOIN labels l ON l.id = nl.label_id
|
||||
WHERE nl.note_id = ?1 ORDER BY l.name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt.query_map([note_id], |r| {
|
||||
Ok(NoteLabel {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
via_tag: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn load_items(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<ChecklistItem>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, text, checked, position FROM checklist_items WHERE note_id = ?1 ORDER BY position ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([note_id], |r| {
|
||||
Ok(ChecklistItem {
|
||||
id: r.get(0)?,
|
||||
text: r.get(1)?,
|
||||
checked: r.get(2)?,
|
||||
position: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn load_attachments(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<Attachment>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, url, filename, mime, size, sha256 FROM attachments WHERE note_id = ?1 ORDER BY position ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([note_id], |r| {
|
||||
let server_url: String = r.get(1)?;
|
||||
let mime: String = r.get(3)?;
|
||||
let sha256: Option<String> = r.get(5)?;
|
||||
Ok(Attachment {
|
||||
id: r.get(0)?,
|
||||
// Point at the LOCAL bytes, not the server's route. The stored url is the
|
||||
// server's relative path, which resolves against the app origin in the
|
||||
// webview and 404s — and even absolute it would need a bearer token the
|
||||
// webview never sends. Rewriting here rather than at each render site
|
||||
// means NoteCard and NoteEditor stay untouched and can't drift.
|
||||
//
|
||||
// Without a hash there's nothing to address the blob by (an older server
|
||||
// that predates the sha256 column), so the original url is left alone:
|
||||
// still broken, but no more broken than it already was.
|
||||
url: match sha256.as_deref() {
|
||||
Some(hash) if !hash.is_empty() => crate::sync::blobs::url_for(hash, &mime),
|
||||
_ => server_url,
|
||||
},
|
||||
filename: r.get(2)?,
|
||||
mime,
|
||||
size: r.get(4)?,
|
||||
sha256,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkPreview>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, url, title, description, image_url, site_name FROM link_previews WHERE note_id = ?1 ORDER BY position ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([note_id], |r| {
|
||||
Ok(LinkPreview {
|
||||
id: r.get(0)?,
|
||||
url: r.get(1)?,
|
||||
title: r.get(2)?,
|
||||
description: r.get(3)?,
|
||||
image_url: r.get(4)?,
|
||||
site_name: r.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
let mut note = conn.query_row(
|
||||
"SELECT id, title, body, color, kind, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
|
||||
FROM notes WHERE id = ?1",
|
||||
[id],
|
||||
|r| {
|
||||
let title: Option<String> = r.get(1)?;
|
||||
let body: String = r.get(2)?;
|
||||
let dt = display_title(title.as_deref(), &body);
|
||||
Ok(Note {
|
||||
id: r.get(0)?,
|
||||
title,
|
||||
display_title: dt,
|
||||
body,
|
||||
color: r.get(3)?,
|
||||
kind: r.get(4)?,
|
||||
position: r.get(5)?,
|
||||
pinned: r.get(6)?,
|
||||
archived: r.get(7)?,
|
||||
trashed: r.get(8)?,
|
||||
deleted_at: r.get(13)?,
|
||||
remind_at: r.get(9)?,
|
||||
recurrence: r.get(10)?,
|
||||
labels: Vec::new(),
|
||||
items: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
previews: Vec::new(),
|
||||
created_at: r.get(11)?,
|
||||
updated_at: r.get(12)?,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
note.labels = load_labels(conn, id)?;
|
||||
note.items = load_items(conn, id)?;
|
||||
note.attachments = load_attachments(conn, id)?;
|
||||
note.previews = load_previews(conn, id)?;
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
fn touch(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE notes SET updated_at = ?1, dirty = 1 WHERE id = ?2",
|
||||
params![now(), id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- #tag -> label derivation ----------------------------------------------
|
||||
|
||||
fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<String> {
|
||||
let existing: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM labels WHERE lower(name) = lower(?1)",
|
||||
[name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(id) = existing {
|
||||
return Ok(id);
|
||||
}
|
||||
let id = new_id();
|
||||
let ts = now();
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty) VALUES (?1, ?2, 'default', ?3, ?3, 1)",
|
||||
params![id, name, ts],
|
||||
)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Re-sync the note's `via_tag` labels to exactly the `#tags` in its body.
|
||||
fn sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
|
||||
let tags = derive::extract_tags(body);
|
||||
let mut desired: Vec<String> = Vec::with_capacity(tags.len());
|
||||
for t in &tags {
|
||||
desired.push(find_or_create_label(conn, t)?);
|
||||
}
|
||||
|
||||
let current: Vec<String> = {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 1")?;
|
||||
let rows = stmt.query_map([note_id], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
for lid in ¤t {
|
||||
if !desired.contains(lid) {
|
||||
conn.execute(
|
||||
"DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1",
|
||||
params![note_id, lid],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for lid in &desired {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)",
|
||||
params![note_id, lid],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- notes: read ------------------------------------------------------------
|
||||
|
||||
pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note>> {
|
||||
let mut sql = String::from("SELECT id FROM notes WHERE ");
|
||||
sql.push_str(match q.view.as_str() {
|
||||
"trash" => "trashed = 1",
|
||||
"archived" => "trashed = 0 AND archived = 1",
|
||||
_ => "trashed = 0 AND archived = 0",
|
||||
});
|
||||
|
||||
let mut binds: Vec<String> = Vec::new();
|
||||
|
||||
// Label filters (sidebar label + facet labels) are ANDed: a note must carry all.
|
||||
let mut label_ids: Vec<String> = Vec::new();
|
||||
if let Some(l) = q.label_id.as_deref().filter(|s| !s.is_empty()) {
|
||||
label_ids.push(l.to_string());
|
||||
}
|
||||
if let Some(f) = &q.facets {
|
||||
if let Some(ls) = &f.label {
|
||||
for l in ls.iter().filter(|s| !s.is_empty()) {
|
||||
label_ids.push(l.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for lid in &label_ids {
|
||||
sql.push_str(" AND EXISTS (SELECT 1 FROM note_labels nl WHERE nl.note_id = notes.id AND nl.label_id = ?)");
|
||||
binds.push(lid.clone());
|
||||
}
|
||||
|
||||
if let Some(f) = &q.facets {
|
||||
if let Some(text) = f.q.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND (title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')");
|
||||
let pat = format!("%{}%", escape_like(text));
|
||||
binds.push(pat.clone());
|
||||
binds.push(pat);
|
||||
}
|
||||
if let Some(c) = f.color.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND color = ?");
|
||||
binds.push(c.to_string());
|
||||
}
|
||||
if let Some(k) = f.kind.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND kind = ?");
|
||||
binds.push(k.to_string());
|
||||
}
|
||||
if f.has_reminder == Some(true) {
|
||||
sql.push_str(" AND remind_at IS NOT NULL");
|
||||
}
|
||||
if f.has_attachment == Some(true) {
|
||||
sql.push_str(" AND EXISTS (SELECT 1 FROM attachments a WHERE a.note_id = notes.id)");
|
||||
}
|
||||
if let Some(a) = f.created_after.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND created_at >= ?");
|
||||
binds.push(a.to_string());
|
||||
}
|
||||
if let Some(b) = f.created_before.as_deref().filter(|s| !s.is_empty()) {
|
||||
sql.push_str(" AND created_at < ?");
|
||||
binds.push(b.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(if q.sort.as_deref() == Some("created") {
|
||||
" ORDER BY created_at DESC"
|
||||
} else {
|
||||
" ORDER BY pinned DESC, position DESC, updated_at DESC"
|
||||
});
|
||||
|
||||
let ids: Vec<String> = {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_from_iter(binds.iter()), |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
ids.iter().map(|id| load_note(conn, id)).collect()
|
||||
}
|
||||
|
||||
pub fn get_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn reminders(conn: &Connection) -> rusqlite::Result<Vec<Note>> {
|
||||
let ids: Vec<String> = {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id FROM notes WHERE trashed = 0 AND remind_at IS NOT NULL ORDER BY remind_at ASC")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
ids.iter().map(|id| load_note(conn, id)).collect()
|
||||
}
|
||||
|
||||
pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
|
||||
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let title: Option<String> = r.get(1)?;
|
||||
let body: String = r.get(2)?;
|
||||
Ok(TitleEntry {
|
||||
id: r.get(0)?,
|
||||
title: display_title(title.as_deref(), &body),
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
|
||||
let pat = format!("%{}%", escape_like(q));
|
||||
let ids: Vec<String> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id FROM notes WHERE trashed = 0 AND (title LIKE ?1 ESCAPE '\\' OR body LIKE ?1 ESCAPE '\\') ORDER BY updated_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([&pat], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
ids.iter().map(|id| load_note(conn, id)).collect()
|
||||
}
|
||||
|
||||
pub fn backlinks(conn: &Connection, id: &str) -> rusqlite::Result<Vec<Backlink>> {
|
||||
let target: String = {
|
||||
let (t, b): (Option<String>, String) =
|
||||
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})?;
|
||||
display_title(t.as_deref(), &b)
|
||||
};
|
||||
if target.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0 AND id != ?1")?;
|
||||
let rows = stmt.query_map([id], |r| {
|
||||
let nid: String = r.get(0)?;
|
||||
let t: Option<String> = r.get(1)?;
|
||||
let b: String = r.get(2)?;
|
||||
Ok((nid, t, b))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (nid, t, b) = row?;
|
||||
if derive::extract_links(&b)
|
||||
.iter()
|
||||
.any(|l| l.eq_ignore_ascii_case(&target))
|
||||
{
|
||||
out.push(Backlink {
|
||||
id: nid,
|
||||
title: display_title(t.as_deref(), &b),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn link_search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<TitleEntry>> {
|
||||
let ql = q.trim().to_lowercase();
|
||||
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let id: String = r.get(0)?;
|
||||
let t: Option<String> = r.get(1)?;
|
||||
let b: String = r.get(2)?;
|
||||
Ok((id, t, b))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, t, b) = row?;
|
||||
let dt = display_title(t.as_deref(), &b);
|
||||
if ql.is_empty() || dt.to_lowercase().contains(&ql) {
|
||||
out.push(TitleEntry { id, title: dt });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- notes: write -----------------------------------------------------------
|
||||
|
||||
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
|
||||
let id = new_id();
|
||||
let ts = now();
|
||||
let title = normalize_title(&input.title);
|
||||
let kind = input.kind.clone().unwrap_or_else(|| "text".to_string());
|
||||
let position: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(position), 0) + 1 FROM notes",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, color, kind, position, created_at, updated_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, 1)",
|
||||
params![id, title, input.body, input.color, kind, position, ts],
|
||||
)?;
|
||||
if let Some(items) = &input.items {
|
||||
for (i, text) in items.iter().enumerate() {
|
||||
conn.execute(
|
||||
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![new_id(), id, text, i as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
sync_tags(conn, &id, &input.body)?;
|
||||
load_note(conn, &id)
|
||||
}
|
||||
|
||||
pub fn create_titled(conn: &Connection, title: &str) -> rusqlite::Result<Note> {
|
||||
let input = NoteCreateInput {
|
||||
title: title.to_string(),
|
||||
body: String::new(),
|
||||
color: "default".to_string(),
|
||||
kind: None,
|
||||
items: None,
|
||||
};
|
||||
create_note(conn, &input)
|
||||
}
|
||||
|
||||
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
let (title, body): (Option<String>, String) =
|
||||
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})?;
|
||||
conn.execute(
|
||||
"INSERT INTO note_revisions (id, note_id, title, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![new_id(), id, title, body, now()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PATCH semantics: apply exactly the fields present in `changes`.
|
||||
pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Result<Note> {
|
||||
let obj = changes
|
||||
.as_object()
|
||||
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
|
||||
|
||||
// Snapshot the pre-edit title/body once if either is being changed (version history).
|
||||
if obj.contains_key("title") || obj.contains_key("body") {
|
||||
snapshot_revision(conn, id)?;
|
||||
}
|
||||
|
||||
for (k, v) in obj {
|
||||
match k.as_str() {
|
||||
"title" => {
|
||||
let norm = v.as_str().and_then(normalize_title);
|
||||
conn.execute(
|
||||
"UPDATE notes SET title = ?1 WHERE id = ?2",
|
||||
params![norm, id],
|
||||
)?;
|
||||
}
|
||||
"body" => {
|
||||
let body = v.as_str().unwrap_or("");
|
||||
conn.execute(
|
||||
"UPDATE notes SET body = ?1 WHERE id = ?2",
|
||||
params![body, id],
|
||||
)?;
|
||||
sync_tags(conn, id, body)?;
|
||||
}
|
||||
"color" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?;
|
||||
}
|
||||
}
|
||||
"kind" => {
|
||||
if let Some(s) = v.as_str() {
|
||||
conn.execute("UPDATE notes SET kind = ?1 WHERE id = ?2", params![s, id])?;
|
||||
}
|
||||
}
|
||||
"pinned" => {
|
||||
if let Some(b) = v.as_bool() {
|
||||
conn.execute("UPDATE notes SET pinned = ?1 WHERE id = ?2", params![b, id])?;
|
||||
}
|
||||
}
|
||||
"archived" => {
|
||||
if let Some(b) = v.as_bool() {
|
||||
conn.execute(
|
||||
"UPDATE notes SET archived = ?1 WHERE id = ?2",
|
||||
params![b, id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
"remind_at" => {
|
||||
let val = v.as_str().map(|s| s.to_string());
|
||||
conn.execute(
|
||||
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
|
||||
params![val, id],
|
||||
)?;
|
||||
}
|
||||
"recurrence" => {
|
||||
let val = v.as_str().map(|s| s.to_string());
|
||||
conn.execute(
|
||||
"UPDATE notes SET recurrence = ?1 WHERE id = ?2",
|
||||
params![val, id],
|
||||
)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
// Clear the reminder. (Recurrence advancement is a later refinement.)
|
||||
conn.execute("UPDATE notes SET remind_at = NULL WHERE id = ?1", [id])?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> {
|
||||
let t = (Utc::now() + Duration::minutes(minutes)).to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||
conn.execute(
|
||||
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
|
||||
params![t, id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn set_labels(conn: &Connection, id: &str, label_ids: &[String]) -> rusqlite::Result<Note> {
|
||||
// Manual labels are replaced wholesale; #tag (via_tag) labels are managed by text.
|
||||
conn.execute(
|
||||
"DELETE FROM note_labels WHERE note_id = ?1 AND via_tag = 0",
|
||||
[id],
|
||||
)?;
|
||||
for lid in label_ids {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)",
|
||||
params![id, lid],
|
||||
)?;
|
||||
}
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> {
|
||||
let pos: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM checklist_items WHERE note_id = ?1",
|
||||
[id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO checklist_items (id, note_id, text, position) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![new_id(), id, text, pos],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn update_item(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
item_id: &str,
|
||||
changes: &Value,
|
||||
) -> rusqlite::Result<Note> {
|
||||
if let Some(text) = changes.get("text").and_then(Value::as_str) {
|
||||
conn.execute(
|
||||
"UPDATE checklist_items SET text = ?1 WHERE id = ?2 AND note_id = ?3",
|
||||
params![text, item_id, id],
|
||||
)?;
|
||||
}
|
||||
if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
|
||||
conn.execute(
|
||||
"UPDATE checklist_items SET checked = ?1 WHERE id = ?2 AND note_id = ?3",
|
||||
params![checked, item_id, id],
|
||||
)?;
|
||||
}
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> {
|
||||
conn.execute(
|
||||
"DELETE FROM checklist_items WHERE id = ?1 AND note_id = ?2",
|
||||
params![item_id, id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn delete_attachment(conn: &Connection, id: &str, att_id: &str) -> rusqlite::Result<Note> {
|
||||
conn.execute(
|
||||
"DELETE FROM attachments WHERE id = ?1 AND note_id = ?2",
|
||||
params![att_id, id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn delete_preview(conn: &Connection, id: &str, preview_id: &str) -> rusqlite::Result<Note> {
|
||||
conn.execute(
|
||||
"DELETE FROM link_previews WHERE id = ?1 AND note_id = ?2",
|
||||
params![preview_id, id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn reorder(conn: &Connection, ordered_ids: &[String]) -> rusqlite::Result<()> {
|
||||
let total = ordered_ids.len() as i64;
|
||||
for (i, id) in ordered_ids.iter().enumerate() {
|
||||
conn.execute(
|
||||
"UPDATE notes SET position = ?1, dirty = 1 WHERE id = ?2",
|
||||
params![total - i as i64, id],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn trash(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
// COALESCE, so trashing an already-trashed note doesn't restart its retention
|
||||
// clock. The server keeps its `deleted_at` the same way — a note shouldn't earn
|
||||
// another 30 days because something touched it twice.
|
||||
conn.execute(
|
||||
"UPDATE notes SET trashed = 1, trashed_at = COALESCE(trashed_at, ?1) WHERE id = ?2",
|
||||
params![now(), id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn restore(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
|
||||
conn.execute(
|
||||
"UPDATE notes SET trashed = 0, trashed_at = NULL WHERE id = ?1",
|
||||
[id],
|
||||
)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
pub fn delete_forever(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
record_pending_delete(conn, "note", id)?;
|
||||
conn.execute("DELETE FROM notes WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remember that a row was permanently deleted, so the sync engine can tell the
|
||||
/// server. Without this the deleted row leaves no trace at all, and the next pull
|
||||
/// would resurrect it — a delete that quietly undoes itself.
|
||||
///
|
||||
/// Harmless when the app is unlinked: the row is simply never read, and a later push
|
||||
/// gets a `noop` for an id the server never had.
|
||||
pub fn record_pending_delete(conn: &Connection, entity: &str, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO pending_deletes (entity, id, deleted_at) VALUES (?1, ?2, ?3)",
|
||||
params![entity, id, now()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- device-local preferences (schema v5) -----------------------------------
|
||||
|
||||
/// A stored preference, or `None` if it was never set. Callers supply their own
|
||||
/// default rather than one being invented here — the meaning of "unset" belongs
|
||||
/// with the setting, not with the storage.
|
||||
pub fn pref(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
|
||||
conn.query_row("SELECT value FROM prefs WHERE key = ?1", [key], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn set_pref(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO prefs (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn revisions(conn: &Connection, id: &str) -> rusqlite::Result<Vec<NoteRevision>> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, title, body, created_at FROM note_revisions WHERE note_id = ?1 ORDER BY created_at DESC")?;
|
||||
let rows = stmt.query_map([id], |r| {
|
||||
Ok(NoteRevision {
|
||||
id: r.get(0)?,
|
||||
title: r.get(1)?,
|
||||
body: r.get(2)?,
|
||||
created_at: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::Result<Note> {
|
||||
let (title, body): (Option<String>, String) = conn.query_row(
|
||||
"SELECT title, body FROM note_revisions WHERE id = ?1 AND note_id = ?2",
|
||||
params![rev_id, id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)?;
|
||||
snapshot_revision(conn, id)?;
|
||||
conn.execute(
|
||||
"UPDATE notes SET title = ?1, body = ?2 WHERE id = ?3",
|
||||
params![title, body, id],
|
||||
)?;
|
||||
sync_tags(conn, id, &body)?;
|
||||
touch(conn, id)?;
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
// ---- labels -----------------------------------------------------------------
|
||||
|
||||
fn load_label(conn: &Connection, id: &str) -> rusqlite::Result<Label> {
|
||||
conn.query_row(
|
||||
"SELECT l.id, l.name, l.color,
|
||||
(SELECT COUNT(*) FROM note_labels nl JOIN notes n ON n.id = nl.note_id
|
||||
WHERE nl.label_id = l.id AND n.trashed = 0)
|
||||
FROM labels l WHERE l.id = ?1",
|
||||
[id],
|
||||
|r| {
|
||||
Ok(Label {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
count: Some(r.get(3)?),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_labels(conn: &Connection) -> rusqlite::Result<Vec<Label>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT l.id, l.name, l.color,
|
||||
(SELECT COUNT(*) FROM note_labels nl JOIN notes n ON n.id = nl.note_id
|
||||
WHERE nl.label_id = l.id AND n.trashed = 0)
|
||||
FROM labels l ORDER BY l.name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(Label {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
count: Some(r.get(3)?),
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn create_label(conn: &Connection, name: &str) -> rusqlite::Result<Label> {
|
||||
let id = find_or_create_label(conn, name)?;
|
||||
load_label(conn, &id)
|
||||
}
|
||||
|
||||
pub fn rename_label(conn: &Connection, id: &str, name: &str) -> rusqlite::Result<Label> {
|
||||
conn.execute(
|
||||
"UPDATE labels SET name = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
|
||||
params![name, now(), id],
|
||||
)?;
|
||||
load_label(conn, id)
|
||||
}
|
||||
|
||||
pub fn set_label_color(conn: &Connection, id: &str, color: &str) -> rusqlite::Result<Label> {
|
||||
conn.execute(
|
||||
"UPDATE labels SET color = ?1, updated_at = ?2, dirty = 1 WHERE id = ?3",
|
||||
params![color, now(), id],
|
||||
)?;
|
||||
load_label(conn, id)
|
||||
}
|
||||
|
||||
pub fn remove_label(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
record_pending_delete(conn, "label", id)?;
|
||||
conn.execute("DELETE FROM labels WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn merge_labels(
|
||||
conn: &Connection,
|
||||
source_id: &str,
|
||||
target_id: &str,
|
||||
) -> rusqlite::Result<Label> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag)
|
||||
SELECT note_id, ?2, 0 FROM note_labels WHERE label_id = ?1",
|
||||
params![source_id, target_id],
|
||||
)?;
|
||||
// The notes that carried the source now have a different label set, and that set
|
||||
// only reaches the server via the note itself (push sends label_ids per note).
|
||||
// Without this the merge would look done locally and never sync. Marked BEFORE
|
||||
// the delete, which cascades the membership rows away.
|
||||
conn.execute(
|
||||
"UPDATE notes SET dirty = 1
|
||||
WHERE id IN (SELECT note_id FROM note_labels WHERE label_id = ?1)",
|
||||
[source_id],
|
||||
)?;
|
||||
record_pending_delete(conn, "label", source_id)?;
|
||||
conn.execute("DELETE FROM labels WHERE id = ?1", [source_id])?;
|
||||
load_label(conn, target_id)
|
||||
}
|
||||
|
||||
// ---- saved filters ----------------------------------------------------------
|
||||
|
||||
pub fn list_saved_filters(conn: &Connection) -> rusqlite::Result<Vec<SavedFilter>> {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, name, params, position FROM saved_filters ORDER BY position ASC, name COLLATE NOCASE")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
let params_str: String = r.get(2)?;
|
||||
let params = serde_json::from_str(¶ms_str).unwrap_or_else(|_| serde_json::json!({}));
|
||||
Ok(SavedFilter {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
params,
|
||||
position: r.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn create_saved_filter(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
params: &Value,
|
||||
) -> rusqlite::Result<SavedFilter> {
|
||||
let id = new_id();
|
||||
let position: i64 = conn.query_row(
|
||||
"SELECT COALESCE(MAX(position), 0) + 1 FROM saved_filters",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let params_str = serde_json::to_string(params).unwrap_or_else(|_| "{}".to_string());
|
||||
conn.execute(
|
||||
"INSERT INTO saved_filters (id, name, params, position, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![id, name, params_str, position, now()],
|
||||
)?;
|
||||
Ok(SavedFilter {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
params: params.clone(),
|
||||
position,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove_saved_filter(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM saved_filters WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rename_saved_filter(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
name: &str,
|
||||
) -> rusqlite::Result<SavedFilter> {
|
||||
conn.execute(
|
||||
"UPDATE saved_filters SET name = ?1 WHERE id = ?2",
|
||||
params![name, id],
|
||||
)?;
|
||||
conn.query_row(
|
||||
"SELECT id, name, params, position FROM saved_filters WHERE id = ?1",
|
||||
[id],
|
||||
|r| {
|
||||
let params_str: String = r.get(2)?;
|
||||
let params =
|
||||
serde_json::from_str(¶ms_str).unwrap_or_else(|_| serde_json::json!({}));
|
||||
Ok(SavedFilter {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
params,
|
||||
position: r.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Local storage for attachment bytes (M10.7d).
|
||||
//!
|
||||
//! Content-addressed: a blob is filed under its own sha256, so the same image
|
||||
//! attached to five notes is stored once and re-downloading it is free. The hash is
|
||||
//! also the integrity check — bytes that don't hash to what the server advertised
|
||||
//! are refused rather than filed under a name that lies about them.
|
||||
//!
|
||||
//! Attachment METADATA rides the delta feed; only the bytes come through here
|
||||
//! (docs/sync.md).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// A sha256 in lowercase hex, and nothing else.
|
||||
///
|
||||
/// This is a **path-safety** check, not a formatting nicety: the hash is taken
|
||||
/// straight from a server response and used as a filename. Without it, a hostile or
|
||||
/// buggy server could send `../../…` and steer a write outside the blob directory.
|
||||
fn is_hash(candidate: &str) -> bool {
|
||||
candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct BlobStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl BlobStore {
|
||||
/// Open (creating if needed) the blob directory.
|
||||
pub fn new(root: PathBuf) -> std::io::Result<Self> {
|
||||
fs::create_dir_all(&root)?;
|
||||
Ok(Self { root })
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Where a blob lives, or `None` if the hash isn't one.
|
||||
pub fn path(&self, sha256: &str) -> Option<PathBuf> {
|
||||
let lower = sha256.to_ascii_lowercase();
|
||||
is_hash(&lower).then(|| self.root.join(lower))
|
||||
}
|
||||
|
||||
/// Whether we already hold these bytes. Drives the "don't download it twice"
|
||||
/// skip, which is the entire point of keying by content.
|
||||
pub fn has(&self, sha256: &str) -> bool {
|
||||
self.path(sha256).is_some_and(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// File bytes under `expected`, refusing them if they don't hash to it.
|
||||
///
|
||||
/// Verifying on the way IN rather than on the way out means a corrupted transfer
|
||||
/// can never be served later as if it were genuine — and the next sync simply
|
||||
/// tries again, because the blob still counts as missing.
|
||||
pub fn store(&self, expected: &str, bytes: &[u8]) -> Result<PathBuf, String> {
|
||||
let path = self
|
||||
.path(expected)
|
||||
.ok_or_else(|| format!("refusing an attachment with a malformed hash: {expected}"))?;
|
||||
let actual = digest(bytes);
|
||||
if actual != expected.to_ascii_lowercase() {
|
||||
return Err(format!(
|
||||
"attachment failed its integrity check (expected {expected}, got {actual})"
|
||||
));
|
||||
}
|
||||
fs::write(&path, bytes).map_err(|e| format!("couldn't save an attachment: {e}"))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn read(&self, sha256: &str) -> Option<Vec<u8>> {
|
||||
fs::read(self.path(sha256)?).ok()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Serving blobs to the webview (M10.7f) -----------------------------------
|
||||
//
|
||||
// A synced note's attachment `url` is the SERVER's relative path
|
||||
// (`/api/notes/<id>/attachments/<aid>`). In the desktop webview that resolves
|
||||
// against the app origin and 404s, and swapping in the absolute server URL wouldn't
|
||||
// help either — that route needs a bearer token the webview won't send, and it would
|
||||
// make an offline app fetch over the network to show a file it already has on disk.
|
||||
//
|
||||
// So the bytes are served locally, over a custom URI scheme, straight out of this
|
||||
// store. The webview then caches and range-requests them like any other resource,
|
||||
// which a `data:` URI would have thrown away.
|
||||
|
||||
/// The scheme the webview fetches attachment bytes over.
|
||||
pub const BLOB_SCHEME: &str = "tsblob";
|
||||
|
||||
/// The blob directory, published once the app has resolved its data dir.
|
||||
///
|
||||
/// A `OnceLock` rather than Tauri's managed state because the scheme handler is
|
||||
/// registered on the BUILDER, before `setup` has computed that path — and because
|
||||
/// reading it this way keeps the handler independent of which Tauri 2.x minor
|
||||
/// changed the handler's context argument.
|
||||
static SERVE_ROOT: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
pub fn publish_root(root: PathBuf) {
|
||||
let _ = SERVE_ROOT.set(root);
|
||||
}
|
||||
|
||||
/// The URL an `<img>`/`<audio>`/`<a href>` should point at for these bytes.
|
||||
///
|
||||
/// **The two forms are not interchangeable.** A custom scheme is reachable as
|
||||
/// `scheme://localhost/<path>` on Linux and macOS, but Windows and Android map it
|
||||
/// onto `http://scheme.localhost/<path>`. Getting this wrong breaks exactly one
|
||||
/// platform, silently, and CI cannot catch it — the runner is headless.
|
||||
pub fn url_for(sha256: &str, mime: &str) -> String {
|
||||
let query = urlencode(mime);
|
||||
if cfg!(any(windows, target_os = "android")) {
|
||||
format!("http://{BLOB_SCHEME}.localhost/{sha256}?mime={query}")
|
||||
} else {
|
||||
format!("{BLOB_SCHEME}://localhost/{sha256}?mime={query}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode the few characters a mime type can contain that don't belong in a
|
||||
/// query value. Hand-rolled rather than adding a dependency for `/` and `+`.
|
||||
fn urlencode(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 8);
|
||||
for b in value.bytes() {
|
||||
match b {
|
||||
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn urldecode(value: &str) -> String {
|
||||
let bytes = value.as_bytes();
|
||||
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
|
||||
if let Ok(byte) = u8::from_str_radix(hex, 16) {
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// The Content-Type to serve for a claimed mime.
|
||||
///
|
||||
/// The mime rides in the URL and this scheme is an origin of its own, so echoing an
|
||||
/// arbitrary type would let an attachment claiming `text/html` run as a document
|
||||
/// there. Echoing is safe only because of the FAMILY check: nothing starting with
|
||||
/// `image/` can name a scriptable type. Everything else is served as an opaque
|
||||
/// download — the right treatment for an arbitrary file regardless.
|
||||
fn content_type_for(mime: &str) -> String {
|
||||
const RENDERABLE: &[&str] = &["image/", "audio/", "video/"];
|
||||
let familiar = RENDERABLE.iter().any(|p| mime.starts_with(p)) || mime == "application/pdf";
|
||||
// A header value can't carry control characters, and a mime type has no business
|
||||
// being long — both would only arrive from a malformed or hostile feed.
|
||||
let printable = mime.len() <= 100 && mime.bytes().all(|b| b.is_ascii_graphic());
|
||||
if familiar && printable {
|
||||
mime.to_string()
|
||||
} else {
|
||||
"application/octet-stream".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve one request from the blob store. `path` is the URI path, `query` its query.
|
||||
pub fn serve(path: &str, query: Option<&str>) -> (u16, String, Vec<u8>) {
|
||||
let requested = path.trim_start_matches('/');
|
||||
let Some(root) = SERVE_ROOT.get() else {
|
||||
// A request before the store was published — nothing to serve yet.
|
||||
return (503, "text/plain".into(), Vec::new());
|
||||
};
|
||||
let store = BlobStore { root: root.clone() };
|
||||
// `read` goes through `path`, which rejects anything that isn't a bare sha256 —
|
||||
// so this handler inherits the traversal guard rather than re-implementing it.
|
||||
let Some(bytes) = store.read(requested) else {
|
||||
return (404, "text/plain".into(), Vec::new());
|
||||
};
|
||||
let claimed = query
|
||||
.and_then(|q| q.split('&').find_map(|p| p.strip_prefix("mime=")))
|
||||
.map(urldecode)
|
||||
.unwrap_or_default();
|
||||
(200, content_type_for(&claimed), bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A blob store in a throwaway directory. No tempfile dependency for one test
|
||||
/// fixture — the process id keeps concurrent runs apart.
|
||||
fn store(tag: &str) -> BlobStore {
|
||||
let dir = std::env::temp_dir().join(format!("ts-blobs-{}-{tag}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
BlobStore::new(dir).expect("store")
|
||||
}
|
||||
|
||||
/// sha256("hello") — a fixed vector, so a broken digest can't agree with itself.
|
||||
const HELLO: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
|
||||
#[test]
|
||||
fn digest_matches_a_known_vector() {
|
||||
assert_eq!(digest(b"hello"), HELLO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_and_reads_back() {
|
||||
let store = store("roundtrip");
|
||||
assert!(!store.has(HELLO));
|
||||
store.store(HELLO, b"hello").expect("store");
|
||||
assert!(store.has(HELLO));
|
||||
assert_eq!(store.read(HELLO).as_deref(), Some(&b"hello"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_bytes_that_dont_match_the_hash() {
|
||||
// A corrupted or substituted transfer must never be filed under a name that
|
||||
// claims it's genuine.
|
||||
let store = store("mismatch");
|
||||
let err = store.store(HELLO, b"goodbye").expect_err("must reject");
|
||||
assert!(err.contains("integrity"), "got {err}");
|
||||
assert!(!store.has(HELLO), "nothing should have been written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_hash_that_could_escape_the_directory() {
|
||||
// The hash arrives from a server response and becomes a filename.
|
||||
let store = store("traversal");
|
||||
assert!(store.path("../../etc/passwd").is_none());
|
||||
assert!(store.store("../../etc/passwd", b"x").is_err());
|
||||
assert!(store.path("").is_none());
|
||||
assert!(store.path("nothex!!").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_an_uppercase_hash() {
|
||||
// The wire format isn't guaranteed to be lowercase; the filename is.
|
||||
let store = store("case");
|
||||
store
|
||||
.store(&HELLO.to_ascii_uppercase(), b"hello")
|
||||
.expect("store");
|
||||
assert!(store.has(HELLO), "should be found under the lowercase name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blob_url_carries_the_hash_and_the_mime() {
|
||||
let url = url_for(HELLO, "image/png");
|
||||
assert!(url.contains(HELLO), "the hash addresses the bytes: {url}");
|
||||
assert!(url.contains("mime=image%2Fpng"), "mime encoded: {url}");
|
||||
// The platform split is the whole risk of this feature, and CI is headless,
|
||||
// so at least pin that the right branch was taken for THIS build.
|
||||
if cfg!(any(windows, target_os = "android")) {
|
||||
assert!(url.starts_with("http://tsblob.localhost/"), "{url}");
|
||||
} else {
|
||||
assert!(url.starts_with("tsblob://localhost/"), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encoding_round_trips_a_mime() {
|
||||
assert_eq!(urldecode(&urlencode("image/svg+xml")), "image/svg+xml");
|
||||
assert_eq!(urldecode(&urlencode("audio/mpeg")), "audio/mpeg");
|
||||
// A malformed escape is left alone rather than eaten — the value still has to
|
||||
// survive intact enough for `content_type_for` to reject it.
|
||||
assert_eq!(urldecode("not-an-escape%ZZ"), "not-an-escape%ZZ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_types_are_echoed_back() {
|
||||
assert_eq!(content_type_for("image/png"), "image/png");
|
||||
assert_eq!(content_type_for("audio/mpeg"), "audio/mpeg");
|
||||
assert_eq!(content_type_for("application/pdf"), "application/pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scriptable_type_is_served_as_a_download() {
|
||||
// This scheme is an origin of its own. An attachment claiming to be HTML
|
||||
// must not be handed back as a document that can run there.
|
||||
let opaque = "application/octet-stream";
|
||||
assert_eq!(content_type_for("text/html"), opaque);
|
||||
assert_eq!(content_type_for("application/javascript"), opaque);
|
||||
assert_eq!(content_type_for(""), opaque);
|
||||
// A control character can't reach a header value even under a safe family.
|
||||
assert_eq!(content_type_for("image/png\r\nX-Evil: 1"), opaque);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serving_refuses_a_path_that_isnt_a_hash() {
|
||||
// Delegated to `path`, so the traversal guard is the same one `store` uses.
|
||||
publish_root(std::env::temp_dir().join("ts-blobs-serve-guard"));
|
||||
let (status, _, body) = serve("/../../etc/passwd", None);
|
||||
assert_eq!(status, 404);
|
||||
assert!(body.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_blob_reads_as_none() {
|
||||
let store = store("missing");
|
||||
assert!(store.read(HELLO).is_none());
|
||||
assert!(!store.has(HELLO));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! HTTP transport to a ThoughtSync server.
|
||||
//!
|
||||
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
|
||||
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
|
||||
//! which is why the timeout, identity headers and error vocabulary live here rather
|
||||
//! than inline at each call site.
|
||||
//!
|
||||
//! Nothing here runs unless the user has linked a server; the app is local-first and
|
||||
//! fully usable with no network at all.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::{RequestBuilder, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::compat::{self, Compatibility, ServerInfo};
|
||||
use super::wire;
|
||||
|
||||
/// Timeout for the short request/response calls in this module. Kept tight because a
|
||||
/// user is watching a button while they run, and the most common mistake — a wrong
|
||||
/// host on a LAN — fails by hanging rather than refusing, so an unbounded wait would
|
||||
/// just look frozen. The sync engine's bulk transfers will need their own, longer one.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Bulk transfers get much longer: a first full sync can be thousands of notes, and
|
||||
/// failing one at ten seconds would make a large store impossible to ever pull.
|
||||
const SYNC_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Shared by every call that presents a token, so a revoked one reads the same way
|
||||
/// wherever it surfaces.
|
||||
const TOKEN_REJECTED: &str = "This server rejected the device token — it may have been \
|
||||
revoked. Unlink and link again to issue a new one.";
|
||||
|
||||
/// What the link UI needs after a handshake: where we ended up (the normalized URL,
|
||||
/// which may differ from what was typed), who answered, and whether we can work
|
||||
/// with them.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProbeResult {
|
||||
pub base_url: String,
|
||||
pub server: ServerInfo,
|
||||
pub compatibility: Compatibility,
|
||||
}
|
||||
|
||||
/// The account a device token belongs to. Surfaced after linking so the user can
|
||||
/// confirm they linked the account they meant to — easy to get wrong on a server
|
||||
/// hosting more than one.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Identity {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeviceLoginResponse {
|
||||
token: String,
|
||||
user: Identity,
|
||||
}
|
||||
|
||||
/// What became of this device's token on the SERVER when unlinking.
|
||||
///
|
||||
/// Not a bool, and not an error: unlinking must never be blocked by the network —
|
||||
/// wanting to stop syncing is a local decision — so the remote half reports back
|
||||
/// instead of failing the call, and each outcome needs different advice.
|
||||
///
|
||||
/// Serialized tagged, like `Compatibility`, so the frontend can `switch` on `status`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum RevokeOutcome {
|
||||
/// The server confirmed it: this token authenticates nothing now.
|
||||
Revoked,
|
||||
/// This server has no self-revoke route — it predates one. The token is still
|
||||
/// live, and only the web app can retire it.
|
||||
Unsupported,
|
||||
/// We couldn't reach the server, or it refused. The token is still live.
|
||||
Failed { reason: String },
|
||||
/// Nothing to revoke; the app wasn't linked.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// Retire the device token we authenticate with, server-side.
|
||||
///
|
||||
/// Identified by the token itself rather than a device id, because a token pasted
|
||||
/// from the web app never carried one — a route keyed on the id would work for
|
||||
/// exactly one of the two ways this app can be linked.
|
||||
pub async fn revoke_self(base_url: &str, token: &str) -> RevokeOutcome {
|
||||
let client = match http() {
|
||||
Ok(client) => client,
|
||||
Err(reason) => return RevokeOutcome::Failed { reason },
|
||||
};
|
||||
let request = prepare(client.delete(revoke_self_url(base_url)), Some(token));
|
||||
let response = match request.send().await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
return RevokeOutcome::Failed {
|
||||
reason: describe_transport_error(base_url, &e),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
// 401 counts as revoked: the token already authenticates nothing — retired by
|
||||
// another device, or purged server-side — which is the state we were asking for.
|
||||
if status.is_success() || status == StatusCode::UNAUTHORIZED {
|
||||
return RevokeOutcome::Revoked;
|
||||
}
|
||||
match status {
|
||||
// No such route: a server older than self-revoke. Any other shape of 404
|
||||
// (a proxy, a stale base URL) leaves the token live too, so the advice the
|
||||
// user needs is the same either way.
|
||||
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => RevokeOutcome::Unsupported,
|
||||
other => RevokeOutcome::Failed {
|
||||
reason: unexpected_status(base_url, other),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn http_with(timeout: Duration) -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| format!("Could not start the network client: {e}"))
|
||||
}
|
||||
|
||||
fn http() -> Result<reqwest::Client, String> {
|
||||
http_with(REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
/// Attach the client-identity headers every request carries, plus a bearer token
|
||||
/// when we hold one.
|
||||
fn prepare(builder: RequestBuilder, token: Option<&str>) -> RequestBuilder {
|
||||
let mut builder = builder;
|
||||
for (name, value) in compat::client_headers() {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
match token {
|
||||
Some(t) => builder.bearer_auth(t),
|
||||
None => builder,
|
||||
}
|
||||
}
|
||||
|
||||
fn unexpected_status(base_url: &str, status: StatusCode) -> String {
|
||||
format!(
|
||||
"{base_url} answered with HTTP {}. Check the address — a reverse proxy or a \
|
||||
different site may be answering there.",
|
||||
status.as_u16()
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask a server who it is and whether we can sync with it.
|
||||
///
|
||||
/// `Err` means we never got a usable answer (bad address, unreachable, not a
|
||||
/// ThoughtSync server). A server that answers but is *incompatible* comes back `Ok`
|
||||
/// with a verdict — that distinction matters, because the two need very different
|
||||
/// messages: one is "check what you typed", the other is "update something".
|
||||
pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
|
||||
let base_url = compat::normalize_base_url(raw_url)
|
||||
.ok_or("Enter a server address, like https://notes.example.com")?;
|
||||
|
||||
let request = prepare(http()?.get(config_url(&base_url)), None);
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(&base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(&base_url, status));
|
||||
}
|
||||
|
||||
// Something answered 200 that isn't a ThoughtSync server (a router login page, a
|
||||
// captive portal). Report the address, not the parse error, which would mean
|
||||
// nothing to the person reading it.
|
||||
let server: ServerInfo = response.json().await.map_err(|_| {
|
||||
format!(
|
||||
"{base_url} responded, but not with ThoughtSync's configuration. \
|
||||
Is that the right address?"
|
||||
)
|
||||
})?;
|
||||
|
||||
let compatibility = compat::evaluate(&server);
|
||||
Ok(ProbeResult {
|
||||
base_url,
|
||||
server,
|
||||
compatibility,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exchange email + password for a device bearer token.
|
||||
///
|
||||
/// The fresh-install path: it needs no existing session, which is what lets a brand
|
||||
/// new desktop install link without visiting the web app first.
|
||||
pub async fn device_login(
|
||||
base_url: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
device_name: &str,
|
||||
) -> Result<(String, Identity), String> {
|
||||
let body = serde_json::json!({
|
||||
"email": email,
|
||||
"password": password,
|
||||
"name": device_name,
|
||||
});
|
||||
let request = prepare(http()?.post(device_login_url(base_url)), None).json(&body);
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err("That email and password didn't match an account on this server.".to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
let parsed: DeviceLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| format!("{base_url} signed us in but sent an unexpected reply."))?;
|
||||
Ok((parsed.token, parsed.user))
|
||||
}
|
||||
|
||||
/// Validate a token by asking whom it belongs to.
|
||||
///
|
||||
/// Used when the user pastes a token issued from the web app. Storing it unverified
|
||||
/// would turn a copy/paste slip into a failure that only surfaces at the next sync,
|
||||
/// far from the thing that caused it.
|
||||
pub async fn fetch_identity(base_url: &str, token: &str) -> Result<Identity, String> {
|
||||
let request = prepare(http()?.get(me_url(base_url)), Some(token));
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
let message = "That token isn't valid on this server — it may have been revoked. \
|
||||
Issue a new one from the web app under Account → Linked devices.";
|
||||
return Err(message.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| format!("{base_url} accepted the token but sent an unexpected reply."))
|
||||
}
|
||||
|
||||
/// Fetch one page of the change feed, starting after `since`.
|
||||
///
|
||||
/// The caller loops until `has_more` is false (see `pull::run`); paging lives there
|
||||
/// rather than here so the transport stays a single request/response.
|
||||
pub async fn fetch_changes(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
since: i64,
|
||||
) -> Result<wire::ChangesPage, String> {
|
||||
let url = format!("{base_url}/api/sync/changes?since={since}");
|
||||
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err(TOKEN_REJECTED.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't read the change feed from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
/// Download one attachment's bytes.
|
||||
///
|
||||
/// Metadata already arrived on the delta feed; this is only the payload, fetched
|
||||
/// over the same route the web app uses (owner/shared scoped server-side).
|
||||
pub async fn fetch_attachment(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
note_id: &str,
|
||||
attachment_id: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let url = format!("{base_url}/api/notes/{note_id}/attachments/{attachment_id}");
|
||||
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err(TOKEN_REJECTED.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
/// Send a batch of changes and hand back the raw reply.
|
||||
///
|
||||
/// Returns text rather than parsed results so this module stays pure transport —
|
||||
/// `push::parse_results` owns the result shapes, and keeping them there is what lets
|
||||
/// the parsing be unit-tested without a server.
|
||||
pub async fn push_changes<T: Serialize>(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
changes: &[T],
|
||||
) -> Result<String, String> {
|
||||
let body = serde_json::json!({ "changes": changes });
|
||||
let url = format!("{base_url}/api/sync/push");
|
||||
let request = prepare(http_with(SYNC_TIMEOUT)?.post(url), Some(token)).json(&body);
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err(TOKEN_REJECTED.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
/// The public, unauthenticated endpoint carrying the handshake.
|
||||
fn config_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/config")
|
||||
}
|
||||
|
||||
fn device_login_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/auth/device-login")
|
||||
}
|
||||
|
||||
fn me_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/auth/me")
|
||||
}
|
||||
|
||||
/// `self` rather than a device id: see `revoke_self`.
|
||||
fn revoke_self_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/auth/devices/self")
|
||||
}
|
||||
|
||||
/// Turn a transport failure into something a person can act on. reqwest's own
|
||||
/// Display is accurate but reads like a stack trace.
|
||||
fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String {
|
||||
if err.is_timeout() {
|
||||
// No specific duration here: these calls run under two different budgets
|
||||
// (interactive vs bulk sync), and naming the wrong one is worse than naming
|
||||
// none.
|
||||
format!(
|
||||
"{base_url} didn't respond in time. It may be offline, or unreachable \
|
||||
from this network."
|
||||
)
|
||||
} else if err.is_connect() {
|
||||
format!(
|
||||
"Couldn't reach {base_url}. Check the address and that the server is \
|
||||
running. If it uses plain HTTP, include http:// explicitly."
|
||||
)
|
||||
} else {
|
||||
format!("Couldn't reach {base_url}: {err}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn urls_join_without_doubling_slashes() {
|
||||
// normalize_base_url has already stripped any trailing slash, so plain
|
||||
// concatenation is correct — this pins that assumption.
|
||||
assert_eq!(
|
||||
config_url("https://notes.example.com"),
|
||||
"https://notes.example.com/api/config"
|
||||
);
|
||||
assert_eq!(
|
||||
device_login_url("https://notes.example.com"),
|
||||
"https://notes.example.com/api/auth/device-login"
|
||||
);
|
||||
assert_eq!(
|
||||
me_url("https://notes.example.com"),
|
||||
"https://notes.example.com/api/auth/me"
|
||||
);
|
||||
assert_eq!(
|
||||
revoke_self_url("https://notes.example.com"),
|
||||
"https://notes.example.com/api/auth/devices/self"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_outcome_serializes_tagged_for_the_frontend() {
|
||||
// The UI decides between "signed out on the server" and "still valid, go
|
||||
// revoke it" by reading this tag, so its shape is part of the contract.
|
||||
let json = serde_json::to_string(&RevokeOutcome::Failed {
|
||||
reason: "offline".into(),
|
||||
})
|
||||
.expect("outcome serializes");
|
||||
assert!(json.contains("\"status\":\"failed\""), "got {json}");
|
||||
let json = serde_json::to_string(&RevokeOutcome::Revoked).expect("outcome serializes");
|
||||
assert!(json.contains("\"status\":\"revoked\""), "got {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_preserve_a_port_and_subpath() {
|
||||
assert_eq!(
|
||||
config_url("http://192.168.1.10:8000/thoughtsync"),
|
||||
"http://192.168.1.10:8000/thoughtsync/api/config"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Client<->server compatibility handshake (M10.6).
|
||||
//!
|
||||
//! The desktop app is local-first: it never *needs* a server. When the user links
|
||||
//! one, this module decides whether the two can actually talk — before a single
|
||||
//! note moves. The sync engine (M10.7) consults it on link and on every sync.
|
||||
//!
|
||||
//! The contract is two integers per side, versioning the WIRE PROTOCOL separately
|
||||
//! from either program's release version:
|
||||
//!
|
||||
//! | | this client | the server advertises |
|
||||
//! |---|---|---|
|
||||
//! | speaks | `CLIENT_PROTOCOL_VERSION` | `sync_protocol_version` |
|
||||
//! | accepts down to | `MIN_SERVER_PROTOCOL_VERSION` | `min_client_protocol_version` |
|
||||
//!
|
||||
//! Each side declaring its own floor is what avoids app<->server lockstep: either
|
||||
//! side can mark a change breaking without the other needing to ship in step. See
|
||||
//! `docs/sync.md` for the policy that governs when those numbers move.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The sync wire protocol this client speaks.
|
||||
pub const CLIENT_PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// The oldest server protocol this client can drive — the symmetric half of the
|
||||
/// server's `min_client_protocol_version`.
|
||||
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
|
||||
/// link rather than degrading it.
|
||||
pub const REQUIRED_FEATURES: &[&str] = &["notes", "labels"];
|
||||
|
||||
/// Capabilities whose absence costs a feature but not the link. Listing these
|
||||
/// explicitly (rather than diffing against whatever the server happens to send) is
|
||||
/// what lets the UI name exactly what the user will be missing.
|
||||
pub const OPTIONAL_FEATURES: &[&str] = &["attachments", "tombstones", "revisions"];
|
||||
|
||||
/// The handshake fields of `GET /api/config`.
|
||||
///
|
||||
/// Every protocol field is optional because a server predating M10.6 simply won't
|
||||
/// send them. That case has to read as "this server is too old to sync", not as a
|
||||
/// parse failure — which would look to the user like they mistyped the URL.
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub struct ServerInfo {
|
||||
#[serde(default)]
|
||||
pub site_name: Option<String>,
|
||||
/// The server's release version, for display only — never gate on it.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_protocol_version: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub min_client_protocol_version: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub sync_features: Vec<String>,
|
||||
/// How long the SERVER keeps a trashed note before purging it (0 = forever).
|
||||
/// Once linked this is the window that actually applies, so the desktop's Trash
|
||||
/// countdown has to come from here rather than from its own offline default.
|
||||
#[serde(default)]
|
||||
pub trash_retention_days: Option<u32>,
|
||||
}
|
||||
|
||||
impl ServerInfo {
|
||||
fn has_feature(&self, name: &str) -> bool {
|
||||
self.sync_features.iter().any(|f| f.as_str() == name)
|
||||
}
|
||||
|
||||
fn missing(&self, from: &[&str]) -> Vec<String> {
|
||||
from.iter()
|
||||
.copied()
|
||||
.filter(|f| !self.has_feature(f))
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The verdict the link/settings UI renders and the sync engine obeys.
|
||||
///
|
||||
/// Serialized tagged so the frontend can `switch` on `status` directly.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum Compatibility {
|
||||
/// Full parity — sync everything.
|
||||
Ok,
|
||||
/// Safe to sync, but these named capabilities aren't available here.
|
||||
Degraded { unavailable: Vec<String> },
|
||||
/// Do not sync. `client_must_update` points the user at the side that can fix
|
||||
/// it, so the message can be actionable instead of just "incompatible".
|
||||
Incompatible {
|
||||
reason: String,
|
||||
client_must_update: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn incompatible(reason: &str, client_must_update: bool) -> Compatibility {
|
||||
Compatibility::Incompatible {
|
||||
reason: reason.to_string(),
|
||||
client_must_update,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide whether this client can sync with the described server.
|
||||
///
|
||||
/// Pure: the transport fetches `ServerInfo`, this decides what it means. Keeping
|
||||
/// the decision free of I/O is what makes every branch below unit-testable, which
|
||||
/// matters because there is no Postgres/live-server lane in CI.
|
||||
pub fn evaluate(info: &ServerInfo) -> Compatibility {
|
||||
// Ordered most-fundamental first, so the user sees the root problem rather than
|
||||
// a downstream symptom of it.
|
||||
let Some(server_proto) = info.sync_protocol_version else {
|
||||
return incompatible(
|
||||
"This server doesn't support device sync — it predates the sync protocol. \
|
||||
Update the server, then link again.",
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
if server_proto < MIN_SERVER_PROTOCOL_VERSION {
|
||||
return incompatible(
|
||||
&format!(
|
||||
"This server speaks sync protocol v{server_proto}, but this app needs \
|
||||
at least v{MIN_SERVER_PROTOCOL_VERSION}. Update the server."
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// The server's floor is what hard-blocks an old client. Absent => no floor: a
|
||||
// server that advertises a protocol but no minimum accepts anything.
|
||||
let floor = info.min_client_protocol_version.unwrap_or(0);
|
||||
if CLIENT_PROTOCOL_VERSION < floor {
|
||||
return incompatible(
|
||||
&format!(
|
||||
"This server requires client protocol v{floor} or newer; this app \
|
||||
speaks v{CLIENT_PROTOCOL_VERSION}. Update ThoughtSync."
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// A version match still isn't enough: a server can speak the protocol with a
|
||||
// core capability compiled out or disabled.
|
||||
let missing_required = info.missing(REQUIRED_FEATURES);
|
||||
if !missing_required.is_empty() {
|
||||
return incompatible(
|
||||
&format!(
|
||||
"This server is missing sync capabilities this app requires: {}.",
|
||||
missing_required.join(", ")
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
let unavailable = info.missing(OPTIONAL_FEATURES);
|
||||
if unavailable.is_empty() {
|
||||
Compatibility::Ok
|
||||
} else {
|
||||
Compatibility::Degraded { unavailable }
|
||||
}
|
||||
}
|
||||
|
||||
/// Headers this client puts on every request to a linked server, so the server can
|
||||
/// log or gate on client identity without a separate handshake round-trip.
|
||||
pub fn client_headers() -> [(&'static str, String); 2] {
|
||||
let agent = format!("thoughtsync-desktop/{}", env!("CARGO_PKG_VERSION"));
|
||||
[
|
||||
("X-ThoughtSync-Client", agent),
|
||||
(
|
||||
"X-ThoughtSync-Protocol",
|
||||
CLIENT_PROTOCOL_VERSION.to_string(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Turn what a user typed into a base URL we can build request paths on, or `None`
|
||||
/// if there's nothing usable in it.
|
||||
///
|
||||
/// A bare host gets **`https://`**, never `http://`. Silently downgrading would put
|
||||
/// a long-lived device token on the wire in cleartext because someone omitted five
|
||||
/// characters. Plain HTTP on a trusted LAN stays fully supported — the user just
|
||||
/// has to type `http://` and thereby choose it.
|
||||
pub fn normalize_base_url(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Resolve the scheme BEFORE touching trailing slashes — stripping them first
|
||||
// turns a bare "https://" into "https:", which then reads as a hostname.
|
||||
let with_scheme = match trimmed.split_once("://") {
|
||||
Some((scheme, rest)) => {
|
||||
// Anything that isn't HTTP(S) (ftp://, file://, a stray "foo://") can't
|
||||
// be a ThoughtSync server; reject rather than fail confusingly later.
|
||||
let scheme = scheme.to_ascii_lowercase();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return None;
|
||||
}
|
||||
format!("{scheme}://{rest}")
|
||||
}
|
||||
None => format!("https://{trimmed}"),
|
||||
};
|
||||
let (scheme, rest) = with_scheme.split_once("://")?;
|
||||
let rest = rest.trim_end_matches('/');
|
||||
// Reject a scheme with no authority ("https://", "http:///path").
|
||||
if rest.split(['/', '?', '#']).next().unwrap_or("").is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{scheme}://{rest}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A server matching this client exactly, which each test then degrades.
|
||||
fn current_server() -> ServerInfo {
|
||||
ServerInfo {
|
||||
site_name: Some("ThoughtSync".into()),
|
||||
version: Some("0.1.0".into()),
|
||||
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
|
||||
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
|
||||
sync_features: REQUIRED_FEATURES
|
||||
.iter()
|
||||
.chain(OPTIONAL_FEATURES.iter())
|
||||
.copied()
|
||||
.map(String::from)
|
||||
.collect(),
|
||||
trash_retention_days: Some(30),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_server_is_fully_compatible() {
|
||||
assert_eq!(evaluate(¤t_server()), Compatibility::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_without_protocol_fields_is_too_old() {
|
||||
// A pre-M10.6 server: /api/config parses, but carries no protocol block.
|
||||
let info = ServerInfo {
|
||||
site_name: Some("ThoughtSync".into()),
|
||||
version: Some("0.0.9".into()),
|
||||
..Default::default()
|
||||
};
|
||||
match evaluate(&info) {
|
||||
Compatibility::Incompatible {
|
||||
client_must_update, ..
|
||||
} => assert!(!client_must_update, "the SERVER is the old side here"),
|
||||
other => panic!("expected incompatible, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_older_than_the_servers_floor_must_update() {
|
||||
let info = ServerInfo {
|
||||
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 5),
|
||||
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 5),
|
||||
..current_server()
|
||||
};
|
||||
match evaluate(&info) {
|
||||
Compatibility::Incompatible {
|
||||
client_must_update, ..
|
||||
} => assert!(client_must_update),
|
||||
other => panic!("expected incompatible, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_server_within_our_floor_still_works() {
|
||||
// The whole point of the two-number contract: a server can move ahead
|
||||
// additively without locking out a client that predates the change.
|
||||
let info = ServerInfo {
|
||||
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 3),
|
||||
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
|
||||
..current_server()
|
||||
};
|
||||
assert_eq!(evaluate(&info), Compatibility::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_with_no_declared_floor_accepts_us() {
|
||||
let info = ServerInfo {
|
||||
min_client_protocol_version: None,
|
||||
..current_server()
|
||||
};
|
||||
assert_eq!(evaluate(&info), Compatibility::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_optional_feature_degrades_rather_than_blocks() {
|
||||
let info = ServerInfo {
|
||||
sync_features: current_server()
|
||||
.sync_features
|
||||
.into_iter()
|
||||
.filter(|f| f.as_str() != "attachments")
|
||||
.collect(),
|
||||
..current_server()
|
||||
};
|
||||
assert_eq!(
|
||||
evaluate(&info),
|
||||
Compatibility::Degraded {
|
||||
unavailable: vec!["attachments".to_string()]
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_feature_blocks() {
|
||||
let info = ServerInfo {
|
||||
sync_features: vec!["labels".to_string()],
|
||||
..current_server()
|
||||
};
|
||||
match evaluate(&info) {
|
||||
Compatibility::Incompatible { reason, .. } => assert!(reason.contains("notes")),
|
||||
other => panic!("expected incompatible, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_mismatch_outranks_a_missing_feature() {
|
||||
// Both wrong → report the version, the root cause of the missing feature.
|
||||
let info = ServerInfo {
|
||||
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 2),
|
||||
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 2),
|
||||
sync_features: vec![],
|
||||
..current_server()
|
||||
};
|
||||
match evaluate(&info) {
|
||||
Compatibility::Incompatible {
|
||||
client_must_update, ..
|
||||
} => assert!(client_must_update),
|
||||
other => panic!("expected incompatible, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verdict_serializes_tagged_for_the_frontend() {
|
||||
let verdict = Compatibility::Degraded {
|
||||
unavailable: vec!["attachments".into()],
|
||||
};
|
||||
let json = serde_json::to_string(&verdict).expect("verdict serializes");
|
||||
assert!(json.contains("\"status\":\"degraded\""), "got {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_info_tolerates_unknown_and_absent_fields() {
|
||||
// Forward compatibility: a NEWER server sending fields we've never heard of
|
||||
// must not break the handshake.
|
||||
let info: ServerInfo = serde_json::from_str(
|
||||
r#"{"site_name":"S","sync_protocol_version":1,
|
||||
"min_client_protocol_version":1,
|
||||
"sync_features":["notes","labels","attachments","tombstones","revisions"],
|
||||
"some_future_field":{"nested":true}}"#,
|
||||
)
|
||||
.expect("unknown fields are ignored");
|
||||
assert_eq!(evaluate(&info), Compatibility::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_headers_identify_app_and_protocol() {
|
||||
let headers = client_headers();
|
||||
assert_eq!(headers[0].0, "X-ThoughtSync-Client");
|
||||
assert!(headers[0].1.starts_with("thoughtsync-desktop/"));
|
||||
assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_defaults_to_https_and_trims() {
|
||||
assert_eq!(
|
||||
normalize_base_url(" notes.example.com/ "),
|
||||
Some("https://notes.example.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_base_url("https://notes.example.com///"),
|
||||
Some("https://notes.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_keeps_an_explicit_http_choice() {
|
||||
// Plain HTTP on a LAN is supported — the user just has to ask for it.
|
||||
assert_eq!(
|
||||
normalize_base_url("http://192.168.1.10:8000"),
|
||||
Some("http://192.168.1.10:8000".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_rejects_junk() {
|
||||
assert_eq!(normalize_base_url(""), None);
|
||||
assert_eq!(normalize_base_url(" "), None);
|
||||
assert_eq!(normalize_base_url("https://"), None);
|
||||
assert_eq!(normalize_base_url("ftp://files.example.com"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! The sync cycle (M10.7c).
|
||||
//!
|
||||
//! Deliberately the ONLY way the UI can sync. Push and pull are each usable on their
|
||||
//! 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;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SyncOutcome {
|
||||
pub push: push::PushSummary,
|
||||
pub pull: pull::PullSummary,
|
||||
/// The state after the cycle, so the UI updates from one round-trip instead of
|
||||
/// following every sync with a status call.
|
||||
pub status: state::Status,
|
||||
}
|
||||
|
||||
/// Push, then pull — in that order, always.
|
||||
///
|
||||
/// Pull writes the server's version straight over the local row, so anything not yet
|
||||
/// sent would be lost to it. Pushing first is what puts the local edit in front of
|
||||
/// the server's last-write-wins comparison, and it's the reason
|
||||
/// `PullSummary::clobbered_dirty` should be zero on every healthy cycle.
|
||||
///
|
||||
/// A failed push aborts before the pull. Pulling anyway would take the exact rows we
|
||||
/// just failed to save and overwrite them — turning a recoverable network error into
|
||||
/// lost work.
|
||||
pub async fn run_cycle(
|
||||
db: &Db,
|
||||
blobs: &BlobStore,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> Result<SyncOutcome, String> {
|
||||
let push = push::run(db, base_url, token).await?;
|
||||
let pull = pull::run(db, blobs, base_url, token).await?;
|
||||
|
||||
if pull.clobbered_dirty > 0 {
|
||||
// Push ran first and reported success, so nothing should still have been
|
||||
// dirty. Reaching here means something wrote to the store mid-cycle, or a
|
||||
// change never got collected — worth a loud line either way.
|
||||
log::warn!(
|
||||
"sync cycle overwrote {} locally-edited note(s) despite pushing first",
|
||||
pull.clobbered_dirty
|
||||
);
|
||||
}
|
||||
|
||||
// While we're already talking to this server, re-read what it says about itself.
|
||||
// Today that's the trash-retention window the Trash view counts down against, and
|
||||
// it can change under us whenever an admin edits the setting. Best-effort on
|
||||
// purpose: a config blip must not fail a cycle whose actual work already
|
||||
// succeeded, and the stored value simply stays as it was.
|
||||
let retention = super::client::probe(base_url)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|p| p.server.trash_retention_days);
|
||||
|
||||
let status = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(days) = retention {
|
||||
state::set_server_retention(&conn, days as i64).map_err(|e| e.to_string())?;
|
||||
}
|
||||
// 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::status(&conn).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
Ok(SyncOutcome { push, pull, status })
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Talking to a ThoughtSync server — entirely opt-in.
|
||||
//!
|
||||
//! The app is local-first: `local` is the source of truth and everything works
|
||||
//! unlinked. Nothing in here runs until the user links a server.
|
||||
//!
|
||||
//! - `compat` — the version/capability handshake (M10.6): whether a given server can
|
||||
//! be talked to at all. Pure decision logic, no I/O.
|
||||
//! - `client` — HTTP transport: the handshake call and device-token auth.
|
||||
//! - `state` — the persisted link record (server, token, change-feed cursor).
|
||||
//! - `engine` — one full cycle: push local changes, then pull the server's.
|
||||
//!
|
||||
//! The UI surface that drives this lives in whichever client is wrapping the crate,
|
||||
//! not here.
|
||||
|
||||
pub mod blobs;
|
||||
pub mod client;
|
||||
pub mod compat;
|
||||
pub mod engine;
|
||||
pub mod pull;
|
||||
pub mod push;
|
||||
pub mod state;
|
||||
pub mod wire;
|
||||
@@ -0,0 +1,840 @@
|
||||
//! Pull: bring a server's changes into the local store (M10.7b).
|
||||
//!
|
||||
//! The feed is a single monotonic sequence shared by notes and labels, so one
|
||||
//! integer cursor is a total-order watermark over both (docs/sync.md). We loop pages
|
||||
//! until the server says there are no more, persisting the cursor **in the same
|
||||
//! 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;
|
||||
|
||||
use super::blobs::BlobStore;
|
||||
use super::client;
|
||||
use super::state;
|
||||
use super::wire;
|
||||
use crate::local::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
|
||||
/// something is wrong, not that someone has a lot of notes.
|
||||
const MAX_PAGES: usize = 10_000;
|
||||
|
||||
/// What a pull did — for the UI, and for the log when something looks off.
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct PullSummary {
|
||||
pub pages: usize,
|
||||
pub notes_applied: usize,
|
||||
pub notes_deleted: usize,
|
||||
pub labels_applied: usize,
|
||||
pub labels_deleted: usize,
|
||||
pub cursor: i64,
|
||||
/// Rows that still held unpushed local edits when the server's version landed on
|
||||
/// top. Should be 0 in the normal cycle, because push runs first; anything higher
|
||||
/// means local work was overwritten, which is worth saying out loud.
|
||||
pub clobbered_dirty: usize,
|
||||
pub blobs_downloaded: usize,
|
||||
/// Attachments whose bytes couldn't be fetched or failed verification. Counted
|
||||
/// rather than fatal — see `download_missing_blobs`.
|
||||
pub blobs_failed: usize,
|
||||
}
|
||||
|
||||
impl PullSummary {
|
||||
fn absorb(&mut self, other: PullSummary) {
|
||||
self.pages += other.pages;
|
||||
self.notes_applied += other.notes_applied;
|
||||
self.notes_deleted += other.notes_deleted;
|
||||
self.labels_applied += other.labels_applied;
|
||||
self.labels_deleted += other.labels_deleted;
|
||||
self.clobbered_dirty += other.clobbered_dirty;
|
||||
self.blobs_downloaded += other.blobs_downloaded;
|
||||
self.blobs_failed += other.blobs_failed;
|
||||
self.cursor = other.cursor;
|
||||
}
|
||||
}
|
||||
|
||||
/// `(note_id, attachment_id, sha256)` for every attachment that advertises a hash.
|
||||
/// The caller filters against the blob store — which blobs we hold isn't a SQL
|
||||
/// question.
|
||||
pub fn hashed_attachments(conn: &Connection) -> rusqlite::Result<Vec<(String, String, String)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT note_id, id, sha256 FROM attachments
|
||||
WHERE sha256 IS NOT NULL AND sha256 <> ''",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
/// Fetch the bytes for any attachment we have metadata for but no blob.
|
||||
///
|
||||
/// A failed attachment NEVER fails the sync. Notes are the primary data and they've
|
||||
/// already landed; an image that didn't arrive is retried on the next cycle simply
|
||||
/// because its blob still counts as missing. Aborting here would mean one unreachable
|
||||
/// file could block every future sync.
|
||||
async fn download_missing_blobs(
|
||||
db: &Db,
|
||||
blobs: &BlobStore,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> Result<(usize, usize), String> {
|
||||
let wanted = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
hashed_attachments(&conn).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let mut downloaded = 0;
|
||||
let mut failed = 0;
|
||||
for (note_id, attachment_id, sha256) in wanted {
|
||||
// Content-addressed, so this skips blobs we already hold — including the same
|
||||
// image attached to a different note.
|
||||
if blobs.has(&sha256) {
|
||||
continue;
|
||||
}
|
||||
match client::fetch_attachment(base_url, token, ¬e_id, &attachment_id).await {
|
||||
Ok(bytes) => match blobs.store(&sha256, &bytes) {
|
||||
Ok(_) => downloaded += 1,
|
||||
Err(e) => {
|
||||
log::warn!("attachment {attachment_id}: {e}");
|
||||
failed += 1;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("attachment {attachment_id}: {e}");
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
/// doesn't exist yet.
|
||||
pub fn apply_page(conn: &Connection, page: &wire::ChangesPage) -> rusqlite::Result<PullSummary> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut summary = PullSummary {
|
||||
pages: 1,
|
||||
cursor: page.cursor,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for label in &page.labels {
|
||||
if label.is_tombstone() {
|
||||
tx.execute("DELETE FROM labels WHERE id = ?1", params![label.id])?;
|
||||
summary.labels_deleted += 1;
|
||||
} else {
|
||||
upsert_label(&tx, label)?;
|
||||
summary.labels_applied += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for note in &page.notes {
|
||||
if note.is_tombstone() {
|
||||
// A purge tombstone carries no content — its only job is to say "delete
|
||||
// your copy". Children go with it via ON DELETE CASCADE.
|
||||
tx.execute("DELETE FROM notes WHERE id = ?1", params![note.id])?;
|
||||
summary.notes_deleted += 1;
|
||||
continue;
|
||||
}
|
||||
if is_dirty(&tx, ¬e.id)? {
|
||||
summary.clobbered_dirty += 1;
|
||||
}
|
||||
upsert_note(&tx, note)?;
|
||||
summary.notes_applied += 1;
|
||||
}
|
||||
|
||||
state::set_cursor(&tx, page.cursor)?;
|
||||
tx.commit()?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn is_dirty(conn: &Connection, note_id: &str) -> rusqlite::Result<bool> {
|
||||
let dirty: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT dirty FROM notes WHERE id = ?1",
|
||||
params![note_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(dirty == Some(1))
|
||||
}
|
||||
|
||||
fn upsert_label(conn: &Connection, label: &wire::Label) -> rusqlite::Result<()> {
|
||||
// One label per name is enforced on both sides (locally a UNIQUE index on
|
||||
// lower(name); on the server, per owner). A label created offline can therefore
|
||||
// collide with one the server already had under a different id — "work" typed on
|
||||
// this machine and "work" that already existed.
|
||||
//
|
||||
// The server's row wins, but its MEMBERSHIPS have to survive the swap. Just
|
||||
// deleting the local duplicate would cascade its note_labels away, stripping the
|
||||
// label off notes that this pull never even mentions — silent loss that no later
|
||||
// page would repair. So: free the name, insert the server's row, re-point the
|
||||
// memberships onto it, then drop the husk.
|
||||
let duplicates: Vec<String> = {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id FROM labels WHERE lower(name) = lower(?1) AND id <> ?2")?;
|
||||
let rows = stmt.query_map(params![label.name, label.id], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
// Renaming first is what makes the insert possible at all — the unique index
|
||||
// would otherwise reject the server's row before anything could be merged.
|
||||
for old in &duplicates {
|
||||
conn.execute(
|
||||
"UPDATE labels SET name = name || ' (superseded ' || id || ')' WHERE id = ?1",
|
||||
params![old],
|
||||
)?;
|
||||
}
|
||||
|
||||
let created = label.created_at.clone().unwrap_or_else(now);
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, name, color, created_at, updated_at, sync_revision, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?4, ?5, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
color = excluded.color,
|
||||
sync_revision = excluded.sync_revision,
|
||||
dirty = 0",
|
||||
params![
|
||||
label.id,
|
||||
label.name,
|
||||
label.color,
|
||||
created,
|
||||
label.sync_revision
|
||||
],
|
||||
)?;
|
||||
|
||||
for old in &duplicates {
|
||||
// OR IGNORE guards a (note_id, label_id) collision. Today the unique index on
|
||||
// lower(name) makes that unreachable — two same-name labels can't coexist
|
||||
// locally — so this is belt-and-braces against that index changing, not a
|
||||
// case we've seen. Anything it skips cascades away with the husk below, which
|
||||
// is correct: those are duplicates of a membership that now exists.
|
||||
conn.execute(
|
||||
"UPDATE OR IGNORE note_labels SET label_id = ?1 WHERE label_id = ?2",
|
||||
params![label.id, old],
|
||||
)?;
|
||||
conn.execute("DELETE FROM labels WHERE id = ?1", params![old])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
let created = note.created_at.clone().unwrap_or_else(now);
|
||||
let updated = note.updated_at.clone().unwrap_or_else(|| created.clone());
|
||||
// The server's `deleted_at` is the authority on trash AGE. Taking it from the feed
|
||||
// rather than stamping "now" locally is what keeps a note trashed three weeks ago
|
||||
// from looking brand-new to a device that only just heard about it — otherwise
|
||||
// every fresh install would silently reset the whole retention clock. Falls back
|
||||
// to the note's updated_at only if an older server omits the field.
|
||||
let trashed_at = if note.trashed {
|
||||
note.deleted_at.clone().or_else(|| Some(updated.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
|
||||
// never changes, and the server's copy is the same value anyway.
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, color, kind, position, pinned, archived,
|
||||
trashed, remind_at, recurrence, created_at, updated_at,
|
||||
sync_revision, trashed_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 0)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
body = excluded.body,
|
||||
color = excluded.color,
|
||||
kind = excluded.kind,
|
||||
position = excluded.position,
|
||||
pinned = excluded.pinned,
|
||||
archived = excluded.archived,
|
||||
trashed = excluded.trashed,
|
||||
remind_at = excluded.remind_at,
|
||||
recurrence = excluded.recurrence,
|
||||
updated_at = excluded.updated_at,
|
||||
sync_revision = excluded.sync_revision,
|
||||
trashed_at = excluded.trashed_at,
|
||||
dirty = 0",
|
||||
params![
|
||||
note.id,
|
||||
note.title,
|
||||
note.body,
|
||||
note.color,
|
||||
note.kind,
|
||||
note.position,
|
||||
note.pinned,
|
||||
note.archived,
|
||||
note.trashed,
|
||||
note.remind_at,
|
||||
note.recurrence,
|
||||
created,
|
||||
updated,
|
||||
note.sync_revision,
|
||||
trashed_at,
|
||||
],
|
||||
)?;
|
||||
|
||||
// Children are replaced wholesale: a delta carries the note's FULL current state,
|
||||
// so "what the server sent" IS the complete set. Diffing would be more code and
|
||||
// could leave behind a row the server no longer has.
|
||||
replace_items(conn, note)?;
|
||||
replace_attachments(conn, note)?;
|
||||
replace_previews(conn, note)?;
|
||||
replace_labels(conn, note)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_items(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM checklist_items WHERE note_id = ?1",
|
||||
params![note.id],
|
||||
)?;
|
||||
for (index, item) in note.items.iter().enumerate() {
|
||||
conn.execute(
|
||||
"INSERT INTO checklist_items (id, note_id, text, checked, position)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
item.id,
|
||||
note.id,
|
||||
item.text,
|
||||
item.checked,
|
||||
position_of(item.position, index)
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_attachments(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM attachments WHERE note_id = ?1",
|
||||
params![note.id],
|
||||
)?;
|
||||
for (index, att) in note.attachments.iter().enumerate() {
|
||||
// The feed carries no explicit position for attachments — they arrive in
|
||||
// creation order, so the index preserves it.
|
||||
conn.execute(
|
||||
"INSERT INTO attachments (id, note_id, url, filename, mime, size, sha256, position)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
att.id,
|
||||
note.id,
|
||||
att.url,
|
||||
att.filename,
|
||||
att.mime,
|
||||
att.size,
|
||||
att.sha256,
|
||||
index as i64
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_previews(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM link_previews WHERE note_id = ?1",
|
||||
params![note.id],
|
||||
)?;
|
||||
for (index, preview) in note.previews.iter().enumerate() {
|
||||
conn.execute(
|
||||
"INSERT INTO link_previews (id, note_id, url, title, description, image_url,
|
||||
site_name, position)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
preview.id,
|
||||
note.id,
|
||||
preview.url,
|
||||
preview.title,
|
||||
preview.description,
|
||||
preview.image_url,
|
||||
preview.site_name,
|
||||
index as i64
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_labels(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM note_labels WHERE note_id = ?1",
|
||||
params![note.id],
|
||||
)?;
|
||||
for label in ¬e.labels {
|
||||
ensure_label_stub(conn, label)?;
|
||||
// `via_tag` is applied verbatim rather than re-derived from the body. The
|
||||
// server already reconciled tags when it saved the note, and re-deriving here
|
||||
// would call the local find-or-create path, which marks new labels dirty and
|
||||
// would push them straight back — sync churn out of nothing.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![note.id, label.id, label.via_tag],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Materialize a label referenced by a note, if we don't have it yet.
|
||||
///
|
||||
/// Notes and labels page from one shared sequence, so a note can reference a label
|
||||
/// whose own delta landed in an earlier page — or, right at a page boundary, hasn't
|
||||
/// landed. The note carries enough of the label to create it, so a membership never
|
||||
/// fails on a missing row. `OR IGNORE` because the label's real delta (later in this
|
||||
/// page or a future one) is the authority on its name and color.
|
||||
fn ensure_label_stub(conn: &Connection, label: &wire::NoteLabel) -> rusqlite::Result<()> {
|
||||
let ts = now();
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO labels (id, name, color, created_at, updated_at, dirty)
|
||||
VALUES (?1, ?2, ?3, ?4, ?4, 0)",
|
||||
params![label.id, label.name, label.color, ts],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trust an explicit position; fall back to arrival order when the server sent 0 for
|
||||
/// everything (which is what an unordered list looks like on the wire).
|
||||
fn position_of(explicit: i64, index: usize) -> i64 {
|
||||
if explicit > 0 {
|
||||
explicit
|
||||
} else {
|
||||
index as i64
|
||||
}
|
||||
}
|
||||
|
||||
/// Loop the feed to exhaustion, starting from the persisted cursor.
|
||||
///
|
||||
/// NOTE ON ORDERING: the full cycle is push-then-pull (docs/sync.md). Running this
|
||||
/// against a store with unpushed edits lets the server's version land on top of them
|
||||
/// — counted as `clobbered_dirty` and logged, rather than hidden.
|
||||
pub async fn run(
|
||||
db: &Db,
|
||||
blobs: &BlobStore,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> Result<PullSummary, String> {
|
||||
let mut total = PullSummary::default();
|
||||
|
||||
loop {
|
||||
let since = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
state::read(&conn).map_err(|e| e.to_string())?.last_cursor
|
||||
};
|
||||
|
||||
let page = client::fetch_changes(base_url, token, since).await?;
|
||||
|
||||
// Trust the data over the flag: a server that claims more pages without
|
||||
// advancing the cursor would spin this loop forever.
|
||||
if page.has_more && page.cursor <= since {
|
||||
return Err(format!(
|
||||
"The server reported more changes but its cursor didn't advance past \
|
||||
{since}. Stopping rather than looping forever."
|
||||
));
|
||||
}
|
||||
|
||||
let has_more = page.has_more;
|
||||
let applied = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
apply_page(&conn, &page).map_err(|e| e.to_string())?
|
||||
};
|
||||
total.absorb(applied);
|
||||
|
||||
if !has_more {
|
||||
break;
|
||||
}
|
||||
if total.pages >= MAX_PAGES {
|
||||
return Err(format!(
|
||||
"Stopped after {MAX_PAGES} pages without reaching the end of the \
|
||||
server's changes. Something is wrong with the feed."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Notes first, bytes after: the metadata is what makes the attachments knowable,
|
||||
// and knowing one is missing is what lets the next cycle retry it.
|
||||
let (downloaded, failed) = download_missing_blobs(db, blobs, base_url, token).await?;
|
||||
total.blobs_downloaded = downloaded;
|
||||
total.blobs_failed = failed;
|
||||
|
||||
if total.clobbered_dirty > 0 {
|
||||
log::warn!(
|
||||
"pull overwrote {} note(s) that still had unpushed local edits",
|
||||
total.clobbered_dirty
|
||||
);
|
||||
}
|
||||
if total.blobs_failed > 0 {
|
||||
log::warn!(
|
||||
"pull: {} attachment(s) couldn't be downloaded; will retry next sync",
|
||||
total.blobs_failed
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"pull complete: {} page(s), {} note(s) applied, {} deleted, {} label(s) applied, cursor {}",
|
||||
total.pages,
|
||||
total.notes_applied,
|
||||
total.notes_deleted,
|
||||
total.labels_applied,
|
||||
total.cursor
|
||||
);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::local::schema;
|
||||
|
||||
fn db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("in-memory db");
|
||||
schema::migrate(&conn).expect("migrate");
|
||||
conn
|
||||
}
|
||||
|
||||
fn note(id: &str, revision: i64) -> wire::Note {
|
||||
wire::Note {
|
||||
id: id.to_string(),
|
||||
title: Some("Title".into()),
|
||||
body: "Body".into(),
|
||||
color: "default".into(),
|
||||
kind: "text".into(),
|
||||
position: 0,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
deleted_at: None,
|
||||
remind_at: None,
|
||||
recurrence: None,
|
||||
created_at: Some("2026-07-26T00:00:00.000Z".into()),
|
||||
updated_at: Some("2026-07-26T00:00:00.000Z".into()),
|
||||
sync_revision: revision,
|
||||
purged_at: None,
|
||||
labels: vec![],
|
||||
items: vec![],
|
||||
attachments: vec![],
|
||||
previews: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn page(notes: Vec<wire::Note>, labels: Vec<wire::Label>, cursor: i64) -> wire::ChangesPage {
|
||||
wire::ChangesPage {
|
||||
notes,
|
||||
labels,
|
||||
cursor,
|
||||
has_more: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn count(conn: &Connection, sql: &str) -> i64 {
|
||||
conn.query_row(sql, [], |r| r.get(0)).expect("count")
|
||||
}
|
||||
|
||||
fn trash_stamp(conn: &Connection, id: &str) -> Option<String> {
|
||||
let sql = "SELECT trashed_at FROM notes WHERE id = ?1";
|
||||
conn.query_row(sql, [id], |r| r.get(0)).expect("stamp")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_a_note_and_advances_the_cursor() {
|
||||
let conn = db();
|
||||
let summary = apply_page(&conn, &page(vec![note("n1", 7)], vec![], 7)).expect("apply");
|
||||
assert_eq!(summary.notes_applied, 1);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 1);
|
||||
assert_eq!(state::read(&conn).expect("state").last_cursor, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulled_rows_are_not_dirty() {
|
||||
// They came FROM the server, so pushing them back would be pure churn.
|
||||
let conn = db();
|
||||
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
|
||||
assert_eq!(count(&conn, "SELECT dirty FROM notes WHERE id = 'n1'"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_deletes_the_local_note() {
|
||||
let conn = db();
|
||||
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
|
||||
let mut dead = note("n1", 2);
|
||||
dead.purged_at = Some("2026-07-26T01:00:00.000Z".into());
|
||||
let summary = apply_page(&conn, &page(vec![dead], vec![], 2)).expect("apply");
|
||||
assert_eq!(summary.notes_deleted, 1);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trashed_is_not_a_tombstone() {
|
||||
// `trashed` is ordinary state that keeps syncing; only `purged_at` deletes.
|
||||
let conn = db();
|
||||
let mut trashed = note("n1", 1);
|
||||
trashed.trashed = true;
|
||||
apply_page(&conn, &page(vec![trashed], vec![], 1)).expect("apply");
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 1);
|
||||
assert_eq!(count(&conn, "SELECT trashed FROM notes WHERE id = 'n1'"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_age_comes_from_the_server_not_from_now() {
|
||||
// The retention countdown runs off this timestamp. Stamping it locally would
|
||||
// hand every note a fresh 30 days on any device that syncs it for the first
|
||||
// time — a note trashed last month would never expire anywhere.
|
||||
let conn = db();
|
||||
let mut trashed = note("n1", 1);
|
||||
trashed.trashed = true;
|
||||
trashed.deleted_at = Some("2026-06-01T09:30:00+00:00".into());
|
||||
apply_page(&conn, &page(vec![trashed], vec![], 1)).expect("apply");
|
||||
let stamped = trash_stamp(&conn, "n1");
|
||||
assert_eq!(stamped.as_deref(), Some("2026-06-01T09:30:00+00:00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_a_note_server_side_clears_its_trash_stamp() {
|
||||
let conn = db();
|
||||
let mut trashed = note("n1", 1);
|
||||
trashed.trashed = true;
|
||||
trashed.deleted_at = Some("2026-06-01T09:30:00+00:00".into());
|
||||
apply_page(&conn, &page(vec![trashed], vec![], 1)).expect("apply");
|
||||
apply_page(&conn, &page(vec![note("n1", 2)], vec![], 2)).expect("apply");
|
||||
let stamped = trash_stamp(&conn, "n1");
|
||||
assert_eq!(stamped, None, "an untrashed note keeps no trash stamp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_server_without_deleted_at_still_ages_the_trash() {
|
||||
// Falls back to updated_at rather than leaving the stamp null, which would
|
||||
// make the note un-expirable and its countdown blank.
|
||||
let conn = db();
|
||||
let mut trashed = note("n1", 1);
|
||||
trashed.trashed = true;
|
||||
trashed.deleted_at = None;
|
||||
apply_page(&conn, &page(vec![trashed], vec![], 1)).expect("apply");
|
||||
let stamped = trash_stamp(&conn, "n1");
|
||||
assert_eq!(stamped.as_deref(), Some("2026-07-26T00:00:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn children_are_replaced_not_merged() {
|
||||
let conn = db();
|
||||
let mut first = note("n1", 1);
|
||||
first.items = vec![
|
||||
wire::Item {
|
||||
id: "i1".into(),
|
||||
text: "one".into(),
|
||||
checked: false,
|
||||
position: 0,
|
||||
},
|
||||
wire::Item {
|
||||
id: "i2".into(),
|
||||
text: "two".into(),
|
||||
checked: false,
|
||||
position: 1,
|
||||
},
|
||||
];
|
||||
apply_page(&conn, &page(vec![first], vec![], 1)).expect("apply");
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 2);
|
||||
|
||||
// The server dropped an item; the local copy must drop it too.
|
||||
let mut second = note("n1", 2);
|
||||
second.items = vec![wire::Item {
|
||||
id: "i1".into(),
|
||||
text: "one".into(),
|
||||
checked: true,
|
||||
position: 0,
|
||||
}];
|
||||
apply_page(&conn, &page(vec![second], vec![], 2)).expect("apply");
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM checklist_items"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_label_membership_materializes_a_missing_label() {
|
||||
// The label's own delta may have landed in an earlier page, or not yet.
|
||||
let conn = db();
|
||||
let mut n = note("n1", 1);
|
||||
n.labels = vec![wire::NoteLabel {
|
||||
id: "l1".into(),
|
||||
name: "work".into(),
|
||||
color: "blue".into(),
|
||||
via_tag: true,
|
||||
}];
|
||||
apply_page(&conn, &page(vec![n], vec![], 1)).expect("apply");
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM labels"), 1);
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT via_tag FROM note_labels WHERE note_id = 'n1'"
|
||||
),
|
||||
1,
|
||||
"via_tag is applied verbatim, not re-derived"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_label_replaces_a_local_duplicate_by_name() {
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
|
||||
VALUES ('local-id', 'Work', 'default', '2026-01-01', '2026-01-01', 1)",
|
||||
[],
|
||||
)
|
||||
.expect("seed local label");
|
||||
|
||||
let server = wire::Label {
|
||||
id: "server-id".into(),
|
||||
name: "work".into(),
|
||||
color: "blue".into(),
|
||||
sync_revision: 5,
|
||||
purged_at: None,
|
||||
created_at: Some("2026-07-26T00:00:00.000Z".into()),
|
||||
};
|
||||
apply_page(&conn, &page(vec![], vec![server], 5)).expect("apply");
|
||||
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM labels"), 1);
|
||||
let id: String = conn
|
||||
.query_row("SELECT id FROM labels", [], |r| r.get(0))
|
||||
.expect("label");
|
||||
assert_eq!(id, "server-id", "the server's row wins on pull");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_a_duplicate_label_keeps_its_note_memberships() {
|
||||
// The notes carrying the local label may not be in this page at all, so a
|
||||
// plain delete would strip the label off them with nothing to repair it.
|
||||
let conn = db();
|
||||
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("seed note");
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
|
||||
VALUES ('local-id', 'Work', 'default', '2026-01-01', '2026-01-01', 1)",
|
||||
[],
|
||||
)
|
||||
.expect("seed local label");
|
||||
conn.execute(
|
||||
"INSERT INTO note_labels (note_id, label_id, via_tag)
|
||||
VALUES ('n1', 'local-id', 0)",
|
||||
[],
|
||||
)
|
||||
.expect("seed membership");
|
||||
|
||||
let server = wire::Label {
|
||||
id: "server-id".into(),
|
||||
name: "work".into(),
|
||||
color: "blue".into(),
|
||||
sync_revision: 5,
|
||||
purged_at: None,
|
||||
created_at: None,
|
||||
};
|
||||
apply_page(&conn, &page(vec![], vec![server], 5)).expect("apply");
|
||||
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM labels"), 1);
|
||||
let label_id: String = conn
|
||||
.query_row(
|
||||
"SELECT label_id FROM note_labels WHERE note_id = 'n1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.expect("membership survived");
|
||||
assert_eq!(label_id, "server-id", "membership re-pointed, not dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_tombstone_deletes_and_cascades_memberships() {
|
||||
let conn = db();
|
||||
let mut n = note("n1", 1);
|
||||
n.labels = vec![wire::NoteLabel {
|
||||
id: "l1".into(),
|
||||
name: "work".into(),
|
||||
color: "blue".into(),
|
||||
via_tag: false,
|
||||
}];
|
||||
apply_page(&conn, &page(vec![n], vec![], 1)).expect("apply");
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM note_labels"), 1);
|
||||
|
||||
let dead = wire::Label {
|
||||
id: "l1".into(),
|
||||
name: "work".into(),
|
||||
color: "blue".into(),
|
||||
sync_revision: 2,
|
||||
purged_at: Some("2026-07-26T01:00:00.000Z".into()),
|
||||
created_at: None,
|
||||
};
|
||||
apply_page(&conn, &page(vec![], vec![dead], 2)).expect("apply");
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM labels"), 0);
|
||||
assert_eq!(
|
||||
count(&conn, "SELECT COUNT(*) FROM note_labels"),
|
||||
0,
|
||||
"membership should cascade with the label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwriting_a_dirty_note_is_counted() {
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, body, created_at, updated_at, dirty)
|
||||
VALUES ('n1', 'local edit', '2026-01-01', '2026-01-01', 1)",
|
||||
[],
|
||||
)
|
||||
.expect("seed dirty note");
|
||||
let summary = apply_page(&conn, &page(vec![note("n1", 9)], vec![], 9)).expect("apply");
|
||||
assert_eq!(summary.clobbered_dirty, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_fresh_note_reports_no_clobber() {
|
||||
let conn = db();
|
||||
let summary = apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
|
||||
assert_eq!(summary.clobbered_dirty, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_page_still_advances_the_cursor() {
|
||||
// The server can page past rows that were trimmed to the shared watermark.
|
||||
let conn = db();
|
||||
apply_page(&conn, &page(vec![], vec![], 42)).expect("apply");
|
||||
assert_eq!(state::read(&conn).expect("state").last_cursor, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_upsert_preserves_the_original_created_at() {
|
||||
let conn = db();
|
||||
apply_page(&conn, &page(vec![note("n1", 1)], vec![], 1)).expect("apply");
|
||||
let mut later = note("n1", 2);
|
||||
later.created_at = Some("2099-01-01T00:00:00.000Z".into());
|
||||
apply_page(&conn, &page(vec![later], vec![], 2)).expect("apply");
|
||||
let created: String = conn
|
||||
.query_row("SELECT created_at FROM notes WHERE id = 'n1'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.expect("created_at");
|
||||
assert_eq!(created, "2026-07-26T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_that_fails_leaves_the_cursor_untouched() {
|
||||
// Atomicity is the whole resumability story: a cursor committed ahead of its
|
||||
// data would skip those rows forever. Force a failure with a duplicate
|
||||
// checklist-item id inside one page.
|
||||
let conn = db();
|
||||
let mut n = note("n1", 3);
|
||||
n.items = vec![
|
||||
wire::Item {
|
||||
id: "dup".into(),
|
||||
text: "one".into(),
|
||||
checked: false,
|
||||
position: 0,
|
||||
},
|
||||
wire::Item {
|
||||
id: "dup".into(),
|
||||
text: "two".into(),
|
||||
checked: false,
|
||||
position: 1,
|
||||
},
|
||||
];
|
||||
assert!(apply_page(&conn, &page(vec![n], vec![], 3)).is_err());
|
||||
assert_eq!(state::read(&conn).expect("state").last_cursor, 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM notes"), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
//! Push: send local changes to the server and apply what it says (M10.7c).
|
||||
//!
|
||||
//! Two sources feed a push: rows flagged `dirty` (created or edited locally) and rows
|
||||
//! in `pending_deletes` (permanently deleted locally — see `local::schema` v2 for why
|
||||
//! a delete needs its own record).
|
||||
//!
|
||||
//! Sync is **whole-note**: an upsert carries the client's full current state, not a
|
||||
//! patch (docs/sync.md). The server resolves conflicts last-write-wins by the client's
|
||||
//! `edited_at`, snapshotting anything it overwrites into the note's version history.
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::client;
|
||||
use super::state;
|
||||
use crate::local::Db;
|
||||
|
||||
/// The server rejects a batch larger than this (`MAX_PUSH` in `sync.py`).
|
||||
const BATCH: usize = 500;
|
||||
|
||||
/// Backstop: a batch whose results never clear `dirty` would loop forever.
|
||||
const MAX_BATCHES: usize = 10_000;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct PushSummary {
|
||||
pub batches: usize,
|
||||
pub sent: usize,
|
||||
pub created: usize,
|
||||
pub applied: usize,
|
||||
/// The server had a newer edit and kept it. Not a failure — the local row stops
|
||||
/// being dirty and the following pull adopts the server's version.
|
||||
pub kept: usize,
|
||||
pub noop: usize,
|
||||
/// Still dirty, and surfaced: these need a human (a duplicate label name is the
|
||||
/// realistic case). Silently retrying forever would be the wrong shape.
|
||||
pub rejected: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl PushSummary {
|
||||
fn absorb(&mut self, other: PushSummary) {
|
||||
self.batches += other.batches;
|
||||
self.sent += other.sent;
|
||||
self.created += other.created;
|
||||
self.applied += other.applied;
|
||||
self.kept += other.kept;
|
||||
self.noop += other.noop;
|
||||
self.rejected += other.rejected;
|
||||
self.errors.extend(other.errors);
|
||||
}
|
||||
}
|
||||
|
||||
// --- outgoing shapes ---------------------------------------------------------
|
||||
|
||||
/// One entry in the `changes` array. Notes and labels share the envelope; serde skips
|
||||
/// the fields that don't apply, so the server sees exactly the shape docs/sync.md
|
||||
/// describes for each entity.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Change {
|
||||
pub entity: &'static str,
|
||||
pub id: String,
|
||||
pub op: &'static str,
|
||||
pub edited_at: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pinned: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub archived: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trashed: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub remind_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recurrence: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub items: Option<Vec<ItemOut>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl Change {
|
||||
fn delete(entity: &'static str, id: String, edited_at: String) -> Self {
|
||||
Change {
|
||||
entity,
|
||||
id,
|
||||
op: "delete",
|
||||
edited_at,
|
||||
title: None,
|
||||
body: None,
|
||||
color: None,
|
||||
kind: None,
|
||||
pinned: None,
|
||||
archived: None,
|
||||
trashed: None,
|
||||
remind_at: None,
|
||||
recurrence: None,
|
||||
position: None,
|
||||
items: None,
|
||||
label_ids: None,
|
||||
created_at: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ItemOut {
|
||||
pub text: String,
|
||||
pub checked: bool,
|
||||
}
|
||||
|
||||
// --- incoming results --------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PushResponse {
|
||||
#[serde(default)]
|
||||
results: Vec<PushResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PushResult {
|
||||
#[serde(default)]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entity: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub sync_revision: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// --- collecting --------------------------------------------------------------
|
||||
|
||||
/// Everything waiting to go up, oldest edit first so a truncated batch still makes
|
||||
/// forward progress in a sensible order.
|
||||
pub fn collect(conn: &Connection, limit: usize) -> rusqlite::Result<Vec<Change>> {
|
||||
let mut out = Vec::new();
|
||||
collect_deletes(conn, &mut out, limit)?;
|
||||
if out.len() < limit {
|
||||
collect_labels(conn, &mut out, limit)?;
|
||||
}
|
||||
if out.len() < limit {
|
||||
collect_notes(conn, &mut out, limit)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn collect_deletes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusqlite::Result<()> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT entity, id, deleted_at FROM pending_deletes ORDER BY deleted_at LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit as i64], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (entity, id, deleted_at) = row?;
|
||||
// Only 'note' and 'label' exist on the wire; anything else is a bug in a
|
||||
// writer, and shipping it would earn a blanket rejection for the batch.
|
||||
let entity: &'static str = match entity.as_str() {
|
||||
"note" => "note",
|
||||
"label" => "label",
|
||||
_ => continue,
|
||||
};
|
||||
out.push(Change::delete(entity, id, deleted_at));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_labels(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusqlite::Result<()> {
|
||||
let remaining = limit.saturating_sub(out.len());
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, color, updated_at FROM labels
|
||||
WHERE dirty = 1 ORDER BY updated_at LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![remaining as i64], |r| {
|
||||
Ok(Change {
|
||||
entity: "label",
|
||||
id: r.get(0)?,
|
||||
op: "upsert",
|
||||
name: Some(r.get(1)?),
|
||||
color: Some(r.get(2)?),
|
||||
edited_at: r.get(3)?,
|
||||
title: None,
|
||||
body: None,
|
||||
kind: None,
|
||||
pinned: None,
|
||||
archived: None,
|
||||
trashed: None,
|
||||
remind_at: None,
|
||||
recurrence: None,
|
||||
position: None,
|
||||
items: None,
|
||||
label_ids: None,
|
||||
created_at: None,
|
||||
})
|
||||
})?;
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusqlite::Result<()> {
|
||||
let remaining = limit.saturating_sub(out.len());
|
||||
let ids: Vec<String> = {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id FROM notes WHERE dirty = 1 ORDER BY updated_at LIMIT ?1")?;
|
||||
let rows = stmt.query_map(params![remaining as i64], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
for id in ids {
|
||||
out.push(note_change(conn, &id)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The note's own columns. A named struct rather than a twelve-wide tuple so the
|
||||
/// field-to-column mapping stays readable at the call site.
|
||||
struct NoteRow {
|
||||
title: Option<String>,
|
||||
body: String,
|
||||
color: String,
|
||||
kind: String,
|
||||
position: i64,
|
||||
pinned: bool,
|
||||
archived: bool,
|
||||
trashed: bool,
|
||||
remind_at: Option<String>,
|
||||
recurrence: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
|
||||
conn.query_row(
|
||||
"SELECT title, body, color, kind, position, pinned, archived, trashed,
|
||||
remind_at, recurrence, created_at, updated_at
|
||||
FROM notes WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
Ok(NoteRow {
|
||||
title: r.get(0)?,
|
||||
body: r.get(1)?,
|
||||
color: r.get(2)?,
|
||||
kind: r.get(3)?,
|
||||
position: r.get(4)?,
|
||||
pinned: r.get::<_, i64>(5)? != 0,
|
||||
archived: r.get::<_, i64>(6)? != 0,
|
||||
trashed: r.get::<_, i64>(7)? != 0,
|
||||
remind_at: r.get(8)?,
|
||||
recurrence: r.get(9)?,
|
||||
created_at: r.get(10)?,
|
||||
updated_at: r.get(11)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
|
||||
let row = note_row(conn, id)?;
|
||||
|
||||
let items = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT text, checked FROM checklist_items WHERE note_id = ?1 ORDER BY position",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![id], |r| {
|
||||
Ok(ItemOut {
|
||||
text: r.get(0)?,
|
||||
checked: r.get::<_, i64>(1)? != 0,
|
||||
})
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<ItemOut>>>()?
|
||||
};
|
||||
|
||||
// MANUAL memberships only. Tag-sourced ones (`via_tag = 1`) are re-derived by the
|
||||
// server from the body; sending them as label_ids would convert them into manual
|
||||
// assignments that no longer disappear when the #tag is removed from the text.
|
||||
let label_ids = {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 0")?;
|
||||
let rows = stmt.query_map(params![id], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<String>>>()?
|
||||
};
|
||||
|
||||
Ok(Change {
|
||||
entity: "note",
|
||||
id: id.to_string(),
|
||||
op: "upsert",
|
||||
// The local `updated_at` IS the client's edit time, which is what the
|
||||
// server's last-write-wins comparison runs against.
|
||||
edited_at: row.updated_at,
|
||||
title: row.title,
|
||||
body: Some(row.body),
|
||||
color: Some(row.color),
|
||||
kind: Some(row.kind),
|
||||
pinned: Some(row.pinned),
|
||||
archived: Some(row.archived),
|
||||
trashed: Some(row.trashed),
|
||||
remind_at: row.remind_at,
|
||||
recurrence: row.recurrence,
|
||||
position: Some(row.position),
|
||||
items: Some(items),
|
||||
label_ids: Some(label_ids),
|
||||
created_at: Some(row.created_at),
|
||||
name: None,
|
||||
})
|
||||
}
|
||||
|
||||
// --- applying results --------------------------------------------------------
|
||||
|
||||
/// Fold one batch's results back into the local store, atomically.
|
||||
pub fn apply_results(
|
||||
conn: &Connection,
|
||||
sent: &[Change],
|
||||
results: &[PushResult],
|
||||
) -> rusqlite::Result<PushSummary> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let mut summary = PushSummary {
|
||||
batches: 1,
|
||||
sent: sent.len(),
|
||||
..Default::default()
|
||||
};
|
||||
// The server answers positionally, one result per change. Zip rather than trust
|
||||
// the echoed id: a rejected malformed entry may carry no id at all.
|
||||
let mut lowest_kept: Option<i64> = None;
|
||||
|
||||
for (change, result) in sent.iter().zip(results.iter()) {
|
||||
match result.status.as_str() {
|
||||
"created" | "applied" => {
|
||||
clear_dirty(&tx, change, result.sync_revision)?;
|
||||
if result.status == "created" {
|
||||
summary.created += 1;
|
||||
} else {
|
||||
summary.applied += 1;
|
||||
}
|
||||
if change.op == "delete" {
|
||||
forget_pending_delete(&tx, change)?;
|
||||
}
|
||||
}
|
||||
"noop" => {
|
||||
// The server had nothing to do — typically a delete for a row it
|
||||
// never saw (created and deleted while offline).
|
||||
clear_dirty(&tx, change, result.sync_revision)?;
|
||||
forget_pending_delete(&tx, change)?;
|
||||
summary.noop += 1;
|
||||
}
|
||||
"kept" => {
|
||||
// The server's version is newer. Stop being dirty — re-pushing would
|
||||
// lose to the same comparison forever — and let the next pull bring
|
||||
// the server's copy down.
|
||||
clear_dirty(&tx, change, None)?;
|
||||
if change.op == "delete" {
|
||||
// Our delete lost to a newer server edit; the note lives on, and
|
||||
// the pull will restore it locally. Drop the tombstone so we
|
||||
// don't keep trying to delete a note the user has since edited.
|
||||
forget_pending_delete(&tx, change)?;
|
||||
}
|
||||
if let Some(revision) = result.sync_revision {
|
||||
lowest_kept = Some(lowest_kept.map_or(revision, |c: i64| c.min(revision)));
|
||||
}
|
||||
summary.kept += 1;
|
||||
}
|
||||
_ => {
|
||||
// "rejected" and anything unrecognized: leave the row dirty so it is
|
||||
// retried, and surface the reason. A duplicate label name is the
|
||||
// realistic case and only a human can resolve it.
|
||||
summary.rejected += 1;
|
||||
let reason = result
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| result.status.clone());
|
||||
summary
|
||||
.errors
|
||||
.push(format!("{} {}: {reason}", change.entity, change.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A `kept` result means the server holds a version we have not seen. Normally its
|
||||
// revision is above our cursor and the next pull fetches it anyway. If it is NOT
|
||||
// — which happens when a skewed clock makes a genuinely later local edit look
|
||||
// older — rewind so that note is re-fetched. Without this the local edit is
|
||||
// dropped from sync and the stale copy stays on screen with nothing marking it.
|
||||
if let Some(revision) = lowest_kept {
|
||||
let current = state::read(&tx)?.last_cursor;
|
||||
if revision <= current {
|
||||
state::set_cursor(&tx, (revision - 1).max(0))?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn clear_dirty(conn: &Connection, change: &Change, revision: Option<i64>) -> rusqlite::Result<()> {
|
||||
// A delete has no local row left to update.
|
||||
if change.op == "delete" {
|
||||
return Ok(());
|
||||
}
|
||||
let table = match change.entity {
|
||||
"label" => "labels",
|
||||
_ => "notes",
|
||||
};
|
||||
match revision {
|
||||
Some(rev) => conn.execute(
|
||||
&format!("UPDATE {table} SET dirty = 0, sync_revision = ?2 WHERE id = ?1"),
|
||||
params![change.id, rev],
|
||||
)?,
|
||||
None => conn.execute(
|
||||
&format!("UPDATE {table} SET dirty = 0 WHERE id = ?1"),
|
||||
params![change.id],
|
||||
)?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn forget_pending_delete(conn: &Connection, change: &Change) -> rusqlite::Result<()> {
|
||||
if change.op != "delete" {
|
||||
return Ok(());
|
||||
}
|
||||
conn.execute(
|
||||
"DELETE FROM pending_deletes WHERE entity = ?1 AND id = ?2",
|
||||
params![change.entity, change.id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True when anything is waiting to go up. Cheap enough to call before a cycle.
|
||||
pub fn has_pending(conn: &Connection) -> rusqlite::Result<bool> {
|
||||
let pending: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM notes WHERE dirty = 1
|
||||
UNION ALL SELECT 1 FROM labels WHERE dirty = 1
|
||||
UNION ALL SELECT 1 FROM pending_deletes LIMIT 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(pending.is_some())
|
||||
}
|
||||
|
||||
/// Send everything pending, in batches, applying each batch's results before the
|
||||
/// next is collected.
|
||||
pub async fn run(db: &Db, base_url: &str, token: &str) -> Result<PushSummary, String> {
|
||||
let mut total = PushSummary::default();
|
||||
|
||||
loop {
|
||||
let batch = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
collect(&conn, BATCH).map_err(|e| e.to_string())?
|
||||
};
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let raw = client::push_changes(base_url, token, &batch).await?;
|
||||
let results = parse_results(&raw)?;
|
||||
|
||||
let applied = {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
apply_results(&conn, &batch, &results).map_err(|e| e.to_string())?
|
||||
};
|
||||
// Everything rejected clears nothing, so the same batch would be collected
|
||||
// again forever. Stop and report instead.
|
||||
let progressed = applied.rejected < applied.sent;
|
||||
total.absorb(applied);
|
||||
|
||||
if !progressed {
|
||||
break;
|
||||
}
|
||||
if total.batches >= MAX_BATCHES {
|
||||
return Err(format!(
|
||||
"Stopped after {MAX_BATCHES} push batches without draining the queue."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if total.rejected > 0 {
|
||||
log::warn!(
|
||||
"push: {} change(s) rejected by the server: {}",
|
||||
total.rejected,
|
||||
total.errors.join("; ")
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"push complete: {} sent ({} created, {} applied, {} kept, {} noop, {} rejected)",
|
||||
total.sent,
|
||||
total.created,
|
||||
total.applied,
|
||||
total.kept,
|
||||
total.noop,
|
||||
total.rejected
|
||||
);
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Parse the server's reply. Kept next to the shapes it produces.
|
||||
pub fn parse_results(raw: &str) -> Result<Vec<PushResult>, String> {
|
||||
let parsed: PushResponse =
|
||||
serde_json::from_str(raw).map_err(|e| format!("Couldn't read the push response: {e}"))?;
|
||||
Ok(parsed.results)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::local::schema;
|
||||
use crate::local::store;
|
||||
|
||||
fn db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("in-memory db");
|
||||
schema::migrate(&conn).expect("migrate");
|
||||
conn
|
||||
}
|
||||
|
||||
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, title, body, color, kind, position, pinned, archived,
|
||||
trashed, created_at, updated_at, sync_revision, dirty)
|
||||
VALUES (?1, 'T', 'B', 'default', 'text', 0, 0, 0, 0,
|
||||
'2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
|
||||
params![id, dirty],
|
||||
)
|
||||
.expect("seed note");
|
||||
}
|
||||
|
||||
fn ok(status: &str, revision: Option<i64>) -> PushResult {
|
||||
PushResult {
|
||||
id: None,
|
||||
entity: None,
|
||||
status: status.to_string(),
|
||||
sync_revision: revision,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn dirty_count(conn: &Connection) -> i64 {
|
||||
conn.query_row("SELECT COUNT(*) FROM notes WHERE dirty = 1", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.expect("count")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_only_dirty_notes() {
|
||||
let conn = db();
|
||||
seed_note(&conn, "clean", 0);
|
||||
seed_note(&conn, "dirty", 1);
|
||||
let batch = collect(&conn, 100).expect("collect");
|
||||
assert_eq!(batch.len(), 1);
|
||||
assert_eq!(batch[0].id, "dirty");
|
||||
assert_eq!(batch[0].op, "upsert");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_only_manual_label_memberships() {
|
||||
// Tag-sourced labels are re-derived server-side. Sending them as label_ids
|
||||
// would convert them to manual assignments that survive removing the #tag.
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
for (id, name, via_tag) in [("manual", "Manual", 0), ("tagged", "Tagged", 1)] {
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
|
||||
VALUES (?1, ?2, 'default', '2026-01-01', '2026-01-01', 0)",
|
||||
params![id, name],
|
||||
)
|
||||
.expect("seed label");
|
||||
conn.execute(
|
||||
"INSERT INTO note_labels (note_id, label_id, via_tag) VALUES ('n1', ?1, ?2)",
|
||||
params![id, via_tag],
|
||||
)
|
||||
.expect("seed membership");
|
||||
}
|
||||
let batch = collect(&conn, 100).expect("collect");
|
||||
let note = batch.iter().find(|c| c.entity == "note").expect("note");
|
||||
assert_eq!(note.label_ids.as_deref(), Some(&["manual".to_string()][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_delete_becomes_a_delete_change() {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 0);
|
||||
store::delete_forever(&conn, "n1").expect("delete");
|
||||
let batch = collect(&conn, 100).expect("collect");
|
||||
assert_eq!(batch.len(), 1);
|
||||
assert_eq!(batch[0].op, "delete");
|
||||
assert_eq!(batch[0].entity, "note");
|
||||
assert_eq!(batch[0].id, "n1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_clears_dirty_and_records_the_revision() {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
let batch = collect(&conn, 100).expect("collect");
|
||||
apply_results(&conn, &batch, &[ok("applied", Some(42))]).expect("apply");
|
||||
assert_eq!(dirty_count(&conn), 0);
|
||||
let rev: i64 = conn
|
||||
.query_row("SELECT sync_revision FROM notes WHERE id = 'n1'", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.expect("revision");
|
||||
assert_eq!(rev, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_clears_dirty_so_it_is_not_pushed_forever() {
|
||||
// The server has a newer edit. Re-pushing would lose the same comparison
|
||||
// every time; the following pull adopts the server's version instead.
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
let batch = collect(&conn, 100).expect("collect");
|
||||
let summary = apply_results(&conn, &batch, &[ok("kept", Some(99))]).expect("apply");
|
||||
assert_eq!(summary.kept, 1);
|
||||
assert_eq!(dirty_count(&conn), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_rewinds_the_cursor_when_the_server_version_is_already_behind_it() {
|
||||
// Clock skew: a genuinely later local edit can look older, so the server
|
||||
// keeps its copy at a revision we have ALREADY consumed. Without a rewind the
|
||||
// next pull skips it and the stale local copy stays on screen silently.
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
state::set_cursor(&conn, 100).expect("cursor");
|
||||
let batch = collect(&conn, 100).expect("collect");
|
||||
apply_results(&conn, &batch, &[ok("kept", Some(40))]).expect("apply");
|
||||
assert_eq!(state::read(&conn).expect("state").last_cursor, 39);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_leaves_the_cursor_alone_when_the_server_version_is_ahead() {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
state::set_cursor(&conn, 10).expect("cursor");
|
||||
let batch = collect(&conn, 100).expect("collect");
|
||||
apply_results(&conn, &batch, &[ok("kept", Some(40))]).expect("apply");
|
||||
assert_eq!(
|
||||
state::read(&conn).expect("state").last_cursor,
|
||||
10,
|
||||
"the pending pull already covers it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_stays_dirty_and_is_reported() {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
let batch = collect(&conn, 100).expect("collect");
|
||||
let mut bad = ok("rejected", None);
|
||||
bad.error = Some("name in use".into());
|
||||
let summary = apply_results(&conn, &batch, &[bad]).expect("apply");
|
||||
assert_eq!(summary.rejected, 1);
|
||||
assert_eq!(dirty_count(&conn), 1, "a rejected change must be retried");
|
||||
assert!(summary.errors[0].contains("name in use"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_acknowledged_delete_drops_its_tombstone() {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 0);
|
||||
store::delete_forever(&conn, "n1").expect("delete");
|
||||
let batch = collect(&conn, 100).expect("collect");
|
||||
apply_results(&conn, &batch, &[ok("applied", Some(7))]).expect("apply");
|
||||
assert!(!has_pending(&conn).expect("pending"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_noop_delete_also_drops_its_tombstone() {
|
||||
// Created and deleted entirely offline: the server never saw it.
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 1);
|
||||
store::delete_forever(&conn, "n1").expect("delete");
|
||||
let batch = collect(&conn, 100).expect("collect");
|
||||
apply_results(&conn, &batch, &[ok("noop", None)]).expect("apply");
|
||||
assert!(!has_pending(&conn).expect("pending"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_labels_marks_the_affected_notes_dirty() {
|
||||
// The membership change only reaches the server through the note itself.
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 0);
|
||||
for (id, name) in [("src", "Source"), ("dst", "Target")] {
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, name, color, created_at, updated_at, dirty)
|
||||
VALUES (?1, ?2, 'default', '2026-01-01', '2026-01-01', 0)",
|
||||
params![id, name],
|
||||
)
|
||||
.expect("seed label");
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO note_labels (note_id, label_id, via_tag) VALUES ('n1', 'src', 0)",
|
||||
[],
|
||||
)
|
||||
.expect("seed membership");
|
||||
|
||||
store::merge_labels(&conn, "src", "dst").expect("merge");
|
||||
assert_eq!(dirty_count(&conn), 1, "the note's label set changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_pending_is_false_on_a_clean_store() {
|
||||
let conn = db();
|
||||
seed_note(&conn, "n1", 0);
|
||||
assert!(!has_pending(&conn).expect("pending"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_results_reads_the_documented_shape() {
|
||||
let results = parse_results(
|
||||
r#"{"results":[{"id":"a","entity":"note","status":"created","sync_revision":44},
|
||||
{"id":"b","entity":"label","status":"rejected","error":"name in use"}]}"#,
|
||||
)
|
||||
.expect("parse");
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0].status, "created");
|
||||
assert_eq!(results[1].error.as_deref(), Some("name in use"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delete_change_serializes_without_note_fields() {
|
||||
let change = Change::delete("note", "n1".into(), "2026-07-26T00:00:00.000Z".into());
|
||||
let json = serde_json::to_string(&change).expect("serialize");
|
||||
assert!(json.contains("\"op\":\"delete\""), "got {json}");
|
||||
assert!(
|
||||
!json.contains("body"),
|
||||
"a delete carries no content: {json}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! The link record: which server this app is paired with, the device token that
|
||||
//! authenticates to it, and how far it has consumed that server's change feed.
|
||||
//!
|
||||
//! One row, enforced by `CHECK (id = 1)` and seeded during migration, so every
|
||||
//! operation here is an UPDATE — there is no create-or-missing case to handle.
|
||||
//!
|
||||
//! The token lives in the app-data SQLite file rather than an OS keyring on purpose:
|
||||
//! the `keyring` crate needs libsecret/DBus on Linux, which adds a C dependency to a
|
||||
//! binary that has to cross-compile, and fails outright on headless or minimal-WM
|
||||
//! setups. Protecting the database file is the portable trade.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
|
||||
/// The full link record, token included. Internal to the Rust side.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SyncState {
|
||||
pub server_url: Option<String>,
|
||||
pub device_token: Option<String>,
|
||||
pub last_cursor: i64,
|
||||
pub last_sync_at: Option<String>,
|
||||
/// The linked server's trash-retention window, as it last advertised it. `None`
|
||||
/// until a probe or sync has learned it.
|
||||
pub server_retention_days: Option<i64>,
|
||||
}
|
||||
|
||||
impl SyncState {
|
||||
/// Linked means BOTH a server and a credential for it. Either one alone is a
|
||||
/// half-written link that nothing can act on, so it must not read as linked.
|
||||
pub fn is_linked(&self) -> bool {
|
||||
self.server_url.is_some() && self.device_token.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the UI is allowed to see.
|
||||
///
|
||||
/// Deliberately has no `device_token` field: this crosses into the webview, and a
|
||||
/// long-lived bearer token has no business being reachable from page scripts.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct Status {
|
||||
pub linked: bool,
|
||||
pub server_url: Option<String>,
|
||||
pub last_cursor: i64,
|
||||
pub last_sync_at: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&SyncState> for Status {
|
||||
fn from(s: &SyncState) -> Self {
|
||||
Status {
|
||||
linked: s.is_linked(),
|
||||
server_url: s.server_url.clone(),
|
||||
last_cursor: s.last_cursor,
|
||||
last_sync_at: s.last_sync_at.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Treat a blank string as absent, so a half-cleared row can't masquerade as linked.
|
||||
fn present(value: Option<String>) -> Option<String> {
|
||||
value.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn read(conn: &Connection) -> rusqlite::Result<SyncState> {
|
||||
conn.query_row(
|
||||
"SELECT server_url, device_token, last_cursor, last_sync_at, server_retention_days
|
||||
FROM sync_state WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let cursor: Option<String> = row.get(2)?;
|
||||
Ok(SyncState {
|
||||
last_sync_at: present(row.get(3)?),
|
||||
server_retention_days: row.get(4)?,
|
||||
server_url: present(row.get(0)?),
|
||||
device_token: present(row.get(1)?),
|
||||
// Stored TEXT (schema) but used as an integer watermark. Absent or
|
||||
// unparseable means "start from the beginning" — always the safe
|
||||
// reading, because a redundant full sync costs time, never data,
|
||||
// whereas a too-high cursor silently skips changes.
|
||||
last_cursor: cursor.and_then(|c| c.trim().parse().ok()).unwrap_or(0),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Record a link.
|
||||
///
|
||||
/// Resets the change-feed cursor whenever the server differs from the one previously
|
||||
/// linked. A cursor is only meaningful against the server that issued it; carrying
|
||||
/// one across would silently skip every change on the new server below that
|
||||
/// watermark — data loss that looks like a successful sync. Re-linking the SAME
|
||||
/// server (after a token refresh, say) keeps the cursor, so a routine re-auth doesn't
|
||||
/// force a full re-download.
|
||||
pub fn set_link(conn: &Connection, server_url: &str, device_token: &str) -> rusqlite::Result<()> {
|
||||
let keep_cursor = read(conn)?.server_url.as_deref() == Some(server_url);
|
||||
conn.execute(
|
||||
"UPDATE sync_state
|
||||
SET server_url = ?1,
|
||||
device_token = ?2,
|
||||
last_cursor = CASE WHEN ?3 THEN last_cursor ELSE NULL END
|
||||
WHERE id = 1",
|
||||
params![server_url, device_token, keep_cursor],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the server entirely.
|
||||
///
|
||||
/// Clears the cursor as well as the credentials: a cursor left behind would, on the
|
||||
/// next link, be interpreted against a server that never issued it.
|
||||
pub fn clear_link(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE sync_state
|
||||
SET server_url = NULL, device_token = NULL, last_cursor = NULL,
|
||||
last_sync_at = NULL, server_retention_days = NULL
|
||||
WHERE id = 1",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remember the linked server's trash-retention window (0 = it never purges).
|
||||
///
|
||||
/// Refreshed on every sync rather than only at link time, so changing the setting on
|
||||
/// the server reaches the desktop's Trash countdown on the next cycle instead of
|
||||
/// waiting for someone to re-link.
|
||||
pub fn set_server_retention(conn: &Connection, days: i64) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET server_retention_days = ?1 WHERE id = 1",
|
||||
params![days],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The retention window in force on THIS device: the linked server's if we know it,
|
||||
/// otherwise the caller's offline default. A linked device must never enforce or
|
||||
/// advertise its own window over the server's.
|
||||
pub fn effective_retention_days(conn: &Connection, offline_default: i64) -> rusqlite::Result<i64> {
|
||||
let state = read(conn)?;
|
||||
if !state.is_linked() {
|
||||
return Ok(offline_default);
|
||||
}
|
||||
// Linked but the server hasn't told us yet (linked by an older build, or no sync
|
||||
// has completed). Fall back to the default rather than claiming "kept forever".
|
||||
Ok(state.server_retention_days.unwrap_or(offline_default))
|
||||
}
|
||||
|
||||
/// Stamp a completed sync. The cursor can't stand in for this: it's a revision
|
||||
/// watermark, and it doesn't move at all when a sync correctly finds nothing new —
|
||||
/// so "synced a moment ago, no changes" would be indistinguishable from "never
|
||||
/// synced" without it.
|
||||
pub fn mark_synced(conn: &Connection, when: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET last_sync_at = ?1 WHERE id = 1",
|
||||
params![when],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advance the consumed-change watermark. Called by the pull loop (M10.7b) only
|
||||
/// after a page has been fully applied.
|
||||
pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET last_cursor = ?1 WHERE id = 1",
|
||||
params![cursor.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status(conn: &Connection) -> rusqlite::Result<Status> {
|
||||
Ok(Status::from(&read(conn)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::local::schema;
|
||||
|
||||
fn db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("in-memory db");
|
||||
schema::migrate(&conn).expect("migrate");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_store_is_unlinked() {
|
||||
let conn = db();
|
||||
let state = read(&conn).expect("read");
|
||||
assert_eq!(state, SyncState::default());
|
||||
assert!(!state.is_linked());
|
||||
assert_eq!(state.last_cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_round_trips() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
|
||||
let state = read(&conn).expect("read");
|
||||
assert!(state.is_linked());
|
||||
assert_eq!(
|
||||
state.server_url.as_deref(),
|
||||
Some("https://notes.example.com")
|
||||
);
|
||||
assert_eq!(state.device_token.as_deref(), Some("tok-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlinked_device_uses_its_own_retention_window() {
|
||||
let conn = db();
|
||||
assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_device_adopts_the_servers_window() {
|
||||
// Including 0 — a server that keeps trash forever must not have this device
|
||||
// showing a 30-day countdown that will never fire.
|
||||
let conn = db();
|
||||
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
|
||||
set_server_retention(&conn, 0).expect("retention");
|
||||
assert_eq!(effective_retention_days(&conn, 30).expect("read"), 0);
|
||||
set_server_retention(&conn, 90).expect("retention");
|
||||
assert_eq!(effective_retention_days(&conn, 30).expect("read"), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_device_that_hasnt_heard_yet_falls_back() {
|
||||
// Linked by an older build, or no cycle has completed. The default is a
|
||||
// safer guess than "forever", which would promise a note is being kept.
|
||||
let conn = db();
|
||||
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
|
||||
assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlinking_forgets_the_servers_window() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://notes.example.com", "tok-1").expect("link");
|
||||
set_server_retention(&conn, 90).expect("retention");
|
||||
clear_link(&conn).expect("unlink");
|
||||
assert_eq!(read(&conn).expect("read").server_retention_days, None);
|
||||
assert_eq!(effective_retention_days(&conn, 30).expect("read"), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relinking_the_same_server_keeps_the_cursor() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
||||
set_cursor(&conn, 4242).expect("cursor");
|
||||
// e.g. the token was revoked and the user re-authenticated.
|
||||
set_link(&conn, "https://a.example.com", "tok-2").expect("relink");
|
||||
let state = read(&conn).expect("read");
|
||||
assert_eq!(
|
||||
state.last_cursor, 4242,
|
||||
"a re-auth shouldn't force a full re-sync"
|
||||
);
|
||||
assert_eq!(state.device_token.as_deref(), Some("tok-2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linking_a_different_server_resets_the_cursor() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
||||
set_cursor(&conn, 4242).expect("cursor");
|
||||
set_link(&conn, "https://b.example.com", "tok-2").expect("relink");
|
||||
assert_eq!(
|
||||
read(&conn).expect("read").last_cursor,
|
||||
0,
|
||||
"a cursor from another server would skip everything below it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlink_clears_the_cursor_too() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
||||
set_cursor(&conn, 99).expect("cursor");
|
||||
clear_link(&conn).expect("unlink");
|
||||
let state = read(&conn).expect("read");
|
||||
assert!(!state.is_linked());
|
||||
assert_eq!(state.last_cursor, 0);
|
||||
assert!(state.server_url.is_none());
|
||||
assert!(state.device_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlink_clears_the_last_sync_stamp() {
|
||||
// Otherwise a freshly-linked server would claim it synced at a time that
|
||||
// belonged to a different one.
|
||||
let conn = db();
|
||||
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
|
||||
mark_synced(&conn, "2026-07-26T04:00:00.000Z").expect("stamp");
|
||||
assert!(read(&conn).expect("read").last_sync_at.is_some());
|
||||
clear_link(&conn).expect("unlink");
|
||||
assert!(read(&conn).expect("read").last_sync_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_written_link_is_not_linked() {
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET server_url = 'https://a.example.com' WHERE id = 1",
|
||||
[],
|
||||
)
|
||||
.expect("partial write");
|
||||
assert!(!read(&conn).expect("read").is_linked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_strings_count_as_absent() {
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET server_url = ' ', device_token = '' WHERE id = 1",
|
||||
[],
|
||||
)
|
||||
.expect("blank write");
|
||||
let state = read(&conn).expect("read");
|
||||
assert!(!state.is_linked());
|
||||
assert!(state.server_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unparseable_cursor_falls_back_to_a_full_sync() {
|
||||
let conn = db();
|
||||
conn.execute(
|
||||
"UPDATE sync_state SET last_cursor = 'garbage' WHERE id = 1",
|
||||
[],
|
||||
)
|
||||
.expect("bad cursor");
|
||||
assert_eq!(read(&conn).expect("read").last_cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_never_carries_the_token() {
|
||||
let conn = db();
|
||||
set_link(&conn, "https://a.example.com", "super-secret").expect("link");
|
||||
let json = serde_json::to_string(&status(&conn).expect("status")).expect("serialize");
|
||||
assert!(
|
||||
!json.contains("super-secret"),
|
||||
"token leaked to the webview: {json}"
|
||||
);
|
||||
assert!(json.contains("\"linked\":true"), "got {json}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! The delta-feed JSON shapes, exactly as `GET /api/sync/changes` sends them.
|
||||
//!
|
||||
//! Mirrors the server's serializers (`notes/serialize.py` + `serialize.py`) — see
|
||||
//! `docs/sync.md` for the contract. Every field is `#[serde(default)]` or `Option`
|
||||
//! so a NEWER server adding fields, or an older one omitting one, degrades to a
|
||||
//! partial note rather than failing the whole page. Losing one attribute is
|
||||
//! recoverable; refusing a page stalls sync permanently at that cursor.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct ChangesPage {
|
||||
#[serde(default)]
|
||||
pub notes: Vec<Note>,
|
||||
#[serde(default)]
|
||||
pub labels: Vec<Label>,
|
||||
#[serde(default)]
|
||||
pub cursor: i64,
|
||||
#[serde(default)]
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Note {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
#[serde(default = "default_kind")]
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub position: i64,
|
||||
#[serde(default)]
|
||||
pub pinned: bool,
|
||||
#[serde(default)]
|
||||
pub archived: bool,
|
||||
/// The server derives this from `deleted_at` — trash, NOT a tombstone.
|
||||
#[serde(default)]
|
||||
pub trashed: bool,
|
||||
/// WHEN it was trashed. The trash-retention clock runs from here, so it has to be
|
||||
/// the server's timestamp rather than anything this device invents. Absent from an
|
||||
/// older server, which is why it's optional rather than required.
|
||||
#[serde(default)]
|
||||
pub deleted_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub remind_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub recurrence: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_revision: i64,
|
||||
/// Set means the row was permanently purged: a content-less tombstone whose only
|
||||
/// job is to tell clients to delete their copy.
|
||||
#[serde(default)]
|
||||
pub purged_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub labels: Vec<NoteLabel>,
|
||||
#[serde(default)]
|
||||
pub items: Vec<Item>,
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<Attachment>,
|
||||
#[serde(default)]
|
||||
pub previews: Vec<Preview>,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
pub fn is_tombstone(&self) -> bool {
|
||||
self.purged_at.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// A label as it appears attached to a note. Carries enough to materialize the label
|
||||
/// row itself, which is what lets a membership be applied even if the label's own
|
||||
/// delta hasn't arrived (see `pull::apply_page`).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NoteLabel {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
/// True when the membership came from a `#tag` in the body rather than a manual
|
||||
/// assignment. Applied verbatim rather than re-derived — see `pull::apply_page`.
|
||||
#[serde(default)]
|
||||
pub via_tag: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Item {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub checked: bool,
|
||||
#[serde(default)]
|
||||
pub position: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub filename: Option<String>,
|
||||
#[serde(default = "default_mime")]
|
||||
pub mime: String,
|
||||
#[serde(default)]
|
||||
pub size: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Preview {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub site_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Label {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
pub sync_revision: i64,
|
||||
#[serde(default)]
|
||||
pub purged_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
pub fn is_tombstone(&self) -> bool {
|
||||
self.purged_at.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_color() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
fn default_kind() -> String {
|
||||
"text".to_string()
|
||||
}
|
||||
|
||||
fn default_mime() -> String {
|
||||
"application/octet-stream".to_string()
|
||||
}
|
||||
Reference in New Issue
Block a user