# ARCHITECTURE NoteDeck — マルチサーバー対応 Misskey デッキクライアントのアーキテクチャ。 --- ## 目次 - [縦の経路: 1 つのノートが画面に出るまで](#縦の経路-1-つのノートが画面に出るまで) - [アーキテクチャ概要](#アーキテクチャ概要) - [全体像](#全体像) - [notedeck(GUI アプリ)](#notedeckgui-アプリ) - [notecli(コアライブラリ)](#notecliコアライブラリ) - [Session-centric + Viewport-centric Architecture](#session-centric--viewport-centric-architecture) - [Rust Query Runtime + Read Model](#rust-query-runtime--read-model) - [ノートキャッシュ・キューアーキテクチャ](#ノートキャッシュキューアーキテクチャ) - [チャットキャッシュ・アーキテクチャ](#チャットキャッシュアーキテクチャ) - [レンダリングパフォーマンス](#レンダリングパフォーマンス) - [採用状況マトリクス](#採用状況マトリクス) --- ## 縦の経路: 1 つのノートが画面に出るまで このドキュメントの他の節は、層ごとの説明(横方向)で書かれている。全体像を知るには向くが、新しく入った人が最初に詰まるのは全体像ではなく**入口**——「で、実際に何がどう繋がっているのか」。 そこでここでは、**フォローしている誰かがノートを投稿してから、それが自分のタイムラインカラムに現れるまで**を、1 本の線として端から端まで追う。各段の詳しい仕様は後続の節にある。 | # | 場所 | 何が起きるか | |---|------|-------------| | 1 | notecli(Rust) | Misskey サーバーとの WebSocket からイベントを受け取る | | 2 | `streaming.rs` の `TauriEmitter` | 受信を OS 通知 / WebView / SSE / Query Runtime の 4 つの出力先へ同時に配る | | 3 | `query_runtime.rs` の `ingest_stream_event` | Read Model に反映し、フラッシュ待ちに積む | | 4 | `query_runtime.rs` の `drain_pending`(`lib.rs` の flusher が駆動) | 溜まった分を 1 フレームぶんまとめ、`query-delta` を typed event として emit | | 5 | `core/queryDeltaBus.ts` | `query-delta` をアプリ全体で 1 本だけ listen し、登録済みの購読へ配る | | 6 | `adapters/misskey/query.ts` の `createQuerySubscription` | 自分の `queryId` 宛だけを拾い、insert / delete / update に振り分ける | | 7 | `components/deck/Deck*Column.vue` | `streaming.subscribe` の `onInsert` でノートに変換して enqueue する | | 8 | `composables/useNoteColumn.ts` の `enqueueWithQuery` | 組込フィルタとカラムクエリを通す。通らなければ**ここで消える** | | 9 | `composables/useStreamingBatch.ts` の `enqueueNote` | rAF でまとめ、スクロール位置に応じて即挿入かバナー待ちかを決める | | 10 | noteStore + 仮想リスト | 実体は noteStore に 1 つだけ置き、カラムは ID 配列を持って描画する | ### なぜこの形なのか 線を追うだけでは「なぜ途中にこれがあるのか」が分からないので、各段の存在理由を挙げる。ここが変わらない限り、この経路も変わらない。 - **2 で分岐する** — 同じ 1 つの受信を、通知・画面・外部 API・Read Model が別々に取りに行くと、購読が増えるほど WebSocket 側の負荷と取りこぼしのリスクが増える。受け口は 1 つにして、そこから配る - **4 でまとめる** — ストリーミングイベントは 1 件ずつ来るが、1 件ごとに IPC を渡すと流速の速いカラムで WebView 側が溢れる。フレーム単位に畳んで IPC 回数を落とす - **5 で 1 本だけ listen する** — 購読ごとに listen すると、スリープ復帰やウィンドウ再生成のときに何を再登録すべきかを追えなくなる。listen は 1 本に固定し、配布は JS 側で行う - **8 でフィルタする** — 取得(何が届くか)と表示(何を見せるか)を同じ場所で決めると、フィルタを足すたびに取得側を触ることになる。届いたものを落とす判断はここに閉じる - **10 で ID だけ持つ** — 同じノートが複数のカラムに出るとき実体を複製すると、リアクションが付いたときに全カラムを更新して回る必要がある。実体を 1 つにすれば更新は 1 回で済む ### 逆から辿るとき 画面に出ているノートが「なぜこう表示されているのか」を調べるときは、この表を下から上に読む。よくある分岐は次の 2 つ。 - **届いているのに出ない** → 8 のフィルタか、9 のバナー待ち(スクロール位置)で止まっている - **そもそも届かない** → 2 より前。WebSocket の購読状態を見る。Stream Inspector カラムが raw イベントをそのまま表示するので、Rust 側まで来ているかはそこで判別できる フロントの `commands.xxx()` から Rust 実装へ飛ぶ方法は [DEVELOPMENT.md — IPC 境界を跨ぐジャンプ](DEVELOPMENT.md#ipc-境界を跨ぐジャンプ) を参照。 --- ## アーキテクチャ概要 ### 全体像 ```mermaid graph TB subgraph App["NoteDeck Application"] subgraph Frontend["Frontend (WebView)
Vue 3 + TypeScript + Vite"] Pinia["Pinia Stores"] Router["Vue Router"] Composables["Composables
useNoteList, useColumnSetup,
useStreamingBatch, useDeckWindow,
useEmojiResolver, ..."] Adapter["Server Adapter Layer
(Misskey + フォーク)"] Pinia --> Composables end subgraph Backend["Rust Backend"] IPC["Tauri IPC Layer
(212 commands)"] Notecli["notecli Library
(Misskey API)"] SQLite["SQLite (WAL+FTS5)
refinery マイグレーション"] Axum["Axum HTTP Server
(localhost:19820)"] Streaming["Streaming Manager
(WebSocket)"] ImageCache["Image Cache
(3層: Mem/Disk/Net)"] OGP["OGP Cache
(SQLite-backed)"] IPC --> Notecli --> SQLite end subgraph TauriCore["Tauri V2 Core"] Bridge["IPC Bridge"] Events["Event System (emit/listen)"] MultiWin["Multi-Window Management"] PluginSys["Plugin System"] end Frontend <-->|"Tauri IPC & Events"| TauriCore TauriCore <--> Backend end Plugins["Plugins: notification, global-shortcut,
autostart, updater, opener, os, process"] App --- Plugins ``` **技術スタック:** - **フレームワーク**: Tauri V2 - **フロントエンド**: Vue 3 + TypeScript + Vite(Vapor モード移行予定 [#52](https://github.com/notedeck-dev/notedeck/issues/52)) - **バックエンド**: Rust (Axum, notecli) - **対応プラットフォーム**: Windows, macOS, Linux, Android **Frontend ↔ Backend の3つの通信パターン:** ```mermaid sequenceDiagram participant FE as Frontend participant Tauri as Tauri V2 participant RS as Rust Backend participant Ext as External Tool Note over FE,RS: 1. Tauri IPC (同期的リクエスト/レスポンス) FE->>Tauri: invoke("api_get_timeline", {...}) Tauri->>RS: Rust command RS-->>FE: Response Note over FE,RS: 2. Tauri Event System (非同期・双方向) RS->>FE: emit("stream-event", payload) RS->>FE: emit("query-delta", QueryDelta) — typed via tauri-specta FE->>RS: emit("nd:query-request") RS-->>FE: emit("nd:query-response-{id}") Note over Ext,RS: 3. Localhost HTTP API (外部ツール連携) Ext->>RS: HTTP GET /api/{host}/timeline/home RS-->>Ext: JSON Response Ext->>RS: SSE /api/events RS-->>Ext: リアルタイムイベント受信 ``` --- ### notedeck(GUI アプリ) #### A-1. Query Bridge(Rust ↔ フロントエンド双方向クエリ) **場所**: `query_bridge.rs` + `utils/apiBridge.ts` 外部 HTTP リクエスト → Rust → Tauri Event → Vue/Pinia → Tauri Event → Rust → HTTP レスポンス。 フロントエンドのリアクティブ状態(デッキカラム、コマンド一覧等)を外部ツールから直接取得可能。 --- #### A-2. マルチウィンドウ・デッキ(クロスウィンドウ D&D) **場所**: `useDeckWindow.ts` + `useColumnDrag.ts` - カラムを別ウィンドウにポップアウト - ウィンドウ間でカラムをドラッグ移動 - マルチモニター対応のレイアウト保存・復元 - ウィンドウ閉鎖時の自動カラム回収 --- #### A-2b. PiP ウィンドウ(常前面フローティングカラム) **場所**: `usePipWindow.ts` + `src/views/PipPage.vue` - デッキのカラムを常前面フローティングウィンドウとして切り離し - 375×700px(リサイズ可能)、`alwaysOnTop`、複数同時起動(動的ラベル `pip-*`) - コマンドパレット / タイトルバー / カラムメニューの 3 経路で起動 --- #### A-3. HTTP API(notecli ルーター共有) **場所**: `http_server.rs`(notedeck)+ `http_server.rs`(notecli) notecli の `build_core_routes()` でコア API 16ルートを共有し、notedeck 固有ルート(deck, commands, image proxy, OpenAPI docs)を `.merge()` で追加。SSE イベントストリーム、Scalar UI ドキュメント付き。 --- #### A-4. ストリーミング → マルチ配信ブリッジ **場所**: `streaming.rs` + `EventBus` + `query_runtime.rs` WebSocket 受信 → 1箇所で4つの出力先に同時配信: 1. OS ネイティブ通知(`tauri-plugin-notification`) 2. WebView イベント(`app.emit("stream-event")`。`stream-status` サブイベントが接続状態の唯一の真実源) 3. SSE(外部 HTTP クライアント向け) 4. **Query Runtime の Read Model**(後述 A-11)— stream-note/mention/notification/chat-message などを ingest し、`query-delta` を typed event として emit ストリーミングで受信したノートは `db.cache_note()` で SQLite に非同期保存。 #### A-4b. Subscription suspend / resume **場所**: `notecli::streaming` + `commands::stream_suspend_subscription` / `stream_resume_subscription` WebSocket 接続は維持したまま、subscription 単位で channel から **WS unsubscribe** する。`info.active = false` にして受信ループから除外し、`WsCommand::Unsubscribe` を WS に送出。Misskey 側もそのチャンネルへの送出を停止するため、suspend 中の noteUpdated は再送されない(resume 時に過去分は届かない)。WS 自体の再接続時は active な subscription と subNote が replay され、subNote はセッション内 dedup で二重送信(Misskey 側 subNote は refcount 式で冪等でない)を防ぐ。 `live → warm → suspended` は `createQuerySubscription.setRuntimeState` → `query_set_runtime_state` が駆動する。warm → suspended への escalation(既定 8s、`WARM_GRACE`)は Rust 側 QueryRuntime の warm timer が行う。JS 側 `MisskeyStream` は接続状態の funnel(`setStatus`)と 5s の offline grace debounce のみを担う。 **suspend させる条件**(`useNoteColumn` の `[isVisible, isLive]` watcher): | isVisible | isLive | 挙動 | |-----------|--------|------| | false | * | warm → 8s 後 suspend (Rust 側 unsub) | | true | false | **subscription は live 維持**、`streamingBatch` だけ pause | | true | true | live、`onResume` で sinceId 差分 fetch、batch flush 再開 | **重要**: 「可視・予算外」だけでは suspend しない。これをやると見えているのに reaction が永続的に取り逃される(Misskey は再送しない)。 **main チャンネルは suspend / unsubscribe の対象外** (#984): Misskey の `main` は `shouldShare` チャンネルで **1 WS 接続に 1 本しか張れない**(2 本目の connect はサーバーが黙って無視する)。通知・メンション・OS 通知・未読バッジがすべてこの 1 本にぶら下がるため、main の寿命はカラム(query)ではなく**アカウントセッション**に属する。`StreamingManager` は main をアカウント単位で dedup し、main への `unsubscribe` / `suspend_subscription` を no-op にする。解放経路は `disconnect` のみ。 #### A-4c. Reaction freshness guarantees **場所**: `useNoteCapture` + `noteStore.applyUpdate` reaction / poll vote / delete は **2 経路冗長化** で取り逃しを最小化する。 1. **Channel auto-capture**: `stream-note-updated` が timeline / antenna / channel / role / list / mentions の subscription 経由で届く。新着ノートとセットで運ばれるので一括カバーできるが、subscription が suspend されると止まる 2. **Per-note capture (`subNote` / `unsubNote`)**: 個別 noteId に対する独立した WS subscription。channel と無関係なので、channel が suspend されてもそのノートの noteUpdated は受信し続ける。WS 再接続時は capture 中の noteId が replay され、セッション内 dedup で二重送信を防ぐ `useNoteCapture` は表示中の上位 `noteCaptureMax`(既定 80–150)件をすべて自動 subNote する。channel と capture の **両方が同じイベントを fire する** ため、`noteStore.applyUpdate` は `(noteId, type, userId, reaction, choice)` を sig としてキー化し、1.5s 窓で重複を弾く。 **カバー範囲:** | 状態 | 新着ノート | 既存ノートの reaction | |------|-----------|---------------------| | channel alive | ✅ channel | ✅ channel + capture | | channel suspended(不可視 >8s) | ❌ どちらも届かない | ✅ capture のみ | | ノートが noteCaptureMax 超過位置にスクロール | — | ❌ capture 対象外 | つまり **新着ノートを取り逃さない保証は無い**(channel suspend 時)。reaction の鮮度だけは復帰時に古くならない。新着ノートを死守したい場合は warm timer を伸ばすか、`onResume` で先頭ページを refetch するかの別アプローチが必要。 --- #### A-5. 3層画像プロキシキャッシュ **場所**: `image_cache.rs` + `/proxy/image` ```mermaid graph LR Browser["ブラウザ (ETag)"] --> Axum["/proxy/image"] Axum --> Mem["メモリ LRU (32MB)"] Axum --> Disk["ディスクキャッシュ
(7日 TTL, 20MB max)"] Axum --> Upstream["アップストリーム
(ストリーミング配信)"] ``` CSP で外部画像を直接ロードせず、Rust 側のプロキシを経由。ETag/304 対応、インフライト重複排除、同時フェッチ30件制限。 --- #### A-6. OGP プラグインシステム(15プラットフォーム対応) **場所**: `ogp/plugins/` (Twitter, YouTube, Pixiv, Amazon, ニコニコ 等) URL ごとに専用パーサーが起動し、汎用 OG タグ解析より高精度なプレビューを生成。 3段フォールバック: プラグイン → サーバー API → 直接 HTML パース。 --- #### A-7. グローバルショートカット + ボスキー + システムトレイ **場所**: `lib.rs`(デスクトップ専用 `#[cfg(not(mobile))]`) - `Ctrl+Shift+B`: ボスキー(瞬時にウィンドウ非表示) - `Ctrl+Alt+N`: クイックノート(ウィンドウ表示 + 投稿フォーム起動) - トレイアイコン: 左クリックで表示切替、右クリックメニュー - 閉じるボタン: トレイに隠す(終了しない) --- #### A-8. オフラインファースト(読み取り専用) **場所**: `useNoteColumn.ts` + `useColumnSetup.ts` + `DeckTimelineColumn.vue` ```mermaid graph LR subgraph Online["オンライン"] WS["WebSocket"] --> Recv["ノート受信"] --> Save["SQLite 保存"] --> Show1["UI 表示"] end subgraph Offline["オフライン"] Restore["SQLite から復元"] --> Show2["UI 表示"] Write["投稿・リアクション"] --> Block["ブロック"] end subgraph Reconnect["復帰時"] Gap["差分フェッチ"] --> Sync["UI 同期"] end ``` - **オフライン検出**: WebSocket 切断 (`disconnected`/`reconnecting`) + API fetch 失敗の両方で即座に検出。接続状態は Rust の `stream-status` イベントだけを信じる(JS 側の楽観的 `connected` は廃止。冪等 connect でも Rust が現在状態を emit する) - **接続状態の台帳**: 生の遷移は `core/streamHealth` に記録し、healthcheck 診断とオフラインバッジ tooltip が「いつからこの状態か」を表示する(#698) - **キャッシュ自動切替**: API 失敗時にキャッシュ済みノートを表示し続ける。スクロールで古いノートも SQLite から読み込み - **書き込みガード**: オフライン時はリアクション・リノート・リプライ・引用・削除・編集・ブックマークをサイレントにブロック - **自動復帰**: WebSocket 再接続成功 or API fetch 成功で `isOffline` が自動解除 - **Android 復帰**: `MainActivity.onResume` → `nd-app-resumed` DOM イベント → `useDeckInit` が `emitDeckResume`(reconnect / observer 張り直し / `reattachQueryDeltaListener` / refetch)を冪等に駆動 - **UI バナー**: 「オフライン — キャッシュを表示中」をカラム上部に表示 **方針**: 書き込みキューイングは行わない。Misskey はリアルタイム性が重要な SNS であり、オフライン時に蓄積した操作を後から送信しても文脈が失われる。 --- #### A-9. フロントエンド層 **Pinia Stores**(正本: `src/stores/`。下表は主要なもの): | Store | 役割 | |-------|------| | `accounts` | マルチアカウント管理(ゲスト・ログアウト済みアカウント含む) | | `deck` | デッキ・カラム・レイアウト・プロファイル管理(カラム種別は `BuiltinColumnType` が正本) | | `streaming` | WebSocket接続状態・購読管理 | | `notes` | ノートのキャッシュ・正規化 | | `emojis` | カスタム絵文字管理 | | `servers` | 接続先サーバー情報 | | `theme` | テーマ設定 | | `ui` | UI状態 | | `keybinds` | キーバインド設定 | | `windows` | マルチウィンドウ管理 | | `plugins` | AiScriptプラグイン | | `pinnedReactions` | ピン留めリアクション | | `recentEmojis` | 最近使った絵文字 | | `confirm` | 確認ダイアログ管理 | | `deckProfile` | デッキプロファイル管理 | | `deckWallpaper` | デッキ壁紙設定 | | `performance` | パフォーマンス設定 | | `themeFileSync` | テーマファイル同期 | | `toast` | トースト通知 | | `offlineMode` | オフラインモード状態管理 | | `realtimeMode` | リアルタイムモード状態管理 | **Server Adapter パターン** (`types.ts` → `registry.ts` → `misskey/`): Misskey 本家および Misskey を名乗り続けるフォークに共通インターフェースで対応。 --- #### A-10. タイムライン DOM 管理 **場所**: `NoteScroller.vue` + `useNoteList.ts` + `useStreamingBatch.ts` `@tanstack/vue-virtual` による仮想スクロールで、viewport + overscan 分のみ DOM に描画する。 **仮想スクロール:** | 設定 | 値 | 説明 | |------|-----|------| | `noteListMax` | 200(デフォルト) | データ配列の上限(`performanceStore` で設定可能、50〜1000) | | `overscan` | 8 | viewport 外に余分に描画する件数 | | `estimateSize` | 動的 | 実測値の移動平均(20件ごとに更新) | - `NoteScroller.vue` が `useVirtualizer` で仮想化。実 DOM は 30-50 件程度に抑制 - `measureElement` + ResizeObserver で可変高さ(テキストのみ 80px〜画像付き 400px+)を自動追跡 **現行の制約と次の一手:** - 現行は `orderedIds` を column ごとに持ち、描画時に `notes.resolve(ids)` で `NormalizedNote[]` を再構築する - `notes` store は `triggerRef(noteMap)` ベースのため、局所更新でも列単位の再評価が起きやすい - Misskey らしいリッチ表示を維持したまま持続的なヌルヌルさを上げるには、**Timeline Store (`ids[]`) と Entity Store (`noteId -> ref`) の分離**が次の有力候補 - 高流量対策として、Rust 側で stream event を batch / materialize し、フロントは snapshot / delta を受ける構成(A-11 Query Runtime)の **インフラは導入済み**。WebView カラム側を queryId 購読に置き換えるのは段階移行中 - `near-end` イベントで末尾到達を検知し loadMore を発火 - `scrollToIndex` expose でキーボードナビゲーション(j/k)に対応 **アニメーション:** `` は使わず、データレイヤーでの ID マーキング + CSS `@keyframes` で新着ノートの slide-in アニメーションを実現。位置指定に `translate` プロパティ、アニメーションに `transform` プロパティを使い、独立プロパティとして干渉なく動作する。Vue Vapor Mode 互換。 **バッファリング:** - `useStreamingBatch` は RAF バッファリング + pending 2段階で高頻度更新を1フレームにまとめる - 超過分は末尾から削除。削除されたノートは SQLite に保存済みのため再取得可能 --- #### A-11. Rust Query Runtime + Read Model(インフラ整備済み・段階移行中) **場所**: `src-tauri/src/query_runtime.rs` + `src/adapters/misskey/query.ts`(`createQuerySubscription`) JS カラムが直接 `MisskeyStream` を握る "column-centric" モデルから、Rust 側で query 単位に subscription を集約・materialize し、WebView は snapshot / delta を購読するだけの "Rust Query Runtime" モデルへ段階移行する。 **QueryKey:** 「同じ stream を共有できる query」を表す正規化キー。 | kind | パラメータ | |------|-----------| | `timeline` | accountId / timelineType (home/local/global/hybrid/list) / listId? | | `antenna` | accountId / antennaId | | `channel` | accountId / channelId | | `role` | accountId / roleId | | `mentions` | accountId(main 経由) | | `notifications` | accountId(main 経由) | | `chatUser` | accountId / otherId | | `chatRoom` | accountId / roomId | canonical key(serde JSON)で同一 query を dedup し、`subscriber_count` を持つ。 **コマンド:** - `query_subscribe_{timeline,antenna,channel,role,mentions,notifications,chat_user,chat_room}` — `connect → open → attach_stream_subscription` を 1 IPC で行い `QuerySnapshot` を返す。ただし mentions / notifications は main 共有のため `attach_shared_stream_subscription`(snapshot にだけ subscription id を載せ、配送マップには登録しない)を使う - `query_open(key)` — stream は張らず query レコードだけ作る(read-only 用途) - `query_set_runtime_state(queryId, state)` — `live | warm | suspended`。live ↔ suspended 遷移時は対応する subscription も resume / suspend - `query_close(queryId)` — refcount-- し 0 になったら stream も unsubscribe - `query_get_snapshot(queryId)` — メタデータ - `query_get_read_model_snapshot(queryId, limit?)` — 初期 snapshot(item_ids + revision) **Read Model: id-only design** ```text QueryEntry { recent_ids: VecDeque, // 上限 200, newest-first id_set: HashSet, // O(1) dedupe lookup revision: u64, runtime_state: Live | Warm | Suspended, source_subscription_id: Option, } ``` note 本体は保持せず、id 列だけを順序付きで持つ。理由: 1. **二重化回避**: note 本体は JS 側 noteStore (`src/stores/notes.ts`) が唯一の真実。Rust 側に複製を持たない。 2. **dedupe 専用**: insert/delete 時の同一 id 検出を `id_set` で O(1) に。 3. **Suspended で全クリア**: `set_runtime_state(Suspended)` 遷移時に `recent_ids` / `id_set` / `pending` を破棄。`apply()` も Suspended 中は gate される。Live 復帰時は JS 側 noteStore + 各カラムの orderedIds で表示維持、新規 delta のみ流入する。 **delta は note 本体を含む**: pending.inserts / QueryDelta.inserts は依然として `Vec` で note 本体を JS に流す(16ms debounce window でしか保持されない短期バッファ)。JS 側はこれを noteStore に put する。 `StreamChange::from_event` が以下の stream-* を `Insert(item)` / `Delete(id)` に正規化し `apply()` で entry に反映: - `stream-note` → `payload.note` - `stream-chat-message` → `payload.message` - `stream-note-updated` (updateType = `deleted`) → `payload.noteId` を削除 - `stream-chat-message-deleted` → `payload.messageId` を削除 **main 由来イベントは subscription_id で引かない** (#984): `stream-notification` / `stream-mention` は `ingest_stream_event` の冒頭で **(account_id, 種別) → QueryKey** に解決する(`NoteCaptureUpdated` と同じ「アカウント単位イベント」の型)。main はアカウント単位 1 本の共有購読で、mentions / notifications の複数 query がぶら下がるため、`query_ids_by_subscription` の 1:1 マップでは配れない。この経路は attach 不要 — query が開いてさえいれば届く。 **Delta emit:** `QueryDelta { queryId, revision, inserts, deletes }` を `tauri-specta` の typed event(`#[derive(Event)]`)として emit。bindings.ts に `events.queryDelta` として export される。`mount_events()` を `setup` 内で呼んで registry を登録している。 **WebView 側(`createQuerySubscription` — `src/adapters/misskey/query.ts`):** - `open()` で query を開いて queryId を取得。起動直後の失敗は Equal Jitter バックオフ(1s → 上限 30s)で成功するまで再試行 - 開通時に `queryRegistry.registerQuery` へ refcount 付きで登録(AiScript プラグインの fan-out 用) - queryDelta は各購読が個別に listen せず、`core/queryDeltaBus` の単一 Tauri リスナーに多重化(`onQueryDelta`)。復帰時は `reattachQueryDeltaListener()` で張り直す - `queryId` 一致 + 新しい revision のみ増分適用(delta.inserts は note 本体を含むので消費側が noteStore に put) - `setRuntimeState()` が `query_set_runtime_state` を呼び、Rust 側 subscription の suspend/resume を駆動 - `dispose()` で `unregisterQuery` + `query_close` を呼び refcount を返す **現状(2026-04-27 時点):** | 項目 | 状態 | |------|------| | stream subscription suspend/resume | **稼働中**(`commands::stream_*_subscription`) | | live/warm/suspended 駆動 | **稼働中**(`createQuerySubscription.setRuntimeState` → `query_set_runtime_state`。warm → suspend escalation は Rust 側 `WARM_GRACE`=8s) | | QueryRuntime レジストリ + QueryKey/State | **稼働中** | | Read Model materialize(note/mention/notification/chat) | **稼働中** | | QueryDelta typed event(inserts / deletes / updates) | **稼働中** | | createQuerySubscription(`adapters/misskey/query.ts`) | **稼働中** | | Mentions / Chat (user/room) カラムの queryId 化 | **完了** | | Timeline-family(timeline / list / antenna / channel / role)の queryId 化 | **完了** | | 残課題: 旧 `MisskeyStream.subscribe*` を呼んでいるフォールバック経路の縮小 | accountId 不在 / cross-account / guest 用に温存 | これで主要なノート列カラム購読は Rust QueryRuntime 経由に切り替え済み。`createQuerySubscription` が `delta.inserts / deletes / updates` を `enqueue / onNoteUpdated` にブリッジするため、`useNoteColumn` 側の API は変更なしで段階移行できた。後述の Session Layer S-3 を参照。 --- ### notecli(コアライブラリ) notecli は notedeck のコア基盤となる Rust クレートであり、**スタンドアロン CLI** と **ライブラリ** の二重の役割を持つ。 #### B-1. デュアルパーパス・クレート設計 | モード | エントリポイント | FrontendEmitter | HTTP サーバー | |--------|------------------|-----------------|---------------| | **CLI** | `main.rs` (clap) | `NoopEmitter` | なし | | **デーモン** | `main.rs --daemon` | `EventBusEmitter` | Axum (16ルート) | | **notedeck 組込** | `lib.rs` (ライブラリ) | `TauriEmitter` (notedeck側) | 拡張版 Axum (notecli の `build_core_routes()` + notedeck 固有ルート) | 同じビジネスロジック(API呼び出し、DB操作、ストリーミング)が CLI・デーモン・GUI のすべてで共有される。 --- #### B-2. FrontendEmitter トレイトパターン ストリーミング(WebSocket)からのイベント配信を実行環境ごとに分離する Strategy パターン: - **CLI**: `NoopEmitter`(何もしない) - **デーモン**: `EventBusEmitter`(broadcast channel → SSE) - **Tauri GUI**: `TauriEmitter`(Tauri Event System → Vue) --- #### B-3. Raw → Normalized モデル変換 Misskey API レスポンスはフォークによってフィールドが異なる問題を2層モデルで解決: - 既知フィールドは型安全にデシリアライズ - 未知フィールド(フォーク固有)は `extra: HashMap` に自動収集 - `normalize()` で統一的な `NormalizedNote` に変換 - 新フォーク固有フィールド追加時に **コード変更不要** セキュリティ: `Account` の `Drop` 実装で `token.zeroize()` を呼び、メモリ残留リスクを最小化。 --- #### B-4. SQLite + FTS5 + refinery マイグレーション **DB マイグレーション**: refinery による番号付き SQL マイグレーション (`migrations/V1__*.sql`)。`refinery_schema_history` テーブルでバージョンを自動追跡。今後のスキーマ変更は SQL ファイル追加のみで対応可能。 **FTS5 トライグラム検索**: ```sql CREATE VIRTUAL TABLE notes_fts USING fts5( text, content=notes_cache, content_rowid=rowid, tokenize='trigram' ); ``` CJK(日本語・中国語・韓国語)の部分文字列検索に対応。CW も検索対象。 --- #### B-5. プラットフォーム・キーチェーン抽象化 条件付きコンパイルで各 OS ネイティブのキーチェーンに対応: - Android → `AndroidNativeCredentialStore` - macOS/iOS → `IosKeychain::Authenticated` - Windows → `WindowsNativeCredentialStore` - Linux → `LinuxKeyutilsPersistentStore` クレデンシャル解決: キーチェーン → DB フォールバック → 遅延移行(既存ユーザーの自動移行)。 **ゲスト・ログアウト対応**: `get_credentials_or_anon()` でトークンがなければ `(host, "")` を返し、notecli が公開 API にフォールバック。認証必須 API は従来の `get_credentials()` を使用。 --- #### B-6. ストリーミング・マネージャー - **指数バックオフ再接続**: 接続断 → 1秒 → 2秒 → ... → 最大30秒。成功時にバックオフリセット + 全サブスクリプション再送信 - **初回接続失敗も同じ再接続ループに委譲**: `connect` は初回接続に失敗しても Err を返さず Ok を返す(invoke の resolve は接続確立を意味しない)。接続の生死は `stream-status` イベント(connected / reconnecting)でフロントへ伝わる - **TLS ルート証明書**: rustls の native-roots に加え webpki-roots をフォールバックとして同梱(Android は native cert が 0 件で全 wss:// が即死するため) - **メッセージ処理**: `spawn_blocking` で SQLite 書き込みを非同期タスクからオフロード - **ノート自動キャッシュ**: ストリーミング受信ノートを SQLite に非同期保存(オフラインファースト基盤) --- #### B-7. CLI 設計:Unix 哲学の適用 5つの出力フォーマット(Default, JSON, JSONL, IDs, Compact/TSV)でパイプライン処理に対応。 ```bash notecli tl -f compact | fzf | cut -f1 | xargs notecli note notecli tl -f json | jq '.[].text' ``` --- #### B-8. エラーハンドリング: safe_message() パターン 内部情報(SQLite クエリ、ネットワークトレース、キーチェーン詳細)はフロントエンドに露出させない。`Serialize` 実装で `code` + `safe_message` のペアを自動生成。 --- ### Vue Vapor モード移行([#52](https://github.com/notedeck-dev/notedeck/issues/52)) Vue 3.6 で導入予定の Vapor モード(仮想DOMレス)への移行準備が**完了**。 既知の移行ブロッカーはゼロ。Vue 3.6 リリース時にそのまま有効化可能。 **対応済み項目:** - `