//! 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); //! `#tags` are NOT stored as such (derived at query time into labels), matching //! docs/sync.md. //! //! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change. use rusqlite::{params, Connection, OptionalExtension}; use crate::local::derive; 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', -- dropped in v6; kept so DROP COLUMN has something to drop 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); -- Both dropped in v8; kept here so an existing database has something to migrate -- FROM, exactly as `kind` above is kept for v6. 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 ); "#; // v6 (M13 step 2): `kind` is gone. A checklist is something a note HAS, not something // a note IS — the column was a mode flag with no enum and no constraint behind it, // and `note_items` was never tied to it. Dropping it loses nothing: a note that was // 'list' keeps every one of its items. // // SQLite has supported DROP COLUMN since 3.35 (2021); rusqlite bundles well past it. const SCHEMA_V6: &str = r#" ALTER TABLE notes DROP COLUMN kind; "#; // v7 (M13 step 3): the title field is gone. A note is a body plus optional items, and // its NAME is the first non-empty line of that body, falling back to its first item — // derived at read time, never stored (see store::display_title). // // note_revisions loses its copy for the same reason: a revision snapshots a body. const SCHEMA_V7: &str = r#" ALTER TABLE notes DROP COLUMN title; ALTER TABLE note_revisions DROP COLUMN title; "#; // v8 (M304): `checklist_items` is gone. The body IS the checklist — a `- [ ] milk` // line is the item — so a list can sit between two paragraphs instead of only after // them, which a side table could never express no matter how it was styled. // // Rust rather than a SQL const, for two reasons. The fold has to produce EXACTLY what // `derive::append_item` produces, and expressing that in SQL would be a second // implementation of the layout rule. And `group_concat` only gained a guaranteed // ORDER BY in SQLite 3.44 — a checklist that silently reordered itself during the // migration would be a poor way to find that out. // // `updated_at` and `dirty` are deliberately NOT touched. The server's Alembic // migration folds the same rows with the same spacing, so both sides land on // identical bodies and this needs no sync at all; marking every note dirty would // push a body the server already has, and would do it for every device at once. fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> { // Grouped in one pass — the query is ordered by note, so a change of note_id is // the group boundary. `rowid` breaks ties, because `position` was only ever // advisory and two rows sharing one is not a reason to reorder someone's list. let mut grouped: Vec<(String, Vec<(String, bool)>)> = Vec::new(); { let mut stmt = conn.prepare( "SELECT note_id, text, checked FROM checklist_items ORDER BY note_id ASC, position ASC, rowid ASC", )?; let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let note_id: String = row.get(0)?; let text: String = row.get(1)?; let checked: bool = row.get(2)?; match grouped.last_mut() { Some((id, items)) if *id == note_id => items.push((text, checked)), _ => grouped.push((note_id, vec![(text, checked)])), } } } for (note_id, items) in grouped { let existing: Option = conn .query_row( "SELECT body FROM notes WHERE id = ?1", [¬e_id], |r| r.get(0), ) .optional()?; // An item whose note is already gone has nothing to fold into. The foreign key // should make this impossible; skipping costs nothing, and failing here would // leave the only copy of someone's notes half-migrated. let mut body = match existing { Some(b) => b, None => continue, }; for (text, checked) in items { body = derive::append_item(&body, &text, checked); } conn.execute( "UPDATE notes SET body = ?1 WHERE id = ?2", params![body, note_id], )?; } conn.execute_batch( "DROP INDEX IF EXISTS idx_items_note; DROP TABLE checklist_items;", )?; Ok(()) } /// 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;")?; } if version < 6 { conn.execute_batch(SCHEMA_V6)?; conn.execute_batch("PRAGMA user_version = 6;")?; } if version < 7 { conn.execute_batch(SCHEMA_V7)?; conn.execute_batch("PRAGMA user_version = 7;")?; } if version < 8 { migrate_v8(conn)?; conn.execute_batch("PRAGMA user_version = 8;")?; } Ok(()) } #[cfg(test)] mod tests { use super::*; /// A database as it stood before M304 — items still in their own table. fn v7_db() -> Connection { let conn = Connection::open_in_memory().expect("open"); conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk"); for batch in [ SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, ] { conn.execute_batch(batch).expect("batch"); } conn.execute_batch("PRAGMA user_version = 7;").expect("v7"); conn } fn add_note(conn: &Connection, id: &str, body: &str) { conn.execute( "INSERT INTO notes (id, body, created_at, updated_at) VALUES (?1, ?2, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')", params![id, body], ) .expect("note"); } fn add_item(conn: &Connection, note: &str, text: &str, checked: bool, pos: i64) { conn.execute( "INSERT INTO checklist_items (id, note_id, text, checked, position) VALUES (?1, ?2, ?3, ?4, ?5)", params![format!("{note}-{pos}"), note, text, checked, pos], ) .expect("item"); } fn body_of(conn: &Connection, id: &str) -> String { conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0)) .expect("body") } #[test] fn v8_folds_items_into_the_body() { let conn = v7_db(); add_note(&conn, "n1", "shopping"); add_item(&conn, "n1", "milk", false, 0); add_item(&conn, "n1", "eggs", true, 1); migrate(&conn).expect("migrate"); // Prose, blank line, list — the layout _note_markdown already exports, so an // export taken before this migration and one taken after agree byte for byte. assert_eq!(body_of(&conn, "n1"), "shopping\n\n- [ ] milk\n- [x] eggs"); } #[test] fn v8_keeps_a_list_only_note_whole() { let conn = v7_db(); add_note(&conn, "n1", ""); add_item(&conn, "n1", "milk", false, 0); migrate(&conn).expect("migrate"); assert_eq!(body_of(&conn, "n1"), "- [ ] milk"); } #[test] fn v8_leaves_timestamps_alone() { // The whole reason this needs no sync: the server folds the same rows the same // way, so both sides already agree. Marking notes dirty would push a body the // server has, from every device at once. let conn = v7_db(); add_note(&conn, "n1", "note"); add_item(&conn, "n1", "milk", false, 0); migrate(&conn).expect("migrate"); let (updated, dirty): (String, i64) = conn .query_row( "SELECT updated_at, dirty FROM notes WHERE id = 'n1'", [], |r| Ok((r.get(0)?, r.get(1)?)), ) .expect("row"); assert_eq!(updated, "2026-01-01T00:00:00.000Z"); assert_eq!(dirty, 1); // as inserted, not raised by the migration } #[test] fn v8_drops_the_table_and_is_idempotent() { let conn = v7_db(); add_note(&conn, "n1", "note"); migrate(&conn).expect("migrate"); migrate(&conn).expect("again"); let exists: i64 = conn .query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='checklist_items'", [], |r| r.get(0), ) .expect("count"); assert_eq!(exists, 0); } #[test] fn a_fresh_database_reaches_v8() { let conn = Connection::open_in_memory().expect("open"); migrate(&conn).expect("migrate"); let version: i64 = conn .query_row("PRAGMA user_version", [], |r| r.get(0)) .expect("version"); assert_eq!(version, 8); } }