//! 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::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(()) }