Graphical PDS migrator for AT Protocol

more robust data fetching, status endpts

Changed files
+392 -96
islands
lib
routes
.DS_Store

This is a binary file and will not be displayed.

+1 -1
islands/Header.tsx
···
const handleLogout = async () => {
try {
-
const response = await fetch("/api/oauth/logout", {
+
const response = await fetch("/api/logout", {
method: "POST",
credentials: "include",
});
+63 -39
islands/MigrationProgress.tsx
···
const [steps, setSteps] = useState<MigrationStep[]>([
{ name: "Create Account", status: "pending" },
{ name: "Migrate Data", status: "pending" },
-
{ name: "Request Identity Migration", status: "pending" },
-
{ name: "Complete Identity Migration", status: "pending" },
+
{ name: "Migrate Identity", status: "pending" },
{ name: "Finalize Migration", status: "pending" },
]);
···
});
}, []);
+
const getStepDisplayName = (step: MigrationStep, index: number) => {
+
if (step.status === "completed") {
+
switch (index) {
+
case 0: return "Account Created";
+
case 1: return "Data Migrated";
+
case 2: return "Identity Migrated";
+
case 3: return "Migration Finalized";
+
}
+
}
+
+
if (step.status === "in-progress") {
+
switch (index) {
+
case 0: return "Creating your new account...";
+
case 1: return "Migrating your data...";
+
case 2: return step.name === "Enter the token sent to your email to complete identity migration"
+
? step.name
+
: "Migrating your identity...";
+
case 3: return "Finalizing migration...";
+
}
+
}
+
+
return step.name;
+
};
+
const startMigration = async () => {
try {
// Step 1: Create Account
···
);
}
console.log("Identity migration requested successfully");
+
+
// Don't mark step as completed yet since we need token input
+
steps[2].name = "Enter the token sent to your email to complete identity migration";
+
setSteps([...steps]);
} catch (e) {
console.error("Failed to parse identity request response:", e);
throw new Error(
"Invalid response from server during identity request",
);
}
-
-
updateStepStatus(2, "completed");
-
// Move to token input step
-
updateStepStatus(3, "in-progress");
} catch (error) {
updateStepStatus(
2,
···
}
setRecoveryKey(data.recoveryKey);
-
updateStepStatus(3, "completed");
+
updateStepStatus(2, "completed");
+
steps[2].name = "Migrate Identity"; // Reset to default name after completion
+
setSteps([...steps]);
-
// Step 5: Finalize Migration
-
updateStepStatus(4, "in-progress");
+
// Step 4: Finalize Migration
+
updateStepStatus(3, "in-progress");
try {
const finalizeRes = await fetch("/api/migrate/finalize", {
method: "POST",
···
throw new Error("Invalid response from server during finalization");
}
-
updateStepStatus(4, "completed");
+
updateStepStatus(3, "completed");
} catch (error) {
updateStepStatus(
-
4,
+
3,
"error",
error instanceof Error ? error.message : String(error),
);
···
} catch (error) {
console.error("Identity migration error:", error);
updateStepStatus(
-
3,
+
2,
"error",
error instanceof Error ? error.message : String(error),
);
···
return (
<div class="space-y-8">
<div class="space-y-4">
-
{steps.map((step) => (
+
{steps.map((step, index) => (
<div key={step.name} class={getStepClasses(step.status)}>
{getStepIcon(step.status)}
<div class="flex-1">
···
: "text-gray-900 dark:text-gray-200"
}`}
>
-
{step.name}
+
{getStepDisplayName(step, index)}
</p>
{step.error && (
<p class="text-sm text-red-600 dark:text-red-400 mt-1">
{step.error}
</p>
)}
+
{index === 2 && step.status === "in-progress" && (
+
<div class="mt-4 space-y-4">
+
<p class="text-sm text-blue-800 dark:text-blue-200">
+
Please check your email for the migration token and enter it below:
+
</p>
+
<div class="flex space-x-2">
+
<input
+
type="text"
+
value={token}
+
onChange={(e) => setToken(e.currentTarget.value)}
+
placeholder="Enter token"
+
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400"
+
/>
+
<button
+
type="button"
+
onClick={handleIdentityMigration}
+
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
+
>
+
Submit Token
+
</button>
+
</div>
+
</div>
+
)}
</div>
</div>
))}
</div>
-
{steps[3].status === "in-progress" && (
-
<div class="space-y-4 p-4 bg-blue-50 dark:bg-blue-900 rounded-lg border-2 border-blue-200 dark:border-blue-800">
-
<p class="text-sm text-blue-800 dark:text-blue-200">
-
Please check your email for the migration token and enter it below:
-
</p>
-
<div class="flex space-x-2">
-
<input
-
type="text"
-
value={token}
-
onChange={(e) => setToken(e.currentTarget.value)}
-
placeholder="Enter token"
-
class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400"
-
/>
-
<button
-
type="button"
-
onClick={handleIdentityMigration}
-
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
-
>
-
Submit Token
-
</button>
-
</div>
-
</div>
-
)}
-
{recoveryKey && (
<div class="p-4 bg-yellow-50 dark:bg-yellow-900 rounded-lg border-2 border-yellow-200 dark:border-yellow-800">
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
···
</div>
)}
-
{steps[4].status === "completed" && (
+
{steps[3].status === "completed" && (
<div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800">
<p class="text-sm text-green-800 dark:text-green-200">
Migration completed successfully! You can now close this page.
+16
lib/sessions.ts
···
return null;
}
+
+
export async function destroyAllSessions(req: Request) {
+
const oauthSession = await getOauthSession(req);
+
const credentialSession = await getCredentialSession(req);
+
const migrationSession = await getCredentialSession(req, new Response(), true);
+
+
if (oauthSession.did) {
+
await oauthSession.destroy();
+
}
+
if (credentialSession.did) {
+
await credentialSession.destroy();
+
}
+
if (migrationSession.did) {
+
await migrationSession.destroy();
+
}
+
}
+1 -1
routes/api/cred/login.ts
···
password,
handle,
accessJwt: sessionRes.data.accessJwt
-
}, true);
+
});
// Log the response headers
console.log("Response headers:", {
+10
routes/api/logout.ts
···
+
import { destroyAllSessions } from "../../lib/sessions.ts";
+
import { define } from "../../utils.ts";
+
+
export const handler = define.handlers({
+
async POST(ctx) {
+
await destroyAllSessions(ctx.req)
+
+
return new Response("All Sessions Destroyed")
+
},
+
});
+188 -28
routes/api/migrate/data.ts
···
import {
getSessionAgent,
} from "../../../lib/sessions.ts";
+
import { Agent, ComAtprotoSyncGetBlob } from "npm:@atproto/api";
+
+
// Retry configuration
+
const MAX_RETRIES = 3;
+
const INITIAL_RETRY_DELAY = 1000; // 1 second
+
+
interface RetryOptions {
+
maxRetries?: number;
+
initialDelay?: number;
+
onRetry?: (attempt: number, error: Error) => void;
+
}
+
+
async function withRetry<T>(
+
operation: () => Promise<T>,
+
options: RetryOptions = {},
+
): Promise<T> {
+
const maxRetries = options.maxRetries ?? MAX_RETRIES;
+
const initialDelay = options.initialDelay ?? INITIAL_RETRY_DELAY;
+
+
let lastError: Error | null = null;
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
+
try {
+
return await operation();
+
} catch (error) {
+
lastError = error instanceof Error ? error : new Error(String(error));
+
+
// Don't retry on certain errors
+
if (error instanceof Error) {
+
// Don't retry on permanent errors like authentication
+
if (error.message.includes("Unauthorized") || error.message.includes("Invalid token")) {
+
throw error;
+
}
+
}
+
+
if (attempt < maxRetries - 1) {
+
const delay = initialDelay * Math.pow(2, attempt);
+
console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms:`, lastError.message);
+
if (options.onRetry) {
+
options.onRetry(attempt + 1, lastError);
+
}
+
await new Promise(resolve => setTimeout(resolve, delay));
+
}
+
}
+
}
+
throw lastError ?? new Error("Operation failed after retries");
+
}
+
+
async function handleBlobUpload(
+
newAgent: Agent,
+
blobRes: ComAtprotoSyncGetBlob.Response,
+
cid: string
+
) {
+
try {
+
const contentLength = parseInt(blobRes.headers["content-length"] || "0", 10);
+
const contentType = blobRes.headers["content-type"];
+
+
// Check file size before attempting upload
+
const MAX_SIZE = 95 * 1024 * 1024; // 95MB to be safe
+
if (contentLength > MAX_SIZE) {
+
throw new Error(`Blob ${cid} exceeds maximum size limit (${contentLength} bytes)`);
+
}
+
+
await withRetry(
+
() => newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
+
encoding: contentType,
+
}),
+
{
+
maxRetries: 5,
+
onRetry: (attempt, error) => {
+
console.log(`Retrying blob upload for ${cid} (attempt ${attempt}):`, error.message);
+
},
+
}
+
);
+
} catch (error) {
+
console.error(`Failed to upload blob ${cid}:`, error);
+
throw error;
+
}
+
}
export const handler = define.handlers({
async POST(ctx) {
···
const session = await oldAgent.com.atproto.server.getSession();
const accountDid = session.data.did;
-
// Migrate repo data
-
const repoRes = await oldAgent.com.atproto.sync.getRepo({
-
did: accountDid,
-
});
-
await newAgent.com.atproto.repo.importRepo(repoRes.data, {
-
encoding: "application/vnd.ipld.car",
-
});
+
// Migrate repo data with retries
+
const repoRes = await withRetry(
+
() => oldAgent.com.atproto.sync.getRepo({
+
did: accountDid,
+
}),
+
{
+
maxRetries: 5,
+
onRetry: (attempt, error) => {
+
console.log(`Retrying repo fetch (attempt ${attempt}):`, error.message);
+
},
+
}
+
);
-
// Migrate blobs
+
await withRetry(
+
() => newAgent.com.atproto.repo.importRepo(repoRes.data, {
+
encoding: "application/vnd.ipld.car",
+
}),
+
{
+
maxRetries: 5,
+
onRetry: (attempt, error) => {
+
console.log(`Retrying repo import (attempt ${attempt}):`, error.message);
+
},
+
}
+
);
+
+
// Migrate blobs with enhanced error handling
let blobCursor: string | undefined = undefined;
const migratedBlobs: string[] = [];
+
const failedBlobs: Array<{ cid: string; error: string }> = [];
+
do {
-
const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({
-
did: accountDid,
-
cursor: blobCursor,
-
});
-
for (const cid of listedBlobs.data.cids) {
-
const blobRes = await oldAgent.com.atproto.sync.getBlob({
-
did: accountDid,
-
cid,
-
});
-
await newAgent.com.atproto.repo.uploadBlob(blobRes.data, {
-
encoding: blobRes.headers["content-type"],
-
});
-
migratedBlobs.push(cid);
+
try {
+
const listedBlobs = await withRetry(
+
() => oldAgent.com.atproto.sync.listBlobs({
+
did: accountDid,
+
cursor: blobCursor,
+
}),
+
{
+
maxRetries: 5,
+
onRetry: (attempt, error) => {
+
console.log(`Retrying blob list fetch (attempt ${attempt}):`, error.message);
+
},
+
}
+
);
+
+
for (const cid of listedBlobs.data.cids) {
+
try {
+
const blobRes = await withRetry(
+
() => oldAgent.com.atproto.sync.getBlob({
+
did: accountDid,
+
cid,
+
}),
+
{
+
maxRetries: 5,
+
onRetry: (attempt, error) => {
+
console.log(`Retrying blob download for ${cid} (attempt ${attempt}):`, error.message);
+
},
+
}
+
);
+
+
await handleBlobUpload(newAgent, blobRes, cid);
+
migratedBlobs.push(cid);
+
console.log(`Successfully migrated blob: ${cid}`);
+
} catch (error) {
+
console.error(`Failed to migrate blob ${cid}:`, error);
+
failedBlobs.push({
+
cid,
+
error: error instanceof Error ? error.message : String(error),
+
});
+
}
+
}
+
blobCursor = listedBlobs.data.cursor;
+
} catch (error) {
+
console.error("Error during blob migration batch:", error);
+
// If we hit a critical error during blob listing, break the loop
+
if (error instanceof Error &&
+
(error.message.includes("Unauthorized") ||
+
error.message.includes("Invalid token"))) {
+
throw error;
+
}
+
break;
}
-
blobCursor = listedBlobs.data.cursor;
} while (blobCursor);
-
// Migrate preferences
-
const prefs = await oldAgent.app.bsky.actor.getPreferences();
-
await newAgent.app.bsky.actor.putPreferences(prefs.data);
+
// Migrate preferences with retry
+
const prefs = await withRetry(
+
() => oldAgent.app.bsky.actor.getPreferences(),
+
{
+
maxRetries: 3,
+
onRetry: (attempt, error) => {
+
console.log(`Retrying preferences fetch (attempt ${attempt}):`, error.message);
+
},
+
}
+
);
+
+
await withRetry(
+
() => newAgent.app.bsky.actor.putPreferences(prefs.data),
+
{
+
maxRetries: 3,
+
onRetry: (attempt, error) => {
+
console.log(`Retrying preferences update (attempt ${attempt}):`, error.message);
+
},
+
}
+
);
return new Response(
JSON.stringify({
success: true,
-
message: "Data migration completed successfully",
-
migratedBlobs: migratedBlobs,
+
message: failedBlobs.length > 0
+
? `Data migration completed with ${failedBlobs.length} failed blobs`
+
: "Data migration completed successfully",
+
migratedBlobs,
+
failedBlobs,
+
totalMigrated: migratedBlobs.length,
+
totalFailed: failedBlobs.length,
}),
{
-
status: 200,
+
status: failedBlobs.length > 0 ? 207 : 200, // Use 207 Multi-Status if some blobs failed
headers: {
"Content-Type": "application/json",
...Object.fromEntries(res.headers), // Include session cookie headers
···
message: error instanceof Error
? error.message
: "Failed to migrate data",
+
error: error instanceof Error ? {
+
name: error.name,
+
message: error.message,
+
stack: error.stack,
+
} : String(error),
}),
{
status: 400,
+45
routes/api/migrate/next-step.ts
···
+
import { getSessionAgent } from "../../../lib/sessions.ts";
+
import { define } from "../../../utils.ts";
+
+
export const handler = define.handlers({
+
async GET(ctx) {
+
let nextStep = null;
+
const oldAgent = await getSessionAgent(ctx.req);
+
const newAgent = await getSessionAgent(ctx.req, new Response(), true);
+
+
if (!newAgent) return Response.json({ nextStep: 1, completed: false });
+
if (!oldAgent) return new Response("Unauthorized", { status: 401 });
+
+
const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus();
+
const newStatus = await newAgent.com.atproto.server.checkAccountStatus();
+
if (!oldStatus.data || !newStatus.data) return new Response("Could not verify status", { status: 500 });
+
+
// Check conditions in sequence to determine the next step
+
if (!newStatus.data) {
+
nextStep = 1;
+
} else if (!(newStatus.data.repoCommit &&
+
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
+
newStatus.data.privateStateValues === oldStatus.data.privateStateValues &&
+
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
+
newStatus.data.importedBlobs === oldStatus.data.importedBlobs)) {
+
nextStep = 2;
+
} else if (!newStatus.data.validDid) {
+
nextStep = 3;
+
} else if (!(newStatus.data.activated === true && oldStatus.data.activated === false)) {
+
nextStep = 4;
+
}
+
+
return Response.json({
+
nextStep,
+
completed: nextStep === null,
+
currentStatus: {
+
activated: newStatus.data.activated,
+
validDid: newStatus.data.validDid,
+
repoCommit: newStatus.data.repoCommit,
+
indexedRecords: newStatus.data.indexedRecords,
+
privateStateValues: newStatus.data.privateStateValues,
+
importedBlobs: newStatus.data.importedBlobs
+
}
+
});
+
}
+
})
+68
routes/api/migrate/status.ts
···
+
import { getSessionAgent } from "../../../lib/sessions.ts";
+
import { define } from "../../../utils.ts";
+
+
export const handler = define.handlers({
+
async GET(ctx) {
+
const url = new URL(ctx.req.url);
+
const params = new URLSearchParams(url.search);
+
const step = params.get("step");
+
const oldAgent = await getSessionAgent(ctx.req);
+
const newAgent = await getSessionAgent(ctx.req, new Response(), true);
+
+
+
if (!oldAgent || !newAgent) return new Response("Unauthorized", { status: 401 });
+
+
const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus();
+
const newStatus = await newAgent.com.atproto.server.checkAccountStatus();
+
if (!oldStatus.data || !newStatus.data) return new Response("Could not verify status", { status: 500 });
+
+
const readyToContinue = () => {
+
if (step) {
+
switch (step) {
+
case "1":
+
if (newStatus.data) {
+
return true;
+
}
+
return false;
+
case "2":
+
if (newStatus.data.repoCommit &&
+
newStatus.data.indexedRecords === oldStatus.data.indexedRecords &&
+
newStatus.data.privateStateValues === oldStatus.data.privateStateValues &&
+
newStatus.data.expectedBlobs === newStatus.data.importedBlobs &&
+
newStatus.data.importedBlobs === oldStatus.data.importedBlobs) {
+
return true;
+
}
+
return false;
+
case "3":
+
if (newStatus.data.validDid) {
+
return true;
+
}
+
return false;
+
case "4":
+
if (newStatus.data.activated === true && oldStatus.data.activated === false) {
+
return true;
+
}
+
return false;
+
}
+
} else {
+
return true;
+
}
+
}
+
+
const status = {
+
activated: newStatus.data.activated,
+
validDid: newStatus.data.validDid,
+
repoCommit: newStatus.data.repoCommit,
+
repoRev: newStatus.data.repoRev,
+
repoBlocks: newStatus.data.repoBlocks,
+
expectedRecords: oldStatus.data.indexedRecords,
+
indexedRecords: newStatus.data.indexedRecords,
+
privateStateValues: newStatus.data.privateStateValues,
+
expectedBlobs: newStatus.data.expectedBlobs,
+
importedBlobs: newStatus.data.importedBlobs,
+
readyToContinue: readyToContinue()
+
}
+
+
return Response.json(status);
+
}
+
})
-27
routes/api/oauth/logout.ts
···
-
import { getSession } from "../../../lib/sessions.ts";
-
import { oauthClient } from "../../../lib/oauth/client.ts";
-
import { define } from "../../../utils.ts";
-
-
export const handler = define.handlers({
-
async POST(ctx) {
-
const req = ctx.req;
-
-
try {
-
const response = new Response(null, { status: 200 });
-
const session = await getSession(req, response);
-
-
if (session.did) {
-
// First destroy the oauth session
-
await oauthClient.revoke(session.did);
-
// Then destroy the iron session
-
session.destroy();
-
}
-
-
return response;
-
} catch (error: unknown) {
-
const err = error instanceof Error ? error : new Error(String(error));
-
console.error("Logout failed:", err.message);
-
return new Response("Logout failed", { status: 500 });
-
}
-
},
-
});