Atproto AMA app
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 364 lines 11 kB view raw
1/** 2 * Schema bridge layer for converting between Drizzle models and Lex-validated data 3 */ 4 5import { l } from '@atproto/lex' 6import type { InferSelectModel } from 'drizzle-orm' 7import { questions, answers, users } from './schema' 8import * as lexicons from '../lexicons/index.js' 9import { SOURCE_TYPES, type SourceType } from './shared-schemas' 10 11// Type aliases for Drizzle models 12export type DrizzleQuestion = InferSelectModel<typeof questions> 13export type DrizzleAnswer = InferSelectModel<typeof answers> 14export type DrizzleUser = InferSelectModel<typeof users> 15 16// Type aliases for Lex records 17export type LexQuestion = lexicons.question.Main 18export type LexAnswer = lexicons.answer.Main 19export type LexProfile = lexicons.profile.Main 20 21/** 22 * Convert a Drizzle question record to a Lex question record 23 */ 24export function questionToLexRecord(dbQuestion: DrizzleQuestion): LexQuestion { 25 const built = lexicons.question.$build({ 26 content: dbQuestion.content, 27 targetDid: dbQuestion.targetDid as l.DidString, 28 authorDid: dbQuestion.authorDid as l.DidString, 29 sourceType: (dbQuestion.sourceType as SourceType) || SOURCE_TYPES.ASKIMUT, 30 anonymous: dbQuestion.anonymous, 31 createdAt: l.toDatetimeString(dbQuestion.createdAt), 32 // Tags are not currently stored in the database, but could be added later 33 tags: undefined, 34 sourceUri: dbQuestion.sourceUri || undefined, 35 sourceData: dbQuestion.sourceData ? JSON.parse(dbQuestion.sourceData) : undefined 36 }) 37 return built as LexQuestion 38} 39 40/** 41 * Convert a Lex question record to a partial Drizzle question record 42 */ 43export function lexRecordToQuestion(lexQuestion: LexQuestion): Partial<DrizzleQuestion> { 44 return { 45 content: lexQuestion.content as string, 46 targetDid: lexQuestion.targetDid as string, 47 authorDid: lexQuestion.authorDid as string, 48 sourceType: (lexQuestion.sourceType as string) || SOURCE_TYPES.ASKIMUT, 49 anonymous: (lexQuestion.anonymous as boolean) ?? false, 50 createdAt: new Date(lexQuestion.createdAt as string), 51 sourceUri: lexQuestion.sourceUri as string || null, 52 sourceData: lexQuestion.sourceData ? JSON.stringify(lexQuestion.sourceData) : null, 53 // Note: id, atUri, and reindexed are handled separately 54 } 55} 56 57/** 58 * Convert a Drizzle answer record to a Lex answer record 59 */ 60export function answerToLexRecord(dbAnswer: DrizzleAnswer, questionAtUri: string): LexAnswer { 61 const built = lexicons.answer.$build({ 62 content: dbAnswer.content, 63 questionUri: questionAtUri as l.AtUriString, 64 authorDid: dbAnswer.authorDid as l.DidString, 65 sourceType: (dbAnswer.sourceType as SourceType) || SOURCE_TYPES.ASKIMUT, 66 createdAt: l.toDatetimeString(dbAnswer.createdAt), 67 sourceUri: dbAnswer.sourceUri || undefined, 68 sourceData: dbAnswer.sourceData ? JSON.parse(dbAnswer.sourceData) : undefined 69 }) 70 return built as LexAnswer 71} 72 73/** 74 * Convert a Lex answer record to a partial Drizzle answer record 75 */ 76export function lexRecordToAnswer(lexAnswer: LexAnswer): Partial<DrizzleAnswer> { 77 return { 78 content: lexAnswer.content as string, 79 authorDid: lexAnswer.authorDid as string, 80 sourceType: (lexAnswer.sourceType as string) || SOURCE_TYPES.ASKIMUT, 81 createdAt: new Date(lexAnswer.createdAt as string), 82 sourceUri: lexAnswer.sourceUri as string || null, 83 sourceData: lexAnswer.sourceData ? JSON.stringify(lexAnswer.sourceData) : null, 84 // Note: questionId, id, atUri, and reindexed are handled separately 85 } 86} 87 88/** 89 * Convert a Drizzle user record to a Lex profile record 90 */ 91export function userToLexProfile(dbUser: DrizzleUser): LexProfile { 92 const built = lexicons.profile.$build({ 93 displayName: dbUser.displayName || undefined, 94 description: undefined, // Not currently stored in database 95 questionsOpen: dbUser.questionsOpen, 96 avatar: undefined // Avatar handling would need to be implemented 97 }) 98 return built as LexProfile 99} 100 101/** 102 * Convert a Lex profile record to a partial Drizzle user record 103 */ 104export function lexProfileToUser(lexProfile: LexProfile): Partial<DrizzleUser> { 105 return { 106 displayName: (lexProfile.displayName as string) || null, 107 questionsOpen: lexProfile.questionsOpen as boolean, 108 // Note: handle, did, avatarUrl, and timestamps are handled separately 109 } 110} 111 112/** 113 * Validate and convert data using a Lex schema 114 */ 115export function validateAndConvert<T>( 116 data: unknown, 117 schema: { $validate: (data: unknown) => T } 118): T { 119 return schema.$validate(data) 120} 121 122/** 123 * Safely validate and convert data using a Lex schema 124 */ 125export function safeValidateAndConvert<T>( 126 data: unknown, 127 schema: { $safeParse: (data: unknown) => { success: boolean; value?: T; error?: any } } 128): { success: true; value: T } | { success: false; error: any } { 129 const result = schema.$safeParse(data) 130 if (result.success && result.value) { 131 return { success: true, value: result.value } 132 } else { 133 return { success: false, error: result.error } 134 } 135} 136 137/** 138 * Create a question with validation 139 */ 140export function createValidatedQuestion(data: { 141 content: string 142 targetDid: string 143 authorDid: string 144 sourceType?: SourceType 145 anonymous?: boolean 146 tags?: string[] 147 sourceUri?: string 148 sourceData?: Record<string, unknown> 149}): { lexRecord: LexQuestion; dbData: Partial<DrizzleQuestion> } { 150 const built = lexicons.question.$build({ 151 content: data.content, 152 targetDid: data.targetDid as l.DidString, 153 authorDid: data.authorDid as l.DidString, 154 sourceType: data.sourceType || SOURCE_TYPES.ASKIMUT, 155 anonymous: data.anonymous ?? false, 156 createdAt: l.toDatetimeString(new Date()), 157 tags: data.tags, 158 sourceUri: data.sourceUri, 159 sourceData: data.sourceData 160 }) 161 162 const lexRecord = built as LexQuestion 163 164 // Validate the record 165 lexicons.question.$validate(lexRecord) 166 167 const dbData: Partial<DrizzleQuestion> = { 168 content: data.content, 169 targetDid: data.targetDid, 170 authorDid: data.authorDid, 171 sourceType: data.sourceType || SOURCE_TYPES.ASKIMUT, 172 anonymous: data.anonymous ?? false, 173 createdAt: new Date(), 174 sourceUri: data.sourceUri || null, 175 sourceData: data.sourceData ? JSON.stringify(data.sourceData) : null 176 } 177 178 return { lexRecord, dbData } 179} 180 181/** 182 * Create an answer with validation 183 */ 184export function createValidatedAnswer(data: { 185 content: string 186 questionId: string 187 questionAtUri: string 188 authorDid: string 189 sourceType?: SourceType 190 sourceUri?: string 191 sourceData?: Record<string, unknown> 192}): { lexRecord: LexAnswer; dbData: Partial<DrizzleAnswer> } { 193 const built = lexicons.answer.$build({ 194 content: data.content, 195 questionUri: data.questionAtUri as l.AtUriString, 196 authorDid: data.authorDid as l.DidString, 197 sourceType: data.sourceType || SOURCE_TYPES.ASKIMUT, 198 createdAt: l.toDatetimeString(new Date()), 199 sourceUri: data.sourceUri, 200 sourceData: data.sourceData 201 }) 202 203 const lexRecord = built as LexAnswer 204 205 // Validate the record 206 lexicons.answer.$validate(lexRecord) 207 208 const dbData: Partial<DrizzleAnswer> = { 209 content: data.content, 210 questionId: data.questionId, 211 authorDid: data.authorDid, 212 sourceType: data.sourceType || SOURCE_TYPES.ASKIMUT, 213 createdAt: new Date(), 214 sourceUri: data.sourceUri || null, 215 sourceData: data.sourceData ? JSON.stringify(data.sourceData) : null 216 } 217 218 return { lexRecord, dbData } 219} 220 221/** 222 * Update a profile with validation 223 */ 224export function createValidatedProfile(data: { 225 displayName?: string 226 description?: string 227 questionsOpen: boolean 228}): { lexRecord: LexProfile; dbData: Partial<DrizzleUser> } { 229 const built = lexicons.profile.$build({ 230 displayName: data.displayName, 231 description: data.description, 232 questionsOpen: data.questionsOpen, 233 avatar: undefined // Avatar handling would need to be implemented 234 }) 235 236 const lexRecord = built as LexProfile 237 238 // Validate the record 239 lexicons.profile.$validate(lexRecord) 240 241 const dbData: Partial<DrizzleUser> = { 242 displayName: data.displayName || null, 243 questionsOpen: data.questionsOpen, 244 updatedAt: new Date() 245 } 246 247 return { lexRecord, dbData } 248} 249 250/** 251 * Create a question from Bluesky post data 252 */ 253export function createQuestionFromBlueSky(data: { 254 content: string 255 targetDid: string 256 authorDid: string 257 bskyUri: string 258 bskyPost: Record<string, unknown> 259 anonymous?: boolean 260}): { lexRecord: LexQuestion; dbData: Partial<DrizzleQuestion> } { 261 return createValidatedQuestion({ 262 content: data.content, 263 targetDid: data.targetDid, 264 authorDid: data.authorDid, 265 sourceType: SOURCE_TYPES.BLUESKY, 266 anonymous: data.anonymous ?? false, 267 sourceUri: data.bskyUri, 268 sourceData: { 269 bskyPost: data.bskyPost, 270 platform: 'bluesky' 271 } 272 }) 273} 274 275/** 276 * Create an answer from Bluesky post data 277 */ 278export function createAnswerFromBlueSky(data: { 279 content: string 280 questionId: string 281 questionAtUri: string 282 authorDid: string 283 bskyUri: string 284 bskyPost: Record<string, unknown> 285}): { lexRecord: LexAnswer; dbData: Partial<DrizzleAnswer> } { 286 return createValidatedAnswer({ 287 content: data.content, 288 questionId: data.questionId, 289 questionAtUri: data.questionAtUri, 290 authorDid: data.authorDid, 291 sourceType: SOURCE_TYPES.BLUESKY, 292 sourceUri: data.bskyUri, 293 sourceData: { 294 bskyPost: data.bskyPost, 295 platform: 'bluesky' 296 } 297 }) 298} 299 300/** 301 * Create a question from standard.site data 302 */ 303export function createQuestionFromStandardSite(data: { 304 content: string 305 targetDid: string 306 authorDid: string 307 standardSiteUri: string 308 standardSiteData: Record<string, unknown> 309 anonymous?: boolean 310}): { lexRecord: LexQuestion; dbData: Partial<DrizzleQuestion> } { 311 return createValidatedQuestion({ 312 content: data.content, 313 targetDid: data.targetDid, 314 authorDid: data.authorDid, 315 sourceType: SOURCE_TYPES.STANDARD_SITE, 316 anonymous: data.anonymous ?? false, 317 sourceUri: data.standardSiteUri, 318 sourceData: { 319 standardSite: data.standardSiteData, 320 platform: 'standard.site' 321 } 322 }) 323} 324 325/** 326 * Detect source type from URI or data 327 */ 328export function detectSourceType(uri?: string, data?: Record<string, unknown>): SourceType { 329 if (!uri && !data) { 330 return SOURCE_TYPES.ASKIMUT 331 } 332 333 if (uri) { 334 if (uri.includes('bsky.app') || uri.includes('at://')) { 335 return SOURCE_TYPES.BLUESKY 336 } 337 if (uri.includes('standard.site')) { 338 return SOURCE_TYPES.STANDARD_SITE 339 } 340 if (uri.includes('mastodon') || uri.includes('/@')) { 341 return SOURCE_TYPES.MASTODON 342 } 343 if (uri.includes('nostr:') || uri.includes('npub')) { 344 return SOURCE_TYPES.NOSTR 345 } 346 } 347 348 if (data?.platform) { 349 const platform = data.platform as string 350 switch (platform.toLowerCase()) { 351 case 'bluesky': 352 case 'bsky': 353 return SOURCE_TYPES.BLUESKY 354 case 'standard.site': 355 return SOURCE_TYPES.STANDARD_SITE 356 case 'mastodon': 357 return SOURCE_TYPES.MASTODON 358 case 'nostr': 359 return SOURCE_TYPES.NOSTR 360 } 361 } 362 363 return SOURCE_TYPES.ASKIMUT 364}