Files
inkwell/desktop/src-tauri/src/crossover.rs
T
bvandeusenandClaude Opus 5.5 63abff8681 DRY pass #2, batch 8: the Rust and ffi docs (#5372)
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>
2026-10-08 15:05:43 -04:00

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");
}
}