From 3e620389a6e7f6cf395254de83a43fc04a83dd0e Mon Sep 17 00:00:00 2001 From: hitalin Date: Mon, 27 Jul 2026 09:34:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(db):=20=E4=B8=80=E8=B2=AB=E3=81=97?= =?UTF-8?q?=E3=81=9F=E3=82=B9=E3=83=8A=E3=83=83=E3=83=97=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=82=92=E6=9B=B8=E3=81=8D=E5=87=BA=E3=81=99?= =?UTF-8?q?=20backup=5Fto=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAL モードのため単純なファイルコピーでは未反映のトランザクションが 取り残され、古い、または壊れた複製ができる。VACUUM INTO で writer 接続 から単一ファイルのコピーを作る API を公開する。 キーチェーンが永続しない環境では accounts.token に API トークンが平文で 残るため、複製側からトークンを落とすオプションも持たせる。 Co-Authored-By: Claude Opus 4.8 --- src/db.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/db.rs b/src/db.rs index 1e81345..1c99589 100644 --- a/src/db.rs +++ b/src/db.rs @@ -704,6 +704,38 @@ impl Database { Ok(()) } + /// DB の一貫したスナップショットを `dest` に書き出す。 + /// + /// 単純なファイルコピーでは WAL に未反映のトランザクションが取り残され、 + /// 古い、または壊れた複製ができる。`VACUUM INTO` は writer 接続上で + /// 単一ファイルの完結したコピーを作るため、-wal / -shm を伴わない。 + /// + /// `strip_tokens` を立てると、複製側の `accounts.token` を空にしてから + /// 返す。キーチェーンが永続しない環境では DB に API トークンが平文で + /// 残るため、持ち出す成果物からは落とせるようにする。 + pub fn backup_to(&self, dest: &Path, strip_tokens: bool) -> Result<(), NoteDeckError> { + if dest.exists() { + // VACUUM INTO は既存ファイルへの書き出しを拒否する + std::fs::remove_file(dest).map_err(|e| { + NoteDeckError::Database(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CANTOPEN), + Some(format!("failed to replace existing backup: {e}")), + )) + })?; + } + { + let conn = self.lock_write()?; + conn.execute("VACUUM INTO ?1", params![dest.to_string_lossy()])?; + } + Self::restrict_permissions(dest); + + if strip_tokens { + let copy = Connection::open(dest)?; + copy.execute("UPDATE accounts SET token = ''", [])?; + } + Ok(()) + } + /// Delete a single note from the cache (e.g. when a deletion event is received). pub fn delete_cached_note(&self, note_id: &str) -> Result<(), NoteDeckError> { let conn = self.lock_write()?; @@ -1433,6 +1465,51 @@ mod tests { } } + #[test] + fn backup_to_produces_a_readable_copy_with_data() { + let (dir, db) = temp_db(); + db.upsert_account(&sample_account()).unwrap(); + let dest = dir.path().join("backup.db"); + + db.backup_to(&dest, false).unwrap(); + + // VACUUM INTO は単一ファイルで完結する (-wal を伴わない) + assert!(dest.exists()); + assert!(!dir.path().join("backup.db-wal").exists()); + + let restored = Database::open(&dest).unwrap(); + let accounts = restored.load_accounts().unwrap(); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].token, "test-token"); + } + + #[test] + fn backup_to_can_strip_tokens_from_the_copy() { + let (dir, db) = temp_db(); + db.upsert_account(&sample_account()).unwrap(); + let dest = dir.path().join("backup.db"); + + db.backup_to(&dest, true).unwrap(); + + let restored = Database::open(&dest).unwrap(); + assert_eq!(restored.load_accounts().unwrap()[0].token, ""); + // 元の DB は触らない + assert_eq!(db.load_accounts().unwrap()[0].token, "test-token"); + } + + #[test] + fn backup_to_overwrites_an_existing_file() { + let (dir, db) = temp_db(); + db.upsert_account(&sample_account()).unwrap(); + let dest = dir.path().join("backup.db"); + std::fs::write(&dest, b"stale").unwrap(); + + db.backup_to(&dest, false).unwrap(); + + let restored = Database::open(&dest).unwrap(); + assert_eq!(restored.load_accounts().unwrap().len(), 1); + } + #[test] fn account_upsert_and_load() { let (_dir, db) = temp_db();