Client-side, single user live journal backed by AT Protocol
1import { agent } from "#app/oauth";
2import { makeXRPC, getATURIParts } from "#app";
3
4/**
5 * @template T
6 * @typedef {Object} ListRecordsResponse
7 * @prop {string} [cursor]
8 * @prop {T[]} records
9 */
10
11/**
12 * Split an array by `n` partitions
13 * @template {unknown[]} T
14 * @param {T} array
15 * @param {number} n - Must be greater than 1
16 * @returns {T[]}
17 */
18function partition(array, n) {
19 let local_idx = 0
20 let initial = true
21 return Object.values(array.reduce((acc, val, idx) => {
22 if (idx % n === 0 && !initial) local_idx++
23 if (initial) { initial = false } // hack
24 if (!acc[local_idx]) acc[local_idx] = []
25 acc[local_idx].push(val)
26
27 return acc
28 }, {}))
29}
30
31/**
32 * Check if target is eligible for the lexicon namespace migration (one-time)
33 * @param {string} target
34 * @returns {Promise<boolean>}
35 */
36export async function shouldMigrate(target) {
37 /** @type {{ collections: string[], [string]: any}} */
38 const { collections } = await fetch(makeXRPC("com.atproto.repo.describeRepo", {
39 repo: target
40 })).then(res => res.json())
41
42 return collections.includes("space.bunniesin.micro.log")
43}
44
45/**
46 * Retry with exponential backoff
47 * @template {() => unknown} T
48 * @param {T} fn - Function to call on retry
49 * @param {{initialDelay: number, maxAttempts: number}} [options]
50 * @async
51 * @returns {ReturnType<T>}
52 */
53async function retry(fn, options = { initialDelay: 200, maxAttempts: 10 }) {
54 const { initialDelay, maxAttempts } = options;
55 let attempt = 1
56 while (attempt <= maxAttempts) {
57 try {
58 return await fn()
59 } catch (err) {
60 if (attempt === maxAttempts) throw err
61
62 const t = initialDelay * Math.pow(2, attempt - 1);
63 const j = Math.random() * initialDelay
64 const d = t + j;
65
66 await new Promise((r) => setTimeout(() => r(), d))
67 attempt++
68 }
69 }
70}
71
72export async function queryAllRecordsToBeMigrated(target) {
73 /** @type {string?} */
74 let cursor = "";
75 /** @type {(import("./app.js").BunnyLogEntry & { rkey: string })[]} */
76 let records = [];
77
78 /**
79 * @returns {Promise<ListRecordsResponse<import("./app.js").BunnyLogEntry>>}
80 */
81 async function next() {
82 const res = await fetch(
83 makeXRPC("com.atproto.repo.listRecords", {
84 repo: target,
85 collection: "space.bunniesin.micro.log",
86 cursor,
87 }),
88 );
89
90 if (!res.ok) {
91 return retry(next); // /technically/ recursive, but it isn't
92 }
93
94 return await res.json();
95 }
96
97 while (cursor != null) {
98 let p = await next();
99 cursor = p.cursor ?? null;
100 records.push(...p.records.map(r => ({ value: r.value, rkey: getATURIParts(r.uri).rkey })))
101 }
102
103 return records
104}
105
106/**
107 * @asnyc
108 * @returns {boolean}
109 */
110export async function migrateRecordsToNewLexicon(target, batchSize = 50) {
111 /** @type {(import("./app.js").BunnyLogEntry & { rkey: string })[]}*/
112 const records = await queryAllRecordsToBeMigrated(target);
113
114 console.info("[MIGRATION]", "Records to be migrated:", records.length);
115
116 let currentBatch = 1;
117 // Part 2 --- Migrate them to the new namespace & lexicon using com.atproto.repo.applyWrites
118 for (const rx of partition(records, batchSize)) {
119 /** @type {any[]} */
120 const creates = rx.reduce((acc, r) => {
121 const { rkey, value } = {...r, value: {...r.value, $type: "space.bunniesin.log.entry" }} // this is stupid
122 return [...acc,
123 { $type: "com.atproto.repo.applyWrites#create", collection: "space.bunniesin.log.entry", rkey, value }];
124 }, [])
125
126 const deletes = rx.reduce((acc, r) => {
127 return [...acc, { $type: "com.atproto.repo.applyWrites#delete", collection: "space.bunniesin.micro.log", rkey: r.rkey}]
128 }, [])
129
130 let res = await agent.com.atproto.repo.applyWrites({
131 repo: agent.did,
132 writes: creates,
133 validate: false // Most, if not all PDSes do not include our lexicons, so validation is not possible
134 })
135
136 if (!res.success) throw new Error(`FAILED TO CREATE RECORDS!!!!! FUCK!!!!!\n${JSON.stringify(res)}`);
137
138 console.info("[MIGRATION]", `Created ${creates.length} records.`)
139
140
141 res = await agent.com.atproto.repo.applyWrites({
142 repo: agent.did,
143 writes: deletes,
144 validate: false
145 })
146
147 if (!res.success) throw new Error(`FAILED TO DELETE OLD RECORDS!!!!! FUCK!!!!!\n${JSON.stringify(res)}`);
148
149 console.info("[MIGRATION]", `Deleted ${deletes.length} old records.`)
150
151 console.info("[MIGRATION]", `Batch ${currentBatch} was migrated without issue.`)
152 currentBatch++
153 }
154
155 console.info("[MIGRATION]", "All batches were migrated successfully.")
156
157 return true
158}