CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME stays — search results, export filenames and the command palette all need one — but nothing is typed into it any more. `display_title` is now the first non-empty line of the body, falling back to the first checklist item. That fallback is what step 2 bought, and the reason this could not go first: a checklist had no body to be named from, so the title was its only name. Now every note has a body, and a note that is only a checklist is named by its first item. Gone everywhere: the column and note_revisions.title (0026), the field on the core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7), `normalize_title`, the wire field, the FFI record and `NoteEdit::Title` / `ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and the Android title field in both the compose sheet and the editor. **The search vector had to be rebuilt, not just left alone.** `notes.search_vector` is a STORED GENERATED column whose expression names `title` — Postgres refuses to drop a column another generated column depends on. It is dropped and recreated over `display_title` at weight A, which keeps the original intent: a note's NAME ranks above the rest of its body. **An imported title becomes the note's first body line.** Keep notes carry one, and so does any ThoughtSync export taken before this. Dropping it would silently lose text someone wrote; folding it in puts it exactly where a name now lives, so the note arrives named as it was. Skipped when the body already opens with that line, so re-importing an export this code produced doesn't stack duplicates. Two smaller things fell out. The Android editor loses its bold first field — one weight throughout, because the first line is the note's name but not a different KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s justification comment moved to `ClearRemindAt`, which is now the surviving example of why NoteEdit is a list rather than a struct of options. Protocol note corrected to say what actually shipped: v2 is "no kind, no title", one bump for the pair. Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests all green before pushing. It caught four things — orphaned serde attributes where fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview still has one), nine retention fixtures inserting a dropped column, and four rustfmt diffs.
217 lines
7.9 KiB
Rust
217 lines
7.9 KiB
Rust
//! 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', -- 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);
|
|
|
|
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;
|
|
"#;
|
|
|
|
/// 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;")?;
|
|
}
|
|
Ok(())
|
|
}
|