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.
This commit is contained in:
SkateSpots 2026-10-01 09:57:19 +07:00
parent b89136161c
commit d6713ba20e
4 changed files with 263 additions and 66 deletions

View file

@ -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),

24
NIP.md
View file

@ -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": "<uuid of a removed item>", "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 |

View file

@ -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<string, number>();
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<string>([...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<Stash> => {
if (!user?.pubkey || !hasNip44) return { items: [], updatedAt: 0 };
const fetchLatestStash = useCallback(async (): Promise<{ stash: Stash; confirmed: boolean }> => {
const fallback: Stash =
queryClient.getQueryData<Stash>(['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>(['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>(['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>(['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>(['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<StashItem, 'id' | 'addedAt'>) => {
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<Omit<StashItem, 'id' | 'addedAt'>>) => {
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: () => {

View file

@ -738,6 +738,17 @@ export default function StashPage() {
</div>
);
// 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<StashItem, 'id' | 'addedAt'>) => {
@ -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('');