From 2a8e8ff98fb0df13343e4182e9f4eb1f16a32633 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 26 Apr 2026 23:52:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(sync):=20F7=20=E2=80=94=20drop=20repair-si?= =?UTF-8?q?lent-twin=20+=20legacy-avatar=20migrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../lib/modules/profile/MeImagesView.svelte | 17 --- .../profile/migration/legacy-avatar.ts | 108 ------------------ .../profile/migration/repair-silent-twin.ts | 86 -------------- .../src/lib/modules/wardrobe/ListView.svelte | 14 --- 4 files changed, 225 deletions(-) delete mode 100644 apps/mana/apps/web/src/lib/modules/profile/migration/legacy-avatar.ts delete mode 100644 apps/mana/apps/web/src/lib/modules/profile/migration/repair-silent-twin.ts 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 676f7bed2..9c40fa655 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/MeImagesView.svelte @@ -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'); diff --git a/apps/mana/apps/web/src/lib/modules/profile/migration/legacy-avatar.ts b/apps/mana/apps/web/src/lib/modules/profile/migration/legacy-avatar.ts deleted file mode 100644 index 355035d93..000000000 --- a/apps/mana/apps/web/src/lib/modules/profile/migration/legacy-avatar.ts +++ /dev/null @@ -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:`) - * 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 { - 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:` 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 - } -} 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 deleted file mode 100644 index 6d3857ea0..000000000 --- a/apps/mana/apps/web/src/lib/modules/profile/migration/repair-silent-twin.ts +++ /dev/null @@ -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:` 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 { - 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 - } -} 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 5f506cd87..7bb982dba 100644 --- a/apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/wardrobe/ListView.svelte @@ -12,13 +12,11 @@ the same way picture/ListView does. -->