Merge branch 'fix/blobbi-legacy-new-deduplication' into 'main'

Fix Blobbi legacy/new-format deduplication to prevent infinite duplicates

Closes #247

See merge request soapbox-pub/ditto!198
This commit is contained in:
Chad Curtis
2026-04-21 22:28:00 +00:00
4 changed files with 383 additions and 30 deletions
+106 -5
View File
@@ -9,6 +9,7 @@ import { toast } from '@/hooks/useToast';
import {
KIND_BLOBBI_STATE,
KIND_BLOBBONAUT_PROFILE,
BLOBBI_ECOSYSTEM_NAMESPACE,
BLOBBONAUT_PROFILE_KINDS,
getBlobbonautQueryDValues,
buildMigrationTags,
@@ -22,6 +23,7 @@ import {
parseBlobbiEvent,
parseBlobbonautEvent,
parseStorageTags,
findCanonicalEquivalent,
type BlobbiCompanion,
type BlobbonautProfile,
type StorageItem,
@@ -295,6 +297,38 @@ export function useBlobbiMigration() {
return null;
}, [nostr]);
/**
* Fetch all companions for a user from relays, parse and deduplicate by d-tag.
* Used to find existing canonical equivalents before migrating a legacy Blobbi.
*/
const fetchAllCompanions = useCallback(async (
pubkey: string,
): Promise<BlobbiCompanion[]> => {
const events = await nostr.query([{
kinds: [KIND_BLOBBI_STATE],
authors: [pubkey],
'#b': [BLOBBI_ECOSYSTEM_NAMESPACE],
}]);
// Deduplicate by d-tag (newest wins), same logic as useBlobbisCollection
const eventsByD = new Map<string, NostrEvent>();
for (const event of events.filter(isValidBlobbiEvent)) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
if (!dTag) continue;
const existing = eventsByD.get(dTag);
if (!existing || event.created_at > existing.created_at) {
eventsByD.set(dTag, event);
}
}
const companions: BlobbiCompanion[] = [];
for (const event of eventsByD.values()) {
const parsed = parseBlobbiEvent(event);
if (parsed) companions.push(parsed);
}
return companions;
}, [nostr]);
/**
* Ensure a Blobbi is in canonical format before performing an action.
*
@@ -302,14 +336,19 @@ export function useBlobbiMigration() {
* instead of using potentially stale cache data. This prevents state resets
* caused by publishing over a newer event with stale cached data.
*
* If the companion is legacy, it will be migrated first.
* If the companion is legacy, it checks for an existing canonical equivalent
* (by normalized name) before migrating. This prevents creating duplicate
* canonical events when interacting with a legacy Blobbi multiple times.
*
* Returns the canonical companion to use for the action.
*
* Flow:
* 1. Fetch fresh companion + profile from relays
* 2. Check if Blobbi is legacy
* 3. If legacy: migrate Blobbi
* 4. Return the resolved canonical Blobbi with fresh data
* 3. If legacy: look for existing canonical equivalent by name
* 4. If found: reuse it (no migration needed)
* 5. If not found: migrate to canonical format
* 6. Return the resolved canonical Blobbi with fresh data
*
* All interaction handlers should call this before publishing events.
*/
@@ -332,7 +371,69 @@ export function useBlobbiMigration() {
// Check if the companion needs migration
if (companion.isLegacy) {
console.log('[Blobbi Migration] Legacy companion detected, migrating before action');
console.log('[Blobbi Migration] Legacy companion detected, checking for existing canonical equivalent');
// Check if a canonical equivalent already exists (by migrated_from tag,
// name+base_color, or name-only fallback). This prevents duplicate migrations
// when interacting with a legacy Blobbi that was already migrated.
const allCompanions = await fetchAllCompanions(user.pubkey);
const existing = findCanonicalEquivalent(companion, allCompanions);
if (existing) {
console.log('[Blobbi Migration] Found existing canonical equivalent:', existing.d, '— skipping migration');
// Update profile.has and current_companion to point to the canonical version
// (in case profile still references the legacy d-tag)
const hasLegacyInProfile = profile.has.includes(companion.d);
const hasCanonicalInProfile = profile.has.includes(existing.d);
if (hasLegacyInProfile || !hasCanonicalInProfile) {
const updatedHas = migratePetInHas(profile.has, companion.d, existing.d);
const profileUpdates: Record<string, string | string[]> = { has: updatedHas };
if (profile.currentCompanion === companion.d) {
profileUpdates.current_companion = existing.d;
}
const profileTags = updateBlobbonautTags(profile.allTags, profileUpdates);
const profileEvent = await publishEvent({
kind: KIND_BLOBBONAUT_PROFILE,
content: '',
tags: profileTags,
prev: profile.event,
});
options.updateProfileEvent(profileEvent);
// Update localStorage selection if it was pointing to legacy d
if (options.updateStoredSelectedD) {
options.updateStoredSelectedD(existing.d);
}
// Update the canonical companion in query cache
options.updateCompanionEvent(existing.event);
return {
wasMigrated: false,
companion: existing,
allTags: existing.allTags,
content: existing.event.content,
profileAllTags: profileTags,
profileEvent,
profileStorage: parseStorageTags(profileTags),
};
}
// Profile is already correct, just return the existing canonical companion
return {
wasMigrated: false,
companion: existing,
allTags: existing.allTags,
content: existing.event.content,
profileAllTags: profile.allTags,
profileEvent: profile.event,
profileStorage: profile.storage,
};
}
console.log('[Blobbi Migration] No canonical equivalent found, migrating');
// Use fresh data in migration options
const migrationOptions = { ...options, companion, profile };
@@ -367,7 +468,7 @@ export function useBlobbiMigration() {
profileEvent: profile.event,
profileStorage: profile.storage,
};
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, fetchAllCompanions, migrateLegacyBlobbi, publishEvent]);
return {
/** Migrate a legacy Blobbi to canonical format */
+223 -1
View File
@@ -1528,15 +1528,22 @@ export function buildMigrationTags(
const now = Math.floor(Date.now() / 1000).toString();
// Start with required tags
const legacyD = getTagValue(legacyTags, 'd');
const newTags: string[][] = [
['d', canonicalD],
['b', BLOBBI_ECOSYSTEM_NAMESPACE],
['seed', seed],
];
// Store a back-reference to the legacy d-tag for future equivalence lookups.
// This is additive — current dedup logic uses name-based matching, but future
// versions can use this tag for stronger deterministic equivalence.
if (legacyD) {
newTags.push(['migrated_from', legacyD]);
}
// Preserve name with priority: name tag > legacy d-tag derived > fallback
const nameTag = getTagValue(legacyTags, 'name');
const legacyD = getTagValue(legacyTags, 'd');
const resolvedName = nameTag ?? (legacyD ? deriveNameFromLegacyD(legacyD) : 'Unnamed Blobbi');
newTags.push(['name', resolvedName]);
@@ -1678,6 +1685,221 @@ export function migratePetInHas(
return filtered;
}
// ─── Legacy / Canonical Deduplication ──────────────────────────────────────────
/**
* Normalize a Blobbi name for equivalence comparison.
* Lowercases and trims whitespace so "Jack", "jack", and " Jack " all match.
*/
export function normalizeBlobbiName(name: string): string {
return name.trim().toLowerCase();
}
/**
* Check whether a canonical companion is equivalent to a legacy companion.
*
* Equivalence priority (first match wins):
* 1. **migrated_from exact match**: canonical has a `migrated_from` tag that
* equals the legacy d-tag. This is the strongest signal — it was written
* during migration and is preserved across all subsequent updates.
* 2. **name + base_color match**: same normalized name AND same raw `base_color`
* tag value (both present and equal). Covers older canonical copies that
* were created before the `migrated_from` tag existed.
* 3. **name-only fallback**: same normalized name when the legacy event has no
* explicit `base_color` tag (too bare to compare). This is the weakest tier
* and only applies to genuinely old legacy events with no visual tags.
*/
function isCanonicalEquivalentToLegacy(
canonical: BlobbiCompanion,
legacyD: string,
legacyName: string,
legacyBaseColor: string | undefined,
): boolean {
// Priority 1: migrated_from exact match
const migratedFrom = getTagValue(canonical.event.tags, 'migrated_from');
if (migratedFrom === legacyD) return true;
// Priority 2: name + base_color (both must be present and equal)
const canonicalBaseColor = getTagValue(canonical.event.tags, 'base_color');
if (
normalizeBlobbiName(canonical.name) === legacyName &&
legacyBaseColor !== undefined &&
canonicalBaseColor !== undefined &&
legacyBaseColor.toUpperCase() === canonicalBaseColor.toUpperCase()
) {
return true;
}
// Priority 3: name-only when legacy has no base_color to compare
if (
normalizeBlobbiName(canonical.name) === legacyName &&
legacyBaseColor === undefined
) {
return true;
}
return false;
}
/**
* Filter out legacy companions that have been migrated to canonical format.
*
* A legacy companion is hidden when ALL of the following are true:
* 1. It is a legacy event (companion.isLegacy === true)
* 2. The legacy d-tag is NOT present in profile.has (confirming migration occurred)
* 3. A canonical equivalent exists, determined by (first match wins):
* a. migrated_from exact match on the legacy d-tag
* b. same normalized name + same raw base_color tag
* c. same normalized name (fallback when legacy has no base_color tag)
*
* After legacy filtering, a second pass collapses canonical → canonical
* duplicates that share the same `migrated_from` tag value (i.e. were both
* migrated from the same legacy d-tag due to a race condition). For each
* group the companion with the newest `created_at` is kept; the rest are
* hidden. Canonical companions without a `migrated_from` tag are always
* kept — no heuristic (name, color, etc.) grouping is applied.
*
* @param companions - All parsed companions (legacy + canonical)
* @param profileHas - The profile.has array of owned Blobbi d-tags
* @returns Filtered companions with migrated legacy entries and canonical
* duplicates removed
*/
export function filterMigratedLegacyCompanions(
companions: BlobbiCompanion[],
profileHas: string[],
): BlobbiCompanion[] {
// Collect canonical companions for equivalence checks
const canonicals = companions.filter((c) => !c.isLegacy);
// If there are no canonical companions, nothing to filter
if (canonicals.length === 0) return companions;
const hasSet = new Set(profileHas);
const afterLegacyFilter = companions.filter((c) => {
// Keep all canonical companions unconditionally (deduped in next pass)
if (!c.isLegacy) return true;
// Keep legacy companions that are still in profile.has (not yet migrated)
if (hasSet.has(c.d)) return true;
// Check if any canonical companion is equivalent to this legacy one
const legacyName = normalizeBlobbiName(c.name);
const legacyBaseColor = getTagValue(c.event.tags, 'base_color');
const hasEquivalent = canonicals.some((canonical) =>
isCanonicalEquivalentToLegacy(canonical, c.d, legacyName, legacyBaseColor),
);
// Hide if a canonical equivalent exists, keep otherwise
return !hasEquivalent;
});
// ── Canonical → canonical dedup ──────────────────────────────────────────
// Group canonical companions by `migrated_from` tag. Within each group,
// keep only the newest event (highest created_at). Canonicals without the
// tag are never grouped — they pass through untouched.
const canonicalWinners = collapseCanonicalDuplicates(
afterLegacyFilter.filter((c) => !c.isLegacy),
);
const winnerDs = new Set(canonicalWinners.map((c) => c.d));
return afterLegacyFilter.filter((c) => {
// Legacy companions already survived the first pass — keep them
if (c.isLegacy) return true;
// Canonical companions must be in the winner set
return winnerDs.has(c.d);
});
}
/**
* Collapse canonical companions that were duplicated by a migration race.
*
* Two canonical companions are considered duplicates of the same logical
* Blobbi if and only if both carry a `migrated_from` tag with the same
* value. For each such group the companion with the newest `created_at`
* is kept; ties are broken by d-tag lexicographic order (deterministic).
*
* Canonical companions *without* a `migrated_from` tag are always kept —
* no heuristic grouping (name, color, etc.) is applied.
*/
function collapseCanonicalDuplicates(
canonicals: BlobbiCompanion[],
): BlobbiCompanion[] {
// Companions without migrated_from — always kept
const ungrouped: BlobbiCompanion[] = [];
// Group canonical companions by migrated_from value
const groups = new Map<string, BlobbiCompanion[]>();
for (const c of canonicals) {
const migratedFrom = getTagValue(c.event.tags, 'migrated_from');
if (!migratedFrom) {
ungrouped.push(c);
continue;
}
const group = groups.get(migratedFrom);
if (group) {
group.push(c);
} else {
groups.set(migratedFrom, [c]);
}
}
// Pick the winner from each group: newest created_at, tie-break on d-tag
const winners: BlobbiCompanion[] = [...ungrouped];
for (const group of groups.values()) {
let best = group[0];
for (let i = 1; i < group.length; i++) {
const c = group[i];
if (
c.event.created_at > best.event.created_at ||
(c.event.created_at === best.event.created_at && c.d > best.d)
) {
best = c;
}
}
winners.push(best);
}
return winners;
}
/**
* Find an existing canonical companion that is equivalent to a legacy companion.
*
* Used by the migration guard to avoid creating duplicate canonical events.
* Uses the same equivalence priority as `filterMigratedLegacyCompanions`:
* 1. migrated_from exact match (strongest)
* 2. same normalized name + same raw base_color tag
* 3. same normalized name when legacy has no base_color (weakest)
*
* When multiple canonical companions match, the one with the newest
* created_at is returned (most up-to-date state).
*
* @param legacy - The legacy companion to find an equivalent for
* @param companions - All parsed companions
* @returns The best matching canonical companion, or undefined
*/
export function findCanonicalEquivalent(
legacy: BlobbiCompanion,
companions: BlobbiCompanion[],
): BlobbiCompanion | undefined {
const legacyName = normalizeBlobbiName(legacy.name);
const legacyBaseColor = getTagValue(legacy.event.tags, 'base_color');
let best: BlobbiCompanion | undefined;
for (const c of companions) {
if (c.isLegacy) continue;
if (!isCanonicalEquivalentToLegacy(c, legacy.d, legacyName, legacyBaseColor)) continue;
// Among multiple matches, prefer the newest (most up-to-date state)
if (!best || c.event.created_at > best.event.created_at) {
best = c;
}
}
return best;
}
// ─── LocalStorage Cache Types ─────────────────────────────────────────────────
export interface BlobbiBootCache {
+21 -7
View File
@@ -13,7 +13,7 @@ import { useBlobbiMigration } from '@/blobbi/core/hooks/useBlobbiMigration';
import { useBlobbiUseInventoryItem } from '@/blobbi/actions/hooks/useBlobbiUseInventoryItem';
import { isActionVisibleForStage, type InventoryAction, type BlobbiAction } from '@/blobbi/actions/lib/blobbi-action-utils';
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
import { KIND_BLOBBI_STATE, KIND_BLOBBONAUT_PROFILE, updateBlobbiTags, updateBlobbonautTags } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, KIND_BLOBBONAUT_PROFILE, updateBlobbiTags, updateBlobbonautTags, filterMigratedLegacyCompanions } from '@/blobbi/core/lib/blobbi';
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
@@ -61,25 +61,39 @@ function getSelectedBlobbiKey(pubkey: string): string {
/** Mini Blobbi widget with live stats and quick actions. */
export function BlobbiWidget() {
const { user } = useCurrentUser();
const { companions, companionsByD, isLoading, updateCompanionEvent } = useBlobbisCollection();
const { companions, isLoading, updateCompanionEvent } = useBlobbisCollection();
const { profile, updateProfileEvent, invalidate: invalidateProfile } = useBlobbonautProfile();
const { ensureCanonicalBlobbiBeforeAction } = useBlobbiMigration();
const { mutateAsync: publishEvent } = useNostrPublish();
// Filter out legacy companions that have been migrated to canonical format
const filteredCompanions = useMemo(() => {
if (!profile) return companions;
return filterMigratedLegacyCompanions(companions, profile.has);
}, [companions, profile]);
const filteredCompanionsByD = useMemo(() => {
const record: Record<string, BlobbiCompanion> = {};
for (const c of filteredCompanions) {
record[c.d] = c;
}
return record;
}, [filteredCompanions]);
// Match BlobbiPage's selection logic: localStorage > profile.has > first companion
const localStorageKey = user?.pubkey ? getSelectedBlobbiKey(user.pubkey) : 'blobbi:selected:d:none';
const [storedSelectedD, setStoredSelectedD] = useLocalStorage<string | null>(localStorageKey, null);
const companion = useMemo<BlobbiCompanion | null>(() => {
if (!companions || companions.length === 0) return null;
if (storedSelectedD && companionsByD[storedSelectedD]) return companionsByD[storedSelectedD];
if (!filteredCompanions || filteredCompanions.length === 0) return null;
if (storedSelectedD && filteredCompanionsByD[storedSelectedD]) return filteredCompanionsByD[storedSelectedD];
if (profile) {
for (const d of profile.has) {
if (companionsByD[d]) return companionsByD[d];
if (filteredCompanionsByD[d]) return filteredCompanionsByD[d];
}
}
return companions[0];
}, [companions, companionsByD, storedSelectedD, profile]);
return filteredCompanions[0];
}, [filteredCompanions, filteredCompanionsByD, storedSelectedD, profile]);
// Zero-arg wrapper for ensureCanonical (same pattern as BlobbiPage)
const ensureCanonicalBeforeAction = useCallback(async () => {
+33 -17
View File
@@ -39,6 +39,7 @@ import {
KIND_BLOBBONAUT_PROFILE,
updateBlobbiTags,
updateBlobbonautTags,
filterMigratedLegacyCompanions,
type BlobbiCompanion,
type BlobbonautProfile,
type StorageItem,
@@ -201,7 +202,6 @@ function BlobbiContent() {
// No dList needed — useBlobbisCollection() without args queries by author + ecosystem tag.
// This ensures blobbis are never invisible due to a stale profile.has[] list.
const {
companionsByD,
companions,
isLoading: collectionLoading,
isFetching: collectionFetching,
@@ -209,6 +209,22 @@ function BlobbiContent() {
updateCompanionEvent,
} = useBlobbisCollection();
// STEP 2: Filter out legacy companions that have been migrated to canonical format.
// A legacy Blobbi is hidden when a canonical Blobbi with the same name exists AND
// the legacy d-tag is no longer in profile.has (confirming migration occurred).
const filteredCompanions = useMemo(() => {
if (!profile) return companions;
return filterMigratedLegacyCompanions(companions, profile.has);
}, [companions, profile]);
const filteredCompanionsByD = useMemo(() => {
const record: Record<string, BlobbiCompanion> = {};
for (const c of filteredCompanions) {
record[c.d] = c;
}
return record;
}, [filteredCompanions]);
// STEP 5: localStorage for UI selection (user-scoped key)
const localStorageKey = user?.pubkey ? getSelectedBlobbiKey(user.pubkey) : 'blobbi:selected:d:none';
const [storedSelectedD, setStoredSelectedD] = useLocalStorage<string | null>(localStorageKey, null);
@@ -225,33 +241,33 @@ function BlobbiContent() {
// CRITICAL: Default selection must NEVER overwrite localStorage.
// User selection persists only via handleSelectBlobbi, not via this computed value.
const selectedD = useMemo(() => {
// Priority 1: localStorage selection (if it exists in loaded collection)
// Priority 1: localStorage selection (if it exists in filtered collection)
// USER SELECTION ALWAYS WINS - this is the authoritative source
if (storedSelectedD && companionsByD[storedSelectedD]) {
if (storedSelectedD && filteredCompanionsByD[storedSelectedD]) {
if (DEBUG_BLOBBI) {
console.log('[BlobbiPage] selectedD: using localStorage selection:', storedSelectedD);
}
return storedSelectedD;
}
// Priority 2: First item from profile.has that exists in companionsByD
// Priority 2: First item from profile.has that exists in filtered collection
// This preserves the user's ordering preference from their profile
if (profile) {
for (const d of profile.has) {
if (companionsByD[d]) {
if (filteredCompanionsByD[d]) {
if (DEBUG_BLOBBI) {
console.log('[BlobbiPage] selectedD: using default from profile.has:', d,
'(storedSelectedD was:', storedSelectedD,
storedSelectedD ? (companionsByD[storedSelectedD] ? 'exists' : 'NOT in companionsByD') : 'null', ')');
storedSelectedD ? (filteredCompanionsByD[storedSelectedD] ? 'exists' : 'NOT in filteredCompanionsByD') : 'null', ')');
}
return d;
}
}
}
// Priority 3: First companion in the collection (covers blobbis not in profile.has)
if (companions.length > 0) {
const firstD = companions[0].d;
// Priority 3: First companion in the filtered collection
if (filteredCompanions.length > 0) {
const firstD = filteredCompanions[0].d;
if (DEBUG_BLOBBI) {
console.log('[BlobbiPage] selectedD: using first companion from collection:', firstD);
}
@@ -263,18 +279,18 @@ function BlobbiContent() {
console.log('[BlobbiPage] selectedD: no valid selection available');
}
return undefined;
}, [profile, storedSelectedD, companionsByD, companions]);
}, [profile, storedSelectedD, filteredCompanionsByD, filteredCompanions]);
// NOTE: We intentionally do NOT auto-save the computed selectedD to localStorage.
// This prevents the default selection from overwriting user selections during:
// - WebSocket updates
// - Query refetches
// - Race conditions where storedSelectedD is not yet in companionsByD
// - Race conditions where storedSelectedD is not yet in filteredCompanionsByD
//
// User selections are only persisted via handleSelectBlobbi (line ~232).
// Get the selected companion from the collection
const companion = selectedD ? companionsByD[selectedD] ?? null : null;
// Get the selected companion from the filtered collection
const companion = selectedD ? filteredCompanionsByD[selectedD] ?? null : null;
// Debug log to confirm which Blobbi is rendered (dev only)
useEffect(() => {
@@ -698,12 +714,12 @@ function BlobbiContent() {
// ─── CASE G: Companions loaded, but no valid selection ───
// Show selector to pick which pet to display
if (!selectedD && companions.length > 0) {
if (!selectedD && filteredCompanions.length > 0) {
if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: pet selector');
return (
<>
<BlobbiSelectorPage
companions={companions}
companions={filteredCompanions}
onSelect={handleSelectBlobbi}
isLoading={companionFetching}
onAdopt={() => setShowAdoptionFlow(true)}
@@ -735,7 +751,7 @@ function BlobbiContent() {
return (
<>
<BlobbiSelectorPage
companions={companions}
companions={filteredCompanions}
onSelect={handleSelectBlobbi}
isLoading={companionFetching}
onAdopt={() => setShowAdoptionFlow(true)}
@@ -768,7 +784,7 @@ function BlobbiContent() {
return (
<BlobbiDashboard
companion={companion}
companions={companions}
companions={filteredCompanions}
selectedD={selectedD}
onSelectBlobbi={handleSelectBlobbi}
onRest={handleRest}