diff --git a/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte b/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte index e984438e7..676f7bed2 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte @@ -24,6 +24,7 @@ import { meImagesStore } from './stores/me-images.svelte'; import { ingestMeImageFile } from './api/me-images'; import { migrateLegacyAvatarIfNeeded } from './migration/legacy-avatar'; + import { repairSilentTwinAvatarRows } from './migration/repair-silent-twin'; import type { MeImage, MeImageKind, MeImagePrimarySlot } from './types'; // Active-space indicator for the intro card. After v40 meImages are @@ -31,12 +32,19 @@ // badge makes that transparent without cluttering the rest of the UI. const activeSpace = $derived(getActiveSpace()); - // One-shot bootstrap: pull the pre-M1 auth.users.image into meImages - // as the avatar primary. Idempotent — see migration/legacy-avatar.ts. + // One-shot bootstraps, both idempotent + localStorage-guarded: + // 1. legacy-avatar: pull pre-M1 auth.users.image into meImages as + // the avatar primary. + // 2. repair-silent-twin: flip rows that the M2.5 setPrimary bug + // left with primaryFor='avatar' back to 'face-ref' so the + // face-ref live-query sees them again (see migration file). onMount(() => { migrateLegacyAvatarIfNeeded().catch((err) => { console.error('[profile] legacy avatar migration failed', err); }); + repairSilentTwinAvatarRows().catch((err) => { + console.error('[profile] silent-twin repair failed', err); + }); }); const allImages$ = useAllMeImages(); diff --git a/apps/mana/apps/web/src/lib/modules/profile/migration/repair-silent-twin.ts b/apps/mana/apps/web/src/lib/modules/profile/migration/repair-silent-twin.ts new file mode 100644 index 000000000..cfdd8205b --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/profile/migration/repair-silent-twin.ts @@ -0,0 +1,78 @@ +/** + * One-shot repair for the M2.5 silent-twin bug (fixed in the commit + * that adds this file). + * + * Before the fix, `setPrimary(id, 'face-ref')` ran two sequential + * `setPrimaryInTx` calls on the same row — one for face-ref, then a + * silent twin for avatar. Because `primaryFor` is a single column the + * second write clobbered the first, so every new face upload ended + * up with `primaryFor='avatar'` and `useImageByPrimary('face-ref')` + * returned null. Wardrobe's TryOn banner stayed forever, Try-On was + * blocked, Picture's reference picker showed nothing. + * + * This bootstrap walks the meImages table once per user (localStorage + * guard) and rewrites rows that are clearly silent-twin victims back + * to `primaryFor='face-ref'`. Legacy-avatar rows (written by the + * pre-M2.5 migration) are distinguishable by their sentinel mediaId + * `legacy-avatar:` and are left alone — `syncAvatarToAuth` still + * uses them as a fallback when no face-ref exists. + * + * Runs at MeImagesView mount alongside `migrateLegacyAvatarIfNeeded`. + * Idempotent after the first pass via the localStorage flag; the + * per-row check is also idempotent so re-running is safe. + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { meImagesTable } from '../collections'; + +export async function repairSilentTwinAvatarRows(): Promise { + const user = authStore.user; + if (!user?.id) return; + + const flagKey = `mana.profile.silentTwinRepair.${user.id}`; + if (typeof localStorage !== 'undefined' && localStorage.getItem(flagKey)) return; + + // Find every row currently holding `primaryFor='avatar'`. That set + // contains two kinds of rows: + // - legacy-avatar rows (mediaId starts with `legacy-avatar:`) — + // legitimate, produced by migrateLegacyAvatarIfNeeded. + // - silent-twin victims (any other mediaId) — user uploads that + // were supposed to be face-ref but got overwritten to avatar. + // Only the second group is repaired. + const avatarHolders = await meImagesTable.where('primaryFor').equals('avatar').toArray(); + const victims = avatarHolders.filter( + (row) => !row.deletedAt && !row.mediaId.startsWith('legacy-avatar:') + ); + + if (victims.length === 0) { + try { + localStorage.setItem(flagKey, '1'); + } catch { + // localStorage blocked — fine, next mount re-checks Dexie. + } + return; + } + + // Transactional rewrite: if multiple victims exist (rare but + // possible after repeated uploads), keep the newest as face-ref and + // clear the primaryFor of the rest. Matches the "one holder per + // slot" invariant the query layer expects. + victims.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')); + const nowIso = new Date().toISOString(); + + await meImagesTable.db.transaction('rw', meImagesTable, async () => { + for (let i = 0; i < victims.length; i++) { + const row = victims[i]; + await meImagesTable.update(row.id, { + primaryFor: i === 0 ? 'face-ref' : null, + updatedAt: nowIso, + }); + } + }); + + try { + localStorage.setItem(flagKey, '1'); + } catch { + // ignore + } +} diff --git a/apps/mana/apps/web/src/lib/modules/profile/stores/me-images.svelte.ts b/apps/mana/apps/web/src/lib/modules/profile/stores/me-images.svelte.ts index 6a49438f5..2c06506c3 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/stores/me-images.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/stores/me-images.svelte.ts @@ -59,10 +59,10 @@ function defaultUsage(override?: Partial): MeImageUsage { } /** - * After any primary-avatar change in the **personal** space, push the - * current holder's publicUrl back to Better Auth so `auth.users.image` - * stays in lockstep. Plan M2.5 — this is the only path by which - * auth.users.image ever gets written from now on. + * After any primary-avatar-relevant change in the **personal** space, + * push the current holder's publicUrl back to Better Auth so + * `auth.users.image` stays in lockstep. Plan M2.5 — this is the only + * path by which auth.users.image ever gets written from now on. * * After v40 (space-scope migration), avatar-primary is per-space; only * the personal-space holder represents the user's global SSO identity. @@ -71,6 +71,16 @@ function defaultUsage(override?: Partial): MeImageUsage { * record — otherwise switching into a brand space and picking a new * avatar would leak into the user's navigation/SSO identity elsewhere. * + * The user's avatar follows their face-ref: "the photo we'd use to + * generate you" is also "the photo we'd put next to your name in nav". + * Previously `setPrimary(id, 'face-ref')` tried to coerce both slots + * onto one row with a silent twin — but `primaryFor` is a single + * column, so the avatar write clobbered the face-ref write and every + * fresh upload ended up with `primaryFor='avatar'` and no face-ref at + * all. We now keep face-ref as the single source of truth and only + * fall back to the legacy `primaryFor='avatar'` row (written by the + * pre-M2.5 legacy-avatar migration) when no face-ref exists yet. + * * Best-effort: failures are logged and swallowed. The meImages row is * authoritative for the app's own avatar rendering, so a stale * auth.users.image is a cross-session degradation, not data loss. @@ -80,9 +90,14 @@ async function syncAvatarToAuth(): Promise { const active = getActiveSpace(); if (active?.type !== 'personal') return; const rows = await scopedForModule('profile', 'meImages') - .and((row) => row.primaryFor === 'avatar') + .and((row) => row.primaryFor === 'face-ref' || row.primaryFor === 'avatar') .toArray(); - const holder = rows.find((row) => !row.deletedAt); + const visible = rows.filter((row) => !row.deletedAt); + // Prefer the face-ref holder (current, user-uploaded) over the + // legacy avatar row (migrated from the pre-M2.5 auth.users.image). + const holder = + visible.find((row) => row.primaryFor === 'face-ref') ?? + visible.find((row) => row.primaryFor === 'avatar'); const nextImage = holder?.publicUrl ?? ''; await profileService.updateProfile({ image: nextImage }); } catch (err) { @@ -188,34 +203,34 @@ export const meImagesStore = { * Pass `null` as the second argument to unset the slot on `id` * without claiming it for anyone else. * - * The `avatar` slot is coupled to `face-ref`: setting a new - * face-ref also claims the avatar on the same row (plan M2.5 - * decision — keeps auth.users.image in lockstep with the user's - * current reference face without a second UI control). Explicit - * avatar-only setPrimary calls (e.g. the legacy migration - * bootstrap) still work and only touch the avatar slot. + * The user's avatar follows their face-ref via `syncAvatarToAuth` + * (which reads the face-ref holder directly) — no silent twin + * write, because `primaryFor` is a single-value column and the + * twin would overwrite the face-ref claim on the same row. + * Explicit avatar-only setPrimary calls (e.g. the legacy migration + * bootstrap that seeds `primaryFor='avatar'` for pre-M2.5 users) + * still work; `syncAvatarToAuth` falls back to that row when no + * face-ref exists yet. */ async setPrimary(id: string, slot: MeImagePrimarySlot | null): Promise { if (slot === null) { // Clear whatever this row currently holds. If it was the - // avatar, we also need to sync that out to Better Auth. + // face-ref or the legacy avatar holder, we also need to sync + // the change out to Better Auth. const existing = await meImagesTable.get(id); - const wasAvatar = existing?.primaryFor === 'avatar'; + const wasAvatarRelevant = + existing?.primaryFor === 'face-ref' || existing?.primaryFor === 'avatar'; const nowIso = new Date().toISOString(); await meImagesTable.update(id, { primaryFor: null, updatedAt: nowIso }); emitDomainEvent('MeImagePrimaryChanged', 'profile', 'meImages', id, { meImageId: id, slot: null, }); - if (wasAvatar) await syncAvatarToAuth(); + if (wasAvatarRelevant) await syncAvatarToAuth(); return; } await setPrimaryInTx(id, slot); - // Silent twin: a fresh face-ref is also the fresh avatar. - if (slot === 'face-ref') { - await setPrimaryInTx(id, 'avatar'); - } emitDomainEvent('MeImagePrimaryChanged', 'profile', 'meImages', id, { meImageId: id, slot, @@ -227,7 +242,8 @@ export const meImagesStore = { async deleteMeImage(id: string): Promise { const existing = await meImagesTable.get(id); - const wasAvatar = existing?.primaryFor === 'avatar'; + const wasAvatarRelevant = + existing?.primaryFor === 'face-ref' || existing?.primaryFor === 'avatar'; const nowIso = new Date().toISOString(); await meImagesTable.update(id, { deletedAt: nowIso, @@ -238,6 +254,6 @@ export const meImagesStore = { primaryFor: null, }); emitDomainEvent('MeImageDeleted', 'profile', 'meImages', id, { meImageId: id }); - if (wasAvatar) await syncAvatarToAuth(); + if (wasAvatarRelevant) await syncAvatarToAuth(); }, }; diff --git a/apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte b/apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte index 7bb982dba..5f506cd87 100644 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte @@ -12,11 +12,13 @@ the same way picture/ListView does. -->