mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
66 comments cited `bugs.md B<n>`, the ledger's old name; they now say `docs/BUGS.md B<n>`, and every cited number has an entry. About 90 more cited offline.md, devices.md, features.md, mount-architecture.md, plan.md and improvements.md, planning notes that were never in the repository. Those citations are removed; the comments keep their text.
7200 lines
247 KiB
Rust
7200 lines
247 KiB
Rust
use super::activity::ACTIVITY_KEEP;
|
||
use super::migrations::SCHEMA_VERSION;
|
||
use super::*;
|
||
use crate::control::{ActivityEntry, ActivityKind};
|
||
use crate::localindex::LocalEntry;
|
||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||
use proton_drive_rs::{Node, NodeKind};
|
||
use serde_json::json;
|
||
|
||
/// `Db::open` applies the pragmas that `open_in_memory` skips, so the WAL
|
||
/// settings can only be checked against a real file.
|
||
#[test]
|
||
fn open_bounds_the_wal_size() {
|
||
let path = std::env::temp_dir().join(format!("pdfs-db-wal-{}.db", std::process::id()));
|
||
let _ = std::fs::remove_file(&path);
|
||
let db = Db::open(&path).unwrap();
|
||
let conn = db.conn.lock();
|
||
|
||
let mode: String = conn
|
||
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
|
||
.unwrap();
|
||
assert_eq!(mode, "wal");
|
||
// Without this the WAL is reused in place but never shrinks, so one
|
||
// oversized transaction strands its high-water mark on disk forever.
|
||
let limit: i64 = conn
|
||
.query_row("PRAGMA journal_size_limit", [], |r| r.get(0))
|
||
.unwrap();
|
||
assert_eq!(limit, WAL_SIZE_LIMIT);
|
||
|
||
// Without a busy timeout the default is 0: a lock held by anyone else fails
|
||
// the statement instantly instead of waiting for it to clear.
|
||
let busy: i64 = conn
|
||
.query_row("PRAGMA busy_timeout", [], |r| r.get(0))
|
||
.unwrap();
|
||
assert_eq!(busy, BUSY_TIMEOUT.as_millis() as i64);
|
||
|
||
drop(conn);
|
||
drop(db);
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
/// A second connection to the same file — a hand-started `pdfs mount` alongside
|
||
/// the systemd service, a backup tool, a stray `sqlite3` session — holds a write
|
||
/// lock briefly. Our write must wait for it rather than failing on the spot.
|
||
///
|
||
/// **This passed before the busy timeout was set explicitly**, because rusqlite
|
||
/// already applies one. It is kept as a characterisation test, not as evidence
|
||
/// of a fix: it states the behaviour the daemon relies on, so that losing it
|
||
/// (dropping the pragma, or a dependency changing its default) fails here rather
|
||
/// than in front of a user.
|
||
#[test]
|
||
fn a_write_waits_for_a_competing_writer() {
|
||
use std::time::Duration;
|
||
|
||
let path = std::env::temp_dir().join(format!("pdfs-db-busy-{}.db", std::process::id()));
|
||
let _ = std::fs::remove_file(&path);
|
||
let db = Db::open(&path).unwrap();
|
||
|
||
// A second process's connection takes an exclusive write lock and holds it
|
||
// for a moment, as any real transaction would.
|
||
let holder = {
|
||
let path = path.clone();
|
||
std::thread::spawn(move || {
|
||
let other = rusqlite::Connection::open(&path).unwrap();
|
||
other.busy_timeout(Duration::from_secs(5)).unwrap();
|
||
other.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||
std::thread::sleep(Duration::from_millis(300));
|
||
other.execute_batch("COMMIT").unwrap();
|
||
})
|
||
};
|
||
// Let the holder actually acquire the lock before we contend for it.
|
||
std::thread::sleep(Duration::from_millis(50));
|
||
|
||
// This is the assertion: it blocks until the holder commits, rather than
|
||
// returning SQLITE_BUSY.
|
||
db.set_state_i64("busy_probe", 7).unwrap();
|
||
|
||
holder.join().unwrap();
|
||
assert_eq!(db.state_i64("busy_probe").unwrap(), Some(7));
|
||
|
||
drop(db);
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||
|
||
fn uid(link: &str) -> NodeUid {
|
||
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||
}
|
||
|
||
fn now_test_id() -> u64 {
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||
}
|
||
|
||
// `NodeVerification` is not re-exported, so build test nodes by
|
||
// deserializing JSON (the field has a serde default and can be omitted).
|
||
fn node_from(parent: serde_json::Value, link: &str, name: &str, kind: serde_json::Value) -> Node {
|
||
let v = serde_json::json!({
|
||
"uid": {"volume_id": "vol", "link_id": link},
|
||
"parent_uid": parent,
|
||
"kind": kind,
|
||
"name": name,
|
||
"creation_time": 100,
|
||
"modification_time": 200,
|
||
"trashed": false,
|
||
"signature_email": null,
|
||
});
|
||
serde_json::from_value(v).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
media_type: Some("video/mp4".into()),
|
||
favorite: Some(true),
|
||
..TimelineRow::new("p1", 300)
|
||
},
|
||
TimelineRow::new("p2", 200),
|
||
TimelineRow::new("p3", 100),
|
||
])
|
||
.unwrap();
|
||
|
||
// A thumbnail attempt teaches us p1's ratio and that p2 can never have one.
|
||
db.photo_set_thumb("p1", THUMB_HAVE, Some(1.5)).unwrap();
|
||
db.photo_set_thumb("p2", THUMB_NONE, None).unwrap();
|
||
|
||
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
|
||
// p1's media type arrives as `None` this time and must not be forgotten.
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("new.jpg".into()),
|
||
favorite: Some(false),
|
||
..TimelineRow::new("p0", 400)
|
||
},
|
||
TimelineRow::new("p1", 300),
|
||
TimelineRow::new("p2", 200),
|
||
])
|
||
.unwrap();
|
||
|
||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||
assert_eq!(
|
||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["p0", "p1", "p2"],
|
||
"server order is preserved, and the dropped photo is gone"
|
||
);
|
||
assert_eq!(page[0].name.as_deref(), Some("new.jpg"));
|
||
// Ratios and verdicts cost a download to rediscover: they survive a refresh.
|
||
assert_eq!(page[1].ratio, Some(1.5));
|
||
assert_eq!(page[1].thumb_state, THUMB_HAVE);
|
||
assert_eq!(page[2].thumb_state, THUMB_NONE);
|
||
// A photo we know nothing about yet starts blank.
|
||
assert_eq!(page[0].ratio, None);
|
||
assert_eq!(page[0].thumb_state, THUMB_UNKNOWN);
|
||
// Media type is learned-and-kept like the ratio: p1 keeps the video type
|
||
// it was first seen with even though the later refresh carried `None`, so
|
||
// it stays classified as a video.
|
||
assert_eq!(page[1].kind, crate::control::PhotoKind::Video);
|
||
|
||
assert_eq!(db.photos_count().unwrap(), 3);
|
||
// The counts break down by tab, and a filtered page returns only its tab.
|
||
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
|
||
let videos = db
|
||
.photos_page(
|
||
0,
|
||
10,
|
||
Some(crate::control::PhotoKind::Video),
|
||
None,
|
||
false,
|
||
false,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["p1"]
|
||
);
|
||
let by_uid = db.photos_by_uid(&["p2".into()]).unwrap();
|
||
assert_eq!(by_uid.len(), 1);
|
||
assert_eq!(by_uid[0].capture_time, 200);
|
||
|
||
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
|
||
// not p0 at 400. Combined with a kind filter both conditions apply.
|
||
let ranged = db
|
||
.photos_page(0, 10, None, Some((150, 350)), false, false)
|
||
.unwrap();
|
||
assert_eq!(
|
||
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["p1", "p2"]
|
||
);
|
||
let ranged_video = db
|
||
.photos_page(
|
||
0,
|
||
10,
|
||
Some(crate::control::PhotoKind::Video),
|
||
Some((150, 350)),
|
||
false,
|
||
false,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
ranged_video
|
||
.iter()
|
||
.map(|p| p.uid.as_str())
|
||
.collect::<Vec<_>>(),
|
||
["p1"]
|
||
);
|
||
// All three surviving photos sit in the same (1970-01) local month.
|
||
let months = db.photos_months(None).unwrap();
|
||
assert_eq!(months.len(), 1);
|
||
assert_eq!(months[0].2, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
favorite: Some(true),
|
||
..TimelineRow::new("p1", 300)
|
||
},
|
||
TimelineRow {
|
||
favorite: Some(false),
|
||
..TimelineRow::new("p2", 200)
|
||
},
|
||
])
|
||
.unwrap();
|
||
let favorites = db.photos_page(0, 10, None, None, true, false).unwrap();
|
||
assert_eq!(
|
||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["p1"],
|
||
"only the favorited photo is in a favorites page"
|
||
);
|
||
|
||
// A local toggle survives a refresh that could not resolve the photo's node
|
||
// (`None`), and loses to one that could.
|
||
db.photos_set_favorite("p2", true).unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
favorite: Some(false),
|
||
..TimelineRow::new("p1", 300)
|
||
},
|
||
TimelineRow::new("p2", 200),
|
||
])
|
||
.unwrap();
|
||
let favorites = db.photos_page(0, 10, None, None, true, false).unwrap();
|
||
assert_eq!(
|
||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["p2"],
|
||
"the server's answer wins where it has one; the local flag holds where it doesn't"
|
||
);
|
||
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
|
||
}
|
||
|
||
/// The strip shows the same calendar day in earlier years only: not today's
|
||
/// own photos, not the day before, and one tile per RAW+JPEG shot.
|
||
#[test]
|
||
fn on_this_day_finds_the_same_date_in_earlier_years() {
|
||
const DAY: i64 = 86_400;
|
||
// Noon UTC, so the local date is the same in every common time zone.
|
||
let today = 1_781_524_800; // 2026-06-15 12:00 UTC
|
||
let year_ago = today - 365 * DAY; // 2025-06-15
|
||
let three_years_ago = today - (365 * 3 + 1) * DAY; // 2023-06-15, past a 29 February
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow::new("today", today),
|
||
TimelineRow {
|
||
name: Some("IMG_1.JPG".into()),
|
||
..TimelineRow::new("jpeg", year_ago)
|
||
},
|
||
TimelineRow {
|
||
name: Some("IMG_1.CR2".into()),
|
||
..TimelineRow::new("raw", year_ago)
|
||
},
|
||
TimelineRow::new("day-before", year_ago - DAY),
|
||
TimelineRow::new("older", three_years_ago),
|
||
])
|
||
.unwrap();
|
||
|
||
let found = db.photos_on_this_day(today, 10).unwrap();
|
||
assert_eq!(
|
||
found.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["jpeg", "older"]
|
||
);
|
||
}
|
||
|
||
fn hashed(uid: &str, capture_time: i64, name: &str, hash: &str) -> TimelineRow {
|
||
TimelineRow {
|
||
name: Some(name.into()),
|
||
content_hash: Some(hash.into()),
|
||
..TimelineRow::new(uid, capture_time)
|
||
}
|
||
}
|
||
|
||
fn located(uid: &str, capture_time: i64, name: &str, at: (f64, f64)) -> TimelineRow {
|
||
TimelineRow {
|
||
name: Some(name.into()),
|
||
location: Some(at),
|
||
resolved_at: Some(1),
|
||
..TimelineRow::new(uid, capture_time)
|
||
}
|
||
}
|
||
|
||
const BERLIN: (f64, f64) = (52.52, 13.405);
|
||
const PARIS: (f64, f64) = (48.857, 2.352);
|
||
|
||
/// Places count shots, not files, lead with the most photographed town, and
|
||
/// leave out photos with no location or taken far from any town.
|
||
#[test]
|
||
fn places_group_photos_by_the_nearest_town() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
located("b2", 500, "IMG_5.JPG", BERLIN),
|
||
located("p1", 400, "IMG_4.JPG", PARIS),
|
||
located("b1-jpeg", 300, "IMG_3.JPG", BERLIN),
|
||
located("b1-raw", 300, "IMG_3.CR2", BERLIN),
|
||
located("sea", 200, "IMG_2.JPG", (0.0, -30.0)),
|
||
TimelineRow::new("nowhere", 100),
|
||
])
|
||
.unwrap();
|
||
|
||
let places = db.photo_places().unwrap();
|
||
let summary: Vec<(&str, usize, &str)> = places
|
||
.iter()
|
||
.map(|place| {
|
||
let city = crate::places::city(place.id).expect("a known town");
|
||
(city.name, place.count, place.cover.uid.as_str())
|
||
})
|
||
.collect();
|
||
assert_eq!(summary, [("Berlin", 2, "b2"), ("Paris", 1, "p1")]);
|
||
|
||
let berlin: Vec<String> = db
|
||
.photos_at_place(places[0].id, 0, 10)
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|p| p.uid)
|
||
.collect();
|
||
assert_eq!(berlin, ["b2", "b1-jpeg"]);
|
||
}
|
||
|
||
/// A town's photos come with where each was taken, one per RAW+JPEG shot.
|
||
#[test]
|
||
fn place_photos_carry_their_own_location() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let kreuzberg = (52.497, 13.411);
|
||
db.photos_replace(&[
|
||
located("b2", 500, "IMG_5.JPG", BERLIN),
|
||
located("b1-jpeg", 300, "IMG_3.JPG", kreuzberg),
|
||
located("b1-raw", 300, "IMG_3.CR2", kreuzberg),
|
||
])
|
||
.unwrap();
|
||
let id = db.photo_places().unwrap()[0].id;
|
||
let located: Vec<(String, (f64, f64))> = db
|
||
.photo_locations_at_place(id)
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|(photo, at)| (photo.uid, at))
|
||
.collect();
|
||
assert_eq!(
|
||
located,
|
||
[("b2".to_owned(), BERLIN), ("b1-jpeg".to_owned(), kreuzberg)]
|
||
);
|
||
}
|
||
|
||
/// A refresh that skips an already-resolved photo keeps where it was taken; one
|
||
/// that reads the node again takes what the node now says.
|
||
#[test]
|
||
fn a_skipped_refresh_keeps_a_photos_location() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[located("a", 100, "IMG_1.JPG", BERLIN)])
|
||
.unwrap();
|
||
db.photos_replace(&[TimelineRow::new("a", 100)]).unwrap();
|
||
assert_eq!(db.photo_places().unwrap().len(), 1);
|
||
|
||
db.photos_replace(&[TimelineRow {
|
||
resolved_at: Some(2),
|
||
..TimelineRow::new("a", 100)
|
||
}])
|
||
.unwrap();
|
||
assert!(db.photo_places().unwrap().is_empty());
|
||
}
|
||
|
||
/// A copy an album holds is the one kept; the files of one RAW+JPEG shot are
|
||
/// never copies of each other; a hash only one file carries is no set.
|
||
#[test]
|
||
fn duplicates_list_the_copy_to_keep_first() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
hashed("copy", 500, "IMG_2.JPG", "h2"),
|
||
hashed("filed", 400, "IMG_2(1).JPG", "h2"),
|
||
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
|
||
hashed("raw", 300, "IMG_1.CR2", "h1"),
|
||
hashed("stray", 200, "IMG_1(1).CR2", "h1"),
|
||
hashed("alone", 100, "IMG_3.JPG", "h3"),
|
||
])
|
||
.unwrap();
|
||
db.album_photos_replace("a1", &[("filed".into(), 400, None, None)])
|
||
.unwrap();
|
||
|
||
let sets = db.photo_duplicates().unwrap();
|
||
let uids: Vec<Vec<&str>> = sets
|
||
.iter()
|
||
.map(|set| set.iter().map(|p| p.uid.as_str()).collect())
|
||
.collect();
|
||
assert_eq!(uids, [vec!["filed", "copy"], vec!["jpeg", "stray"]]);
|
||
}
|
||
|
||
/// Shots that look alike form a set, one photo per shot, while a set of
|
||
/// byte-identical copies is left to the duplicate finder.
|
||
#[test]
|
||
fn similar_photos_group_by_look_and_skip_exact_copies() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
hashed("big", 500, "IMG_1.JPG", "h1"),
|
||
hashed("small", 400, "IMG_1-small.JPG", "h2"),
|
||
hashed("copy", 300, "IMG_2.JPG", "h3"),
|
||
hashed("copy2", 200, "IMG_2(1).JPG", "h3"),
|
||
hashed("other", 100, "IMG_3.JPG", "h4"),
|
||
hashed("flat", 50, "IMG_4.JPG", "h5"),
|
||
])
|
||
.unwrap();
|
||
let look = 0x0f0f_3c3c_5a5a_9696_u64;
|
||
db.photo_set_similar_hash("big", look).unwrap();
|
||
db.photo_set_similar_hash("small", look ^ 0b101).unwrap();
|
||
db.photo_set_similar_hash("copy", !look).unwrap();
|
||
db.photo_set_similar_hash("copy2", !look).unwrap();
|
||
db.photo_set_similar_hash("other", look ^ 0xffff_ffff)
|
||
.unwrap();
|
||
db.photo_set_similar_hash("flat", crate::similar::NO_HASH)
|
||
.unwrap();
|
||
|
||
let sets = db.photo_similar().unwrap();
|
||
let uids: Vec<Vec<&str>> = sets
|
||
.iter()
|
||
.map(|set| set.iter().map(|p| p.uid.as_str()).collect())
|
||
.collect();
|
||
assert_eq!(uids, [vec!["small", "big"]]);
|
||
assert_eq!(db.similar_progress().unwrap(), (6, 6));
|
||
}
|
||
|
||
/// What is left to hash skips videos and hashed shots, and a hash survives a
|
||
/// refresh unless the photo's bytes changed.
|
||
#[test]
|
||
fn similar_hashes_are_kept_until_the_photo_changes() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let resolved = |uid: &str, hash: &str| TimelineRow {
|
||
resolved_at: Some(1),
|
||
..hashed(uid, 100, &format!("{uid}.JPG"), hash)
|
||
};
|
||
db.photos_replace(&[
|
||
resolved("a", "h1"),
|
||
resolved("b", "h2"),
|
||
hashed("clip", 50, "clip.mp4", "h3"),
|
||
])
|
||
.unwrap();
|
||
db.photo_set_similar_hash("a", 7).unwrap();
|
||
db.photo_set_similar_hash("b", 9).unwrap();
|
||
let left = |db: &Db| -> Vec<String> {
|
||
db.photos_unhashed(10)
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|p| p.uid)
|
||
.collect()
|
||
};
|
||
assert!(left(&db).is_empty());
|
||
assert_eq!(db.similar_progress().unwrap(), (2, 2));
|
||
|
||
db.photos_replace(&[
|
||
resolved("a", "h1"),
|
||
resolved("b", "h2-edited"),
|
||
hashed("clip", 50, "clip.mp4", "h3"),
|
||
])
|
||
.unwrap();
|
||
assert_eq!(left(&db), ["b"]);
|
||
assert_eq!(db.similar_progress().unwrap(), (1, 2));
|
||
}
|
||
|
||
/// Trashing the file a group is shown as leaves the rest of the group on the
|
||
/// timeline instead of pointing at a photo that is gone.
|
||
#[test]
|
||
fn deleting_a_group_representative_keeps_its_other_files() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
|
||
hashed("raw", 300, "IMG_1.CR2", "h2"),
|
||
])
|
||
.unwrap();
|
||
assert_eq!(
|
||
db.photos_page(0, 10, None, None, false, false)
|
||
.unwrap()
|
||
.len(),
|
||
1
|
||
);
|
||
|
||
db.photos_delete(&["jpeg".into()]).unwrap();
|
||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||
assert_eq!(
|
||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["raw"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn not_in_album_hides_photos_any_album_holds() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow::new("p1", 300),
|
||
TimelineRow::new("p2", 200),
|
||
TimelineRow::new("p3", 100),
|
||
])
|
||
.unwrap();
|
||
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
|
||
.unwrap();
|
||
db.album_photos_replace("a2", &[("p3".into(), 100, None, None)])
|
||
.unwrap();
|
||
|
||
let page = db.photos_page(0, 10, None, None, false, true).unwrap();
|
||
assert_eq!(
|
||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["p2"]
|
||
);
|
||
}
|
||
|
||
/// Filing the JPEG of a RAW+JPEG shot files the shot: the grid shows it as
|
||
/// one tile, and that tile is in an album.
|
||
#[test]
|
||
fn not_in_album_counts_a_raw_pair_as_filed() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("IMG_1234.JPG".into()),
|
||
..TimelineRow::new("jpeg", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("img_1234.cr2".into()),
|
||
..TimelineRow::new("raw", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("IMG_9999.JPG".into()),
|
||
..TimelineRow::new("alone", 200)
|
||
},
|
||
])
|
||
.unwrap();
|
||
db.album_photos_replace("a1", &[("raw".into(), 300, None, None)])
|
||
.unwrap();
|
||
|
||
let page = db.photos_page(0, 10, None, None, false, true).unwrap();
|
||
assert_eq!(
|
||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["alone"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn photos_page_slices_the_timeline_in_order() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let items: Vec<_> = (0..5)
|
||
.map(|i| TimelineRow::new(format!("p{i}"), 500 - i as i64))
|
||
.collect();
|
||
db.photos_replace(&items).unwrap();
|
||
|
||
let page = db.photos_page(2, 2, None, None, false, false).unwrap();
|
||
assert_eq!(
|
||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["p2", "p3"]
|
||
);
|
||
assert!(
|
||
db.photos_page(9, 2, None, None, false, false)
|
||
.unwrap()
|
||
.is_empty()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn trash_replace_lists_folders_first() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.trash_replace(&[
|
||
StoredTrash {
|
||
uid: "t1".into(),
|
||
name: "zeta.txt".into(),
|
||
is_dir: false,
|
||
size: 10,
|
||
mtime: 1,
|
||
parent_uid: Some("t2".into()),
|
||
trashed_at: Some(100),
|
||
},
|
||
StoredTrash {
|
||
uid: "t2".into(),
|
||
name: "Alpha".into(),
|
||
is_dir: true,
|
||
size: 0,
|
||
mtime: 2,
|
||
parent_uid: None,
|
||
trashed_at: None,
|
||
},
|
||
])
|
||
.unwrap();
|
||
|
||
let items = db.trash_list().unwrap();
|
||
assert_eq!(
|
||
items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(),
|
||
["Alpha", "zeta.txt"]
|
||
);
|
||
|
||
// The parent links come back with the rows: a restore reads the tree shape
|
||
// from them.
|
||
let mut parents = db.trash_parents().unwrap();
|
||
parents.sort();
|
||
assert_eq!(
|
||
parents,
|
||
[
|
||
("t1".to_string(), Some("t2".to_string())),
|
||
("t2".to_string(), None),
|
||
]
|
||
);
|
||
|
||
// The first-seen times round-trip, unknown ones included.
|
||
let mut seen: Vec<_> = db.trash_seen().unwrap().into_iter().collect();
|
||
seen.sort();
|
||
assert_eq!(
|
||
seen,
|
||
[("t1".to_string(), Some(100)), ("t2".to_string(), None)]
|
||
);
|
||
assert_eq!(items[1].trashed_at, Some(100));
|
||
|
||
// A replace is a replace: emptying the trash on the server empties it here.
|
||
db.trash_replace(&[]).unwrap();
|
||
assert!(db.trash_list().unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn state_stamps_round_trip_and_clear() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
assert_eq!(db.state_i64("photos_synced_ms").unwrap(), None);
|
||
db.set_state_i64("photos_synced_ms", 1234).unwrap();
|
||
assert_eq!(db.state_i64("photos_synced_ms").unwrap(), Some(1234));
|
||
db.clear_state("photos_synced_ms").unwrap();
|
||
assert_eq!(
|
||
db.state_i64("photos_synced_ms").unwrap(),
|
||
None,
|
||
"a cleared stamp reads as never fetched, so the next request blocks on a refresh"
|
||
);
|
||
}
|
||
|
||
fn folder(link: &str, parent: Option<&str>, name: &str) -> Node {
|
||
let parent = match parent {
|
||
Some(p) => serde_json::json!({"volume_id": "vol", "link_id": p}),
|
||
None => serde_json::Value::Null,
|
||
};
|
||
node_from(parent, link, name, serde_json::json!("Folder"))
|
||
}
|
||
|
||
fn file(link: &str, parent: &str, name: &str, size: i64) -> Node {
|
||
let kind = serde_json::json!({
|
||
"File": {
|
||
"media_type": "text/plain",
|
||
"total_size_on_storage": size + 10,
|
||
"claimed_size": size,
|
||
"claimed_modification_time": null,
|
||
}
|
||
});
|
||
node_from(
|
||
serde_json::json!({"volume_id": "vol", "link_id": parent}),
|
||
link,
|
||
name,
|
||
kind,
|
||
)
|
||
}
|
||
|
||
fn foreign_folder(volume: &str, link: &str, parent: NodeUid, name: &str) -> Node {
|
||
let mut node = folder(link, None, name);
|
||
node.uid = NodeUid::new(VolumeId::from(volume), LinkId::from(link));
|
||
node.parent_uid = Some(parent);
|
||
node
|
||
}
|
||
|
||
/// Recovering the root by uid is what lets the daemon mount offline:
|
||
/// the uid is remembered in `sync_state`, the node
|
||
/// itself comes back out of `nodes`.
|
||
#[test]
|
||
fn node_by_uid_recovers_a_stored_node() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = folder("root", None, "My Files");
|
||
db.upsert_node(&root).unwrap();
|
||
db.set_state_str("root_uid", &root.uid.to_string()).unwrap();
|
||
|
||
let key = db.state_str("root_uid").unwrap().unwrap();
|
||
let got = db.node_by_uid(&key).unwrap().expect("root recovered");
|
||
assert_eq!(got.uid, root.uid);
|
||
assert_eq!(got.name, "My Files");
|
||
assert!(got.is_folder());
|
||
|
||
assert!(db.node_by_uid("vol~nope").unwrap().is_none());
|
||
assert!(db.state_str("never_written").unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn upsert_and_load_all_roundtrip() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = folder("root", None, "My Files");
|
||
let child = file("f1", "root", "hello.txt", 42);
|
||
db.upsert_node(&root).unwrap();
|
||
db.upsert_node(&child).unwrap();
|
||
|
||
let loaded = db.load_all().unwrap();
|
||
assert_eq!(loaded.len(), 2);
|
||
let f = loaded.iter().find(|s| s.node.uid == uid("f1")).unwrap();
|
||
assert_eq!(f.node.name, "hello.txt");
|
||
assert!(!f.listed);
|
||
match &f.node.kind {
|
||
NodeKind::File { claimed_size, .. } => assert_eq!(*claimed_size, Some(42)),
|
||
_ => panic!("expected file"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn upsert_nodes_and_load_all_roundtrip() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = folder("root", None, "My Files");
|
||
let child1 = file("f1", "root", "hello.txt", 42);
|
||
let child2 = file("f2", "root", "world.txt", 100);
|
||
db.upsert_nodes(&[root, child1, child2]).unwrap();
|
||
|
||
let loaded = db.load_all().unwrap();
|
||
assert_eq!(loaded.len(), 3);
|
||
let f1 = loaded.iter().find(|s| s.node.uid == uid("f1")).unwrap();
|
||
assert_eq!(f1.node.name, "hello.txt");
|
||
let f2 = loaded.iter().find(|s| s.node.uid == uid("f2")).unwrap();
|
||
assert_eq!(f2.node.name, "world.txt");
|
||
}
|
||
|
||
#[test]
|
||
fn upsert_is_idempotent_update() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let mut n = folder("root", None, "My Files");
|
||
db.upsert_node(&n).unwrap();
|
||
n.name = "Renamed".into();
|
||
db.upsert_node(&n).unwrap();
|
||
let loaded = db.load_all().unwrap();
|
||
assert_eq!(loaded.len(), 1);
|
||
assert_eq!(loaded[0].node.name, "Renamed");
|
||
}
|
||
|
||
#[test]
|
||
fn delete_node_removes_row() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let n = folder("root", None, "My Files");
|
||
db.upsert_node(&n).unwrap();
|
||
db.delete_node(&uid("root")).unwrap();
|
||
assert!(db.load_all().unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn completing_trash_atomically_removes_the_op_and_retained_node() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let node = file("trash-me", "parent", "trash-me.txt", 4);
|
||
let uid = node.uid.clone();
|
||
db.upsert_node(&node).unwrap();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_TRASH.to_string(),
|
||
uid: uid.to_string(),
|
||
parent_uid: None,
|
||
name: Some(node.name.clone()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
let op = db.pending_ops().unwrap().remove(0);
|
||
|
||
db.complete_trash_op(op.id, &uid).unwrap();
|
||
|
||
assert!(db.node_by_uid(&uid.to_string()).unwrap().is_none());
|
||
assert!(db.pending_ops().unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn children_if_listed_gated_on_flag() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&file("f1", "root", "a.txt", 1)).unwrap();
|
||
db.upsert_node(&file("f2", "root", "b.txt", 2)).unwrap();
|
||
|
||
// Not listed yet → unknown, force a re-fetch.
|
||
assert!(db.children_if_listed(&uid("root")).unwrap().is_none());
|
||
// Still known, for a caller that has nothing better while offline.
|
||
assert_eq!(db.known_children(&uid("root")).unwrap().len(), 2);
|
||
|
||
db.set_listed(&uid("root"), true).unwrap();
|
||
let kids = db.children_if_listed(&uid("root")).unwrap().unwrap();
|
||
assert_eq!(kids.len(), 2);
|
||
}
|
||
|
||
/// The shape of the `mv`-loses-the-file bug (`docs/BUGS.md` B1): a rename deletes the
|
||
/// moved node's row, so a destination left marked `listed` is rebuilt from the DB
|
||
/// without it — the file is gone from the source and absent from the destination,
|
||
/// with `rename(2)` having reported success. Clearing the flag is what forces the
|
||
/// re-enumeration that finds it again.
|
||
#[test]
|
||
fn a_deleted_child_leaves_a_listed_parent_serving_a_stale_listing() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("dst", Some("root"), "dest"))
|
||
.unwrap();
|
||
db.upsert_node(&file("f1", "root", "a.txt", 1)).unwrap();
|
||
db.set_listed(&uid("dst"), true).unwrap();
|
||
|
||
// The rename moved a.txt into `dst` server-side, then forgot the node.
|
||
db.delete_node(&uid("f1")).unwrap();
|
||
|
||
// `dst` is still flagged listed, so the DB fast path answers — and the file
|
||
// it was just moved into is nowhere in the result.
|
||
assert!(
|
||
db.children_if_listed(&uid("dst"))
|
||
.unwrap()
|
||
.unwrap()
|
||
.is_empty()
|
||
);
|
||
|
||
// Clearing the flag is the only thing that sends the next read to the server.
|
||
db.set_listed(&uid("dst"), false).unwrap();
|
||
assert!(db.children_if_listed(&uid("dst")).unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn children_if_listed_excludes_trashed() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let mut trashed = file("f1", "root", "a.txt", 1);
|
||
trashed.trashed = true;
|
||
db.upsert_node(&trashed).unwrap();
|
||
db.set_listed(&uid("root"), true).unwrap();
|
||
assert_eq!(
|
||
db.children_if_listed(&uid("root")).unwrap().unwrap().len(),
|
||
0
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn shared_root_replacement_tombstones_without_losing_work_or_authority() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let own_root = folder("root", None, "My Files");
|
||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||
let virtual_root = foreign_folder(
|
||
"virtual",
|
||
"sharedwithme",
|
||
own_root.uid.clone(),
|
||
"Shared with me",
|
||
);
|
||
let shared = foreign_folder("foreign", "share", virtual_uid.clone(), "Project");
|
||
let child = foreign_folder("foreign", "child", shared.uid.clone(), "Secret");
|
||
db.upsert_nodes(&[own_root, virtual_root, shared.clone(), child.clone()])
|
||
.unwrap();
|
||
db.set_share_access(&shared.uid, crate::Access::Editor)
|
||
.unwrap();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: child.uid.to_string(),
|
||
parent_uid: Some(shared.uid.to_string()),
|
||
name: Some(child.name.clone()),
|
||
blob_path: Some("/staging/shared-edit".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
assert_eq!(db.search("Secret", 10).unwrap().len(), 1);
|
||
|
||
let removed = db.publish_shared_roots(&virtual_uid, &[], &[]).unwrap();
|
||
assert_eq!(removed, vec![shared.uid.clone()]);
|
||
assert!(db.visible_children(&virtual_uid).unwrap().is_empty());
|
||
assert!(
|
||
db.node_by_uid(&shared.uid.to_string())
|
||
.unwrap()
|
||
.unwrap()
|
||
.trashed
|
||
);
|
||
assert!(
|
||
db.node_by_uid(&child.uid.to_string())
|
||
.unwrap()
|
||
.unwrap()
|
||
.trashed
|
||
);
|
||
assert!(db.search("Secret", 10).unwrap().is_empty());
|
||
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||
assert_eq!(
|
||
db.share_access(&shared.uid).unwrap(),
|
||
Some(crate::Access::Viewer)
|
||
);
|
||
|
||
let mut reappeared = shared.clone();
|
||
reappeared.trashed = false;
|
||
assert!(
|
||
db.publish_shared_roots(
|
||
&virtual_uid,
|
||
std::slice::from_ref(&shared.uid),
|
||
&[PublishedSharedRoot {
|
||
node: reappeared,
|
||
access: crate::Access::Viewer,
|
||
}],
|
||
)
|
||
.unwrap()
|
||
.is_empty()
|
||
);
|
||
let restored = db.visible_children(&virtual_uid).unwrap();
|
||
assert_eq!(restored.len(), 1);
|
||
assert_eq!(restored[0].parent_uid.as_ref(), Some(&virtual_uid));
|
||
assert!(!restored[0].trashed);
|
||
assert!(
|
||
db.node_by_uid(&child.uid.to_string())
|
||
.unwrap()
|
||
.unwrap()
|
||
.trashed,
|
||
"descendants remain hidden until their folder is re-enumerated"
|
||
);
|
||
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||
assert_eq!(
|
||
db.share_access(&shared.uid).unwrap(),
|
||
Some(crate::Access::Viewer),
|
||
"reappearance stays fail-closed until an observed role refresh"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn shared_root_node_and_authority_publication_roll_back_together() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let own_root = folder("root", None, "My Files");
|
||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||
let virtual_root = foreign_folder(
|
||
"virtual",
|
||
"sharedwithme",
|
||
own_root.uid.clone(),
|
||
"Shared with me",
|
||
);
|
||
let shared = foreign_folder("foreign", "share", virtual_uid.clone(), "Before");
|
||
db.upsert_nodes(&[own_root, virtual_root, shared.clone()])
|
||
.unwrap();
|
||
db.set_share_access(&shared.uid, crate::Access::Viewer)
|
||
.unwrap();
|
||
db.set_listed(&virtual_uid, false).unwrap();
|
||
db.with_conn(|conn| {
|
||
conn.execute_batch(
|
||
"CREATE TRIGGER reject_shared_authority
|
||
BEFORE UPDATE ON share_access
|
||
WHEN NEW.root_uid = 'foreign~share'
|
||
BEGIN SELECT RAISE(ABORT, 'injected authority failure'); END;",
|
||
)?;
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
|
||
let mut changed = shared.clone();
|
||
changed.name = "After".into();
|
||
let result = db.publish_shared_roots(
|
||
&virtual_uid,
|
||
std::slice::from_ref(&shared.uid),
|
||
&[PublishedSharedRoot {
|
||
node: changed,
|
||
access: crate::Access::Editor,
|
||
}],
|
||
);
|
||
assert!(result.is_err());
|
||
assert_eq!(
|
||
db.node_by_uid(&shared.uid.to_string())
|
||
.unwrap()
|
||
.unwrap()
|
||
.name,
|
||
"Before"
|
||
);
|
||
assert_eq!(
|
||
db.share_access(&shared.uid).unwrap(),
|
||
Some(crate::Access::Viewer)
|
||
);
|
||
assert!(db.children_if_listed(&virtual_uid).unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn accepted_but_unmaterialized_shared_root_keeps_its_snapshot() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let own_root = folder("root", None, "My Files");
|
||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||
let virtual_root = foreign_folder(
|
||
"virtual",
|
||
"sharedwithme",
|
||
own_root.uid.clone(),
|
||
"Shared with me",
|
||
);
|
||
let shared = foreign_folder("foreign", "share", virtual_uid.clone(), "Retained");
|
||
db.upsert_nodes(&[own_root, virtual_root, shared.clone()])
|
||
.unwrap();
|
||
db.set_share_access(&shared.uid, crate::Access::Editor)
|
||
.unwrap();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_RENAME.to_string(),
|
||
uid: shared.uid.to_string(),
|
||
parent_uid: Some(virtual_uid.to_string()),
|
||
name: Some("Retained locally".to_string()),
|
||
blob_path: None,
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
|
||
let removed = db
|
||
.publish_shared_roots(&virtual_uid, std::slice::from_ref(&shared.uid), &[])
|
||
.unwrap();
|
||
assert!(removed.is_empty());
|
||
assert_eq!(db.visible_children(&virtual_uid).unwrap().len(), 1);
|
||
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||
assert_eq!(
|
||
db.share_access(&shared.uid).unwrap(),
|
||
Some(crate::Access::Viewer),
|
||
"an accepted root without verified materialized membership fails closed"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn foreign_listing_reconciles_authoritative_uids_and_survives_restart_shape() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let own_root = folder("root", None, "My Files");
|
||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||
let virtual_root = foreign_folder(
|
||
"virtual",
|
||
"sharedwithme",
|
||
own_root.uid.clone(),
|
||
"Shared with me",
|
||
);
|
||
let shared = foreign_folder("foreign", "share", virtual_uid, "Shared");
|
||
let kept = foreign_folder("foreign", "kept", shared.uid.clone(), "Kept");
|
||
let removed = foreign_folder("foreign", "removed", shared.uid.clone(), "Removed");
|
||
let deep = foreign_folder("foreign", "deep", removed.uid.clone(), "Deep secret");
|
||
db.upsert_nodes(&[
|
||
own_root,
|
||
virtual_root,
|
||
shared.clone(),
|
||
kept.clone(),
|
||
removed.clone(),
|
||
deep.clone(),
|
||
])
|
||
.unwrap();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: deep.uid.to_string(),
|
||
parent_uid: Some(removed.uid.to_string()),
|
||
name: Some(deep.name.clone()),
|
||
blob_path: Some("/staging/foreign".into()),
|
||
meta_json: Some("{}".into()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
|
||
let removed_uids = db
|
||
.publish_foreign_children(&shared.uid, std::slice::from_ref(&kept.uid), &[])
|
||
.unwrap();
|
||
assert_eq!(removed_uids, vec![removed.uid.clone()]);
|
||
let snapshot = db.visible_children(&shared.uid).unwrap();
|
||
assert_eq!(snapshot.len(), 1);
|
||
assert_eq!(snapshot[0].uid, kept.uid);
|
||
assert!(
|
||
db.node_by_uid(&deep.uid.to_string())
|
||
.unwrap()
|
||
.unwrap()
|
||
.trashed
|
||
);
|
||
assert!(db.search("secret", 10).unwrap().is_empty());
|
||
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||
assert_eq!(
|
||
db.children_if_listed(&shared.uid).unwrap().unwrap().len(),
|
||
1,
|
||
"restart hydration sees the completed authoritative snapshot"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn foreign_delete_tombstones_the_subtree_without_losing_pending_work() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let own_root = folder("root", None, "My Files");
|
||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||
let virtual_root = foreign_folder(
|
||
"virtual",
|
||
"sharedwithme",
|
||
own_root.uid.clone(),
|
||
"Shared with me",
|
||
);
|
||
let shared = foreign_folder("foreign", "share", virtual_uid, "Shared");
|
||
let deleted = foreign_folder("foreign", "deleted", shared.uid.clone(), "Deleted");
|
||
let deep = foreign_folder("foreign", "deep", deleted.uid.clone(), "Searchable deep");
|
||
db.upsert_nodes(&[
|
||
own_root,
|
||
virtual_root,
|
||
shared,
|
||
deleted.clone(),
|
||
deep.clone(),
|
||
])
|
||
.unwrap();
|
||
db.set_share_access(&deleted.uid, crate::Access::Editor)
|
||
.unwrap();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: deep.uid.to_string(),
|
||
parent_uid: Some(deleted.uid.to_string()),
|
||
name: Some(deep.name.clone()),
|
||
blob_path: Some("/staging/deleted-foreign".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
assert_eq!(db.search("Searchable", 10).unwrap().len(), 1);
|
||
|
||
db.tombstone_foreign_subtree(&deleted.uid).unwrap();
|
||
|
||
for uid in [&deleted.uid, &deep.uid] {
|
||
assert!(db.node_by_uid(&uid.to_string()).unwrap().unwrap().trashed);
|
||
}
|
||
assert!(db.search("Searchable", 10).unwrap().is_empty());
|
||
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||
assert_eq!(
|
||
db.share_access(&deleted.uid).unwrap(),
|
||
Some(crate::Access::Viewer)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn hidden_virtual_root_removes_descendants_from_fts_and_restores_them() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let own_root = folder("root", None, "My Files");
|
||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||
let mut virtual_root = foreign_folder(
|
||
"virtual",
|
||
"sharedwithme",
|
||
own_root.uid.clone(),
|
||
"Shared with me",
|
||
);
|
||
let shared = foreign_folder("foreign", "share", virtual_uid, "Shared");
|
||
let child = foreign_folder(
|
||
"foreign",
|
||
"child",
|
||
shared.uid.clone(),
|
||
"Findable descendant",
|
||
);
|
||
db.upsert_node(&own_root).unwrap();
|
||
assert!(
|
||
db.publish_virtual_root("shared_with_me_name", &virtual_root)
|
||
.unwrap()
|
||
);
|
||
db.upsert_nodes(&[shared, child.clone()]).unwrap();
|
||
assert_eq!(db.search("Findable", 10).unwrap().len(), 1);
|
||
|
||
virtual_root.trashed = true;
|
||
assert!(
|
||
db.publish_virtual_root("shared_with_me_name", &virtual_root)
|
||
.unwrap()
|
||
);
|
||
assert!(db.search("Findable", 10).unwrap().is_empty());
|
||
assert!(
|
||
!db.node_by_uid(&child.uid.to_string())
|
||
.unwrap()
|
||
.unwrap()
|
||
.trashed,
|
||
"valid descendants are hidden by ancestry, not revoked"
|
||
);
|
||
|
||
virtual_root.trashed = false;
|
||
assert!(
|
||
db.publish_virtual_root("shared_with_me_name", &virtual_root)
|
||
.unwrap()
|
||
);
|
||
assert_eq!(db.search("Findable", 10).unwrap().len(), 1);
|
||
let before = db.with_conn(|conn| Ok(conn.total_changes())).unwrap();
|
||
assert!(
|
||
!db.publish_virtual_root("shared_with_me_name", &virtual_root)
|
||
.unwrap()
|
||
);
|
||
let after = db.with_conn(|conn| Ok(conn.total_changes())).unwrap();
|
||
assert_eq!(
|
||
before, after,
|
||
"unchanged root lookup performs no DB/FTS writes"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn shared_root_pinned_name_survives_database_reopen() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-shared-name-{}-{}.db",
|
||
std::process::id(),
|
||
now_test_id()
|
||
));
|
||
{
|
||
let db = Db::open(&path).unwrap();
|
||
db.set_state_str("shared_with_me_name", "Shared with me (Proton 4)")
|
||
.unwrap();
|
||
}
|
||
let reopened = Db::open(&path).unwrap();
|
||
assert_eq!(
|
||
reopened
|
||
.state_str("shared_with_me_name")
|
||
.unwrap()
|
||
.as_deref(),
|
||
Some("Shared with me (Proton 4)")
|
||
);
|
||
drop(reopened);
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
|
||
/// Two writers on one database is the failure mode the single-instance lock
|
||
/// exists to prevent — a hand-run `pdfs mount` next to the systemd unit gives
|
||
/// two daemons the same inode space, content cache, and drain queue. The second
|
||
/// open has to fail, and it has to stop failing once the first handle is gone.
|
||
#[test]
|
||
fn a_second_writer_is_refused_while_the_first_holds_the_database() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-single-writer-{}-{}.db",
|
||
std::process::id(),
|
||
now_test_id()
|
||
));
|
||
let first = Db::open(&path).unwrap();
|
||
let second = Db::open(&path);
|
||
assert!(second.is_err(), "a second writer was allowed in");
|
||
|
||
drop(first);
|
||
// The lock lives in the kernel and dies with the descriptor, so nothing has
|
||
// to be cleaned up for the next daemon to start.
|
||
Db::open(&path).expect("reopen after the first handle is dropped");
|
||
|
||
let _ = std::fs::remove_file(&path);
|
||
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||
}
|
||
|
||
/// `cache.db` is derived state — every row can be re-fetched or re-scanned — so
|
||
/// a file that is not a database at all must not stop the daemon starting. It
|
||
/// is moved aside (not deleted: it is the only evidence) and rebuilt.
|
||
#[test]
|
||
fn a_corrupt_database_is_moved_aside_and_rebuilt() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-corrupt-{}-{}.db",
|
||
std::process::id(),
|
||
now_test_id()
|
||
));
|
||
std::fs::write(&path, b"this is not an SQLite database").unwrap();
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
db.set_state_str("shared_with_me_name", "rebuilt").unwrap();
|
||
assert_eq!(
|
||
db.state_str("shared_with_me_name").unwrap().as_deref(),
|
||
Some("rebuilt")
|
||
);
|
||
drop(db);
|
||
|
||
let dir = path.parent().unwrap();
|
||
let stem = path.file_name().unwrap().to_string_lossy().to_string();
|
||
let quarantined: Vec<_> = std::fs::read_dir(dir)
|
||
.unwrap()
|
||
.filter_map(|e| e.ok())
|
||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||
.filter(|name| name.starts_with(&format!("{stem}.corrupt-")))
|
||
.collect();
|
||
assert_eq!(quarantined.len(), 1, "the damaged file was not kept");
|
||
|
||
for name in quarantined {
|
||
let _ = std::fs::remove_file(dir.join(name));
|
||
}
|
||
let _ = std::fs::remove_file(&path);
|
||
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||
}
|
||
|
||
fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry {
|
||
LocalEntry {
|
||
path: path.into(),
|
||
name: name.into(),
|
||
is_dir,
|
||
size: 10,
|
||
mtime: 5,
|
||
}
|
||
}
|
||
|
||
/// A local scan is searchable by substring, and a *later* scan prunes the
|
||
/// paths it no longer sees — including out of the FTS index, so a deleted
|
||
/// file cannot keep surfacing in the prompt.
|
||
#[test]
|
||
fn local_scan_indexes_then_prunes_stale_paths() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
|
||
let gen1 = db.local_begin_scan().unwrap();
|
||
db.local_upsert_batch(
|
||
gen1,
|
||
&[
|
||
local("/home/u/docs/report.pdf", "report.pdf", false),
|
||
local("/home/u/docs/notes.md", "notes.md", false),
|
||
],
|
||
)
|
||
.unwrap();
|
||
assert_eq!(db.local_finish_scan(gen1, 1_000).unwrap(), 2);
|
||
|
||
// Trigram index gives substring (not just prefix) matches.
|
||
let hits = db.search_local("port", 10).unwrap();
|
||
assert_eq!(hits.len(), 1);
|
||
assert_eq!(hits[0].path, "/home/u/docs/report.pdf");
|
||
assert!(!hits[0].is_dir);
|
||
assert_eq!(db.local_indexed_at().unwrap(), Some(1_000));
|
||
|
||
// Second scan sees only notes.md → report.pdf is gone from disk.
|
||
let gen2 = db.local_begin_scan().unwrap();
|
||
db.local_upsert_batch(gen2, &[local("/home/u/docs/notes.md", "notes.md", false)])
|
||
.unwrap();
|
||
assert_eq!(db.local_finish_scan(gen2, 2_000).unwrap(), 1);
|
||
assert!(db.search_local("report", 10).unwrap().is_empty());
|
||
assert_eq!(db.search_local("notes", 10).unwrap().len(), 1);
|
||
}
|
||
|
||
/// The index is maintained incrementally rather than rebuilt at the end of a
|
||
/// scan, so the case that used to be covered for free — a file the scan sees
|
||
/// again — needs its own test: a re-scan must neither lose the entry nor
|
||
/// duplicate it.
|
||
#[test]
|
||
fn rescanning_an_unchanged_file_keeps_exactly_one_index_entry() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
for (generation, mtime) in [(1_000, 5), (2_000, 9)] {
|
||
let g = db.local_begin_scan().unwrap();
|
||
let mut e = local("/home/u/docs/report.pdf", "report.pdf", false);
|
||
e.mtime = mtime;
|
||
db.local_upsert_batch(g, &[e]).unwrap();
|
||
assert_eq!(db.local_finish_scan(g, generation).unwrap(), 1);
|
||
let hits = db.search_local("report", 10).unwrap();
|
||
assert_eq!(hits.len(), 1, "generation {generation}");
|
||
assert_eq!(hits[0].mtime, mtime);
|
||
}
|
||
}
|
||
|
||
/// Queries below the trigram minimum still match, via the `LIKE` fallback.
|
||
#[test]
|
||
fn local_search_short_query_like_fallback() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let g = db.local_begin_scan().unwrap();
|
||
db.local_upsert_batch(g, &[local("/home/u/a.txt", "a.txt", false)])
|
||
.unwrap();
|
||
db.local_finish_scan(g, 1).unwrap();
|
||
assert_eq!(db.search_local("a", 10).unwrap().len(), 1);
|
||
assert!(db.search_local("", 10).unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn event_cursor_roundtrip() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
// Absent before first write → seed from server head on first mount.
|
||
assert!(db.get_event_cursor().unwrap().is_none());
|
||
db.set_event_cursor("evt-1").unwrap();
|
||
assert_eq!(db.get_event_cursor().unwrap().as_deref(), Some("evt-1"));
|
||
// Overwrites, not appends.
|
||
db.set_event_cursor("evt-2").unwrap();
|
||
assert_eq!(db.get_event_cursor().unwrap().as_deref(), Some("evt-2"));
|
||
}
|
||
|
||
#[test]
|
||
fn search_trigram_substring_and_path() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("docs", Some("root"), "Documents"))
|
||
.unwrap();
|
||
db.upsert_node(&file("f1", "docs", "report.pdf", 1))
|
||
.unwrap();
|
||
db.upsert_node(&file("f2", "root", "notes.txt", 2)).unwrap();
|
||
db.upsert_node(&file(
|
||
"f3",
|
||
"root",
|
||
"Rampage Open Air 2026 - order 166765244.pdf",
|
||
3,
|
||
))
|
||
.unwrap();
|
||
|
||
// Substring match (trigram), not just prefix.
|
||
let hits = db.search("port", 10).unwrap();
|
||
assert_eq!(hits.len(), 1);
|
||
assert_eq!(hits[0].node.name, "report.pdf");
|
||
// Path is mountpoint-relative, root excluded.
|
||
assert_eq!(hits[0].path, "Documents/report.pdf");
|
||
|
||
// Top-level file → bare name.
|
||
let hits = db.search("notes", 10).unwrap();
|
||
assert_eq!(hits[0].path, "notes.txt");
|
||
|
||
// Multi-term FTS5 query matching (out of order, separated terms)
|
||
let hits = db.search("rampage 2026", 10).unwrap();
|
||
assert_eq!(hits.len(), 1);
|
||
assert_eq!(
|
||
hits[0].node.name,
|
||
"Rampage Open Air 2026 - order 166765244.pdf"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn search_in_scope_only_returns_hits_below_the_folder() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("work", Some("root"), "Work"))
|
||
.unwrap();
|
||
db.upsert_node(&folder("sub", Some("work"), "Old")).unwrap();
|
||
db.upsert_node(&folder("work2", Some("root"), "Work 2"))
|
||
.unwrap();
|
||
db.upsert_node(&file("f1", "work", "report.pdf", 1))
|
||
.unwrap();
|
||
db.upsert_node(&file("f2", "sub", "report-2019.pdf", 1))
|
||
.unwrap();
|
||
db.upsert_node(&file("f3", "work2", "report-copy.pdf", 1))
|
||
.unwrap();
|
||
db.upsert_node(&file("f4", "root", "report-top.pdf", 1))
|
||
.unwrap();
|
||
|
||
let mut paths: Vec<String> = db
|
||
.search_in("report", 10, Some("Work"))
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|h| h.path)
|
||
.collect();
|
||
paths.sort();
|
||
// A sibling whose name starts with the scope is not inside it.
|
||
assert_eq!(paths, vec!["Work/Old/report-2019.pdf", "Work/report.pdf"]);
|
||
// Short queries take the LIKE path and scope the same way.
|
||
assert_eq!(db.search_in("re", 10, Some("Work/Old")).unwrap().len(), 1);
|
||
// No scope, or the root, is everywhere.
|
||
assert_eq!(db.search_in("report", 10, None).unwrap().len(), 4);
|
||
assert_eq!(db.search_in("report", 10, Some("")).unwrap().len(), 4);
|
||
}
|
||
|
||
#[test]
|
||
fn search_excludes_trashed_and_respects_limit() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&file("f1", "root", "alpha.txt", 1)).unwrap();
|
||
let mut gone = file("f2", "root", "alphb.txt", 1);
|
||
gone.trashed = true;
|
||
db.upsert_node(&gone).unwrap();
|
||
|
||
let hits = db.search("alph", 10).unwrap();
|
||
assert_eq!(hits.len(), 1);
|
||
assert_eq!(hits[0].node.name, "alpha.txt");
|
||
|
||
db.upsert_node(&file("f3", "root", "alphc.txt", 1)).unwrap();
|
||
assert_eq!(db.search("alph", 1).unwrap().len(), 1);
|
||
}
|
||
|
||
/// A device folder's root is stored in `device`/`sync_folder`, never in
|
||
/// `nodes`, so its children's `parent_uid` points at a row that does not exist.
|
||
/// Requiring the ancestor walk to reach a `parent_uid IS NULL` row therefore
|
||
/// excluded every device-folder subtree from search: 2,705 of 9,252 nodes on
|
||
/// the account this was found on, including everything under `~/Downloads`.
|
||
#[test]
|
||
fn search_finds_nodes_below_an_uncached_parent() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
// "device-root" is deliberately never upserted.
|
||
db.upsert_node(&folder("downloads", Some("device-root"), "Downloads"))
|
||
.unwrap();
|
||
db.upsert_node(&file("t", "downloads", "tickets.pdf", 1))
|
||
.unwrap();
|
||
|
||
let hits = db.search("tickets", 10).unwrap();
|
||
assert_eq!(hits.len(), 1, "a device folder's files must be searchable");
|
||
assert_eq!(hits[0].path, "Downloads/tickets.pdf");
|
||
}
|
||
|
||
/// The relaxed rule must not also start indexing trash: a node whose *parent*
|
||
/// is trashed stays out even though the walk now terminates happily.
|
||
#[test]
|
||
fn search_excludes_a_node_under_a_trashed_ancestor() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let mut folder_gone = folder("boxed", Some("root"), "Boxed");
|
||
folder_gone.trashed = true;
|
||
db.upsert_node(&folder_gone).unwrap();
|
||
db.upsert_node(&file("f1", "boxed", "buried.txt", 1))
|
||
.unwrap();
|
||
|
||
assert!(db.search("buried", 10).unwrap().is_empty());
|
||
}
|
||
|
||
/// A parent cycle is corrupt data the API can still hand us. It must stay out
|
||
/// of the index, and — because the short-query `LIKE` lane reads `nodes`
|
||
/// directly and never consults the index — resolving one's path must terminate
|
||
/// rather than spin inside the daemon's only SQLite connection.
|
||
#[test]
|
||
fn a_parent_cycle_is_unindexed_and_never_hangs_a_search() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("a", Some("b"), "Alphaville"))
|
||
.unwrap();
|
||
db.upsert_node(&folder("b", Some("a"), "Betamax")).unwrap();
|
||
|
||
// Index lane: the cycle is not indexed, so it cannot be found.
|
||
assert!(db.search("alphaville", 10).unwrap().is_empty());
|
||
assert!(db.search("betamax", 10).unwrap().is_empty());
|
||
|
||
// LIKE lane (below the trigram minimum): the rows are reachable, and what
|
||
// matters is that resolving their paths returns at all.
|
||
let hits = db.search("al", 10).unwrap();
|
||
assert_eq!(hits.len(), 1);
|
||
assert!(hits[0].path.ends_with("Alphaville"));
|
||
}
|
||
|
||
#[test]
|
||
fn search_short_query_like_fallback() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&file("f1", "root", "ab.txt", 1)).unwrap();
|
||
// Under trigram min length → LIKE path still finds it.
|
||
let hits = db.search("ab", 10).unwrap();
|
||
assert_eq!(hits.len(), 1);
|
||
assert_eq!(hits[0].node.name, "ab.txt");
|
||
}
|
||
|
||
#[test]
|
||
fn fuzzy_candidates_include_typo_and_parent_path_matches() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("projects", Some("root"), "Projects"))
|
||
.unwrap();
|
||
db.upsert_node(&file("report", "projects", "quarterly-report.pdf", 1))
|
||
.unwrap();
|
||
db.upsert_node(&file("video", "projects", "video.mp4", 1))
|
||
.unwrap();
|
||
|
||
let typo = db.search_candidates("quartrly", 20).unwrap();
|
||
assert!(
|
||
typo.iter()
|
||
.any(|hit| hit.node.name == "quarterly-report.pdf")
|
||
);
|
||
assert!(
|
||
db.search_candidates("vedio", 20)
|
||
.unwrap()
|
||
.iter()
|
||
.any(|hit| hit.node.name == "video.mp4")
|
||
);
|
||
|
||
let path = db.search_candidates("projects", 20).unwrap();
|
||
assert!(
|
||
path.iter()
|
||
.any(|hit| hit.node.name == "quarterly-report.pdf")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn renaming_parent_refreshes_descendant_search_paths() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("folder", Some("root"), "zzbeforezz"))
|
||
.unwrap();
|
||
db.upsert_node(&file("child", "folder", "plain.txt", 1))
|
||
.unwrap();
|
||
assert!(
|
||
db.search_candidates("zzbeforezz", 20)
|
||
.unwrap()
|
||
.iter()
|
||
.any(|h| h.node.name == "plain.txt")
|
||
);
|
||
|
||
db.upsert_node(&folder("folder", Some("root"), "yyafteryy"))
|
||
.unwrap();
|
||
assert!(
|
||
!db.search_candidates("zzbeforezz", 20)
|
||
.unwrap()
|
||
.iter()
|
||
.any(|h| h.node.name == "plain.txt")
|
||
);
|
||
assert!(
|
||
db.search_candidates("yyafteryy", 20)
|
||
.unwrap()
|
||
.iter()
|
||
.any(|h| h.node.name == "plain.txt")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn local_fuzzy_candidates_include_typo_and_parent_path() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let generation = db.local_begin_scan().unwrap();
|
||
db.local_upsert_batch(
|
||
generation,
|
||
&[
|
||
local(
|
||
"/home/u/Projects/quarterly-report.pdf",
|
||
"quarterly-report.pdf",
|
||
false,
|
||
),
|
||
local("/home/u/Videos/video.mp4", "video.mp4", false),
|
||
],
|
||
)
|
||
.unwrap();
|
||
db.local_finish_scan(generation, 1).unwrap();
|
||
|
||
assert_eq!(db.search_local_candidates("quartrly", 20).unwrap().len(), 1);
|
||
assert_eq!(db.search_local_candidates("projects", 20).unwrap().len(), 1);
|
||
assert!(
|
||
db.search_local_candidates("vedio", 20)
|
||
.unwrap()
|
||
.iter()
|
||
.any(|hit| hit.name == "video.mp4")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn search_drops_fts_row_on_delete_and_trash() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&file("f1", "root", "unique.txt", 1))
|
||
.unwrap();
|
||
assert_eq!(db.search("unique", 10).unwrap().len(), 1);
|
||
|
||
// Re-upsert as trashed → leaves the index.
|
||
let mut t = file("f1", "root", "unique.txt", 1);
|
||
t.trashed = true;
|
||
db.upsert_node(&t).unwrap();
|
||
assert_eq!(db.search("unique", 10).unwrap().len(), 0);
|
||
|
||
// Resurrect, then hard-delete.
|
||
db.upsert_node(&file("f1", "root", "unique.txt", 1))
|
||
.unwrap();
|
||
assert_eq!(db.search("unique", 10).unwrap().len(), 1);
|
||
db.delete_node(&uid("f1")).unwrap();
|
||
assert_eq!(db.search("unique", 10).unwrap().len(), 0);
|
||
}
|
||
|
||
/// Renaming re-indexes the node, which is the case delete-by-rowid has to get
|
||
/// right: the *old* name must leave the index. A stale row here would be
|
||
/// invisible in every other test — the new name is findable either way.
|
||
#[test]
|
||
fn renaming_a_node_replaces_the_name_in_the_search_index() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&file("f1", "root", "beforename.txt", 1))
|
||
.unwrap();
|
||
assert_eq!(db.search("beforename", 10).unwrap().len(), 1);
|
||
|
||
db.upsert_node(&file("f1", "root", "aftername.txt", 1))
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
db.search("aftername", 10).unwrap().len(),
|
||
1,
|
||
"the new name must be searchable"
|
||
);
|
||
assert_eq!(
|
||
db.search("beforename", 10).unwrap().len(),
|
||
0,
|
||
"the old name must have left the index, not merely been added alongside"
|
||
);
|
||
}
|
||
|
||
/// B12: the cost of writing a listing must not scale with how much is already
|
||
/// indexed. The old `DELETE FROM nodes_fts WHERE uid = ?` scanned the whole
|
||
/// index once per node written, so a large account paid more to write the *same*
|
||
/// folder — measured at 6.5 ms per node against a 17k-node index on the live DB.
|
||
///
|
||
/// Written as a ratio between two index sizes rather than an absolute time, so
|
||
/// it fails on the scaling regression and not on a slow machine.
|
||
#[test]
|
||
fn writing_nodes_does_not_scale_with_the_size_of_the_index() {
|
||
use std::time::Instant;
|
||
|
||
/// Fill `db` with `n` indexed nodes, then time re-writing a fixed batch.
|
||
fn cost_against_index_of(n: usize) -> std::time::Duration {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let filler: Vec<_> = (0..n)
|
||
.map(|i| file(&format!("bg{i}"), "root", &format!("background{i}.txt"), 1))
|
||
.collect();
|
||
db.upsert_nodes(&filler).unwrap();
|
||
|
||
// The batch under test is identical in both runs; only the amount of
|
||
// already-indexed data around it differs.
|
||
let batch: Vec<_> = (0..200)
|
||
.map(|i| file(&format!("m{i}"), "root", &format!("measured{i}.txt"), 1))
|
||
.collect();
|
||
db.upsert_nodes(&batch).unwrap();
|
||
|
||
let t = Instant::now();
|
||
db.upsert_nodes(&batch).unwrap();
|
||
t.elapsed()
|
||
}
|
||
|
||
let small = cost_against_index_of(200);
|
||
let large = cost_against_index_of(5000);
|
||
|
||
// Calibration, measured while writing this: over the same 25× growth in
|
||
// index size, the old full-scan path cost 6.4× more (46 ms → 294 ms) while
|
||
// the rowid path costs 1.04× (39 ms → 41 ms). 3× sits well clear of both.
|
||
println!("B12: 200-node write — against 200 indexed {small:?}, against 5000 {large:?}");
|
||
assert!(
|
||
large < small * 3,
|
||
"writing a listing got dramatically more expensive as the index grew, \
|
||
which is the B12 full-scan regression; small={small:?} large={large:?}"
|
||
);
|
||
}
|
||
|
||
fn activity(target: &str, kind: ActivityKind, ok: bool) -> ActivityEntry {
|
||
ActivityEntry {
|
||
time: 1700,
|
||
kind,
|
||
target: target.into(),
|
||
detail: "detail".into(),
|
||
ok,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn activity_reads_back_newest_first() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.activity_add(&activity("a.txt", ActivityKind::Upload, true))
|
||
.unwrap();
|
||
db.activity_add(&activity("b.txt", ActivityKind::Download, false))
|
||
.unwrap();
|
||
|
||
let items = db.activity_list(10).unwrap();
|
||
assert_eq!(items.len(), 2);
|
||
assert_eq!(items[0].target, "b.txt");
|
||
assert_eq!(items[0].kind, ActivityKind::Download);
|
||
assert!(!items[0].ok);
|
||
assert_eq!(items[0].detail, "detail");
|
||
assert_eq!(items[0].time, 1700);
|
||
assert_eq!(items[1].target, "a.txt");
|
||
|
||
assert_eq!(db.activity_list(1).unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn activity_has_finds_only_matching_kind_and_target() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.activity_add(&activity(
|
||
"a (sync-conflict 1).txt",
|
||
ActivityKind::Conflict,
|
||
false,
|
||
))
|
||
.unwrap();
|
||
db.activity_add(&activity("b.txt", ActivityKind::Upload, true))
|
||
.unwrap();
|
||
|
||
assert!(
|
||
db.activity_has(ActivityKind::Conflict, "a (sync-conflict 1).txt")
|
||
.unwrap()
|
||
);
|
||
assert!(
|
||
!db.activity_has(ActivityKind::Upload, "a (sync-conflict 1).txt")
|
||
.unwrap()
|
||
);
|
||
assert!(!db.activity_has(ActivityKind::Conflict, "b.txt").unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn activity_prunes_to_the_keep_limit() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
for i in 0..(ACTIVITY_KEEP + 10) {
|
||
db.activity_add(&activity(&format!("f{i}"), ActivityKind::Upload, true))
|
||
.unwrap();
|
||
}
|
||
let items = db.activity_list(ACTIVITY_KEEP as usize * 2).unwrap();
|
||
assert_eq!(items.len(), ACTIVITY_KEEP as usize);
|
||
// The newest survive; the oldest are the ones dropped.
|
||
assert_eq!(items[0].target, format!("f{}", ACTIVITY_KEEP + 9));
|
||
}
|
||
|
||
#[test]
|
||
fn opens_and_migrates() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let version: String = db
|
||
.with_conn(|c| {
|
||
Ok(c.query_row(
|
||
"SELECT value FROM sync_state WHERE key = 'schema_version'",
|
||
[],
|
||
|r| r.get(0),
|
||
)?)
|
||
})
|
||
.unwrap();
|
||
assert_eq!(version, SCHEMA_VERSION.to_string());
|
||
}
|
||
|
||
#[test]
|
||
fn share_access_round_trips_updates_and_deletes() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = uid("shared-root");
|
||
|
||
assert_eq!(db.share_access(&root).unwrap(), None);
|
||
for access in [
|
||
crate::Access::Owner,
|
||
crate::Access::Editor,
|
||
crate::Access::Viewer,
|
||
crate::Access::Unknown,
|
||
] {
|
||
db.set_share_access(&root, access).unwrap();
|
||
assert_eq!(db.share_access(&root).unwrap(), Some(access));
|
||
assert_eq!(db.all_share_access().unwrap().get(&root), Some(&access));
|
||
}
|
||
db.delete_share_access(&root).unwrap();
|
||
assert_eq!(db.share_access(&root).unwrap(), None);
|
||
assert!(db.all_share_access().unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn effective_node_access_inherits_from_the_persisted_share_root() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("shared", Some("root"), "Shared"))
|
||
.unwrap();
|
||
db.upsert_node(&folder("nested", Some("shared"), "Nested"))
|
||
.unwrap();
|
||
db.upsert_node(&file("child", "nested", "child.txt", 1))
|
||
.unwrap();
|
||
db.set_share_access(&uid("shared"), crate::Access::Viewer)
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
db.effective_node_access(&uid("child")).unwrap(),
|
||
Some(crate::Access::Viewer)
|
||
);
|
||
assert_eq!(
|
||
db.effective_node_access(&uid("root")).unwrap(),
|
||
Some(crate::Access::Owner)
|
||
);
|
||
assert_eq!(db.effective_node_access(&uid("missing")).unwrap(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn effective_node_access_uses_the_nearest_nested_share_root() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("outer", Some("root"), "Outer"))
|
||
.unwrap();
|
||
db.upsert_node(&folder("inner", Some("outer"), "Inner"))
|
||
.unwrap();
|
||
db.upsert_node(&file("child", "inner", "child.txt", 1))
|
||
.unwrap();
|
||
db.set_share_access(&uid("outer"), crate::Access::Editor)
|
||
.unwrap();
|
||
db.set_share_access(&uid("inner"), crate::Access::Viewer)
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
db.effective_node_access(&uid("child")).unwrap(),
|
||
Some(crate::Access::Viewer)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn effective_node_access_keeps_a_missing_share_root_tombstone_authoritative() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("shared", Some("root"), "Shared"))
|
||
.unwrap();
|
||
db.upsert_node(&folder("nested", Some("shared"), "Nested"))
|
||
.unwrap();
|
||
db.upsert_node(&file("child", "nested", "child.txt", 1))
|
||
.unwrap();
|
||
db.set_share_access(&uid("shared"), crate::Access::Viewer)
|
||
.unwrap();
|
||
db.delete_node(&uid("shared")).unwrap();
|
||
|
||
assert_eq!(
|
||
db.effective_node_access(&uid("child")).unwrap(),
|
||
Some(crate::Access::Viewer)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn downgrade_all_share_access_leaves_unrecorded_owned_nodes_owned() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("shared", Some("root"), "Shared"))
|
||
.unwrap();
|
||
db.set_share_access(&uid("shared"), crate::Access::Editor)
|
||
.unwrap();
|
||
|
||
assert_eq!(db.downgrade_all_share_access().unwrap(), 1);
|
||
assert_eq!(
|
||
db.effective_node_access(&uid("shared")).unwrap(),
|
||
Some(crate::Access::Viewer)
|
||
);
|
||
assert_eq!(
|
||
db.effective_node_access(&uid("root")).unwrap(),
|
||
Some(crate::Access::Owner)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn migration_v17_adds_share_access_to_a_v16_fixture() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-v16-fixture-{}-{}.db",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
{
|
||
// Remove the later tables too, so restoring the version stamp produces
|
||
// the exact schema a released V16 database had.
|
||
let db = Db::open(&path).unwrap();
|
||
db.set_state_str("fixture_value", "survives").unwrap();
|
||
}
|
||
{
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
conn.execute_batch(
|
||
"DROP TRIGGER mount_sync_folder_insert;
|
||
DROP TABLE mount;
|
||
DROP TABLE share_access;
|
||
DROP TABLE album_photos;
|
||
DROP TABLE albums;
|
||
DROP INDEX idx_photos_favorite;
|
||
ALTER TABLE photos DROP COLUMN favorite;
|
||
ALTER TABLE nodes DROP COLUMN path;
|
||
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
assert_eq!(
|
||
db.state_str("fixture_value").unwrap().as_deref(),
|
||
Some("survives")
|
||
);
|
||
let root = uid("offline-share");
|
||
db.set_share_access(&root, crate::Access::Viewer).unwrap();
|
||
assert_eq!(db.share_access(&root).unwrap(), Some(crate::Access::Viewer));
|
||
// Migrating an old fixture always lands on head, whatever head is today.
|
||
assert_eq!(
|
||
db.state_str("schema_version").unwrap().as_deref(),
|
||
Some(SCHEMA_VERSION.to_string().as_str())
|
||
);
|
||
|
||
drop(db);
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
|
||
#[test]
|
||
fn migration_v18_projects_v17_sync_folders_and_cascades_deletes() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-v17-fixture-{}-{}.db",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
{
|
||
// Pin the released V17 shape that V18 reads instead of deriving a
|
||
// fixture from today's head schema and dropping newer objects.
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
conn.execute_batch(
|
||
"CREATE TABLE sync_state (key TEXT PRIMARY KEY, value TEXT);
|
||
INSERT INTO sync_state VALUES ('schema_version', '17');
|
||
INSERT INTO sync_state VALUES ('fixture_value', 'survives');
|
||
CREATE TABLE sync_folder (
|
||
id INTEGER PRIMARY KEY,
|
||
local_path TEXT NOT NULL UNIQUE,
|
||
remote_uid TEXT NOT NULL,
|
||
remote_share_id TEXT NOT NULL,
|
||
mode TEXT NOT NULL DEFAULT 'mirror',
|
||
state TEXT NOT NULL DEFAULT 'idle',
|
||
last_sync INTEGER NOT NULL DEFAULT 0,
|
||
pending_mode TEXT
|
||
);
|
||
CREATE TABLE sync_entry (
|
||
folder_id INTEGER NOT NULL,
|
||
rel_path TEXT NOT NULL,
|
||
remote_uid TEXT,
|
||
local_mtime INTEGER NOT NULL DEFAULT 0,
|
||
local_size INTEGER NOT NULL DEFAULT 0,
|
||
remote_hash TEXT,
|
||
remote_rev TEXT,
|
||
PRIMARY KEY (folder_id, rel_path)
|
||
);
|
||
INSERT INTO sync_folder
|
||
(id, local_path, remote_uid, remote_share_id, mode, state, last_sync)
|
||
VALUES
|
||
(41, '/home/me/Existing', 'vol~existing', 'share-existing',
|
||
'ondemand', 'syncing', 123);
|
||
-- The timeline as V17 held it: later migrations add columns to it,
|
||
-- so a fixture without it isn't a database any release produced.
|
||
CREATE TABLE photos (
|
||
uid TEXT PRIMARY KEY,
|
||
capture_time INTEGER NOT NULL,
|
||
name TEXT,
|
||
ratio REAL,
|
||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||
seq INTEGER NOT NULL,
|
||
media_type TEXT,
|
||
kind INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE TABLE share_access (
|
||
root_uid TEXT PRIMARY KEY,
|
||
access TEXT NOT NULL
|
||
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
|
||
);",
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
let existing = 41;
|
||
let locations = db.mount_list().unwrap();
|
||
assert_eq!(locations.len(), 1);
|
||
let projected = &locations[0];
|
||
assert!(matches!(
|
||
projected.kind,
|
||
crate::mounts::MountKind::Device {
|
||
sync_folder_id
|
||
} if sync_folder_id == existing
|
||
));
|
||
assert_eq!(projected.local_path, "/home/me/Existing");
|
||
assert_eq!(projected.mode, crate::mounts::MountMode::OnDemand);
|
||
assert_eq!(projected.state, "syncing");
|
||
assert_eq!(projected.last_sync, 123);
|
||
assert_eq!(
|
||
db.state_str("fixture_value").unwrap().as_deref(),
|
||
Some("survives")
|
||
);
|
||
|
||
let added = db
|
||
.sync_folder_add("/home/me/New", "vol~new", "share-new")
|
||
.unwrap();
|
||
assert_eq!(db.mount_list().unwrap().len(), 2);
|
||
assert!(db.sync_folder_remove(added).unwrap());
|
||
assert_eq!(
|
||
db.mount_list().unwrap().len(),
|
||
1,
|
||
"the V18 foreign key must remove the device projection"
|
||
);
|
||
// Migrating an old fixture always lands on head, whatever head is today.
|
||
assert_eq!(
|
||
db.state_str("schema_version").unwrap().as_deref(),
|
||
Some(SCHEMA_VERSION.to_string().as_str())
|
||
);
|
||
|
||
drop(db);
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
|
||
#[test]
|
||
fn my_files_projection_retains_share_id_only_for_the_same_root() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let id = db
|
||
.mount_upsert_my_files("/home/me/ProtonDrive", "vol~root", Some("share-main"))
|
||
.unwrap();
|
||
let same_id = db
|
||
.mount_upsert_my_files("/mnt/proton", "vol~root", None)
|
||
.unwrap();
|
||
assert_eq!(same_id, id);
|
||
assert_eq!(db.mount_list().unwrap()[0].root_share_id, "share-main");
|
||
|
||
db.mount_upsert_my_files("/mnt/proton", "vol~root-new", None)
|
||
.unwrap();
|
||
|
||
let locations = db.mount_list().unwrap();
|
||
assert_eq!(locations.len(), 1);
|
||
assert!(matches!(
|
||
locations[0].kind,
|
||
crate::mounts::MountKind::MyFiles
|
||
));
|
||
assert_eq!(locations[0].local_path, "/mnt/proton");
|
||
assert_eq!(locations[0].root_uid, "vol~root-new");
|
||
assert_eq!(
|
||
locations[0].root_share_id, "",
|
||
"a share id must never be paired with a different root uid"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn my_files_share_id_repair_is_conditional_on_root_uid() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.mount_upsert_my_files("/mnt/proton", "vol~root", None)
|
||
.unwrap();
|
||
|
||
assert!(
|
||
!db.mount_repair_my_files_share_id("vol~stale", "share-stale")
|
||
.unwrap()
|
||
);
|
||
assert_eq!(db.mount_list().unwrap()[0].root_share_id, "");
|
||
|
||
assert!(
|
||
db.mount_repair_my_files_share_id("vol~root", "share-current")
|
||
.unwrap()
|
||
);
|
||
assert_eq!(db.mount_list().unwrap()[0].root_share_id, "share-current");
|
||
}
|
||
|
||
#[test]
|
||
fn mount_constraints_reject_malformed_kinds_and_missing_sync_folders() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let conn = db.conn.lock();
|
||
|
||
assert!(
|
||
conn.execute(
|
||
"INSERT INTO mount (kind, local_path, root_uid)
|
||
VALUES ('device', '/tmp/device', 'vol~device')",
|
||
[],
|
||
)
|
||
.is_err(),
|
||
"a device projection without sync_folder_id must fail its CHECK"
|
||
);
|
||
assert!(
|
||
conn.execute(
|
||
"INSERT INTO mount (kind, sync_folder_id)
|
||
VALUES ('device', 999999)",
|
||
[],
|
||
)
|
||
.is_err(),
|
||
"a device projection cannot reference a missing sync folder"
|
||
);
|
||
assert!(
|
||
conn.execute(
|
||
"INSERT INTO mount (kind, share_root_uid, local_path, root_uid)
|
||
VALUES ('myfiles', 'vol~shared', '/tmp/root', 'vol~root')",
|
||
[],
|
||
)
|
||
.is_err(),
|
||
"kind-specific columns must not be mixed"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn deleting_a_device_projection_does_not_delete_its_sync_folder() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let folder_id = db
|
||
.sync_folder_add("/home/me/Keep", "vol~keep", "share-keep")
|
||
.unwrap();
|
||
{
|
||
let conn = db.conn.lock();
|
||
assert_eq!(
|
||
conn.execute(
|
||
"DELETE FROM mount WHERE kind = 'device' AND sync_folder_id = ?1",
|
||
[folder_id],
|
||
)
|
||
.unwrap(),
|
||
1
|
||
);
|
||
}
|
||
|
||
assert!(db.sync_folder_get(folder_id).unwrap().is_some());
|
||
assert!(
|
||
db.mount_list().unwrap().is_empty(),
|
||
"the foreign key direction must not recreate or delete sync authority"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn refuses_a_schema_newer_than_the_running_build() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-future-{}-{}.db",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
conn.execute_batch(
|
||
"CREATE TABLE sync_state (key TEXT PRIMARY KEY, value TEXT);
|
||
INSERT INTO sync_state VALUES ('schema_version', '9999');",
|
||
)
|
||
.unwrap();
|
||
drop(conn);
|
||
|
||
let err = Db::open(&path)
|
||
.err()
|
||
.expect("future schema must fail closed");
|
||
assert!(err.to_string().contains("newer than this build supports"));
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
let version: String = conn
|
||
.query_row(
|
||
"SELECT value FROM sync_state WHERE key = 'schema_version'",
|
||
[],
|
||
|row| row.get(0),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(version, "9999", "failed open must not rewrite the database");
|
||
drop(conn);
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
|
||
/// A queued write whose baseline is restamped must keep everything else.
|
||
///
|
||
/// The restamp happens after our *own* upload seals a new revision under a
|
||
/// still-queued write. If it took the blob or the retry state with it, the fix
|
||
/// for a spurious conflict copy would cost the bytes that conflict copy was
|
||
/// there to protect.
|
||
#[test]
|
||
fn restamping_a_baseline_leaves_the_blob_and_retry_state_alone() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let op = PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: uid("a").to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: Some("/staging/blob".to_string()),
|
||
meta_json: Some(r#"{"based_on":"old"}"#.to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
db.enqueue_op(&op).unwrap();
|
||
db.record_op_failure(db.pending_ops().unwrap()[0].id, "offline", 999)
|
||
.unwrap();
|
||
|
||
let updated = db
|
||
.update_op_meta(&uid("a").to_string(), OP_REVISION, r#"{"based_on":"new"}"#)
|
||
.unwrap();
|
||
assert!(updated, "the queued write is there to restamp");
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!(ops[0].meta_json.as_deref(), Some(r#"{"based_on":"new"}"#));
|
||
assert_eq!(
|
||
ops[0].blob_path.as_deref(),
|
||
Some("/staging/blob"),
|
||
"the only copy of the user's bytes"
|
||
);
|
||
assert_eq!(ops[0].attempts, 1, "backoff survives a restamp");
|
||
assert_eq!(ops[0].next_attempt_at, 999);
|
||
|
||
// No queued write for that node is the ordinary case, not an error: most
|
||
// uploads are the last one for their file.
|
||
assert!(
|
||
!db.update_op_meta(&uid("b").to_string(), OP_REVISION, "{}")
|
||
.unwrap()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_second_write_supersedes_the_first_pending_op() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let op = |blob: &str| PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: uid("a").to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: Some(blob.to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
|
||
let (_, superseded) = db.enqueue_op(&op("/staging/first")).unwrap();
|
||
assert_eq!(superseded, None, "nothing to supersede on the first write");
|
||
|
||
// The newer blob already contains everything the older one did, so the
|
||
// older op must go — and its blob must be reported so it can be deleted
|
||
// rather than leaked.
|
||
let (id2, superseded) = db.enqueue_op(&op("/staging/second")).unwrap();
|
||
assert_eq!(superseded.as_deref(), Some("/staging/first"));
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1, "one queued upload per node");
|
||
assert_eq!(ops[0].id, id2);
|
||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/second"));
|
||
assert_eq!(db.pending_op_counts().unwrap().uploads, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn failed_superseding_insert_keeps_the_old_pending_op() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let op = |blob: &str| PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: uid("atomic").to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: Some(blob.to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
db.enqueue_op(&op("/staging/original")).unwrap();
|
||
db.with_conn(|conn| {
|
||
conn.execute_batch(
|
||
"CREATE TRIGGER reject_replacement BEFORE INSERT ON pending_op
|
||
WHEN NEW.blob_path = '/staging/replacement'
|
||
BEGIN SELECT RAISE(ABORT, 'injected insert failure'); END;",
|
||
)?;
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
|
||
assert!(db.enqueue_op(&op("/staging/replacement")).is_err());
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/original"));
|
||
}
|
||
|
||
/// B53's remaining half. The insert-failure regression above proves the *row*
|
||
/// rolls back; this proves the two things that make the rollback usable — the
|
||
/// old blob is not reported as superseded (so the caller does not delete the
|
||
/// bytes the surviving row still points at), and the rollback survives a
|
||
/// restart rather than living only in the connection that failed.
|
||
#[test]
|
||
fn a_rolled_back_supersede_keeps_its_blob_and_survives_reopen() {
|
||
let dir = std::env::temp_dir().join(format!("pdfs-db-b53-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("queue.sqlite");
|
||
let op = |blob: &str| PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: uid("durable").to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: Some(blob.to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
|
||
{
|
||
let db = Db::open(&path).unwrap();
|
||
db.enqueue_op(&op("/staging/original")).unwrap();
|
||
db.with_conn(|conn| {
|
||
conn.execute_batch(
|
||
"CREATE TRIGGER reject_replacement BEFORE INSERT ON pending_op
|
||
WHEN NEW.blob_path = '/staging/replacement'
|
||
BEGIN SELECT RAISE(ABORT, 'injected insert failure'); END;",
|
||
)?;
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
|
||
// The failure must not hand back the old blob path. That value is the
|
||
// caller's instruction to delete those bytes, and the row that owns
|
||
// them is still queued.
|
||
assert!(db.enqueue_op(&op("/staging/replacement")).is_err());
|
||
db.with_conn(|conn| {
|
||
conn.execute_batch("DROP TRIGGER reject_replacement")?;
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
}
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1, "old or new — never neither");
|
||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/original"));
|
||
|
||
// And once the injected failure is gone, superseding works and *does*
|
||
// report the blob it retired.
|
||
let (_, superseded) = db.enqueue_op(&op("/staging/replacement")).unwrap();
|
||
assert_eq!(superseded.as_deref(), Some("/staging/original"));
|
||
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||
|
||
drop(db);
|
||
std::fs::remove_dir_all(&dir).unwrap();
|
||
}
|
||
|
||
/// A full disk, as SQLite reports it. `max_page_count` makes the database
|
||
/// refuse to grow, which is the same `SQLITE_FULL` an out-of-space filesystem
|
||
/// produces — and the queue must come out of it with the acknowledged work
|
||
/// still in it (B53).
|
||
#[test]
|
||
fn a_full_database_rolls_the_supersede_back_rather_than_erasing_it() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let op = |blob: &str| PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: uid("full").to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: Some(blob.to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
db.enqueue_op(&op("/staging/original")).unwrap();
|
||
|
||
// Cap the file at its current size, so any page the insert needs fails.
|
||
let pages: i64 = db
|
||
.with_conn(|conn| Ok(conn.query_row("PRAGMA page_count", [], |r| r.get(0))?))
|
||
.unwrap();
|
||
db.with_conn(|conn| {
|
||
conn.execute_batch(&format!("PRAGMA max_page_count = {pages}"))?;
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
|
||
// A payload far larger than one page, so growth is unavoidable.
|
||
let mut fat = op("/staging/replacement");
|
||
fat.meta_json = Some("x".repeat(512 * 1024));
|
||
let result = db.enqueue_op(&fat);
|
||
|
||
db.with_conn(|conn| {
|
||
conn.execute_batch("PRAGMA max_page_count = 1073741823")?;
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
|
||
assert!(result.is_err(), "a database that cannot grow must refuse");
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!(
|
||
ops[0].blob_path.as_deref(),
|
||
Some("/staging/original"),
|
||
"the acknowledged upload is still queued and still owns its bytes"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn atomic_trash_replacement_returns_owned_blobs_after_commit() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = uid("trash-root").to_string();
|
||
let child = uid("trash-child").to_string();
|
||
let revision = |uid: &str, parent_uid: Option<&str>, blob: &str| PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: uid.to_string(),
|
||
parent_uid: parent_uid.map(str::to_string),
|
||
name: None,
|
||
blob_path: Some(blob.to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
db.enqueue_op(&revision(&root, None, "/staging/root"))
|
||
.unwrap();
|
||
db.enqueue_op(&revision(&child, Some(&root), "/staging/child"))
|
||
.unwrap();
|
||
|
||
let (trash_id, mut blobs) = db.replace_ops_with_trash(&root, "removed.txt", 2).unwrap();
|
||
blobs.sort();
|
||
assert_eq!(blobs, vec!["/staging/child", "/staging/root"]);
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!(ops[0].id, trash_id);
|
||
assert_eq!(ops[0].kind, OP_TRASH);
|
||
assert_eq!(ops[0].uid, root);
|
||
assert_eq!(ops[0].name.as_deref(), Some("removed.txt"));
|
||
assert!(ops[0].blob_path.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_queued_trash_holds_its_name_until_it_lands() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let node = uid("held-name").to_string();
|
||
assert!(!db.has_pending_trash_named("report.txt").unwrap());
|
||
|
||
let (trash_id, _) = db.replace_ops_with_trash(&node, "report.txt", 1).unwrap();
|
||
assert!(db.has_pending_trash_named("report.txt").unwrap());
|
||
assert!(!db.has_pending_trash_named("other.txt").unwrap());
|
||
|
||
db.delete_op(trash_id).unwrap();
|
||
assert!(!db.has_pending_trash_named("report.txt").unwrap());
|
||
}
|
||
|
||
/// A create that found its name held by a queued trash backed off. The trash
|
||
/// landing frees the name, so the create is due at once instead of after the
|
||
/// backoff; a parked create stays parked.
|
||
#[test]
|
||
fn a_landed_trash_makes_the_ops_waiting_for_its_name_due() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let old = uid("old");
|
||
db.upsert_node(&file("old", "root", "report.txt", 1))
|
||
.unwrap();
|
||
let (trash_id, _) = db
|
||
.replace_ops_with_trash(&old.to_string(), "report.txt", 1)
|
||
.unwrap();
|
||
let create = |name: &str, next_attempt_at: i64| {
|
||
let (id, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: format!("local~{name}-{next_attempt_at}"),
|
||
parent_uid: Some(uid("root").to_string()),
|
||
name: Some(name.to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 2,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at,
|
||
})
|
||
.unwrap();
|
||
db.record_op_failure(id, "report.txt is still held", next_attempt_at)
|
||
.unwrap();
|
||
id
|
||
};
|
||
let waiting = create("report.txt", 9_000);
|
||
let parked = create("report.txt", PARK_UNTIL);
|
||
let other = create("other.txt", 9_000);
|
||
|
||
db.complete_trash_op(trash_id, &old).unwrap();
|
||
|
||
let due = |id| {
|
||
db.pending_ops()
|
||
.unwrap()
|
||
.into_iter()
|
||
.find(|op| op.id == id)
|
||
.unwrap()
|
||
.next_attempt_at
|
||
};
|
||
assert_eq!(due(waiting), 0);
|
||
assert_eq!(due(parked), PARK_UNTIL);
|
||
assert_eq!(due(other), 9_000);
|
||
}
|
||
|
||
#[test]
|
||
fn a_queued_rename_away_from_a_name_holds_it_until_it_lands() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = uid("root").to_string();
|
||
let rename = |node: &str, from: &str, to: &str| {
|
||
let meta = serde_json::to_string(&RenameMeta {
|
||
original_parent_uid: root.clone(),
|
||
original_name: Some(from.to_string()),
|
||
})
|
||
.unwrap();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_RENAME.to_string(),
|
||
uid: uid(node).to_string(),
|
||
parent_uid: Some(root.clone()),
|
||
name: Some(to.to_string()),
|
||
blob_path: None,
|
||
meta_json: Some(meta),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap()
|
||
.0
|
||
};
|
||
let away = rename("away", "c.bin", "d");
|
||
rename("back", "e.md", "e.md");
|
||
let (waiting, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: "local~waiting".to_string(),
|
||
parent_uid: Some(root.clone()),
|
||
name: Some("c.bin".to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 2,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
db.record_op_failure(waiting, "c.bin is still held", 9_000)
|
||
.unwrap();
|
||
|
||
assert!(db.has_pending_move_from("c.bin").unwrap());
|
||
assert!(!db.has_pending_move_from("d").unwrap());
|
||
assert!(
|
||
!db.has_pending_move_from("e.md").unwrap(),
|
||
"a rename that keeps the name in its folder frees nothing"
|
||
);
|
||
db.wake_ops_waiting_for("c.bin").unwrap();
|
||
let due = || {
|
||
db.pending_ops()
|
||
.unwrap()
|
||
.into_iter()
|
||
.find(|op| op.id == waiting)
|
||
.unwrap()
|
||
.next_attempt_at
|
||
};
|
||
assert_eq!(due(), 9_000, "the rename still holds the name");
|
||
|
||
db.delete_op(away).unwrap();
|
||
db.wake_ops_waiting_for("c.bin").unwrap();
|
||
assert_eq!(due(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn failed_atomic_trash_insert_keeps_prior_revision_and_blob_ownership() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let node = uid("atomic-trash").to_string();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: node.clone(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: Some("/staging/still-owned".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
db.with_conn(|conn| {
|
||
conn.execute_batch(
|
||
"CREATE TRIGGER reject_trash_insert BEFORE INSERT ON pending_op
|
||
WHEN NEW.kind = 'trash'
|
||
BEGIN SELECT RAISE(ABORT, 'injected trash insert failure'); END;",
|
||
)?;
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
|
||
assert!(db.replace_ops_with_trash(&node, "removed.txt", 2).is_err());
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!(ops[0].kind, OP_REVISION);
|
||
assert_eq!(ops[0].uid, node);
|
||
assert_eq!(
|
||
ops[0].blob_path.as_deref(),
|
||
Some("/staging/still-owned"),
|
||
"the rolled-back revision still owns its staged bytes"
|
||
);
|
||
}
|
||
|
||
/// Discarding a change to a node Drive has drops that node's ops and nothing
|
||
/// queued below it, and nothing at all while one of them is on the wire.
|
||
#[test]
|
||
fn discarding_a_nodes_change_keeps_what_is_queued_inside_it() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_nodes(&[
|
||
folder("root", None, "My Files"),
|
||
folder("dir", Some("root"), "dir"),
|
||
file("inner", "dir", "inner.bin", 1),
|
||
])
|
||
.unwrap();
|
||
let op = |kind: &str, link: &str, blob: Option<&str>| PendingOp {
|
||
id: 0,
|
||
kind: kind.to_string(),
|
||
uid: uid(link).to_string(),
|
||
parent_uid: None,
|
||
name: Some("renamed".to_string()),
|
||
blob_path: blob.map(str::to_string),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
db.enqueue_op(&op(OP_RENAME, "dir", None)).unwrap();
|
||
db.enqueue_op(&op(OP_REVISION, "inner", Some("/staging/inner")))
|
||
.unwrap();
|
||
|
||
let dir = uid("dir").to_string();
|
||
assert_eq!(db.discard_ops_on_node(&dir).unwrap(), Some(vec![]));
|
||
let left: Vec<String> = db
|
||
.pending_ops()
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|op| op.uid)
|
||
.collect();
|
||
assert_eq!(left, vec![uid("inner").to_string()]);
|
||
|
||
let inner = uid("inner").to_string();
|
||
let claimed = db.claim_next_due_op(2).unwrap().unwrap();
|
||
assert_eq!(claimed.uid, inner);
|
||
assert_eq!(db.discard_ops_on_node(&inner).unwrap(), None);
|
||
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||
db.record_op_failure(claimed.id, "no answer", 0).unwrap();
|
||
db.release_op_claim(claimed.id).unwrap();
|
||
assert_eq!(
|
||
db.discard_ops_on_node(&inner).unwrap(),
|
||
Some(vec!["/staging/inner".to_string()])
|
||
);
|
||
assert!(db.pending_ops().unwrap().is_empty());
|
||
}
|
||
|
||
/// A revision op names no parent, so the queue alone cannot see that its file
|
||
/// sits inside a folder being removed. The node tree can: trashing the folder
|
||
/// has to take the uploads of the files below it along, or they fail forever
|
||
/// against a trashed node and keep their bytes on disk (B102). A rename that
|
||
/// moved a node into the folder still has to land.
|
||
#[test]
|
||
fn trashing_a_folder_drops_the_queued_revisions_of_files_below_it() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_nodes(&[
|
||
folder("root", None, "My Files"),
|
||
folder("doomed", Some("root"), "doomed"),
|
||
folder("sub", Some("doomed"), "sub"),
|
||
file("deep", "sub", "deep.bin", 1),
|
||
file("moved", "doomed", "moved.txt", 1),
|
||
file("kept", "root", "kept.bin", 1),
|
||
])
|
||
.unwrap();
|
||
let op = |kind: &str, link: &str, blob: Option<&str>| PendingOp {
|
||
id: 0,
|
||
kind: kind.to_string(),
|
||
uid: uid(link).to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: blob.map(str::to_string),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
db.enqueue_op(&op(OP_REVISION, "deep", Some("/staging/deep")))
|
||
.unwrap();
|
||
db.enqueue_op(&op(OP_RENAME, "moved", None)).unwrap();
|
||
db.enqueue_op(&op(OP_REVISION, "kept", Some("/staging/kept")))
|
||
.unwrap();
|
||
|
||
let doomed = uid("doomed").to_string();
|
||
let (_, blobs) = db.replace_ops_with_trash(&doomed, "doomed", 2).unwrap();
|
||
assert_eq!(blobs, vec!["/staging/deep"]);
|
||
|
||
let mut left: Vec<(String, String)> = db
|
||
.pending_ops()
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|op| (op.kind, op.uid))
|
||
.collect();
|
||
left.sort();
|
||
assert_eq!(
|
||
left,
|
||
vec![
|
||
(OP_RENAME.to_string(), uid("moved").to_string()),
|
||
(OP_REVISION.to_string(), uid("kept").to_string()),
|
||
(OP_TRASH.to_string(), doomed.clone()),
|
||
]
|
||
);
|
||
|
||
// Deleting outright follows the same tree.
|
||
db.enqueue_op(&op(OP_REVISION, "deep", Some("/staging/deep2")))
|
||
.unwrap();
|
||
assert_eq!(
|
||
db.delete_ops_for_uid(&uid("sub").to_string()).unwrap(),
|
||
vec!["/staging/deep2"]
|
||
);
|
||
}
|
||
|
||
/// A create in a subfolder that already exists on Drive names that subfolder,
|
||
/// not the folder being trashed, as its parent. It has to go with the folder
|
||
/// all the same, along with whatever is queued inside it, or a parked one
|
||
/// keeps its bytes for good and a due one is made in the root (B116).
|
||
#[test]
|
||
fn trashing_a_folder_drops_the_queued_creates_in_its_subfolders() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_nodes(&[
|
||
folder("root", None, "My Files"),
|
||
folder("doomed", Some("root"), "doomed"),
|
||
folder("sub", Some("doomed"), "sub"),
|
||
folder("other", Some("root"), "other"),
|
||
])
|
||
.unwrap();
|
||
let op = |kind: &str, node: &str, parent: &str, blob: Option<&str>| PendingOp {
|
||
id: 0,
|
||
kind: kind.to_string(),
|
||
uid: node.to_string(),
|
||
parent_uid: Some(parent.to_string()),
|
||
name: Some(format!("{node}.bin")),
|
||
blob_path: blob.map(str::to_string),
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
let sub = uid("sub").to_string();
|
||
db.enqueue_op(&op(OP_CREATE, "local~deep", &sub, Some("/staging/deep")))
|
||
.unwrap();
|
||
db.enqueue_op(&op(OP_MKDIR, "local~dir", &sub, None))
|
||
.unwrap();
|
||
db.enqueue_op(&op(
|
||
OP_CREATE,
|
||
"local~deeper",
|
||
"local~dir",
|
||
Some("/staging/deeper"),
|
||
))
|
||
.unwrap();
|
||
db.enqueue_op(&op(
|
||
OP_CREATE,
|
||
"local~kept",
|
||
&uid("other").to_string(),
|
||
Some("/staging/kept"),
|
||
))
|
||
.unwrap();
|
||
|
||
let doomed = uid("doomed").to_string();
|
||
let (_, mut blobs) = db.replace_ops_with_trash(&doomed, "doomed", 2).unwrap();
|
||
blobs.sort();
|
||
assert_eq!(blobs, vec!["/staging/deep", "/staging/deeper"]);
|
||
|
||
let mut left: Vec<(String, String)> = db
|
||
.pending_ops()
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|op| (op.kind, op.uid))
|
||
.collect();
|
||
left.sort();
|
||
assert_eq!(
|
||
left,
|
||
vec![
|
||
(OP_CREATE.to_string(), "local~kept".to_string()),
|
||
(OP_TRASH.to_string(), doomed),
|
||
]
|
||
);
|
||
}
|
||
|
||
/// Deleting a folder that was created offline must take the ops queued
|
||
/// underneath it with it: they name a placeholder parent that will now never
|
||
/// become real, so nothing could ever drain them and nothing is left to
|
||
/// rewrite them.
|
||
#[test]
|
||
fn deleting_a_queued_folder_takes_its_queued_children_with_it() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let op = |kind: &str, uid: &str, parent: &str, blob: Option<&str>| PendingOp {
|
||
id: 0,
|
||
kind: kind.to_string(),
|
||
uid: uid.to_string(),
|
||
parent_uid: Some(parent.to_string()),
|
||
name: Some("n".to_string()),
|
||
blob_path: blob.map(str::to_string),
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
let root = uid("root").to_string();
|
||
db.enqueue_op(&op(OP_MKDIR, "local~dir", &root, None))
|
||
.unwrap();
|
||
db.enqueue_op(&op(OP_MKDIR, "local~sub", "local~dir", None))
|
||
.unwrap();
|
||
db.enqueue_op(&op(
|
||
OP_CREATE,
|
||
"local~deep",
|
||
"local~sub",
|
||
Some("/staging/deep"),
|
||
))
|
||
.unwrap();
|
||
// A sibling outside the doomed subtree must survive.
|
||
db.enqueue_op(&op(OP_CREATE, "local~other", &root, Some("/staging/other")))
|
||
.unwrap();
|
||
|
||
let blobs = db.delete_ops_for_uid("local~dir").unwrap();
|
||
assert_eq!(
|
||
blobs,
|
||
vec!["/staging/deep"],
|
||
"the subtree's bytes come back"
|
||
);
|
||
|
||
let left: Vec<String> = db
|
||
.pending_ops()
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|o| o.uid)
|
||
.collect();
|
||
assert_eq!(left, vec!["local~other"]);
|
||
}
|
||
|
||
/// A rename is the node's desired end state, so the newest one is the only
|
||
/// one worth performing — but it must not disturb the queued *upload* of the
|
||
/// same node, which is unrelated work.
|
||
#[test]
|
||
fn a_second_rename_supersedes_the_first_but_leaves_the_upload_alone() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let rename = |name: &str| PendingOp {
|
||
id: 0,
|
||
kind: OP_RENAME.to_string(),
|
||
uid: uid("a").to_string(),
|
||
parent_uid: Some(uid("parent").to_string()),
|
||
name: Some(name.to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: uid("a").to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: Some("/staging/blob".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
|
||
db.enqueue_op(&rename("first")).unwrap();
|
||
let (_, superseded) = db.enqueue_op(&rename("second")).unwrap();
|
||
assert_eq!(superseded, None, "a rename owns no blob to clean up");
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 2, "the queued upload survives the rename");
|
||
let renames: Vec<_> = ops.iter().filter(|o| o.kind == OP_RENAME).collect();
|
||
assert_eq!(renames.len(), 1, "one rename per node");
|
||
assert_eq!(renames[0].name.as_deref(), Some("second"));
|
||
|
||
let counts = db.pending_op_counts().unwrap();
|
||
assert_eq!(counts.uploads, 1, "the revision is the only upload");
|
||
assert_eq!(counts.changes, 1, "the rename carries no bytes");
|
||
}
|
||
|
||
/// Renaming a node whose create has not drained rewrites the intent rather
|
||
/// than queueing a rename against a uid the server has never issued.
|
||
#[test]
|
||
fn renaming_a_queued_create_rewrites_its_target() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let local = "local~abc";
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(uid("old").to_string()),
|
||
name: Some("draft.txt".to_string()),
|
||
blob_path: Some("/staging/blob".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
|
||
let rewritten = db
|
||
.rewrite_op_target(local, &uid("new").to_string(), "final.txt")
|
||
.unwrap();
|
||
assert!(rewritten);
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1, "a rewrite is not a second op");
|
||
assert_eq!(ops[0].name.as_deref(), Some("final.txt"));
|
||
assert_eq!(
|
||
ops[0].parent_uid.as_deref(),
|
||
Some(uid("new").to_string()).as_deref()
|
||
);
|
||
assert_eq!(
|
||
ops[0].blob_path.as_deref(),
|
||
Some("/staging/blob"),
|
||
"the bytes riding on the create are untouched"
|
||
);
|
||
|
||
// Once the create has drained there is no intent left to rewrite, and the
|
||
// caller has to rename the real node instead.
|
||
db.delete_op(ops[0].id).unwrap();
|
||
assert!(
|
||
!db.rewrite_op_target(local, &uid("new").to_string(), "final.txt")
|
||
.unwrap()
|
||
);
|
||
}
|
||
|
||
/// A create that failed on a name held by a queued change waits out a
|
||
/// backoff. Renamed away from that name, it is due again at once; parked, it
|
||
/// stays parked. One whose blob a write replaced as it failed is due again at
|
||
/// once too.
|
||
#[test]
|
||
fn renaming_a_backing_off_create_makes_it_due() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let create = |local: &str| PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(uid("dir").to_string()),
|
||
name: Some("a.txt".to_string()),
|
||
blob_path: Some(format!("/staging/{local}")),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
let (failed, _) = db.enqueue_op(&create("local~failed")).unwrap();
|
||
db.record_op_failure(failed, "a.txt is still held", 10_000)
|
||
.unwrap();
|
||
db.enqueue_op(&create("local~parked")).unwrap();
|
||
db.set_create_hold("local~parked", true).unwrap();
|
||
|
||
db.rewrite_op_target("local~failed", &uid("dir").to_string(), "d")
|
||
.unwrap();
|
||
db.rewrite_op_target("local~parked", &uid("dir").to_string(), "e.tmp")
|
||
.unwrap();
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
let op = |local: &str| ops.iter().find(|o| o.uid == local).unwrap();
|
||
assert_eq!(op("local~failed").next_attempt_at, 0);
|
||
assert_eq!(op("local~failed").attempts, 1);
|
||
assert_eq!(op("local~parked").next_attempt_at, PARK_UNTIL);
|
||
|
||
// Renamed while an attempt was on the wire, which then failed: the
|
||
// failure is recorded after the rename, and the create is still due.
|
||
let sent = op("local~failed").clone();
|
||
db.rewrite_op_target("local~failed", &uid("dir").to_string(), "f")
|
||
.unwrap();
|
||
db.record_op_failure(sent.id, "d is still held", 10_000)
|
||
.unwrap();
|
||
assert!(
|
||
db.retry_if_retargeted(
|
||
sent.id,
|
||
sent.parent_uid.as_deref(),
|
||
sent.name.as_deref(),
|
||
sent.blob_path.as_deref()
|
||
)
|
||
.unwrap()
|
||
);
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(
|
||
ops.iter()
|
||
.find(|o| o.id == sent.id)
|
||
.unwrap()
|
||
.next_attempt_at,
|
||
0
|
||
);
|
||
assert!(
|
||
!db.retry_if_retargeted(
|
||
sent.id,
|
||
sent.parent_uid.as_deref(),
|
||
Some("f"),
|
||
sent.blob_path.as_deref()
|
||
)
|
||
.unwrap()
|
||
);
|
||
|
||
// A write replaced the blob before the attempt opened it: the old one was
|
||
// discarded, the attempt failed on a missing file, and the create is due
|
||
// again at once with the new one (`docs/BUGS.md` B185).
|
||
db.record_op_failure(sent.id, "No such file or directory", 10_000)
|
||
.unwrap();
|
||
db.attach_blob_to_create("local~failed", "/staging/newer", "{}")
|
||
.unwrap()
|
||
.expect("the create is still queued");
|
||
db.record_op_failure(sent.id, "No such file or directory", 10_000)
|
||
.unwrap();
|
||
assert!(
|
||
db.retry_if_retargeted(
|
||
sent.id,
|
||
sent.parent_uid.as_deref(),
|
||
Some("f"),
|
||
sent.blob_path.as_deref()
|
||
)
|
||
.unwrap()
|
||
);
|
||
assert!(
|
||
!db.retry_if_retargeted(
|
||
sent.id,
|
||
sent.parent_uid.as_deref(),
|
||
Some("f"),
|
||
Some("/staging/newer")
|
||
)
|
||
.unwrap()
|
||
);
|
||
}
|
||
|
||
/// A rename queued while an older one is on the wire waits for it. The older
|
||
/// one, if it fails, is dropped rather than retried after the newer one, also
|
||
/// when the newer one came after the failure was recorded.
|
||
#[test]
|
||
fn a_rename_on_the_wire_lands_before_the_one_that_replaces_it() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let file = uid("file").to_string();
|
||
let rename = |parent: &str| PendingOp {
|
||
id: 0,
|
||
kind: OP_RENAME.to_string(),
|
||
uid: file.clone(),
|
||
parent_uid: Some(uid(parent).to_string()),
|
||
name: Some("e.md".to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
let (first, _) = db.enqueue_op(&rename("x")).unwrap();
|
||
assert_eq!(db.claim_next_due_op(1).unwrap().unwrap().id, first);
|
||
|
||
let (second, _) = db.enqueue_op(&rename("c0")).unwrap();
|
||
assert!(db.claim_next_due_op(1).unwrap().is_none());
|
||
|
||
db.record_op_failure(first, "original hash is out of date", 10_000)
|
||
.unwrap();
|
||
db.release_op_claim(first).unwrap();
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.iter().map(|o| o.id).collect::<Vec<_>>(), [second]);
|
||
assert_eq!(db.claim_next_due_op(1).unwrap().unwrap().id, second);
|
||
|
||
// Unclaimed, a queued rename is replaced as before.
|
||
db.release_op_claim(second).unwrap();
|
||
let (third, _) = db.enqueue_op(&rename("x")).unwrap();
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.iter().map(|o| o.id).collect::<Vec<_>>(), [third]);
|
||
|
||
// Queued after the failure was recorded but before the claim went back:
|
||
// the older one goes then, rather than landing after the newer (B158).
|
||
assert_eq!(db.claim_next_due_op(1).unwrap().unwrap().id, third);
|
||
db.record_op_failure(third, "e.md is still held", 10_000)
|
||
.unwrap();
|
||
let (fourth, _) = db.enqueue_op(&rename("c0")).unwrap();
|
||
db.release_op_claim(third).unwrap();
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.iter().map(|o| o.id).collect::<Vec<_>>(), [fourth]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_write_folds_into_a_queued_create_instead_of_superseding_it() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let local = "local~abc";
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(uid("parent").to_string()),
|
||
name: Some("new.txt".to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
|
||
let first = db
|
||
.attach_blob_to_create(local, "/staging/first", "{}")
|
||
.unwrap()
|
||
.expect("create is still queued");
|
||
assert_eq!(first.superseded, None);
|
||
|
||
// Rewriting the file before it drains replaces the bytes but must leave
|
||
// the create itself alone: it is the only thing that will ever bring this
|
||
// file into existence remotely.
|
||
let second = db
|
||
.attach_blob_to_create(local, "/staging/second", "{}")
|
||
.unwrap()
|
||
.expect("create is still queued");
|
||
assert_eq!(second.superseded.as_deref(), Some("/staging/first"));
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1, "still exactly one create");
|
||
assert_eq!(ops[0].kind, OP_CREATE);
|
||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/second"));
|
||
assert_eq!(ops[0].name.as_deref(), Some("new.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn attaching_to_an_already_drained_create_reports_it_is_gone() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
// No create queued: the caller must fall back to a revision op rather
|
||
// than silently dropping the bytes.
|
||
let out = db
|
||
.attach_blob_to_create("local~gone", "/staging/x", "{}")
|
||
.unwrap();
|
||
assert!(out.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_write_attached_while_its_create_uploads_becomes_a_revision() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let local = "local~abc";
|
||
let real = uid("real").to_string();
|
||
let parent = uid("parent").to_string();
|
||
let create = |blob: Option<&str>| PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(uid("parent").to_string()),
|
||
name: Some("new.txt".to_string()),
|
||
blob_path: blob.map(str::to_string),
|
||
meta_json: blob.map(|_| "{\"uid\":\"local~abc\"}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
let (id, _) = db.enqueue_op(&create(None)).unwrap();
|
||
// The drain picked the create up with no blob; the file's first write
|
||
// closes while the empty upload is on the wire.
|
||
db.attach_blob_to_create(local, "/staging/written", "{\"uid\":\"local~abc\"}")
|
||
.unwrap()
|
||
.expect("create is still queued");
|
||
|
||
let newer = db
|
||
.finish_create(
|
||
id,
|
||
None,
|
||
&landing(local, &real, &parent, "new.txt"),
|
||
|json| Some(json.replace("local~abc", "vol~real")),
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
newer,
|
||
CreateRetired::Newer {
|
||
blob: "/staging/written".to_string(),
|
||
meta: "{\"uid\":\"vol~real\"}".to_string()
|
||
}
|
||
);
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1, "the write is still queued");
|
||
assert_eq!(ops[0].kind, OP_REVISION);
|
||
assert_eq!(ops[0].uid, real);
|
||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/written"));
|
||
assert_eq!(ops[0].parent_uid, None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_create_whose_blob_landed_is_retired() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let local = "local~abc";
|
||
let real = uid("real").to_string();
|
||
let parent = uid("parent").to_string();
|
||
let (id, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(uid("parent").to_string()),
|
||
name: Some("new.txt".to_string()),
|
||
blob_path: Some("/staging/sent".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: "local~child".to_string(),
|
||
parent_uid: Some(local.to_string()),
|
||
name: Some("inside.txt".to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 2,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
|
||
let newer = db
|
||
.finish_create(
|
||
id,
|
||
Some("/staging/sent"),
|
||
&landing(local, &real, &parent, "new.txt"),
|
||
|_| panic!("an unchanged create has no sidecar to rewrite"),
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(newer, CreateRetired::Landed);
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1, "only the child is left");
|
||
assert_eq!(ops[0].parent_uid.as_deref(), Some(real.as_str()));
|
||
}
|
||
|
||
/// The parent and name a create was sent with, landing where it was sent.
|
||
fn landing<'a>(local: &'a str, real: &'a str, parent: &'a str, name: &'a str) -> CreateLanding<'a> {
|
||
CreateLanding {
|
||
local,
|
||
real,
|
||
sent: (parent, name),
|
||
landed: (parent, name),
|
||
}
|
||
}
|
||
|
||
/// A queued op on a node made here, `kind` under `uid`.
|
||
fn op_on(kind: &str, uid: &str, parent: Option<&str>) -> PendingOp {
|
||
PendingOp {
|
||
id: 0,
|
||
kind: kind.to_string(),
|
||
uid: uid.to_string(),
|
||
parent_uid: parent.map(str::to_string),
|
||
name: parent.map(|_| "new.txt".to_string()),
|
||
blob_path: Some(format!("/staging/{kind}")),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_op_queued_under_a_stand_in_after_its_create_landed_goes_to_the_landed_node() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("parent", None, "My Files")).unwrap();
|
||
let parent = uid("parent").to_string();
|
||
let lid = db.add_local_row(&parent, "new.txt", false, 1).unwrap();
|
||
let local = local_uid(lid);
|
||
let real = uid("real").to_string();
|
||
let (id, _) = db
|
||
.enqueue_op(&op_on(OP_CREATE, &local, Some(&parent)))
|
||
.unwrap();
|
||
db.claim_next_due_op(1).unwrap().unwrap();
|
||
db.finish_create(
|
||
id,
|
||
Some("/staging/create"),
|
||
&landing(&local, &real, &parent, "new.txt"),
|
||
|_| None,
|
||
)
|
||
.unwrap();
|
||
|
||
// A handle opened before the create landed still names the stand-in.
|
||
db.enqueue_op(&op_on(OP_REVISION, &local, None)).unwrap();
|
||
assert!(db.has_pending_op(&real, OP_REVISION).unwrap());
|
||
assert!(db.has_any_op(&real).unwrap());
|
||
// A second write, made through the real uid, replaces it.
|
||
let (_, superseded) = db.enqueue_op(&op_on(OP_REVISION, &real, None)).unwrap();
|
||
assert_eq!(superseded.as_deref(), Some("/staging/revision"));
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!(
|
||
ops[0].uid, real,
|
||
"the queue names the node by the uid it has now"
|
||
);
|
||
let claimed = db.claim_next_due_op(1).unwrap().unwrap();
|
||
assert_eq!(claimed.uid, real, "the drain sends the real uid");
|
||
}
|
||
|
||
#[test]
|
||
fn a_stand_in_keeps_its_access_once_its_create_has_landed() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("parent", None, "My Files")).unwrap();
|
||
let parent = uid("parent").to_string();
|
||
let lid = db.add_local_row(&parent, "new.txt", false, 1).unwrap();
|
||
let local = local_uid(lid);
|
||
let stand_in = NodeUid::new(VolumeId::from("local"), LinkId::from(lid.to_string()));
|
||
let mut node = file("new", "parent", "new.txt", 1);
|
||
node.uid = stand_in.clone();
|
||
db.upsert_node(&node).unwrap();
|
||
let real = uid("real").to_string();
|
||
let (id, _) = db
|
||
.enqueue_op(&op_on(OP_CREATE, &local, Some(&parent)))
|
||
.unwrap();
|
||
assert_eq!(
|
||
db.effective_node_access(&stand_in).unwrap(),
|
||
Some(crate::Access::Owner)
|
||
);
|
||
db.claim_next_due_op(1).unwrap().unwrap();
|
||
db.finish_create(
|
||
id,
|
||
Some("/staging/create"),
|
||
&landing(&local, &real, &parent, "new.txt"),
|
||
|_| None,
|
||
)
|
||
.unwrap();
|
||
|
||
// The tree still names the file by its stand-in for a moment.
|
||
assert_eq!(
|
||
db.effective_node_access(&stand_in).unwrap(),
|
||
Some(crate::Access::Owner)
|
||
);
|
||
assert_eq!(
|
||
db.effective_node_access(&uid("real")).unwrap(),
|
||
Some(crate::Access::Owner)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_stand_in_folder_stays_listed_once_its_create_has_landed() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("parent", None, "My Files")).unwrap();
|
||
let parent = uid("parent").to_string();
|
||
let lid = db.add_local_row(&parent, "dir", true, 1).unwrap();
|
||
let local = local_uid(lid);
|
||
let stand_in = NodeUid::new(VolumeId::from("local"), LinkId::from(lid.to_string()));
|
||
let mut node = folder("new", Some("parent"), "dir");
|
||
node.uid = stand_in.clone();
|
||
db.upsert_node(&node).unwrap();
|
||
db.set_listed(&stand_in, true).unwrap();
|
||
let real = uid("real").to_string();
|
||
let (id, _) = db
|
||
.enqueue_op(&op_on(OP_MKDIR, &local, Some(&parent)))
|
||
.unwrap();
|
||
db.claim_next_due_op(1).unwrap().unwrap();
|
||
db.finish_create(id, None, &landing(&local, &real, &parent, "dir"), |_| None)
|
||
.unwrap();
|
||
|
||
// The tree still names the folder by its stand-in for a moment; a listing
|
||
// asked by it must not go to Drive, which has never heard of it.
|
||
assert!(
|
||
db.children_if_listed(&stand_in)
|
||
.unwrap()
|
||
.is_some_and(|kids| kids.is_empty())
|
||
);
|
||
db.set_listed(&stand_in, false).unwrap();
|
||
assert!(db.children_if_listed(&uid("real")).unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_node_queued_under_both_its_uids_is_sent_one_op_at_a_time() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("parent", None, "My Files")).unwrap();
|
||
let parent = uid("parent").to_string();
|
||
let lid = db.add_local_row(&parent, "new.txt", false, 1).unwrap();
|
||
let local = local_uid(lid);
|
||
let real = uid("real").to_string();
|
||
db.land_placeholder_row(&local, &real).unwrap();
|
||
db.enqueue_op(&op_on(OP_RENAME, &real, Some(&parent)))
|
||
.unwrap();
|
||
db.enqueue_op(&op_on(OP_REVISION, &local, None)).unwrap();
|
||
|
||
let first = db.claim_next_due_op(1).unwrap().unwrap();
|
||
assert_eq!(first.kind, OP_RENAME);
|
||
assert!(
|
||
db.claim_next_due_op(1).unwrap().is_none(),
|
||
"the write waits for the rename of the same node"
|
||
);
|
||
db.delete_op(first.id).unwrap();
|
||
let second = db.claim_next_due_op(1).unwrap().unwrap();
|
||
assert_eq!(
|
||
(second.kind.as_str(), second.uid.as_str()),
|
||
(OP_REVISION, real.as_str())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_rename_made_while_its_create_uploads_is_queued_for_the_landed_node() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let local = "local~abc";
|
||
let real = uid("real").to_string();
|
||
let parent = uid("parent").to_string();
|
||
let (id, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(uid("parent").to_string()),
|
||
name: Some("new.txt".to_string()),
|
||
blob_path: Some("/staging/sent".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
// The drain took the create; the user moves the file into a folder that
|
||
// is itself still queued.
|
||
db.rewrite_op_target(local, "local~dir", "moved.txt")
|
||
.unwrap();
|
||
|
||
let mut landed = landing(local, &real, &parent, "new.txt");
|
||
landed.landed.1 = "new (conflict).txt";
|
||
db.finish_create(id, Some("/staging/sent"), &landed, |_| None)
|
||
.unwrap();
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1, "the move outlives the create");
|
||
assert_eq!(ops[0].kind, OP_RENAME);
|
||
assert_eq!(ops[0].uid, real);
|
||
assert_eq!(ops[0].parent_uid.as_deref(), Some("local~dir"));
|
||
assert_eq!(ops[0].name.as_deref(), Some("moved.txt"));
|
||
let meta: RenameMeta = serde_json::from_str(ops[0].meta_json.as_deref().unwrap()).unwrap();
|
||
assert_eq!(meta.original_parent_uid, uid("parent").to_string());
|
||
assert_eq!(meta.original_name.as_deref(), Some("new (conflict).txt"));
|
||
|
||
// The folder lands next and the move follows it.
|
||
db.land_placeholder_row("local~dir", &uid("dir").to_string())
|
||
.unwrap();
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops[0].parent_uid, Some(uid("dir").to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn draining_a_folder_repoints_its_queued_children() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let local_dir = "local~dir";
|
||
let real_dir = uid("realdir").to_string();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: "local~child".to_string(),
|
||
parent_uid: Some(local_dir.to_string()),
|
||
name: Some("inside.txt".to_string()),
|
||
blob_path: Some("/staging/child".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 2,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
|
||
db.land_placeholder_row(local_dir, &real_dir).unwrap();
|
||
|
||
// The child was queued against a folder that did not exist yet. Once the
|
||
// folder is real, the child must target the server's uid — otherwise the
|
||
// upload would address `local~dir` and 404.
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops[0].parent_uid.as_deref(), Some(real_dir.as_str()));
|
||
}
|
||
|
||
/// B120: a child queued inside a folder that has just landed is checked
|
||
/// against the folder's real uid before the server's copy of it arrives.
|
||
#[test]
|
||
fn a_landed_folder_keeps_a_row_until_the_server_copy_replaces_it() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("dir"));
|
||
let mut placeholder = folder("dir", Some("root"), "New folder");
|
||
placeholder.uid = local.clone();
|
||
db.upsert_node(&placeholder).unwrap();
|
||
let mut child = file("child", "root", "inside.txt", 1);
|
||
child.parent_uid = Some(local.clone());
|
||
db.upsert_node(&child).unwrap();
|
||
let (id, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_MKDIR.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(uid("root").to_string()),
|
||
name: Some("New folder".to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
|
||
db.finish_create(
|
||
id,
|
||
None,
|
||
&landing(
|
||
&local.to_string(),
|
||
&uid("real").to_string(),
|
||
&uid("root").to_string(),
|
||
"New folder",
|
||
),
|
||
|_| None,
|
||
)
|
||
.unwrap();
|
||
|
||
assert!(db.node_by_uid(&local.to_string()).unwrap().is_none());
|
||
let landed = db
|
||
.node_by_uid(&uid("real").to_string())
|
||
.unwrap()
|
||
.expect("the landed folder has a row before the server's copy is read");
|
||
assert_eq!(landed.uid, uid("real"));
|
||
assert_eq!(landed.name, "New folder");
|
||
assert_eq!(
|
||
db.effective_node_access(&uid("child")).unwrap(),
|
||
Some(crate::Access::Owner),
|
||
"the child's authority resolves without a network re-read"
|
||
);
|
||
let names: Vec<_> = db
|
||
.known_children(&uid("real"))
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|n| n.name)
|
||
.collect();
|
||
assert_eq!(names, ["inside.txt"]);
|
||
let found = db.search("inside", 10).unwrap();
|
||
assert_eq!(found.len(), 1);
|
||
assert_eq!(found[0].path, "New folder/inside.txt");
|
||
}
|
||
|
||
/// A queued create names its folder by local id, so it is sent into the folder
|
||
/// as soon as the folder's row has its real uid, whatever the op's own copy
|
||
/// of the folder's uid still says.
|
||
#[test]
|
||
fn a_create_waits_for_its_folder_by_local_id() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("dir"));
|
||
let mut placeholder = folder("dir", Some("root"), "New folder");
|
||
placeholder.uid = local.clone();
|
||
db.upsert_node(&placeholder).unwrap();
|
||
let dir = db.lid_of(&local.to_string()).unwrap().unwrap();
|
||
let (id, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: "local~child".to_string(),
|
||
parent_uid: Some(local.to_string()),
|
||
name: Some("inside.txt".to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
let parent_lid: Option<i64> = db
|
||
.conn
|
||
.lock()
|
||
.query_row(
|
||
"SELECT parent_lid FROM pending_op WHERE id = ?1",
|
||
[id],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(parent_lid, Some(dir));
|
||
assert!(
|
||
db.claim_next_due_op(1).unwrap().is_none(),
|
||
"nothing to send into a folder that has not landed"
|
||
);
|
||
|
||
db.conn
|
||
.lock()
|
||
.execute(
|
||
"UPDATE nodes SET uid = ?2 WHERE lid = ?1",
|
||
rusqlite::params![dir, uid("dir").to_string()],
|
||
)
|
||
.unwrap();
|
||
|
||
let op = db.claim_next_due_op(1).unwrap().expect("the create is due");
|
||
assert_eq!(op.id, id);
|
||
assert_eq!(op.parent_uid, Some(uid("dir").to_string()));
|
||
}
|
||
|
||
fn parent_lid(db: &Db, uid: &NodeUid) -> Option<i64> {
|
||
db.conn
|
||
.lock()
|
||
.query_row(
|
||
"SELECT parent_lid FROM nodes WHERE uid = ?1",
|
||
[uid.to_string()],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap()
|
||
}
|
||
|
||
/// A node stored before its folder, as an event can bring it, is linked to
|
||
/// the folder by local id once the folder's row arrives.
|
||
#[test]
|
||
fn a_node_stored_before_its_folder_is_linked_when_the_folder_arrives() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&file("child", "dir", "inside.txt", 1))
|
||
.unwrap();
|
||
assert_eq!(parent_lid(&db, &uid("child")), None);
|
||
|
||
db.upsert_node(&folder("dir", Some("root"), "Folder"))
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
parent_lid(&db, &uid("child")),
|
||
db.lid_of(&uid("dir").to_string()).unwrap()
|
||
);
|
||
assert_eq!(
|
||
parent_lid(&db, &uid("dir")),
|
||
db.lid_of(&uid("root").to_string()).unwrap()
|
||
);
|
||
}
|
||
|
||
/// A restart puts a node below the folder its row links to by local id, under
|
||
/// the uid that row has now, whatever the node's own copy of it says.
|
||
#[test]
|
||
fn a_restart_finds_a_node_under_its_landed_folder() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("dir"));
|
||
let mut placeholder = folder("dir", Some("root"), "New folder");
|
||
placeholder.uid = local.clone();
|
||
db.upsert_node(&placeholder).unwrap();
|
||
let mut child = file("child", "root", "inside.txt", 1);
|
||
child.parent_uid = Some(local.clone());
|
||
db.upsert_node(&child).unwrap();
|
||
let dir = db.lid_of(&local.to_string()).unwrap().unwrap();
|
||
db.conn
|
||
.lock()
|
||
.execute(
|
||
"UPDATE nodes SET uid = ?2 WHERE lid = ?1",
|
||
rusqlite::params![dir, uid("dir").to_string()],
|
||
)
|
||
.unwrap();
|
||
|
||
let stored = db.load_all().unwrap();
|
||
let child = stored.iter().find(|s| s.node.uid == uid("child")).unwrap();
|
||
assert_eq!(child.node.parent_uid, Some(uid("dir")));
|
||
}
|
||
|
||
/// A rename queued against the row Drive listed for a folder still landing
|
||
/// follows the folder when that row is merged into the landing one (B140).
|
||
#[test]
|
||
fn an_op_on_a_listed_twin_follows_the_folder_that_lands() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("dir"));
|
||
let mut placeholder = folder("dir", Some("root"), "New folder");
|
||
placeholder.uid = local.clone();
|
||
db.upsert_node(&placeholder).unwrap();
|
||
db.upsert_node(&folder("real", Some("root"), "New folder"))
|
||
.unwrap();
|
||
let kept = db.lid_of(&local.to_string()).unwrap().unwrap();
|
||
let (rename, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_RENAME.to_string(),
|
||
uid: uid("real").to_string(),
|
||
parent_uid: Some(uid("root").to_string()),
|
||
name: Some("Renamed".to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
|
||
db.land_placeholder_row(&local.to_string(), &uid("real").to_string())
|
||
.unwrap();
|
||
|
||
let lid: Option<i64> = db
|
||
.conn
|
||
.lock()
|
||
.query_row("SELECT lid FROM pending_op WHERE id = ?1", [rename], |r| {
|
||
r.get(0)
|
||
})
|
||
.unwrap();
|
||
assert_eq!(lid, Some(kept));
|
||
assert_eq!(db.lid_of(&uid("real").to_string()).unwrap(), Some(kept));
|
||
}
|
||
|
||
/// B148: a folder pinned before it landed is still pinned once it has.
|
||
#[test]
|
||
fn a_folder_pinned_before_it_landed_stays_pinned() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("dir"));
|
||
let mut placeholder = folder("dir", Some("root"), "Keep");
|
||
placeholder.uid = local.clone();
|
||
db.upsert_node(&placeholder).unwrap();
|
||
db.pin_add(&local.to_string(), "Keep", true).unwrap();
|
||
let (id, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_MKDIR.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(uid("root").to_string()),
|
||
name: Some("Keep".to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
|
||
db.finish_create(
|
||
id,
|
||
None,
|
||
&landing(
|
||
&local.to_string(),
|
||
&uid("real").to_string(),
|
||
&uid("root").to_string(),
|
||
"Keep",
|
||
),
|
||
|_| None,
|
||
)
|
||
.unwrap();
|
||
|
||
assert!(db.is_pinned(&uid("real").to_string()).unwrap());
|
||
assert!(!db.is_pinned(&local.to_string()).unwrap());
|
||
let pins = db.pin_list().unwrap();
|
||
assert_eq!(pins.len(), 1);
|
||
assert_eq!(pins[0].uid, uid("real").to_string());
|
||
assert!(pins[0].recursive);
|
||
}
|
||
|
||
/// A file deleted while its create was on the wire still lands: nothing stops
|
||
/// an upload in flight. The node it made is unwanted, so a trash of it is
|
||
/// queued in the same transaction, already claimed by the drain that landed it
|
||
/// (`docs/BUGS.md` B129).
|
||
#[test]
|
||
fn a_create_withdrawn_while_it_uploads_queues_a_trash_of_the_landed_node() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let local = "local~gone";
|
||
let real = uid("real").to_string();
|
||
let parent = uid("parent").to_string();
|
||
let (id, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(parent.clone()),
|
||
name: Some("d".to_string()),
|
||
blob_path: Some("/staging/sent".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
db.claim_next_due_op(10)
|
||
.unwrap()
|
||
.expect("the drain takes the create");
|
||
db.delete_ops_for_uid(local).unwrap();
|
||
|
||
let retired = db
|
||
.finish_create(
|
||
id,
|
||
Some("/staging/sent"),
|
||
&landing(local, &real, &parent, "d"),
|
||
|_| None,
|
||
)
|
||
.unwrap();
|
||
|
||
let CreateRetired::Withdrawn { trash } = retired else {
|
||
panic!("a withdrawn create is not landed: {retired:?}");
|
||
};
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!(ops[0].id, trash);
|
||
assert_eq!(ops[0].kind, OP_TRASH);
|
||
assert_eq!(ops[0].uid, real);
|
||
assert_eq!(ops[0].name.as_deref(), Some("d"));
|
||
assert!(
|
||
db.claim_next_due_op(i64::MAX / 2).unwrap().is_none(),
|
||
"the trash is the landing drain's to send"
|
||
);
|
||
}
|
||
|
||
/// A file deleted after its create was sent and failed may still have been
|
||
/// made: the answer can be lost after Drive made it. Its trash keeps the
|
||
/// create's folder and blob to find that node by, and holds the name in the
|
||
/// folder until it is done (`docs/BUGS.md` B151). A create never sent goes,
|
||
/// blob and all.
|
||
#[test]
|
||
fn a_create_withdrawn_after_it_failed_queues_a_trash_that_keeps_its_blob() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let parent = uid("parent").to_string();
|
||
let create = |local: &str, blob: &str| {
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(parent.clone()),
|
||
name: Some("f.txt".to_string()),
|
||
blob_path: Some(blob.to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap()
|
||
.0
|
||
};
|
||
let sent = create("local~sent", "/staging/sent");
|
||
create("local~unsent", "/staging/unsent");
|
||
db.record_op_failure(sent, "no answer", 0).unwrap();
|
||
|
||
assert!(db.delete_ops_for_uid("local~sent").unwrap().is_empty());
|
||
assert_eq!(
|
||
db.delete_ops_for_uid("local~unsent").unwrap(),
|
||
vec!["/staging/unsent".to_string()]
|
||
);
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!(ops[0].kind, OP_TRASH);
|
||
assert_eq!(ops[0].uid, "local~sent");
|
||
assert_eq!(ops[0].parent_uid.as_deref(), Some(parent.as_str()));
|
||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/sent"));
|
||
assert!(db.has_withdrawn_create(&parent, "f.txt").unwrap());
|
||
assert!(!db.has_withdrawn_create(&parent, "g.txt").unwrap());
|
||
|
||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("sent"));
|
||
assert_eq!(
|
||
db.complete_trash_op(sent, &local).unwrap().as_deref(),
|
||
Some("/staging/sent")
|
||
);
|
||
assert!(db.pending_ops().unwrap().is_empty());
|
||
}
|
||
|
||
/// What a queued create sent, and where, stays on its row: through a newer
|
||
/// blob, through the turn into a trash a delete makes of it, and across a
|
||
/// reopen. The trash finds the file the create made by those bytes, after a
|
||
/// restart too, and under a name the file had before a rename
|
||
/// (`docs/BUGS.md` B183, B184).
|
||
#[test]
|
||
fn what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-create-sent-{}-{}.db",
|
||
std::process::id(),
|
||
now_test_id()
|
||
));
|
||
let parent = uid("parent").to_string();
|
||
let id = {
|
||
let db = Db::open(&path).unwrap();
|
||
let (id, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: "local~sent".to_string(),
|
||
parent_uid: Some(parent.clone()),
|
||
name: Some("d".to_string()),
|
||
blob_path: Some("/staging/first".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
assert!(db.create_sent(id).unwrap().is_empty());
|
||
assert!(db.create_targets(id).unwrap().is_empty());
|
||
db.note_create_target(id, &parent, "c").unwrap();
|
||
db.note_create_target(id, &parent, "c").unwrap();
|
||
db.note_create_sent(id, "aaaa").unwrap();
|
||
db.note_create_sent(id, "aaaa").unwrap();
|
||
db.record_op_failure(id, "no answer", 0).unwrap();
|
||
db.attach_blob_to_create("local~sent", "/staging/second", "{}")
|
||
.unwrap()
|
||
.expect("the create is still queued");
|
||
db.note_create_target(id, &parent, "d").unwrap();
|
||
db.note_create_sent(id, "bbbb").unwrap();
|
||
db.record_op_failure(id, "no answer", 0).unwrap();
|
||
db.delete_ops_for_uid("local~sent").unwrap();
|
||
id
|
||
};
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!((ops[0].id, ops[0].kind.as_str()), (id, OP_TRASH));
|
||
assert_eq!(db.create_sent(id).unwrap(), vec!["aaaa", "bbbb"]);
|
||
assert_eq!(
|
||
db.create_targets(id).unwrap(),
|
||
vec![
|
||
(parent.clone(), "c".to_string()),
|
||
(parent.clone(), "d".to_string())
|
||
]
|
||
);
|
||
drop(db);
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
|
||
/// A write after a create failed clears its attempts, so the next try goes
|
||
/// out at once. The create was still sent: deleting the file then left no
|
||
/// trash, and the file a lost answer made stayed on Drive under the name it
|
||
/// was sent with (`docs/BUGS.md` B191).
|
||
#[test]
|
||
fn a_create_rewritten_after_it_failed_still_leaves_a_trash_when_deleted() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let parent = uid("parent").to_string();
|
||
let (id, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: "local~sent".to_string(),
|
||
parent_uid: Some(parent.clone()),
|
||
name: Some("a.txt".to_string()),
|
||
blob_path: Some("/staging/first".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
db.note_create_target(id, &parent, "a.txt").unwrap();
|
||
db.note_create_sent(id, "aaaa").unwrap();
|
||
db.record_op_failure(id, "no answer", 0).unwrap();
|
||
db.attach_blob_to_create("local~sent", "/staging/second", "{}")
|
||
.unwrap()
|
||
.expect("the create is still queued");
|
||
|
||
assert!(db.delete_ops_for_uid("local~sent").unwrap().is_empty());
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!((ops[0].id, ops[0].kind.as_str()), (id, OP_TRASH));
|
||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/second"));
|
||
assert_eq!(
|
||
db.create_targets(id).unwrap(),
|
||
vec![(parent.clone(), "a.txt".to_string())]
|
||
);
|
||
}
|
||
|
||
/// A create deleted after it was sent becomes a trash in its place. The
|
||
/// create's backoff, and the failure of an attempt still on the wire, are the
|
||
/// create's: the trash is due at once (`docs/BUGS.md` B171).
|
||
#[test]
|
||
fn a_create_deleted_after_it_was_sent_leaves_a_trash_that_is_due_at_once() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let parent = uid("parent").to_string();
|
||
let create = |local: &str| {
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(parent.clone()),
|
||
name: Some(format!("{local}.txt")),
|
||
blob_path: Some(format!("/staging/{local}")),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap()
|
||
.0
|
||
};
|
||
let failed = create("local~failed");
|
||
db.record_attempt_failure(failed, OP_CREATE, "no answer", 50_000)
|
||
.unwrap();
|
||
let sending = create("local~sending");
|
||
assert_eq!(
|
||
db.claim_next_due_op(10).unwrap().map(|op| op.id),
|
||
Some(sending)
|
||
);
|
||
|
||
db.delete_ops_for_uid("local~failed").unwrap();
|
||
db.delete_ops_for_uid("local~sending").unwrap();
|
||
db.record_attempt_failure(sending, OP_CREATE, "No such file or directory", 50_000)
|
||
.unwrap();
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 2);
|
||
for op in &ops {
|
||
assert_eq!(op.kind, OP_TRASH);
|
||
assert_eq!((op.attempts, op.last_error.as_deref()), (0, None), "{op:?}");
|
||
assert_eq!(op.next_attempt_at, 0, "{op:?}");
|
||
}
|
||
db.record_attempt_failure(failed, OP_TRASH, "offline", 50_000)
|
||
.unwrap();
|
||
let trash = db
|
||
.pending_ops()
|
||
.unwrap()
|
||
.into_iter()
|
||
.find(|op| op.id == failed);
|
||
assert_eq!(trash.map(|op| op.attempts), Some(1));
|
||
}
|
||
|
||
#[test]
|
||
fn a_landed_placeholder_never_overwrites_the_server_copy() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("dir"));
|
||
let mut placeholder = folder("dir", Some("root"), "New folder");
|
||
placeholder.uid = local.clone();
|
||
db.upsert_node(&placeholder).unwrap();
|
||
// The event stream reported the folder before the drain got round to it.
|
||
db.upsert_node(&folder("real", Some("root"), "New folder (1)"))
|
||
.unwrap();
|
||
|
||
db.land_placeholder_row(&local.to_string(), &uid("real").to_string())
|
||
.unwrap();
|
||
|
||
assert!(db.node_by_uid(&local.to_string()).unwrap().is_none());
|
||
assert_eq!(
|
||
db.node_by_uid(&uid("real").to_string())
|
||
.unwrap()
|
||
.map(|n| n.name),
|
||
Some("New folder (1)".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_node_keeps_its_lid_across_updates_and_a_new_one_never_reuses_it() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&file("a", "root", "a.txt", 1)).unwrap();
|
||
db.upsert_node(&file("b", "root", "b.txt", 1)).unwrap();
|
||
let a = db.lid_of(&uid("a").to_string()).unwrap().unwrap();
|
||
let b = db.lid_of(&uid("b").to_string()).unwrap().unwrap();
|
||
|
||
db.upsert_node(&file("a", "root", "renamed.txt", 2))
|
||
.unwrap();
|
||
assert_eq!(db.lid_of(&uid("a").to_string()).unwrap(), Some(a));
|
||
|
||
db.delete_node(&uid("b")).unwrap();
|
||
db.upsert_node(&file("c", "root", "c.txt", 1)).unwrap();
|
||
let c = db.lid_of(&uid("c").to_string()).unwrap().unwrap();
|
||
assert!(c > b, "lid {c} reuses one a deleted row had");
|
||
}
|
||
|
||
#[test]
|
||
fn a_lid_handed_out_before_the_node_is_stored_reads_as_no_node() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.set_share_access(&uid("root"), crate::Access::Owner)
|
||
.unwrap();
|
||
let stub = file("stub", "root", "stub.txt", 1);
|
||
let [lid] = db.lids_for(&[&stub]).unwrap()[..] else {
|
||
panic!("one lid per node");
|
||
};
|
||
|
||
assert_eq!(db.lid_of(&uid("stub").to_string()).unwrap(), Some(lid));
|
||
assert_eq!(db.lids_for(&[&stub]).unwrap(), vec![lid]);
|
||
assert!(db.node_by_uid(&uid("stub").to_string()).unwrap().is_none());
|
||
assert!(db.effective_node_access(&uid("stub")).unwrap().is_none());
|
||
assert!(db.node_path(&uid("stub").to_string()).unwrap().is_none());
|
||
assert!(!db.has_children(&uid("root")).unwrap());
|
||
assert!(db.search("stub", 10).unwrap().is_empty());
|
||
assert!(db.load_all().unwrap().iter().all(|n| n.lid != lid));
|
||
|
||
db.upsert_node(&stub).unwrap();
|
||
assert_eq!(db.lid_of(&uid("stub").to_string()).unwrap(), Some(lid));
|
||
assert!(db.has_children(&uid("root")).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn a_landed_placeholder_keeps_its_lid() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
for known in [false, true] {
|
||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from(format!("{known}")));
|
||
let real = uid(&format!("real-{known}"));
|
||
let mut placeholder = folder("dir", Some("root"), "New folder");
|
||
placeholder.uid = local.clone();
|
||
db.upsert_node(&placeholder).unwrap();
|
||
let lid = db.lid_of(&local.to_string()).unwrap().unwrap();
|
||
if known {
|
||
// The event stream reported the folder before the drain got round
|
||
// to it.
|
||
let mut listed = folder("x", Some("root"), "New folder (1)");
|
||
listed.uid = real.clone();
|
||
db.upsert_node(&listed).unwrap();
|
||
}
|
||
|
||
db.land_placeholder_row(&local.to_string(), &real.to_string())
|
||
.unwrap();
|
||
|
||
assert_eq!(db.lid_of(&local.to_string()).unwrap(), None);
|
||
assert_eq!(db.lid_of(&real.to_string()).unwrap(), Some(lid));
|
||
assert_eq!(db.uid_of_lid(lid).unwrap(), Some(real.to_string()));
|
||
let name = if known {
|
||
"New folder (1)"
|
||
} else {
|
||
"New folder"
|
||
};
|
||
assert_eq!(
|
||
db.node_by_uid(&real.to_string()).unwrap().map(|n| n.name),
|
||
Some(name.to_string())
|
||
);
|
||
assert_eq!(db.search(name, 10).unwrap().len(), 1, "{name}");
|
||
}
|
||
}
|
||
|
||
/// Drive lists none of what is queued for a folder, so a listing read from
|
||
/// it is laid over with what the queue made, moved in and moved away (B132).
|
||
#[test]
|
||
fn queued_children_are_what_drive_does_not_list_yet() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("dir", Some("root"), "dir")).unwrap();
|
||
db.upsert_node(&folder("other", Some("root"), "other"))
|
||
.unwrap();
|
||
let op = |kind: &str, uid: &str, parent: &str, name: &str| PendingOp {
|
||
id: 0,
|
||
kind: kind.to_string(),
|
||
uid: uid.to_string(),
|
||
parent_uid: Some(parent.to_string()),
|
||
name: Some(name.to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
let dir = uid("dir").to_string();
|
||
let other = uid("other").to_string();
|
||
|
||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("new"));
|
||
let mut made = file("x", "dir", "made.txt", 3);
|
||
made.uid = local.clone();
|
||
db.upsert_node(&made).unwrap();
|
||
db.enqueue_op(&op(OP_CREATE, &local.to_string(), &dir, "made.txt"))
|
||
.unwrap();
|
||
db.upsert_node(&file("in", "dir", "in.txt", 3)).unwrap();
|
||
db.enqueue_op(&op(OP_RENAME, &uid("in").to_string(), &dir, "in.txt"))
|
||
.unwrap();
|
||
db.upsert_node(&file("out", "other", "out.txt", 3)).unwrap();
|
||
db.enqueue_op(&op(OP_RENAME, &uid("out").to_string(), &other, "out.txt"))
|
||
.unwrap();
|
||
db.upsert_node(&file("still", "dir", "still.txt", 3))
|
||
.unwrap();
|
||
|
||
let (here, gone) = db.queued_children(&uid("dir")).unwrap();
|
||
let mut names: Vec<_> = here.iter().map(|n| n.name.as_str()).collect();
|
||
names.sort();
|
||
assert_eq!(names, ["in.txt", "made.txt"]);
|
||
assert_eq!(
|
||
gone,
|
||
std::collections::HashSet::from([uid("out").to_string()])
|
||
);
|
||
}
|
||
|
||
/// A restart builds the tree from the node each row holds. A folder that
|
||
/// landed handed its children to the real uid in the row's column only, so
|
||
/// they came back under a parent no row has (B140).
|
||
#[test]
|
||
fn a_landed_folder_hands_its_children_over_in_the_stored_node_too() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("dir"));
|
||
let mut placeholder = folder("x", Some("root"), "dir");
|
||
placeholder.uid = local.clone();
|
||
db.upsert_node(&placeholder).unwrap();
|
||
let mut child = file("child", "x", "a.txt", 3);
|
||
child.parent_uid = Some(local.clone());
|
||
db.upsert_node(&child).unwrap();
|
||
|
||
db.land_placeholder_row(&local.to_string(), &uid("dir").to_string())
|
||
.unwrap();
|
||
|
||
let stored = db.node_by_uid(&uid("child").to_string()).unwrap().unwrap();
|
||
assert_eq!(stored.parent_uid, Some(uid("dir")));
|
||
let names: Vec<_> = db
|
||
.known_children(&uid("dir"))
|
||
.unwrap()
|
||
.into_iter()
|
||
.map(|n| n.name)
|
||
.collect();
|
||
assert_eq!(names, ["a.txt"]);
|
||
}
|
||
|
||
/// A folder made here that lands takes its real uid in its own row only. What
|
||
/// was made inside it names it by local id, and is found under the real uid.
|
||
#[test]
|
||
fn a_landed_folder_rewrites_no_row_or_op_below_it() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let root = uid("root").to_string();
|
||
let dir_lid = db.add_local_row(&root, "dir", true, 1).unwrap();
|
||
let local_dir = local_uid(dir_lid);
|
||
let mut dir = folder("x", Some("root"), "dir");
|
||
dir.uid = NodeUid::new(VolumeId::from("local"), LinkId::from(dir_lid.to_string()));
|
||
db.upsert_node(&dir).unwrap();
|
||
let child_lid = db.add_local_row(&local_dir, "a.txt", false, 1).unwrap();
|
||
let local_child = local_uid(child_lid);
|
||
let mut child = file("y", "x", "a.txt", 3);
|
||
child.uid = NodeUid::new(VolumeId::from("local"), LinkId::from(child_lid.to_string()));
|
||
child.parent_uid = Some(dir.uid.clone());
|
||
db.upsert_node(&child).unwrap();
|
||
db.enqueue_op(&op_on(OP_CREATE, &local_child, Some(&local_dir)))
|
||
.unwrap();
|
||
|
||
let real_dir = uid("dir").to_string();
|
||
db.land_placeholder_row(&local_dir, &real_dir).unwrap();
|
||
|
||
let (stored_parent, op_parent): (String, String) = {
|
||
let conn = db.conn.lock();
|
||
(
|
||
conn.query_row(
|
||
"SELECT parent_uid FROM nodes WHERE lid = ?1",
|
||
[child_lid],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap(),
|
||
conn.query_row("SELECT parent_uid FROM pending_op", [], |r| r.get(0))
|
||
.unwrap(),
|
||
)
|
||
};
|
||
assert_eq!(
|
||
(stored_parent.as_str(), op_parent.as_str()),
|
||
(local_dir.as_str(), local_dir.as_str()),
|
||
"the child's row and op are not rewritten"
|
||
);
|
||
|
||
let children = db.known_children(&uid("dir")).unwrap();
|
||
assert_eq!(children.len(), 1);
|
||
assert_eq!(children[0].parent_uid, Some(uid("dir")));
|
||
assert!(db.has_children(&uid("dir")).unwrap());
|
||
assert_eq!(
|
||
db.node_path(&local_child).unwrap().as_deref(),
|
||
Some("dir/a.txt")
|
||
);
|
||
assert_eq!(
|
||
db.pending_ops().unwrap()[0].parent_uid.as_deref(),
|
||
Some(real_dir.as_str()),
|
||
"the drain sends the folder's real uid"
|
||
);
|
||
|
||
db.delete_ops_for_uid(&real_dir).unwrap();
|
||
assert!(
|
||
db.pending_ops().unwrap().is_empty(),
|
||
"removing the folder takes the create made inside it"
|
||
);
|
||
}
|
||
|
||
/// A node the mount still holds under its stand-in, written after it landed,
|
||
/// updates the landed row: written under the stand-in, it brought the
|
||
/// placeholder row back beside it.
|
||
#[test]
|
||
fn a_stand_in_written_after_landing_updates_the_landed_row() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let lid = db
|
||
.add_local_row(&uid("root").to_string(), "a.txt", false, 1)
|
||
.unwrap();
|
||
let local = local_uid(lid);
|
||
let mut node = file("x", "root", "a.txt", 3);
|
||
node.uid = NodeUid::new(VolumeId::from("local"), LinkId::from(lid.to_string()));
|
||
db.upsert_node(&node).unwrap();
|
||
db.land_placeholder_row(&local, &uid("a").to_string())
|
||
.unwrap();
|
||
|
||
node.name = "b.txt".into();
|
||
db.upsert_node(&node).unwrap();
|
||
|
||
let rows: Vec<(i64, String, String)> = {
|
||
let conn = db.conn.lock();
|
||
let mut stmt = conn
|
||
.prepare("SELECT lid, uid, name FROM nodes WHERE uid != ?1")
|
||
.unwrap();
|
||
stmt.query_map([uid("root").to_string()], |r| {
|
||
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||
})
|
||
.unwrap()
|
||
.collect::<rusqlite::Result<_>>()
|
||
.unwrap()
|
||
};
|
||
assert_eq!(rows, [(lid, uid("a").to_string(), "b.txt".to_string())]);
|
||
let landed = db.node_by_uid(&uid("a").to_string()).unwrap().unwrap();
|
||
assert_eq!((landed.uid, landed.name), (uid("a"), "b.txt".to_string()));
|
||
}
|
||
|
||
/// A create queued inside a folder made here is sent under the folder's real
|
||
/// uid once it lands. Landing where it was sent is no move.
|
||
#[test]
|
||
fn a_create_sent_into_a_landed_folder_lands_where_it_was_queued() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let dir_lid = db
|
||
.add_local_row(&uid("root").to_string(), "dir", true, 1)
|
||
.unwrap();
|
||
let local_dir = local_uid(dir_lid);
|
||
let child_lid = db.add_local_row(&local_dir, "new.txt", false, 1).unwrap();
|
||
let local_child = local_uid(child_lid);
|
||
let (id, _) = db
|
||
.enqueue_op(&op_on(OP_CREATE, &local_child, Some(&local_dir)))
|
||
.unwrap();
|
||
let real_dir = uid("dir").to_string();
|
||
db.land_placeholder_row(&local_dir, &real_dir).unwrap();
|
||
|
||
let claimed = db.claim_next_due_op(1).unwrap().unwrap();
|
||
assert_eq!(claimed.parent_uid.as_deref(), Some(real_dir.as_str()));
|
||
let real = uid("new").to_string();
|
||
db.finish_create(
|
||
id,
|
||
Some("/staging/create"),
|
||
&landing(&local_child, &real, &real_dir, "new.txt"),
|
||
|_| None,
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
db.pending_ops().unwrap().is_empty(),
|
||
"no rename is queued for a file that landed where it was sent"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn migration_v36_gives_every_node_its_rowid_as_lid() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-v35-fixture-{}-{}.db",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
let local = NodeUid::new(VolumeId::from("local"), LinkId::from("dir"));
|
||
{
|
||
let db = Db::open(&path).unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let mut placeholder = folder("dir", Some("root"), "Offline folder");
|
||
placeholder.uid = local.clone();
|
||
db.upsert_node(&placeholder).unwrap();
|
||
db.upsert_node(&file("gone", "root", "gone.txt", 1))
|
||
.unwrap();
|
||
db.upsert_node(&file("kept", "root", "kept-report.txt", 1))
|
||
.unwrap();
|
||
db.delete_node(&uid("gone")).unwrap();
|
||
let (id, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_MKDIR.to_string(),
|
||
uid: local.to_string(),
|
||
parent_uid: Some(uid("root").to_string()),
|
||
name: Some("Offline folder".to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
db.record_op_failure(id, "network unreachable", 5_000)
|
||
.unwrap();
|
||
}
|
||
{
|
||
// Put the file back in the state a released V35 database was in:
|
||
// keyed by uid, with the rowids of the rows it held.
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
conn.execute_batch(
|
||
"CREATE TABLE nodes_v35 (
|
||
uid TEXT PRIMARY KEY, parent_uid TEXT, name TEXT NOT NULL,
|
||
is_dir INTEGER NOT NULL, size INTEGER, mtime INTEGER NOT NULL,
|
||
revision_hash TEXT, trashed INTEGER NOT NULL DEFAULT 0, node_json TEXT,
|
||
listed INTEGER NOT NULL DEFAULT 0, path TEXT
|
||
);
|
||
INSERT INTO nodes_v35 (rowid, uid, parent_uid, name, is_dir, size, mtime,
|
||
revision_hash, trashed, node_json, listed, path)
|
||
SELECT lid, uid, parent_uid, name, is_dir, size, mtime, revision_hash,
|
||
trashed, node_json, listed, path FROM nodes;
|
||
DROP TABLE nodes;
|
||
ALTER TABLE nodes_v35 RENAME TO nodes;
|
||
CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
|
||
CREATE INDEX idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
|
||
CREATE INDEX idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;
|
||
DELETE FROM sqlite_sequence WHERE name = 'nodes';
|
||
UPDATE sync_state SET value = '35' WHERE key = 'schema_version';",
|
||
)
|
||
.unwrap();
|
||
}
|
||
let rowids: Vec<(String, i64)> = {
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
let mut stmt = conn
|
||
.prepare("SELECT uid, rowid FROM nodes ORDER BY rowid")
|
||
.unwrap();
|
||
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
||
.unwrap()
|
||
.map(|row| row.unwrap())
|
||
.collect()
|
||
};
|
||
assert_eq!(rowids.len(), 3);
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
// The placeholder goes by its row's stand-in from V41 on.
|
||
let stand_in = |uid: &str, rowid: i64| match uid == local.to_string() {
|
||
true => local_uid(rowid),
|
||
false => uid.to_string(),
|
||
};
|
||
for (uid, rowid) in &rowids {
|
||
let uid = stand_in(uid, *rowid);
|
||
assert_eq!(db.lid_of(&uid).unwrap(), Some(*rowid), "{uid}");
|
||
}
|
||
let found = db.search("report", 10).unwrap();
|
||
assert_eq!(found.len(), 1, "the search index still finds the node");
|
||
assert_eq!(found[0].node.uid, uid("kept"));
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
let (_, dir) = rowids
|
||
.iter()
|
||
.find(|(uid, _)| *uid == local.to_string())
|
||
.unwrap();
|
||
assert_eq!(ops[0].uid, local_uid(*dir));
|
||
assert_eq!(ops[0].attempts, 1);
|
||
assert_eq!(ops[0].last_error.as_deref(), Some("network unreachable"));
|
||
|
||
db.upsert_node(&file("new", "root", "new.txt", 1)).unwrap();
|
||
let newest = db.lid_of(&uid("new").to_string()).unwrap().unwrap();
|
||
assert!(newest > rowids.last().unwrap().1);
|
||
drop(db);
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
#[test]
|
||
fn migration_v41_gives_every_placeholder_its_rows_stand_in() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-v40-fixture-{}-{}.db",
|
||
std::process::id(),
|
||
now_test_id()
|
||
));
|
||
let _ = std::fs::remove_file(&path);
|
||
let (dir, inner) = {
|
||
let db = Db::open(&path).unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
let old_dir = NodeUid::new(VolumeId::from("local"), LinkId::from("1700000000000-0"));
|
||
let old_inner = NodeUid::new(VolumeId::from("local"), LinkId::from("1700000000000-1"));
|
||
let mut dir = folder("x", Some("root"), "Offline folder");
|
||
dir.uid = old_dir.clone();
|
||
db.upsert_node(&dir).unwrap();
|
||
let mut inner = file("x", "root", "inner.txt", 0);
|
||
inner.uid = old_inner.clone();
|
||
inner.parent_uid = Some(old_dir.clone());
|
||
db.upsert_node(&inner).unwrap();
|
||
let op = |kind: &str, uid: &NodeUid, parent: &str, meta: Option<String>| PendingOp {
|
||
id: 0,
|
||
kind: kind.to_string(),
|
||
uid: uid.to_string(),
|
||
parent_uid: Some(parent.to_string()),
|
||
name: Some("n".to_string()),
|
||
blob_path: None,
|
||
meta_json: meta,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
db.enqueue_op(&op(OP_MKDIR, &old_dir, &uid("root").to_string(), None))
|
||
.unwrap();
|
||
let staged = json!({"uid": old_inner.to_string(), "len": 0}).to_string();
|
||
db.enqueue_op(&op(
|
||
OP_CREATE,
|
||
&old_inner,
|
||
&old_dir.to_string(),
|
||
Some(staged),
|
||
))
|
||
.unwrap();
|
||
db.pin_add(&old_dir.to_string(), "/Offline folder", true)
|
||
.unwrap();
|
||
let lids = (
|
||
db.lid_of(&old_dir.to_string()).unwrap().unwrap(),
|
||
db.lid_of(&old_inner.to_string()).unwrap().unwrap(),
|
||
);
|
||
drop(db);
|
||
rusqlite::Connection::open(&path)
|
||
.unwrap()
|
||
.execute(
|
||
"UPDATE sync_state SET value = '40' WHERE key = 'schema_version'",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
lids
|
||
};
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
let (dir_uid, inner_uid) = (local_uid(dir), local_uid(inner));
|
||
assert_eq!(db.uid_of_lid(dir).unwrap(), Some(dir_uid.clone()));
|
||
assert_eq!(db.uid_of_lid(inner).unwrap(), Some(inner_uid.clone()));
|
||
let stored = db.node_by_uid(&inner_uid).unwrap().unwrap();
|
||
assert_eq!(
|
||
stored.uid.to_string(),
|
||
inner_uid,
|
||
"node_json follows the row"
|
||
);
|
||
assert_eq!(
|
||
stored.parent_uid.map(|p| p.to_string()),
|
||
Some(dir_uid.clone())
|
||
);
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops[0].uid, dir_uid);
|
||
assert_eq!(ops[1].uid, inner_uid);
|
||
assert_eq!(ops[1].parent_uid, Some(dir_uid.clone()));
|
||
let staged: serde_json::Value =
|
||
serde_json::from_str(ops[1].meta_json.as_deref().unwrap()).unwrap();
|
||
assert_eq!(staged["uid"], inner_uid.as_str());
|
||
let pins: Vec<String> = db.pin_list().unwrap().into_iter().map(|p| p.uid).collect();
|
||
assert_eq!(pins, vec![dir_uid]);
|
||
drop(db);
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
/// A database as 2.8.2 wrote it (`sqlite3 .dump`, schema 35), with queued ops
|
||
/// in every state the drain leaves them in: due, parked, failing, deferred for
|
||
/// access, and claimed by a worker when the daemon stopped. The creates below
|
||
/// a folder made offline still name their parent by its `local~` placeholder.
|
||
#[test]
|
||
fn a_2_8_database_migrates_with_every_node_and_op_intact() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-2.8.2-{}-{}.db",
|
||
std::process::id(),
|
||
now_test_id()
|
||
));
|
||
let _ = std::fs::remove_file(&path);
|
||
rusqlite::Connection::open(&path)
|
||
.unwrap()
|
||
.execute_batch(include_str!("fixtures/v2.8.2.sql"))
|
||
.unwrap();
|
||
let (nodes, ops) = migrates_intact(&path);
|
||
assert_eq!((nodes, ops), (8, 6));
|
||
let db = Db::open(&path).unwrap();
|
||
let lid_named = |name: &str| -> i64 {
|
||
db.conn
|
||
.lock()
|
||
.query_row("SELECT lid FROM nodes WHERE name = ?1", [name], |r| {
|
||
r.get(0)
|
||
})
|
||
.unwrap()
|
||
};
|
||
let (dir, inner_lid) = (lid_named("Offline folder"), lid_named("inner.txt"));
|
||
let queued = db.pending_ops().unwrap();
|
||
let inner = queued
|
||
.iter()
|
||
.find(|op| op.uid == local_uid(inner_lid))
|
||
.unwrap();
|
||
assert_eq!(inner.parent_uid, Some(local_uid(dir)));
|
||
let lids: (Option<i64>, Option<i64>) = db
|
||
.conn
|
||
.lock()
|
||
.query_row(
|
||
"SELECT lid, parent_lid FROM pending_op WHERE id = ?1",
|
||
[inner.id],
|
||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
lids,
|
||
(Some(inner_lid), Some(dir)),
|
||
"the migration names the op's node and parent by local id"
|
||
);
|
||
let parent_lid: Option<i64> = db
|
||
.conn
|
||
.lock()
|
||
.query_row(
|
||
"SELECT parent_lid FROM nodes WHERE lid = ?1",
|
||
[inner_lid],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(parent_lid, Some(dir));
|
||
let parked = db.parked_create_ops().unwrap();
|
||
assert_eq!(parked.len(), 1);
|
||
assert_eq!(parked[0].name.as_deref(), Some("movie.part"));
|
||
drop(db);
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
/// The same migration over a copy of a database a released build wrote, which
|
||
/// the fixtures above cannot stand in for: years of rows, every kind of op, and
|
||
/// whatever shapes real use left behind. The copy is taken with `VACUUM INTO`
|
||
/// over a read-only connection, so the database named is never written.
|
||
///
|
||
/// `PDFS_MIGRATE_DB=~/.local/state/proton-drive-linux/cache.db cargo test -p
|
||
/// pdfs-core a_copy_of_a_real_database -- --ignored`
|
||
#[test]
|
||
#[ignore = "needs a real database: set PDFS_MIGRATE_DB"]
|
||
fn a_copy_of_a_real_database_migrates_with_every_node_and_op_intact() {
|
||
let source = std::env::var("PDFS_MIGRATE_DB").expect("set PDFS_MIGRATE_DB");
|
||
let path = std::env::temp_dir().join(format!("pdfs-db-real-{}.db", std::process::id()));
|
||
let _ = std::fs::remove_file(&path);
|
||
rusqlite::Connection::open_with_flags(&source, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||
.unwrap()
|
||
.execute("VACUUM INTO ?1", rusqlite::params![path.to_str().unwrap()])
|
||
.unwrap();
|
||
let (nodes, ops) = migrates_intact(&path);
|
||
eprintln!("{nodes} nodes, {ops} queued ops");
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
/// Opens the database at `path` and checks that the migration kept every node
|
||
/// on its row id and every queued op as it was. Returns how many of each.
|
||
fn migrates_intact(path: &std::path::Path) -> (usize, usize) {
|
||
let (version, nodes, mut ops, sealed) = {
|
||
let conn = rusqlite::Connection::open(path).unwrap();
|
||
let version: String = conn
|
||
.query_row(
|
||
"SELECT value FROM sync_state WHERE key = 'schema_version'",
|
||
[],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap();
|
||
let nodes: Vec<(String, i64)> = conn
|
||
.prepare("SELECT uid, rowid FROM nodes")
|
||
.unwrap()
|
||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
||
.unwrap()
|
||
.map(|row| row.unwrap())
|
||
.collect();
|
||
let ops = rows(&conn, QUEUED);
|
||
let sealed = rows(&conn, "SELECT * FROM own_sealed_rev ORDER BY uid");
|
||
(version, nodes, ops, sealed)
|
||
};
|
||
// A node made on this machine goes by its row's stand-in from schema 41
|
||
// on, in its ops as everywhere else.
|
||
let stand_ins: Vec<(String, String)> = nodes
|
||
.iter()
|
||
.filter(|(uid, rowid)| uid.starts_with("local~") && *uid != local_uid(*rowid))
|
||
.map(|(uid, rowid)| (uid.clone(), local_uid(*rowid)))
|
||
.collect();
|
||
let nodes: Vec<(String, i64)> = nodes
|
||
.into_iter()
|
||
.map(
|
||
|(uid, rowid)| match stand_ins.iter().any(|(old, _)| *old == uid) {
|
||
true => (local_uid(rowid), rowid),
|
||
false => (uid, rowid),
|
||
},
|
||
)
|
||
.collect();
|
||
for value in ops.iter_mut().flatten() {
|
||
if let rusqlite::types::Value::Text(text) = value {
|
||
for (old, new) in &stand_ins {
|
||
if text == old {
|
||
*text = new.clone();
|
||
} else {
|
||
*text = text.replace(&format!("\"{old}\""), &format!("\"{new}\""));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let db = Db::open(path).unwrap();
|
||
{
|
||
let conn = rusqlite::Connection::open(path).unwrap();
|
||
assert_eq!(
|
||
rows(&conn, QUEUED),
|
||
ops,
|
||
"every queued op survives as it was, parked, failing or deferred"
|
||
);
|
||
// A claim names a drain worker of the daemon that wrote the file.
|
||
let claimed: i64 = conn
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM pending_op WHERE claimed_at <> 0",
|
||
[],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(claimed, 0, "a stopped daemon's claims are released");
|
||
assert_eq!(
|
||
rows(&conn, "SELECT * FROM own_sealed_rev ORDER BY uid"),
|
||
sealed
|
||
);
|
||
}
|
||
if version.parse::<i64>().unwrap() < 36 {
|
||
for (uid, rowid) in &nodes {
|
||
assert_eq!(db.lid_of(uid).unwrap(), Some(*rowid), "{uid}");
|
||
}
|
||
} else {
|
||
for (uid, _) in &nodes {
|
||
assert!(db.lid_of(uid).unwrap().is_some(), "{uid}");
|
||
}
|
||
}
|
||
drop(db);
|
||
|
||
let db = Db::open(path).unwrap();
|
||
let conn = rusqlite::Connection::open(path).unwrap();
|
||
let count: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))
|
||
.unwrap();
|
||
assert_eq!(count as usize, nodes.len(), "no node is lost or doubled");
|
||
let version: String = conn
|
||
.query_row(
|
||
"SELECT value FROM sync_state WHERE key = 'schema_version'",
|
||
[],
|
||
|r| r.get(0),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(version, SCHEMA_VERSION.to_string());
|
||
drop(db);
|
||
(nodes.len(), ops.len())
|
||
}
|
||
|
||
/// Every column of the queue but the drain's claim.
|
||
const QUEUED: &str = "SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||
attempts, last_error, next_attempt_at, access_deferred_since
|
||
FROM pending_op ORDER BY id";
|
||
|
||
/// Every row `query` returns, every column.
|
||
fn rows(conn: &rusqlite::Connection, query: &str) -> Vec<Vec<rusqlite::types::Value>> {
|
||
let mut stmt = conn.prepare(query).unwrap();
|
||
let columns = stmt.column_count();
|
||
stmt.query_map([], |r| (0..columns).map(|i| r.get(i)).collect())
|
||
.unwrap()
|
||
.map(|row| row.unwrap())
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn a_failed_op_stays_queued_with_backoff() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: uid("a").to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: Some("/staging/blob".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
let id = db.pending_ops().unwrap()[0].id;
|
||
|
||
db.record_op_failure(id, "network unreachable", 5_000)
|
||
.unwrap();
|
||
|
||
// The staged blob is the only copy of the user's bytes: a failure must
|
||
// never drop the row, only defer it.
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!(ops[0].attempts, 1);
|
||
assert_eq!(ops[0].last_error.as_deref(), Some("network unreachable"));
|
||
assert_eq!(ops[0].next_attempt_at, 5_000);
|
||
|
||
db.record_op_failure(id, "still down", 9_000).unwrap();
|
||
assert_eq!(db.pending_ops().unwrap()[0].attempts, 2);
|
||
|
||
db.delete_op(id).unwrap();
|
||
assert_eq!(db.pending_op_counts().unwrap().uploads, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn an_access_blocked_op_is_deferred_without_counting_an_attempt() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: uid("blocked").to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: Some("/staging/blocked".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 3,
|
||
last_error: Some("earlier network failure".to_string()),
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
let before = db.pending_ops().unwrap();
|
||
let id = before[0].id;
|
||
let count = db.pending_op_counts().unwrap();
|
||
|
||
db.defer_op_without_attempt(id, 5_000).unwrap();
|
||
|
||
let after = db.pending_ops().unwrap();
|
||
assert_eq!(after.len(), 1);
|
||
let after_count = db.pending_op_counts().unwrap();
|
||
assert_eq!(after_count.uploads, count.uploads);
|
||
assert_eq!(after_count.changes, count.changes);
|
||
assert_eq!(after[0].attempts, before[0].attempts);
|
||
assert_eq!(after[0].last_error, before[0].last_error);
|
||
assert_eq!(after[0].next_attempt_at, 5_000);
|
||
}
|
||
|
||
#[test]
|
||
fn access_deferral_does_not_unpark_a_transient_create() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: uid("transient").to_string(),
|
||
parent_uid: Some(uid("parent").to_string()),
|
||
name: Some(".part".to_string()),
|
||
blob_path: Some("/staging/transient".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: PARK_UNTIL,
|
||
})
|
||
.unwrap();
|
||
let op = db.pending_ops().unwrap().remove(0);
|
||
|
||
db.defer_op_without_attempt(op.id, 5_000).unwrap();
|
||
|
||
assert_eq!(db.pending_ops().unwrap()[0].next_attempt_at, PARK_UNTIL);
|
||
}
|
||
|
||
/// The drain retires the op it worked on by id. An id given out again let
|
||
/// a rename that landed delete the trash queued over it.
|
||
#[test]
|
||
fn a_dropped_op_id_is_never_given_out_again() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let rename = |name: &str| PendingOp {
|
||
id: 0,
|
||
kind: OP_RENAME.to_string(),
|
||
uid: uid("node").to_string(),
|
||
parent_uid: Some(uid("root").to_string()),
|
||
name: Some(name.to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
let (renamed, _) = db.enqueue_op(&rename("b.txt")).unwrap();
|
||
let in_flight = db.claim_next_due_op(10).unwrap().unwrap();
|
||
assert_eq!(in_flight.id, renamed);
|
||
|
||
let (trash, _) = db
|
||
.replace_ops_with_trash(&uid("node").to_string(), "b.txt", 2)
|
||
.unwrap();
|
||
assert_ne!(trash, renamed);
|
||
db.delete_op(in_flight.id).unwrap();
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1);
|
||
assert_eq!(ops[0].kind, OP_TRASH);
|
||
}
|
||
|
||
#[test]
|
||
fn migrate_is_idempotent() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
// Second migrate is a no-op (already at head) and must not error.
|
||
db.migrate().unwrap();
|
||
}
|
||
|
||
/// A queued mode switch is a promise the daemon has to keep across a restart,
|
||
/// so it lives in the row, and reaching the mode is what retires it.
|
||
#[test]
|
||
fn pending_mode_is_queued_until_the_mode_is_reached() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let id = db
|
||
.sync_folder_add("/home/me/Downloads", "v~l", "s")
|
||
.unwrap();
|
||
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().pending_mode, None);
|
||
|
||
db.sync_folder_set_pending_mode(id, Some("ondemand"))
|
||
.unwrap();
|
||
assert_eq!(
|
||
db.sync_folder_get(id)
|
||
.unwrap()
|
||
.unwrap()
|
||
.pending_mode
|
||
.as_deref(),
|
||
Some("ondemand")
|
||
);
|
||
// The listing carries it too — it is what the front-ends paint from.
|
||
assert_eq!(
|
||
db.sync_folder_list().unwrap()[0].pending_mode.as_deref(),
|
||
Some("ondemand")
|
||
);
|
||
|
||
// Landing the switch satisfies the request: a `pending_mode` outliving it
|
||
// would have the engine try to apply the same switch on every later pass.
|
||
db.sync_folder_set_mode(id, "ondemand").unwrap();
|
||
let folder = db.sync_folder_get(id).unwrap().unwrap();
|
||
assert_eq!(folder.mode, "ondemand");
|
||
assert_eq!(folder.pending_mode, None);
|
||
|
||
// And the user can withdraw a request that hasn't landed yet.
|
||
db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap();
|
||
db.sync_folder_set_pending_mode(id, None).unwrap();
|
||
let folder = db.sync_folder_get(id).unwrap().unwrap();
|
||
assert_eq!(folder.mode, "ondemand");
|
||
assert_eq!(folder.pending_mode, None);
|
||
}
|
||
|
||
#[test]
|
||
fn mode_commit_clears_only_the_intent_it_satisfies() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let id = db
|
||
.sync_folder_add("/tmp/pdfs-mode-intent", "vol~folder", "share")
|
||
.unwrap();
|
||
|
||
db.sync_folder_set_pending_mode(id, Some("ondemand"))
|
||
.unwrap();
|
||
db.sync_folder_set_mode(id, "ondemand").unwrap();
|
||
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().pending_mode, None);
|
||
|
||
db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap();
|
||
db.sync_folder_set_mode(id, "ondemand").unwrap();
|
||
assert_eq!(
|
||
db.sync_folder_get(id)
|
||
.unwrap()
|
||
.unwrap()
|
||
.pending_mode
|
||
.as_deref(),
|
||
Some("mirror")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clearing_a_stale_pending_mode_preserves_the_newer_intent() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let id = db
|
||
.sync_folder_add("/tmp/pdfs-pending", "vol~folder", "share")
|
||
.unwrap();
|
||
db.sync_folder_set_pending_mode(id, Some("ondemand"))
|
||
.unwrap();
|
||
db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap();
|
||
|
||
assert!(
|
||
!db.sync_folder_clear_pending_mode_if(id, "ondemand")
|
||
.unwrap()
|
||
);
|
||
assert_eq!(
|
||
db.sync_folder_get(id)
|
||
.unwrap()
|
||
.unwrap()
|
||
.pending_mode
|
||
.as_deref(),
|
||
Some("mirror")
|
||
);
|
||
assert!(db.sync_folder_clear_pending_mode_if(id, "mirror").unwrap());
|
||
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().pending_mode, None);
|
||
}
|
||
|
||
fn baseline_row(rel: &str) -> StoredSyncEntry {
|
||
StoredSyncEntry {
|
||
rel_path: rel.into(),
|
||
remote_uid: Some(format!("vol~{rel}")),
|
||
local_mtime: 1_700_000_000,
|
||
local_mtime_ns: Some(1_700_000_000_000_000_001),
|
||
local_size: 7,
|
||
remote_rev: Some("1700000000".into()),
|
||
remote_hash: Some("7".into()),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn moving_a_baseline_subtree_takes_its_rows_and_leaves_name_prefixed_siblings() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let from = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap();
|
||
let to = db.sync_folder_add("/home/me/B", "vol~b", "share").unwrap();
|
||
for rel in [
|
||
"docs",
|
||
"docs/x.txt",
|
||
"docs/sub/y.txt",
|
||
"docsextra.txt",
|
||
"other",
|
||
] {
|
||
db.sync_entry_upsert(from, &baseline_row(rel)).unwrap();
|
||
}
|
||
db.sync_entry_upsert(to, &baseline_row("inbox")).unwrap();
|
||
|
||
db.sync_entries_move(from, "docs", to, "inbox/docs")
|
||
.unwrap();
|
||
|
||
let left = db.sync_entries(from).unwrap();
|
||
let mut left: Vec<_> = left.keys().cloned().collect();
|
||
left.sort();
|
||
assert_eq!(left, ["docsextra.txt", "other"]);
|
||
|
||
let moved = db.sync_entries(to).unwrap();
|
||
let mut keys: Vec<_> = moved.keys().cloned().collect();
|
||
keys.sort();
|
||
assert_eq!(
|
||
keys,
|
||
[
|
||
"inbox",
|
||
"inbox/docs",
|
||
"inbox/docs/sub/y.txt",
|
||
"inbox/docs/x.txt"
|
||
]
|
||
);
|
||
// Only the path changes: the row still describes the same node on both sides.
|
||
assert_eq!(
|
||
moved["inbox/docs/x.txt"],
|
||
StoredSyncEntry {
|
||
rel_path: "inbox/docs/x.txt".into(),
|
||
..baseline_row("docs/x.txt")
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn moving_a_baseline_subtree_within_one_folder_renames_it() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let folder = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap();
|
||
for rel in ["old", "old/f.txt", "new"] {
|
||
db.sync_entry_upsert(folder, &baseline_row(rel)).unwrap();
|
||
}
|
||
|
||
db.sync_entries_move(folder, "old", folder, "new/old")
|
||
.unwrap();
|
||
|
||
let mut keys: Vec<_> = db.sync_entries(folder).unwrap().into_keys().collect();
|
||
keys.sort();
|
||
assert_eq!(keys, ["new", "new/old", "new/old/f.txt"]);
|
||
}
|
||
|
||
#[test]
|
||
fn removing_a_baseline_subtree_keeps_name_prefixed_siblings() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let folder = db.sync_folder_add("/home/me/A", "vol~a", "share").unwrap();
|
||
for rel in ["docs", "docs/x.txt", "docsextra.txt"] {
|
||
db.sync_entry_upsert(folder, &baseline_row(rel)).unwrap();
|
||
}
|
||
|
||
db.sync_entries_remove_subtree(folder, "docs").unwrap();
|
||
|
||
let keys: Vec<_> = db.sync_entries(folder).unwrap().into_keys().collect();
|
||
assert_eq!(keys, ["docsextra.txt"]);
|
||
}
|
||
|
||
#[test]
|
||
fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let mirror = db
|
||
.sync_folder_add("/home/me/Mirror", "vol~mirror-root", "share")
|
||
.unwrap();
|
||
db.sync_entry_upsert(
|
||
mirror,
|
||
&StoredSyncEntry {
|
||
rel_path: "nested/file.txt".into(),
|
||
remote_uid: Some("vol~mirror-file".into()),
|
||
local_mtime: 1,
|
||
local_size: 2,
|
||
remote_rev: Some("3".into()),
|
||
remote_hash: Some("2".into()),
|
||
local_mtime_ns: None,
|
||
},
|
||
)
|
||
.unwrap();
|
||
let ondemand = db
|
||
.sync_folder_add("/home/me/OnDemand", "vol~ondemand-root", "share")
|
||
.unwrap();
|
||
db.sync_folder_set_mode(ondemand, "ondemand").unwrap();
|
||
db.sync_entry_upsert(
|
||
ondemand,
|
||
&StoredSyncEntry {
|
||
rel_path: "stale.txt".into(),
|
||
remote_uid: Some("vol~ondemand-stale".into()),
|
||
local_mtime: 1,
|
||
local_size: 2,
|
||
remote_rev: Some("3".into()),
|
||
remote_hash: Some("2".into()),
|
||
local_mtime_ns: None,
|
||
},
|
||
)
|
||
.unwrap();
|
||
|
||
assert!(db.mirror_contains_uid("vol~mirror-root").unwrap());
|
||
assert!(db.mirror_contains_uid("vol~mirror-file").unwrap());
|
||
assert!(!db.mirror_contains_uid("vol~ondemand-root").unwrap());
|
||
assert!(!db.mirror_contains_uid("vol~ondemand-stale").unwrap());
|
||
assert!(!db.mirror_contains_uid("vol~missing").unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn node_path_resolves_relative_to_a_sync_root_uid() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = node_from(serde_json::Value::Null, "root", "My Files", json!("Folder"));
|
||
let videos = node_from(json!(uid("root")), "videos", "Videos", json!("Folder"));
|
||
let anime = node_from(json!(uid("videos")), "anime", "anime", json!("Folder"));
|
||
let show = node_from(json!(uid("anime")), "show", "Oshi no Ko", json!("Folder"));
|
||
let episode = node_from(
|
||
json!(uid("show")),
|
||
"episode",
|
||
"episode 01.mkv",
|
||
json!({"File": {
|
||
"media_type": "video/x-matroska",
|
||
"total_size_on_storage": 42,
|
||
"claimed_size": 42,
|
||
"claimed_modification_time": null
|
||
}}),
|
||
);
|
||
db.upsert_nodes(&[root, videos, anime, show, episode])
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
db.path_relative_to(&uid("videos").to_string(), &uid("episode").to_string())
|
||
.unwrap()
|
||
.as_deref(),
|
||
Some("anime/Oshi no Ko/episode 01.mkv")
|
||
);
|
||
assert_eq!(
|
||
db.path_relative_to(&uid("videos").to_string(), &uid("videos").to_string())
|
||
.unwrap()
|
||
.as_deref(),
|
||
Some("")
|
||
);
|
||
assert_eq!(
|
||
db.path_relative_to(&uid("anime").to_string(), &uid("episode").to_string())
|
||
.unwrap()
|
||
.as_deref(),
|
||
Some("Oshi no Ko/episode 01.mkv")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn node_path_rejects_unrelated_missing_and_broken_ancestor_chains() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = node_from(serde_json::Value::Null, "root", "My Files", json!("Folder"));
|
||
let videos = node_from(json!(uid("root")), "videos", "Videos", json!("Folder"));
|
||
let episode = node_from(
|
||
json!(uid("videos")),
|
||
"episode",
|
||
"episode.mkv",
|
||
json!({"File": {
|
||
"media_type": "video/x-matroska",
|
||
"total_size_on_storage": 42,
|
||
"claimed_size": 42,
|
||
"claimed_modification_time": null
|
||
}}),
|
||
);
|
||
let unrelated = node_from(
|
||
json!(uid("root")),
|
||
"documents",
|
||
"Documents",
|
||
json!("Folder"),
|
||
);
|
||
let orphan = node_from(
|
||
json!(uid("missing-parent")),
|
||
"orphan",
|
||
"orphan.mkv",
|
||
json!({"File": {
|
||
"media_type": "video/x-matroska",
|
||
"total_size_on_storage": 42,
|
||
"claimed_size": 42,
|
||
"claimed_modification_time": null
|
||
}}),
|
||
);
|
||
db.upsert_nodes(&[root, videos, episode, unrelated, orphan])
|
||
.unwrap();
|
||
|
||
for (ancestor, descendant) in [
|
||
("documents", "episode"),
|
||
("videos", "missing-node"),
|
||
("videos", "orphan"),
|
||
] {
|
||
assert_eq!(
|
||
db.path_relative_to(&uid(ancestor).to_string(), &uid(descendant).to_string())
|
||
.unwrap(),
|
||
None
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cache_index_touch_access_and_lru_order() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.cache_touch("k1", "blob", 100, 10).unwrap();
|
||
db.cache_touch("k2", "blob", 200, 20).unwrap();
|
||
// LRU-first: k1 (older access) before k2.
|
||
let rows = db.cache_entries_by_kind("blob").unwrap();
|
||
assert_eq!(rows, vec![("k1".into(), 100), ("k2".into(), 200)]);
|
||
|
||
// Accessing k1 moves it to the back (most recent).
|
||
db.cache_accessed("k1", 30).unwrap();
|
||
let rows = db.cache_entries_by_kind("blob").unwrap();
|
||
assert_eq!(rows[0].0, "k2");
|
||
assert_eq!(rows[1].0, "k1");
|
||
|
||
// Re-touch updates size, not just time.
|
||
db.cache_touch("k1", "blob", 150, 40).unwrap();
|
||
let rows = db.cache_entries_by_kind("blob").unwrap();
|
||
assert_eq!(rows.iter().find(|(k, _)| k == "k1").unwrap().1, 150);
|
||
}
|
||
|
||
#[test]
|
||
fn cache_index_kinds_are_separate() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.cache_touch("blob1", "blob", 100, 1).unwrap();
|
||
db.cache_touch("blk1.b0", "block", 50, 1).unwrap();
|
||
assert_eq!(db.cache_entries_by_kind("blob").unwrap().len(), 1);
|
||
assert_eq!(db.cache_entries_by_kind("block").unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn cache_index_remove_and_remove_all() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
// A blob plus two of its blocks (key prefix shared).
|
||
db.cache_touch("abc", "blob", 1, 1).unwrap();
|
||
db.cache_touch("abc.b0", "block", 1, 1).unwrap();
|
||
db.cache_touch("abc.b1", "block", 1, 1).unwrap();
|
||
// An unrelated entry that must survive.
|
||
db.cache_touch("xyz", "blob", 1, 1).unwrap();
|
||
|
||
db.cache_remove("abc.b0").unwrap();
|
||
assert_eq!(db.cache_entries_by_kind("block").unwrap().len(), 1);
|
||
|
||
// remove_all drops the blob row and every remaining block of that uid.
|
||
db.cache_remove_all("abc").unwrap();
|
||
assert!(db.cache_entries_by_kind("block").unwrap().is_empty());
|
||
let blobs = db.cache_entries_by_kind("blob").unwrap();
|
||
assert_eq!(blobs, vec![("xyz".into(), 1)]);
|
||
}
|
||
|
||
/// A rebuild is a replacement, not a merge: whatever the index said before
|
||
/// is what a stale or externally-deleted cache file would leave behind.
|
||
#[test]
|
||
fn cache_index_rebuild_replaces_every_row() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.cache_touch("gone", "blob", 1, 1).unwrap();
|
||
db.cache_touch("also-gone", "block", 1, 1).unwrap();
|
||
|
||
db.cache_rebuild(&[CacheEntryInput {
|
||
key: "kept",
|
||
kind: "blob",
|
||
size: 7,
|
||
last_accessed: 42,
|
||
}])
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
db.cache_entries_by_kind("blob").unwrap(),
|
||
vec![("kept".to_string(), 7)]
|
||
);
|
||
assert!(db.cache_entries_by_kind("block").unwrap().is_empty());
|
||
|
||
// An empty rebuild is how a cache directory that vanished reports itself.
|
||
db.cache_rebuild(&[]).unwrap();
|
||
assert!(db.cache_entries_by_kind("blob").unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn pin_add_list_remove_roundtrip() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.pin_add("vol~a", "docs/a.txt", false).unwrap();
|
||
db.pin_add("vol~d", "docs", true).unwrap();
|
||
let list = db.pin_list().unwrap();
|
||
assert_eq!(list.len(), 2);
|
||
// No `nodes` row for either pin, so the kind is unknown rather than guessed
|
||
// from the recursive flag.
|
||
assert_eq!(
|
||
list[0],
|
||
PinRow {
|
||
uid: "vol~a".into(),
|
||
path: "docs/a.txt".into(),
|
||
recursive: false,
|
||
is_dir: None,
|
||
}
|
||
);
|
||
assert_eq!(
|
||
list[1],
|
||
PinRow {
|
||
uid: "vol~d".into(),
|
||
path: "docs".into(),
|
||
recursive: true,
|
||
is_dir: None,
|
||
}
|
||
);
|
||
|
||
// Once the node is cached, the pin reports the real kind — a folder pinned
|
||
// non-recursively must not look like a file.
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("d", Some("root"), "docs")).unwrap();
|
||
db.pin_add("vol~d", "docs", false).unwrap();
|
||
let list = db.pin_list().unwrap();
|
||
assert!(!list[1].recursive, "pin policy stays non-recursive");
|
||
assert_eq!(list[1].is_dir, Some(true), "but the node is still a folder");
|
||
|
||
// Re-pin refreshes path/flag, not a duplicate row.
|
||
db.pin_add("vol~a", "moved/a.txt", false).unwrap();
|
||
let list = db.pin_list().unwrap();
|
||
assert_eq!(list.len(), 2);
|
||
assert_eq!(list[0].path, "moved/a.txt");
|
||
|
||
assert!(db.pin_remove("vol~a").unwrap());
|
||
assert!(!db.pin_remove("vol~a").unwrap());
|
||
assert_eq!(db.pin_list().unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn is_pinned_direct_without_node_row() {
|
||
// A direct pin counts even when the node was never hydrated into `nodes`.
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.pin_add("vol~a", "a.txt", false).unwrap();
|
||
assert!(db.is_pinned("vol~a").unwrap());
|
||
assert!(!db.is_pinned("vol~b").unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn recursive_folder_pin_covers_subtree() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
// root/docs/{report.pdf, sub/deep.txt}, root/loose.txt
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("docs", Some("root"), "Documents"))
|
||
.unwrap();
|
||
db.upsert_node(&file("rep", "docs", "report.pdf", 1))
|
||
.unwrap();
|
||
db.upsert_node(&folder("sub", Some("docs"), "Sub")).unwrap();
|
||
db.upsert_node(&file("deep", "sub", "deep.txt", 1)).unwrap();
|
||
db.upsert_node(&file("loose", "root", "loose.txt", 1))
|
||
.unwrap();
|
||
|
||
// Pin the Documents folder recursively (uids are `vol~link` display form).
|
||
let du = |l: &str| uid(l).to_string();
|
||
db.pin_add(&du("docs"), "Documents", true).unwrap();
|
||
|
||
// Everything under docs (any depth) is pinned; loose.txt is not.
|
||
assert!(db.is_pinned(&du("docs")).unwrap());
|
||
assert!(db.is_pinned(&du("rep")).unwrap());
|
||
assert!(db.is_pinned(&du("deep")).unwrap());
|
||
assert!(!db.is_pinned(&du("loose")).unwrap());
|
||
|
||
// pinned_uids expands the subtree (folder + descendants), no loose.txt.
|
||
let mut got = db.pinned_uids().unwrap();
|
||
got.sort();
|
||
assert_eq!(got, vec![du("deep"), du("docs"), du("rep"), du("sub")]);
|
||
|
||
// descendants() lists the subtree of a folder (excludes the folder).
|
||
let mut desc = db.descendants(&du("docs")).unwrap();
|
||
desc.sort();
|
||
assert_eq!(desc, vec![du("deep"), du("rep"), du("sub")]);
|
||
}
|
||
|
||
#[test]
|
||
fn non_recursive_folder_pin_does_not_cover_children() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("docs", Some("root"), "Documents"))
|
||
.unwrap();
|
||
db.upsert_node(&file("rep", "docs", "report.pdf", 1))
|
||
.unwrap();
|
||
// A non-recursive pin on the folder covers only the folder itself.
|
||
let du = |l: &str| uid(l).to_string();
|
||
db.pin_add(&du("docs"), "Documents", false).unwrap();
|
||
assert!(db.is_pinned(&du("docs")).unwrap());
|
||
assert!(!db.is_pinned(&du("rep")).unwrap());
|
||
}
|
||
|
||
/// A `parent_uid` cycle is corrupt data the API can hand us, and `is_pinned`'s
|
||
/// ancestor walk is `UNION ALL` — uncapped it never terminates, while holding
|
||
/// the daemon's only SQLite connection, on a path that runs per cached read.
|
||
/// The answer here matters less than the fact that there is one.
|
||
#[test]
|
||
fn is_pinned_terminates_on_a_parent_cycle() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let du = |l: &str| uid(l).to_string();
|
||
// a -> b -> a, with nothing pinned above either.
|
||
db.upsert_node(&folder("a", Some("b"), "A")).unwrap();
|
||
db.upsert_node(&folder("b", Some("a"), "B")).unwrap();
|
||
db.upsert_node(&file("leaf", "a", "leaf.txt", 1)).unwrap();
|
||
|
||
assert!(!db.is_pinned(&du("leaf")).unwrap());
|
||
|
||
// And a pin *inside* the cycle is still found rather than walked past.
|
||
db.pin_add(&du("a"), "A", true).unwrap();
|
||
assert!(db.is_pinned(&du("leaf")).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn schema_objects_exist() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let count: i64 = db
|
||
.with_conn(|c| {
|
||
Ok(c.query_row(
|
||
"SELECT count(*) FROM sqlite_master
|
||
WHERE name IN ('nodes', 'nodes_fts', 'cache_entries')",
|
||
[],
|
||
|r| r.get(0),
|
||
)?)
|
||
})
|
||
.unwrap();
|
||
assert_eq!(count, 3);
|
||
}
|
||
|
||
/// A queued op carrying a realistically-sized `meta_json`, which is what made
|
||
/// the old scan expensive: the drain read and parsed every op's sidecar to pick
|
||
/// one.
|
||
fn bulk_op(i: usize, parent: &str) -> PendingOp {
|
||
PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: format!("vol~link{i}"),
|
||
parent_uid: Some(parent.to_string()),
|
||
name: Some(format!("file-{i}.bin")),
|
||
blob_path: Some(format!("/staging/vol~link{i}-{i}")),
|
||
// A StagedWrite sidecar, roughly the shape and size of a real one.
|
||
meta_json: Some(format!(
|
||
r#"{{"uid":"vol~link{i}","len":8388608,"base_size":8388608,
|
||
"base_mtime":1700000000,"authored":[[0,8388608]],"complete":true,
|
||
"based_on":{{"mtime":1700000000,"size":8388608}}}}"#
|
||
)),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
}
|
||
}
|
||
|
||
/// **The B3 measurement.** The drain picks one op per iteration. Doing that by
|
||
/// reading the whole queue is quadratic in queue length; doing it in SQL with
|
||
/// `LIMIT 1` is flat. Asserts the shape of the difference, not a wall-clock
|
||
/// number — the point is that one grows with the queue and the other does not.
|
||
#[test]
|
||
fn next_due_op_does_not_scale_with_queue_length() {
|
||
use std::time::Instant;
|
||
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = uid("root").to_string();
|
||
const N: usize = 2000;
|
||
for i in 0..N {
|
||
db.enqueue_op(&bulk_op(i, &root)).unwrap();
|
||
}
|
||
|
||
// Both must agree on which op is next.
|
||
let scanned = db
|
||
.pending_ops()
|
||
.unwrap()
|
||
.into_iter()
|
||
.find(|o| o.next_attempt_at <= 10);
|
||
let queried = db.next_due_op(10).unwrap();
|
||
assert_eq!(
|
||
scanned.as_ref().map(|o| o.id),
|
||
queried.as_ref().map(|o| o.id),
|
||
"the new query must pick the op the old scan picked"
|
||
);
|
||
assert_eq!(scanned.map(|o| o.uid), queried.map(|o| o.uid));
|
||
|
||
// Simulate a drain pass: pick the next op, retire it, repeat.
|
||
let rounds = 200;
|
||
|
||
let t0 = Instant::now();
|
||
for _ in 0..rounds {
|
||
let _ = db
|
||
.pending_ops()
|
||
.unwrap()
|
||
.into_iter()
|
||
.find(|o| o.next_attempt_at <= 10);
|
||
}
|
||
let scan = t0.elapsed();
|
||
|
||
let t1 = Instant::now();
|
||
for _ in 0..rounds {
|
||
let _ = db.next_due_op(10).unwrap();
|
||
}
|
||
let query = t1.elapsed();
|
||
|
||
println!("B3: {rounds} picks over a {N}-op queue — scan {scan:?}, query {query:?}");
|
||
assert!(
|
||
query * 20 < scan,
|
||
"expected the LIMIT 1 query to be far cheaper than a full scan; \
|
||
scan={scan:?} query={query:?}"
|
||
);
|
||
}
|
||
|
||
/// The readiness filter moved into SQL, so it needs its own coverage there: an
|
||
/// op whose parent was itself created offline is not yet sendable and must be
|
||
/// skipped in favour of a later one that is.
|
||
#[test]
|
||
fn next_due_op_skips_ops_blocked_on_a_local_parent() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = uid("root").to_string();
|
||
|
||
db.enqueue_op(&bulk_op(1, "local~dir")).unwrap();
|
||
db.enqueue_op(&bulk_op(2, &root)).unwrap();
|
||
|
||
let next = db.next_due_op(10).unwrap().expect("an op is due");
|
||
assert_eq!(next.uid, "vol~link2", "skipped the local-parent op");
|
||
|
||
// A NULL parent is not blocked.
|
||
let mut orphan = bulk_op(3, &root);
|
||
orphan.parent_uid = None;
|
||
db.enqueue_op(&orphan).unwrap();
|
||
db.delete_op(next.id).unwrap();
|
||
assert_eq!(db.next_due_op(10).unwrap().unwrap().uid, "vol~link3");
|
||
}
|
||
|
||
/// B70: a transient file's create is parked so its bytes never upload while it
|
||
/// wears that name, the park survives a write attaching its blob, and the
|
||
/// finalize rename (un-park) is what finally makes the completed file due.
|
||
#[test]
|
||
fn a_parked_transient_create_stays_off_the_drain_until_finalized() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let node = uid("dl").to_string();
|
||
let parent = uid("root").to_string();
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: node.clone(),
|
||
parent_uid: Some(parent),
|
||
name: Some("movie.mkv.part".to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: PARK_UNTIL,
|
||
})
|
||
.unwrap();
|
||
|
||
// Parked: never selected, however far the clock advances.
|
||
assert!(
|
||
db.next_due_op(PARK_UNTIL - 1).unwrap().is_none(),
|
||
"a parked create is never due"
|
||
);
|
||
|
||
// A write attaches its blob to the create but must not wake it.
|
||
let attached = db
|
||
.attach_blob_to_create(&node, "/staging/blob1", "{}")
|
||
.unwrap();
|
||
assert!(attached.is_some(), "the blob attached to the create");
|
||
assert!(
|
||
db.next_due_op(PARK_UNTIL - 1).unwrap().is_none(),
|
||
"still parked after a write attaches"
|
||
);
|
||
|
||
// Finalize: un-park, and the completed file is due to upload with its bytes.
|
||
assert!(
|
||
db.set_create_hold(&node, false).unwrap(),
|
||
"a create row was un-parked"
|
||
);
|
||
let due = db
|
||
.next_due_op(1_000)
|
||
.unwrap()
|
||
.expect("the un-parked create is due");
|
||
assert_eq!(due.uid, node);
|
||
assert_eq!(due.blob_path.as_deref(), Some("/staging/blob1"));
|
||
}
|
||
|
||
/// The park sweep's query: parked creates are the rows nothing else in the drain
|
||
/// can see, so it has to find them by the sentinel and report how long each has
|
||
/// been parked. Ordinary queued work is not its business.
|
||
#[test]
|
||
fn parked_creates_are_listed_with_the_age_the_sweep_judges_them_by() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let parent = uid("root").to_string();
|
||
let parked = uid("swap").to_string();
|
||
let due = uid("ordinary").to_string();
|
||
for (node, name, next_attempt_at, created_at) in [
|
||
(&parked, ".notes.txt.swp", PARK_UNTIL, 1_000),
|
||
(&due, "notes.txt", 0, 2_000),
|
||
] {
|
||
db.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_CREATE.to_string(),
|
||
uid: node.clone(),
|
||
parent_uid: Some(parent.clone()),
|
||
name: Some(name.to_string()),
|
||
blob_path: Some(format!("/staging/{name}")),
|
||
meta_json: None,
|
||
created_at,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at,
|
||
})
|
||
.unwrap();
|
||
}
|
||
|
||
let rows = db.parked_create_ops().unwrap();
|
||
assert_eq!(rows.len(), 1, "only the parked create is swept");
|
||
assert_eq!(rows[0].uid, parked);
|
||
assert_eq!(rows[0].created_at, 1_000, "the park's age is readable");
|
||
assert_eq!(
|
||
rows[0].blob_path.as_deref(),
|
||
Some("/staging/.notes.txt.swp")
|
||
);
|
||
|
||
// What the sweep does with an expired park: un-park it, and it drains like
|
||
// any other create rather than waiting for a rename that is not coming.
|
||
assert!(db.set_create_hold(&parked, false).unwrap());
|
||
assert!(
|
||
db.parked_create_ops().unwrap().is_empty(),
|
||
"an un-parked create is no longer the sweep's business"
|
||
);
|
||
assert_eq!(
|
||
db.next_due_op(1_000 + PARK_EXPIRY_MS).unwrap().unwrap().uid,
|
||
parked
|
||
);
|
||
}
|
||
|
||
/// Backoff still gates: nothing is returned before an op is due.
|
||
#[test]
|
||
fn next_due_op_respects_backoff() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = uid("root").to_string();
|
||
let id = db.enqueue_op(&bulk_op(1, &root)).unwrap().0;
|
||
db.record_op_failure(id, "boom", 5_000).unwrap();
|
||
|
||
assert!(
|
||
db.next_due_op(4_999).unwrap().is_none(),
|
||
"still backing off"
|
||
);
|
||
assert!(db.next_due_op(5_000).unwrap().is_some(), "due now");
|
||
}
|
||
|
||
/// **The B4 measurement.** `enforce_block_budget` runs on *every* `store_block`,
|
||
/// i.e. once per 4 MiB of every cold read. The old path read and sorted every
|
||
/// row of the cache index to answer a question that is almost always "no, we are
|
||
/// under budget" — under the shared connection lock, so it also stalled FUSE
|
||
/// metadata calls.
|
||
///
|
||
/// Asserts correctness — the aggregate must agree with summing the rows, and
|
||
/// must count the kinds apart — and *reports* the timing without asserting on
|
||
/// it. See the note at the measurement for why.
|
||
#[test]
|
||
fn cache_total_bytes_agrees_with_summing_the_rows() {
|
||
use std::time::Instant;
|
||
|
||
let db = Db::open_in_memory().unwrap();
|
||
// A 20 GB block cache at 4 MiB a block is ~5000 rows.
|
||
const N: usize = 5000;
|
||
const BLOCK: u64 = 4 << 20;
|
||
for i in 0..N {
|
||
db.cache_touch(&format!("k{i}.b0"), "block", BLOCK, i as i64)
|
||
.unwrap();
|
||
}
|
||
|
||
// The aggregate must agree with summing the rows.
|
||
let summed: u64 = db
|
||
.cache_entries_by_kind("block")
|
||
.unwrap()
|
||
.iter()
|
||
.map(|(_, s)| s)
|
||
.sum();
|
||
assert_eq!(db.cache_total_bytes("block").unwrap(), summed);
|
||
assert_eq!(summed, N as u64 * BLOCK);
|
||
// Kinds are counted apart.
|
||
assert_eq!(db.cache_total_bytes("blob").unwrap(), 0);
|
||
|
||
let rounds = 500;
|
||
|
||
let t0 = Instant::now();
|
||
for _ in 0..rounds {
|
||
let entries = db.cache_entries_by_kind("block").unwrap();
|
||
let _: u64 = entries.iter().map(|(_, s)| *s).sum();
|
||
}
|
||
let scan = t0.elapsed();
|
||
|
||
let t1 = Instant::now();
|
||
for _ in 0..rounds {
|
||
let _ = db.cache_total_bytes("block").unwrap();
|
||
}
|
||
let aggregate = t1.elapsed();
|
||
|
||
// Reported, not asserted. The SUM is cheaper than reading every row — no
|
||
// materialization, no allocation, no sort — but it is still O(rows), so this
|
||
// is a constant factor, and an assertion on a constant factor measured
|
||
// alongside the rest of the suite tests the machine's load as much as the
|
||
// query. The claim that matters — that the *common* path does not touch the
|
||
// database at all — is pinned by `budget_check_is_free_when_under_budget`,
|
||
// which compares against itself and so is load-independent.
|
||
println!(
|
||
"B4: {rounds} budget checks over {N} entries — scan {scan:?}, aggregate {aggregate:?}"
|
||
);
|
||
}
|
||
|
||
/// Victims still come out least-recently-accessed first, now in bounded batches
|
||
/// rather than one unbounded read of the table.
|
||
#[test]
|
||
fn cache_eviction_candidates_are_lru_ordered_and_limited() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
for i in 0..10u64 {
|
||
// Insert newest-first so insertion order cannot be mistaken for LRU order.
|
||
db.cache_touch(&format!("k{i}"), "blob", 100, (10 - i) as i64)
|
||
.unwrap();
|
||
}
|
||
db.cache_touch("other", "block", 100, 0).unwrap();
|
||
|
||
let batch = db.cache_eviction_candidates("blob", 3).unwrap();
|
||
assert_eq!(batch.len(), 3, "honours the limit");
|
||
let keys: Vec<&str> = batch.iter().map(|(k, _)| k.as_str()).collect();
|
||
assert_eq!(keys, ["k9", "k8", "k7"], "least-recently-accessed first");
|
||
assert!(
|
||
batch.iter().all(|(k, _)| k != "other"),
|
||
"a different kind is never a candidate"
|
||
);
|
||
}
|
||
|
||
/// **The B5 checkpoint.** A performance plan proposed concurrent SQLite
|
||
/// reads, on the premise that the single `Mutex<Connection>` serializes the FUSE
|
||
/// workers. This measures what the connection is actually asked to do now that
|
||
/// B3 and B4 have landed, so the proposal is decided on evidence.
|
||
///
|
||
/// Note what is *not* here: `lookup`/`getattr`/`readdir` do not read this
|
||
/// database in the steady state — they serve from `State::entries` in memory and
|
||
/// only write through on a cold fill. The per-read DB operation is
|
||
/// `cache_accessed`, one `UPDATE` per cache hit, which is what this drives.
|
||
#[test]
|
||
fn db_contention_under_fuse_worker_load() {
|
||
use std::sync::Arc;
|
||
use std::time::Instant;
|
||
|
||
let db = Arc::new(Db::open_in_memory().unwrap());
|
||
for i in 0..5000u64 {
|
||
db.cache_touch(&format!("k{i}.b0"), "block", 4 << 20, i as i64)
|
||
.unwrap();
|
||
}
|
||
|
||
// Eight workers, matching FUSE_WORKERS, each doing what a served block read
|
||
// does to the database: one LRU touch.
|
||
const WORKERS: usize = 8;
|
||
const PER_WORKER: usize = 2000;
|
||
|
||
let t = Instant::now();
|
||
let mut handles = Vec::new();
|
||
for w in 0..WORKERS {
|
||
let db = db.clone();
|
||
handles.push(std::thread::spawn(move || {
|
||
for i in 0..PER_WORKER {
|
||
db.cache_accessed(&format!("k{}.b0", (w * PER_WORKER + i) % 5000), i as i64)
|
||
.unwrap();
|
||
}
|
||
}));
|
||
}
|
||
for h in handles {
|
||
h.join().unwrap();
|
||
}
|
||
let concurrent = t.elapsed();
|
||
|
||
// The same work on one thread, for the serialization baseline.
|
||
let t = Instant::now();
|
||
for i in 0..(WORKERS * PER_WORKER) {
|
||
db.cache_accessed(&format!("k{}.b0", i % 5000), i as i64)
|
||
.unwrap();
|
||
}
|
||
let serial = t.elapsed();
|
||
|
||
let total = WORKERS * PER_WORKER;
|
||
println!(
|
||
"B5: {total} LRU touches — {WORKERS} threads {concurrent:?}, 1 thread {serial:?} \
|
||
(per-op {:?} vs {:?})",
|
||
concurrent / total as u32,
|
||
serial / total as u32
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_db_has_children_and_trashed_filtering() {
|
||
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||
let db = Db::open_in_memory().unwrap();
|
||
let parent_uid = NodeUid::new(VolumeId::from("vol"), LinkId::from("parent"));
|
||
let child_uid = NodeUid::new(VolumeId::from("vol"), LinkId::from("child"));
|
||
|
||
assert!(
|
||
!db.has_children(&parent_uid).unwrap(),
|
||
"no children initially"
|
||
);
|
||
|
||
let parent_node = Node {
|
||
uid: parent_uid.clone(),
|
||
parent_uid: None,
|
||
name: "parent".into(),
|
||
kind: NodeKind::Folder,
|
||
creation_time: 100,
|
||
modification_time: 100,
|
||
trashed: false,
|
||
is_shared: false,
|
||
is_shared_publicly: false,
|
||
signature_email: None,
|
||
membership: None,
|
||
photo: None,
|
||
album: None,
|
||
verification: Default::default(),
|
||
direct_role: None,
|
||
share_id: None,
|
||
};
|
||
db.upsert_node(&parent_node).unwrap();
|
||
|
||
let mut child_node = Node {
|
||
uid: child_uid.clone(),
|
||
parent_uid: Some(parent_uid.clone()),
|
||
name: "child.txt".into(),
|
||
kind: NodeKind::File {
|
||
media_type: "text/plain".into(),
|
||
total_size_on_storage: 10,
|
||
active_revision_state: None,
|
||
active_revision_id: None,
|
||
content_sha1: None,
|
||
claimed_size: Some(10),
|
||
claimed_modification_time: None,
|
||
},
|
||
creation_time: 100,
|
||
modification_time: 100,
|
||
trashed: false,
|
||
is_shared: false,
|
||
is_shared_publicly: false,
|
||
signature_email: None,
|
||
membership: None,
|
||
photo: None,
|
||
album: None,
|
||
verification: Default::default(),
|
||
direct_role: None,
|
||
share_id: None,
|
||
};
|
||
db.upsert_node(&child_node).unwrap();
|
||
|
||
assert!(db.has_children(&parent_uid).unwrap(), "child node present");
|
||
|
||
// Trashing child removes it from active children
|
||
child_node.trashed = true;
|
||
db.upsert_node(&child_node).unwrap();
|
||
|
||
assert!(
|
||
!db.has_children(&parent_uid).unwrap(),
|
||
"trashed child ignored"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_db_has_create_op() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let local_uid = "local~testnode12345678901234567890";
|
||
|
||
assert!(!db.has_create_op(local_uid).unwrap(), "no op initially");
|
||
|
||
let op = ops::PendingOp {
|
||
id: 0,
|
||
kind: ops::OP_CREATE.to_string(),
|
||
uid: local_uid.to_string(),
|
||
parent_uid: Some("vol~parent".to_string()),
|
||
name: Some("test.txt".to_string()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1000,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
};
|
||
db.enqueue_op(&op).unwrap();
|
||
|
||
assert!(db.has_create_op(local_uid).unwrap(), "create op present");
|
||
|
||
db.delete_ops_for_uid(local_uid).unwrap();
|
||
assert!(!db.has_create_op(local_uid).unwrap(), "op deleted");
|
||
}
|
||
|
||
/// The album listing is a wholesale replacement, and an album that leaves the
|
||
/// listing must take its contents with it — nothing else ever deletes those rows.
|
||
#[test]
|
||
fn albums_replace_drops_the_contents_of_an_album_that_left() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let album = |uid: &str, name: &str, activity: Option<i64>| StoredAlbum {
|
||
uid: uid.into(),
|
||
name: name.into(),
|
||
photo_count: 2,
|
||
cover_uid: Some("cover".into()),
|
||
last_activity: activity,
|
||
shared: false,
|
||
};
|
||
db.albums_replace(&[
|
||
album("a1", "Trip", Some(500)),
|
||
album("a2", "Cats", Some(400)),
|
||
])
|
||
.unwrap();
|
||
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
|
||
.unwrap();
|
||
db.album_photos_replace("a2", &[("p2".into(), 200, None, None)])
|
||
.unwrap();
|
||
|
||
// The next refresh no longer has a2 — it was deleted, or unshared.
|
||
db.albums_replace(&[album("a1", "Trip", Some(600))])
|
||
.unwrap();
|
||
|
||
assert_eq!(db.albums_count().unwrap(), 1);
|
||
assert_eq!(db.album_photos_count("a1").unwrap(), 1);
|
||
assert_eq!(
|
||
db.album_photos_count("a2").unwrap(),
|
||
0,
|
||
"a departed album's photos go with it"
|
||
);
|
||
}
|
||
|
||
/// An album's contents are replaced like the timeline: server order is kept, and
|
||
/// what a thumbnail attempt cost a download to learn survives the refresh.
|
||
#[test]
|
||
fn album_photos_replace_keeps_what_was_learned() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.albums_replace(&[StoredAlbum {
|
||
uid: "a1".into(),
|
||
name: "Trip".into(),
|
||
photo_count: 3,
|
||
cover_uid: None,
|
||
last_activity: None,
|
||
shared: true,
|
||
}])
|
||
.unwrap();
|
||
db.album_photos_replace(
|
||
"a1",
|
||
&[
|
||
("p1".into(), 300, None, Some("video/mp4".into())),
|
||
("p2".into(), 200, None, None),
|
||
],
|
||
)
|
||
.unwrap();
|
||
|
||
db.album_photo_set_thumb("p1", THUMB_HAVE, Some(1.5))
|
||
.unwrap();
|
||
db.album_photo_set_thumb("p2", THUMB_NONE, None).unwrap();
|
||
|
||
db.album_photos_replace(
|
||
"a1",
|
||
&[
|
||
("p0".into(), 400, Some("new.jpg".into()), None),
|
||
("p1".into(), 300, None, None),
|
||
("p2".into(), 200, None, None),
|
||
],
|
||
)
|
||
.unwrap();
|
||
|
||
let page = db.album_photos_page("a1", 0, 10).unwrap();
|
||
assert_eq!(
|
||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["p0", "p1", "p2"],
|
||
"the album's own order is preserved"
|
||
);
|
||
assert_eq!(page[1].ratio, Some(1.5));
|
||
assert_eq!(page[1].thumb_state, THUMB_HAVE);
|
||
assert_eq!(page[2].thumb_state, THUMB_NONE);
|
||
// The media type is learned-and-kept, so p1 stays classified as a video even
|
||
// though the second refresh carried none.
|
||
assert_eq!(page[1].kind, crate::control::PhotoKind::Video);
|
||
assert_eq!(page[0].thumb_state, THUMB_UNKNOWN);
|
||
|
||
// Paging is relative to the album, and the shared flag round-trips.
|
||
let second = db.album_photos_page("a1", 1, 1).unwrap();
|
||
assert_eq!(second.len(), 1);
|
||
assert_eq!(second[0].uid, "p1");
|
||
assert!(db.albums_list().unwrap()[0].shared);
|
||
}
|
||
|
||
/// A photo held only by an album — everything in an album shared with us — is
|
||
/// still resolvable for its capture time, which is what tags its cached
|
||
/// thumbnail. One row per uid, even when the photo is in several albums.
|
||
#[test]
|
||
fn album_photos_by_uid_resolves_a_photo_outside_the_timeline() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.album_photos_replace("a1", &[("p1".into(), 300, None, None)])
|
||
.unwrap();
|
||
db.album_photos_replace("a2", &[("p1".into(), 300, None, None)])
|
||
.unwrap();
|
||
|
||
let found = db
|
||
.album_photos_by_uid(&["p1".into(), "absent".into()])
|
||
.unwrap();
|
||
assert_eq!(found.len(), 1, "a photo in two albums resolves once");
|
||
assert_eq!(found[0].capture_time, 300);
|
||
assert!(db.album_photos_by_uid(&[]).unwrap().is_empty());
|
||
}
|
||
|
||
/// Per-album freshness stamps are keyed by uid, so invalidating them all is a
|
||
/// prefix sweep — and it must not take unrelated keys with it.
|
||
#[test]
|
||
fn clear_state_prefix_takes_only_the_prefixed_keys() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.set_state_i64("album_synced_ms:vol~a1", 1).unwrap();
|
||
db.set_state_i64("album_synced_ms:vol~a2", 2).unwrap();
|
||
db.set_state_i64("albums_synced_ms", 3).unwrap();
|
||
|
||
db.clear_state_prefix("album_synced_ms:").unwrap();
|
||
|
||
assert_eq!(db.state_i64("album_synced_ms:vol~a1").unwrap(), None);
|
||
assert_eq!(db.state_i64("album_synced_ms:vol~a2").unwrap(), None);
|
||
assert_eq!(
|
||
db.state_i64("albums_synced_ms").unwrap(),
|
||
Some(3),
|
||
"the listing's own stamp is not a per-album one"
|
||
);
|
||
}
|
||
|
||
// --- `nodes.path`, and the subtree work it replaces (DB3) -------------------
|
||
|
||
/// Read the stored path column directly: the point of these tests is that the
|
||
/// column itself is right, not that `path_of` can still fall back to a walk.
|
||
fn stored_path(db: &Db, link: &str) -> Option<String> {
|
||
use rusqlite::OptionalExtension;
|
||
db.with_conn(|conn| {
|
||
Ok(conn
|
||
.query_row(
|
||
"SELECT path FROM nodes WHERE uid = ?1",
|
||
[uid(link).to_string()],
|
||
|row| row.get::<_, Option<String>>(0),
|
||
)
|
||
.optional()?
|
||
.flatten())
|
||
})
|
||
.unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn a_node_stores_its_path_when_it_is_written() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("docs", Some("root"), "Documents"))
|
||
.unwrap();
|
||
db.upsert_node(&file("f1", "docs", "report.pdf", 1))
|
||
.unwrap();
|
||
|
||
// The root's name is the mount, so it contributes nothing.
|
||
assert_eq!(stored_path(&db, "root").as_deref(), Some(""));
|
||
assert_eq!(stored_path(&db, "docs").as_deref(), Some("Documents"));
|
||
assert_eq!(
|
||
stored_path(&db, "f1").as_deref(),
|
||
Some("Documents/report.pdf")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_node_below_an_uncached_parent_starts_its_own_path() {
|
||
// A device folder's root lives in `device`/`sync_folder` and never gets a
|
||
// `nodes` row, so its children are the top of the cached chain.
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("orphan", Some("never-cached"), "Work"))
|
||
.unwrap();
|
||
db.upsert_node(&file("f1", "orphan", "notes.txt", 1))
|
||
.unwrap();
|
||
|
||
assert_eq!(stored_path(&db, "orphan").as_deref(), Some("Work"));
|
||
assert_eq!(stored_path(&db, "f1").as_deref(), Some("Work/notes.txt"));
|
||
}
|
||
|
||
#[test]
|
||
fn renaming_a_folder_rewrites_every_descendant_path() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("a", Some("root"), "Before"))
|
||
.unwrap();
|
||
db.upsert_node(&folder("b", Some("a"), "Inner")).unwrap();
|
||
db.upsert_node(&file("f1", "b", "deep.txt", 1)).unwrap();
|
||
assert_eq!(
|
||
stored_path(&db, "f1").as_deref(),
|
||
Some("Before/Inner/deep.txt")
|
||
);
|
||
|
||
db.upsert_node(&folder("a", Some("root"), "After")).unwrap();
|
||
|
||
assert_eq!(stored_path(&db, "a").as_deref(), Some("After"));
|
||
assert_eq!(stored_path(&db, "b").as_deref(), Some("After/Inner"));
|
||
assert_eq!(
|
||
stored_path(&db, "f1").as_deref(),
|
||
Some("After/Inner/deep.txt")
|
||
);
|
||
let hit = &db.search("deep.txt", 10).unwrap()[0];
|
||
assert_eq!(
|
||
hit.path, "After/Inner/deep.txt",
|
||
"the search index must carry the rewritten path too"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn moving_a_folder_rewrites_every_descendant_path() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("old", Some("root"), "Old")).unwrap();
|
||
db.upsert_node(&folder("new", Some("root"), "New")).unwrap();
|
||
db.upsert_node(&folder("moved", Some("old"), "Moved"))
|
||
.unwrap();
|
||
db.upsert_node(&file("f1", "moved", "deep.txt", 1)).unwrap();
|
||
|
||
db.upsert_node(&folder("moved", Some("new"), "Moved"))
|
||
.unwrap();
|
||
|
||
assert_eq!(stored_path(&db, "moved").as_deref(), Some("New/Moved"));
|
||
assert_eq!(
|
||
stored_path(&db, "f1").as_deref(),
|
||
Some("New/Moved/deep.txt")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn trashing_a_folder_drops_its_subtree_from_the_index() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("a", Some("root"), "Holder"))
|
||
.unwrap();
|
||
db.upsert_node(&file("f1", "a", "hidden.txt", 1)).unwrap();
|
||
assert_eq!(db.search("hidden", 10).unwrap().len(), 1);
|
||
|
||
let mut trashed = folder("a", Some("root"), "Holder");
|
||
trashed.trashed = true;
|
||
db.upsert_node(&trashed).unwrap();
|
||
|
||
assert_eq!(
|
||
db.search("hidden", 10).unwrap().len(),
|
||
0,
|
||
"a file under a trashed folder is not reachable and must not be findable"
|
||
);
|
||
|
||
// …and comes back when the folder does.
|
||
db.upsert_node(&folder("a", Some("root"), "Holder"))
|
||
.unwrap();
|
||
assert_eq!(db.search("hidden", 10).unwrap().len(), 1);
|
||
}
|
||
|
||
/// The re-listing case, which is most of what the write path actually does: a
|
||
/// folder upserted with the same name and parent must not touch its subtree.
|
||
///
|
||
/// Written as a ratio between two subtree sizes, so it fails on the scaling
|
||
/// regression rather than on a slow machine. Before this, every re-listing paid
|
||
/// a `path_of` walk plus an ancestor walk per descendant.
|
||
#[test]
|
||
fn re_listing_a_folder_does_not_scale_with_its_subtree() {
|
||
fn elapsed_for(children: usize) -> std::time::Duration {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("big", Some("root"), "Big")).unwrap();
|
||
let kids: Vec<Node> = (0..children)
|
||
.map(|i| file(&format!("k{i}"), "big", &format!("child-{i}.txt"), 1))
|
||
.collect();
|
||
db.upsert_nodes(&kids).unwrap();
|
||
|
||
let unchanged = folder("big", Some("root"), "Big");
|
||
let start = std::time::Instant::now();
|
||
for _ in 0..20 {
|
||
db.upsert_node(&unchanged).unwrap();
|
||
}
|
||
start.elapsed()
|
||
}
|
||
|
||
let small = elapsed_for(100);
|
||
let large = elapsed_for(2_000);
|
||
assert!(
|
||
large < small * 8,
|
||
"re-listing a folder scaled with its subtree: {small:?} for 100 children, \
|
||
{large:?} for 2000 (20x the descendants)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn migration_v25_backfills_paths_for_an_existing_tree() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-v24-fixture-{}-{}.db",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
{
|
||
let db = Db::open(&path).unwrap();
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&folder("docs", Some("root"), "Documents"))
|
||
.unwrap();
|
||
db.upsert_node(&file("f1", "docs", "report.pdf", 1))
|
||
.unwrap();
|
||
db.upsert_node(&folder("orphan", Some("never-cached"), "Work"))
|
||
.unwrap();
|
||
db.upsert_node(&file("f2", "orphan", "notes.txt", 1))
|
||
.unwrap();
|
||
}
|
||
{
|
||
// Put the file back in the state a released V24 database was in.
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
conn.execute_batch(
|
||
"ALTER TABLE nodes DROP COLUMN path;
|
||
UPDATE sync_state SET value = '24' WHERE key = 'schema_version';",
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
assert_eq!(stored_path(&db, "root").as_deref(), Some(""));
|
||
assert_eq!(stored_path(&db, "docs").as_deref(), Some("Documents"));
|
||
assert_eq!(
|
||
stored_path(&db, "f1").as_deref(),
|
||
Some("Documents/report.pdf"),
|
||
"an existing tree must come out of the migration with the same paths \
|
||
the walk used to produce"
|
||
);
|
||
// A subtree whose top has an uncached parent is ordinary data, not an error.
|
||
assert_eq!(stored_path(&db, "orphan").as_deref(), Some("Work"));
|
||
assert_eq!(stored_path(&db, "f2").as_deref(), Some("Work/notes.txt"));
|
||
|
||
let _ = std::fs::remove_file(&path);
|
||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||
}
|
||
|
||
/// A queue carried over from V26 keeps its rows, and every one of them starts
|
||
/// out not access-deferred — a migration must not invent a deferral window that
|
||
/// would report an ordinary queued upload as blocked.
|
||
#[test]
|
||
fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-v26-fixture-{}-{}.db",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
{
|
||
let db = Db::open(&path).unwrap();
|
||
db.enqueue_op(&crate::db::PendingOp {
|
||
id: 0,
|
||
kind: crate::db::OP_REVISION.to_string(),
|
||
uid: "v~l".to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: Some("/tmp/blob".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
}
|
||
{
|
||
// Put the file back in the state a released V26 database was in.
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
conn.execute_batch(
|
||
"ALTER TABLE pending_op DROP COLUMN access_deferred_since;
|
||
UPDATE sync_state SET value = '26' WHERE key = 'schema_version';",
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
let ops = db.pending_ops().unwrap();
|
||
assert_eq!(ops.len(), 1, "the migration keeps the queued write");
|
||
let now = 5_000i64;
|
||
assert_eq!(
|
||
db.defer_op_for_access(ops[0].id, now, now + 5_000).unwrap(),
|
||
now,
|
||
"a carried-over op is not already deferred"
|
||
);
|
||
|
||
let _ = std::fs::remove_file(&path);
|
||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||
}
|
||
|
||
/// A mirror baseline carried over from V27 keeps its rows, and each one reads
|
||
/// back with no nanosecond time. That `None` is the whole point: it makes the
|
||
/// comparison fall back to whole seconds for rows written before the column
|
||
/// existed, instead of asserting a sub-second time of zero and re-uploading
|
||
/// every already-synced file in the folder (`docs/BUGS.md` B25).
|
||
#[test]
|
||
fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-v27-fixture-{}-{}.db",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
{
|
||
let db = Db::open(&path).unwrap();
|
||
let folder = db
|
||
.sync_folder_add("/home/me/Mirror", "vol~root", "share")
|
||
.unwrap();
|
||
db.sync_entry_upsert(
|
||
folder,
|
||
&StoredSyncEntry {
|
||
rel_path: "notes.txt".into(),
|
||
remote_uid: Some("vol~notes".into()),
|
||
local_mtime: 1_700_000_000,
|
||
local_mtime_ns: Some(1_700_000_000_123_456_789),
|
||
local_size: 42,
|
||
remote_rev: Some("1700000000".into()),
|
||
remote_hash: Some("42".into()),
|
||
},
|
||
)
|
||
.unwrap();
|
||
}
|
||
{
|
||
// Put the file back in the state a released V27 database was in.
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
conn.execute_batch(
|
||
"ALTER TABLE sync_entry DROP COLUMN local_mtime_ns;
|
||
UPDATE sync_state SET value = '27' WHERE key = 'schema_version';",
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
let entries = db.sync_entries(1).unwrap();
|
||
let entry = entries.get("notes.txt").expect("the baseline row survives");
|
||
assert_eq!(entry.local_mtime, 1_700_000_000);
|
||
assert_eq!(entry.local_size, 42);
|
||
assert_eq!(
|
||
entry.local_mtime_ns, None,
|
||
"a carried-over row must not claim a sub-second time it never recorded"
|
||
);
|
||
|
||
// And the column is live: a refreshed row keeps the precision it is given.
|
||
db.sync_entry_upsert(
|
||
1,
|
||
&StoredSyncEntry {
|
||
local_mtime_ns: Some(1_700_000_001_000_000_500),
|
||
..entry.clone()
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
db.sync_entries(1).unwrap()["notes.txt"].local_mtime_ns,
|
||
Some(1_700_000_001_000_000_500)
|
||
);
|
||
|
||
let _ = std::fs::remove_file(&path);
|
||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||
}
|
||
|
||
// --- read-only connection pool (DB2) ---------------------------------------
|
||
|
||
/// A read must not queue behind whatever happens to be writing.
|
||
///
|
||
/// The write connection is held for the whole measurement — which is what a
|
||
/// large listing's commit does to it — while a reader asks for a search. Before
|
||
/// the pool this was the same mutex, so the read waited for the writer;
|
||
/// `busy_timeout` never entered into it, because the contention was this
|
||
/// process's own lock rather than SQLite's.
|
||
#[test]
|
||
fn a_read_does_not_wait_for_the_write_connection() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-readpool-{}-{}.db",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
let db = std::sync::Arc::new(Db::open(&path).unwrap());
|
||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||
db.upsert_node(&file("f1", "root", "findme.txt", 1))
|
||
.unwrap();
|
||
|
||
let held = std::time::Duration::from_millis(500);
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
let writer = {
|
||
let db = db.clone();
|
||
std::thread::spawn(move || {
|
||
db.with_conn(|_conn| {
|
||
tx.send(()).unwrap();
|
||
std::thread::sleep(held);
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
})
|
||
};
|
||
rx.recv().unwrap();
|
||
|
||
let start = std::time::Instant::now();
|
||
let hits = db.search("findme", 10).unwrap();
|
||
let waited = start.elapsed();
|
||
writer.join().unwrap();
|
||
|
||
assert_eq!(hits.len(), 1, "the read must still see committed data");
|
||
assert!(
|
||
waited < held / 2,
|
||
"the read waited {waited:?} for a write connection held {held:?}"
|
||
);
|
||
|
||
drop(db);
|
||
let _ = std::fs::remove_file(&path);
|
||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||
}
|
||
|
||
/// §5: the drain is several workers over one queue, and the claim column is the
|
||
/// only thing keeping them off each other's work. Two properties matter — no
|
||
/// row is ever handed to two workers, and no *node* is ever worked by two
|
||
/// workers even though its ops are separate rows.
|
||
#[test]
|
||
fn a_claimed_op_is_invisible_to_every_other_worker() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = uid("root").to_string();
|
||
for i in 0..3 {
|
||
db.enqueue_op(&bulk_op(i, &root)).unwrap();
|
||
}
|
||
|
||
let first = db.claim_next_due_op(10).unwrap().expect("an op is due");
|
||
let second = db
|
||
.claim_next_due_op(10)
|
||
.unwrap()
|
||
.expect("another op is due");
|
||
let third = db
|
||
.claim_next_due_op(10)
|
||
.unwrap()
|
||
.expect("a third op is due");
|
||
assert!(
|
||
db.claim_next_due_op(10).unwrap().is_none(),
|
||
"a fourth claim must find nothing left: every queued op is taken"
|
||
);
|
||
|
||
let mut ids = [first.id, second.id, third.id];
|
||
ids.sort_unstable();
|
||
assert_eq!(
|
||
ids,
|
||
[1, 2, 3],
|
||
"each worker must get a distinct row, oldest first"
|
||
);
|
||
|
||
// Releasing puts one back, and it is the one that comes out next.
|
||
db.release_op_claim(second.id).unwrap();
|
||
let again = db.claim_next_due_op(10).unwrap().expect("the released op");
|
||
assert_eq!(again.id, second.id);
|
||
}
|
||
|
||
#[test]
|
||
fn two_workers_never_share_a_node() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = uid("root").to_string();
|
||
// Two ops of different kinds against one node — a queued rename and a
|
||
// queued revision, which is an ordinary `mv` plus a save.
|
||
let mut revision = bulk_op(0, &root);
|
||
revision.parent_uid = None;
|
||
db.enqueue_op(&revision).unwrap();
|
||
let rename = PendingOp {
|
||
kind: OP_RENAME.to_string(),
|
||
parent_uid: Some(root.clone()),
|
||
blob_path: None,
|
||
meta_json: None,
|
||
..bulk_op(0, &root)
|
||
};
|
||
db.enqueue_op(&rename).unwrap();
|
||
|
||
let claimed = db.claim_next_due_op(10).unwrap().expect("an op is due");
|
||
assert_eq!(claimed.kind, OP_REVISION, "the older op goes first");
|
||
assert!(
|
||
db.claim_next_due_op(10).unwrap().is_none(),
|
||
"the second op targets the same node, so it must wait for the first: \
|
||
two workers on one uid would race over its staged blob and land its \
|
||
changes out of order"
|
||
);
|
||
|
||
db.release_op_claim(claimed.id).unwrap();
|
||
db.delete_op(claimed.id).unwrap();
|
||
assert_eq!(
|
||
db.claim_next_due_op(10).unwrap().map(|o| o.kind),
|
||
Some(OP_RENAME.to_string()),
|
||
"once the revision retires, the rename is claimable"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn claims_do_not_survive_the_process_that_took_them() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = uid("root").to_string();
|
||
for i in 0..2 {
|
||
db.enqueue_op(&bulk_op(i, &root)).unwrap();
|
||
}
|
||
db.claim_next_due_op(10).unwrap().unwrap();
|
||
db.claim_next_due_op(10).unwrap().unwrap();
|
||
assert!(db.claim_next_due_op(10).unwrap().is_none());
|
||
|
||
// What `Db::open` does after the single-writer lock says the previous run
|
||
// is gone. Without it those ops are invisible to every worker forever.
|
||
assert_eq!(db.clear_op_claims().unwrap(), 2);
|
||
assert!(
|
||
db.claim_next_due_op(10).unwrap().is_some(),
|
||
"a crashed run's claims must not park its queue"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let root = uid("root").to_string();
|
||
db.enqueue_op(&bulk_op(0, &root)).unwrap();
|
||
|
||
assert_eq!(db.earliest_due_at().unwrap(), Some(0));
|
||
db.claim_next_due_op(10).unwrap().unwrap();
|
||
assert_eq!(
|
||
db.earliest_due_at().unwrap(),
|
||
None,
|
||
"an op somebody else is draining is not work this worker is waiting for"
|
||
);
|
||
}
|
||
|
||
/// Trashing a photo from the gallery must not leave it behind on an album page:
|
||
/// the row goes, its album membership goes with it, and nothing else moves.
|
||
#[test]
|
||
fn photos_delete_removes_the_photo_and_its_album_membership() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow::new("p1", 300),
|
||
TimelineRow::new("p2", 200),
|
||
TimelineRow::new("p3", 100),
|
||
])
|
||
.unwrap();
|
||
db.albums_replace(&[StoredAlbum {
|
||
uid: "a1".into(),
|
||
name: "Trip".into(),
|
||
photo_count: 3,
|
||
cover_uid: Some("p1".into()),
|
||
last_activity: Some(500),
|
||
shared: false,
|
||
}])
|
||
.unwrap();
|
||
db.album_photos_replace(
|
||
"a1",
|
||
&[
|
||
("p1".into(), 300, None, None),
|
||
("p2".into(), 200, None, None),
|
||
("p3".into(), 100, None, None),
|
||
],
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 1);
|
||
|
||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||
assert_eq!(
|
||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["p1", "p3"],
|
||
"only the trashed photo leaves the timeline"
|
||
);
|
||
assert_eq!(db.album_photos_count("a1").unwrap(), 2);
|
||
let album = db.album_photos_page("a1", 0, 10).unwrap();
|
||
assert_eq!(
|
||
album.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["p1", "p3"]
|
||
);
|
||
|
||
// A uid we no longer hold is not an error: the event poller and the trash
|
||
// reply can both report the same deletion.
|
||
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 0);
|
||
assert_eq!(db.photos_delete(&[]).unwrap(), 0);
|
||
}
|
||
|
||
/// A shot taken as RAW+JPEG is one photo to the person who took it: the grid
|
||
/// shows the JPEG once, and the Raw tab still lists the raw file.
|
||
#[test]
|
||
fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("IMG_1234.JPG".into()),
|
||
..TimelineRow::new("jpeg", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("img_1234.cr2".into()),
|
||
..TimelineRow::new("raw", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("IMG_9999.JPG".into()),
|
||
..TimelineRow::new("alone", 200)
|
||
},
|
||
])
|
||
.unwrap();
|
||
|
||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||
assert_eq!(
|
||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["jpeg", "alone"],
|
||
"the pair shows once, as the member that can be displayed"
|
||
);
|
||
assert_eq!(page[0].group_size, 2);
|
||
assert!(page[0].has_raw, "the tile can say the raw is there");
|
||
assert_eq!(page[1].group_size, 1);
|
||
assert!(!page[1].has_raw);
|
||
|
||
// The Raw tab answers "which raws do I have", so it lists files.
|
||
let raws = db
|
||
.photos_page(
|
||
0,
|
||
10,
|
||
Some(crate::control::PhotoKind::Raw),
|
||
None,
|
||
false,
|
||
false,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
raws.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["raw"]
|
||
);
|
||
assert_eq!(db.photos_counts().unwrap(), (2, 0, 1));
|
||
|
||
// Both members are reachable from either uid: the lightbox switches between
|
||
// them, and deleting the tile has to trash both.
|
||
let group = db.photos_group("raw").unwrap();
|
||
assert_eq!(
|
||
group.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["jpeg", "raw"]
|
||
);
|
||
assert_eq!(db.photos_group("alone").unwrap().len(), 1);
|
||
}
|
||
|
||
/// Pixel names the two files of one raw capture after the role each plays, so
|
||
/// their stems differ. The shot is still one shot, and the grid shows the cover
|
||
/// JPEG.
|
||
#[test]
|
||
fn a_pixel_raw_capture_groups_with_its_cover_jpeg() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("PXL_20260919_000625670.RAW-01.COVER.jpg".into()),
|
||
..TimelineRow::new("cover", 1_789_776_385)
|
||
},
|
||
TimelineRow {
|
||
name: Some("PXL_20260919_000625670.RAW-02.ORIGINAL.dng".into()),
|
||
..TimelineRow::new("original", 1_789_776_385)
|
||
},
|
||
// A name that merely contains the marker's opening is not a marker.
|
||
TimelineRow {
|
||
name: Some("scan.raw-notes.jpg".into()),
|
||
..TimelineRow::new("notes", 1_789_776_385)
|
||
},
|
||
TimelineRow {
|
||
name: Some("scan.raw-other.dng".into()),
|
||
..TimelineRow::new("other", 1_789_776_385)
|
||
},
|
||
])
|
||
.unwrap();
|
||
|
||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||
assert_eq!(
|
||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["cover", "notes", "other"],
|
||
"the capture shows once, as the cover JPEG"
|
||
);
|
||
assert_eq!(page[0].group_size, 2);
|
||
assert!(page[0].has_raw);
|
||
assert_eq!(
|
||
db.photos_group("original")
|
||
.unwrap()
|
||
.iter()
|
||
.map(|p| p.uid.as_str())
|
||
.collect::<Vec<_>>(),
|
||
["cover", "original"]
|
||
);
|
||
}
|
||
|
||
/// Two files that merely share a name are not one shot: the same stem on
|
||
/// different days, or two files of the same kind, stay apart.
|
||
#[test]
|
||
fn a_shared_name_alone_does_not_group_photos() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("IMG_1234.JPG".into()),
|
||
..TimelineRow::new("today", 1_700_000_000)
|
||
},
|
||
TimelineRow {
|
||
name: Some("IMG_1234.CR2".into()),
|
||
..TimelineRow::new("last-year", 1_600_000_000)
|
||
},
|
||
TimelineRow {
|
||
name: Some("IMG_1234.PNG".into()),
|
||
..TimelineRow::new("same-kind", 1_700_000_000)
|
||
},
|
||
])
|
||
.unwrap();
|
||
|
||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||
assert_eq!(page.len(), 3, "three shots, three tiles");
|
||
assert!(page.iter().all(|p| p.group_size == 1));
|
||
}
|
||
|
||
/// The server knows about live photos and bursts, so its own relation groups
|
||
/// files this client would never pair by name.
|
||
#[test]
|
||
fn the_servers_photo_relation_groups_a_live_photo() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("IMG_0001.HEIC".into()),
|
||
..TimelineRow::new("main", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("clip.mov".into()),
|
||
main_uid: Some("main".into()),
|
||
..TimelineRow::new("related", 299)
|
||
},
|
||
])
|
||
.unwrap();
|
||
|
||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||
assert_eq!(
|
||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["main"]
|
||
);
|
||
assert_eq!(page[0].group_size, 2);
|
||
assert!(!page[0].has_raw, "a live photo's video is not a raw file");
|
||
|
||
// The relation is learned-and-kept: a refresh that could not resolve the
|
||
// nodes must not break the group up.
|
||
db.photos_replace(&[
|
||
TimelineRow::new("main", 300),
|
||
TimelineRow::new("related", 299),
|
||
])
|
||
.unwrap();
|
||
assert_eq!(db.photos_group("related").unwrap().len(), 2);
|
||
}
|
||
|
||
/// A database written before grouping existed opens, keeps its timeline, and
|
||
/// shows every photo — a row with no `group_key` is its own group, which is how
|
||
/// the gallery behaved before the column.
|
||
#[test]
|
||
fn migration_v32_retries_videos_written_off_as_unthumbnailable() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-v31-fixture-{}-{}.db",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
{
|
||
let db = Db::open(&path).unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("VID-20260804-WA0023.mp4".into()),
|
||
..TimelineRow::new("video", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("broken.jpg".into()),
|
||
..TimelineRow::new("photo", 200)
|
||
},
|
||
])
|
||
.unwrap();
|
||
db.photo_set_thumb("video", THUMB_NONE, None).unwrap();
|
||
db.photo_set_thumb("photo", THUMB_NONE, None).unwrap();
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
conn.execute_batch("UPDATE sync_state SET value = '31' WHERE key = 'schema_version';")
|
||
.unwrap();
|
||
}
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
let state = |uid: &str| db.photos_by_uid(&[uid.into()]).unwrap()[0].thumb_state;
|
||
assert_eq!(state("video"), THUMB_UNKNOWN, "the video is tried again");
|
||
assert_eq!(
|
||
state("photo"),
|
||
THUMB_NONE,
|
||
"an undecodable photo stays written off"
|
||
);
|
||
drop(db);
|
||
let _ = std::fs::remove_file(&path);
|
||
}
|
||
|
||
#[test]
|
||
fn migration_v30_leaves_a_v29_timeline_intact() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-v29-fixture-{}-{}.db",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
{
|
||
let db = Db::open(&path).unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("IMG_1234.JPG".into()),
|
||
..TimelineRow::new("jpeg", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("IMG_1234.CR2".into()),
|
||
..TimelineRow::new("raw", 300)
|
||
},
|
||
])
|
||
.unwrap();
|
||
db.photo_set_thumb("jpeg", THUMB_HAVE, Some(1.5)).unwrap();
|
||
}
|
||
{
|
||
// Put the file back in the state a released V29 database was in.
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
conn.execute_batch(
|
||
"DROP INDEX idx_photos_group;
|
||
ALTER TABLE photos DROP COLUMN group_key;
|
||
ALTER TABLE photos DROP COLUMN main_uid;
|
||
ALTER TABLE photos DROP COLUMN content_hash;
|
||
UPDATE sync_state SET value = '29' WHERE key = 'schema_version';",
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||
assert_eq!(
|
||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||
["jpeg", "raw"],
|
||
"no photo goes missing before the next refresh groups them"
|
||
);
|
||
assert!(page.iter().all(|p| p.group_size == 1));
|
||
assert_eq!(page[0].ratio, Some(1.5), "what was learned is still there");
|
||
|
||
// The next refresh fills the column in and the pair collapses.
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("IMG_1234.JPG".into()),
|
||
..TimelineRow::new("jpeg", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("IMG_1234.CR2".into()),
|
||
..TimelineRow::new("raw", 300)
|
||
},
|
||
])
|
||
.unwrap();
|
||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||
assert_eq!(page.len(), 1);
|
||
assert_eq!(page[0].uid, "jpeg");
|
||
|
||
let _ = std::fs::remove_file(&path);
|
||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||
}
|
||
|
||
/// A refresh that skips an already-resolved photo sends a row carrying only the
|
||
/// uid and capture time. The name must survive that, because the tab split and
|
||
/// the grouping are both derived from it.
|
||
#[test]
|
||
fn photos_replace_keeps_a_skipped_photos_name() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("IMG_1234.JPG".into()),
|
||
media_type: Some("image/jpeg".into()),
|
||
resolved_at: Some(1),
|
||
..TimelineRow::new("jpeg", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("IMG_1234.CR2".into()),
|
||
media_type: Some("application/octet-stream".into()),
|
||
resolved_at: Some(1),
|
||
..TimelineRow::new("raw", 300)
|
||
},
|
||
])
|
||
.unwrap();
|
||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||
assert_eq!(page.len(), 1, "one shot, two files");
|
||
assert!(page[0].has_raw);
|
||
|
||
// The next refresh resolves neither, because both are already resolved.
|
||
db.photos_replace(&[TimelineRow::new("jpeg", 300), TimelineRow::new("raw", 300)])
|
||
.unwrap();
|
||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||
assert_eq!(page.len(), 1, "the group survives a skipped refresh");
|
||
assert_eq!(page[0].name.as_deref(), Some("IMG_1234.JPG"));
|
||
assert!(page[0].has_raw, "the raw file is still a raw file");
|
||
assert_eq!(page[0].group_size, 2);
|
||
}
|
||
|
||
/// Only the photos a refresh actually read are marked resolved; the rest stay
|
||
/// eligible, so the next refresh tries them again.
|
||
#[test]
|
||
fn photos_replace_marks_only_the_rows_it_resolved() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("ok.jpg".into()),
|
||
resolved_at: Some(1),
|
||
..TimelineRow::new("ok", 300)
|
||
},
|
||
// The chunk holding this one failed.
|
||
TimelineRow::new("failed", 200),
|
||
])
|
||
.unwrap();
|
||
assert_eq!(db.photos_resolved().unwrap(), ["ok"]);
|
||
|
||
// A resolved photo stays resolved across a refresh that skips it.
|
||
db.photos_replace(&[
|
||
TimelineRow::new("ok", 300),
|
||
TimelineRow {
|
||
name: Some("late.jpg".into()),
|
||
resolved_at: Some(2),
|
||
..TimelineRow::new("failed", 200)
|
||
},
|
||
])
|
||
.unwrap();
|
||
let mut resolved = db.photos_resolved().unwrap();
|
||
resolved.sort();
|
||
assert_eq!(resolved, ["failed", "ok"]);
|
||
}
|
||
|
||
/// What a refresh read is the truth, including what it no longer reports: a
|
||
/// photo unlinked from its main on another device leaves the group.
|
||
#[test]
|
||
fn a_resolved_replace_clears_a_relation_the_server_dropped() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("IMG_0001.HEIC".into()),
|
||
resolved_at: Some(1),
|
||
..TimelineRow::new("main", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("clip.mov".into()),
|
||
main_uid: Some("main".into()),
|
||
resolved_at: Some(1),
|
||
..TimelineRow::new("related", 299)
|
||
},
|
||
])
|
||
.unwrap();
|
||
assert_eq!(db.photos_group("related").unwrap().len(), 2);
|
||
|
||
// Both are read again and the server no longer reports the relation.
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("IMG_0001.HEIC".into()),
|
||
resolved_at: Some(2),
|
||
..TimelineRow::new("main", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("clip.mov".into()),
|
||
resolved_at: Some(2),
|
||
..TimelineRow::new("related", 299)
|
||
},
|
||
])
|
||
.unwrap();
|
||
assert_eq!(
|
||
db.photos_group("related").unwrap().len(),
|
||
1,
|
||
"an unlinked photo stands on its own"
|
||
);
|
||
}
|
||
|
||
/// A refresh that did *not* read the nodes must not break the group up, which is
|
||
/// the older behaviour and still the one a failed resolve gets.
|
||
#[test]
|
||
fn an_unresolved_replace_keeps_a_relation() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("IMG_0001.HEIC".into()),
|
||
resolved_at: Some(1),
|
||
..TimelineRow::new("main", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("clip.mov".into()),
|
||
main_uid: Some("main".into()),
|
||
resolved_at: Some(1),
|
||
..TimelineRow::new("related", 299)
|
||
},
|
||
])
|
||
.unwrap();
|
||
|
||
db.photos_replace(&[
|
||
TimelineRow::new("main", 300),
|
||
TimelineRow::new("related", 299),
|
||
])
|
||
.unwrap();
|
||
assert_eq!(db.photos_group("related").unwrap().len(), 2);
|
||
}
|
||
|
||
/// A remote event names the photo it changed, and that is all it takes to have
|
||
/// the next refresh read that one node again.
|
||
#[test]
|
||
fn marking_a_photo_unresolved_makes_it_eligible_again() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("a.jpg".into()),
|
||
resolved_at: Some(1),
|
||
..TimelineRow::new("a", 300)
|
||
},
|
||
TimelineRow {
|
||
name: Some("b.jpg".into()),
|
||
resolved_at: Some(1),
|
||
..TimelineRow::new("b", 200)
|
||
},
|
||
])
|
||
.unwrap();
|
||
|
||
assert_eq!(db.photos_mark_unresolved(&["a".to_string()]).unwrap(), 1);
|
||
assert_eq!(db.photos_resolved().unwrap(), ["b"]);
|
||
// A uid the timeline does not hold changes nothing.
|
||
assert_eq!(db.photos_mark_unresolved(&["gone".to_string()]).unwrap(), 0);
|
||
|
||
assert_eq!(db.photos_mark_all_unresolved().unwrap(), 2);
|
||
assert!(db.photos_resolved().unwrap().is_empty());
|
||
}
|
||
|
||
/// An existing timeline opens as resolved, so upgrading does not re-read the
|
||
/// whole library once — except for the photos nothing was ever learned about.
|
||
#[test]
|
||
fn migration_v31_treats_an_existing_timeline_as_resolved() {
|
||
let path = std::env::temp_dir().join(format!(
|
||
"pdfs-db-v30-fixture-{}-{}.db",
|
||
std::process::id(),
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos()
|
||
));
|
||
{
|
||
let db = Db::open(&path).unwrap();
|
||
db.photos_replace(&[
|
||
TimelineRow {
|
||
name: Some("IMG_1234.JPG".into()),
|
||
resolved_at: Some(1),
|
||
..TimelineRow::new("named", 300)
|
||
},
|
||
// A photo whose resolve had failed: nothing was ever learned.
|
||
TimelineRow::new("nameless", 200),
|
||
])
|
||
.unwrap();
|
||
}
|
||
{
|
||
// Put the file back in the state a released V30 database was in.
|
||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||
conn.execute_batch(
|
||
"DROP INDEX idx_photos_unresolved;
|
||
ALTER TABLE photos DROP COLUMN resolved_at;
|
||
UPDATE sync_state SET value = '30' WHERE key = 'schema_version';",
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
let db = Db::open(&path).unwrap();
|
||
assert_eq!(
|
||
db.photos_resolved().unwrap(),
|
||
["named"],
|
||
"the stored timeline is trusted, a nameless row is not"
|
||
);
|
||
|
||
let _ = std::fs::remove_file(&path);
|
||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||
}
|
||
|
||
fn queued_op(db: &Db, uid: &str, failures: i64, next_attempt_at: i64) -> i64 {
|
||
let (id, _) = db
|
||
.enqueue_op(&ops::PendingOp {
|
||
id: 0,
|
||
kind: ops::OP_REVISION.to_string(),
|
||
uid: uid.to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: None,
|
||
meta_json: None,
|
||
created_at: 1000,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at,
|
||
})
|
||
.unwrap();
|
||
for _ in 0..failures {
|
||
db.record_op_failure(id, "boom", next_attempt_at).unwrap();
|
||
}
|
||
id
|
||
}
|
||
|
||
/// "Retry now" must make a backed-off op due, but never un-park a transient
|
||
/// file's op: that one waits for its finishing rename, not for time.
|
||
#[test]
|
||
fn retry_now_makes_a_backed_off_op_due_but_leaves_parked_ops_parked() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let backed_off = queued_op(&db, "vol~a", 3, 90_000);
|
||
let parked = queued_op(&db, "vol~b", 0, ops::PARK_UNTIL);
|
||
|
||
assert!(db.retry_op_now(backed_off, 5_000).unwrap());
|
||
assert!(!db.retry_op_now(parked, 5_000).unwrap());
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
let due = |id: i64| ops.iter().find(|op| op.id == id).unwrap().next_attempt_at;
|
||
assert_eq!(due(backed_off), 5_000);
|
||
assert_eq!(due(parked), ops::PARK_UNTIL);
|
||
}
|
||
|
||
/// A change Drive refused needs the user now, not after six more failures, and
|
||
/// its refusal is the error the status leads with.
|
||
#[test]
|
||
fn an_op_with_an_issue_counts_as_failing_at_once() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
queued_op(&db, "vol~a", 1, 90_000);
|
||
let refused = queued_op(&db, "vol~b", 1, 90_000);
|
||
assert_eq!(db.pending_op_counts().unwrap().failing, 0);
|
||
|
||
db.record_op_failure(refused, "storage quota exceeded", 90_000)
|
||
.unwrap();
|
||
db.set_op_issue(refused, Some("quota")).unwrap();
|
||
let counts = db.pending_op_counts().unwrap();
|
||
assert_eq!(counts.failing, 1);
|
||
assert_eq!(counts.last_error.as_deref(), Some("storage quota exceeded"));
|
||
assert_eq!(
|
||
db.op_issues().unwrap(),
|
||
std::collections::HashMap::from([(refused, "quota".to_string())])
|
||
);
|
||
|
||
// The op stays queued with its bytes; clearing the issue is all it takes.
|
||
db.set_op_issue(refused, None).unwrap();
|
||
assert_eq!(db.pending_op_counts().unwrap().failing, 0);
|
||
assert!(db.op_issues().unwrap().is_empty());
|
||
assert_eq!(db.pending_ops().unwrap().len(), 2);
|
||
}
|
||
|
||
/// The browser shows an issue on the node it is about, which it knows by
|
||
/// local id, under either uid.
|
||
#[test]
|
||
fn a_refused_change_names_its_node_by_local_id() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
db.upsert_nodes(&[
|
||
folder("root", None, "My Files"),
|
||
file("a", "root", "a.bin", 1),
|
||
file("b", "root", "b.bin", 1),
|
||
])
|
||
.unwrap();
|
||
let refused = queued_op(&db, &uid("a").to_string(), 1, 90_000);
|
||
queued_op(&db, &uid("b").to_string(), 1, 90_000);
|
||
let gone = queued_op(&db, "vol~nowhere", 1, 90_000);
|
||
db.set_op_issue(refused, Some("quota")).unwrap();
|
||
db.set_op_issue(gone, Some("missing")).unwrap();
|
||
|
||
let lid = db.lid_of(&uid("a").to_string()).unwrap().unwrap();
|
||
assert_eq!(
|
||
db.node_issues().unwrap(),
|
||
std::collections::HashMap::from([(lid, "quota".to_string())])
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn retry_all_touches_only_ops_that_have_failed() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let failed = queued_op(&db, "vol~a", 2, 90_000);
|
||
let debounced = queued_op(&db, "vol~b", 0, 7_000);
|
||
let parked = queued_op(&db, "vol~c", 4, ops::PARK_UNTIL);
|
||
|
||
assert_eq!(db.retry_failed_ops_now(5_000).unwrap(), 1);
|
||
|
||
let ops = db.pending_ops().unwrap();
|
||
let due = |id: i64| ops.iter().find(|op| op.id == id).unwrap().next_attempt_at;
|
||
assert_eq!(due(failed), 5_000);
|
||
assert_eq!(due(debounced), 7_000);
|
||
assert_eq!(due(parked), ops::PARK_UNTIL);
|
||
}
|
||
|
||
#[test]
|
||
fn a_queued_op_stops_existing_once_the_node_is_deleted() {
|
||
let db = Db::open_in_memory().unwrap();
|
||
let (id, _) = db
|
||
.enqueue_op(&PendingOp {
|
||
id: 0,
|
||
kind: OP_REVISION.to_string(),
|
||
uid: uid("journal").to_string(),
|
||
parent_uid: None,
|
||
name: None,
|
||
blob_path: Some("/staging/journal".to_string()),
|
||
meta_json: Some("{}".to_string()),
|
||
created_at: 1,
|
||
attempts: 0,
|
||
last_error: None,
|
||
next_attempt_at: 0,
|
||
})
|
||
.unwrap();
|
||
assert!(db.op_exists(id).unwrap());
|
||
|
||
let blobs = db.delete_ops_for_uid(&uid("journal").to_string()).unwrap();
|
||
assert_eq!(blobs, vec!["/staging/journal"]);
|
||
assert!(!db.op_exists(id).unwrap());
|
||
}
|