diff --git a/Cargo.lock b/Cargo.lock index e4c739b..c25c80c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1413,7 +1413,7 @@ checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "notecli" -version = "0.7.0" +version = "0.8.0" dependencies = [ "android-native-keyring-store", "apple-native-keyring-store", diff --git a/Cargo.toml b/Cargo.toml index 04dde44..6460b4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notecli" -version = "0.7.0" +version = "0.8.0" edition = "2021" description = "Headless Misskey client — CLI & library" repository = "https://github.com/notedeck-dev/notecli" diff --git a/migrations/V6__split_note_timeline_membership.sql b/migrations/V6__split_note_timeline_membership.sql new file mode 100644 index 0000000..91c23be --- /dev/null +++ b/migrations/V6__split_note_timeline_membership.sql @@ -0,0 +1,49 @@ +-- ノート実体 (notes_cache) とタイムライン所属 (note_timelines) の分離。 +-- 設計の正本: https://github.com/notedeck-dev/notecli/issues/30 の仕様 v5。 +-- 本 migration は refinery の set_grouped(true) により履歴記録と単一 tx で適用される +-- (非 grouped だと本体コミット後・履歴記録前の kill で再適用が DROP COLUMN で失敗する)。 + +-- (1) 復元不能な壊れキー行を削除。'' = streaming の antenna/channel/role、 +-- 'user-list' = streaming の listId 欠落。この 2 種で全て (streaming.rs 書込箇所 +-- 全数確認済み)。これらはタイムライン読み出しからは不可視。検索・スキャン corpus +-- からは本 DELETE で消える (一時的。V6 以降は正キーで再蓄積)。 +DELETE FROM notes_cache WHERE timeline_type IN ('', 'user-list'); + +-- (2) junction の定石どおり WITHOUT ROWID (autoindex 二重格納の排除。 +-- 容量 41% 減 — 1.9M 行実測)。 +-- sort_key は note.created_at (サーバー由来文字列をそのまま。辞書順=時系列は +-- 本家 toISOString 前提)。added_at は unix epoch 秒 (初回ローカル取得時刻)。 +CREATE TABLE note_timelines ( + account_id TEXT NOT NULL, + timeline_key TEXT NOT NULL, + note_id TEXT NOT NULL, + sort_key TEXT NOT NULL, + added_at INTEGER NOT NULL, + PRIMARY KEY (account_id, timeline_key, note_id), + FOREIGN KEY (note_id, account_id) + REFERENCES notes_cache (note_id, account_id) ON DELETE CASCADE +) WITHOUT ROWID; + +-- note_id DESC まで明示: 3 列だと暗黙 PK 残余列が ASC になり tie-break 付き ORDER BY が +-- temp b-tree sort に落ちる (同一 sort_key 50K 行 + LIMIT 10 で 156 倍差を実測)。 +-- PK 全列明示のためサイズ増ゼロ、COVERING 維持 (EQP 実測)。 +CREATE INDEX idx_note_timelines_order + ON note_timelines (account_id, timeline_key, sort_key DESC, note_id DESC); + +-- CASCADE 性能の必須要件。ただし sqlite_stat1 必須 (本 migration 末尾の ANALYZE / +-- 起動時 PRAGMA optimize が生成。stat1 なしでは planner が本 index を選ばず +-- CASCADE が WITHOUT ROWID PK の prefix スキャンに落ちる — 実測 2000 倍差)。 +CREATE INDEX idx_note_timelines_note + ON note_timelines (note_id, account_id); + +INSERT INTO note_timelines (account_id, timeline_key, note_id, sort_key, added_at) +SELECT account_id, timeline_type, note_id, created_at, cached_at +FROM notes_cache; + +-- idx_notes_cache_timeline (V1 の account_id+created_at DESC) は search/scan の +-- ORDER BY が使うため残す。DROP するのは timeline_type 系のみ。 +DROP INDEX IF EXISTS idx_notes_cache_tl; +ALTER TABLE notes_cache DROP COLUMN timeline_type; + +-- stat1 生成 (idx_note_timelines_note を CASCADE の planner に選ばせるための必須要件) +ANALYZE; diff --git a/src/api.rs b/src/api.rs index dbd0c9e..61eefd6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -6,13 +6,13 @@ use reqwest::multipart::{Form, Part}; use reqwest::Client; use serde_json::{json, Value}; -use crate::error::NoteDeckError; +use crate::error::{AuthErrorKind, NoteDeckError}; use crate::models::{ - Antenna, AuthResult, Channel, ChatMessage, ChatUser, Clip, CreateNoteParams, + Antenna, AuthResult, Channel, ChatMessage, ChatUser, Clip, CreateNoteParams, MutedWordsResult, NormalizedDriveFile, NormalizedNote, NormalizedNoteReaction, NormalizedNotification, - MutedWordsResult, NormalizedUser, NormalizedUserDetail, RawCreateNoteResponse, RawDriveFile, - RawEmojisResponse, RawMiAuthResponse, RawNote, RawNoteReaction, RawNotification, RawUser, - RawUserDetail, SearchOptions, ServerEmoji, TimelineOptions, TimelineType, UserList, + NormalizedUser, NormalizedUserDetail, RawCreateNoteResponse, RawDriveFile, RawEmojisResponse, + RawMiAuthResponse, RawNote, RawNoteReaction, RawNotification, RawUser, RawUserDetail, + SearchOptions, ServerEmoji, TimelineKey, TimelineOptions, UserList, }; /// Maximum response body size (50 MB) to prevent memory exhaustion from malicious servers. @@ -39,6 +39,23 @@ fn apply_pagination(params: &mut Value, since_id: Option<&str>, until_id: Option } } +/// Misskey ID (aid / aidx) の時刻部の基準時刻 (2000-01-01T00:00:00Z)。 +const TIME2000_MS: i64 = 946_684_800_000; + +/// epoch ミリ秒を Misskey ID の時刻部 (base36 8 桁) に変換する。 +/// +/// サーバーは sinceId/untilId を `idService.parse()` で createdAt に戻して +/// 比較するため、時刻部だけの ID でも日付フィルタとして機能する。 +fn misskey_id_at(ms: i64) -> String { + let mut n = (ms - TIME2000_MS).max(0) as u64; + let mut s = String::new(); + while n > 0 { + s.insert(0, char::from_digit((n % 36) as u32, 36).unwrap()); + n /= 36; + } + format!("{s:0>8}") +} + pub struct MisskeyClient { client: Client, /// Override base URL for testing (e.g. "http://127.0.0.1:PORT"). @@ -90,6 +107,7 @@ impl MisskeyClient { return Err(NoteDeckError::Api { endpoint: endpoint.to_string(), status: 0, + api_code: None, message: "Response too large".to_string(), }); } @@ -104,6 +122,7 @@ impl MisskeyClient { return Err(NoteDeckError::Api { endpoint: endpoint.to_string(), status: 0, + api_code: None, message: "Response too large".to_string(), }); } @@ -111,6 +130,7 @@ impl MisskeyClient { String::from_utf8(buf).map_err(|_| NoteDeckError::Api { endpoint: endpoint.to_string(), status: 0, + api_code: None, message: "Invalid UTF-8 in response".to_string(), }) } @@ -160,6 +180,7 @@ impl MisskeyClient { return Err(NoteDeckError::Api { endpoint: endpoint.to_string(), status, + api_code, message, }); } @@ -172,16 +193,28 @@ impl MisskeyClient { } } + /// タイムラインを取得する。endpoint と追加パラメータ (listId 等) は `key` から + /// 導出する (`TimelineOptions.list_id` は境界アダプタ入力であり本 API は読まない)。 + /// 専用 API を持つ種別 (Favorites / Clip — `api_endpoint() == None`) は Err。 pub async fn get_timeline( &self, host: &str, token: &str, account_id: &str, - timeline_type: TimelineType, + key: &TimelineKey, options: TimelineOptions, ) -> Result, NoteDeckError> { - let endpoint = timeline_type.api_endpoint(); + let (endpoint, key_params) = key.api_endpoint().ok_or_else(|| { + NoteDeckError::InvalidInput(format!( + "timeline key '{key}' has no generic timeline endpoint" + )) + })?; let mut params = json!({ "limit": options.limit() }); + if let Value::Object(extra) = key_params { + for (k, v) in extra { + params[k] = v; + } + } apply_pagination( &mut params, options.since_id.as_deref(), @@ -207,9 +240,6 @@ impl MisskeyClient { params["excludeNsfw"] = json!(!v); } } - if let Some(ref id) = options.list_id { - params["listId"] = json!(id); - } let data = self.request(host, token, &endpoint, params).await?; let raw: Vec = serde_json::from_value(data)?; @@ -251,7 +281,12 @@ impl MisskeyClient { antenna_id: &str, ) -> Result { let data = self - .request(host, token, "antennas/show", json!({ "antennaId": antenna_id })) + .request( + host, + token, + "antennas/show", + json!({ "antennaId": antenna_id }), + ) .await?; let antenna: Antenna = serde_json::from_value(data)?; Ok(antenna) @@ -667,6 +702,7 @@ impl MisskeyClient { .map_err(|e| NoteDeckError::Api { endpoint: "drive/files/create".to_string(), status: 0, + api_code: None, message: e.to_string(), })?; @@ -689,6 +725,7 @@ impl MisskeyClient { return Err(NoteDeckError::Api { endpoint: "drive/files/create".to_string(), status, + api_code: None, message, }); } @@ -869,6 +906,44 @@ impl MisskeyClient { .collect()) } + /// はなみすきー (hanamisskey/misskey) 独自のノート検索。 + /// + /// 本家 `notes/search` はロールポリシー `canSearchNotes` で無効化されており、 + /// 代わりに `notes/hanamisearch-v1` が開放されている。このエンドポイントは + /// sinceDate/untilDate を受け付けないため、日付は ID の時刻部に変換して渡す。 + /// ページング用の sinceId/untilId が明示されていればそちらを優先する + /// (untilId で遡る 2 ページ目以降は日付の上限より必ず古いため)。 + pub async fn search_notes_hanami( + &self, + host: &str, + token: &str, + account_id: &str, + query: &str, + options: SearchOptions, + ) -> Result, NoteDeckError> { + let mut params = json!({ "query": query, "limit": options.limit() }); + let since_id = options + .since_id + .clone() + .or_else(|| options.since_date.map(misskey_id_at)); + let until_id = options + .until_id + .clone() + .or_else(|| options.until_date.map(misskey_id_at)); + apply_pagination(&mut params, since_id.as_deref(), until_id.as_deref()); + if let Some(ref uid) = options.user_id { + params["userId"] = json!(uid); + } + let data = self + .request(host, token, "notes/hanamisearch-v1", params) + .await?; + let raw: Vec = serde_json::from_value(data)?; + Ok(raw + .into_iter() + .map(|n| n.normalize(account_id, host)) + .collect()) + } + pub async fn get_notifications( &self, host: &str, @@ -928,26 +1003,23 @@ impl MisskeyClient { .await?; if !res.status().is_success() { - return Err(NoteDeckError::Auth(format!( - "MiAuth check failed: {}", - res.status().as_u16() + return Err(NoteDeckError::Auth(AuthErrorKind::MiAuthFailed( + res.status().as_u16(), ))); } let text = Self::read_body_limited(res, "miauth/check").await?; let data: RawMiAuthResponse = serde_json::from_str(&text)?; if !data.ok { - return Err(NoteDeckError::Auth( - "MiAuth authentication was not completed".to_string(), - )); + return Err(NoteDeckError::Auth(AuthErrorKind::MiAuthPending)); } let token = data .token - .ok_or_else(|| NoteDeckError::Auth("MiAuth response missing token".to_string()))?; + .ok_or(NoteDeckError::Auth(AuthErrorKind::MiAuthMalformed("token")))?; let user = data .user - .ok_or_else(|| NoteDeckError::Auth("MiAuth response missing user".to_string()))?; + .ok_or(NoteDeckError::Auth(AuthErrorKind::MiAuthMalformed("user")))?; Ok(AuthResult { token, @@ -1338,6 +1410,7 @@ impl MisskeyClient { return Err(NoteDeckError::Api { endpoint: "endpoint".to_string(), status: res.status().as_u16(), + api_code: None, message: "Failed to fetch endpoint info".to_string(), }); } @@ -1383,6 +1456,7 @@ impl MisskeyClient { return Err(NoteDeckError::Api { endpoint: "endpoints".to_string(), status: res.status().as_u16(), + api_code: None, message: "Failed to fetch endpoints".to_string(), }); } @@ -2155,6 +2229,7 @@ impl MisskeyClient { return Err(NoteDeckError::Api { endpoint: ".well-known/nodeinfo".to_string(), status: res.status().as_u16(), + api_code: None, message: "Failed to fetch well-known nodeinfo".to_string(), }); } @@ -2176,6 +2251,7 @@ impl MisskeyClient { .ok_or_else(|| NoteDeckError::Api { endpoint: ".well-known/nodeinfo".to_string(), status: 0, + api_code: None, message: format!("No nodeinfo URL found for {host}"), })?; @@ -2185,6 +2261,7 @@ impl MisskeyClient { return Err(NoteDeckError::Api { endpoint: ".well-known/nodeinfo".to_string(), status: 0, + api_code: None, message: format!("Nodeinfo URL host/scheme mismatch for {host}"), }); } @@ -2199,6 +2276,7 @@ impl MisskeyClient { return Err(NoteDeckError::Api { endpoint: "nodeinfo".to_string(), status: res.status().as_u16(), + api_code: None, message: "Failed to fetch nodeinfo".to_string(), }); } @@ -2580,6 +2658,7 @@ impl MisskeyClient { return Err(NoteDeckError::Api { endpoint: "meta".to_string(), status: res.status().as_u16(), + api_code: None, message: "Failed to fetch server meta".to_string(), }); } @@ -2741,7 +2820,7 @@ mod tests { "h", "token", "acc1", - TimelineType::new("home"), + &TimelineKey::parse("home").unwrap(), TimelineOptions::default(), ) .await @@ -2901,7 +2980,10 @@ mod tests { let role = notifs[0].role.as_ref().expect("role present"); assert_eq!(role.name, "Active"); assert_eq!(role.color.as_deref(), Some("#ff0000")); - assert_eq!(role.icon_url.as_deref(), Some("https://example.com/role.png")); + assert_eq!( + role.icon_url.as_deref(), + Some("https://example.com/role.png") + ); } #[tokio::test] @@ -2925,6 +3007,60 @@ mod tests { assert_eq!(notes[0].text.as_deref(), Some("Rust is great")); } + #[test] + fn misskey_id_at_encodes_time_part() { + // misskey.flowers の実 ID apfldnaym0v100e3 (createdAt 2026-08-02T16:41:40.666Z) + assert_eq!(misskey_id_at(1_785_688_900_666), "apfldnay"); + assert_eq!(misskey_id_at(TIME2000_MS), "00000000"); + // 2000 年より前は下限に丸める + assert_eq!(misskey_id_at(0), "00000000"); + } + + #[tokio::test] + async fn search_notes_hanami_uses_hanamisearch_endpoint() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/notes/hanamisearch-v1")) + .and(body_partial_json( + json!({ "query": "rust", "sinceId": "apfldnay", "untilId": "apfldnay" }), + )) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!([raw_note_json("n1", "rust note")])), + ) + .mount(&server) + .await; + + let mut options = SearchOptions::default(); + options.since_date = Some(1_785_688_900_666); + options.until_date = Some(1_785_688_900_666); + let client = MisskeyClient::with_base_url(&server.uri()); + let notes = client + .search_notes_hanami("h", "token", "acc1", "rust", options) + .await + .unwrap(); + assert_eq!(notes.len(), 1); + } + + #[tokio::test] + async fn search_notes_hanami_prefers_explicit_pagination_ids() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/notes/hanamisearch-v1")) + .and(body_partial_json(json!({ "untilId": "n42" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) + .mount(&server) + .await; + + let mut options = SearchOptions::default(); + options.until_id = Some("n42".to_string()); + options.until_date = Some(1_785_688_900_666); + let client = MisskeyClient::with_base_url(&server.uri()); + client + .search_notes_hanami("h", "token", "acc1", "rust", options) + .await + .unwrap(); + } + #[tokio::test] async fn get_user_detail_parses() { let server = MockServer::start().await; @@ -3596,7 +3732,9 @@ mod tests { // withReplies のみ指定 → notify は body に含まれないこと Mock::given(method("POST")) .and(path("/api/following/update")) - .and(body_partial_json(json!({ "userId": "u1", "withReplies": false }))) + .and(body_partial_json( + json!({ "userId": "u1", "withReplies": false }), + )) .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) .mount(&server) .await; @@ -3613,7 +3751,9 @@ mod tests { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/users/update-memo")) - .and(body_partial_json(json!({ "userId": "u1", "memo": "friend" }))) + .and(body_partial_json( + json!({ "userId": "u1", "memo": "friend" }), + )) .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) .mount(&server) .await; @@ -3700,10 +3840,11 @@ mod tests { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/notes/search")) - .and(body_partial_json(json!({ "query": "rust", "userId": "u1" }))) + .and(body_partial_json( + json!({ "query": "rust", "userId": "u1" }), + )) .respond_with( - ResponseTemplate::new(200) - .set_body_json(json!([raw_note_json("n1", "rust note")])), + ResponseTemplate::new(200).set_body_json(json!([raw_note_json("n1", "rust note")])), ) .mount(&server) .await; diff --git a/src/cli.rs b/src/cli.rs index b69d49b..0e2fcf6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -207,17 +207,25 @@ pub enum Commands { #[command( long_about = "指定したタイプのタイムラインからノートを取得します。\n\n\ タイプ:\n\ - \x20 home - ホームタイムライン(フォロー中のユーザーの投稿)\n\ - \x20 local - ローカルタイムライン(同じインスタンスの投稿)\n\ - \x20 social - ソーシャルタイムライン(ローカル + フォロー中)\n\ - \x20 global - グローバルタイムライン(連合の全投稿)", + \x20 home - ホームタイムライン(フォロー中のユーザーの投稿)\n\ + \x20 local - ローカルタイムライン(同じインスタンスの投稿)\n\ + \x20 social - ソーシャルタイムライン(ローカル + フォロー中)\n\ + \x20 global - グローバルタイムライン(連合の全投稿)\n\ + \x20 antenna:{id} - アンテナ\n\ + \x20 channel:{id} - チャンネル\n\ + \x20 role:{id} - ロールタイムライン\n\ + \x20 user-list:{id} - ユーザーリスト\n\ + \x20 user:{id} - ユーザーの投稿\n\ + \x20 mentions - あなた宛て\n\ + フォーク独自のベーシック TL (bubble 等) もそのまま指定できます。", after_long_help = "使用例:\n\ \x20 notecli timeline\n\ \x20 notecli timeline local -l 10\n\ + \x20 notecli timeline antenna:9abcdef12345\n\ \x20 notecli timeline -c | fzf --with-nth=2.. | cut -f1" )] Timeline { - /// タイムラインの種類: home, local, social, global + /// タイムラインの種類 (home, local, social, global, antenna:{id} 等) #[arg(default_value = "home")] r#type: String, /// 取得するノート数 (1-100) @@ -225,6 +233,10 @@ pub enum Commands { limit: i64, }, + /// ローカルノートキャッシュの管理 + #[command(subcommand)] + Cache(CacheCommands), + /// ノートを全文検索 #[command( long_about = "キーワードでノートを全文検索します。\n\ @@ -421,6 +433,17 @@ pub enum Commands { Emojis, } +#[derive(Subcommand)] +pub enum CacheCommands { + /// どのタイムラインにも属さない孤児ノート実体を掃除 + #[command( + long_about = "どのタイムラインにも属さないノート実体 (orphan) をキャッシュ DB から\n\ + 削除します。通常運用では所属を失った実体は同一トランザクションで掃除される\n\ + ため、これは修復用の手動コマンドです。" + )] + Sweep, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 597061b..d5de2fc 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -52,11 +52,7 @@ pub fn run_accounts(db: &Database, fmt: OutputFormat) -> Result<(), NoteDeckErro Ok(()) } -pub async fn run_login( - db: &Database, - host: &str, - fmt: OutputFormat, -) -> Result<(), NoteDeckError> { +pub async fn run_login(db: &Database, host: &str, fmt: OutputFormat) -> Result<(), NoteDeckError> { let client = MisskeyClient::new()?; let session_id = uuid::Uuid::new_v4().to_string(); @@ -89,9 +85,8 @@ pub async fn run_login( ]; let permission_str = permissions.join(","); let scheme = crate::insecure::http_scheme(host); - let auth_url = format!( - "{scheme}://{host}/miauth/{session_id}?name=notecli&permission={permission_str}" - ); + let auth_url = + format!("{scheme}://{host}/miauth/{session_id}?name=notecli&permission={permission_str}"); match fmt { OutputFormat::Json | OutputFormat::Jsonl => { @@ -108,7 +103,10 @@ pub async fn run_login( println!(); println!(" {}", theme::link(&auth_url)); println!(); - println!("{}", theme::muted("認証が完了したらEnterを押してください...")); + println!( + "{}", + theme::muted("認証が完了したらEnterを押してください...") + ); } } diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index a241f9f..93f241b 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -35,10 +35,22 @@ pub struct Check { impl Check { fn env(name: &str, status: Status, message: String) -> Self { - Self { name: name.into(), status, message, account: None, fix: None } + Self { + name: name.into(), + status, + message, + account: None, + fix: None, + } } fn acc(account: &str, name: &str, status: Status, message: String) -> Self { - Self { name: name.into(), status, message, account: Some(account.into()), fix: None } + Self { + name: name.into(), + status, + message, + account: Some(account.into()), + fix: None, + } } fn with_fix(mut self, fix: impl Into) -> Self { self.fix = Some(fix.into()); @@ -78,7 +90,10 @@ pub async fn diagnose( } let targets: Vec<&Account> = match account_spec { - Some(spec) => accounts.iter().filter(|a| account_matches(a, spec)).collect(), + Some(spec) => accounts + .iter() + .filter(|a| account_matches(a, spec)) + .collect(), None => accounts.iter().collect(), }; if let Some(spec) = account_spec { @@ -119,7 +134,11 @@ pub async fn run_doctor( fn check_database(db: &Database, path: &Path) -> Check { match db.load_accounts() { - Ok(_) => Check::env("database", Status::Ok, format!("{} (readable)", path.display())), + Ok(_) => Check::env( + "database", + Status::Ok, + format!("{} (readable)", path.display()), + ), Err(e) => Check::env( "database", Status::Fail, @@ -157,7 +176,12 @@ async fn check_account(client: &MisskeyClient, a: &Account, checks: &mut Vec 0 { println!("{}", theme::error(&format!("{fails} check(s) failed"))); } else if warns > 0 { - println!("{}", theme::badge(&format!("all checks passed ({warns} warning(s))"))); + println!( + "{}", + theme::badge(&format!("all checks passed ({warns} warning(s))")) + ); } else { println!("{}", theme::success("all checks passed")); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index da1af40..14b852a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -41,6 +41,15 @@ pub async fn run_cli( let account = resolve_account(&db, Some(target))?; return auth::run_logout(&db, &account, fmt); } + Commands::Cache(cache_cmd) => { + return match cache_cmd { + crate::cli::CacheCommands::Sweep => { + let deleted = db.sweep_orphan_notes()?; + println!("Removed {deleted} orphan note(s) from cache"); + Ok(()) + } + } + } _ => {} } @@ -63,7 +72,15 @@ pub async fn run_cli( reply_to, local_only, } => { - notes::run_post(&ctx, text, cw.as_deref(), visibility, reply_to.as_deref(), *local_only).await + notes::run_post( + &ctx, + text, + cw.as_deref(), + visibility, + reply_to.as_deref(), + *local_only, + ) + .await } Commands::Timeline { r#type, limit } => notes::run_timeline(&ctx, r#type, *limit).await, Commands::Search { query, limit } => notes::run_search(&ctx, query, *limit).await, @@ -71,9 +88,7 @@ pub async fn run_cli( Commands::Replies { id, limit } => notes::run_replies(&ctx, id, *limit).await, Commands::Thread { id, limit } => notes::run_thread(&ctx, id, *limit).await, Commands::Delete { id } => notes::run_delete(&ctx, id).await, - Commands::Update { id, text, cw } => { - notes::run_update(&ctx, id, text, cw.as_deref()).await - } + Commands::Update { id, text, cw } => notes::run_update(&ctx, id, text, cw.as_deref()).await, Commands::React { note_id, reaction } => notes::run_react(&ctx, note_id, reaction).await, Commands::Unreact { note_id } => notes::run_unreact(&ctx, note_id).await, Commands::Renote { note_id } => notes::run_renote(&ctx, note_id).await, @@ -93,7 +108,8 @@ pub async fn run_cli( | Commands::Doctor | Commands::Daemon { .. } | Commands::Login { .. } - | Commands::Logout { .. } => { + | Commands::Logout { .. } + | Commands::Cache(..) => { unreachable!() } } diff --git a/src/commands/notes.rs b/src/commands/notes.rs index 6e93854..27ef2f5 100644 --- a/src/commands/notes.rs +++ b/src/commands/notes.rs @@ -4,7 +4,7 @@ use crate::format::{ print_action, print_emojis, print_note_compact, print_note_detail, print_notes, print_notifications, OutputFormat, }; -use crate::models::{CreateNoteParams, SearchOptions, TimelineOptions, TimelineType}; +use crate::models::{CreateNoteParams, SearchOptions, TimelineKey, TimelineOptions}; pub async fn run_post( ctx: &CmdContext, @@ -43,14 +43,21 @@ pub async fn run_post( Ok(()) } -pub async fn run_timeline(ctx: &CmdContext, tl_type: &str, limit: i64) -> Result<(), NoteDeckError> { +pub async fn run_timeline( + ctx: &CmdContext, + tl_type: &str, + limit: i64, +) -> Result<(), NoteDeckError> { + // parse により prefix 付きキー (antenna:{id} 等) もここから使える。 + // favorites / clip: は専用 API のため get_timeline 側で Err になる。 + let key = TimelineKey::parse(tl_type)?; let notes = ctx .client .get_timeline( &ctx.host, &ctx.token, &ctx.account.id, - TimelineType::new(tl_type), + &key, TimelineOptions::new(limit, None, None), ) .await?; @@ -58,11 +65,7 @@ pub async fn run_timeline(ctx: &CmdContext, tl_type: &str, limit: i64) -> Result Ok(()) } -pub async fn run_search( - ctx: &CmdContext, - query: &str, - limit: i64, -) -> Result<(), NoteDeckError> { +pub async fn run_search(ctx: &CmdContext, query: &str, limit: i64) -> Result<(), NoteDeckError> { let notes = ctx .client .search_notes( @@ -93,11 +96,7 @@ pub async fn run_note(ctx: &CmdContext, id: &str) -> Result<(), NoteDeckError> { Ok(()) } -pub async fn run_replies( - ctx: &CmdContext, - id: &str, - limit: i64, -) -> Result<(), NoteDeckError> { +pub async fn run_replies(ctx: &CmdContext, id: &str, limit: i64) -> Result<(), NoteDeckError> { let notes = ctx .client .get_note_children(&ctx.host, &ctx.token, &ctx.account.id, id, limit as u32) @@ -106,11 +105,7 @@ pub async fn run_replies( Ok(()) } -pub async fn run_thread( - ctx: &CmdContext, - id: &str, - limit: i64, -) -> Result<(), NoteDeckError> { +pub async fn run_thread(ctx: &CmdContext, id: &str, limit: i64) -> Result<(), NoteDeckError> { let notes = ctx .client .get_note_conversation(&ctx.host, &ctx.token, &ctx.account.id, id, limit as u32) @@ -280,24 +275,14 @@ pub async fn run_unfavorite(ctx: &CmdContext, note_id: &str) -> Result<(), NoteD pub async fn run_favorites(ctx: &CmdContext, limit: i64) -> Result<(), NoteDeckError> { let notes = ctx .client - .get_favorites( - &ctx.host, - &ctx.token, - &ctx.account.id, - limit, - None, - None, - ) + .get_favorites(&ctx.host, &ctx.token, &ctx.account.id, limit, None, None) .await?; print_notes(¬es, ctx.fmt); Ok(()) } pub async fn run_emojis(ctx: &CmdContext) -> Result<(), NoteDeckError> { - let emojis = ctx - .client - .get_server_emojis(&ctx.host, &ctx.token) - .await?; + let emojis = ctx.client.get_server_emojis(&ctx.host, &ctx.token).await?; print_emojis(&emojis, ctx.fmt); Ok(()) } diff --git a/src/db.rs b/src/db.rs index 1c99589..a5373c8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -5,6 +5,7 @@ use std::sync::{Mutex, MutexGuard}; use crate::error::NoteDeckError; use crate::models::{ Account, ChatMessage, ChatMessageReaction, ChatReactionUser, NormalizedNote, ServerDetection, + TimelineKey, }; mod embedded { @@ -34,11 +35,14 @@ const PRAGMAS_WRITER: &str = "\ PRAGMA journal_mode=WAL;\ PRAGMA foreign_keys=ON;\ PRAGMA synchronous=NORMAL;\ + PRAGMA busy_timeout=5000;\ + PRAGMA journal_size_limit=67108864;\ PRAGMA mmap_size=268435456;\ PRAGMA cache_size=-16000;\ PRAGMA temp_store=MEMORY;"; const PRAGMAS_READER: &str = "\ + PRAGMA busy_timeout=5000;\ PRAGMA mmap_size=268435456;\ PRAGMA cache_size=-8000;\ PRAGMA temp_store=MEMORY;"; @@ -47,6 +51,11 @@ const PRAGMAS_READER: &str = "\ /// 大きすぎると起動が遅くなり、小さすぎると free page が溜まり続ける。 const INCREMENTAL_VACUUM_PAGES_PER_BOOT: i64 = 1000; +/// per-timeline トリムの 1 チャンク tx あたりの victim 上限。 +/// 初回有効化 (1M 規模で百万行級の削除) が単一 tx だと writer lock を分オーダーで +/// 占有し WS ingest / 全コマンドが停止するため分割する。 +const TRIM_CHUNK_ROWS: i64 = 50_000; + /// `notes_cache` の eviction policy。 デフォルトは「ほぼ永続保存」 — notedeck の /// 「過去ノートを一瞬でローカル検索」という UX を尊重し、 暴走防止の hard cap /// だけを残す。 アプリ側からユーザー設定で上書きできる。 @@ -54,10 +63,13 @@ const INCREMENTAL_VACUUM_PAGES_PER_BOOT: i64 = 1000; #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "specta", derive(specta::Type))] pub struct EvictionConfig { - /// 各アカウントごとの note 上限。`None` なら無制限。 + /// 各アカウントごとの note (entity) 上限。`None` なら無制限。 pub per_account_limit: Option, /// `cached_at` の TTL (日)。`None` なら無期限保持。 pub ttl_days: Option, + /// バケット (account_id × timeline_key) ごとの所属行上限。`None` なら無制限。 + /// トリムは membership とその対象限定の orphan entity のみを消す。 + pub per_timeline_limit: Option, } impl Default for EvictionConfig { @@ -66,6 +78,7 @@ impl Default for EvictionConfig { Self { per_account_limit: Some(1_000_000), ttl_days: None, + per_timeline_limit: None, } } } @@ -92,6 +105,45 @@ impl Default for ChatEvictionConfig { /// SQLite database with separate reader/writer connections. /// WAL mode allows concurrent reads while writing. +/// 走査を中断した位置。継続時はこの行より後ろから読み直す。 +/// +/// 「最後に**走査した**行」を指す。最後にマッチした行を指すと、その間にあった +/// マッチしない行を再開時にもう一度読むことになる。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CachedNoteCursor { + pub created_at: String, + pub note_id: String, +} + +/// `scan_cached_notes` の結果。 +#[derive(Debug, Clone, Default)] +pub struct CachedNoteScan { + /// 述語が true を返したノート + pub notes: Vec, + /// 実際に読んだ行数 + pub scanned: usize, + /// 述語が判定できなかった行 + JSON として読めなかった行の数 + pub errors: usize, + /// 走査上限で打ち切ったときの継続位置。読み切った場合は None + pub cursor: Option, +} + +/// FTS5 の MATCH 文字列を組み立てる。リテラルは AND 結合し、`"` は doubling で +/// エスケープする。trigram が成立しない 3 文字未満は落とす (押し込むと 0 件に +/// なり偽陰性を生むため)。押し込めるものが無ければ None = FTS を使わない。 +fn build_fts_match_query(literals: &[String]) -> Option { + let quoted: Vec = literals + .iter() + .filter(|l| l.chars().count() >= 3) + .map(|l| format!("\"{}\"", l.replace('"', "\"\""))) + .collect(); + if quoted.is_empty() { + None + } else { + Some(quoted.join(" AND ")) + } +} + pub struct Database { writer: Mutex, reader: Mutex, @@ -135,8 +187,23 @@ impl Database { // 既存 DB の場合はここで一度だけ VACUUM が走る。 Self::ensure_incremental_vacuum(&writer)?; - // Run numbered migrations (V1, V2, ...) + // V6 (実体/所属分離) は既存 DB の全行リライトを伴い 1M 行で 1 分前後かかる。 + // 既定 EnvFilter=warn では info が出ず無言ハングに見えるため warn で告知する。 + let long_migration_pending = Self::schema_version(&writer).is_some_and(|v| v < 6); + if long_migration_pending { + tracing::warn!( + "applying notes-cache schema migration (V6); this may take a minute \ + and temporarily needs free disk up to ~2x the database size" + ); + } + let migration_started = std::time::Instant::now(); + + // Run numbered migrations (V1, V2, ...)。 + // set_grouped(true) は必須: 既定 (grouped=false) では migration 本体と + // schema_history 記録が別コミットになり、間で kill されると非冪等 SQL + // (V2 の ADD COLUMN / V6 の DROP COLUMN) の再適用が失敗して DB が開けなくなる。 embedded::migrations::runner() + .set_grouped(true) .run(&mut writer) .map_err(|e| { NoteDeckError::Database(rusqlite::Error::SqliteFailure( @@ -144,6 +211,15 @@ impl Database { Some(format!("Migration failed: {e}")), )) })?; + if long_migration_pending { + tracing::warn!( + elapsed_ms = migration_started.elapsed().as_millis() as u64, + "notes-cache schema migration complete" + ); + } + + // checkpoint#1: migration が膨らませた WAL を回収する (best-effort)。 + Self::wal_checkpoint_truncate(&writer); // One-time FTS rebuild for existing databases upgraded before FTS5 was added Self::rebuild_fts_if_needed(&writer)?; @@ -165,9 +241,53 @@ impl Database { db.cleanup_chat_with_eviction(&chat_eviction)?; // cleanup で生まれた free page を少し返却する (起動コスト一定)。 db.incremental_vacuum_step()?; + { + let conn = db.lock_write()?; + // 空テーブルへの ANALYZE は stat1 を作らないため、新規 DB が成長した後の + // stat1 生成 (idx_note_timelines_note を CASCADE に選ばせる必須要件) を + // ここが担う。ANALYZE 済みなら実質 no-op。 + conn.execute_batch("PRAGMA optimize;")?; + // checkpoint#2: cleanup / optimize / vacuum step の write を回収 (best-effort)。 + Self::wal_checkpoint_truncate(&conn); + } Ok(db) } + /// refinery_schema_history の最新 version。テーブルが無い (新規 DB) なら None。 + fn schema_version(conn: &Connection) -> Option { + conn.query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |row| row.get::<_, Option>(0), + ) + .ok() + .flatten() + } + + /// `PRAGMA wal_checkpoint(TRUNCATE)` を best-effort で実行する。 + /// 他プロセスの active reader/writer がいると busy=1 の結果行を返して + /// エラーなく劣化する (frame copy は完了、truncate のみ持ち越し)。 + /// 恒久残留の防止は毎起動の再試行が実体。 + fn wal_checkpoint_truncate(conn: &Connection) { + match conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + )) + }) { + Ok((busy, log, checkpointed)) if busy != 0 => { + tracing::debug!( + log, + checkpointed, + "wal_checkpoint(TRUNCATE) busy; truncate deferred" + ); + } + Ok(_) => {} + Err(e) => tracing::debug!(error = %e, "wal_checkpoint(TRUNCATE) failed"), + } + } + /// DB 本体と WAL/SHM を owner-only (0600) に締める。失敗しても DB は開ける /// (パーミッションより可用性を優先し、エラーは握りつぶす)。 #[cfg(unix)] @@ -345,12 +465,20 @@ impl Database { pub fn delete_account(&self, id: &str) -> Result<(), NoteDeckError> { let conn = self.lock_write()?; - conn.execute("DELETE FROM notes_cache WHERE account_id = ?1", params![id])?; - conn.execute( + let tx = conn.unchecked_transaction()?; + // membership を先に一括 DELETE してから entity を消す (行単位 CASCADE + + // FTS トリガの遅い経路を回避 — clear_account_cache と同じ理由)。 + tx.execute( + "DELETE FROM note_timelines WHERE account_id = ?1", + params![id], + )?; + tx.execute("DELETE FROM notes_cache WHERE account_id = ?1", params![id])?; + tx.execute( "DELETE FROM chat_messages_cache WHERE account_id = ?1", params![id], )?; - conn.execute("DELETE FROM accounts WHERE id = ?1", params![id])?; + tx.execute("DELETE FROM accounts WHERE id = ?1", params![id])?; + tx.commit()?; Ok(()) } @@ -359,17 +487,26 @@ impl Database { /// Delete all cached notes for a specific account. pub fn clear_account_cache(&self, account_id: &str) -> Result { let conn = self.lock_write()?; - let deleted = conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( + "DELETE FROM note_timelines WHERE account_id = ?1", + params![account_id], + )?; + let deleted = tx.execute( "DELETE FROM notes_cache WHERE account_id = ?1", params![account_id], )?; + tx.commit()?; Ok(deleted as u64) } /// Delete all cached notes for every account. pub fn clear_all_notes_cache(&self) -> Result { let conn = self.lock_write()?; - let deleted = conn.execute("DELETE FROM notes_cache", [])?; + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM note_timelines", [])?; + let deleted = tx.execute("DELETE FROM notes_cache", [])?; + tx.commit()?; Ok(deleted as u64) } @@ -437,11 +574,21 @@ impl Database { // --- Notes cache --- - pub fn cache_notes( + /// 唯一の書込経路。entity upsert + membership upsert を単一 tx で行う。 + /// + /// - entity: `ON CONFLICT DO UPDATE` (text / note_json / cached_at / uri)。 + /// 新旧判定は持たない last-writer-wins (WS/polling の fire-and-forget により + /// DB 到達順は無保証 — 現行同等)。 + /// - membership: `ON CONFLICT DO NOTHING` (added_at は初回値維持)。 + /// - sort_key は常に `note.created_at` (サーバー由来文字列をそのまま) を書く。 + /// - account_id は各 note の `NormalizedNote.account_id` から取る (混在配列も + /// per-note に正しく処理)。 + pub fn ingest_notes( &self, notes: &[NormalizedNote], - timeline_type: &str, + key: &TimelineKey, ) -> Result<(), NoteDeckError> { + let canonical = key.as_canonical(); let conn = self.lock_write()?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -449,19 +596,23 @@ impl Database { .as_secs() as i64; let tx = conn.unchecked_transaction()?; { - let mut stmt = tx.prepare_cached( - "INSERT INTO notes_cache (note_id, account_id, server_host, created_at, text, note_json, cached_at, timeline_type, uri) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + let mut entity_stmt = tx.prepare_cached( + "INSERT INTO notes_cache (note_id, account_id, server_host, created_at, text, note_json, cached_at, uri) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(note_id, account_id) DO UPDATE SET text = excluded.text, note_json = excluded.note_json, cached_at = excluded.cached_at, - timeline_type = excluded.timeline_type, uri = excluded.uri", )?; + let mut membership_stmt = tx.prepare_cached( + "INSERT INTO note_timelines (account_id, timeline_key, note_id, sort_key, added_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(account_id, timeline_key, note_id) DO NOTHING", + )?; for note in notes { let json = serde_json::to_string(note).unwrap_or_default(); - stmt.execute(params![ + entity_stmt.execute(params![ note.id, note.account_id, note.server_host, @@ -469,21 +620,103 @@ impl Database { note.text, json, now, - timeline_type, note.uri, ])?; + membership_stmt.execute(params![ + note.account_id, + canonical, + note.id, + note.created_at, + now, + ])?; } } tx.commit()?; Ok(()) } - pub fn cache_note( + /// バケットから 1 ノートの所属を外す。当該バケットにのみ所属する entity は + /// 同一 tx で掃除する (CASCADE が membership を道連れにする)。 + /// + /// 戻り値は「対象 note の membership が実在し削除されたか」の件数 (0 or 1)。 + /// entity 先行 CASCADE の場合も 1 と数える。 + pub fn remove_membership( &self, - note: &NormalizedNote, - timeline_type: &str, - ) -> Result<(), NoteDeckError> { - self.cache_notes(std::slice::from_ref(note), timeline_type) + account_id: &str, + key: &TimelineKey, + note_id: &str, + ) -> Result { + let canonical = key.as_canonical(); + let conn = self.lock_write()?; + let tx = conn.unchecked_transaction()?; + // 逆順 2 文: ①当該バケットにのみ所属する entity を先に DELETE (CASCADE が + // membership を道連れ)。述語は EXISTS(当該バケット) ∧ NOT EXISTS(他バケット) + // — EXISTS を欠くと membership ゼロの orphan entity を巻き添え削除して + // 件数意味論が破れる。 + let entity_deleted = tx.execute( + "DELETE FROM notes_cache + WHERE note_id = ?3 AND account_id = ?1 + AND EXISTS (SELECT 1 FROM note_timelines m + WHERE m.account_id = ?1 AND m.timeline_key = ?2 AND m.note_id = ?3) + AND NOT EXISTS (SELECT 1 FROM note_timelines m + WHERE m.note_id = ?3 AND m.account_id = ?1 + AND m.timeline_key <> ?2)", + params![account_id, canonical, note_id], + )?; + // ②残 membership DELETE (①が発火した場合は CASCADE 済みで 0 行) + let membership_deleted = tx.execute( + "DELETE FROM note_timelines + WHERE account_id = ?1 AND timeline_key = ?2 AND note_id = ?3", + params![account_id, canonical, note_id], + )?; + tx.commit()?; + Ok((entity_deleted + membership_deleted) as u64) + } + + /// バケットを丸ごと破棄する (次回フェッチで再構築される)。 + /// 当該バケットにのみ所属する entity は同一 tx で掃除する。 + /// + /// 戻り値は削除した membership 行数 (対象限定掃除で消えた entity は数えない — + /// ①の entity 1 件は CASCADE でちょうど 1 membership を道連れにするため + /// ①+② が membership 総数になる)。 + pub fn clear_timeline( + &self, + account_id: &str, + key: &TimelineKey, + ) -> Result { + let canonical = key.as_canonical(); + let conn = self.lock_write()?; + let tx = conn.unchecked_transaction()?; + let entity_deleted = tx.execute( + "DELETE FROM notes_cache + WHERE (note_id, account_id) IN ( + SELECT m.note_id, m.account_id FROM note_timelines m + WHERE m.account_id = ?1 AND m.timeline_key = ?2 + AND NOT EXISTS (SELECT 1 FROM note_timelines o + WHERE o.note_id = m.note_id AND o.account_id = m.account_id + AND o.timeline_key <> ?2))", + params![account_id, canonical], + )?; + let membership_deleted = tx.execute( + "DELETE FROM note_timelines WHERE account_id = ?1 AND timeline_key = ?2", + params![account_id, canonical], + )?; + tx.commit()?; + Ok((entity_deleted + membership_deleted) as u64) + } + + /// どのバケットにも所属しない entity を掃除する (修復用の手動 API。自動実行なし)。 + /// 戻り値は削除した entity 行数。 + pub fn sweep_orphan_notes(&self) -> Result { + let conn = self.lock_write()?; + let deleted = conn.execute( + "DELETE FROM notes_cache + WHERE NOT EXISTS (SELECT 1 FROM note_timelines m + WHERE m.note_id = notes_cache.note_id + AND m.account_id = notes_cache.account_id)", + [], + )?; + Ok(deleted as u64) } /// Find cached notes by ActivityPub URI across all accounts. @@ -602,20 +835,169 @@ impl Database { .collect()) } + /// キャッシュ済みノートを走査し、呼び出し側の述語で絞り込む。 + /// + /// クエリ機能 (notedeck の カラムクエリ #783) のように、判定ロジックが + /// 呼び出し側にしかない検索のための API。この層は「FTS で粗く絞って行を + /// 読み、述語に渡す」だけで、述語の意味論には関与しない。 + /// + /// - `fts_literals`: FTS5 に押し込むリテラル群 (AND 結合)。空なら全件走査。 + /// 偽陰性を避けるため、trigram が成立しない 3 文字未満は無視する + /// - `limit`: 返すノートの上限 + /// - `max_scanned_rows`: 走査する行数の上限。到達したら打ち切って + /// 継続カーソルを返す (巨大キャッシュで応答が返らなくなるのを防ぐ) + /// - `pred`: `None` を返すと per-note エラーとして除外し件数に計上する + /// + /// DB ロックはチャンク単位で取り直す。述語の評価はロックの外で行うので、 + /// 重い述語が他の DB 利用者を待たせない。 + pub fn scan_cached_notes( + &self, + account_id: &str, + fts_literals: &[String], + limit: usize, + max_scanned_rows: usize, + after: Option<&CachedNoteCursor>, + mut pred: F, + ) -> Result + where + F: FnMut(&NormalizedNote) -> Option, + { + /// 1 度のロックで読む行数 + const CHUNK: usize = 200; + + let mut out = CachedNoteScan::default(); + if limit == 0 || max_scanned_rows == 0 { + return Ok(out); + } + + // trigram が成立しない短いリテラルを押し込むと 0 件になり偽陰性になる。 + // 呼び出し側で弾く約束だが、影響が致命的なのでここでも落とす + let match_query = build_fts_match_query(fts_literals); + + let mut cursor = after.cloned(); + let mut exhausted = false; + + while out.notes.len() < limit && out.scanned < max_scanned_rows { + let take = CHUNK.min(max_scanned_rows - out.scanned); + let rows = self.fetch_scan_chunk(account_id, match_query.as_deref(), &cursor, take)?; + if rows.is_empty() { + exhausted = true; + break; + } + let fetched = rows.len(); + let mut hit_limit = false; + for (note_id, created_at, json) in rows { + out.scanned += 1; + cursor = Some(CachedNoteCursor { + created_at, + note_id, + }); + match serde_json::from_str::(&json) { + Ok(note) => match pred(¬e) { + Some(true) => { + out.notes.push(note); + if out.notes.len() >= limit { + hit_limit = true; + break; + } + } + Some(false) => {} + // 述語が判定できなかった (型エラー等) + None => out.errors += 1, + }, + // スキーマ世代差・破損行も per-note エラーとして扱う + Err(_) => out.errors += 1, + } + } + // limit で止めた場合はこのチャンクを読み切っていないので、 + // 「読み切った」判定に落とさずカーソルを残す + if hit_limit { + break; + } + if fetched < take { + exhausted = true; + break; + } + } + + out.cursor = if exhausted { None } else { cursor }; + Ok(out) + } + + /// 走査の 1 チャンクを読む。ロックはこの関数の中だけで保持する。 + fn fetch_scan_chunk( + &self, + account_id: &str, + match_query: Option<&str>, + cursor: &Option, + take: usize, + ) -> Result, NoteDeckError> { + let conn = self.lock_read()?; + let mut conditions = vec!["nc.account_id = ?1".to_string()]; + let mut idx = 2u32; + if match_query.is_some() { + conditions.push(format!( + "nc.rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?{idx})" + )); + idx += 1; + } + if cursor.is_some() { + // created_at の同値で分かれても順序が定まるよう note_id を副キーにする + conditions.push(format!( + "(nc.created_at < ?{idx} OR (nc.created_at = ?{idx} AND nc.note_id < ?{}))", + idx + 1 + )); + idx += 2; + } + let sql = format!( + "SELECT nc.note_id, nc.created_at, nc.note_json FROM notes_cache nc WHERE {} \ + ORDER BY nc.created_at DESC, nc.note_id DESC LIMIT ?{idx}", + conditions.join(" AND "), + ); + + let mut params: Vec> = Vec::new(); + params.push(Box::new(account_id.to_string())); + if let Some(q) = match_query { + params.push(Box::new(q.to_string())); + } + if let Some(c) = cursor { + params.push(Box::new(c.created_at.clone())); + params.push(Box::new(c.note_id.clone())); + } + params.push(Box::new(take as i64)); + + let refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare_cached(&sql)?; + let rows = stmt + .query_map(refs.as_slice(), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? + .filter_map(|r| r.ok()) + .collect::>(); + Ok(rows) + } + + /// バケットの最新 `limit` 件を返す。membership を index seek → entity を PK lookup。 + /// limit は membership 行数に適用し、note_json parse 失敗行は skip (返却 < limit 許容)。 pub fn get_cached_timeline( &self, account_id: &str, - timeline_type: &str, + key: &TimelineKey, limit: i64, ) -> Result, NoteDeckError> { let conn = self.lock_read()?; let mut stmt = conn.prepare_cached( - "SELECT note_json FROM notes_cache - WHERE account_id = ?1 AND timeline_type = ?2 - ORDER BY created_at DESC + "SELECT e.note_json FROM note_timelines m + JOIN notes_cache e ON e.note_id = m.note_id AND e.account_id = m.account_id + WHERE m.account_id = ?1 AND m.timeline_key = ?2 + ORDER BY m.sort_key DESC, m.note_id DESC LIMIT ?3", )?; - let rows = stmt.query_map(params![account_id, timeline_type, limit], |row| { + let rows = stmt.query_map(params![account_id, key.as_canonical(), limit], |row| { let json_str: String = row.get(0)?; Ok(json_str) })?; @@ -640,36 +1022,60 @@ impl Database { /// して該当する DELETE をスキップする。 アプリ実行中に設定を変えた直後にも /// 呼ぶ想定 (UI から「すぐ反映」 ボタン等)。 /// - /// 削除順: - /// 1. **TTL**: `cached_at < now - ttl_days` の行を削除 (`ttl_days = None` ならスキップ)。 - /// 2. **Per-account hard cap**: アカウントごとに最新 `per_account_limit` 件を - /// 残し、それ以外を削除 (`per_account_limit = None` ならスキップ)。 + /// 削除順 (①③は単一 tx、②はチャンク分割 tx): + /// 1. **TTL**: `cached_at < now - ttl_days` の entity を削除 → CASCADE で所属連動。 + /// 2. **Per-timeline トリム**: バケットごとに上位 `per_timeline_limit` 件を残し + /// membership を削除。当該 victim のうちどのバケットにも所属しなくなった + /// entity は同一チャンク tx 内で掃除する。Favorites/Clip バケットは + /// added_at 降順 (= 初回ローカル取得時刻。サーバー上の追加時刻とは一致しない + /// 既知の制限)、他は sort_key 降順で残す。 + /// 初回有効化は 1M 規模で分オーダーの削除になり得るため、victim を + /// `TRIM_CHUNK_ROWS` 行ずつのチャンク tx に分割する (中断しても各チャンクは + /// 一貫状態で orphan を生まない — 未処理 victim は membership が残るため + /// 次回 cleanup が再計算して続きから削る)。 + /// 3. **Per-account hard cap**: アカウントごとに `cached_at` 降順で + /// `per_account_limit` 件を残し entity を削除 → CASCADE で所属連動。 /// - /// 戻り値は削除した行数。`notes_fts` は `AFTER DELETE` トリガーで連動掃除される。 + /// 戻り値は削除した entity + membership の総行数。`notes_fts` は + /// `AFTER DELETE` トリガーで連動掃除される。 pub fn cleanup_with_eviction(&self, config: &EvictionConfig) -> Result { - // どちらも無効なら早期 return (lock も取らない)。 - if config.per_account_limit.is_none() && config.ttl_days.is_none() { + // 全フィールド無効なら早期 return (lock も取らない)。 + // per_timeline_limit を含む 3 フィールド判定であること (2 フィールド判定だと + // per-timeline のみ設定時にトリムが走らない)。 + if config.per_account_limit.is_none() + && config.ttl_days.is_none() + && config.per_timeline_limit.is_none() + { return Ok(0); } let conn = self.lock_write()?; - let tx = conn.unchecked_transaction()?; let mut total_deleted: u64 = 0; + // ① TTL (単一 tx) if let Some(ttl_days) = config.ttl_days { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64; let ttl_cutoff = now - ttl_days * 86_400; + let tx = conn.unchecked_transaction()?; let n = tx.execute( "DELETE FROM notes_cache WHERE cached_at < ?1", params![ttl_cutoff], )?; + tx.commit()?; total_deleted += n as u64; } + // ② per-timeline トリム (チャンク分割 tx) + if let Some(per_timeline_limit) = config.per_timeline_limit { + total_deleted += self.trim_timelines_chunked(&conn, per_timeline_limit)?; + } + + // ③ per-account hard cap (単一 tx) if let Some(per_account_limit) = config.per_account_limit { + let tx = conn.unchecked_transaction()?; // SQLite 3.25+ の window function で 1 クエリで評価。 let n = tx.execute( "DELETE FROM notes_cache @@ -686,13 +1092,79 @@ impl Database { )", params![per_account_limit], )?; + tx.commit()?; total_deleted += n as u64; } - tx.commit()?; Ok(total_deleted) } + /// per-timeline トリムの実体。victim (バケット上限超過の membership) を + /// チャンクごとの tx で削除し、victim のうち所属ゼロになった entity を + /// 同一 tx で掃除する。戻り値は削除した membership + entity の総行数。 + fn trim_timelines_chunked( + &self, + conn: &Connection, + per_timeline_limit: i64, + ) -> Result { + let mut total: u64 = 0; + loop { + let tx = conn.unchecked_transaction()?; + tx.execute_batch( + "CREATE TEMP TABLE IF NOT EXISTS trim_victims ( + account_id TEXT NOT NULL, + timeline_key TEXT NOT NULL, + note_id TEXT NOT NULL, + PRIMARY KEY (account_id, timeline_key, note_id) + ) WITHOUT ROWID; + DELETE FROM trim_victims;", + )?; + // 残す順: Favorites/Clip は added_at 降順、他は sort_key 降順。 + // チャンクの選び方は任意でよい (削除後に再計算するため最終形は不変)。 + let picked = tx.execute( + "INSERT INTO trim_victims (account_id, timeline_key, note_id) + SELECT account_id, timeline_key, note_id FROM ( + SELECT account_id, timeline_key, note_id, + ROW_NUMBER() OVER ( + PARTITION BY account_id, timeline_key + ORDER BY + CASE WHEN timeline_key = 'favorites' + OR timeline_key LIKE 'clip:%' + THEN added_at END DESC, + sort_key DESC, note_id DESC + ) AS rn + FROM note_timelines + ) + WHERE rn > ?1 + LIMIT ?2", + params![per_timeline_limit, TRIM_CHUNK_ROWS], + )?; + if picked == 0 { + tx.commit()?; + break; + } + // membership DELETE → 当該 victim 群の対象限定 entity 掃除 (同一 tx) + let memberships = tx.execute( + "DELETE FROM note_timelines + WHERE (account_id, timeline_key, note_id) IN ( + SELECT account_id, timeline_key, note_id FROM trim_victims)", + [], + )?; + let entities = tx.execute( + "DELETE FROM notes_cache + WHERE (note_id, account_id) IN ( + SELECT DISTINCT v.note_id, v.account_id FROM trim_victims v + WHERE NOT EXISTS (SELECT 1 FROM note_timelines m + WHERE m.note_id = v.note_id + AND m.account_id = v.account_id))", + [], + )?; + tx.commit()?; + total += (memberships + entities) as u64; + } + Ok(total) + } + /// 1 度に最大 `INCREMENTAL_VACUUM_PAGES_PER_BOOT` ページを `auto_vacuum=INCREMENTAL` /// で返却する。起動時の cleanup 後に呼ぶことで、長期蓄積した free page を /// 段階的にディスクへ返す。実行コストは数ミリ秒オーダー。 @@ -737,13 +1209,19 @@ impl Database { } /// 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> { + /// account スコープ (全アカウント一括削除の暗黙挙動を廃止)。所属は CASCADE で + /// 連動削除される。戻り値は entity が実在し削除されたか。 + pub fn delete_cached_note( + &self, + account_id: &str, + note_id: &str, + ) -> Result { let conn = self.lock_write()?; - conn.execute( - "DELETE FROM notes_cache WHERE note_id = ?1", - params![note_id], + let n = conn.execute( + "DELETE FROM notes_cache WHERE note_id = ?1 AND account_id = ?2", + params![note_id, account_id], )?; - Ok(()) + Ok(n > 0) } /// Return (note_count, db_size_bytes). @@ -762,46 +1240,71 @@ impl Database { Ok((count, page_count * page_size)) } - /// Fetch cached notes created at or before the given ISO 8601 datetime. + /// カーソル以前のバケット内ノートを返す。 + /// + /// keyset cursor: `before_note_id` が Some なら行値比較 `(sort_key, note_id) < (?, ?)` + /// (排他)、None なら `sort_key <= ?` (現行互換の包含比較。境界重複はフロント + /// dedup が吸収)。タイムスタンプ単独カーソルは同一 sort_key が limit 以上並ぶと + /// 前進不能になるため、呼び出し側は note_id を渡すこと。 pub fn get_cached_timeline_before( &self, account_id: &str, - timeline_type: &str, - before: &str, + key: &TimelineKey, + before_sort_key: &str, + before_note_id: Option<&str>, limit: i64, ) -> Result, NoteDeckError> { let conn = self.lock_read()?; - let mut stmt = conn.prepare_cached( - "SELECT note_json FROM notes_cache - WHERE account_id = ?1 AND timeline_type = ?2 AND created_at <= ?3 - ORDER BY created_at DESC - LIMIT ?4", - )?; - let rows = stmt.query_map(params![account_id, timeline_type, before, limit], |row| { - let json_str: String = row.get(0)?; - Ok(json_str) - })?; - let mut notes = Vec::new(); - for row in rows { - let json_str = row?; - if let Ok(note) = serde_json::from_str::(&json_str) { - notes.push(note); + let canonical = key.as_canonical(); + let jsons: Vec = match before_note_id { + Some(note_id) => { + let mut stmt = conn.prepare_cached( + "SELECT e.note_json FROM note_timelines m + JOIN notes_cache e ON e.note_id = m.note_id AND e.account_id = m.account_id + WHERE m.account_id = ?1 AND m.timeline_key = ?2 + AND (m.sort_key, m.note_id) < (?3, ?4) + ORDER BY m.sort_key DESC, m.note_id DESC + LIMIT ?5", + )?; + let rows = stmt.query_map( + params![account_id, canonical, before_sort_key, note_id, limit], + |row| row.get::<_, String>(0), + )?; + rows.collect::>()? } - } - Ok(notes) + None => { + let mut stmt = conn.prepare_cached( + "SELECT e.note_json FROM note_timelines m + JOIN notes_cache e ON e.note_id = m.note_id AND e.account_id = m.account_id + WHERE m.account_id = ?1 AND m.timeline_key = ?2 AND m.sort_key <= ?3 + ORDER BY m.sort_key DESC, m.note_id DESC + LIMIT ?4", + )?; + let rows = stmt.query_map( + params![account_id, canonical, before_sort_key, limit], + |row| row.get::<_, String>(0), + )?; + rows.collect::>()? + } + }; + Ok(jsons + .iter() + .filter_map(|json| serde_json::from_str::(json).ok()) + .collect()) } - /// Get the date range (min, max) of cached notes for a timeline. + /// バケットの sort_key 範囲 (min, max) を返す。sort_key = created_at の間は + /// 現行と同値。 pub fn get_cache_date_range( &self, account_id: &str, - timeline_type: &str, + key: &TimelineKey, ) -> Result, NoteDeckError> { let conn = self.lock_read()?; let result: (Option, Option) = conn.query_row( - "SELECT MIN(created_at), MAX(created_at) FROM notes_cache - WHERE account_id = ?1 AND timeline_type = ?2", - params![account_id, timeline_type], + "SELECT MIN(sort_key), MAX(sort_key) FROM note_timelines + WHERE account_id = ?1 AND timeline_key = ?2", + params![account_id, key.as_canonical()], |row| Ok((row.get(0)?, row.get(1)?)), )?; match result { @@ -1339,6 +1842,10 @@ mod tests { (dir, db) } + fn tk(s: &str) -> TimelineKey { + TimelineKey::parse(s).unwrap() + } + // --- Migration tests --- #[test] @@ -1437,7 +1944,8 @@ mod tests { } #[test] - fn notes_cache_has_timeline_type_column() { + fn notes_cache_has_no_timeline_type_column() { + // V6 で timeline_type 列は除去され、所属は note_timelines が持つ let (_dir, db) = temp_db(); let conn = db.lock().unwrap(); let has: bool = conn @@ -1447,7 +1955,14 @@ mod tests { .unwrap() .query_row([], |row| row.get(0)) .unwrap(); - assert!(has); + assert!(!has); + + let has_membership: bool = conn + .prepare("SELECT COUNT(*) FROM sqlite_master WHERE name='note_timelines'") + .unwrap() + .query_row([], |row| row.get(0)) + .unwrap(); + assert!(has_membership); } // --- Account CRUD tests --- @@ -1645,9 +2160,9 @@ mod tests { fn cache_note_and_retrieve() { let (_dir, db) = temp_db(); let note = sample_note("note-1", "Hello world"); - db.cache_notes(&[note], "home").unwrap(); + db.ingest_notes(&[note], &tk("home")).unwrap(); - let cached = db.get_cached_timeline("acc-1", "home", 10).unwrap(); + let cached = db.get_cached_timeline("acc-1", &tk("home"), 10).unwrap(); assert_eq!(cached.len(), 1); assert_eq!(cached[0].id, "note-1"); } @@ -1655,23 +2170,23 @@ mod tests { #[test] fn cache_note_delete() { let (_dir, db) = temp_db(); - db.cache_notes(&[sample_note("note-1", "test")], "home") + db.ingest_notes(&[sample_note("note-1", "test")], &tk("home")) .unwrap(); - db.delete_cached_note("note-1").unwrap(); + db.delete_cached_note("acc-1", "note-1").unwrap(); - let cached = db.get_cached_timeline("acc-1", "home", 10).unwrap(); + let cached = db.get_cached_timeline("acc-1", &tk("home"), 10).unwrap(); assert!(cached.is_empty()); } #[test] fn fts_search_finds_cached_notes() { let (_dir, db) = temp_db(); - db.cache_notes( + db.ingest_notes( &[ sample_note("n1", "Rust programming language"), sample_note("n2", "Python scripting"), ], - "home", + &tk("home"), ) .unwrap(); @@ -1701,11 +2216,11 @@ mod tests { #[test] fn fts_search_reflects_note_edit() { let (_dir, db) = temp_db(); - db.cache_notes(&[sample_note("n1", "before edit text")], "home") + db.ingest_notes(&[sample_note("n1", "before edit text")], &tk("home")) .unwrap(); // 同じノートが編集後のテキストで再キャッシュされる(Misskey のノート編集) - db.cache_notes(&[sample_note("n1", "after edit text")], "home") + db.ingest_notes(&[sample_note("n1", "after edit text")], &tk("home")) .unwrap(); let hit_new = db.search_cached_notes("acc-1", "after", 10).unwrap(); @@ -1722,9 +2237,9 @@ mod tests { // text が null のノート(renote 等)が後からテキスト付きで再キャッシュされる let mut no_text = sample_note("n1", ""); no_text.text = None; - db.cache_notes(&[no_text], "home").unwrap(); + db.ingest_notes(&[no_text], &tk("home")).unwrap(); - db.cache_notes(&[sample_note("n1", "now has text")], "home") + db.ingest_notes(&[sample_note("n1", "now has text")], &tk("home")) .unwrap(); let results = db.search_cached_notes("acc-1", "now has", 10).unwrap(); @@ -1734,10 +2249,10 @@ mod tests { #[test] fn cache_date_range() { let (_dir, db) = temp_db(); - db.cache_notes(&[sample_note("n1", "test")], "home") + db.ingest_notes(&[sample_note("n1", "test")], &tk("home")) .unwrap(); - let range = db.get_cache_date_range("acc-1", "home").unwrap(); + let range = db.get_cache_date_range("acc-1", &tk("home")).unwrap(); assert!(range.is_some()); let (oldest, newest) = range.unwrap(); assert_eq!(oldest, newest); // single note @@ -1817,9 +2332,9 @@ mod tests { #[test] fn cleanup_removes_notes_older_than_ttl() { let (_dir, db) = temp_db(); - db.cache_note(¬e_for_account("fresh", "acc-1"), "home") + db.ingest_notes(&[note_for_account("fresh", "acc-1")], &tk("home")) .unwrap(); - db.cache_note(¬e_for_account("stale", "acc-1"), "home") + db.ingest_notes(&[note_for_account("stale", "acc-1")], &tk("home")) .unwrap(); // stale を 10 日前に偽装、TTL = 1 日でカット set_cached_at(&db, "stale", 0); @@ -1832,11 +2347,13 @@ mod tests { let cfg = EvictionConfig { per_account_limit: Some(10_000), ttl_days: Some(1), + per_timeline_limit: None, }; let deleted = db.cleanup_with_eviction(&cfg).unwrap(); assert_eq!(deleted, 1); - let remaining: Vec = db.get_cached_timeline("acc-1", "home", 100).unwrap(); + let remaining: Vec = + db.get_cached_timeline("acc-1", &tk("home"), 100).unwrap(); assert_eq!(remaining.len(), 1); assert_eq!(remaining[0].id, "fresh"); } @@ -1846,7 +2363,7 @@ mod tests { let (_dir, db) = temp_db(); // 5 件 insert (cached_at は now ですべて同程度) for i in 0..5 { - db.cache_note(¬e_for_account(&format!("n{i}"), "acc-1"), "home") + db.ingest_notes(&[note_for_account(&format!("n{i}"), "acc-1")], &tk("home")) .unwrap(); } // 古い 2 件を 1 時間前に偽装 → cap=3 で削除されるのはこの 2 件 @@ -1856,11 +2373,13 @@ mod tests { let cfg = EvictionConfig { per_account_limit: Some(3), ttl_days: None, // TTL 無効で件数だけテスト + per_timeline_limit: None, }; let deleted = db.cleanup_with_eviction(&cfg).unwrap(); assert_eq!(deleted, 2); - let remaining: Vec = db.get_cached_timeline("acc-1", "home", 100).unwrap(); + let remaining: Vec = + db.get_cached_timeline("acc-1", &tk("home"), 100).unwrap(); assert_eq!(remaining.len(), 3); // n0 / n1 (古い) が消えて n2 / n3 / n4 が残る let mut ids: Vec<&str> = remaining.iter().map(|n| n.id.as_str()).collect(); @@ -1873,11 +2392,11 @@ mod tests { let (_dir, db) = temp_db(); // acc-1 に 4 件、acc-2 に 2 件 for i in 0..4 { - db.cache_note(¬e_for_account(&format!("a{i}"), "acc-1"), "home") + db.ingest_notes(&[note_for_account(&format!("a{i}"), "acc-1")], &tk("home")) .unwrap(); } for i in 0..2 { - db.cache_note(¬e_for_account(&format!("b{i}"), "acc-2"), "home") + db.ingest_notes(&[note_for_account(&format!("b{i}"), "acc-2")], &tk("home")) .unwrap(); } // acc-1 の古い 2 件 @@ -1888,6 +2407,7 @@ mod tests { let cfg = EvictionConfig { per_account_limit: Some(2), ttl_days: None, + per_timeline_limit: None, }; let deleted = db.cleanup_with_eviction(&cfg).unwrap(); assert_eq!(deleted, 2); @@ -1900,12 +2420,13 @@ mod tests { fn cleanup_no_op_when_under_limits() { let (_dir, db) = temp_db(); for i in 0..3 { - db.cache_note(¬e_for_account(&format!("n{i}"), "acc-1"), "home") + db.ingest_notes(&[note_for_account(&format!("n{i}"), "acc-1")], &tk("home")) .unwrap(); } let cfg = EvictionConfig { per_account_limit: Some(100), ttl_days: None, + per_timeline_limit: None, }; let deleted = db.cleanup_with_eviction(&cfg).unwrap(); assert_eq!(deleted, 0); @@ -1913,17 +2434,18 @@ mod tests { } #[test] - fn cleanup_with_both_disabled_is_pure_noop() { + fn cleanup_with_all_disabled_is_pure_noop() { let (_dir, db) = temp_db(); for i in 0..3 { - db.cache_note(¬e_for_account(&format!("n{i}"), "acc-1"), "home") + db.ingest_notes(&[note_for_account(&format!("n{i}"), "acc-1")], &tk("home")) .unwrap(); } - // ttl_days=None かつ per_account_limit=None: ロックを取らずに 0 を返す。 + // 3 フィールド全て None: ロックを取らずに 0 を返す。 // 検索 UX 優先のデフォルトに近いケースをカバー。 let cfg = EvictionConfig { per_account_limit: None, ttl_days: None, + per_timeline_limit: None, }; let deleted = db.cleanup_with_eviction(&cfg).unwrap(); assert_eq!(deleted, 0); @@ -1934,13 +2456,14 @@ mod tests { fn cleanup_only_ttl_keeps_high_count() { let (_dir, db) = temp_db(); for i in 0..5 { - db.cache_note(¬e_for_account(&format!("n{i}"), "acc-1"), "home") + db.ingest_notes(&[note_for_account(&format!("n{i}"), "acc-1")], &tk("home")) .unwrap(); } // 5 件すべてが新しいので、TTL=1 でも何も消えない (cap は無効) let cfg = EvictionConfig { per_account_limit: None, ttl_days: Some(1), + per_timeline_limit: None, }; let deleted = db.cleanup_with_eviction(&cfg).unwrap(); assert_eq!(deleted, 0); @@ -1971,7 +2494,7 @@ mod tests { let (_dir, db) = temp_db(); // データ insert → 削除 → free page を生む for i in 0..50 { - db.cache_note(¬e_for_account(&format!("n{i}"), "acc-1"), "home") + db.ingest_notes(&[note_for_account(&format!("n{i}"), "acc-1")], &tk("home")) .unwrap(); } db.clear_all_notes_cache().unwrap(); @@ -2334,4 +2857,709 @@ mod tests { assert_eq!(stored.len(), 1); assert_eq!(stored[0].text.as_deref(), Some("edited")); } + + // --- scan_cached_notes (predicate 注入) --- + + /// 走査用に created_at をずらしたノートを作る (新しい順は id の降順) + fn scan_note(id: &str, text: &str, seq: u32) -> NormalizedNote { + let mut note = sample_note(id, text); + note.created_at = format!("2025-01-01T00:00:{seq:02}Z"); + note + } + + fn seed_scan_notes(db: &Database) { + let notes = vec![ + scan_note("n1", "alpha bravo", 1), + scan_note("n2", "alpha charlie", 2), + scan_note("n3", "delta echo", 3), + scan_note("n4", "alpha foxtrot", 4), + ]; + db.ingest_notes(¬es, &tk("home")).unwrap(); + } + + #[test] + fn scan_filters_by_predicate() { + let (_dir, db) = temp_db(); + seed_scan_notes(&db); + let out = db + .scan_cached_notes("acc-1", &[], 10, 100, None, |n| { + Some(n.text.as_deref().unwrap_or("").contains("alpha")) + }) + .unwrap(); + assert_eq!(out.notes.len(), 3); + assert_eq!(out.scanned, 4); + assert_eq!(out.errors, 0); + assert!(out.cursor.is_none(), "読み切ったらカーソルは返さない"); + } + + #[test] + fn scan_returns_notes_newest_first() { + let (_dir, db) = temp_db(); + seed_scan_notes(&db); + let out = db + .scan_cached_notes("acc-1", &[], 10, 100, None, |_| Some(true)) + .unwrap(); + let ids: Vec<&str> = out.notes.iter().map(|n| n.id.as_str()).collect(); + assert_eq!(ids, vec!["n4", "n3", "n2", "n1"]); + } + + #[test] + fn scan_uses_fts_prefilter() { + let (_dir, db) = temp_db(); + seed_scan_notes(&db); + let out = db + .scan_cached_notes("acc-1", &["delta".to_string()], 10, 100, None, |_| { + Some(true) + }) + .unwrap(); + // FTS で 1 行に絞られるので、述語に渡る行も 1 件だけ + assert_eq!(out.scanned, 1); + assert_eq!(out.notes.len(), 1); + assert_eq!(out.notes[0].id, "n3"); + } + + #[test] + fn scan_ignores_too_short_literals() { + let (_dir, db) = temp_db(); + seed_scan_notes(&db); + // 3 文字未満を押し込むと trigram が 0 件を返して偽陰性になるので無視する + let out = db + .scan_cached_notes("acc-1", &["ab".to_string()], 10, 100, None, |_| Some(true)) + .unwrap(); + assert_eq!(out.scanned, 4, "FTS を使わず全件走査するべき"); + } + + #[test] + fn scan_stops_at_limit() { + let (_dir, db) = temp_db(); + seed_scan_notes(&db); + let out = db + .scan_cached_notes("acc-1", &[], 2, 100, None, |_| Some(true)) + .unwrap(); + assert_eq!(out.notes.len(), 2); + assert!(out.cursor.is_some(), "続きがあるならカーソルを返す"); + } + + #[test] + fn scan_resumes_from_cursor_without_gap_or_overlap() { + let (_dir, db) = temp_db(); + seed_scan_notes(&db); + // 走査上限 2 行で打ち切る + let first = db + .scan_cached_notes("acc-1", &[], 10, 2, None, |_| Some(true)) + .unwrap(); + assert_eq!(first.scanned, 2); + let cursor = first.cursor.expect("打ち切ったらカーソルが返る"); + + let second = db + .scan_cached_notes("acc-1", &[], 10, 10, Some(&cursor), |_| Some(true)) + .unwrap(); + let mut all: Vec = first.notes.iter().map(|n| n.id.clone()).collect(); + all.extend(second.notes.iter().map(|n| n.id.clone())); + assert_eq!( + all, + vec!["n4", "n3", "n2", "n1"], + "取りこぼしも重複もなく続きが読める" + ); + } + + #[test] + fn scan_counts_predicate_errors() { + let (_dir, db) = temp_db(); + seed_scan_notes(&db); + let out = db + .scan_cached_notes("acc-1", &[], 10, 100, None, |n| { + // n3 だけ判定不能にする + if n.id == "n3" { + None + } else { + Some(true) + } + }) + .unwrap(); + assert_eq!(out.errors, 1); + assert_eq!(out.notes.len(), 3, "判定不能なノートは除外する"); + } + + #[test] + fn scan_counts_broken_rows_as_errors() { + let (_dir, db) = temp_db(); + seed_scan_notes(&db); + { + // note_json を壊す (スキーマ世代差で読めない行の代役) + let conn = db.lock().unwrap(); + conn.execute( + "UPDATE notes_cache SET note_json = '{ broken' WHERE note_id = 'n2'", + [], + ) + .unwrap(); + } + let out = db + .scan_cached_notes("acc-1", &[], 10, 100, None, |_| Some(true)) + .unwrap(); + assert_eq!(out.errors, 1); + assert_eq!(out.notes.len(), 3); + assert_eq!(out.scanned, 4, "読めない行も走査行数には数える"); + } + + #[test] + fn scan_is_scoped_to_account() { + let (_dir, db) = temp_db(); + seed_scan_notes(&db); + let mut other = scan_note("n9", "alpha", 9); + other.account_id = "acc-2".to_string(); + db.ingest_notes(&[other], &tk("home")).unwrap(); + + let out = db + .scan_cached_notes("acc-1", &[], 10, 100, None, |_| Some(true)) + .unwrap(); + assert!(out.notes.iter().all(|n| n.account_id == "acc-1")); + } + + #[test] + fn scan_handles_zero_limits() { + let (_dir, db) = temp_db(); + seed_scan_notes(&db); + let out = db + .scan_cached_notes("acc-1", &[], 0, 100, None, |_| Some(true)) + .unwrap(); + assert!(out.notes.is_empty()); + assert_eq!(out.scanned, 0); + } + + #[test] + fn fts_match_query_escapes_quotes() { + let q = build_fts_match_query(&["say \"hi\" now".to_string()]).unwrap(); + assert_eq!(q, "\"say \"\"hi\"\" now\""); + } + + #[test] + fn fts_match_query_joins_with_and() { + let q = build_fts_match_query(&["alpha".to_string(), "bravo".to_string()]).unwrap(); + assert_eq!(q, "\"alpha\" AND \"bravo\""); + } + + // --- 実体/所属分離 (issue #30 仕様 v5) --- + + fn note_with_created_at(id: &str, account_id: &str, created_at: &str) -> NormalizedNote { + let mut n = note_for_account(id, account_id); + n.created_at = created_at.to_string(); + n + } + + #[test] + fn note_belongs_to_multiple_timelines() { + // §9-2: home ∩ social の複数所属 (v5 以前は後勝ちで付け替わっていた現行バグ) + let (_dir, db) = temp_db(); + let note = sample_note("n1", "both timelines"); + db.ingest_notes(std::slice::from_ref(¬e), &tk("home")) + .unwrap(); + db.ingest_notes(&[note], &tk("social")).unwrap(); + + let home = db.get_cached_timeline("acc-1", &tk("home"), 10).unwrap(); + let social = db.get_cached_timeline("acc-1", &tk("social"), 10).unwrap(); + assert_eq!(home.len(), 1, "home からも読めること"); + assert_eq!(social.len(), 1, "social からも読めること"); + // entity は 1 行のまま + assert_eq!(db.account_cache_count("acc-1").unwrap(), 1); + } + + #[test] + fn ingest_with_param_key_is_readable() { + // §9-3: 孤児化解消 — antenna キーで ingest → 同じキーで読める + let (_dir, db) = temp_db(); + db.ingest_notes(&[sample_note("n1", "from antenna")], &tk("antenna:a1")) + .unwrap(); + let notes = db + .get_cached_timeline("acc-1", &tk("antenna:a1"), 10) + .unwrap(); + assert_eq!(notes.len(), 1); + } + + #[test] + fn ingest_mixed_accounts_processes_per_note() { + // §9-12: 複数 account 混在配列の per-note 処理 + let (_dir, db) = temp_db(); + db.ingest_notes( + &[ + note_for_account("n1", "acc-1"), + note_for_account("n2", "acc-2"), + ], + &tk("home"), + ) + .unwrap(); + assert_eq!( + db.get_cached_timeline("acc-1", &tk("home"), 10) + .unwrap() + .len(), + 1 + ); + assert_eq!( + db.get_cached_timeline("acc-2", &tk("home"), 10) + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn remove_membership_keeps_shared_entity() { + // §9-6: 他バケット所属 entity の生存 + let (_dir, db) = temp_db(); + let note = sample_note("n1", "shared"); + db.ingest_notes(std::slice::from_ref(¬e), &tk("home")) + .unwrap(); + db.ingest_notes(&[note], &tk("favorites")).unwrap(); + + let removed = db + .remove_membership("acc-1", &tk("favorites"), "n1") + .unwrap(); + assert_eq!(removed, 1); + assert!(db + .get_cached_timeline("acc-1", &tk("favorites"), 10) + .unwrap() + .is_empty()); + assert_eq!( + db.get_cached_timeline("acc-1", &tk("home"), 10) + .unwrap() + .len(), + 1 + ); + assert_eq!(db.account_cache_count("acc-1").unwrap(), 1); + } + + #[test] + fn remove_membership_sweeps_sole_entity() { + // §9-6: 単独所属 entity は同一 tx で掃除される + let (_dir, db) = temp_db(); + db.ingest_notes(&[sample_note("n1", "only fav")], &tk("favorites")) + .unwrap(); + let removed = db + .remove_membership("acc-1", &tk("favorites"), "n1") + .unwrap(); + assert_eq!(removed, 1); + assert_eq!(db.account_cache_count("acc-1").unwrap(), 0); + } + + #[test] + fn remove_membership_on_orphan_entity_returns_zero() { + // §9-6 追補 (R11-5): membership ゼロの orphan entity を巻き添え削除しない + let (_dir, db) = temp_db(); + db.ingest_notes(&[sample_note("n1", "will be orphan")], &tk("home")) + .unwrap(); + { + let conn = db.lock().unwrap(); + conn.execute("DELETE FROM note_timelines", []).unwrap(); + } + let removed = db.remove_membership("acc-1", &tk("home"), "n1").unwrap(); + assert_eq!(removed, 0, "membership 不在なら 0 を返す"); + assert_eq!(db.account_cache_count("acc-1").unwrap(), 1, "entity は残す"); + } + + #[test] + fn clear_timeline_scoped_to_bucket_and_account() { + // §9-6: バケット破棄 — 他バケット所属は生存・単独所属は掃除・他アカウント非干渉 + let (_dir, db) = temp_db(); + let shared = note_for_account("shared", "acc-1"); + db.ingest_notes(std::slice::from_ref(&shared), &tk("antenna:a1")) + .unwrap(); + db.ingest_notes(&[shared], &tk("home")).unwrap(); + db.ingest_notes(&[note_for_account("sole", "acc-1")], &tk("antenna:a1")) + .unwrap(); + db.ingest_notes(&[note_for_account("other", "acc-2")], &tk("antenna:a1")) + .unwrap(); + + // membership 3 行 (shared/sole の antenna:a1 = 2、sole entity の CASCADE 1 は + // 数えない → shared 1 + sole 1(entity 先行 CASCADE) = 2 + let removed = db.clear_timeline("acc-1", &tk("antenna:a1")).unwrap(); + assert_eq!(removed, 2); + assert!(db + .get_cached_timeline("acc-1", &tk("antenna:a1"), 10) + .unwrap() + .is_empty()); + assert_eq!( + db.get_cached_timeline("acc-1", &tk("home"), 10) + .unwrap() + .len(), + 1 + ); + assert_eq!(db.account_cache_count("acc-1").unwrap(), 1, "sole は掃除"); + assert_eq!( + db.account_cache_count("acc-2").unwrap(), + 1, + "他アカウント非干渉" + ); + } + + #[test] + fn delete_cached_note_is_account_scoped() { + // §9-7: 同一 note_id でも他アカウントの entity は残る + let (_dir, db) = temp_db(); + db.ingest_notes(&[note_for_account("n1", "acc-1")], &tk("home")) + .unwrap(); + db.ingest_notes(&[note_for_account("n1", "acc-2")], &tk("home")) + .unwrap(); + + assert!(db.delete_cached_note("acc-1", "n1").unwrap()); + assert!(db + .get_cached_timeline("acc-1", &tk("home"), 10) + .unwrap() + .is_empty()); + assert_eq!( + db.get_cached_timeline("acc-2", &tk("home"), 10) + .unwrap() + .len(), + 1 + ); + // 二度目は false + assert!(!db.delete_cached_note("acc-1", "n1").unwrap()); + } + + #[test] + fn sweep_orphan_notes_removes_only_orphans() { + let (_dir, db) = temp_db(); + db.ingest_notes(&[sample_note("kept", "has membership")], &tk("home")) + .unwrap(); + db.ingest_notes(&[sample_note("orphan", "loses membership")], &tk("home")) + .unwrap(); + { + let conn = db.lock().unwrap(); + conn.execute("DELETE FROM note_timelines WHERE note_id = 'orphan'", []) + .unwrap(); + } + assert_eq!(db.sweep_orphan_notes().unwrap(), 1); + assert_eq!(db.account_cache_count("acc-1").unwrap(), 1); + } + + #[test] + fn keyset_paging_advances_through_equal_sort_keys() { + // §9-8: 同一 sort_key が limit 超で並んでも note_id tie-break で前進する + let (_dir, db) = temp_db(); + let same_ts = "2025-06-01T00:00:00Z"; + let notes: Vec = (0..5) + .map(|i| note_with_created_at(&format!("n{i}"), "acc-1", same_ts)) + .collect(); + db.ingest_notes(¬es, &tk("home")).unwrap(); + + let mut seen: Vec = Vec::new(); + let mut cursor: Option<(String, String)> = None; + loop { + let page = match &cursor { + None => db.get_cached_timeline("acc-1", &tk("home"), 2).unwrap(), + Some((sk, nid)) => db + .get_cached_timeline_before("acc-1", &tk("home"), sk, Some(nid), 2) + .unwrap(), + }; + if page.is_empty() { + break; + } + for n in &page { + seen.push(n.id.clone()); + } + let last = page.last().unwrap(); + cursor = Some((last.created_at.clone(), last.id.clone())); + } + assert_eq!(seen.len(), 5, "重複・欠落なく全件回収"); + let mut dedup = seen.clone(); + dedup.sort(); + dedup.dedup(); + assert_eq!(dedup.len(), 5); + } + + #[test] + fn timeline_before_without_note_id_is_inclusive() { + // note_id なしは現行互換の包含比較 (境界重複はフロント dedup が吸収) + let (_dir, db) = temp_db(); + db.ingest_notes( + &[ + note_with_created_at("n1", "acc-1", "2025-06-01T00:00:00Z"), + note_with_created_at("n2", "acc-1", "2025-06-02T00:00:00Z"), + ], + &tk("home"), + ) + .unwrap(); + let page = db + .get_cached_timeline_before("acc-1", &tk("home"), "2025-06-01T00:00:00Z", None, 10) + .unwrap(); + assert_eq!(page.len(), 1); + assert_eq!(page[0].id, "n1"); + } + + #[test] + fn ttl_cascade_removes_memberships() { + // §9-5: TTL の entity 削除が CASCADE で所属を道連れにする + let (_dir, db) = temp_db(); + let note = sample_note("n1", "old note"); + db.ingest_notes(std::slice::from_ref(¬e), &tk("home")) + .unwrap(); + db.ingest_notes(&[note], &tk("social")).unwrap(); + set_cached_at(&db, "n1", 1000); + + let cfg = EvictionConfig { + per_account_limit: None, + ttl_days: Some(1), + per_timeline_limit: None, + }; + db.cleanup_with_eviction(&cfg).unwrap(); + assert!(db + .get_cached_timeline("acc-1", &tk("home"), 10) + .unwrap() + .is_empty()); + assert!(db + .get_cached_timeline("acc-1", &tk("social"), 10) + .unwrap() + .is_empty()); + let conn = db.lock().unwrap(); + let memberships: i64 = conn + .query_row("SELECT COUNT(*) FROM note_timelines", [], |r| r.get(0)) + .unwrap(); + assert_eq!(memberships, 0); + } + + #[test] + fn per_timeline_trim_runs_when_only_it_is_set() { + // §9-5: per_timeline_limit のみ設定でもトリムが走る (早期 return 回帰) + let (_dir, db) = temp_db(); + for i in 0..5 { + db.ingest_notes( + &[note_with_created_at( + &format!("n{i}"), + "acc-1", + &format!("2025-06-0{}T00:00:00Z", i + 1), + )], + &tk("home"), + ) + .unwrap(); + } + let cfg = EvictionConfig { + per_account_limit: None, + ttl_days: None, + per_timeline_limit: Some(2), + }; + let deleted = db.cleanup_with_eviction(&cfg).unwrap(); + // membership 3 + orphan entity 3 + assert_eq!(deleted, 6); + let remaining = db.get_cached_timeline("acc-1", &tk("home"), 10).unwrap(); + assert_eq!(remaining.len(), 2); + // sort_key 降順で最新 2 件が残る + assert_eq!(remaining[0].id, "n4"); + assert_eq!(remaining[1].id, "n3"); + assert_eq!(db.account_cache_count("acc-1").unwrap(), 2); + } + + #[test] + fn per_timeline_trim_keeps_shared_entities() { + // §9-5: トリムで membership を失っても他バケット所属の entity は残る + let (_dir, db) = temp_db(); + for i in 0..3 { + let note = note_with_created_at( + &format!("n{i}"), + "acc-1", + &format!("2025-06-0{}T00:00:00Z", i + 1), + ); + db.ingest_notes(std::slice::from_ref(¬e), &tk("home")) + .unwrap(); + db.ingest_notes(&[note], &tk("social")).unwrap(); + } + let cfg = EvictionConfig { + per_account_limit: None, + ttl_days: None, + per_timeline_limit: Some(1), + }; + db.cleanup_with_eviction(&cfg).unwrap(); + // home / social とも最新 1 件ずつ残り、entity は共有されているため + // どちらのバケットの生存分も account_cache_count に含まれる + assert_eq!( + db.get_cached_timeline("acc-1", &tk("home"), 10) + .unwrap() + .len(), + 1 + ); + assert_eq!( + db.get_cached_timeline("acc-1", &tk("social"), 10) + .unwrap() + .len(), + 1 + ); + assert_eq!(db.account_cache_count("acc-1").unwrap(), 1); + } + + #[test] + fn per_timeline_trim_uses_added_at_for_favorites() { + // §9-5: Favorites バケットは added_at (初回取得時刻) 降順で残す + let (_dir, db) = temp_db(); + // created_at は新しいが最初に取得されたノート + db.ingest_notes( + &[note_with_created_at( + "newer-first", + "acc-1", + "2025-06-09T00:00:00Z", + )], + &tk("favorites"), + ) + .unwrap(); + // created_at は古いが後から取得された (backfill) ノート + db.ingest_notes( + &[note_with_created_at( + "older-later", + "acc-1", + "2025-01-01T00:00:00Z", + )], + &tk("favorites"), + ) + .unwrap(); + { + // added_at を明示的に差別化 (同秒対策) + let conn = db.lock().unwrap(); + conn.execute( + "UPDATE note_timelines SET added_at = 100 WHERE note_id = 'newer-first'", + [], + ) + .unwrap(); + conn.execute( + "UPDATE note_timelines SET added_at = 200 WHERE note_id = 'older-later'", + [], + ) + .unwrap(); + } + let cfg = EvictionConfig { + per_account_limit: None, + ttl_days: None, + per_timeline_limit: Some(1), + }; + db.cleanup_with_eviction(&cfg).unwrap(); + let remaining = db + .get_cached_timeline("acc-1", &tk("favorites"), 10) + .unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!( + remaining[0].id, "older-later", + "added_at が新しい方 (後から取得) が残る" + ); + } + + #[test] + fn clear_account_cache_removes_memberships() { + // §9-11: clear 系の membership 連動 + let (_dir, db) = temp_db(); + db.ingest_notes(&[note_for_account("n1", "acc-1")], &tk("home")) + .unwrap(); + db.ingest_notes(&[note_for_account("n2", "acc-2")], &tk("home")) + .unwrap(); + db.clear_account_cache("acc-1").unwrap(); + { + let conn = db.lock().unwrap(); + let memberships: i64 = conn + .query_row( + "SELECT COUNT(*) FROM note_timelines WHERE account_id = 'acc-1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(memberships, 0); + } + assert_eq!( + db.get_cached_timeline("acc-2", &tk("home"), 10) + .unwrap() + .len(), + 1 + ); + + db.clear_all_notes_cache().unwrap(); + let conn = db.lock().unwrap(); + let total: i64 = conn + .query_row("SELECT COUNT(*) FROM note_timelines", [], |r| r.get(0)) + .unwrap(); + assert_eq!(total, 0); + } + + #[test] + fn cascade_delete_uses_membership_index_with_stat1() { + // §9-17: stat1 存在下で親 DELETE の CASCADE が idx_note_timelines_note を使う + // (stat1 なしだと WITHOUT ROWID PK の prefix スキャンに落ちる — 実測 2000 倍差) + let (_dir, db) = temp_db(); + db.ingest_notes(&[sample_note("n1", "note")], &tk("home")) + .unwrap(); + let conn = db.lock().unwrap(); + conn.execute_batch("ANALYZE;").unwrap(); + let plan: String = conn + .prepare("EXPLAIN QUERY PLAN DELETE FROM notes_cache WHERE note_id = 'n1' AND account_id = 'acc-1'") + .unwrap() + .query_map([], |row| row.get::<_, String>(3)) + .unwrap() + .filter_map(|r| r.ok()) + .collect::>() + .join(" | "); + assert!( + plan.contains("idx_note_timelines_note"), + "CASCADE の子スキャンが idx_note_timelines_note を使うこと: {plan}" + ); + } + + #[test] + fn v6_migrates_old_timeline_type_rows() { + // §9-4: V5 時点の旧 DB fixture → V6 適用で membership 復元・壊れキー消滅・FTS 健全 + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("old.db"); + { + let mut conn = Connection::open(&db_path).unwrap(); + conn.execute_batch(PRAGMAS_WRITER).unwrap(); + embedded::migrations::runner() + .set_grouped(true) + .set_target(refinery::Target::Version(5)) + .run(&mut conn) + .unwrap(); + // 旧形式の行を直接 INSERT (正キー 2 種 + 壊れキー 2 種) + let insert = |id: &str, tl: &str| { + conn.execute( + "INSERT INTO notes_cache (note_id, account_id, server_host, created_at, text, note_json, cached_at, timeline_type) + VALUES (?1, 'acc-1', 'misskey.io', '2025-01-01T00:00:00Z', 'migration test text', ?2, 42, ?3)", + params![ + id, + serde_json::to_string(&sample_note(id, "migration test text")).unwrap(), + tl + ], + ) + .unwrap(); + }; + insert("good-home", "home"); + insert("good-antenna", "antenna:a1"); + insert("broken-empty", ""); + insert("broken-userlist", "user-list"); + } + // 再 open で V6 が適用される + let db = Database::open(&db_path).unwrap(); + assert_eq!( + db.get_cached_timeline("acc-1", &tk("home"), 10) + .unwrap() + .len(), + 1 + ); + assert_eq!( + db.get_cached_timeline("acc-1", &tk("antenna:a1"), 10) + .unwrap() + .len(), + 1 + ); + // 壊れキー行は migration 内 DELETE で消滅 + assert_eq!(db.account_cache_count("acc-1").unwrap(), 2); + // added_at = 旧 cached_at の近似移行 + { + let conn = db.lock().unwrap(); + let added_at: i64 = conn + .query_row( + "SELECT added_at FROM note_timelines WHERE note_id = 'good-home'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(added_at, 42); + // FTS integrity-check が通る + conn.execute_batch("INSERT INTO notes_fts(notes_fts) VALUES('integrity-check');") + .unwrap(); + } + // FTS 検索が移行後も動く + let hits = db.search_cached_notes("acc-1", "migration", 10).unwrap(); + assert_eq!(hits.len(), 2); + } } diff --git a/src/error.rs b/src/error.rs index 51071bf..a039be3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,34 @@ use thiserror::Error; +/// 認証エラーの内訳。呼び出し側がパターンマッチで回復手段を選べるよう、 +/// 「再ログインが要る」「認証フローをやり直す」「ユーザーの承認待ち」を区別する。 +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum AuthErrorKind { + /// keychain にも DB にもトークンが無い。再ログインが必要。 + #[error("No token found for account {0}")] + NoToken(String), + + /// MiAuth の check がサーバーに拒否された(HTTP ステータス)。認証フローをやり直す。 + #[error("MiAuth check failed: {0}")] + MiAuthFailed(u16), + + /// ユーザーがまだ Misskey 側で許可していない。同じ URL で再試行できる。 + #[error("MiAuth authentication was not completed")] + MiAuthPending, + + /// MiAuth 応答に必要なフィールドが無い。認証フローをやり直す。 + #[error("MiAuth response missing {0}")] + MiAuthMalformed(&'static str), + + /// アプリ側で持つ認証セッションが無効(期限切れ・host 不一致・消費済み)。 + #[error("{0}")] + SessionInvalid(String), + + /// 外部サービス(AI プロバイダー等)の資格情報が未設定。設定画面へ誘導する。 + #[error("{0}")] + CredentialMissing(String), +} + #[derive(Debug, Error)] pub enum NoteDeckError { #[error("Database error")] @@ -18,14 +47,14 @@ pub enum NoteDeckError { Api { endpoint: String, status: u16, + /// Misskey が返した `error.code`(例: `AUTHENTICATION_FAILED`)。 + /// メッセージ文字列を parse せずに済むよう構造化して保持する。 + api_code: Option, message: String, }, #[error("{0}")] - Auth(String), - - #[error("WebSocket: {0}")] - WebSocket(String), + Auth(AuthErrorKind), #[error("No connection for account: {0}")] NoConnection(String), @@ -38,6 +67,10 @@ pub enum NoteDeckError { #[error("Keychain error: {0}")] Keychain(String), + + /// 起こり得ないはずの内部不整合(ロック汚染、保存直後の読み出し失敗等)。 + #[error("Internal error: {0}")] + Internal(String), } impl NoteDeckError { @@ -48,12 +81,33 @@ impl NoteDeckError { Self::Json(_) => "JSON", Self::AccountNotFound(_) => "ACCOUNT_NOT_FOUND", Self::Api { .. } => "API", - Self::Auth(_) => "AUTH", - Self::WebSocket(_) => "WEBSOCKET", + Self::Auth(kind) => kind.code(), Self::NoConnection(_) => "NO_CONNECTION", Self::ConnectionClosed => "CONNECTION_CLOSED", Self::InvalidInput(_) => "INVALID_INPUT", Self::Keychain(_) => "KEYCHAIN", + Self::Internal(_) => "INTERNAL", + } + } + + /// Misskey が返した `error.code`。API エラー以外では None。 + pub fn api_code(&self) -> Option<&str> { + match self { + Self::Api { api_code, .. } => api_code.as_deref(), + _ => None, + } + } +} + +impl AuthErrorKind { + pub fn code(&self) -> &'static str { + match self { + Self::NoToken(_) => "AUTH_NO_TOKEN", + Self::MiAuthFailed(_) => "AUTH_MIAUTH_FAILED", + Self::MiAuthPending => "AUTH_MIAUTH_PENDING", + Self::MiAuthMalformed(_) => "AUTH_MIAUTH_MALFORMED", + Self::SessionInvalid(_) => "AUTH_SESSION_INVALID", + Self::CredentialMissing(_) => "AUTH_CREDENTIAL_MISSING", } } } @@ -76,17 +130,17 @@ impl NoteDeckError { tracing::error!(error = %e, "JSON parse error"); "Invalid response format".to_string() } - Self::WebSocket(e) => { - tracing::error!(error = %e, "WebSocket error"); - "Connection error".to_string() - } Self::Keychain(e) => { tracing::error!(error = %e, "Keychain error"); "Credential storage error".to_string() } + Self::Internal(e) => { + tracing::error!(error = %e, "Internal error"); + "Internal error".to_string() + } // These contain messages we control — safe to expose Self::Api { message, .. } => message.clone(), - Self::Auth(msg) => msg.clone(), + Self::Auth(kind) => kind.to_string(), Self::AccountNotFound(id) => format!("Account not found: {id}"), Self::NoConnection(id) => format!("No connection for account: {id}"), Self::ConnectionClosed => "Connection closed".to_string(), @@ -99,9 +153,11 @@ impl NoteDeckError { #[cfg(feature = "specta")] #[derive(specta::Type)] #[allow(dead_code)] +#[specta(rename_all = "camelCase")] struct NoteDeckErrorShape { code: String, message: String, + api_code: Option, } #[cfg(feature = "specta")] @@ -120,9 +176,10 @@ impl serde::Serialize for NoteDeckError { S: serde::Serializer, { use serde::ser::SerializeStruct; - let mut s = serializer.serialize_struct("NoteDeckError", 2)?; + let mut s = serializer.serialize_struct("NoteDeckError", 3)?; s.serialize_field("code", self.code())?; s.serialize_field("message", &self.safe_message())?; + s.serialize_field("apiCode", &self.api_code())?; s.end() } } @@ -141,13 +198,16 @@ mod tests { NoteDeckError::Api { endpoint: "test".into(), status: 400, + api_code: None, message: "bad".into() } .code(), "API" ); - assert_eq!(NoteDeckError::Auth("x".into()).code(), "AUTH"); - assert_eq!(NoteDeckError::WebSocket("x".into()).code(), "WEBSOCKET"); + assert_eq!( + NoteDeckError::Auth(AuthErrorKind::NoToken("acc1".into())).code(), + "AUTH_NO_TOKEN" + ); assert_eq!( NoteDeckError::NoConnection("x".into()).code(), "NO_CONNECTION" @@ -158,6 +218,54 @@ mod tests { "INVALID_INPUT" ); assert_eq!(NoteDeckError::Keychain("x".into()).code(), "KEYCHAIN"); + assert_eq!(NoteDeckError::Internal("x".into()).code(), "INTERNAL"); + } + + #[test] + fn auth_kinds_have_distinct_codes() { + let codes = [ + NoteDeckError::Auth(AuthErrorKind::NoToken("acc1".into())).code(), + NoteDeckError::Auth(AuthErrorKind::MiAuthFailed(500)).code(), + NoteDeckError::Auth(AuthErrorKind::MiAuthPending).code(), + NoteDeckError::Auth(AuthErrorKind::MiAuthMalformed("token")).code(), + NoteDeckError::Auth(AuthErrorKind::SessionInvalid("expired".into())).code(), + NoteDeckError::Auth(AuthErrorKind::CredentialMissing("Claude".into())).code(), + ]; + let unique: std::collections::HashSet<_> = codes.iter().collect(); + assert_eq!(unique.len(), codes.len(), "auth codes must be distinct"); + // すべて AUTH_ 接頭辞 — フロントは接頭辞で「認証エラー全般」を判定する + assert!(codes.iter().all(|c| c.starts_with("AUTH_"))); + } + + #[test] + fn api_code_is_exposed_for_programmatic_recovery() { + let err = NoteDeckError::Api { + endpoint: "notes/timeline".into(), + status: 401, + api_code: Some("AUTHENTICATION_FAILED".into()), + message: "notes/timeline: AUTHENTICATION_FAILED: token is invalid".into(), + }; + let json = serde_json::to_value(&err).unwrap(); + assert_eq!(json["code"], "API"); + assert_eq!(json["apiCode"], "AUTHENTICATION_FAILED"); + } + + #[test] + fn api_code_is_null_when_server_gave_none() { + let err = NoteDeckError::Api { + endpoint: "notes/timeline".into(), + status: 500, + api_code: None, + message: "notes/timeline (500)".into(), + }; + let json = serde_json::to_value(&err).unwrap(); + assert!(json["apiCode"].is_null()); + } + + #[test] + fn internal_message_does_not_leak_details() { + let err = NoteDeckError::Internal("session lock poisoned at src/foo.rs:42".into()); + assert_eq!(err.safe_message(), "Internal error"); } #[test] @@ -171,9 +279,6 @@ mod tests { ); assert_eq!(db_err.safe_message(), "Database operation failed"); - let ws_err = NoteDeckError::WebSocket("tungstenite internal detail".into()); - assert_eq!(ws_err.safe_message(), "Connection error"); - let kc_err = NoteDeckError::Keychain("keyring internal detail".into()); assert_eq!(kc_err.safe_message(), "Credential storage error"); } @@ -183,12 +288,13 @@ mod tests { let api_err = NoteDeckError::Api { endpoint: "/api/test".into(), status: 404, + api_code: None, message: "Note not found".into(), }; assert_eq!(api_err.safe_message(), "Note not found"); - let auth_err = NoteDeckError::Auth("Authentication failed".into()); - assert_eq!(auth_err.safe_message(), "Authentication failed"); + let auth_err = NoteDeckError::Auth(AuthErrorKind::NoToken("acc1".into())); + assert_eq!(auth_err.safe_message(), "No token found for account acc1"); let not_found = NoteDeckError::AccountNotFound("acc123".into()); assert_eq!(not_found.safe_message(), "Account not found: acc123"); @@ -210,6 +316,7 @@ mod tests { let err = NoteDeckError::Api { endpoint: "/api/notes/show".into(), status: 404, + api_code: Some("NO_SUCH_NOTE".into()), message: "Note not found".into(), }; let json = serde_json::to_value(&err).unwrap(); @@ -231,8 +338,12 @@ mod tests { let err = NoteDeckError::Api { endpoint: "test".into(), status: 500, + api_code: None, message: "Internal error".into(), }; assert_eq!(format!("{err}"), "Internal error"); + + let err = NoteDeckError::Auth(AuthErrorKind::MiAuthFailed(503)); + assert_eq!(format!("{err}"), "MiAuth check failed: 503"); } } diff --git a/src/http_server.rs b/src/http_server.rs index 323fc20..c91455f 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -9,12 +9,12 @@ use axum::{ routing::get, Json, Router, }; -use subtle::ConstantTimeEq; use futures_util::stream::Stream; use serde::Deserialize; use serde_json::{json, Value}; use std::net::SocketAddr; use std::sync::Arc; +use subtle::ConstantTimeEq; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::StreamExt; use tower_http::cors::CorsLayer; @@ -26,7 +26,7 @@ use crate::db::Database; use crate::event_bus::EventBus; use crate::models::{ AccountPublic, CreateNoteParams, NormalizedNote, NormalizedNoteReaction, - NormalizedNotification, NormalizedUserDetail, TimelineType, + NormalizedNotification, NormalizedUserDetail, TimelineKey, }; pub const DEFAULT_PORT: u16 = 19820; @@ -143,8 +143,15 @@ impl ApiError { impl From for ApiError { fn from(e: crate::error::NoteDeckError) -> Self { let code = e.code().to_string(); + let status = match &e { + // クライアント入力起因 (不正なタイムラインキー等) は 400。 + // InvalidInput の Display はキー文字列等の入力のみでトークンを含まない + // ため e.to_string() のままでよい。 + crate::error::NoteDeckError::InvalidInput(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; Self { - status: StatusCode::INTERNAL_SERVER_ERROR, + status, code, message: e.to_string(), } @@ -239,7 +246,11 @@ fn core_openapi_router() -> OpenApiRouter { .routes(routes!(get_note, delete_note)) .routes(routes!(get_note_children)) .routes(routes!(get_note_conversation)) - .routes(routes!(get_note_reactions, create_reaction, delete_reaction)) + .routes(routes!( + get_note_reactions, + create_reaction, + delete_reaction + )) .routes(routes!(get_user)) .routes(routes!(get_user_notes)) .routes(routes!(search_notes)) @@ -254,7 +265,10 @@ fn core_openapi_router() -> OpenApiRouter { /// own spec. pub fn build_core_routes(state: AppState) -> OpenApiRouter { core_openapi_router() - .layer(middleware::from_fn_with_state(state.clone(), auth_middleware)) + .layer(middleware::from_fn_with_state( + state.clone(), + auth_middleware, + )) .layer(CorsLayer::permissive()) .with_state(state) } @@ -293,8 +307,14 @@ pub fn endpoints_from_spec(openapi: &utoipa::openapi::OpenApi) -> Vec { } } out.sort_by(|a, b| { - let ka = (a["path"].as_str().unwrap_or(""), a["method"].as_str().unwrap_or("")); - let kb = (b["path"].as_str().unwrap_or(""), b["method"].as_str().unwrap_or("")); + let ka = ( + a["path"].as_str().unwrap_or(""), + a["method"].as_str().unwrap_or(""), + ); + let kb = ( + b["path"].as_str().unwrap_or(""), + b["method"].as_str().unwrap_or(""), + ); ka.cmp(&kb) }); out @@ -365,11 +385,12 @@ async fn list_accounts( security(("bearer_auth" = [])), params( ("host" = String, Path, description = "Account host (e.g. misskey.io)"), - ("tl_type" = String, Path, description = "Timeline type: home | local | social | global"), + ("tl_type" = String, Path, description = "Timeline type: home | local | social | global (or fork-specific basic timelines like bubble)"), TimelineQueryParams, ), responses( (status = 200, description = "Timeline notes", body = Vec), + (status = 400, description = "Invalid or non-basic timeline key", body = ApiErrorResponse), (status = 401, description = "Unauthorized", body = ApiErrorResponse), (status = 404, description = "No account for host", body = ApiErrorResponse), ) @@ -382,10 +403,17 @@ async fn get_timeline( let account_id = state.account_id_for_host(&host)?; let (h, token) = crate::get_credentials(&state.db, &account_id)?; let options = opts.into_timeline_options(); - let tl = TimelineType::new(tl_type); + // allowlist: daemon の公開面は Basic タイムラインのみ。パラメータ付きキー + // (antenna: 等) を受理すると実効 API 面が黙って拡大するため 400 で明示拒否する。 + let key = TimelineKey::parse(&tl_type)?; + if !matches!(key, TimelineKey::Basic(_)) { + return Err(ApiError::from(crate::error::NoteDeckError::InvalidInput( + format!("only basic timelines are exposed here (got '{key}')"), + ))); + } let notes = state .client - .get_timeline(&h, &token, &account_id, tl, options) + .get_timeline(&h, &token, &account_id, &key, options) .await?; Ok(Json(notes)) } @@ -622,7 +650,14 @@ async fn get_note_reactions( let limit = opts.limit.unwrap_or(20); let reactions = state .client - .get_note_reactions(&h, &token, ¬e_id, opts.r#type.as_deref(), limit, opts.until_id.as_deref()) + .get_note_reactions( + &h, + &token, + ¬e_id, + opts.r#type.as_deref(), + limit, + opts.until_id.as_deref(), + ) .await?; Ok(Json(reactions)) } @@ -674,10 +709,7 @@ async fn delete_reaction( ) -> Result { let account_id = state.account_id_for_host(&host)?; let (h, token) = crate::get_credentials(&state.db, &account_id)?; - state - .client - .delete_reaction(&h, &token, ¬e_id) - .await?; + state.client.delete_reaction(&h, &token, ¬e_id).await?; Ok(StatusCode::NO_CONTENT) } @@ -790,22 +822,20 @@ async fn sse_events( .r#type .map(|t| t.split(',').map(|s| s.trim().to_string()).collect()); - let stream = BroadcastStream::new(rx).filter_map(move |result| { - match result { - Ok(sse_event) => { - if let Some(ref filter) = type_filter { - if !filter.iter().any(|f| sse_event.event_type.starts_with(f)) { - return None; - } + let stream = BroadcastStream::new(rx).filter_map(move |result| match result { + Ok(sse_event) => { + if let Some(ref filter) = type_filter { + if !filter.iter().any(|f| sse_event.event_type.starts_with(f)) { + return None; } - let event = Event::default() - .event(&sse_event.event_type) - .json_data(&sse_event.data) - .ok()?; - Some(Ok(event)) } - Err(_) => None, + let event = Event::default() + .event(&sse_event.event_type) + .json_data(&sse_event.data) + .ok()?; + Some(Ok(event)) } + Err(_) => None, }); Sse::new(stream).keep_alive(KeepAlive::default()) @@ -826,11 +856,7 @@ struct TimelineQueryParams { impl TimelineQueryParams { fn into_timeline_options(self) -> crate::models::TimelineOptions { - crate::models::TimelineOptions::new( - self.limit.unwrap_or(20), - self.since_id, - self.until_id, - ) + crate::models::TimelineOptions::new(self.limit.unwrap_or(20), self.since_id, self.until_id) } } diff --git a/src/lib.rs b/src/lib.rs index 835bbb8..54873e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,7 @@ pub mod server_info; pub mod streaming; use db::Database; -use error::NoteDeckError; +use error::{AuthErrorKind, NoteDeckError}; use zeroize::Zeroize; /// Retrieve host and API token for an account. @@ -48,8 +48,8 @@ pub fn get_credentials(db: &Database, account_id: &str) -> Result<(String, Strin return Ok((host, token)); } - Err(NoteDeckError::Auth(format!( - "No token found for account {account_id}" + Err(NoteDeckError::Auth(AuthErrorKind::NoToken( + account_id.to_string(), ))) } @@ -91,7 +91,7 @@ mod tests { db.upsert_account(&account).unwrap(); // keychain will fail in test env, DB token is empty → Auth error let err = get_credentials(&db, "acc1").unwrap_err(); - assert_eq!(err.code(), "AUTH"); + assert_eq!(err.code(), "AUTH_NO_TOKEN"); } #[test] diff --git a/src/main.rs b/src/main.rs index 1a533d4..2a045a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,9 +58,8 @@ async fn run_daemon(port: u16) { let db_path = data_dir.join("notecli.db"); let db = Arc::new(Database::open(&db_path).expect("Failed to open database")); - let client = Arc::new( - notecli::api::MisskeyClient::new().expect("Failed to create HTTP client"), - ); + let client = + Arc::new(notecli::api::MisskeyClient::new().expect("Failed to create HTTP client")); let event_bus = Arc::new(EventBus::new()); diff --git a/src/models.rs b/src/models.rs index a259ab0..87e7ffe 100644 --- a/src/models.rs +++ b/src/models.rs @@ -447,41 +447,255 @@ pub struct CreateNotePoll { pub expires_at: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "specta", derive(specta::Type))] -#[serde(transparent)] -pub struct TimelineType(String); +/// タイムライン所属キーの正本。 +/// +/// canonical 文字列形式(DB の `timeline_key` 列・TS 境界の `string` はこの形式): +/// +/// | variant | canonical | +/// |---|---| +/// | `Basic` | `home` / `local` / `social` / `global` / `bubble` 等(`:` を含まない非予約語) | +/// | `UserList` | `user-list:{listId}` | +/// | `Antenna` | `antenna:{antennaId}` | +/// | `Channel` | `channel:{channelId}` | +/// | `Role` | `role:{roleId}` | +/// | `Clip` | `clip:{clipId}` | +/// | `UserNotes` | `user:{userId}` | +/// | `Mentions` | `mentions` | +/// | `Specified` | `specified` | +/// | `Favorites` | `favorites` | +/// +/// `explore` は DeckExploreColumn の読み出し専用キー(`Basic` として parse は通るが +/// 書込経路なし・常に空読み)。 +/// +/// prefix と bare 語は小文字で定義する。id 部は不透明バイト列として入力どおり保持し、 +/// 大小文字の正規化・検証を行わない(ULID 形式の id は大文字を含む)。 +/// 構築は `parse` か境界アダプタ経由に限る。`Basic` へ予約語・`:`・空文字列を直接 +/// 渡してはならない(canonical 衝突 / parse 不能を生む)。 +/// Tauri コマンドの invoke 引数型には使わない(String 受け → parse を維持)。 +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TimelineKey { + Basic(String), + UserList { list_id: String }, + Antenna { antenna_id: String }, + Channel { channel_id: String }, + Role { role_id: String }, + Clip { clip_id: String }, + UserNotes { user_id: String }, + Mentions, + Specified, + Favorites, +} + +/// bare 単独で現れたら parse エラーになる prefix 予約語 +const RESERVED_PREFIXES: [&str; 6] = ["user-list", "antenna", "channel", "role", "clip", "user"]; + +/// kebab-case を lowerCamelCase に変換("vmimi-relay" → "vmimiRelay")。 +/// Misskey の WS チャンネル名は lowerCamel、endpoint は kebab が慣行。 +fn kebab_to_lower_camel(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for (i, seg) in s.split('-').enumerate() { + if i == 0 { + out.push_str(seg); + } else { + let mut chars = seg.chars(); + if let Some(first) = chars.next() { + out.extend(first.to_uppercase()); + out.push_str(chars.as_str()); + } + } + } + out +} -impl TimelineType { - pub fn new(s: impl Into) -> Self { - Self(s.into()) +impl TimelineKey { + /// canonical 文字列から構築する。分割は最初の `:` による splitn(2)(id 内に `:` が + /// 残る場合も id の一部として保持)。 + pub fn parse(s: &str) -> Result { + use crate::error::NoteDeckError; + if s.is_empty() { + return Err(NoteDeckError::InvalidInput( + "timeline key must not be empty".to_string(), + )); + } + if s.len() > 256 { + return Err(NoteDeckError::InvalidInput( + "timeline key exceeds 256 bytes".to_string(), + )); + } + if s.bytes().any(|b| b < 0x20 || b == 0x7F) { + return Err(NoteDeckError::InvalidInput( + "timeline key contains control characters".to_string(), + )); + } + if let Some((prefix, id)) = s.split_once(':') { + if id.is_empty() { + return Err(NoteDeckError::InvalidInput(format!( + "timeline key '{prefix}:' has empty id" + ))); + } + let id = id.to_string(); + match prefix { + "user-list" => Ok(Self::UserList { list_id: id }), + "antenna" => Ok(Self::Antenna { antenna_id: id }), + "channel" => Ok(Self::Channel { channel_id: id }), + "role" => Ok(Self::Role { role_id: id }), + "clip" => Ok(Self::Clip { clip_id: id }), + "user" => Ok(Self::UserNotes { user_id: id }), + _ => Err(NoteDeckError::InvalidInput(format!( + "unknown timeline key prefix '{prefix}'" + ))), + } + } else { + match s { + "mentions" => Ok(Self::Mentions), + "specified" => Ok(Self::Specified), + "favorites" => Ok(Self::Favorites), + _ if RESERVED_PREFIXES.contains(&s) => Err(NoteDeckError::InvalidInput(format!( + "bare reserved timeline key '{s}' (id required)" + ))), + _ => Ok(Self::Basic(s.to_string())), + } + } } - pub fn as_str(&self) -> &str { - &self.0 + pub fn as_canonical(&self) -> String { + match self { + Self::Basic(t) => t.clone(), + Self::UserList { list_id } => format!("user-list:{list_id}"), + Self::Antenna { antenna_id } => format!("antenna:{antenna_id}"), + Self::Channel { channel_id } => format!("channel:{channel_id}"), + Self::Role { role_id } => format!("role:{role_id}"), + Self::Clip { clip_id } => format!("clip:{clip_id}"), + Self::UserNotes { user_id } => format!("user:{user_id}"), + Self::Mentions => "mentions".to_string(), + Self::Specified => "specified".to_string(), + Self::Favorites => "favorites".to_string(), + } } - pub fn api_endpoint(&self) -> String { - match self.0.as_str() { - "home" => "notes/timeline".to_string(), - "local" => "notes/local-timeline".to_string(), - "social" => "notes/hybrid-timeline".to_string(), - "global" => "notes/global-timeline".to_string(), - other => format!("notes/{other}-timeline"), + /// REST エンドポイントと追加パラメータ。Favorites / Clip は応答形状・API 方針の + /// 都合で専用 API を維持するため None。 + pub fn api_endpoint(&self) -> Option<(std::borrow::Cow<'static, str>, Value)> { + use std::borrow::Cow; + match self { + Self::Basic(t) => Some(match t.as_str() { + "home" => (Cow::Borrowed("notes/timeline"), serde_json::json!({})), + "local" => (Cow::Borrowed("notes/local-timeline"), serde_json::json!({})), + "social" => ( + Cow::Borrowed("notes/hybrid-timeline"), + serde_json::json!({}), + ), + "global" => ( + Cow::Borrowed("notes/global-timeline"), + serde_json::json!({}), + ), + other => ( + Cow::Owned(format!("notes/{other}-timeline")), + serde_json::json!({}), + ), + }), + Self::UserList { list_id } => Some(( + Cow::Borrowed("notes/user-list-timeline"), + serde_json::json!({ "listId": list_id }), + )), + Self::Antenna { antenna_id } => Some(( + Cow::Borrowed("antennas/notes"), + serde_json::json!({ "antennaId": antenna_id }), + )), + Self::Channel { channel_id } => Some(( + Cow::Borrowed("channels/timeline"), + serde_json::json!({ "channelId": channel_id }), + )), + Self::Role { role_id } => Some(( + Cow::Borrowed("roles/notes"), + serde_json::json!({ "roleId": role_id }), + )), + Self::UserNotes { user_id } => Some(( + Cow::Borrowed("users/notes"), + serde_json::json!({ "userId": user_id }), + )), + Self::Mentions => Some((Cow::Borrowed("notes/mentions"), serde_json::json!({}))), + Self::Specified => Some(( + Cow::Borrowed("notes/mentions"), + serde_json::json!({ "visibility": "specified" }), + )), + Self::Favorites | Self::Clip { .. } => None, } } - pub fn ws_channel(&self) -> String { - match self.0.as_str() { - "home" => "homeTimeline".to_string(), - "local" => "localTimeline".to_string(), - "social" => "hybridTimeline".to_string(), - "global" => "globalTimeline".to_string(), - other => format!("{other}Timeline"), + /// WS チャンネル名とパラメータ。streaming 購読を持たない種別は None。 + /// 未知 Basic の fallback は kebab→lowerCamel 変換付き + /// ("vmimi-relay" → "vmimiRelayTimeline")。 + pub fn ws_channel(&self) -> Option<(std::borrow::Cow<'static, str>, Option)> { + use std::borrow::Cow; + match self { + Self::Basic(t) => Some(match t.as_str() { + "home" => (Cow::Borrowed("homeTimeline"), None), + "local" => (Cow::Borrowed("localTimeline"), None), + "social" => (Cow::Borrowed("hybridTimeline"), None), + "global" => (Cow::Borrowed("globalTimeline"), None), + other => ( + Cow::Owned(format!("{}Timeline", kebab_to_lower_camel(other))), + None, + ), + }), + Self::UserList { list_id } => Some(( + Cow::Borrowed("userList"), + Some(serde_json::json!({ "listId": list_id })), + )), + Self::Antenna { antenna_id } => Some(( + Cow::Borrowed("antenna"), + Some(serde_json::json!({ "antennaId": antenna_id })), + )), + Self::Channel { channel_id } => Some(( + Cow::Borrowed("channel"), + Some(serde_json::json!({ "channelId": channel_id })), + )), + Self::Role { role_id } => Some(( + Cow::Borrowed("roleTimeline"), + Some(serde_json::json!({ "roleId": role_id })), + )), + Self::UserNotes { .. } + | Self::Mentions + | Self::Specified + | Self::Favorites + | Self::Clip { .. } => None, } } } +impl std::fmt::Display for TimelineKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.as_canonical()) + } +} + +impl Serialize for TimelineKey { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.as_canonical()) + } +} + +impl<'de> Deserialize<'de> for TimelineKey { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +// derive(specta::Type) は serde 属性しか読まず手書き Serialize を無視して +// tagged union を TS に生成するため、String へ委譲する手書き impl を使う +// (前例: error.rs の NoteDeckError)。TS 上は常に string に inline される。 +#[cfg(feature = "specta")] +impl specta::Type for TimelineKey { + fn inline( + type_map: &mut specta::TypeCollection, + generics: specta::Generics, + ) -> specta::datatype::DataType { + String::inline(type_map, generics) + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] @@ -1783,59 +1997,209 @@ mod tests { )); } - // ---- TimelineType ---- + // ---- TimelineKey ---- #[test] - fn timeline_type_api_endpoint_known() { - assert_eq!(TimelineType::new("home").api_endpoint(), "notes/timeline"); - assert_eq!( - TimelineType::new("local").api_endpoint(), - "notes/local-timeline" - ); - assert_eq!( - TimelineType::new("social").api_endpoint(), - "notes/hybrid-timeline" - ); - assert_eq!( - TimelineType::new("global").api_endpoint(), - "notes/global-timeline" - ); + fn timeline_key_parse_canonical_roundtrip() { + // parse が生成した値に対して parse(as_canonical(k)) == k が成立する + let cases = [ + "home", + "local", + "social", + "global", + "bubble", + "explore", + "user-list:abc123", + "antenna:01H8XGJWBWBAAMV5ZRWPS2N4EY", // ULID 大文字 id はそのまま保持 + "channel:xyz", + "role:r1", + "clip:c1", + "user:u1", + "mentions", + "specified", + "favorites", + ]; + for s in cases { + let key = TimelineKey::parse(s).unwrap(); + assert_eq!(key.as_canonical(), s, "canonical mismatch for {s}"); + assert_eq!(TimelineKey::parse(&key.as_canonical()).unwrap(), key); + } + } + + #[test] + fn timeline_key_parse_err_conditions() { + // 空 / 未知 prefix / 空 id / bare 予約語 / 256B 超 / 制御文字 + for s in [ + "", + "xxx:yyy", + "antenna:", + "user-list", + "antenna", + "channel", + "role", + "clip", + "user", + ":", + ":b", + &"a".repeat(257), + "home\n", + "antenna:\x01abc", + ] { + assert!(TimelineKey::parse(s).is_err(), "expected Err for {s:?}"); + } } #[test] - fn timeline_type_api_endpoint_unknown_fallback() { + fn timeline_key_splitn_keeps_colon_in_id() { + // 最初の ':' で分割し、id 内の ':' は保持する + let key = TimelineKey::parse("antenna:a:b").unwrap(); assert_eq!( - TimelineType::new("bubble").api_endpoint(), - "notes/bubble-timeline" + key, + TimelineKey::Antenna { + antenna_id: "a:b".to_string() + } ); + assert_eq!(key.as_canonical(), "antenna:a:b"); } #[test] - fn timeline_type_ws_channel_known() { - assert_eq!(TimelineType::new("home").ws_channel(), "homeTimeline"); - assert_eq!(TimelineType::new("local").ws_channel(), "localTimeline"); - assert_eq!(TimelineType::new("social").ws_channel(), "hybridTimeline"); - assert_eq!(TimelineType::new("global").ws_channel(), "globalTimeline"); + fn timeline_key_api_endpoint_table() { + let ep = |s: &str| { + let (e, p) = TimelineKey::parse(s).unwrap().api_endpoint().unwrap(); + (e.to_string(), p) + }; + assert_eq!(ep("home"), ("notes/timeline".into(), serde_json::json!({}))); + assert_eq!( + ep("local"), + ("notes/local-timeline".into(), serde_json::json!({})) + ); + assert_eq!( + ep("social"), + ("notes/hybrid-timeline".into(), serde_json::json!({})) + ); + assert_eq!( + ep("global"), + ("notes/global-timeline".into(), serde_json::json!({})) + ); + assert_eq!( + ep("bubble"), + ("notes/bubble-timeline".into(), serde_json::json!({})) + ); + assert_eq!( + ep("user-list:l1"), + ( + "notes/user-list-timeline".into(), + serde_json::json!({ "listId": "l1" }) + ) + ); + assert_eq!( + ep("antenna:a1"), + ( + "antennas/notes".into(), + serde_json::json!({ "antennaId": "a1" }) + ) + ); + assert_eq!( + ep("channel:c1"), + ( + "channels/timeline".into(), + serde_json::json!({ "channelId": "c1" }) + ) + ); + assert_eq!( + ep("role:r1"), + ("roles/notes".into(), serde_json::json!({ "roleId": "r1" })) + ); + assert_eq!( + ep("user:u1"), + ("users/notes".into(), serde_json::json!({ "userId": "u1" })) + ); + assert_eq!( + ep("mentions"), + ("notes/mentions".into(), serde_json::json!({})) + ); + assert_eq!( + ep("specified"), + ( + "notes/mentions".into(), + serde_json::json!({ "visibility": "specified" }) + ) + ); + assert!(TimelineKey::Favorites.api_endpoint().is_none()); + assert!(TimelineKey::parse("clip:c1") + .unwrap() + .api_endpoint() + .is_none()); } #[test] - fn timeline_type_ws_channel_unknown_fallback() { - assert_eq!(TimelineType::new("bubble").ws_channel(), "bubbleTimeline"); + fn timeline_key_ws_channel_table() { + let ws = |s: &str| { + let (c, p) = TimelineKey::parse(s).unwrap().ws_channel().unwrap(); + (c.to_string(), p) + }; + assert_eq!(ws("home"), ("homeTimeline".into(), None)); + assert_eq!(ws("local"), ("localTimeline".into(), None)); + assert_eq!(ws("social"), ("hybridTimeline".into(), None)); + assert_eq!(ws("global"), ("globalTimeline".into(), None)); + // userList: 現行の user-listTimeline 誤生成バグの解消点 + assert_eq!( + ws("user-list:l1"), + ( + "userList".into(), + Some(serde_json::json!({ "listId": "l1" })) + ) + ); + assert_eq!( + ws("antenna:a1"), + ( + "antenna".into(), + Some(serde_json::json!({ "antennaId": "a1" })) + ) + ); + assert_eq!( + ws("channel:c1"), + ( + "channel".into(), + Some(serde_json::json!({ "channelId": "c1" })) + ) + ); + assert_eq!( + ws("role:r1"), + ( + "roleTimeline".into(), + Some(serde_json::json!({ "roleId": "r1" })) + ) + ); + // kebab→lowerCamel fallback(VRTL 実例)。単語 1 語は挙動不変 + assert_eq!(ws("vmimi-relay"), ("vmimiRelayTimeline".into(), None)); + assert_eq!(ws("bubble"), ("bubbleTimeline".into(), None)); + // 購読を持たない種別は None + for s in ["user:u1", "mentions", "specified", "favorites", "clip:c1"] { + assert!(TimelineKey::parse(s).unwrap().ws_channel().is_none()); + } } #[test] - fn timeline_type_as_str() { - let tt = TimelineType::new("home"); - assert_eq!(tt.as_str(), "home"); + fn timeline_key_serde_is_canonical_string() { + let key = TimelineKey::parse("user-list:l1").unwrap(); + let json = serde_json::to_string(&key).unwrap(); + assert_eq!(json, "\"user-list:l1\""); + let back: TimelineKey = serde_json::from_str(&json).unwrap(); + assert_eq!(back, key); + // Deserialize は parse に委譲し不正キーを弾く + assert!(serde_json::from_str::("\"user-list\"").is_err()); } + #[cfg(feature = "specta")] #[test] - fn timeline_type_serde_roundtrip() { - let tt = TimelineType::new("local"); - let json = serde_json::to_string(&tt).unwrap(); - assert_eq!(json, "\"local\""); - let back: TimelineType = serde_json::from_str(&json).unwrap(); - assert_eq!(back.as_str(), "local"); + fn timeline_key_specta_inlines_to_string() { + // TS へは常に string として inline される(tagged union にならない) + let mut type_map = specta::TypeCollection::default(); + let dt = ::inline(&mut type_map, specta::Generics::Definition); + let string_dt = + ::inline(&mut type_map, specta::Generics::Definition); + assert_eq!(format!("{dt:?}"), format!("{string_dt:?}")); } // ---- TimelineOptions ---- diff --git a/src/streaming.rs b/src/streaming.rs index 6612437..c6f0e01 100644 --- a/src/streaming.rs +++ b/src/streaming.rs @@ -16,7 +16,7 @@ use crate::event_bus::{EventBus, SseEvent}; use crate::models::{ ChatMessage, ChatReactionUser, NormalizedNote, NormalizedNotification, NoteReactedBody, NoteUnreactedBody, NoteUpdateBody, RawEmoji, RawNote, RawNotification, ServerEmoji, - TimelineOptions, TimelineType, + TimelineKey, TimelineOptions, }; /// Trait for emitting events to a frontend (e.g., Tauri WebView). @@ -369,18 +369,51 @@ struct PollingHandle { // --- Subscription tracking --- +/// 購読対象の正本。WS チャンネル名・params・キャッシュキーを全てここから導出する +/// (層ごとのキー手組みを型レベルで排除する — issue #30 仕様 v5 §4)。 +#[derive(Debug, Clone, PartialEq)] +enum SubscriptionTarget { + /// ノート系タイムライン購読 (キャッシュ書込あり) + Notes(TimelineKey), + /// main チャンネル (通知・メンション等) + Main, + ChatUser { + other_id: String, + }, + ChatRoom { + room_id: String, + }, +} + +impl SubscriptionTarget { + /// WS チャンネル名と基本 params。streaming 購読を持たない Notes 種別 + /// (Favorites / Clip / UserNotes / Mentions / Specified) は None。 + fn ws_channel(&self) -> Option<(std::borrow::Cow<'static, str>, Option)> { + use std::borrow::Cow; + match self { + Self::Notes(key) => key.ws_channel(), + Self::Main => Some((Cow::Borrowed("main"), None)), + Self::ChatUser { other_id } => Some(( + Cow::Borrowed("chatUser"), + Some(json!({ "otherId": other_id })), + )), + Self::ChatRoom { room_id } => Some(( + Cow::Borrowed("chatRoom"), + Some(json!({ "roomId": room_id })), + )), + } + } +} + #[derive(Debug, Clone)] struct SubscriptionInfo { account_id: String, host: String, - /// "timeline", "antenna", "channel", "main", or "chat" - kind: String, - /// The Misskey channel name (e.g. "homeTimeline", "main") - channel: String, - /// Original timeline type (e.g. "home", "local") for cache isolation - timeline_type: String, - /// Extra params for channel subscription (e.g. listId for userListTimeline) - params: Option, + /// 購読対象。チャンネル名・params・キャッシュキーの導出元。 + target: SubscriptionTarget, + /// 追加 params のマージ点 (WS フィルタ等の将来拡張用)。reconnect replay / + /// resume でも維持される。 + extra_params: Option, /// Whether this subscription is actively connected/polled. /// /// Suspended subscriptions keep their metadata for viewport-based resume and @@ -388,6 +421,21 @@ struct SubscriptionInfo { active: bool, } +impl SubscriptionInfo { + /// 購読送信に使うチャンネル名と params (extra_params をマージ済み。extra が勝つ)。 + fn channel_and_params(&self) -> Option<(std::borrow::Cow<'static, str>, Option)> { + let (channel, base) = self.target.ws_channel()?; + let params = match (base, self.extra_params.clone()) { + (Some(Value::Object(mut b)), Some(Value::Object(e))) => { + b.extend(e); + Some(Value::Object(b)) + } + (base, extra) => extra.or(base), + }; + Some((channel, params)) + } +} + pub struct StreamingManager { connections: Arc>>, poll_connections: Arc>>, @@ -435,7 +483,8 @@ impl StreamingManager { } else { StreamConnectionState::Reconnecting }; - self.emitter.emit(StreamEvent::Status(Box::new(StreamStatusEvent { + self.emitter + .emit(StreamEvent::Status(Box::new(StreamStatusEvent { account_id: account_id.to_string(), state, }))); @@ -463,7 +512,10 @@ impl StreamingManager { None } Err(_) => { - tracing::warn!(account_id, "initial connect timed out; retrying in background"); + tracing::warn!( + account_id, + "initial connect timed out; retrying in background" + ); None } }; @@ -513,7 +565,8 @@ impl StreamingManager { }, ); - self.emitter.emit(StreamEvent::Status(Box::new(StreamStatusEvent { + self.emitter + .emit(StreamEvent::Status(Box::new(StreamStatusEvent { account_id: account_id.to_string(), state: if connected { StreamConnectionState::Connected @@ -556,7 +609,8 @@ impl StreamingManager { captured.remove(account_id); drop(captured); - self.emitter.emit(StreamEvent::Status(Box::new(StreamStatusEvent { + self.emitter + .emit(StreamEvent::Status(Box::new(StreamStatusEvent { account_id: account_id.to_string(), state: StreamConnectionState::Disconnected, }))); @@ -598,7 +652,8 @@ impl StreamingManager { let interval = Duration::from_millis(interval_ms.unwrap_or(15_000)); self.start_polling(account_id, host, token, interval).await; - self.emitter.emit(StreamEvent::Status(Box::new(StreamStatusEvent { + self.emitter + .emit(StreamEvent::Status(Box::new(StreamStatusEvent { account_id: account_id.to_string(), state: StreamConnectionState::Connected, }))); @@ -659,122 +714,22 @@ impl StreamingManager { ); } - pub async fn subscribe_timeline( - &self, - account_id: &str, - timeline_type: TimelineType, - list_id: Option, - ) -> Result { - let sub_id = uuid::Uuid::new_v4().to_string(); - let channel = timeline_type.ws_channel(); - let params = list_id.as_ref().map(|id| json!({ "listId": id })); - - let host = self.get_host(account_id).await?; - self.send_subscribe(account_id, &channel, &sub_id, params.clone()) - .await?; - - let mut subs = self.subscriptions.write().await; - subs.insert( - sub_id.clone(), - SubscriptionInfo { - account_id: account_id.to_string(), - host, - kind: "timeline".to_string(), - channel: channel.clone(), - timeline_type: timeline_type.as_str().to_string(), - params, - active: true, - }, - ); - - Ok(sub_id) - } - - pub async fn subscribe_antenna( - &self, - account_id: &str, - antenna_id: &str, - ) -> Result { - let sub_id = uuid::Uuid::new_v4().to_string(); - let params = Some(json!({ "antennaId": antenna_id })); - - let host = self.get_host(account_id).await?; - self.send_subscribe(account_id, "antenna", &sub_id, params.clone()) - .await?; - - let mut subs = self.subscriptions.write().await; - subs.insert( - sub_id.clone(), - SubscriptionInfo { - account_id: account_id.to_string(), - host, - kind: "antenna".to_string(), - channel: "antenna".to_string(), - timeline_type: String::new(), - params, - active: true, - }, - ); - - Ok(sub_id) - } - - pub async fn subscribe_channel( - &self, - account_id: &str, - channel_id: &str, - ) -> Result { - let sub_id = uuid::Uuid::new_v4().to_string(); - let params = Some(json!({ "channelId": channel_id })); - - let host = self.get_host(account_id).await?; - self.send_subscribe(account_id, "channel", &sub_id, params.clone()) - .await?; - - let mut subs = self.subscriptions.write().await; - subs.insert( - sub_id.clone(), - SubscriptionInfo { - account_id: account_id.to_string(), - host, - kind: "channel".to_string(), - channel: "channel".to_string(), - timeline_type: String::new(), - params, - active: true, - }, - ); - - Ok(sub_id) - } - - pub async fn subscribe_role( + /// ノート系タイムライン購読の単一エントリポイント。チャンネル名・params・ + /// キャッシュキーは全て `key` から導出する。streaming 購読を持たない種別 + /// (Favorites 等 — `ws_channel() == None`) は Err。 + pub async fn subscribe_notes( &self, account_id: &str, - role_id: &str, + key: TimelineKey, + extra_params: Option, ) -> Result { - let sub_id = uuid::Uuid::new_v4().to_string(); - let params = Some(json!({ "roleId": role_id })); - - let host = self.get_host(account_id).await?; - self.send_subscribe(account_id, "roleTimeline", &sub_id, params.clone()) - .await?; - - let mut subs = self.subscriptions.write().await; - subs.insert( - sub_id.clone(), - SubscriptionInfo { - account_id: account_id.to_string(), - host, - kind: "role".to_string(), - channel: "roleTimeline".to_string(), - timeline_type: String::new(), - params, - active: true, - }, - ); - - Ok(sub_id) + if key.ws_channel().is_none() { + return Err(NoteDeckError::InvalidInput(format!( + "timeline key '{key}' has no streaming channel" + ))); + } + self.subscribe_target(account_id, SubscriptionTarget::Notes(key), extra_params) + .await } pub async fn subscribe_chat_user( @@ -782,28 +737,14 @@ impl StreamingManager { account_id: &str, other_id: &str, ) -> Result { - let sub_id = uuid::Uuid::new_v4().to_string(); - let params = Some(json!({ "otherId": other_id })); - - let host = self.get_host(account_id).await?; - self.send_subscribe(account_id, "chatUser", &sub_id, params.clone()) - .await?; - - let mut subs = self.subscriptions.write().await; - subs.insert( - sub_id.clone(), - SubscriptionInfo { - account_id: account_id.to_string(), - host, - kind: "chat".to_string(), - channel: "chatUser".to_string(), - timeline_type: String::new(), - params, - active: true, + self.subscribe_target( + account_id, + SubscriptionTarget::ChatUser { + other_id: other_id.to_string(), }, - ); - - Ok(sub_id) + None, + ) + .await } pub async fn subscribe_chat_room( @@ -811,50 +752,44 @@ impl StreamingManager { account_id: &str, room_id: &str, ) -> Result { - let sub_id = uuid::Uuid::new_v4().to_string(); - let params = Some(json!({ "roomId": room_id })); - - let host = self.get_host(account_id).await?; - self.send_subscribe(account_id, "chatRoom", &sub_id, params.clone()) - .await?; - - let mut subs = self.subscriptions.write().await; - subs.insert( - sub_id.clone(), - SubscriptionInfo { - account_id: account_id.to_string(), - host, - kind: "chat".to_string(), - channel: "chatRoom".to_string(), - timeline_type: String::new(), - params, - active: true, + self.subscribe_target( + account_id, + SubscriptionTarget::ChatRoom { + room_id: room_id.to_string(), }, - ); - - Ok(sub_id) + None, + ) + .await } pub async fn subscribe_main(&self, account_id: &str) -> Result { - let sub_id = uuid::Uuid::new_v4().to_string(); + self.subscribe_target(account_id, SubscriptionTarget::Main, None) + .await + } + async fn subscribe_target( + &self, + account_id: &str, + target: SubscriptionTarget, + extra_params: Option, + ) -> Result { + let sub_id = uuid::Uuid::new_v4().to_string(); let host = self.get_host(account_id).await?; - self.send_subscribe(account_id, "main", &sub_id, None) + let info = SubscriptionInfo { + account_id: account_id.to_string(), + host, + target, + extra_params, + active: true, + }; + let (channel, params) = info.channel_and_params().ok_or_else(|| { + NoteDeckError::InvalidInput("subscription target has no streaming channel".to_string()) + })?; + self.send_subscribe(account_id, &channel, &sub_id, params) .await?; let mut subs = self.subscriptions.write().await; - subs.insert( - sub_id.clone(), - SubscriptionInfo { - account_id: account_id.to_string(), - host, - kind: "main".to_string(), - channel: "main".to_string(), - timeline_type: String::new(), - params: None, - active: true, - }, - ); + subs.insert(sub_id.clone(), info); Ok(sub_id) } @@ -921,7 +856,7 @@ impl StreamingManager { account_id: &str, subscription_id: &str, ) -> Result<(), NoteDeckError> { - let (channel, params, was_active) = { + let (channel_params, was_active) = { let subs = self.subscriptions.read().await; let info = subs .get(subscription_id) @@ -931,11 +866,14 @@ impl StreamingManager { "subscription account mismatch".to_string(), )); } - (info.channel.clone(), info.params.clone(), info.active) + (info.channel_and_params(), info.active) }; if was_active { return Ok(()); } + let (channel, params) = channel_params.ok_or_else(|| { + NoteDeckError::InvalidInput("subscription target has no streaming channel".to_string()) + })?; self.send_subscribe(account_id, &channel, subscription_id, params) .await?; @@ -1115,9 +1053,9 @@ async fn connection_task( loop { connected_flag.store(false, Ordering::Relaxed); emitter.emit(StreamEvent::Status(Box::new(StreamStatusEvent { - account_id: account_id.clone(), - state: StreamConnectionState::Reconnecting, - }))); + account_id: account_id.clone(), + state: StreamConnectionState::Reconnecting, + }))); // Wait with backoff, but listen for Shutdown during the wait. // Equal Jitter (sleep in [backoff/2, backoff]) de-syncs reconnects @@ -1158,9 +1096,9 @@ async fn connection_task( connected_flag.store(true, Ordering::Relaxed); emitter.emit(StreamEvent::Status(Box::new(StreamStatusEvent { - account_id: account_id.clone(), - state: StreamConnectionState::Connected, - }))); + account_id: account_id.clone(), + state: StreamConnectionState::Connected, + }))); let reason = run_ws_session( &emitter, @@ -1217,7 +1155,10 @@ async fn run_ws_session( let subs = subscriptions.read().await; subs.iter() .filter(|(_, info)| info.account_id == account_id && info.active) - .map(|(sub_id, info)| (sub_id.clone(), info.channel.clone(), info.params.clone())) + .filter_map(|(sub_id, info)| { + info.channel_and_params() + .map(|(channel, params)| (sub_id.clone(), channel.into_owned(), params)) + }) .collect() }; @@ -1448,7 +1389,11 @@ async fn handle_ws_message( note_id, update, }; - emit_both(emitter, event_bus, StreamEvent::NoteCaptureUpdated(Box::new(payload))); + emit_both( + emitter, + event_bus, + StreamEvent::NoteCaptureUpdated(Box::new(payload)), + ); } return; } @@ -1491,7 +1436,11 @@ async fn handle_ws_message( change, emojis, }; - emit_both(emitter, event_bus, StreamEvent::EmojiChanged(Box::new(payload))); + emit_both( + emitter, + event_bus, + StreamEvent::EmojiChanged(Box::new(payload)), + ); return; } @@ -1520,23 +1469,34 @@ async fn handle_ws_message( _ => return, }; - let (kind, host, timeline_type) = { + let (target, host) = { let subs = subscriptions.read().await; match subs.get(&sub_id) { - Some(i) => (i.kind.clone(), i.host.clone(), i.timeline_type.clone()), + Some(i) => (i.target.clone(), i.host.clone()), None => return, } }; - let is_note_channel = matches!(kind.as_str(), "timeline" | "antenna" | "channel" | "role"); + let note_key = match &target { + SubscriptionTarget::Notes(key) => Some(key.clone()), + _ => None, + }; + let is_note_channel = note_key.is_some(); + let is_main = matches!(target, SubscriptionTarget::Main); + let is_chat = matches!( + target, + SubscriptionTarget::ChatUser { .. } | SubscriptionTarget::ChatRoom { .. } + ); if is_note_channel && event_type == "note" { if let Ok(raw) = serde_json::from_value::(event_body) { + let key = note_key.expect("is_note_channel implies note_key"); let note = Arc::new(raw.normalize(account_id, &host)); let db = db.clone(); let note_for_cache = Arc::clone(¬e); tokio::task::spawn_blocking(move || { - if let Err(e) = db.cache_note(¬e_for_cache, &timeline_type) { + if let Err(e) = db.ingest_notes(std::slice::from_ref(note_for_cache.as_ref()), &key) + { tracing::warn!(error = %e, "failed to cache streamed note"); } }); @@ -1573,8 +1533,12 @@ async fn handle_ws_message( note_id, update, }; - emit_both(emitter, event_bus, StreamEvent::NoteUpdated(Box::new(payload))); - } else if kind == "main" { + emit_both( + emitter, + event_bus, + StreamEvent::NoteUpdated(Box::new(payload)), + ); + } else if is_main { if event_type == "notification" { if let Ok(raw) = serde_json::from_value::(event_body) { let notification = raw.normalize(account_id, &host); @@ -1583,7 +1547,11 @@ async fn handle_ws_message( subscription_id: sub_id, notification, }; - emit_both(emitter, event_bus, StreamEvent::Notification(Box::new(payload))); + emit_both( + emitter, + event_bus, + StreamEvent::Notification(Box::new(payload)), + ); } } else if event_type == "mention" || event_type == "reply" { // main-event として emit しつつ、mention としても parse を試みる @@ -1610,9 +1578,13 @@ async fn handle_ws_message( event_type, body: event_body, }; - emit_both(emitter, event_bus, StreamEvent::MainEvent(Box::new(payload))); + emit_both( + emitter, + event_bus, + StreamEvent::MainEvent(Box::new(payload)), + ); } - } else if kind == "chat" { + } else if is_chat { if event_type == "message" { if let Ok(mut msg) = serde_json::from_value::(event_body) { // Misskey 本家の chat:message WS event は Lite packer 固定で @@ -1647,7 +1619,11 @@ async fn handle_ws_message( subscription_id: sub_id, message: msg, }; - emit_both(emitter, event_bus, StreamEvent::ChatMessage(Box::new(payload))); + emit_both( + emitter, + event_bus, + StreamEvent::ChatMessage(Box::new(payload)), + ); } } else if event_type == "deleted" { if let Some(id) = event_body.as_str() { @@ -1668,7 +1644,11 @@ async fn handle_ws_message( subscription_id: sub_id, message_id: id_owned, }; - emit_both(emitter, event_bus, StreamEvent::ChatMessageDeleted(Box::new(payload))); + emit_both( + emitter, + event_bus, + StreamEvent::ChatMessageDeleted(Box::new(payload)), + ); } } else if event_type == "react" || event_type == "unreact" { let is_react = event_type == "react"; @@ -1698,7 +1678,11 @@ async fn handle_ws_message( reaction: body.reaction, user: body.user, }; - emit_both(emitter, event_bus, StreamEvent::ChatMessageReacted(Box::new(payload))); + emit_both( + emitter, + event_bus, + StreamEvent::ChatMessageReacted(Box::new(payload)), + ); } else { let payload = StreamChatMessageUnreactedEvent { account_id: account_id.to_string(), @@ -1707,7 +1691,11 @@ async fn handle_ws_message( reaction: body.reaction, user: body.user, }; - emit_both(emitter, event_bus, StreamEvent::ChatMessageUnreacted(Box::new(payload))); + emit_both( + emitter, + event_bus, + StreamEvent::ChatMessageUnreacted(Box::new(payload)), + ); } } } @@ -1752,34 +1740,34 @@ async fn polling_loop( return; } - // Collect timeline subscriptions for this account - let subs_snapshot: Vec<(String, SubscriptionInfo)> = { + // Collect note subscriptions for this account. + // 購読経路を持つ全 Notes 種別 (timeline / antenna / channel / role / + // user-list) を polling 対象にする。api_endpoint() を持たない種別は + // subscribe_notes で弾かれているため到達しない。 + let subs_snapshot: Vec<(String, TimelineKey)> = { let subs = subscriptions.read().await; subs.iter() - .filter(|(_, info)| { - info.account_id == account_id && info.kind == "timeline" && info.active + .filter(|(_, info)| info.account_id == account_id && info.active) + .filter_map(|(id, info)| match &info.target { + SubscriptionTarget::Notes(key) if key.api_endpoint().is_some() => { + Some((id.clone(), key.clone())) + } + _ => None, }) - .map(|(id, info)| (id.clone(), info.clone())) .collect() }; let mut poll_failed = false; - for (sub_id, info) in &subs_snapshot { + for (sub_id, key) in &subs_snapshot { let state = sub_states .entry(sub_id.clone()) .or_insert(PollSubState { since_id: None }); - let tl_type = TimelineType::new(&info.timeline_type); - let mut options = TimelineOptions::new(30, state.since_id.clone(), None); - options.list_id = info.params.as_ref().and_then(|p| { - p.get("listId") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }); + let options = TimelineOptions::new(30, state.since_id.clone(), None); match api_client - .get_timeline(&host, &token, &account_id, tl_type, options) + .get_timeline(&host, &token, &account_id, key, options) .await { Ok(notes) if !notes.is_empty() => { @@ -1792,9 +1780,11 @@ async fn polling_loop( // Cache to DB let db = db.clone(); let note_for_cache = Arc::clone(¬e); - let timeline_type = info.timeline_type.clone(); + let key = key.clone(); tokio::task::spawn_blocking(move || { - if let Err(e) = db.cache_note(¬e_for_cache, &timeline_type) { + if let Err(e) = + db.ingest_notes(std::slice::from_ref(note_for_cache.as_ref()), &key) + { tracing::warn!(error = %e, "failed to cache polled note"); } }); @@ -1804,7 +1794,11 @@ async fn polling_loop( subscription_id: sub_id.clone(), note, }; - emit_both(emitter.as_ref(), &event_bus, StreamEvent::Note(Box::new(payload))); + emit_both( + emitter.as_ref(), + &event_bus, + StreamEvent::Note(Box::new(payload)), + ); } consecutive_failures = 0; @@ -1913,9 +1907,9 @@ async fn polling_loop( }; emitter.emit(StreamEvent::Status(Box::new(StreamStatusEvent { - account_id: account_id.clone(), - state: StreamConnectionState::Reconnecting, - }))); + account_id: account_id.clone(), + state: StreamConnectionState::Reconnecting, + }))); Duration::from_secs(backoff) } else { @@ -2016,7 +2010,15 @@ mod tests { for text in [&added, &deleted, &unknown] { handle_ws_message( - &emitter, &event_bus, &db, &api, "acc-1", "h.example", "tok", text, &subs, + &emitter, + &event_bus, + &db, + &api, + "acc-1", + "h.example", + "tok", + text, + &subs, ) .await; } @@ -2058,11 +2060,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let db = Arc::new(crate::db::Database::open(&dir.path().join("test.db")).unwrap()); let (tx, mut rx) = mpsc::unbounded_channel(); - let manager = StreamingManager::new( - Arc::new(ChannelEmitter(tx)), - Arc::new(EventBus::new()), - db, - ); + let manager = + StreamingManager::new(Arc::new(ChannelEmitter(tx)), Arc::new(EventBus::new()), db); // 127.0.0.1:1 は即 connection refused になる manager @@ -2092,4 +2091,293 @@ mod tests { // disconnect が backoff 中の Shutdown を届けて task を終了できる manager.disconnect("acc-1").await; } + + // --- 接続ライフサイクルの状態遷移 (#877) --- + // + // connect() は初回接続に失敗してもハンドルを残す (上のテスト) ので、 + // 到達不能な host を使えば実サーバーなしで購読・中断・再開・モード切替の + // 状態遷移を通せる。WS のフレームそのものではなく、StreamingManager が + // 持つ表 (connections / subscriptions / captured_notes) の遷移を見る。 + + /// 到達不能な host に接続したマネージャ。ハンドルは残るので購読操作は通る。 + async fn manager_with_dead_connection( + accounts: &[&str], + ) -> ( + tempfile::TempDir, + StreamingManager, + mpsc::UnboundedReceiver, + ) { + let dir = tempfile::tempdir().unwrap(); + let db = Arc::new(crate::db::Database::open(&dir.path().join("test.db")).unwrap()); + let (tx, rx) = mpsc::unbounded_channel(); + let manager = + StreamingManager::new(Arc::new(ChannelEmitter(tx)), Arc::new(EventBus::new()), db); + for account_id in accounts { + // 127.0.0.1:1 は即 connection refused + manager + .connect(account_id, "127.0.0.1:1", "token") + .await + .unwrap(); + } + (dir, manager, rx) + } + + fn drain_status(rx: &mut mpsc::UnboundedReceiver) -> Vec { + let mut out = Vec::new(); + while let Ok(event) = rx.try_recv() { + if let StreamEvent::Status(s) = event { + out.push(s.state); + } + } + out + } + + #[tokio::test] + async fn connect_is_idempotent_and_reports_the_live_state() { + // 2 回目の connect は接続を張り直さず、いま持っている実状態を emit する。 + // フロントは復帰時にリスナーを張り直してから connect を呼ぶので、 + // ここで status が出ないと背景化中の遷移を取り逃したままになる。 + let (_dir, manager, mut rx) = manager_with_dead_connection(&["acc-1"]).await; + drain_status(&mut rx); + + manager + .connect("acc-1", "127.0.0.1:1", "token") + .await + .unwrap(); + + // 接続タスクは 1 本のまま (張り直していない) + assert_eq!(manager.connections.lock().await.len(), 1); + // 未接続なので Reconnecting が返る (楽観的に Connected と言わない) + assert!( + drain_status(&mut rx).contains(&StreamConnectionState::Reconnecting), + "冪等 return でも現在状態を emit すること" + ); + + manager.disconnect("acc-1").await; + } + + #[tokio::test] + async fn subscribe_without_connection_reports_no_connection() { + let dir = tempfile::tempdir().unwrap(); + let db = Arc::new(crate::db::Database::open(&dir.path().join("test.db")).unwrap()); + let (tx, _rx) = mpsc::unbounded_channel(); + let manager = + StreamingManager::new(Arc::new(ChannelEmitter(tx)), Arc::new(EventBus::new()), db); + + let err = manager + .subscribe_notes("acc-1", TimelineKey::parse("home").unwrap(), None) + .await + .expect_err("接続していないアカウントの購読は通らない"); + assert_eq!(err.code(), "NO_CONNECTION"); + // 失敗した購読が表に残らない + assert!(manager.subscriptions.read().await.is_empty()); + } + + #[tokio::test] + async fn disconnect_clears_only_that_accounts_state() { + // cross-account (#777): 片方を切っても、もう片方の購読と capture は残る。 + let (_dir, manager, mut rx) = manager_with_dead_connection(&["acc-1", "acc-2"]).await; + let sub1 = manager + .subscribe_notes("acc-1", TimelineKey::parse("home").unwrap(), None) + .await + .unwrap(); + let sub2 = manager + .subscribe_notes("acc-2", TimelineKey::parse("local").unwrap(), None) + .await + .unwrap(); + manager.sub_note("acc-1", "note-1").await.unwrap(); + manager.sub_note("acc-2", "note-2").await.unwrap(); + drain_status(&mut rx); + + manager.disconnect("acc-1").await; + + let subs = manager.subscriptions.read().await; + assert!(!subs.contains_key(&sub1), "切断した側の購読は消える"); + assert!(subs.contains_key(&sub2), "他アカウントの購読は残る"); + drop(subs); + let captured = manager.captured_notes.read().await; + assert!(!captured.contains_key("acc-1")); + assert!(captured.contains_key("acc-2")); + drop(captured); + assert!(drain_status(&mut rx).contains(&StreamConnectionState::Disconnected)); + + manager.disconnect("acc-2").await; + } + + #[tokio::test] + async fn suspend_then_resume_round_trips_the_active_flag() { + // ビューポート予算で使う中断/再開。metadata を捨てずに active だけを倒す + // (捨てると再接続リプレイと再開で channel / params を復元できない)。 + let (_dir, manager, _rx) = manager_with_dead_connection(&["acc-1"]).await; + let sub = manager + .subscribe_notes("acc-1", TimelineKey::parse("home").unwrap(), None) + .await + .unwrap(); + + manager.suspend_subscription("acc-1", &sub).await.unwrap(); + { + let subs = manager.subscriptions.read().await; + let info = subs.get(&sub).expect("中断しても metadata は残る"); + assert!(!info.active); + let (channel, _) = info.channel_and_params().unwrap(); + assert_eq!(channel, "homeTimeline"); + } + // 二重中断は no-op で成功する (UI 側で状態を持たなくてよい) + manager.suspend_subscription("acc-1", &sub).await.unwrap(); + + manager.resume_subscription("acc-1", &sub).await.unwrap(); + assert!(manager.subscriptions.read().await[&sub].active); + // 二重再開も no-op + manager.resume_subscription("acc-1", &sub).await.unwrap(); + + manager.disconnect("acc-1").await; + } + + #[tokio::test] + async fn suspend_and_resume_reject_another_accounts_subscription() { + // 購読 ID を知っていても、持ち主でなければ触れない。 + let (_dir, manager, _rx) = manager_with_dead_connection(&["acc-1", "acc-2"]).await; + let sub = manager + .subscribe_notes("acc-1", TimelineKey::parse("home").unwrap(), None) + .await + .unwrap(); + + let err = manager + .suspend_subscription("acc-2", &sub) + .await + .expect_err("他アカウントの購読は中断できない"); + assert_eq!(err.code(), "INVALID_INPUT"); + assert!( + manager.subscriptions.read().await[&sub].active, + "拒否したのに active を倒してはいけない" + ); + + manager.suspend_subscription("acc-1", &sub).await.unwrap(); + let err = manager + .resume_subscription("acc-2", &sub) + .await + .expect_err("他アカウントの購読は再開できない"); + assert_eq!(err.code(), "INVALID_INPUT"); + assert!(!manager.subscriptions.read().await[&sub].active); + + manager.disconnect("acc-1").await; + manager.disconnect("acc-2").await; + } + + #[tokio::test] + async fn unknown_subscription_is_rejected_not_silently_ignored() { + let (_dir, manager, _rx) = manager_with_dead_connection(&["acc-1"]).await; + assert_eq!( + manager + .suspend_subscription("acc-1", "no-such-sub") + .await + .expect_err("存在しない購読") + .code(), + "INVALID_INPUT" + ); + assert_eq!( + manager + .resume_subscription("acc-1", "no-such-sub") + .await + .expect_err("存在しない購読") + .code(), + "INVALID_INPUT" + ); + manager.disconnect("acc-1").await; + } + + #[tokio::test] + async fn note_capture_survives_until_explicitly_dropped() { + // captured_notes は再接続時のリプレイ元。sub_note が表に載せないと + // 最初の再接続以降 noteUpdated が黙って止まる。 + let (_dir, manager, _rx) = manager_with_dead_connection(&["acc-1"]).await; + + manager.sub_note("acc-1", "note-1").await.unwrap(); + manager.sub_note("acc-1", "note-2").await.unwrap(); + assert_eq!(manager.captured_notes.read().await["acc-1"].len(), 2); + + manager.unsub_note("acc-1", "note-1").await.unwrap(); + assert_eq!(manager.captured_notes.read().await["acc-1"].len(), 1); + + // 最後の 1 件を外すとアカウントのエントリごと消える (空 set を残さない) + manager.unsub_note("acc-1", "note-2").await.unwrap(); + assert!(!manager.captured_notes.read().await.contains_key("acc-1")); + + manager.disconnect("acc-1").await; + } + + #[tokio::test] + async fn set_mode_rejects_unknown_mode() { + let (_dir, manager, _rx) = manager_with_dead_connection(&["acc-1"]).await; + let err = manager + .set_mode("acc-1", "127.0.0.1:1", "token", "carrier-pigeon", None) + .await + .expect_err("未知のモード"); + assert_eq!(err.code(), "INVALID_INPUT"); + // 既存の接続を壊していない + assert_eq!(manager.connections.lock().await.len(), 1); + manager.disconnect("acc-1").await; + } + + #[tokio::test] + async fn switching_to_polling_keeps_subscriptions_and_swaps_the_transport() { + // モード切替は輸送路だけを差し替える。購読を捨てると切替のたびに + // カラムが空になる。 + let (_dir, manager, mut rx) = manager_with_dead_connection(&["acc-1"]).await; + let sub = manager + .subscribe_notes("acc-1", TimelineKey::parse("home").unwrap(), None) + .await + .unwrap(); + drain_status(&mut rx); + + manager + .set_mode("acc-1", "127.0.0.1:1", "token", "polling", Some(60_000)) + .await + .unwrap(); + + assert!( + manager.connections.lock().await.is_empty(), + "polling へ移ったら WS 接続は畳む" + ); + assert!(manager.poll_connections.lock().await.contains_key("acc-1")); + assert!( + manager.subscriptions.read().await.contains_key(&sub), + "購読はモードをまたいで残る" + ); + assert!(drain_status(&mut rx).contains(&StreamConnectionState::Connected)); + + // polling 中でも購読を足せる (WS コマンドではなく表に載るだけ) + let sub2 = manager + .subscribe_notes("acc-1", TimelineKey::parse("local").unwrap(), None) + .await + .unwrap(); + assert!(manager.subscriptions.read().await.contains_key(&sub2)); + + // realtime へ戻すと polling を止めて WS を張り直す + manager + .set_mode("acc-1", "127.0.0.1:1", "token", "realtime", None) + .await + .unwrap(); + assert!(manager.poll_connections.lock().await.is_empty()); + assert_eq!(manager.connections.lock().await.len(), 1); + assert!(manager.subscriptions.read().await.contains_key(&sub)); + + manager.disconnect("acc-1").await; + } + + #[tokio::test] + async fn unsubscribe_drops_the_subscription_in_both_modes() { + let (_dir, manager, _rx) = manager_with_dead_connection(&["acc-1"]).await; + let sub = manager + .subscribe_notes("acc-1", TimelineKey::parse("home").unwrap(), None) + .await + .unwrap(); + + manager.unsubscribe("acc-1", &sub).await.unwrap(); + assert!(manager.subscriptions.read().await.is_empty()); + + // 接続が無くなっても unsubscribe は表を掃除して成功する + manager.disconnect("acc-1").await; + manager.unsubscribe("acc-1", &sub).await.unwrap(); + } }