A personal website powered by Astro and ATProto
1import { AtprotoBrowser } from '../atproto/atproto-browser';
2import { loadConfig } from '../config/site';
3import fs from 'fs/promises';
4import path from 'path';
5
6export interface CollectionType {
7 name: string;
8 description: string;
9 service: string;
10 sampleRecords: any[];
11 generatedTypes: string;
12 $types: string[];
13}
14
15export interface BuildTimeDiscovery {
16 collections: CollectionType[];
17 totalCollections: number;
18 totalRecords: number;
19 generatedAt: string;
20 repository: {
21 handle: string;
22 did: string;
23 recordCount: number;
24 };
25}
26
27export class CollectionDiscovery {
28 private browser: AtprotoBrowser;
29 private config: any;
30
31 constructor() {
32 this.config = loadConfig();
33 this.browser = new AtprotoBrowser();
34 }
35
36 // Discover all collections and generate types
37 async discoverCollections(identifier: string): Promise<BuildTimeDiscovery> {
38 console.log('🔍 Starting collection discovery for:', identifier);
39
40 try {
41 // Get repository info
42 const repoInfo = await this.browser.getRepoInfo(identifier);
43 if (!repoInfo) {
44 throw new Error(`Could not get repository info for: ${identifier}`);
45 }
46
47 console.log('📊 Repository info:', {
48 handle: repoInfo.handle,
49 did: repoInfo.did,
50 collections: repoInfo.collections.length,
51 recordCount: repoInfo.recordCount
52 });
53
54 const collections: CollectionType[] = [];
55 let totalRecords = 0;
56
57 // Process each collection
58 for (const collectionName of repoInfo.collections) {
59 console.log(`📦 Processing collection: ${collectionName}`);
60
61 const collectionType = await this.processCollection(identifier, collectionName);
62 if (collectionType) {
63 collections.push(collectionType);
64 totalRecords += collectionType.sampleRecords.length;
65 }
66 }
67
68 const discovery: BuildTimeDiscovery = {
69 collections,
70 totalCollections: collections.length,
71 totalRecords,
72 generatedAt: new Date().toISOString(),
73 repository: {
74 handle: repoInfo.handle,
75 did: repoInfo.did,
76 recordCount: repoInfo.recordCount
77 }
78 };
79
80 console.log(`✅ Discovery complete: ${collections.length} collections, ${totalRecords} records`);
81 return discovery;
82
83 } catch (error) {
84 console.error('Error discovering collections:', error);
85 throw error;
86 }
87 }
88
89 // Process a single collection
90 private async processCollection(identifier: string, collectionName: string): Promise<CollectionType | null> {
91 try {
92 // Get records from collection
93 const records = await this.browser.getCollectionRecords(identifier, collectionName, 10);
94 if (!records || records.records.length === 0) {
95 console.log(`⚠️ No records found in collection: ${collectionName}`);
96 return null;
97 }
98
99 // Group records by $type
100 const recordsByType = new Map<string, any[]>();
101 for (const record of records.records) {
102 const $type = record.$type || 'unknown';
103 if (!recordsByType.has($type)) {
104 recordsByType.set($type, []);
105 }
106 recordsByType.get($type)!.push(record.value);
107 }
108
109 // Generate types for each $type
110 const generatedTypes: string[] = [];
111 const $types: string[] = [];
112
113 for (const [$type, typeRecords] of recordsByType) {
114 if ($type === 'unknown') continue;
115
116 $types.push($type);
117 const typeDefinition = this.generateTypeDefinition($type, typeRecords);
118 generatedTypes.push(typeDefinition);
119 }
120
121 // Create collection type
122 const collectionType: CollectionType = {
123 name: collectionName,
124 description: this.getCollectionDescription(collectionName),
125 service: this.inferService(collectionName),
126 sampleRecords: records.records.slice(0, 3).map(r => r.value),
127 generatedTypes: generatedTypes.join('\n\n'),
128 $types
129 };
130
131 console.log(`✅ Processed collection ${collectionName}: ${$types.length} types`);
132 return collectionType;
133
134 } catch (error) {
135 console.error(`Error processing collection ${collectionName}:`, error);
136 return null;
137 }
138 }
139
140 // Generate TypeScript type definition
141 private generateTypeDefinition($type: string, records: any[]): string {
142 if (records.length === 0) return '';
143
144 // Analyze the first record to understand the structure
145 const sampleRecord = records[0];
146 const properties = this.extractProperties(sampleRecord);
147
148 // Generate interface
149 const interfaceName = this.$typeToInterfaceName($type);
150 let typeDefinition = `export interface ${interfaceName} {\n`;
151
152 // Add $type property
153 typeDefinition += ` $type: '${$type}';\n`;
154
155 // Add other properties
156 for (const [key, value] of Object.entries(properties)) {
157 const type = this.inferType(value);
158 typeDefinition += ` ${key}?: ${type};\n`;
159 }
160
161 typeDefinition += '}\n';
162
163 return typeDefinition;
164 }
165
166 // Extract properties from a record
167 private extractProperties(obj: any, maxDepth: number = 3, currentDepth: number = 0): Record<string, any> {
168 if (currentDepth >= maxDepth || !obj || typeof obj !== 'object') {
169 return {};
170 }
171
172 const properties: Record<string, any> = {};
173
174 for (const [key, value] of Object.entries(obj)) {
175 if (key === '$type') continue; // Skip $type as it's handled separately
176
177 if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
178 // Nested object
179 const nestedProps = this.extractProperties(value, maxDepth, currentDepth + 1);
180 if (Object.keys(nestedProps).length > 0) {
181 properties[key] = nestedProps;
182 }
183 } else {
184 // Simple property
185 properties[key] = value;
186 }
187 }
188
189 return properties;
190 }
191
192 // Infer TypeScript type from value
193 private inferType(value: any): string {
194 if (value === null) return 'null';
195 if (value === undefined) return 'undefined';
196
197 const type = typeof value;
198
199 switch (type) {
200 case 'string':
201 return 'string';
202 case 'number':
203 return 'number';
204 case 'boolean':
205 return 'boolean';
206 case 'object':
207 if (Array.isArray(value)) {
208 if (value.length === 0) return 'any[]';
209 const elementType = this.inferType(value[0]);
210 return `${elementType}[]`;
211 }
212 return 'Record<string, any>';
213 default:
214 return 'any';
215 }
216 }
217
218 // Convert $type to interface name
219 private $typeToInterfaceName($type: string): string {
220 // Convert app.bsky.feed.post to AppBskyFeedPost
221 return $type
222 .split('.')
223 .map(part => part.charAt(0).toUpperCase() + part.slice(1))
224 .join('');
225 }
226
227 // Get collection description
228 private getCollectionDescription(collectionName: string): string {
229 const descriptions: Record<string, string> = {
230 'app.bsky.feed.post': 'Bluesky posts',
231 'app.bsky.actor.profile': 'Bluesky profile information',
232 'app.bsky.feed.generator': 'Bluesky custom feeds',
233 'app.bsky.graph.follow': 'Bluesky follow relationships',
234 'app.bsky.graph.block': 'Bluesky block relationships',
235 'app.bsky.feed.like': 'Bluesky like records',
236 'app.bsky.feed.repost': 'Bluesky repost records',
237 'social.grain.gallery': 'Grain.social image galleries',
238 'grain.social.feed.gallery': 'Grain.social galleries',
239 'grain.social.feed.post': 'Grain.social posts',
240 'grain.social.actor.profile': 'Grain.social profile information',
241 };
242
243 return descriptions[collectionName] || `${collectionName} records`;
244 }
245
246 // Infer service from collection name
247 private inferService(collectionName: string): string {
248 if (collectionName.startsWith('grain.social') || collectionName.startsWith('social.grain')) {
249 return 'grain.social';
250 }
251 if (collectionName.startsWith('app.bsky')) {
252 return 'bsky.app';
253 }
254 if (collectionName.startsWith('sh.tangled')) {
255 return 'sh.tangled';
256 }
257 return 'unknown';
258 }
259
260 // Save discovery results to file
261 async saveDiscoveryResults(discovery: BuildTimeDiscovery, outputPath: string): Promise<void> {
262 try {
263 // Create output directory if it doesn't exist
264 const outputDir = path.dirname(outputPath);
265 await fs.mkdir(outputDir, { recursive: true });
266
267 // Save discovery metadata
268 const metadataPath = outputPath.replace('.ts', '.json');
269 await fs.writeFile(metadataPath, JSON.stringify(discovery, null, 2));
270
271 // Generate TypeScript file with all types
272 let typesContent = '// Auto-generated types from collection discovery\n';
273 typesContent += `// Generated at: ${discovery.generatedAt}\n`;
274 typesContent += `// Repository: ${discovery.repository.handle} (${discovery.repository.did})\n`;
275 typesContent += `// Collections: ${discovery.totalCollections}, Records: ${discovery.totalRecords}\n\n`;
276
277 // Add all generated types
278 for (const collection of discovery.collections) {
279 typesContent += `// Collection: ${collection.name}\n`;
280 typesContent += `// Service: ${collection.service}\n`;
281 typesContent += `// Types: ${collection.$types.join(', ')}\n`;
282 typesContent += collection.generatedTypes;
283 typesContent += '\n\n';
284 }
285
286 // Add union type for all discovered types
287 const allTypes = discovery.collections.flatMap(c => c.$types);
288 if (allTypes.length > 0) {
289 typesContent += '// Union type for all discovered types\n';
290 typesContent += `export type DiscoveredTypes = ${allTypes.map(t => `'${t}'`).join(' | ')};\n\n`;
291 }
292
293 await fs.writeFile(outputPath, typesContent);
294
295 console.log(`💾 Saved discovery results to: ${outputPath}`);
296 console.log(`📊 Generated ${discovery.totalCollections} collection types`);
297
298 } catch (error) {
299 console.error('Error saving discovery results:', error);
300 throw error;
301 }
302 }
303}