mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
62267f3d3e
commit
4093b91a34
4 changed files with 139 additions and 23 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue