From d6713ba20e0fdc2ee4b300bfe71e445e045d07f5 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 09:57:19 +0700 Subject: [PATCH] feat(stash): merge multi-device changes instead of clobbering The stash is one replaceable NIP-44 blob, so a naive last-write-wins publish silently dropped whatever another device added. Make it merge. - StashItem.updatedAt (last edit, falls back to addedAt) and a top-level Stash.deleted[] of {id, at} tombstones so deletes aren't resurrected. - export mergeStashes(): union by id, newest stamp wins, tombstones win at/after the last edit; tombstones pruned (180d / 500). - fetchLatestStash() merges the live remote with the list already held (query cache -> closure) and toasts when the other device contributed. - Writes now require a confirmed relay read: assertConfirmed() refuses an unconfirmed base so useNostrPublish can't queue a pre-signed blob that would overwrite a newer edit later. Reads still work offline from cached ciphertext. - StashPage surfaces the refusal via onStashError on every mutation. Docs: NIP.md multi-device merge section; AGENTS.md updated. --- AGENTS.md | 20 ++- NIP.md | 24 +++- src/hooks/useStash.ts | 268 ++++++++++++++++++++++++++++++---------- src/pages/StashPage.tsx | 17 +++ 4 files changed, 263 insertions(+), 66 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 45759fb..153e21e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -830,9 +830,23 @@ session with **no other memory of this project**. So: what they'd PAY), so sellers find it in the **Marketplace** (which already filters `listing_type=wtb`), while the encrypted Wishlist tab keeps the private copy. The old `item.itemType !== 'wtb'` showcase guards were removed - so the same sync/unpublish path covers wants. **Still open:** the multi-device - conflict guard (the stash is still one last-write-wins blob — per-item events - are the real fix). + so the same sync/unpublish path covers wants. + - **Multi-device merge guard (2026-10-01):** the stash is still ONE encrypted + blob, but writes now MERGE instead of clobbering. Items gained an + **`updatedAt`** stamp; deletes record **`{id, at}` tombstones** in a + top-level `deleted[]`; **`mergeStashes()`** (exported from `useStash.ts`) + unions items by id (newest stamp wins) and lets a tombstone win at/after the + last edit. **`fetchLatestStash()`** merges the live remote with the list + already held (React Query cache → hook closure) and toasts "Merged changes + from another device" when the other side contributed. ⚠️ **Writes now require + a CONFIRMED relay read** — `fetchLatestStash()` returns `{stash, confirmed}` + and `assertConfirmed()` throws when no relay answered, so an offline stash + write is **REFUSED rather than queued**: `useNostrPublish` would publish the + pre-signed blob later as-is and could silently overwrite a newer edit made on + another device. Reads still work offline from the cached ciphertext; + `StashPage` surfaces the refusal via `onStashError`. Why not per-item events? + N items = N `nip44.decrypt()` calls per load → N Amber prompts. Schema is in + `NIP.md` → "Private Stash" → "Multi-device merge". - **Offline map tiles (done 2026-09-28):** `lib/tileCache.ts` (Dexie `SkateSpotsTilesDB`, bounded: ≤3000 tiles, ≤500 KB each, oldest-used pruned) + `lib/cachedTileLayer.ts` (an `L.TileLayer` subclass that reads through it), diff --git a/NIP.md b/NIP.md index dee25b3..ec054a1 100644 --- a/NIP.md +++ b/NIP.md @@ -1305,6 +1305,7 @@ When decrypted, the content contains a JSON object with the user's inventory: "willingToTrade": true, "willingToSell": true, "addedAt": 1703001600, + "updatedAt": 1703001600, "visibility": "friends", "itemType": "stash", "rarity": "rare" @@ -1328,10 +1329,31 @@ When decrypted, the content contains a JSON object with the user's inventory: "rarity": null } ], - "updatedAt": 1703002600 + "updatedAt": 1703002600, + "deleted": [{ "id": "", "at": 1703003000 }] } ``` +### Multi-device merge (`updatedAt` + `deleted`) + +The stash is ONE replaceable blob, so a naive last-write-wins publish silently +drops whatever another device added. Clients therefore **merge** instead: + +- Every item carries `updatedAt` (epoch seconds, last edit). Legacy items + without it fall back to `addedAt`. +- Deletes record a tombstone `{ id, at }` in the top-level `deleted` array, so a + delete is not resurrected by another device's stale copy. +- On merge: union items by `id`; on a collision the newest `updatedAt` wins; a + tombstone wins when `at` >= the item's stamp. Tombstones are unioned and + pruned (180 days / 500 entries). +- Writes only proceed against a just-read relay copy (`confirmed`); if the relay + can't be reached the write is **refused** rather than queued, because a queued + blob is published later as-is and could overwrite a newer edit. Reads still + work offline from the cached ciphertext. + +`mergeStashes()` in `src/hooks/useStash.ts`. Both fields are optional, so older +stashes (and other clients) remain readable. + ### Item Types | Value | Description | diff --git a/src/hooks/useStash.ts b/src/hooks/useStash.ts index 99b74fb..7ec87ff 100644 --- a/src/hooks/useStash.ts +++ b/src/hooks/useStash.ts @@ -4,6 +4,7 @@ import { useNostr } from '@nostrify/react'; import { useNostrLogin } from '@nostrify/react/login'; import { useCurrentUser } from './useCurrentUser'; import { useNostrPublish } from './useNostrPublish'; +import { toast } from './useToast'; import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities'; import { cacheStashEvent, getCachedStashEvent } from '@/lib/offlineCache'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -33,17 +34,116 @@ export interface StashItem { rarity?: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; // Physical storage location storageLocation?: 'home' | 'car' | 'skatepark' | 'warehouse' | 'storage_unit' | 'van' | 'friends' | 'parents' | 'wardrobe' | 'backpack' | 'sponsor' | 'other'; + /** + * Last local modification time (epoch seconds). Used to resolve multi-device + * merge conflicts — the newest edit of an item wins. Legacy items don't have + * it; `addedAt` is the fallback (see `itemStamp`). + */ + updatedAt?: number; } export interface Stash { items: StashItem[]; updatedAt: number; + /** + * Tombstones for deleted items. The stash is one replaceable blob, so a plain + * "remove from the array" can be *resurrected* by another device's stale copy + * during a merge. Recording when an item was deleted lets deletes win instead. + * Optional — older stashes simply have none. + */ + deleted?: Array<{ id: string; at: number }>; } // Kind 30001 is NIP-51 "Follow sets" - we use a private encrypted list const STASH_KIND = 30001; const STASH_D_TAG = 'skatespots-stash'; +/** Epoch seconds an item was last touched (legacy items fall back to `addedAt`). */ +function itemStamp(i: StashItem): number { + return i.updatedAt ?? i.addedAt ?? 0; +} + +/** Tombstones older than this are dropped — a device offline longer than this + * could resurrect a very old delete, which is an acceptable trade. */ +const TOMBSTONE_MAX_AGE = 180 * 24 * 60 * 60; +const TOMBSTONE_CAP = 500; + +/** + * Merge two stash snapshots WITHOUT losing data. + * + * Why this exists: the stash is a single replaceable event whose content is the + * WHOLE list, so naive last-write-wins silently drops whatever another device + * added. This is the multi-device conflict guard. It is deliberately a *blob* + * merge, not per-item events: per-item events would mean N decrypts per load, + * and with Amber every decrypt is a popup. + * + * Rules: + * • union by item id — an item only one side knows about is kept; + * • on a collision the newest `updatedAt` (fallback `addedAt`) wins; + * • a tombstone (delete) wins when it is at/after the item's last edit; + * • tombstones are unioned (newest per id) and pruned. + * + * `merged` is true when the remote side contributed something (a new item, a + * newer edit, or a delete) — the caller can tell the user. For one device this + * is always a no-op with `merged === false`. + */ +export function mergeStashes(local: Stash, remote: Stash): { stash: Stash; merged: boolean } { + const localItems = new Map(local.items.map((i) => [i.id, i])); + const remoteItems = new Map(remote.items.map((i) => [i.id, i])); + + const collectTombs = (s: Stash) => { + const m = new Map(); + for (const t of s.deleted ?? []) { + if (!t?.id) continue; + m.set(t.id, Math.max(m.get(t.id) ?? 0, t.at ?? 0)); + } + return m; + }; + const localTombs = collectTombs(local); + const remoteTombs = collectTombs(remote); + const tombs = new Map(localTombs); + for (const [id, at] of remoteTombs) tombs.set(id, Math.max(tombs.get(id) ?? 0, at)); + + const ids = new Set([...localItems.keys(), ...remoteItems.keys()]); + const items: StashItem[] = []; + let merged = false; + + for (const id of ids) { + const l = localItems.get(id); + const r = remoteItems.get(id); + + let winner: StashItem; + if (!l) { + winner = r!; + merged = true; // item only the remote knew about + } else if (!r) { + winner = l; + } else if (itemStamp(r) > itemStamp(l)) { + winner = r; + merged = true; // the other device edited this item more recently + } else { + winner = l; + } + + const tomb = tombs.get(id); + if (tomb !== undefined && tomb >= itemStamp(winner)) { + // Deleted at/after the last edit — keep it deleted. + if (l && remoteTombs.has(id) && !localTombs.has(id)) merged = true; + continue; + } + items.push(winner); + } + + const now = Math.floor(Date.now() / 1000); + const deleted = [...tombs.entries()] + .filter(([, at]) => at > now - TOMBSTONE_MAX_AGE) + .sort((a, b) => b[1] - a[1]) + .slice(0, TOMBSTONE_CAP) + .map(([id, at]) => ({ id, at })); + + return { stash: { items, deleted, updatedAt: now }, merged }; +} + /** * Hook to manage user's private gear stash * Uses NIP-44 encryption to store items privately @@ -143,58 +243,81 @@ export function useStash(options?: { enabled?: boolean }) { * Falls back to the cached query data, then to an empty stash, only if the * live fetch genuinely returns nothing. */ - const fetchLatestStash = useCallback(async (): Promise => { - if (!user?.pubkey || !hasNip44) return { items: [], updatedAt: 0 }; + const fetchLatestStash = useCallback(async (): Promise<{ stash: Stash; confirmed: boolean }> => { + const fallback: Stash = + queryClient.getQueryData(['stash', user?.pubkey ?? '']) ?? + stash ?? { items: [], updatedAt: 0 }; + + if (!user?.pubkey || !hasNip44) return { stash: fallback, confirmed: false }; let nip44Dec: typeof user.signer.nip44; try { nip44Dec = user.signer?.nip44; } catch { nip44Dec = undefined; } if (!nip44Dec) { - // Can't decrypt now — use whatever we already have cached to avoid clobber. - return ( - queryClient.getQueryData(['stash', user.pubkey]) ?? - stash ?? { items: [], updatedAt: 0 } - ); + // Can't decrypt now — return what we have, flagged unconfirmed so the + // caller refuses to write rather than risk a clobber. + return { stash: fallback, confirmed: false }; } + // `confirmed` means a relay actually answered this read. It's the signal the + // callers use to decide whether a write is safe (see assertConfirmed). + let confirmed = false; + let event: NostrEvent | undefined; try { - // Live event first; fall back to the locally cached ciphertext when the - // relays are unreachable (so an offline edit still rebuilds from the real - // list instead of throwing / clobbering). - let event: NostrEvent | undefined; - try { - const events = await nostr.query([{ - kinds: [STASH_KIND], - authors: [user.pubkey], - '#d': [STASH_D_TAG], - limit: 1, - }], { signal: AbortSignal.timeout(10000) }); - event = events[0]; - } catch { - // offline — fall through to the cache - } - if (!event) event = await getCachedStashEvent(user.pubkey); - - if (!event) { - // No remote event: safe to start fresh only if we also have nothing cached. - return ( - queryClient.getQueryData(['stash', user.pubkey]) ?? - stash ?? { items: [], updatedAt: 0 } - ); - } - - cacheStashEvent(user.pubkey, event).catch(() => {}); - const decrypted = await nip44Dec.decrypt(user.pubkey, event.content); - return JSON.parse(decrypted) as Stash; - } catch (err) { - // On any failure, prefer cached data over an empty list so we never wipe. - console.error('[Stash] Failed to re-fetch latest stash before save:', err); - const cached = queryClient.getQueryData(['stash', user.pubkey]) ?? stash; - if (cached) return cached; - // Nothing to fall back on — surface the error instead of risking a clobber. - throw err instanceof Error ? err : new Error('Could not load your stash to update it safely.'); + const events = await nostr.query([{ + kinds: [STASH_KIND], + authors: [user.pubkey], + '#d': [STASH_D_TAG], + limit: 1, + }], { signal: AbortSignal.timeout(10000) }); + event = events[0]; + confirmed = true; // the relay answered (possibly with "nothing") + } catch { + // unreachable — fall through to the cached ciphertext } + if (!event) event = await getCachedStashEvent(user.pubkey); + + if (!event) { + // No stash anywhere: safe to start fresh, but only "confirmed" if the + // relay actually answered. + return { stash: fallback, confirmed }; + } + + cacheStashEvent(user.pubkey, event).catch(() => {}); + const remote = JSON.parse(await nip44Dec.decrypt(user.pubkey, event.content)) as Stash; + + // Multi-device conflict guard. The list we already hold (React Query cache, + // then the hook's closure) can contain items the relay doesn't have yet — + // most commonly edits that were made a moment ago. Merge rather than let the + // remote silently overwrite them; deletes still win via tombstones. On a + // single device this is always a no-op. + const known = queryClient.getQueryData(['stash', user.pubkey]) ?? stash; + if (known) { + const { stash: mergedStash, merged } = mergeStashes(known, remote); + if (merged) { + toast({ + title: 'Merged changes from another device', + description: 'Your stash was edited elsewhere — both sets of changes were kept.', + }); + } + return { stash: mergedStash, confirmed }; + } + return { stash: remote, confirmed }; }, [user, hasNip44, nostr, queryClient, stash]); + /** + * Stash writes rewrite the WHOLE blob, so they must run against a just-read + * relay copy for the merge above to protect other devices' changes. If the + * relay couldn't be reached we refuse the write instead of letting + * `useNostrPublish` queue it: a queued blob is published later as-is, and + * could silently overwrite an edit another device made meanwhile. (Reads still + * work offline — only writes need a connection.) + */ + const assertConfirmed = (confirmed: boolean) => { + if (!confirmed) { + throw new Error("Couldn't reach the relays. Reconnect and try again — saving now could overwrite a newer change from another device."); + } + }; + // Save stash (encrypt and publish) const saveStash = useCallback(async (newStash: Stash) => { if (!user?.pubkey || !hasNip44) { @@ -227,16 +350,20 @@ export function useStash(options?: { enabled?: boolean }) { // Add item mutation const addItem = useMutation({ mutationFn: async (item: Omit) => { - const currentStash = await fetchLatestStash(); + const { stash: currentStash, confirmed } = await fetchLatestStash(); + assertConfirmed(confirmed); + const now = Math.floor(Date.now() / 1000); const newItem: StashItem = { ...item, id: crypto.randomUUID(), - addedAt: Math.floor(Date.now() / 1000), + addedAt: now, + updatedAt: now, }; const newStash: Stash = { items: [...currentStash.items, newItem], - updatedAt: Math.floor(Date.now() / 1000), + deleted: currentStash.deleted, + updatedAt: now, }; await saveStash(newStash); @@ -250,14 +377,18 @@ export function useStash(options?: { enabled?: boolean }) { // Update item mutation const updateItem = useMutation({ mutationFn: async (item: StashItem) => { - const currentStash = await fetchLatestStash(); + const { stash: currentStash, confirmed } = await fetchLatestStash(); + assertConfirmed(confirmed); + const now = Math.floor(Date.now() / 1000); + const stamped: StashItem = { ...item, updatedAt: now }; const newStash: Stash = { - items: currentStash.items.map(i => i.id === item.id ? item : i), - updatedAt: Math.floor(Date.now() / 1000), + items: currentStash.items.map(i => i.id === item.id ? stamped : i), + deleted: currentStash.deleted, + updatedAt: now, }; await saveStash(newStash); - return item; + return stamped; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] }); @@ -267,10 +398,15 @@ export function useStash(options?: { enabled?: boolean }) { // Remove item mutation const removeItem = useMutation({ mutationFn: async (itemId: string) => { - const currentStash = await fetchLatestStash(); + const { stash: currentStash, confirmed } = await fetchLatestStash(); + assertConfirmed(confirmed); + const now = Math.floor(Date.now() / 1000); const newStash: Stash = { items: currentStash.items.filter(i => i.id !== itemId), - updatedAt: Math.floor(Date.now() / 1000), + // Record the delete so a merge with another device's stale copy can't + // resurrect it. + deleted: [...(currentStash.deleted ?? []), { id: itemId, at: now }], + updatedAt: now, }; await saveStash(newStash); @@ -284,7 +420,8 @@ export function useStash(options?: { enabled?: boolean }) { // ONE re-encrypt + publish rather than N. const importItems = useMutation({ mutationFn: async (items: Array>) => { - const currentStash = await fetchLatestStash(); + const { stash: currentStash, confirmed } = await fetchLatestStash(); + assertConfirmed(confirmed); const keyOf = (i: { title: string; category: string; size?: string }) => `${i.title.trim().toLowerCase()}|${i.category}|${(i.size ?? '').trim().toLowerCase()}`; const have = new Set(currentStash.items.map(keyOf)); @@ -296,11 +433,11 @@ export function useStash(options?: { enabled?: boolean }) { const key = keyOf({ title: raw.title, category: raw.category, size: raw.size }); if (have.has(key)) continue; have.add(key); - fresh.push({ ...raw, id: crypto.randomUUID(), addedAt: now }); + fresh.push({ ...raw, id: crypto.randomUUID(), addedAt: now, updatedAt: now }); } if (fresh.length === 0) return { added: 0, skipped: items.length }; - await saveStash({ items: [...currentStash.items, ...fresh], updatedAt: now }); + await saveStash({ items: [...currentStash.items, ...fresh], deleted: currentStash.deleted, updatedAt: now }); return { added: fresh.length, skipped: items.length - fresh.length }; }, onSuccess: () => { @@ -311,16 +448,17 @@ export function useStash(options?: { enabled?: boolean }) { // Bulk actions — apply to many items in ONE re-encrypt + publish. const bulkSetVisibility = useMutation({ mutationFn: async ({ ids, visibility }: { ids: string[]; visibility: StashItem['visibility'] }) => { - const current = await fetchLatestStash(); + const { stash: current, confirmed } = await fetchLatestStash(); + assertConfirmed(confirmed); const now = Math.floor(Date.now() / 1000); const changed: StashItem[] = []; const next = current.items.map((i) => { if (!ids.includes(i.id)) return i; - const updated = { ...i, visibility }; + const updated = { ...i, visibility, updatedAt: now }; changed.push(updated); return updated; }); - await saveStash({ items: next, updatedAt: now }); + await saveStash({ items: next, deleted: current.deleted, updatedAt: now }); return { changed }; }, onSuccess: () => { @@ -330,16 +468,17 @@ export function useStash(options?: { enabled?: boolean }) { const bulkSetForSale = useMutation({ mutationFn: async ({ ids, forSale }: { ids: string[]; forSale: boolean }) => { - const current = await fetchLatestStash(); + const { stash: current, confirmed } = await fetchLatestStash(); + assertConfirmed(confirmed); const now = Math.floor(Date.now() / 1000); const changed: StashItem[] = []; const next = current.items.map((i) => { if (!ids.includes(i.id)) return i; - const updated = { ...i, willingToSell: forSale }; + const updated = { ...i, willingToSell: forSale, updatedAt: now }; changed.push(updated); return updated; }); - await saveStash({ items: next, updatedAt: now }); + await saveStash({ items: next, deleted: current.deleted, updatedAt: now }); return { changed }; }, onSuccess: () => { @@ -349,10 +488,15 @@ export function useStash(options?: { enabled?: boolean }) { const bulkRemove = useMutation({ mutationFn: async (ids: string[]) => { - const current = await fetchLatestStash(); + const { stash: current, confirmed } = await fetchLatestStash(); + assertConfirmed(confirmed); const now = Math.floor(Date.now() / 1000); const removed = current.items.filter((i) => ids.includes(i.id)); - await saveStash({ items: current.items.filter((i) => !ids.includes(i.id)), updatedAt: now }); + await saveStash({ + items: current.items.filter((i) => !ids.includes(i.id)), + deleted: [...(current.deleted ?? []), ...removed.map((i) => ({ id: i.id, at: now }))], + updatedAt: now, + }); return { removed }; }, onSuccess: () => { diff --git a/src/pages/StashPage.tsx b/src/pages/StashPage.tsx index 117b949..e2818f2 100644 --- a/src/pages/StashPage.tsx +++ b/src/pages/StashPage.tsx @@ -738,6 +738,17 @@ export default function StashPage() { ); + // Stash writes must run against a fresh relay copy — the multi-device merge + // depends on it. So an unreachable relay fails the action loudly instead of + // queuing a pre-signed blob that could overwrite another device's newer edit. + const onStashError = (e: unknown) => { + toast({ + title: 'Could not update your stash', + description: e instanceof Error ? e.message : 'Please try again.', + variant: 'destructive', + }); + }; + // Add: save to encrypted stash, then publish a public event if it's public // (gear → showcase/listing; a WANT → a marketplace want ad). const handleAddItem = (item: Omit) => { @@ -747,6 +758,7 @@ export default function StashPage() { syncShowcase(created).catch(() => {}); } }, + onError: onStashError, }); }; @@ -764,6 +776,7 @@ export default function StashPage() { unpublishShowcase(updated.id).catch(() => {}); } }, + onError: onStashError, }); }; @@ -789,6 +802,7 @@ export default function StashPage() { unpublishShowcase(itemId).catch(() => {}); } }, + onError: onStashError, }); setPendingDeleteId(null); }; @@ -826,6 +840,7 @@ export default function StashPage() { toast({ title: `Updated ${changed.length} item${changed.length === 1 ? '' : 's'}` }); exitSelecting(); }, + onError: onStashError, }, ); }; @@ -843,6 +858,7 @@ export default function StashPage() { toast({ title: forSale ? `Marked ${changed.length} for sale` : `Unmarked ${changed.length}` }); exitSelecting(); }, + onError: onStashError, }, ); }; @@ -859,6 +875,7 @@ export default function StashPage() { setBulkDeleteOpen(false); exitSelecting(); }, + onError: onStashError, }); }; const [searchQuery, setSearchQuery] = useState('');