Schema v12 adds notes.state_at: a recipient's own pin, archive and order are stamped there instead of on updated_at, which stays the text's time. Push sends them only to a server advertising `shared_state` (push::Accepts), a view share included; the first pull at that level starts the feed over once so held copies drop their owner's pins. The client speaks protocol 7 and lists `shares` and `shared_state` among the features a server may lack. The web card and editor offer pin, archive and drag on shared notes; share and trash stay the owner's, and the board's trash key skips notes you don't own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
590 lines
23 KiB
Rust
590 lines
23 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', -- dropped in v9; kept so DROP COLUMN has something to drop
|
|
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.
|
|
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives
|
|
// only on a tag, so the column was written by a picker nothing read and read by nothing
|
|
// at all. `labels.color` is untouched — that is the colour that survived.
|
|
//
|
|
// The saved-filter sweep is the second half and not optional. `params` is opaque JSON
|
|
// and a stored view could carry `"color": "teal"`; with the facet gone that key would
|
|
// sit there forever, and a view that silently filters on a field the app no longer has
|
|
// is worse than one that visibly lost a criterion. Guarded on `json_valid` because a
|
|
// corrupt blob must keep whatever it holds, not become NULL.
|
|
//
|
|
// The second guard is a LIKE and not `json_extract(...) IS NOT NULL`, which is the
|
|
// obvious way to write it and is a trap: SQLite does not promise to short-circuit AND,
|
|
// so `json_extract` can be evaluated against the very rows `json_valid` was there to
|
|
// exclude — and on malformed input it does not return NULL, it RAISES, which would
|
|
// abort the migration for every other row too. `LIKE` is total over any text.
|
|
const SCHEMA_V9: &str = r#"
|
|
ALTER TABLE notes DROP COLUMN color;
|
|
|
|
UPDATE saved_filters
|
|
SET params = json_remove(params, '$.color')
|
|
WHERE json_valid(params)
|
|
AND params LIKE '%"color"%';
|
|
"#;
|
|
|
|
// v10 (#5168): an attachment can be created on THIS device.
|
|
//
|
|
// Until now every attachment row arrived on the delta feed, so every one was already on
|
|
// the server. A file attached here, offline, is not, and `uploaded = 0` is the queue
|
|
// push drains once the note has landed. The rows already here all came from the
|
|
// server, which is why the default is 1.
|
|
//
|
|
// `upload_error` holds a refusal that retrying won't fix — over the size limit, an id
|
|
// already in use, bytes that don't match their hash. A row carrying one leaves the
|
|
// queue: a file refused for its size would otherwise be re-sent in full on every
|
|
// cycle, every five minutes, for as long as the app was open. The message is what the
|
|
// editor shows on the file instead.
|
|
const SCHEMA_V10: &str = r#"
|
|
ALTER TABLE attachments ADD COLUMN uploaded INTEGER NOT NULL DEFAULT 1;
|
|
ALTER TABLE attachments ADD COLUMN upload_error TEXT;
|
|
"#;
|
|
|
|
// v11 (#5175): notes other people shared with this account.
|
|
//
|
|
// The feed now carries them, so a note says how it is held: `permission` is `owner`,
|
|
// `edit` (its text may change here) or `view`. Every note already on a device is the
|
|
// account's own, which is why the default is `owner`. `shared` is whether the owner
|
|
// has shared it with anyone, and `shared_by_*` names the owner of one shared with us.
|
|
//
|
|
// `shares_synced` is whether this device has pulled with shares since it was linked.
|
|
// The notes shared before this build sit below the cursor it already holds, so the
|
|
// first pull from a server offering `shares` starts again from zero (see
|
|
// `engine::run_cycle`). A pull applies idempotently, so starting over costs a
|
|
// download and nothing else.
|
|
const SCHEMA_V11: &str = r#"
|
|
ALTER TABLE notes ADD COLUMN permission TEXT NOT NULL DEFAULT 'owner';
|
|
ALTER TABLE notes ADD COLUMN shared INTEGER NOT NULL DEFAULT 0;
|
|
ALTER TABLE notes ADD COLUMN shared_by_id TEXT;
|
|
ALTER TABLE notes ADD COLUMN shared_by_name TEXT;
|
|
ALTER TABLE sync_state ADD COLUMN shares_synced INTEGER NOT NULL DEFAULT 0;
|
|
"#;
|
|
|
|
// v12 (#5176): a shared note's pin, archive and position are this account's own.
|
|
//
|
|
// `state_at` is when they last changed here, on a note someone else owns. It is kept
|
|
// apart from `updated_at`, which stays the time of the note's TEXT: pinning a copy
|
|
// whose text is behind the owner's must not make that text look like the newer
|
|
// edit when it is pushed. Null on our own notes, where `updated_at` covers both.
|
|
//
|
|
// `sync_state.shares_synced` now holds a level rather than a flag (see
|
|
// `state::SHARED_STATE`), and a device reaching this level pulls everything once
|
|
// more: the shared notes it holds carry their owner's pins until it does.
|
|
const SCHEMA_V12: &str = r#"
|
|
ALTER TABLE notes ADD COLUMN state_at TEXT;
|
|
"#;
|
|
|
|
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;")?;
|
|
}
|
|
if version < 9 {
|
|
conn.execute_batch(SCHEMA_V9)?;
|
|
conn.execute_batch("PRAGMA user_version = 9;")?;
|
|
}
|
|
if version < 10 {
|
|
conn.execute_batch(SCHEMA_V10)?;
|
|
conn.execute_batch("PRAGMA user_version = 10;")?;
|
|
}
|
|
if version < 11 {
|
|
conn.execute_batch(SCHEMA_V11)?;
|
|
conn.execute_batch("PRAGMA user_version = 11;")?;
|
|
}
|
|
if version < 12 {
|
|
conn.execute_batch(SCHEMA_V12)?;
|
|
conn.execute_batch("PRAGMA user_version = 12;")?;
|
|
}
|
|
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_the_latest_version() {
|
|
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, 12);
|
|
}
|
|
|
|
/// Every attachment that predates v10 came down the feed, so it is already on the
|
|
/// server. Defaulting it to "waiting to upload" would re-send every file once.
|
|
#[test]
|
|
fn v10_counts_existing_attachments_as_already_on_the_server() {
|
|
let conn = v7_db();
|
|
migrate_v8(&conn).expect("v8");
|
|
conn.execute_batch(SCHEMA_V9).expect("v9");
|
|
conn.execute_batch("PRAGMA user_version = 9;").expect("v9");
|
|
add_note(&conn, "n", "a note");
|
|
conn.execute(
|
|
"INSERT INTO attachments (id, note_id, url) VALUES ('a', 'n', '/x')",
|
|
[],
|
|
)
|
|
.expect("seed");
|
|
|
|
migrate(&conn).expect("migrate");
|
|
let (uploaded, error): (bool, Option<String>) = conn
|
|
.query_row(
|
|
"SELECT uploaded, upload_error FROM attachments WHERE id = 'a'",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
)
|
|
.expect("row");
|
|
assert!(uploaded);
|
|
assert_eq!(error, None);
|
|
}
|
|
|
|
/// The column is gone, not merely unread. Asserted by asking SQLite rather than by
|
|
/// reading a row: a SELECT that omits `color` would pass either way.
|
|
#[test]
|
|
fn v9_drops_the_note_colour_column() {
|
|
let conn = Connection::open_in_memory().expect("open");
|
|
migrate(&conn).expect("migrate");
|
|
let mut stmt = conn.prepare("PRAGMA table_info(notes)").expect("pragma");
|
|
let columns: Vec<String> = stmt
|
|
.query_map([], |r| r.get::<_, String>(1))
|
|
.expect("query")
|
|
.collect::<rusqlite::Result<Vec<String>>>()
|
|
.expect("collect");
|
|
assert!(!columns.iter().any(|c| c == "color"));
|
|
// The one that survived. Getting this wrong would take every tag's colour with
|
|
// it, which is the whole thing M315 was keeping.
|
|
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
|
|
let label_columns: Vec<String> = stmt
|
|
.query_map([], |r| r.get::<_, String>(1))
|
|
.expect("query")
|
|
.collect::<rusqlite::Result<Vec<String>>>()
|
|
.expect("collect");
|
|
assert!(label_columns.iter().any(|c| c == "color"));
|
|
}
|
|
|
|
/// A stored view that filtered on colour loses that criterion and keeps the rest.
|
|
/// The alternative — leaving the key — is a lens that silently narrows on a field
|
|
/// the app no longer has and never says why it returned nothing.
|
|
#[test]
|
|
fn v9_sweeps_colour_out_of_saved_filters() {
|
|
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("schema");
|
|
}
|
|
conn.execute_batch("PRAGMA user_version = 8;").expect("v8");
|
|
for (id, params) in [
|
|
("a", r#"{"color":"teal","q":"milk"}"#),
|
|
("b", r#"{"q":"eggs"}"#),
|
|
// Not JSON at all. It must come out UNCHANGED rather than NULL — a blob
|
|
// this migration cannot read is not a blob it gets to destroy.
|
|
("c", "not json"),
|
|
] {
|
|
conn.execute(
|
|
"INSERT INTO saved_filters (id, name, params, created_at)
|
|
VALUES (?1, ?1, ?2, '2026-08-28T00:00:00.000Z')",
|
|
params![id, params],
|
|
)
|
|
.expect("seed");
|
|
}
|
|
|
|
migrate(&conn).expect("migrate");
|
|
|
|
let read = |id: &str| -> String {
|
|
conn.query_row(
|
|
"SELECT params FROM saved_filters WHERE id = ?1",
|
|
[id],
|
|
|r| r.get(0),
|
|
)
|
|
.expect("read")
|
|
};
|
|
assert_eq!(read("a"), r#"{"q":"milk"}"#);
|
|
assert_eq!(read("b"), r#"{"q":"eggs"}"#);
|
|
assert_eq!(read("c"), "not json");
|
|
}
|
|
}
|