fix(profile): setPrimary no longer overwrites face-ref with avatar

setPrimary(id, 'face-ref') ran two sequential setPrimaryInTx writes on
the same row — one for face-ref, then a "silent twin" for avatar. But
primaryFor is a single-value column, so the second write clobbered
the first. Every fresh face upload ended up with primaryFor='avatar'
and useImageByPrimary('face-ref') returned null forever: wardrobe's
try-on banner stayed, TryOn was hard-blocked, picture's reference
picker showed nothing. Latent since M2.5 (e2b5ac38c).

Drop the silent twin. Keep face-ref as the single source of truth for
both the reference-face used by generators and the avatar that syncs
to Better-Auth. syncAvatarToAuth now reads face-ref first and falls
back to the legacy primaryFor='avatar' row (written by
migrateLegacyAvatarIfNeeded for pre-M2.5 users). deleteMeImage's
avatar-relevance check widens the same way.

Plus a one-shot repair bootstrap for users (incl. local dev sessions)
whose Dexie already carries silent-twin-victim rows. Runs on mount of
wardrobe/ListView AND profile/MeImagesView, guarded by a
per-user localStorage flag. Distinguishes legitimate legacy-avatar
rows (mediaId 'legacy-avatar:<uid>') from victims (any other mediaId)
and flips the newest victim back to primaryFor='face-ref', clearing
any older duplicates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 23:38:59 +02:00
parent 62267f3d3e
commit 4093b91a34
4 changed files with 139 additions and 23 deletions

View file

@ -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();

View file

@ -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:<uid>` 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<void> {
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
}
}

View file

@ -59,10 +59,10 @@ function defaultUsage(override?: Partial<MeImageUsage>): 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>): 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<void> {
const active = getActiveSpace();
if (active?.type !== 'personal') return;
const rows = await scopedForModule<LocalMeImage, string>('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<void> {
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<void> {
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();
},
};

View file

@ -12,11 +12,13 @@
the same way picture/ListView does.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { CheckCircle, SpinnerGap, UserCircle } from '@mana/shared-icons';
import { useImageByPrimary } from '$lib/modules/profile/queries';
import MeImageUploadZone from '$lib/modules/profile/components/MeImageUploadZone.svelte';
import { ingestMeImageFile } from '$lib/modules/profile/api/me-images';
import { repairSilentTwinAvatarRows } from '$lib/modules/profile/migration/repair-silent-twin';
import GridView from './views/GridView.svelte';
import OutfitsView from './views/OutfitsView.svelte';
@ -87,6 +89,18 @@
uploadPhase = 'idle';
uploadedPreviewUrl = null;
}
// Repair rows that the M2.5 silent-twin bug left with
// primaryFor='avatar' instead of 'face-ref'. Idempotent + guarded
// by a localStorage flag. Runs here (not only in MeImagesView) so
// a user who uploaded a face photo via the wardrobe banner under
// the buggy code gets their row flipped to the correct slot on
// the next mount, without having to visit /profile/me-images.
onMount(() => {
repairSilentTwinAvatarRows().catch((err) => {
console.error('[wardrobe] silent-twin repair failed', err);
});
});
</script>
<div class="wardrobe-root">