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 // Step 4: Finalize Migration
209 await this.finalizeMigration();
210 if (this.nextStepHook) {
211 await this.nextStepHook(3);
212 }
213
214 return;
215 } catch (error) {
216 console.error("Migration error in try/catch:", error);
217 throw error;
218 }
219 }
220
221 async startDataMigration() {
222 // Step 2: Migrate Data
223 this.updateStepStatus(1, "in-progress");
224 console.log("Starting data migration...");
225
226 // Step 2.1: Migrate Repo
227 console.log("Data migration: Starting repo migration");
228 const repoRes = await fetch(`${this.baseUrl}/api/migrate/data/repo`, {
229 method: "POST",
230 headers: { "Content-Type": "application/json" },
231 });
232
233 console.log("Repo migration: Response status:", repoRes.status);
234 const repoText = await repoRes.text();
235 console.log("Repo migration: Raw response:", repoText);
236
237 if (!repoRes.ok) {
238 try {
239 const json = JSON.parse(repoText);
240 console.error("Repo migration: Error response:", json);
241 throw new Error(json.message || "Failed to migrate repo");
242 } catch {
243 console.error("Repo migration: Non-JSON error response:", repoText);
244 throw new Error(repoText || "Failed to migrate repo");
245 }
246 }
247
248 // Step 2.2: Migrate Blobs
249 console.log("Data migration: Starting blob migration");
250 const blobsRes = await fetch(`${this.baseUrl}/api/migrate/data/blobs`, {
251 method: "POST",
252 headers: { "Content-Type": "application/json" },
253 });
254
255 console.log("Blob migration: Response status:", blobsRes.status);
256 const blobsText = await blobsRes.text();
257 console.log("Blob migration: Raw response:", blobsText);
258
259 if (!blobsRes.ok) {
260 try {
261 const json = JSON.parse(blobsText);
262 console.error("Blob migration: Error response:", json);
263 throw new Error(json.message || "Failed to migrate blobs");
264 } catch {
265 console.error(
266 "Blob migration: Non-JSON error response:",
267 blobsText,
268 );
269 throw new Error(blobsText || "Failed to migrate blobs");
270 }
271 }
272
273 // Step 2.3: Migrate Preferences
274 console.log("Data migration: Starting preferences migration");
275 const prefsRes = await fetch(`${this.baseUrl}/api/migrate/data/prefs`, {
276 method: "POST",
277 headers: { "Content-Type": "application/json" },
278 });
279
280 console.log("Preferences migration: Response status:", prefsRes.status);
281 const prefsText = await prefsRes.text();
282 console.log("Preferences migration: Raw response:", prefsText);
283
284 if (!prefsRes.ok) {
285 try {
286 const json = JSON.parse(prefsText);
287 console.error("Preferences migration: Error response:", json);
288 throw new Error(json.message || "Failed to migrate preferences");
289 } catch {
290 console.error(
291 "Preferences migration: Non-JSON error response:",
292 prefsText,
293 );
294 throw new Error(prefsText || "Failed to migrate preferences");
295 }
296 }
297
298 console.log("Data migration: Starting verification");
299 this.updateStepStatus(1, "verifying");
300 const verified = await this.verifyStep(1);
301 console.log("Data migration: Verification result:", verified);
302 if (!verified) {
303 console.log(
304 "Data migration: Verification failed, waiting for user action",
305 );
306 return;
307 }
308
309 this.updateStepStatus(1, "completed");
310 // Continue to next step
311 //await this.continueToNextStep(2);
312 }
313
314 async startIdentityMigration() {
315 // Step 3: Start Identity Migration (just trigger the email)
316 this.updateStepStatus(2, "in-progress");
317 console.log("Starting identity migration...");
318
319 try {
320 const identityRes = await fetch(
321 `${this.baseUrl}/api/migrate/identity/request`,
322 {
323 method: "POST",
324 headers: { "Content-Type": "application/json" },
325 },
326 );
327
328 const identityData = await identityRes.text();
329
330 if (!identityRes.ok) {
331 try {
332 const json = JSON.parse(identityData);
333 throw new Error(
334 json.message || "Failed to start identity migration",
335 );
336 } catch {
337 throw new Error(
338 identityData || "Failed to start identity migration",
339 );
340 }
341 }
342
343 // Update the step to show token input
344 this.updateStepStatus(2, "in-progress");
345 } catch (error) {
346 this.updateStepStatus(
347 2,
348 "error",
349 error instanceof Error ? error.message : String(error),
350 );
351 throw error;
352 }
353 }
354
355 async handleIdentityMigration(token: string) {
356 if (!token) {
357 throw new Error("Token is required");
358 }
359
360 const identityRes = await fetch(
361 `${this.baseUrl}/api/migrate/identity/sign?token=${
362 encodeURIComponent(token)
363 }`,
364 {
365 method: "POST",
366 headers: { "Content-Type": "application/json" },
367 },
368 );
369
370 const identityData = await identityRes.text();
371 if (!identityRes.ok) {
372 try {
373 const json = JSON.parse(identityData);
374 throw new Error(
375 json.message || "Failed to complete identity migration",
376 );
377 } catch {
378 throw new Error(
379 identityData || "Failed to complete identity migration",
380 );
381 }
382 }
383
384 let data;
385 try {
386 data = JSON.parse(identityData);
387 if (!data.success) {
388 throw new Error(data.message || "Identity migration failed");
389 }
390 } catch {
391 throw new Error("Invalid response from server");
392 }
393
394 // Verify the identity migration succeeded
395 this.updateStepStatus(2, "verifying");
396 const verified = await this.verifyStep(2, true);
397 if (!verified) {
398 console.log(
399 "Identity migration: Verification failed after token submission",
400 );
401 throw new Error("Identity migration verification failed");
402 }
403
404 this.updateStepStatus(2, "completed");
405 }
406
407 async finalizeMigration() {
408 // Step 4: Finalize Migration
409 this.updateStepStatus(3, "in-progress");
410 const finalizeRes = await fetch(`${this.baseUrl}/api/migrate/finalize`, {
411 method: "POST",
412 headers: { "Content-Type": "application/json" },
413 });
414
415 const finalizeData = await finalizeRes.text();
416 if (!finalizeRes.ok) {
417 try {
418 const json = JSON.parse(finalizeData);
419 throw new Error(json.message || "Failed to finalize migration");
420 } catch {
421 throw new Error(finalizeData || "Failed to finalize migration");
422 }
423 }
424
425 try {
426 const jsonData = JSON.parse(finalizeData);
427 if (!jsonData.success) {
428 throw new Error(jsonData.message || "Finalization failed");
429 }
430 } catch {
431 throw new Error("Invalid response from server during finalization");
432 }
433
434 this.updateStepStatus(3, "verifying");
435 const verified = await this.verifyStep(3);
436 if (!verified) {
437 console.log(
438 "Finalization: Verification failed, waiting for user action",
439 );
440 return;
441 }
442
443 this.updateStepStatus(3, "completed");
444 }
445
446 async retryVerification(stepNum: number, steps: MigrationStep[]) {
447 console.log(`Retrying verification for step ${stepNum + 1}`);
448 const isManualSubmission = stepNum === 2 &&
449 steps[2].name ===
450 "Enter the token sent to your email to complete identity migration";
451 const verified = await this.verifyStep(stepNum, isManualSubmission);
452
453 if (verified && stepNum < 3) {
454 await this.continueToNextStep(stepNum + 1);
455 }
456 }
457
458 async verifyStep(
459 stepNum: number,
460 isManualSubmission: boolean = false,
461 ) {
462 console.log(`Verification: Starting step ${stepNum + 1}`);
463
464 // Skip automatic verification for step 2 (identity migration) unless it's after manual token submission
465 if (stepNum === 2 && !isManualSubmission) {
466 console.log(
467 `Verification: Skipping automatic verification for identity migration step`,
468 );
469 return false;
470 }
471
472 this.updateStepStatus(stepNum, "verifying");
473 try {
474 console.log(`Verification: Fetching status for step ${stepNum + 1}`);
475 const res = await fetch(
476 `${this.baseUrl}/api/migrate/status?step=${stepNum + 1}`,
477 );
478 console.log(`Verification: Status response status:`, res.status);
479 const data = await res.json();
480 console.log(`Verification: Status data for step ${stepNum + 1}:`, data);
481
482 if (data.ready) {
483 console.log(`Verification: Step ${stepNum + 1} is ready`);
484 this.updateStepStatus(stepNum, "completed");
485
486 // Reset retry state on success if callbacks are available
487 if (this.setRetryAttempts) {
488 this.setRetryAttempts((prev: any) => ({ ...prev, [stepNum]: 0 }));
489 }
490 if (this.setShowContinueAnyway) {
491 this.setShowContinueAnyway((prev: any) => ({
492 ...prev,
493 [stepNum]: false,
494 }));
495 }
496
497 return true;
498 } else {
499 console.log(
500 `Verification: Step ${stepNum + 1} is not ready:`,
501 data.reason,
502 );
503 const statusDetails = {
504 activated: data.activated,
505 validDid: data.validDid,
506 repoCommit: data.repoCommit,
507 repoRev: data.repoRev,
508 repoBlocks: data.repoBlocks,
509 expectedRecords: data.expectedRecords,
510 indexedRecords: data.indexedRecords,
511 privateStateValues: data.privateStateValues,
512 expectedBlobs: data.expectedBlobs,
513 importedBlobs: data.importedBlobs,
514 };
515 console.log(
516 `Verification: Step ${stepNum + 1} status details:`,
517 statusDetails,
518 );
519 const errorMessage = `${
520 data.reason || "Verification failed"
521 }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`;
522
523 // Track retry attempts only if callbacks are available
524 if (this.setRetryAttempts && this.setShowContinueAnyway) {
525 this.setRetryAttempts((prev: any) => ({
526 ...prev,
527 [stepNum]: (prev[stepNum] || 0) + 1,
528 }));
529
530 // Show continue anyway option if this is the second failure
531 this.setRetryAttempts((prev: any) => {
532 const currentAttempts = (prev[stepNum] || 0) + 1;
533 if (currentAttempts >= 2) {
534 this.setShowContinueAnyway!((prevShow: any) => ({
535 ...prevShow,
536 [stepNum]: true,
537 }));
538 }
539 return { ...prev, [stepNum]: currentAttempts };
540 });
541
542 this.updateStepStatus(stepNum, "error", errorMessage, true);
543 } else {
544 // No retry callbacks - just fail
545 this.updateStepStatus(stepNum, "error", errorMessage, false);
546 }
547
548 return false;
549 }
550 } catch (e) {
551 console.error(`Verification: Error in step ${stepNum + 1}:`, e);
552
553 // Track retry attempts only if callbacks are available
554 if (this.setRetryAttempts && this.setShowContinueAnyway) {
555 this.setRetryAttempts((prev: any) => {
556 const currentAttempts = (prev[stepNum] || 0) + 1;
557 if (currentAttempts >= 2) {
558 this.setShowContinueAnyway!((prevShow: any) => ({
559 ...prevShow,
560 [stepNum]: true,
561 }));
562 }
563 return { ...prev, [stepNum]: currentAttempts };
564 });
565
566 this.updateStepStatus(
567 stepNum,
568 "error",
569 e instanceof Error ? e.message : String(e),
570 true,
571 );
572 } else {
573 // No retry callbacks - just fail
574 this.updateStepStatus(
575 stepNum,
576 "error",
577 e instanceof Error ? e.message : String(e),
578 false,
579 );
580 }
581
582 return false;
583 }
584 }
585
586 async continueToNextStep(stepNum: number) {
587 console.log(`Continuing to step ${stepNum + 1}`);
588 try {
589 switch (stepNum) {
590 case 1:
591 await this.startDataMigration();
592 break;
593 case 2:
594 await this.startIdentityMigration();
595 break;
596 case 3:
597 await this.finalizeMigration();
598 break;
599 }
600
601 this.nextStepHook?.(stepNum);
602 } catch (error) {
603 console.error(`Error in step ${stepNum + 1}:`, error);
604 this.updateStepStatus(
605 stepNum,
606 "error",
607 error instanceof Error ? error.message : String(error),
608 );
609 }
610 }
611}