mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-20 16:27:30 +00:00
migration improvements
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user