migration improvements

This commit is contained in:
lewis
2026-01-15 21:17:35 +02:00
parent 505dc169eb
commit 4e29861990
18 changed files with 247 additions and 213 deletions
+145 -114
View File
@@ -8,60 +8,137 @@ export interface BlobMigrationResult {
sourceUnreachable: boolean;
}
const MAX_RETRIES = 3;
const RETRY_DELAYS = [1000, 2000, 4000];
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
const safeProgress = (
onProgress: (update: Partial<MigrationProgress>) => void,
update: Partial<MigrationProgress>,
): void => {
try {
onProgress(update);
} catch (e) {
console.warn("[blob-migration] Progress callback failed:", e);
}
};
interface MigrateBlobResult {
cid: string;
success: boolean;
error?: string;
}
const migrateSingleBlob = async (
cid: string,
userDid: string,
sourceClient: AtprotoClient,
localClient: AtprotoClient,
attempt = 0,
): Promise<MigrateBlobResult> => {
try {
console.log(
`[blob-migration] Fetching blob ${cid} from source (attempt ${attempt + 1})`,
);
const { data: blobData, contentType } = await sourceClient
.getBlobWithContentType(userDid, cid);
console.log(
`[blob-migration] Got blob ${cid}, size: ${blobData.byteLength}, type: ${contentType}`,
);
console.log(`[blob-migration] Uploading blob ${cid} to local PDS...`);
const uploadResult = await localClient.uploadBlob(blobData, contentType);
console.log(
`[blob-migration] Upload response for ${cid}:`,
JSON.stringify(uploadResult),
);
return { cid, success: true };
} catch (e) {
const errorMessage = (e as Error).message || String(e);
console.error(
`[blob-migration] Failed to migrate blob ${cid} (attempt ${attempt + 1}):`,
errorMessage,
);
const isRetryable = attempt < MAX_RETRIES - 1 &&
!errorMessage.includes("404") &&
!errorMessage.includes("not found") &&
!errorMessage.includes("BlobNotFound");
if (isRetryable) {
const delay = RETRY_DELAYS[attempt] ?? 4000;
console.log(`[blob-migration] Retrying ${cid} in ${delay}ms...`);
await sleep(delay);
return migrateSingleBlob(
cid,
userDid,
sourceClient,
localClient,
attempt + 1,
);
}
return { cid, success: false, error: errorMessage };
}
};
const collectMissingBlobs = async (
localClient: AtprotoClient,
): Promise<string[]> => {
const allBlobs: string[] = [];
let cursor: string | undefined;
do {
const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
cursor,
500,
);
console.log(
`[blob-migration] listMissingBlobs returned ${blobs.length} blobs, cursor: ${nextCursor}`,
);
allBlobs.push(...blobs.map((blob) => blob.cid));
cursor = nextCursor;
} while (cursor);
return allBlobs;
};
export async function migrateBlobs(
localClient: AtprotoClient,
sourceClient: AtprotoClient | null,
userDid: string,
onProgress: (update: Partial<MigrationProgress>) => void,
): Promise<BlobMigrationResult> {
const missingBlobs: string[] = [];
let cursor: string | undefined;
console.log("[blob-migration] Starting blob migration for", userDid);
console.log(
"[blob-migration] Source client:",
sourceClient ? `available (baseUrl: ${sourceClient.getBaseUrl()})` : "NOT AVAILABLE",
);
console.log(
"[blob-migration] Local client baseUrl:",
localClient.getBaseUrl(),
);
console.log("[blob-migration] Local client baseUrl:", localClient.getBaseUrl());
console.log(
"[blob-migration] Local client has access token:",
localClient.getAccessToken() ? "yes" : "NO",
);
onProgress({ currentOperation: "Checking for missing blobs..." });
safeProgress(onProgress, { currentOperation: "Checking for missing blobs..." });
do {
const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
cursor,
100,
);
console.log(
"[blob-migration] listMissingBlobs returned",
blobs.length,
"blobs, cursor:",
nextCursor,
);
missingBlobs.push(...blobs.map((blob) => blob.cid));
cursor = nextCursor;
} while (cursor);
const missingBlobs = await collectMissingBlobs(localClient);
console.log("[blob-migration] Total missing blobs:", missingBlobs.length);
onProgress({ blobsTotal: missingBlobs.length });
safeProgress(onProgress, { blobsTotal: missingBlobs.length });
if (missingBlobs.length === 0) {
console.log("[blob-migration] No blobs to migrate");
onProgress({ currentOperation: "No blobs to migrate" });
safeProgress(onProgress, { currentOperation: "No blobs to migrate" });
return { migrated: 0, failed: [], total: 0, sourceUnreachable: false };
}
if (!sourceClient) {
console.warn(
"[blob-migration] No source client available, cannot fetch blobs",
);
onProgress({
console.warn("[blob-migration] No source client available, cannot fetch blobs");
safeProgress(onProgress, {
currentOperation:
`${missingBlobs.length} media files missing. No source PDS URL available - your old server may have shut down. Posts will work, but some images/media may be unavailable.`,
});
@@ -73,100 +150,54 @@ export async function migrateBlobs(
};
}
onProgress({ currentOperation: `Migrating ${missingBlobs.length} blobs...` });
safeProgress(onProgress, {
currentOperation: `Migrating ${missingBlobs.length} blobs...`,
});
let migrated = 0;
const failed: string[] = [];
let sourceUnreachable = false;
const results = await missingBlobs.reduce<
Promise<{ migrated: number; failed: string[] }>
>(
async (accPromise, cid, index) => {
const acc = await accPromise;
for (const cid of missingBlobs) {
if (sourceUnreachable) {
failed.push(cid);
continue;
}
try {
onProgress({
currentOperation: `Migrating blob ${
migrated + 1
}/${missingBlobs.length}...`,
safeProgress(onProgress, {
currentOperation: `Migrating blob ${index + 1}/${missingBlobs.length}...`,
blobsMigrated: acc.migrated,
});
console.log("[blob-migration] Fetching blob", cid, "from source");
const { data: blobData, contentType } = await sourceClient
.getBlobWithContentType(userDid, cid);
console.log(
"[blob-migration] Got blob",
const result = await migrateSingleBlob(
cid,
"size:",
blobData.byteLength,
"contentType:",
contentType,
);
console.log("[blob-migration] Uploading blob", cid, "to local PDS...");
const uploadResult = await localClient.uploadBlob(blobData, contentType);
console.log(
"[blob-migration] Upload response for",
cid,
":",
JSON.stringify(uploadResult),
);
migrated++;
onProgress({ blobsMigrated: migrated });
} catch (e) {
const errorMessage = (e as Error).message || String(e);
console.error(
"[blob-migration] Failed to migrate blob",
cid,
":",
errorMessage,
userDid,
sourceClient,
localClient,
);
const isNetworkError = errorMessage.includes("fetch") ||
errorMessage.includes("network") ||
errorMessage.includes("CORS") ||
errorMessage.includes("Failed to fetch") ||
errorMessage.includes("NetworkError") ||
errorMessage.includes("blocked by CORS");
return result.success
? { migrated: acc.migrated + 1, failed: acc.failed }
: { migrated: acc.migrated, failed: [...acc.failed, cid] };
},
Promise.resolve({ migrated: 0, failed: [] as string[] }),
);
if (isNetworkError) {
sourceUnreachable = true;
console.warn(
"[blob-migration] Source appears unreachable (likely CORS or network issue), skipping remaining blobs",
);
const remaining = missingBlobs.length - migrated - 1;
if (migrated > 0) {
onProgress({
currentOperation:
`Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${
remaining + 1
} could not be fetched - these may need to be re-uploaded.`,
});
} else {
onProgress({
currentOperation:
`Cannot reach source PDS (browser security restriction). This commonly happens when the old server has shut down or doesn't allow cross-origin requests. Your posts will work, but ${missingBlobs.length} media files couldn't be recovered.`,
});
}
}
failed.push(cid);
}
}
const { migrated, failed } = results;
if (migrated === missingBlobs.length) {
onProgress({
currentOperation: `All ${migrated} blobs migrated successfully`,
});
} else if (migrated > 0) {
onProgress({
currentOperation:
`${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.`,
});
} else {
onProgress({
currentOperation: `Could not migrate blobs (${failed.length} missing)`,
});
}
safeProgress(onProgress, { blobsMigrated: migrated });
return { migrated, failed, total: missingBlobs.length, sourceUnreachable };
const statusMessage = migrated === missingBlobs.length
? `All ${migrated} blobs migrated successfully`
: migrated > 0
? `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.`
: `Could not migrate blobs (${failed.length} missing)`;
safeProgress(onProgress, { currentOperation: statusMessage });
console.log(`[blob-migration] Complete: ${migrated} migrated, ${failed.length} failed`);
failed.length > 0 && console.log("[blob-migration] Failed CIDs:", failed);
return {
migrated,
failed,
total: missingBlobs.length,
sourceUnreachable: false,
};
}
+16 -4
View File
@@ -77,6 +77,7 @@ export function createInboundMigrationFlow() {
authMethod: "password",
passkeySetupToken: null,
oauthCodeVerifier: null,
localAccessToken: null,
generatedAppPassword: null,
generatedAppPasswordName: null,
});
@@ -276,6 +277,9 @@ export function createInboundMigrationFlow() {
if (postEmailSteps.includes(targetStep)) {
localClient = createLocalClient();
if (state.localAccessToken) {
localClient.setAccessToken(state.localAccessToken);
}
if (state.authMethod === "passkey" && state.passkeySetupToken) {
setStep("passkey-setup");
migrationLog(
@@ -289,6 +293,9 @@ export function createInboundMigrationFlow() {
}
} else if (targetStep === "email-verify") {
localClient = createLocalClient();
if (state.localAccessToken) {
localClient.setAccessToken(state.localAccessToken);
}
setStep("email-verify");
migrationLog("handleOAuthCallback: Resuming at email-verify");
} else {
@@ -389,6 +396,7 @@ export function createInboundMigrationFlow() {
state.passkeySetupToken = passkeySetup.setupToken;
if (passkeySetup.accessJwt) {
localClient.setAccessToken(passkeySetup.accessJwt);
state.localAccessToken = passkeySetup.accessJwt;
}
} else {
const accountParams = {
@@ -408,6 +416,7 @@ export function createInboundMigrationFlow() {
did: session.did,
});
localClient.setAccessToken(session.accessJwt);
state.localAccessToken = session.accessJwt;
}
setProgress({ currentOperation: "Exporting repository..." });
@@ -599,10 +608,12 @@ export function createInboundMigrationFlow() {
return true;
}
await localClient.loginDeactivated(
state.targetEmail,
state.targetPassword,
);
if (!localClient.getAccessToken()) {
await localClient.loginDeactivated(
state.targetEmail,
state.targetPassword,
);
}
if (!sourceClient) {
setStep("source-handle");
@@ -916,6 +927,7 @@ export function createInboundMigrationFlow() {
state.targetHandle = stored.targetHandle;
state.targetEmail = stored.targetEmail;
state.authMethod = stored.authMethod ?? "password";
state.localAccessToken = stored.localAccessToken ?? null;
state.progress = {
...createInitialProgress(),
...stored.progress,
@@ -497,12 +497,14 @@ export function createOfflineInboundMigrationFlow() {
const { verified } = await api.checkEmailVerified(state.targetEmail);
if (!verified) return false;
const session = await api.createSession(
state.targetEmail,
state.targetPassword,
);
state.localAccessToken = session.accessJwt;
state.localRefreshToken = session.refreshJwt;
if (!state.localAccessToken) {
const session = await api.createSession(
state.targetEmail,
state.targetPassword,
);
state.localAccessToken = session.accessJwt;
state.localRefreshToken = session.refreshJwt;
}
saveOfflineState(state);
setStep("plc-signing");
+1
View File
@@ -22,6 +22,7 @@ export function saveMigrationState(state: MigrationState): void {
targetEmail: state.targetEmail,
authMethod: state.authMethod,
passkeySetupToken: state.passkeySetupToken ?? undefined,
localAccessToken: state.localAccessToken ?? undefined,
progress: {
repoExported: state.progress.repoExported,
repoImported: state.progress.repoImported,
+2
View File
@@ -69,6 +69,7 @@ export interface InboundMigrationState {
authMethod: AuthMethod;
passkeySetupToken: string | null;
oauthCodeVerifier: string | null;
localAccessToken: string | null;
generatedAppPassword: string | null;
generatedAppPasswordName: string | null;
needsReauth?: boolean;
@@ -117,6 +118,7 @@ export interface StoredMigrationState {
targetEmail: string;
authMethod?: AuthMethod;
passkeySetupToken?: string;
localAccessToken?: string;
progress: {
repoExported: boolean;
repoImported: boolean;