core: the body is the checklist, and checklist_items is gone
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s

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.
This commit is contained in:
2026-08-24 00:41:23 -04:00
parent d0e3e48943
commit 668f7faf03
8 changed files with 331 additions and 184 deletions
+192 -1
View File
@@ -6,7 +6,9 @@
//!
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
use rusqlite::Connection;
use rusqlite::{params, Connection, OptionalExtension};
use crate::local::derive;
const SCHEMA_V1: &str = r#"
CREATE TABLE notes (
@@ -54,6 +56,8 @@ CREATE TABLE checklist_items (
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,
@@ -180,6 +184,73 @@ 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",
[&note_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;")?;
@@ -212,5 +283,125 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
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);
}
}