Graphical PDS migrator for AT Protocol
1/**
2 * The migration state info.
3 * @type {MigrationStateInfo}
4 */
5export interface MigrationStateInfo {
6 state: "up" | "issue" | "maintenance";
7 message: string;
8 allowMigration: boolean;
9}
10
11/**
12 * The migration progress props.
13 * @type {MigrationProgressProps}
14 */
15export interface MigrationProgressProps {
16 service: string;
17 handle: string;
18 email: string;
19 password: string;
20 invite?: string;
21}
22
23/**
24 * The migration step.
25 * @type {MigrationStep}
26 */
27export interface MigrationStep {
28 name: string;
29 status: "pending" | "in-progress" | "verifying" | "completed" | "error";
30 error?: string;
31 isVerificationError?: boolean;
32}
33
34export enum MigrationErrorType {
35 NOT_ALLOWED = "NOT_ALLOWED",
36 NETWORK = "NETWORK",
37 OTHER = "OTHER",
38}
39
40export class MigrationError extends Error {
41 stepIndex: number;
42 type: MigrationErrorType;
43
44 constructor(message: string, stepIndex: number, type: MigrationErrorType) {
45 super(message);
46 this.name = "MigrationError";
47 this.stepIndex = stepIndex;
48 this.type = type;
49 }
50}
51
52export class MigrationClient {
53 private updateStepStatus: (
54 stepIndex: number,
55 status: MigrationStep["status"],
56 error?: string,
57 isVerificationError?: boolean,
58 ) => void;
59 private setRetryAttempts?: (
60 value: (prev: Record<number, number>) => Record<number, number>,
61 ) => void;
62 private setShowContinueAnyway?: (
63 value: (prev: Record<number, boolean>) => Record<number, boolean>,
64 ) => void;
65 private baseUrl = "";
66 private nextStepHook?: (stepNum: number) => unknown;
67
68 constructor({
69 updateStepStatus,
70 setRetryAttempts,
71 setShowContinueAnyway,
72 baseUrl = "",
73 nextStepHook,
74 }: {
75 updateStepStatus: (
76 stepIndex: number,
77 status: MigrationStep["status"],
78 error?: string,
79 isVerificationError?: boolean,
80 ) => void;
81 setRetryAttempts?: (
82 value: (prev: Record<number, number>) => Record<number, number>,
83 ) => void;
84 setShowContinueAnyway?: (
85 value: (prev: Record<number, boolean>) => Record<number, boolean>,
86 ) => void;
87 baseUrl?: string;
88 nextStepHook?: (stepNum: number) => unknown;
89 }) {
90 this.updateStepStatus = updateStepStatus;
91 this.setRetryAttempts = setRetryAttempts;
92 this.setShowContinueAnyway = setShowContinueAnyway;
93 this.baseUrl = baseUrl;
94 this.nextStepHook = nextStepHook;
95 }
96
97 async checkState() {
98 try {
99 const migrationResponse = await fetch(
100 `${this.baseUrl}/api/migration-state`,
101 );
102 if (migrationResponse.ok) {
103 const migrationData = await migrationResponse.json();
104
105 if (!migrationData.allowMigration) {
106 throw new MigrationError(
107 migrationData.message,
108 0,
109 MigrationErrorType.NOT_ALLOWED,
110 );
111 }
112
113 return migrationData;
114 }
115 } catch (error) {
116 console.error("Error checking migration state:", error);
117 throw new MigrationError(
118 "Unable to verify migration availability",
119 0,
120 MigrationErrorType.OTHER,
121 );
122 }
123 }
124
125 async startMigration(props: MigrationProgressProps) {
126 console.log("Starting migration with props:", {
127 service: props.service,
128 handle: props.handle,
129 email: props.email,
130 hasPassword: !!props.password,
131 invite: props.invite,
132 });
133
134 try {
135 // Step 1: Create Account
136 this.updateStepStatus(0, "in-progress");
137 console.log("Starting account creation...");
138
139 try {
140 const createRes = await fetch(`${this.baseUrl}/api/migrate/create`, {
141 method: "POST",
142 headers: { "Content-Type": "application/json" },
143 body: JSON.stringify({
144 service: props.service,
145 handle: props.handle,
146 password: props.password,
147 email: props.email,
148 ...(props.invite ? { invite: props.invite } : {}),
149 }),
150 });
151
152 console.log("Create account response status:", createRes.status);
153 const responseText = await createRes.text();
154 console.log("Create account response:", responseText);
155
156 if (!createRes.ok) {
157 try {
158 const json = JSON.parse(responseText);
159 throw new Error(json.message || "Failed to create account");
160 } catch {
161 throw new Error(responseText || "Failed to create account");
162 }
163 }
164
165 try {
166 const jsonData = JSON.parse(responseText);
167 if (!jsonData.success) {
168 throw new Error(jsonData.message || "Account creation failed");
169 }
170 } catch (e) {
171 console.log("Response is not JSON or lacks success field:", e);
172 }
173
174 this.updateStepStatus(0, "verifying");
175 const verified = await this.verifyStep(0);
176 if (!verified) {
177 console.log(
178 "Account creation: Verification failed, waiting for user action",
179 );
180 return;
181 }
182
183 this.updateStepStatus(0, "completed");
184 if (this.nextStepHook) {
185 await this.nextStepHook(0);
186 }
187 } catch (error) {
188 this.updateStepStatus(
189 0,
190 "error",
191 error instanceof Error ? error.message : String(error),
192 );
193 throw error;
194 }
195
196 // Step 2: Data Migration
197 await this.startDataMigration();
198 if (this.nextStepHook) {
199 await this.nextStepHook(1);
200 }
201
202 // Step 3: Identity Migration (request email)
203 await this.startIdentityMigration();
204 if (this.nextStepHook) {
205 await this.nextStepHook(2);
206 }
207
208 // Stop here - finalization will be called from handleIdentityMigration
209 // after user enters the token
210 return;
211 } catch (error) {
212 console.error("Migration error in try/catch:", error);
213 throw error;
214 }
215 }
216
217 async startDataMigration() {
218 // Step 2: Migrate Data
219 this.updateStepStatus(1, "in-progress");
220 console.log("Starting data migration...");
221
222 // Step 2.1: Migrate Repo
223 console.log("Data migration: Starting repo migration");
224 const repoRes = await fetch(`${this.baseUrl}/api/migrate/data/repo`, {
225 method: "POST",
226 headers: { "Content-Type": "application/json" },
227 });
228
229 console.log("Repo migration: Response status:", repoRes.status);
230 const repoText = await repoRes.text();
231 console.log("Repo migration: Raw response:", repoText);
232
233 if (!repoRes.ok) {
234 try {
235 const json = JSON.parse(repoText);
236 console.error("Repo migration: Error response:", json);
237 throw new Error(json.message || "Failed to migrate repo");
238 } catch {
239 console.error("Repo migration: Non-JSON error response:", repoText);
240 throw new Error(repoText || "Failed to migrate repo");
241 }
242 }
243
244 // Step 2.2: Migrate Blobs
245 console.log("Data migration: Starting blob migration");
246 const blobsRes = await fetch(`${this.baseUrl}/api/migrate/data/blobs`, {
247 method: "POST",
248 headers: { "Content-Type": "application/json" },
249 });
250
251 console.log("Blob migration: Response status:", blobsRes.status);
252 const blobsText = await blobsRes.text();
253 console.log("Blob migration: Raw response:", blobsText);
254
255 if (!blobsRes.ok) {
256 try {
257 const json = JSON.parse(blobsText);
258 console.error("Blob migration: Error response:", json);
259 throw new Error(json.message || "Failed to migrate blobs");
260 } catch {
261 console.error(
262 "Blob migration: Non-JSON error response:",
263 blobsText,
264 );
265 throw new Error(blobsText || "Failed to migrate blobs");
266 }
267 }
268
269 // Step 2.3: Migrate Preferences
270 console.log("Data migration: Starting preferences migration");
271 const prefsRes = await fetch(`${this.baseUrl}/api/migrate/data/prefs`, {
272 method: "POST",
273 headers: { "Content-Type": "application/json" },
274 });
275
276 console.log("Preferences migration: Response status:", prefsRes.status);
277 const prefsText = await prefsRes.text();
278 console.log("Preferences migration: Raw response:", prefsText);
279
280 if (!prefsRes.ok) {
281 try {
282 const json = JSON.parse(prefsText);
283 console.error("Preferences migration: Error response:", json);
284 throw new Error(json.message || "Failed to migrate preferences");
285 } catch {
286 console.error(
287 "Preferences migration: Non-JSON error response:",
288 prefsText,
289 );
290 throw new Error(prefsText || "Failed to migrate preferences");
291 }
292 }
293
294 console.log("Data migration: Starting verification");
295 this.updateStepStatus(1, "verifying");
296 const verified = await this.verifyStep(1);
297 console.log("Data migration: Verification result:", verified);
298 if (!verified) {
299 console.log(
300 "Data migration: Verification failed, waiting for user action",
301 );
302 return;
303 }
304
305 this.updateStepStatus(1, "completed");
306 // Continue to next step
307 //await this.continueToNextStep(2);
308 }
309
310 async startIdentityMigration() {
311 // Step 3: Start Identity Migration (just trigger the email)
312 this.updateStepStatus(2, "in-progress");
313 console.log("Starting identity migration...");
314
315 try {
316 const identityRes = await fetch(
317 `${this.baseUrl}/api/migrate/identity/request`,
318 {
319 method: "POST",
320 headers: { "Content-Type": "application/json" },
321 },
322 );
323
324 const identityData = await identityRes.text();
325
326 if (!identityRes.ok) {
327 try {
328 const json = JSON.parse(identityData);
329 throw new Error(
330 json.message || "Failed to start identity migration",
331 );
332 } catch {
333 throw new Error(
334 identityData || "Failed to start identity migration",
335 );
336 }
337 }
338
339 // Update the step to show token input
340 this.updateStepStatus(2, "in-progress");
341 } catch (error) {
342 this.updateStepStatus(
343 2,
344 "error",
345 error instanceof Error ? error.message : String(error),
346 );
347 throw error;
348 }
349 }
350
351 async handleIdentityMigration(token: string) {
352 if (!token) {
353 throw new Error("Token is required");
354 }
355
356 const identityRes = await fetch(
357 `${this.baseUrl}/api/migrate/identity/sign?token=${
358 encodeURIComponent(token)
359 }`,
360 {
361 method: "POST",
362 headers: { "Content-Type": "application/json" },
363 },
364 );
365
366 const identityData = await identityRes.text();
367 if (!identityRes.ok) {
368 try {
369 const json = JSON.parse(identityData);
370 throw new Error(
371 json.message || "Failed to complete identity migration",
372 );
373 } catch {
374 throw new Error(
375 identityData || "Failed to complete identity migration",
376 );
377 }
378 }
379
380 let data;
381 try {
382 data = JSON.parse(identityData);
383 if (!data.success) {
384 throw new Error(data.message || "Identity migration failed");
385 }
386 } catch {
387 throw new Error("Invalid response from server");
388 }
389
390 // Verify the identity migration succeeded
391 this.updateStepStatus(2, "verifying");
392 const verified = await this.verifyStep(2, true);
393 if (!verified) {
394 console.log(
395 "Identity migration: Verification failed after token submission",
396 );
397 throw new Error("Identity migration verification failed");
398 }
399
400 this.updateStepStatus(2, "completed");
401 }
402
403 async finalizeMigration() {
404 // Step 4: Finalize Migration
405 this.updateStepStatus(3, "in-progress");
406 const finalizeRes = await fetch(`${this.baseUrl}/api/migrate/finalize`, {
407 method: "POST",
408 headers: { "Content-Type": "application/json" },
409 });
410
411 const finalizeData = await finalizeRes.text();
412 if (!finalizeRes.ok) {
413 try {
414 const json = JSON.parse(finalizeData);
415 throw new Error(json.message || "Failed to finalize migration");
416 } catch {
417 throw new Error(finalizeData || "Failed to finalize migration");
418 }
419 }
420
421 try {
422 const jsonData = JSON.parse(finalizeData);
423 if (!jsonData.success) {
424 throw new Error(jsonData.message || "Finalization failed");
425 }
426 } catch {
427 throw new Error("Invalid response from server during finalization");
428 }
429
430 this.updateStepStatus(3, "verifying");
431 const verified = await this.verifyStep(3);
432 if (!verified) {
433 console.log(
434 "Finalization: Verification failed, waiting for user action",
435 );
436 return;
437 }
438
439 this.updateStepStatus(3, "completed");
440 }
441
442 async retryVerification(stepNum: number, steps: MigrationStep[]) {
443 console.log(`Retrying verification for step ${stepNum + 1}`);
444 const isManualSubmission = stepNum === 2 &&
445 steps[2].name ===
446 "Enter the token sent to your email to complete identity migration";
447 const verified = await this.verifyStep(stepNum, isManualSubmission);
448
449 if (verified && stepNum < 3) {
450 await this.continueToNextStep(stepNum + 1);
451 }
452 }
453
454 async verifyStep(
455 stepNum: number,
456 isManualSubmission: boolean = false,
457 ) {
458 console.log(`Verification: Starting step ${stepNum + 1}`);
459
460 // Skip automatic verification for step 2 (identity migration) unless it's after manual token submission
461 if (stepNum === 2 && !isManualSubmission) {
462 console.log(
463 `Verification: Skipping automatic verification for identity migration step`,
464 );
465 return false;
466 }
467
468 this.updateStepStatus(stepNum, "verifying");
469 try {
470 console.log(`Verification: Fetching status for step ${stepNum + 1}`);
471 const res = await fetch(
472 `${this.baseUrl}/api/migrate/status?step=${stepNum + 1}`,
473 );
474 console.log(`Verification: Status response status:`, res.status);
475 const data = await res.json();
476 console.log(`Verification: Status data for step ${stepNum + 1}:`, data);
477
478 if (data.ready) {
479 console.log(`Verification: Step ${stepNum + 1} is ready`);
480 this.updateStepStatus(stepNum, "completed");
481
482 // Reset retry state on success if callbacks are available
483 if (this.setRetryAttempts) {
484 this.setRetryAttempts((prev: any) => ({ ...prev, [stepNum]: 0 }));
485 }
486 if (this.setShowContinueAnyway) {
487 this.setShowContinueAnyway((prev: any) => ({
488 ...prev,
489 [stepNum]: false,
490 }));
491 }
492
493 return true;
494 } else {
495 console.log(
496 `Verification: Step ${stepNum + 1} is not ready:`,
497 data.reason,
498 );
499 const statusDetails = {
500 activated: data.activated,
501 validDid: data.validDid,
502 repoCommit: data.repoCommit,
503 repoRev: data.repoRev,
504 repoBlocks: data.repoBlocks,
505 expectedRecords: data.expectedRecords,
506 indexedRecords: data.indexedRecords,
507 privateStateValues: data.privateStateValues,
508 expectedBlobs: data.expectedBlobs,
509 importedBlobs: data.importedBlobs,
510 };
511 console.log(
512 `Verification: Step ${stepNum + 1} status details:`,
513 statusDetails,
514 );
515 const errorMessage = `${
516 data.reason || "Verification failed"
517 }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
518
519 // Track retry attempts only if callbacks are available
520 if (this.setRetryAttempts && this.setShowContinueAnyway) {
521 this.setRetryAttempts((prev: any) => ({
522 ...prev,
523 [stepNum]: (prev[stepNum] || 0) + 1,
524 }));
525
526 // Show continue anyway option if this is the second failure
527 this.setRetryAttempts((prev: any) => {
528 const currentAttempts = (prev[stepNum] || 0) + 1;
529 if (currentAttempts >= 2) {
530 this.setShowContinueAnyway!((prevShow: any) => ({
531 ...prevShow,
532 [stepNum]: true,
533 }));
534 }
535 return { ...prev, [stepNum]: currentAttempts };
536 });
537
538 this.updateStepStatus(stepNum, "error", errorMessage, true);
539 } else {
540 // No retry callbacks - just fail
541 this.updateStepStatus(stepNum, "error", errorMessage, false);
542 }
543
544 return false;
545 }
546 } catch (e) {
547 console.error(`Verification: Error in step ${stepNum + 1}:`, e);
548
549 // Track retry attempts only if callbacks are available
550 if (this.setRetryAttempts && this.setShowContinueAnyway) {
551 this.setRetryAttempts((prev: any) => {
552 const currentAttempts = (prev[stepNum] || 0) + 1;
553 if (currentAttempts >= 2) {
554 this.setShowContinueAnyway!((prevShow: any) => ({
555 ...prevShow,
556 [stepNum]: true,
557 }));
558 }
559 return { ...prev, [stepNum]: currentAttempts };
560 });
561
562 this.updateStepStatus(
563 stepNum,
564 "error",
565 e instanceof Error ? e.message : String(e),
566 true,
567 );
568 } else {
569 // No retry callbacks - just fail
570 this.updateStepStatus(
571 stepNum,
572 "error",
573 e instanceof Error ? e.message : String(e),
574 false,
575 );
576 }
577
578 return false;
579 }
580 }
581
582 async continueToNextStep(stepNum: number) {
583 console.log(`Continuing to step ${stepNum + 1}`);
584 try {
585 switch (stepNum) {
586 case 1:
587 await this.startDataMigration();
588 break;
589 case 2:
590 await this.startIdentityMigration();
591 break;
592 case 3:
593 await this.finalizeMigration();
594 break;
595 }
596
597 this.nextStepHook?.(stepNum);
598 } catch (error) {
599 console.error(`Error in step ${stepNum + 1}:`, error);
600 this.updateStepStatus(
601 stepNum,
602 "error",
603 error instanceof Error ? error.message : String(error),
604 );
605 }
606 }
607}