a tool for shared writing and social publishing
1import { createClient } from "@supabase/supabase-js";
2import { Database, Json } from "supabase/database.types";
3import { jsonToLex } from "@atproto/lexicon";
4import {
5 PubLeafletDocument,
6 SiteStandardDocument,
7} from "lexicons/api";
8
9const supabase = createClient<Database>(
10 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string,
11 process.env.SUPABASE_SERVICE_ROLE_KEY as string,
12);
13
14const BATCH_SIZE = 100;
15const DRY_RUN = process.argv.includes("--dry-run");
16
17interface ValidationIssue {
18 uri: string;
19 error: string;
20 fixed?: boolean;
21}
22
23// Fields that should be integers based on lexicon schemas
24const INTEGER_FIELDS = new Set([
25 "x", "y", "width", "height", "rotation", "offset", "level",
26 "r", "g", "b", "a",
27]);
28
29/**
30 * Recursively fix float values that should be integers
31 */
32function fixFloatsToIntegers(obj: unknown): { value: unknown; modified: boolean } {
33 if (obj === null || obj === undefined) {
34 return { value: obj, modified: false };
35 }
36
37 if (Array.isArray(obj)) {
38 let modified = false;
39 const newArr = obj.map((item) => {
40 const result = fixFloatsToIntegers(item);
41 if (result.modified) modified = true;
42 return result.value;
43 });
44 return { value: newArr, modified };
45 }
46
47 if (typeof obj === "object") {
48 let modified = false;
49 const newObj: Record<string, unknown> = {};
50
51 for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
52 if (INTEGER_FIELDS.has(key) && typeof value === "number" && !Number.isInteger(value)) {
53 newObj[key] = Math.round(value);
54 modified = true;
55 } else {
56 const result = fixFloatsToIntegers(value);
57 newObj[key] = result.value;
58 if (result.modified) modified = true;
59 }
60 }
61
62 return { value: newObj, modified };
63 }
64
65 return { value: obj, modified: false };
66}
67
68async function validateDocuments(): Promise<void> {
69 console.log("=".repeat(60));
70 console.log("Document Validation Script");
71 console.log(`Mode: ${DRY_RUN ? "DRY RUN" : "FIX MODE"}`);
72 console.log("=".repeat(60));
73
74 const issues: ValidationIssue[] = [];
75 let offset = 0;
76 let totalProcessed = 0;
77 let totalWithIssues = 0;
78 let totalFixed = 0;
79
80 console.log("\nFetching documents in batches...\n");
81
82 while (true) {
83 const { data: documents, error } = await supabase
84 .from("documents")
85 .select("uri, data")
86 .range(offset, offset + BATCH_SIZE - 1)
87 .order("uri");
88
89 if (error) {
90 console.error("Error fetching documents:", error);
91 break;
92 }
93
94 if (!documents || documents.length === 0) {
95 break;
96 }
97
98 for (const doc of documents) {
99 totalProcessed++;
100
101 const rawData = doc.data;
102 if (!rawData || typeof rawData !== "object") {
103 issues.push({ uri: doc.uri, error: "Document data is null or not an object" });
104 totalWithIssues++;
105 continue;
106 }
107
108 // Convert JSON to lexicon types (handles BlobRef conversion)
109 const data = jsonToLex(rawData);
110
111 // Determine document type and validate
112 const $type = (data as Record<string, unknown>).$type;
113
114 let result: { success: boolean; error?: unknown };
115 let validateFn: (v: unknown) => { success: boolean; error?: unknown };
116
117 if ($type === "site.standard.document") {
118 validateFn = SiteStandardDocument.validateRecord;
119 result = validateFn(data);
120 } else if (
121 $type === "pub.leaflet.document" ||
122 // Legacy records without $type but with pages array
123 (Array.isArray((data as Record<string, unknown>).pages) &&
124 typeof (data as Record<string, unknown>).author === "string")
125 ) {
126 validateFn = PubLeafletDocument.validateRecord;
127 result = validateFn(data);
128 } else {
129 issues.push({ uri: doc.uri, error: `Unknown document type: ${$type || "undefined"}` });
130 totalWithIssues++;
131 continue;
132 }
133
134 if (!result.success) {
135 const errorStr = String(result.error);
136
137 // Check if it's an integer validation error we can fix
138 if (errorStr.includes("must be an integer")) {
139 const { value: fixedData, modified } = fixFloatsToIntegers(rawData);
140
141 if (modified) {
142 // Validate the fixed data
143 const fixedResult = validateFn(jsonToLex(fixedData));
144
145 if (fixedResult.success) {
146 if (!DRY_RUN) {
147 const { error: updateError } = await supabase
148 .from("documents")
149 .update({ data: fixedData as Json })
150 .eq("uri", doc.uri);
151
152 if (updateError) {
153 issues.push({ uri: doc.uri, error: `Fix failed: ${updateError.message}` });
154 totalWithIssues++;
155 } else {
156 issues.push({ uri: doc.uri, error: errorStr, fixed: true });
157 totalFixed++;
158 }
159 } else {
160 issues.push({ uri: doc.uri, error: errorStr, fixed: true });
161 totalFixed++;
162 }
163 } else {
164 // Still has issues after fix
165 issues.push({ uri: doc.uri, error: String(fixedResult.error) });
166 totalWithIssues++;
167 }
168 } else {
169 issues.push({ uri: doc.uri, error: errorStr });
170 totalWithIssues++;
171 }
172 } else {
173 issues.push({ uri: doc.uri, error: errorStr });
174 totalWithIssues++;
175 }
176 }
177 }
178
179 process.stdout.write(
180 `\rProcessed ${totalProcessed} documents, ${totalFixed} fixed, ${totalWithIssues} with issues...`,
181 );
182 offset += BATCH_SIZE;
183 }
184
185 console.log("\n");
186
187 // Print summary
188 console.log("=".repeat(60));
189 console.log("VALIDATION SUMMARY");
190 console.log("=".repeat(60));
191 console.log(`Total documents processed: ${totalProcessed}`);
192 console.log(`Documents fixed: ${totalFixed}${DRY_RUN ? " (dry run)" : ""}`);
193 console.log(`Documents with unfixable issues: ${totalWithIssues}`);
194 console.log("");
195
196 if (issues.length > 0) {
197 console.log("DETAILS:");
198 console.log("-".repeat(60));
199
200 for (const issue of issues) {
201 const status = issue.fixed ? "[FIXED]" : "[ERROR]";
202 console.log(`\n${status} ${issue.uri}`);
203 console.log(` ${issue.error}`);
204 }
205 } else {
206 console.log("No issues found!");
207 }
208
209 console.log("\n" + "=".repeat(60));
210}
211
212validateDocuments().catch((err) => {
213 console.error("Fatal error:", err);
214 process.exit(1);
215});