The Appview for the kipclip.com atproto bookmarking service
1/**
2 * Background migration: moves $enriched data from bookmark records
3 * to annotation sidecar records.
4 *
5 * Triggered fire-and-forget from /api/initial-data after the response is sent.
6 * Only runs if the annotation scope is available.
7 */
8
9import { ANNOTATION_COLLECTION, BOOKMARK_COLLECTION } from "./route-utils.ts";
10import type { AnnotationRecord } from "../shared/types.ts";
11
12const BATCH_SIZE = 5;
13
14/**
15 * Find bookmarks with $enriched data that lack a corresponding annotation,
16 * create the annotation, and clean the bookmark record.
17 */
18export async function migrateAnnotations(
19 oauthSession: any,
20 bookmarkRecords: any[],
21 annotationMap: Map<string, AnnotationRecord>,
22): Promise<void> {
23 // Find bookmarks that need migration:
24 // - Have $enriched data or top-level title
25 // - Don't have a corresponding annotation
26 const needsMigration = bookmarkRecords.filter((record) => {
27 const rkey = record.uri.split("/").pop();
28 if (!rkey) return false;
29 if (annotationMap.has(rkey)) return false;
30 return record.value.$enriched || record.value.title;
31 });
32
33 if (needsMigration.length === 0) return;
34
35 console.log(
36 `Annotation migration: ${needsMigration.length} bookmarks to migrate`,
37 );
38
39 // Process in batches to avoid rate limits
40 for (let i = 0; i < needsMigration.length; i += BATCH_SIZE) {
41 const batch = needsMigration.slice(i, i + BATCH_SIZE);
42
43 await Promise.all(batch.map(async (record: any) => {
44 const rkey = record.uri.split("/").pop();
45 if (!rkey) return;
46
47 try {
48 const enriched = record.value.$enriched || {};
49
50 // Create annotation sidecar
51 const annotation: AnnotationRecord = {
52 subject: record.uri,
53 title: enriched.title || record.value.title,
54 description: enriched.description,
55 favicon: enriched.favicon,
56 image: enriched.image,
57 createdAt: record.value.createdAt,
58 };
59
60 const annResponse = await oauthSession.makeRequest(
61 "POST",
62 `${oauthSession.pdsUrl}/xrpc/com.atproto.repo.putRecord`,
63 {
64 headers: { "Content-Type": "application/json" },
65 body: JSON.stringify({
66 repo: oauthSession.did,
67 collection: ANNOTATION_COLLECTION,
68 rkey,
69 record: annotation,
70 }),
71 },
72 );
73
74 if (!annResponse.ok) {
75 console.error(`Migration: failed to create annotation for ${rkey}`);
76 return;
77 }
78
79 // Clean the bookmark record (remove $enriched and top-level title)
80 const cleanRecord: Record<string, unknown> = {
81 subject: record.value.subject,
82 createdAt: record.value.createdAt,
83 tags: record.value.tags || [],
84 };
85
86 await oauthSession.makeRequest(
87 "POST",
88 `${oauthSession.pdsUrl}/xrpc/com.atproto.repo.putRecord`,
89 {
90 headers: { "Content-Type": "application/json" },
91 body: JSON.stringify({
92 repo: oauthSession.did,
93 collection: BOOKMARK_COLLECTION,
94 rkey,
95 record: cleanRecord,
96 }),
97 },
98 );
99 } catch (err) {
100 console.error(`Migration: error migrating bookmark ${rkey}:`, err);
101 }
102 }));
103
104 // Small delay between batches
105 if (i + BATCH_SIZE < needsMigration.length) {
106 await new Promise((resolve) => setTimeout(resolve, 200));
107 }
108 }
109
110 console.log("Annotation migration: complete");
111}