core: the body is the checklist, and checklist_items is gone
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:
+192
-1
@@ -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",
|
||||
[¬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;")?;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user