mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
a031493fec
commit
2a8e8ff98f
4 changed files with 0 additions and 225 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue