Highly ambitious ATProtocol AppView service and sdks
at fix-postgres 395 lines 11 kB view raw
1// @ts-ignore: ts-morph npm import 2import { Project } from "ts-morph"; 3 4export interface LexiconProperty { 5 type: string; 6 description?: string; 7 format?: string; 8 knownValues?: string[]; 9 items?: LexiconProperty; 10 ref?: string; 11 refs?: string[]; 12 closed?: boolean; 13 [key: string]: unknown; 14} 15 16export interface LexiconRecord { 17 type: string; 18 properties?: Record<string, LexiconProperty>; 19 required?: string[]; 20} 21 22export interface LexiconParameters { 23 type: "params"; 24 properties?: Record<string, LexiconProperty>; 25 required?: string[]; 26} 27 28export interface LexiconIO { 29 encoding: string; 30 schema?: LexiconProperty; 31} 32 33export interface LexiconDefinition { 34 type: string; 35 description?: string; 36 // Record type fields 37 record?: LexiconRecord; 38 key?: string; 39 // Query/Procedure fields 40 parameters?: LexiconParameters; 41 input?: LexiconIO; 42 output?: LexiconIO; 43 // Generic schema fields 44 properties?: Record<string, LexiconProperty>; 45 required?: string[]; 46 refs?: string[]; 47 closed?: boolean; 48 items?: LexiconProperty; 49 knownValues?: string[]; 50 ref?: string; 51 [key: string]: unknown; 52} 53 54export interface Lexicon { 55 id: string; 56 definitions?: Record<string, LexiconDefinition>; 57} 58 59export interface GenerateOptions { 60 sliceUri: string; 61} 62 63// Normalize lexicons to use consistent property names 64export function normalizeLexicons(lexicons: unknown[]): Lexicon[] { 65 return lexicons.map((lex: unknown) => { 66 const lexObj = lex as Record<string, unknown>; 67 return { 68 ...lexObj, 69 // TODO: Fix upstream to use 'id' and 'defs' consistently 70 id: (lexObj.id as string) || (lexObj.nsid as string), // Normalize nsid to id 71 definitions: 72 (lexObj.defs as Record<string, LexiconDefinition>) || 73 (lexObj.definitions as Record<string, LexiconDefinition>), // Normalize defs to definitions 74 }; 75 }); 76} 77 78// Convert NSID to PascalCase (for record types, no "Record" suffix) 79export function nsidToPascalCase(nsid: string): string { 80 return nsid 81 .split(".") 82 .map((part) => 83 part 84 .split(/[-_]/) 85 .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 86 .join("") 87 ) 88 .join(""); 89} 90 91// Convert NSID to PascalCase for namespaces (without "Record" suffix) 92export function nsidToNamespace(nsid: string): string { 93 return nsid 94 .split(".") 95 .map((part) => 96 part 97 .split(/[-_]/) 98 .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 99 .join("") 100 ) 101 .join(""); 102} 103 104// Convert definition name to PascalCase 105export function defNameToPascalCase(defName: string): string { 106 return defName 107 .split(/[-_]/) 108 .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 109 .join(""); 110} 111 112// Capitalize first letter 113export function capitalizeFirst(str: string): string { 114 return str.charAt(0).toUpperCase() + str.slice(1); 115} 116 117// Sanitize a string to be a valid JavaScript identifier 118export function sanitizeIdentifier(str: string): string { 119 // Replace hyphens and other non-alphanumeric characters with camelCase 120 return str 121 .split(/[-_]/) 122 .map((word, index) => 123 index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1) 124 ) 125 .join(""); 126} 127 128// Check if property is required 129export function isPropertyRequired( 130 recordObj: LexiconRecord, 131 propName: string 132): boolean { 133 return Boolean(recordObj.required && recordObj.required.includes(propName)); 134} 135 136// Helper function to check if a field type is sortable 137export function isFieldSortable(propDef: LexiconProperty): boolean { 138 // Check for direct types 139 if (propDef.type) { 140 const sortableTypes = ["string", "integer", "number", "datetime"]; 141 if (sortableTypes.includes(propDef.type)) { 142 return true; 143 } 144 } 145 146 // Check for format-based types (datetime strings, etc.) 147 if (propDef.format) { 148 const sortableFormats = ["datetime", "at-identifier", "at-uri"]; 149 if (sortableFormats.includes(propDef.format)) { 150 return true; 151 } 152 } 153 154 // Arrays, objects, blobs, and complex types are not sortable 155 return false; 156} 157 158export function createProject(): Project { 159 return new Project({ useInMemoryFileSystem: true }); 160} 161 162export function generateUsageExample( 163 lexicons: Lexicon[], 164 sliceUri: string 165): string { 166 // Find the first non-network.slices lexicon that has a record type 167 const nonSlicesLexicon = lexicons.find( 168 (lex) => 169 lex.id && 170 !lex.id.startsWith("network.slices.") && 171 lex.definitions && 172 Object.values(lex.definitions).some((def) => def.type === "record") 173 ); 174 175 if (nonSlicesLexicon) { 176 // Use the first non-slices lexicon 177 const parts = nonSlicesLexicon.id.split("."); 178 // Sanitize parts for JavaScript property access (skip first part "network") 179 const accessPath = parts.map((part, index) => 180 index === 0 ? part : sanitizeIdentifier(part) 181 ).join("."); 182 183 return `/** 184 * @example Usage 185 * \`\`\`ts 186 * import { AtProtoClient } from "./generated_client.ts"; 187 * 188 * const client = new AtProtoClient( 189 * 'https://api.slices.network', 190 * '${sliceUri}' 191 * ); 192 * 193 * // Get records from the ${nonSlicesLexicon.id} collection 194 * const records = await client.${accessPath}.getRecords(); 195 * 196 * // Get a specific record 197 * const record = await client.${accessPath}.getRecord({ 198 * uri: 'at://did:plc:example/${nonSlicesLexicon.id}/3abc123' 199 * }); 200 * 201 * // Get records with filtering and search 202 * const filteredRecords = await client.${accessPath}.getRecords({ 203 * where: { 204 * text: { contains: "example search term" } 205 * } 206 * }); 207 * 208 * // Use slice-level methods for cross-collection queries with type safety 209 * const sliceRecords = await client.network.slices.slice.getSliceRecords<${nsidToPascalCase( 210 nonSlicesLexicon.id 211 )}>({ 212 * where: { 213 * collection: { eq: '${nonSlicesLexicon.id}' } 214 * } 215 * }); 216 * 217 * // Search across multiple collections using union types 218 * const multiCollectionRecords = await client.network.slices.slice.getSliceRecords<${nsidToPascalCase( 219 nonSlicesLexicon.id 220 )} | AppBskyActorProfile>({ 221 * where: { 222 * collection: { in: ['${nonSlicesLexicon.id}', 'app.bsky.actor.profile'] }, 223 * text: { contains: 'example search term' }, 224 * did: { in: ['did:plc:user1', 'did:plc:user2'] } 225 * }, 226 * limit: 20 227 * }); 228 * 229 * // Serve the records as JSON 230 * Deno.serve(async () => new Response(JSON.stringify(records.records.map(r => r.value)))); 231 * \`\`\` 232 */`; 233 } else { 234 // Fallback: find any lexicon with a record type (including network.slices) 235 const anyRecordLexicon = lexicons.find( 236 (lex) => 237 lex.definitions && 238 Object.values(lex.definitions).some((def) => def.type === "record") 239 ); 240 241 if (anyRecordLexicon) { 242 const parts = anyRecordLexicon.id.split("."); 243 // Sanitize parts for JavaScript property access (skip first part "network") 244 const accessPath = parts.map((part, index) => 245 index === 0 ? part : sanitizeIdentifier(part) 246 ).join("."); 247 248 return `/** 249 * @example Usage 250 * \`\`\`ts 251 * import { AtProtoClient } from "./generated_client.ts"; 252 * 253 * const client = new AtProtoClient( 254 * 'https://api.slices.network', 255 * '${sliceUri}' 256 * ); 257 * 258 * // Get records from the ${anyRecordLexicon.id} collection 259 * const records = await client.${accessPath}.getRecords(); 260 * 261 * // Get a specific record 262 * const record = await client.${accessPath}.getRecord({ 263 * uri: 'at://did:plc:example/${anyRecordLexicon.id}/3abc123' 264 * }); 265 * 266 * // Get records with search and filtering 267 * const filteredRecords = await client.${accessPath}.getRecords({ 268 * where: { 269 * text: { contains: "example search term" } 270 * } 271 * }); 272 * 273 * // Cross-collection operations using base client with type safety 274 * const multiCollectionResults = await client.getSliceRecords<${nsidToPascalCase( 275 anyRecordLexicon.id 276 )}>({ 277 * where: { 278 * collection: { eq: '${anyRecordLexicon.id}' } 279 * }, 280 * limit: 50 281 * }); 282 * 283 * // Serve the records as JSON 284 * Deno.serve(async () => new Response(JSON.stringify(records.records.map(r => r.value)))); 285 * \`\`\` 286 */`; 287 } 288 289 // Final fallback if no record types exist 290 return `/** 291 * @example Usage 292 * \`\`\`ts 293 * import { AtProtoClient } from "./generated_client.ts"; 294 * 295 * const client = new AtProtoClient( 296 * 'https://api.slices.network', 297 * '${sliceUri}' 298 * ); 299 * 300 * // Client is ready to use with available collections 301 * // Use client methods based on your lexicon definitions 302 * \`\`\` 303 */`; 304 } 305} 306 307export function generateHeaderComment( 308 lexicons: Lexicon[], 309 usageExample: string 310): string { 311 return `// Generated TypeScript client for AT Protocol records 312// Generated at: ${new Date().toISOString().slice(0, 19).replace("T", " ")} UTC 313// Lexicons: ${lexicons.length} 314 315${usageExample} 316 317import { SlicesClient, type RecordResponse, type GetRecordsResponse, type CountRecordsResponse, type GetRecordParams, type WhereCondition, type IndexedRecordFields, type SortField, type BlobRef, type AuthProvider } from "@slices/client"; 318import type { OAuthClient } from "@slices/oauth"; 319 320`; 321} 322 323// Format the code using deno fmt via temp file 324export async function formatCode(code: string): Promise<string> { 325 try { 326 const tempFile = await Deno.makeTempFile({ suffix: ".ts" }); 327 328 // Write unformatted code to temp file 329 await Deno.writeTextFile(tempFile, code); 330 331 // Format the temp file 332 const process = new Deno.Command("deno", { 333 args: ["fmt", tempFile], 334 stdout: "piped", 335 stderr: "piped", 336 }); 337 338 const output = await process.output(); 339 340 if (output.success) { 341 // Read the formatted code back 342 const formattedCode = await Deno.readTextFile(tempFile); 343 await Deno.remove(tempFile); 344 return formattedCode; 345 } else { 346 const error = new TextDecoder().decode(output.stderr); 347 console.warn("deno fmt failed, using unformatted code:", error); 348 await Deno.remove(tempFile); 349 return code; 350 } 351 } catch (error) { 352 console.warn("deno fmt not available, using unformatted code:", error); 353 return code; 354 } 355} 356 357// Convenience function that combines everything 358export async function generateTypeScript( 359 lexicons: unknown[], 360 options: GenerateOptions 361): Promise<string> { 362 const { generateInterfaces } = await import("./interfaces.ts"); 363 const { generateClient } = await import("./client.ts"); 364 365 const normalizedLexicons = normalizeLexicons(lexicons); 366 const project = createProject(); 367 const sourceFile = project.createSourceFile("generated-client.ts", ""); 368 369 const usageExample = generateUsageExample( 370 normalizedLexicons, 371 options.sliceUri 372 ); 373 const headerComment = generateHeaderComment( 374 normalizedLexicons, 375 usageExample 376 ); 377 378 // Add header comment and imports to the source file first 379 sourceFile.insertText(0, headerComment); 380 381 // Generate interfaces and client 382 generateInterfaces( 383 sourceFile, 384 normalizedLexicons 385 ); 386 generateClient( 387 sourceFile, 388 normalizedLexicons 389 ); 390 391 // Get the generated code 392 const generatedCode = sourceFile.getFullText(); 393 394 return await formatCode(generatedCode); 395}