M304 steps 2 and the client half of 4, together — they cannot be separated. A
commit where the store writes items into the body while push.rs still reads them
from a table is one that silently pushes the wrong list, and dev publishes to the
dev channel on every green build.
Store:
* load_items becomes items_of(body) — a parse, not a query. An item's id is its
ORDINAL, which is all it ever amounted to: push.rs sent text and checked and
never an id, and both sides replaced the whole list on every sync.
* add_item / update_item / delete_item route through update_note, so they get
revision snapshotting, #tag re-derivation and the dirty/updated_at bookkeeping
without any of it being written a second time.
* create_note folds its items: input into the body, and syncs tags from the
FOLDED body — an item can carry a #tag too.
* display_title no longer takes items, because items ARE body lines now. It
strips the task marker instead: a list-only note is still named by its first
item, and calling that note "- [ ] milk" would show someone the storage.
Wire: items leave it. A second copy of data already in the body field of the same
message is how the two come to disagree. CLIENT_PROTOCOL_VERSION and
MIN_SERVER_PROTOCOL_VERSION go to 3, which is what makes this safe to land before
the server: a v3 client refuses a v2 server outright rather than pushing a body
whose list the old _apply_note_items would then delete.
Schema v8 folds every existing row into its note's body before dropping the
table. Written in Rust, not SQL: the fold has to produce exactly what
derive::append_item produces, and group_concat only gained a guaranteed ORDER BY
in SQLite 3.44 — a checklist that quietly reordered itself during a migration
would be a poor way to learn that. updated_at and dirty are deliberately left
alone, because the server's migration folds the same rows the same way and both
sides land on identical bodies; marking every note dirty would push a body the
server already has, from every device at once.
NOT deployable yet. The server still speaks v2 and still has note_items, so a
client built from this will refuse to sync until the server half lands.
408 lines
15 KiB
Rust
408 lines
15 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::{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<String> = 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);
|
|
}
|
|
}
|