Atproto AMA app
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}