sync/mod.rs listed 5 of its 9 modules; migrate's doc sat above the v9 SQL; client.rs had items after its test module; the ffi's sync_now doc had fused into client_update's; complete_reminder (ffi and EditorAction) still said recurrence advancement was to come, though the core does it; NoteQuery.view listed views the core never matched and claimed it validated. Test scratch dirs drop the old ts-/iw- prefixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
6.7 KiB
Rust
184 lines
6.7 KiB
Rust
//! One-time move of a ThoughtSync install's data into Inkwell's.
|
|
//!
|
|
//! The app was renamed ThoughtSync → Inkwell (Scribe note 5071), and the Tauri
|
|
//! identifier with it. The app-data dir is named after the identifier, so the
|
|
//! renamed app looks in `com.fabledsword.inkwell` and would start EMPTY beside the
|
|
//! old `com.fabledsword.thoughtsync`, which still holds every note. Local-first means
|
|
//! that directory is the only copy for anyone who never linked a server.
|
|
//!
|
|
//! So on startup, before the store opens, the old directory's contents move across.
|
|
//! The store file was renamed with the app (`thoughtsync.db` → `inkwell.db`), so it
|
|
//! is renamed on the way, together with its SQLite `-wal`/`-shm` sidecars. Those
|
|
//! hold committed writes not yet checkpointed into the main file, and a database
|
|
//! renamed without them silently loses them.
|
|
//!
|
|
//! A removable shim, deliberately (family rule 22): delete this module and its one
|
|
//! call in `lib.rs` once no install is still on the old identifier.
|
|
|
|
use std::fs;
|
|
use std::io;
|
|
use std::path::Path;
|
|
|
|
use inkwell_core::local::DB_FILE;
|
|
|
|
/// The identifier ThoughtSync shipped under. Its app-data dir is a sibling of ours.
|
|
pub const LEGACY_IDENTIFIER: &str = "com.fabledsword.thoughtsync";
|
|
|
|
const LEGACY_DB: &str = "thoughtsync.db";
|
|
|
|
/// What a crossover did, for the startup log.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum Outcome {
|
|
/// No old install beside this one.
|
|
NothingToMove,
|
|
/// This install already has its own store. The old directory is left alone:
|
|
/// merging two stores is not something a rename should decide.
|
|
AlreadyHasAStore,
|
|
/// Moved this many entries across.
|
|
Moved(usize),
|
|
}
|
|
|
|
/// Move `old_dir`'s contents into `new_dir`, unless `new_dir` already has a store.
|
|
///
|
|
/// Entry by entry rather than renaming the directory, because `new_dir` can already
|
|
/// exist without a store: the installer writes its channel marker there before the
|
|
/// app first runs (update.rs, issue 2183), and the webview may create its own files
|
|
/// before setup. Anything already in `new_dir` wins; the old copy of it is left in
|
|
/// place, and so is `old_dir` itself unless it ends up empty.
|
|
pub fn adopt_legacy_data(old_dir: &Path, new_dir: &Path) -> io::Result<Outcome> {
|
|
if new_dir.join(DB_FILE).exists() {
|
|
return Ok(Outcome::AlreadyHasAStore);
|
|
}
|
|
if !old_dir.is_dir() {
|
|
return Ok(Outcome::NothingToMove);
|
|
}
|
|
fs::create_dir_all(new_dir)?;
|
|
|
|
let mut moved = 0;
|
|
for entry in fs::read_dir(old_dir)? {
|
|
let entry = entry?;
|
|
let name = entry.file_name();
|
|
let target = new_dir.join(renamed(&name.to_string_lossy()));
|
|
if target.exists() {
|
|
continue;
|
|
}
|
|
fs::rename(entry.path(), &target)?;
|
|
moved += 1;
|
|
}
|
|
// Only succeeds when empty, which is the point: whatever was skipped stays put.
|
|
let _ = fs::remove_dir(old_dir);
|
|
Ok(Outcome::Moved(moved))
|
|
}
|
|
|
|
/// `thoughtsync.db`, `thoughtsync.db-wal`, `thoughtsync.db-shm` → the `inkwell.db`
|
|
/// family. Everything else keeps its name.
|
|
fn renamed(name: &str) -> String {
|
|
match name.strip_prefix(LEGACY_DB) {
|
|
Some(suffix @ ("" | "-wal" | "-shm")) => format!("{DB_FILE}{suffix}"),
|
|
_ => name.to_string(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
|
|
/// A unique scratch root holding `old/` and `new/`, the shape of a data root
|
|
/// with both identifiers' directories in it.
|
|
fn root() -> (PathBuf, PathBuf) {
|
|
static SEQ: AtomicU32 = AtomicU32::new(0);
|
|
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
|
let root =
|
|
std::env::temp_dir().join(format!("inkwell-crossover-{}-{n}", std::process::id()));
|
|
(root.join("old"), root.join("new"))
|
|
}
|
|
|
|
fn write(path: &Path, contents: &str) {
|
|
fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
|
|
fs::write(path, contents).expect("write");
|
|
}
|
|
|
|
fn read(path: &Path) -> String {
|
|
fs::read_to_string(path).expect("read")
|
|
}
|
|
|
|
#[test]
|
|
fn a_thoughtsync_store_moves_across_with_its_wal_and_blobs() {
|
|
let (old, new) = root();
|
|
write(&old.join("thoughtsync.db"), "main");
|
|
write(&old.join("thoughtsync.db-wal"), "uncheckpointed writes");
|
|
write(&old.join("thoughtsync.db-shm"), "index");
|
|
write(&old.join("blobs/ab/abcdef"), "image bytes");
|
|
|
|
assert_eq!(
|
|
adopt_legacy_data(&old, &new).expect("move"),
|
|
Outcome::Moved(4)
|
|
);
|
|
|
|
assert_eq!(read(&new.join("inkwell.db")), "main");
|
|
assert_eq!(read(&new.join("inkwell.db-wal")), "uncheckpointed writes");
|
|
assert_eq!(read(&new.join("inkwell.db-shm")), "index");
|
|
assert_eq!(read(&new.join("blobs/ab/abcdef")), "image bytes");
|
|
assert!(!old.exists(), "an emptied old dir is removed");
|
|
}
|
|
|
|
#[test]
|
|
fn the_installers_marker_already_in_the_new_dir_wins() {
|
|
// install.sh writes the channel marker into the NEW dir before first launch,
|
|
// so the new dir exists but holds no store. The crossover still has to run,
|
|
// and the fresh marker has to beat the old install's.
|
|
let (old, new) = root();
|
|
write(&old.join("thoughtsync.db"), "main");
|
|
write(&old.join("install-channel"), "stable");
|
|
write(&new.join("install-channel"), "dev");
|
|
|
|
assert_eq!(
|
|
adopt_legacy_data(&old, &new).expect("move"),
|
|
Outcome::Moved(1)
|
|
);
|
|
|
|
assert_eq!(read(&new.join("inkwell.db")), "main");
|
|
assert_eq!(read(&new.join("install-channel")), "dev");
|
|
assert_eq!(
|
|
read(&old.join("install-channel")),
|
|
"stable",
|
|
"the loser stays put"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_install_with_its_own_store_is_left_alone() {
|
|
let (old, new) = root();
|
|
write(&old.join("thoughtsync.db"), "old notes");
|
|
write(&new.join("inkwell.db"), "new notes");
|
|
|
|
assert_eq!(
|
|
adopt_legacy_data(&old, &new).expect("check"),
|
|
Outcome::AlreadyHasAStore
|
|
);
|
|
|
|
assert_eq!(read(&new.join("inkwell.db")), "new notes");
|
|
assert_eq!(read(&old.join("thoughtsync.db")), "old notes");
|
|
}
|
|
|
|
#[test]
|
|
fn no_old_install_means_nothing_happens() {
|
|
let (old, new) = root();
|
|
assert_eq!(
|
|
adopt_legacy_data(&old, &new).expect("check"),
|
|
Outcome::NothingToMove
|
|
);
|
|
assert!(!new.exists(), "nothing created for a fresh install");
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_store_family_is_renamed() {
|
|
assert_eq!(renamed("thoughtsync.db"), "inkwell.db");
|
|
assert_eq!(renamed("thoughtsync.db-wal"), "inkwell.db-wal");
|
|
assert_eq!(renamed("thoughtsync.db-journal"), "thoughtsync.db-journal");
|
|
assert_eq!(renamed("blobs"), "blobs");
|
|
}
|
|
}
|