feat(federation): backfill snowflake-named replicated-user stubs

Heals existing legacy stubs (created when resolveOrCreateReplicatedUser used
homeUserId@domain) by asking the peer for the canonical username via
lookupRemoteUserByHomeId and rewriting the local row. Idempotent and
collision-safe.

Wired into onPeerActivated (per-origin) so future peer flaps re-attempt for
stubs whose home was unreachable on a prior pass, and into a one-shot pass at
startupBootstrapSync for all currently-active peers (not just first-time
lastSyncedAt=0 peers).
This commit is contained in:
Jannis Braun
2026-05-05 15:57:37 +02:00
parent b2faf5afaa
commit bdbc90ebd2
3 changed files with 282 additions and 2 deletions
@@ -0,0 +1,89 @@
import { and, eq, like } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { lookupRemoteUserByHomeId } from './federationLookup.js';
import { extractDomain } from '../routes/federation.js';
/**
* For each replicated stub on this instance whose username still matches the
* legacy `<homeUserId>@<domain>` pattern AND whose home_instance equals the
* given peer's domain, ask the peer for the canonical username via
* lookupRemoteUserByHomeId and rewrite the stub.
*
* Idempotent — stubs already migrated (username does not start with their
* homeUserId) are skipped without a network call.
*
* Gated on peer.status='active' — the lookup endpoint requires the requesting
* peer to be active on the receiving side. We additionally check our local
* peer row here so we don't waste outbound RTTs on peers we know aren't ready.
*
* Called from onPeerActivated (per-origin, gated on peer status='active') and
* from a one-shot startup pass for any peer already active at boot.
*/
export async function backfillStubUsernamesForPeer(peerOrigin: string): Promise<void> {
const db = getDb();
const peerDomain = extractDomain(peerOrigin);
const peer = db
.select({ status: schema.federationPeers.status })
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, peerOrigin))
.get();
if (!peer || peer.status !== 'active') return;
// Coarse SQL prefilter: stubs from this peer whose username ends with @peerDomain.
// We then narrow in JS to the legacy `<homeUserId>@<domain>` shape because Drizzle
// can't express that comparison portably.
const candidates = db
.select()
.from(schema.users)
.where(
and(
eq(schema.users.homeInstance, peerDomain),
eq(schema.users.isDeleted, 0),
like(schema.users.username, '%@' + peerDomain),
),
)
.all();
for (const stub of candidates) {
if (!stub.homeUserId) continue;
const expectedLegacy = `${stub.homeUserId}@${peerDomain}`.toLowerCase();
if (stub.username !== expectedLegacy) continue; // already migrated or non-legacy shape
const result = await lookupRemoteUserByHomeId(peerOrigin, stub.homeUserId);
if (!result.ok) {
// not_found / unreachable / rate_limited — leave untouched.
// Will retry on next onPeerActivated for this origin.
continue;
}
const newUsername = `${result.username}@${peerDomain}`.toLowerCase();
if (newUsername === stub.username) continue; // already correct
// Collision check: another row at the target username (rare under the new
// scheme but possible from prior partial replication).
const collision = db
.select({ id: schema.users.id })
.from(schema.users)
.where(eq(schema.users.username, newUsername))
.get();
if (collision && collision.id !== stub.id) {
console.warn(`[stub-backfill] username collision on ${newUsername} — leaving stub ${stub.id} as ${stub.username}`);
continue;
}
// Fill displayName from result.profile if the stub has none, mirroring the
// displayName ?? username fallback applied at hydrate / profile_update time.
const updates: { username: string; displayName?: string } = { username: newUsername };
if (!stub.displayName) {
updates.displayName = result.profile.displayName ?? result.username;
}
db.update(schema.users)
.set(updates)
.where(eq(schema.users.id, stub.id))
.run();
console.log(`[stub-backfill] rewrote stub ${stub.id}: ${stub.username}${newUsername}`);
}
}