feat(sync): F7 — drop repair-silent-twin + legacy-avatar migrations

The two one-shot bootstraps that were the structural source of three
of the four pre-F1 conflict-toasts have been obsolete since F2 +
shipped:

- F2 now stamps `origin: 'migration'` on Repair-Migration writes via
  the system actor wrapper, so even if these helpers ran they would
  not surface as conflict toasts on other devices anymore.
- F3 took `updatedAt` out of the wire entirely, removing the field
  the helpers used to bump explicitly (the only reason their writes
  showed up in someone else's pull as a conflict).

Files removed:
- apps/mana/apps/web/src/lib/modules/profile/migration/repair-silent-twin.ts
- apps/mana/apps/web/src/lib/modules/profile/migration/legacy-avatar.ts
- (empty) migration/ directory

Callers cleaned up:
- profile/MeImagesView.svelte — onMount block + imports gone.
- wardrobe/ListView.svelte — same; `onMount` import dropped (unused).

The original silent-twin bug was already fixed in M2.5 via
`setPrimary` no longer creating a "silent twin" — the repair helper
existed only to clean up rows produced by the buggy code before the
fix shipped. Pre-live, with no production data, no users hold rows
in that broken state, so the cleanup is safe.

Plan: docs/plans/sync-field-meta-overhaul.md F7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 23:52:58 +02:00
parent a031493fec
commit 2a8e8ff98f
4 changed files with 0 additions and 225 deletions

View file

@ -23,8 +23,6 @@
import { useAllMeImages, useImageByPrimary } from './queries';
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
@ -32,21 +30,6 @@
// badge makes that transparent without cluttering the rest of the UI.
const activeSpace = $derived(getActiveSpace());
// 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();
const faceSlot$ = useImageByPrimary('face-ref');
const bodySlot$ = useImageByPrimary('body-ref');

View file

@ -1,108 +0,0 @@
/**
* One-shot migration of the legacy `auth.users.image` URL into a
* meImages row with `primaryFor='avatar'`. Runs the first time the
* user opens /profile/me-images after M2.5 ships; idempotent after
* that (localStorage guard + primary-slot existence check).
*
* Why this lives as a client-side bootstrap rather than a Dexie
* db.version() upgrade: the legacy value lives in Better Auth
* (services/mana-auth), not in Dexie. A schema-upgrade hook has no
* way to reach it without a network call, and running network calls
* from inside a Dexie version upgrade is exactly the kind of thing
* that breaks silently on slow links. A mount-time bootstrap gives
* us explicit error handling + a retry path (next visit).
*
* The migrated row carries a sentinel mediaId (`legacy-avatar:<uid>`)
* because the original bytes were not uploaded through mana-media
* they live wherever the old avatar upload endpoint put them. As a
* result, this row intentionally fails M3's verifyMediaOwnership if
* the user ever flips its `usage.aiReference` on and tries to use it
* for generation. That is correct: legacy avatars shouldn't silently
* start feeding OpenAI without an explicit re-upload.
*/
import { authStore } from '$lib/stores/auth.svelte';
import { profileService } from '$lib/api/profile';
import { meImagesTable } from '../collections';
import { meImagesStore } from '../stores/me-images.svelte';
import { makeSystemActor, runAsAsync, SYSTEM_MIGRATION } from '$lib/data/events/actor';
export async function migrateLegacyAvatarIfNeeded(): Promise<void> {
const user = authStore.user;
if (!user?.id) return;
const flagKey = `mana.profile.avatarMigration.${user.id}`;
if (typeof localStorage !== 'undefined' && localStorage.getItem(flagKey)) return;
// Already have an avatar-holder? Mark done and skip. This also
// covers the case where a user had their primary set in a prior
// browser — after sync catches up, the row is here and we should
// not create a duplicate.
const existing = await meImagesTable.where('primaryFor').equals('avatar').toArray();
if (existing.some((row) => !row.deletedAt)) {
try {
localStorage.setItem(flagKey, '1');
} catch {
// localStorage blocked — fine, next visit re-checks Dexie.
}
return;
}
let profile;
try {
profile = await profileService.getProfile();
} catch {
// Offline or Better Auth down; try again on next visit.
return;
}
if (!profile.image) {
try {
localStorage.setItem(flagKey, '1');
} catch {
// ignore
}
return;
}
// Pin the narrowed `profile.image` to a const so the type stays
// `string` across the runAsAsync closure (control-flow narrowing
// doesn't survive nested callbacks).
const imageUrl = profile.image;
// Run the seed insert under a migration-system actor so the Dexie
// creating-hook stamps `origin: 'migration'` on every field —
// conflict-detection ignores this row when the same migration fires
// later on a different device.
const migrationActor = makeSystemActor(SYSTEM_MIGRATION, 'Migration: legacy avatar');
await runAsAsync(migrationActor, async () => {
await meImagesStore.createMeImage({
kind: 'face',
// Sentinel mediaId: not a real mana-media reference. The generate-
// with-reference path (M3) gates on MediaClient.list({app:'me'}),
// so this id will naturally bounce if ever used for generation.
mediaId: `legacy-avatar:${user.id}`,
storagePath: imageUrl,
publicUrl: imageUrl,
thumbnailUrl: imageUrl,
width: 0,
height: 0,
label: 'Bisheriges Profilbild',
usage: { aiReference: false, showInProfile: true },
primaryFor: 'avatar',
// Legacy avatar is the user's global SSO identity (Better Auth
// `users.image`) — it belongs explicitly in the *personal* space,
// regardless of which space the user happens to be in when the
// migration fires. Use the `_personal:<uid>` sentinel that
// reconcileSentinels() rewrites to the real personal-space id on
// the next active-space bootstrap (same pattern v28 used for the
// blanket data-table migration).
spaceId: `_personal:${user.id}`,
});
});
try {
localStorage.setItem(flagKey, '1');
} catch {
// ignore
}
}

View file

@ -1,86 +0,0 @@
/**
* 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';
import { makeSystemActor, runAsAsync, SYSTEM_MIGRATION } from '$lib/data/events/actor';
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();
// Run the rewrite under a migration-system actor so the Dexie
// updating-hook stamps `origin: 'migration'` on every touched field.
// Conflict-detection later treats these writes as pipeline-internal —
// a fresh client pulling the same updates from another device must
// NOT see "another session overwrote your edit" toasts.
const migrationActor = makeSystemActor(SYSTEM_MIGRATION, 'Repair: silent-twin');
await runAsAsync(migrationActor, async () => {
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,
});
}
});
});
try {
localStorage.setItem(flagKey, '1');
} catch {
// ignore
}
}

View file

@ -12,13 +12,11 @@
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';
@ -89,18 +87,6 @@
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">