nostr-replaceable-event-mutation-overwrite
Nostr置き換え可能イベント(Kind 0プロフィール、Kind 3コンタクト/フォローリスト、Kind 10002リレーリストなど)をクライアントアプリで変更する際の、サイレント データロスを修正します。以下の場合に使用してください:(1) ユーザーをフォローすると、フォローリスト全体が消去される、(2) プロフィールメタデータの更新で既存フィールドが失われる、(3) 新規ブラウザセッションやモバイルログインで初回操作時にデータが失われる、(4) 置き換え可能イベントの変更で古い状態やnullのキャッシュが使用される。根本原因:Nostr置き換え可能イベントは完全置き換え方式(部分更新なし)のため、古い/未読み込みキャッシュに基づいて公開すると、正規バージョンが上書きされます。React、Flutter、またはミューテーション実行時にクエリ状態が読み込まれていない可能性がある同様のリアクティブフレームワークを使用するNostrクライアントに適用されます。
description の原文を見る
Fix silent data loss when mutating Nostr replaceable events (Kind 0 profile, Kind 3 contact/follow list, Kind 10002 relay list, etc.) in client apps. Use when: (1) Following someone wipes the user's entire follow list, (2) Updating profile metadata loses existing fields, (3) Fresh browser session or mobile login causes data loss on first action, (4) Replaceable event mutation uses stale or null cached state. Root cause: Nostr replaceable events are full-replace (no partial update), so publishing based on stale/unloaded cache overwrites the canonical version. Applies to any Nostr client using React, Flutter, or similar reactive frameworks where query state may not be loaded when a mutation fires.
SKILL.md 本文
Nostr 置き換え可能イベント ミューテーション上書き
問題
Nostr の置き換え可能イベント(Kind 0、3、10002 など)は完全置換モデルを使用します: 新しいイベントを公開すると、前のイベントは完全に置き換わります。クライアントが古い、不完全、 またはnull のキャッシュ状態に基づいてミューテーションを公開した場合、リレー上の正規バージョンが サイレントに上書きされ、データロスが発生します。最も一般的なケースは、クライアントが既存の コンタクトリストを読み込む前にユーザーが誰かをフォローしたときのフォローリスト(Kind 3)の 消去です。
コンテキスト / トリガー条件
- ユーザーからの報告「誰かをフォローしたら他のフォローが全て消えた」
- 新規ブラウザセッションまたはモバイルログイン時のフォロー/アンフォロー アクション
- プロフィール更新で既存メタデータフィールドが失われる
- リレーリスト更新で既存リレーが削除される
- UI が キャッシュ状態をミューテーション関数に渡す置き換え可能イベントに対するあらゆるミューテーション
- React Query / TanStack Query の
dataがミューテーション実行時にundefined(クエリがまだ読み込み中) - ミューテーション関数が UI レイヤーからパラメータとして現在のイベントを受け取る
ソリューション
1. ミューテーション内で常に新鮮な状態をフェッチする
UI のキャッシュ/クエリ状態だけに依存しないでください。公開前に、ミューテーション関数内から リレーに直接アクセスして置き換え可能イベントの最新バージョンをフェッチしてください:
// 悪い例: UI キャッシュに依存(null/古い可能性あり)
mutationFn: async ({ targetPubkey, currentContactList }) => {
const currentTags = currentContactList?.tags || []; // null -> [] -> データロス!
// ... 新しいフォローのみで公開
}
// 良い例: ミューテーション前にリレーから新鮮な状態をフェッチ
mutationFn: async ({ targetPubkey, currentContactList }) => {
let bestContactList = currentContactList;
try {
const relayEvents = await nostr.query([
{ kinds: [3], authors: [userPubkey], limit: 1 },
], { signal: AbortSignal.timeout(5000) });
const relayContactList = relayEvents
.sort((a, b) => b.created_at - a.created_at)[0] || null;
if (relayContactList) {
// より多くのデータを持つ方を使用してロスを防止
const relayCount = relayContactList.tags.filter(t => t[0] === 'p').length;
const passedCount = currentContactList?.tags.filter(t => t[0] === 'p').length ?? 0;
if (relayCount >= passedCount) {
bestContactList = relayContactList;
}
}
} catch {
// 渡されたコンタクトリストにフォールバック
}
if (!bestContactList) {
throw new Error('既存データを読み込めません。もう一度お試しください。');
}
// bestContactList をミューテート...
}
2. 「最良の組み合わせ」戦略
リレーのバージョンと UI のキャッシュバージョンを比較し、より多くのデータを持つ方を使用します (より多くのタグ、より多くのフィールドなど)。これにより以下を防止します:
- 古いリレー(UI キャッシュは最近のローカルアクションからより新しい)
- 古い UI キャッシュ(リレーは別のクライアントからの更新を持つ)
- null の UI キャッシュ(新規セッションでクエリが読み込まれていない)
3. 完全な失敗時は公開を拒否する
リレーフェッチも UI キャッシュもデータを提供しない場合は、空の/最小限の置き換え可能イベントを 公開する代わりにエラーをスローしてください。ユーザーフレンドリーなエラーメッセージは常に サイレントデータロスより優れています。
4. 両方向に適用する
このパターンを ALL のミューテーション方向に適用してください(フォロー AND アンフォロー、 リレー追加 AND 削除、プロフィールフィールド更新 AND クリア)。アンフォロー パスはフォロー パスと同じくらい危険です。
検証
- プライベート/シークレット ブラウザウィンドウでアプリを開く
- 複数のフォローを持つアカウントでログイン
- プロフィールに移動し、ページが完全に読み込まれる前に「フォロー」をタップ
- フォロー数が1 増加したことを確認(1 にリセットされていない)
例
// divine-web useFollowUser フックからの実装例
export function useFollowUser() {
const { nostr } = useNostr();
return useMutation({
mutationFn: async ({ targetPubkey, currentContactList }) => {
// ステップ 1: リレーから新鮮な状態をフェッチ
let bestContactList = currentContactList;
try {
const events = await nostr.query([
{ kinds: [3], authors: [user.pubkey], limit: 1 }
], { signal: AbortSignal.timeout(5000) });
const relayList = events.sort((a, b) => b.created_at - a.created_at)[0];
if (relayList) {
const relayFollows = relayList.tags.filter(t => t[0] === 'p').length;
const cachedFollows = currentContactList?.tags.filter(t => t[0] === 'p').length ?? 0;
if (relayFollows >= cachedFollows) bestContactList = relayList;
}
} catch { /* キャッシュにフォールバック */ }
// ステップ 2: データがない場合は拒否
if (!bestContactList) throw new Error('フォローリストを読み込めません');
// ステップ 3: 安全にミューテート
const tags = [...bestContactList.tags, ['p', targetPubkey]];
return publishEvent({ kind: 3, tags, content: bestContactList.content });
}
});
}
注記
- このパターンは ALL の Nostr 置き換え可能イベント Kind に適用されます: Kind 0(プロフィール)、 Kind 3(コンタクト)、Kind 10002(リレーリスト)、Kind 10000(ミュートリスト)、Kind 30000+(アドレス指定可能)
- レース条件は、ネットワークが遅く、ユーザーが素早くタップするモバイルブラウザで最も一般的です
- 確認ダイアログ(「よろしいですか?」など)は同じ古いキャッシュをチェックするため役立ちません。 修正はミューテーション自体の内部に必要です
- リレーフェッチの5秒タイムアウトは、安全性と UX のバランスが取れています
- 追加のセーフティとして、初期クエリの読み込み中はミューテーション ボタンを無効にする ことも検討してください
ライセンス: MPL-2.0(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- divinevideo
- ライセンス
- MPL-2.0
- 最終更新
- 2026/5/12
Source: https://github.com/divinevideo/divine-mobile / ライセンス: MPL-2.0