···2929 }
3030 }
31313232- // Build the filter for actors instead of directly searching profiles
3333- const actorFilter: any = {}
3232+ const filter: any = {}
3433 const sort: any = {}
3535-3636- // Only search for actors that already have profiles
3737- actorFilter.profile = { $exists: true, $ne: null }
38343935 if (q) {
4036 const escaped = escapeRegExp(q)
4137 const regex = new RegExp(escaped, 'i')
4242-4343- // Search by handle directly on actor model
4444- actorFilter.$or = [
4545- { handle: regex }
3838+ filter.$or = [
3939+ { displayName: regex },
4040+ { description: regex },
4141+ { handle: regex },
4642 ]
4747-4848- // For queries matching profile fields, we need to find actors by their profiles
4949- const profileIds = await ctx.db.models.Profile.find({
5050- $or: [
5151- { displayName: regex },
5252- { description: regex }
5353- ]
5454- })
5555- .select('_id authorDid')
5656- .lean()
5757-5858- // Add actor DIDs from matching profiles
5959- if (profileIds.length > 0) {
6060- const profileDids = profileIds.map(p => p.authorDid)
6161- // Add to $or condition
6262- actorFilter.$or.push({ did: { $in: profileDids } })
6363- }
6464-6565- // Sort by recency and relevance
6666- sort.indexedAt = -1
4343+ // fall back to sorting by createdAt
4444+ sort.createdAt = -1
6745 } else {
6868- // Default sort for discovery - prioritize recently indexed actors
6969- sort.indexedAt = -1
4646+ sort.createdAt = -1
7047 }
71487272- // Find actors with populated profiles - no need to index them
7373- const actorsWithProfiles = await ctx.db.models.Actor.find(actorFilter)
7474- .populate('profile')
4949+ const profiles = await ctx.db.models.Profile.find(filter)
7550 .sort(sort)
7651 .skip(skip)
7752 .limit(limit)
7853 .lean()
79548080- // Filter out any invalid profiles and transform to profile views
8181- const actors: SoSprkActorDefs.ProfileView[] = actorsWithProfiles
8282- .filter(actor => actor.profile)
8383- .map(actor => {
8484- const profile = actor.profile as any
8585-8686- const avatar = profile?.avatar
8787- ? `https://media.sprk.so/avatar/tiny/${actor.did}/${profile.avatar.ref.$link}/webp`
5555+ const actors: SoSprkActorDefs.ProfileView[] = await Promise.all(
5656+ profiles.map(async (p) => {
5757+ const avatar = p.avatar
5858+ ? `https://media.sprk.so/avatar/tiny/${p.authorDid}/${(p.avatar as any).ref.$link}/webp`
8859 : undefined
8989-9090- const labels = profile?.labels && Array.isArray(profile.labels)
9191- ? (profile.labels as Label[])
6060+ const labels = Array.isArray(p.labels)
6161+ ? (p.labels as Label[])
9262 : undefined
9393-6363+ const handle = await ctx.resolver.resolveDidToHandle(p.authorDid)
9464 return {
9565 $type: 'so.sprk.actor.defs#profileView',
9696- did: actor.did,
9797- handle: actor.handle || actor.did,
9898- displayName: profile?.displayName,
9999- description: profile?.description,
6666+ did: p.authorDid,
6767+ handle: handle,
6868+ displayName: p.displayName,
6969+ description: p.description,
10070 avatar,
101101- indexedAt: actor.indexedAt,
102102- createdAt: actor.createdAt ? new Date(actor.createdAt).toISOString() : undefined,
7171+ indexedAt: p.indexedAt,
7272+ createdAt: p.createdAt,
10373 labels,
10474 } satisfies SoSprkActorDefs.ProfileView
105105- })
7575+ }),
7676+ )
10677107107- // Calculate cursor for pagination
10878 const nextCursor =
109109- actorsWithProfiles.length === limit ? String(skip + limit) : undefined
7979+ profiles.length === limit ? String(skip + limit) : undefined
11080 const result: SoSprkActorSearch.OutputSchema = { actors }
11181 if (nextCursor) {
11282 result.cursor = nextCursor
···11686 })
1178711888 return router
119119-}
8989+}
+15-2
services/appview/src/services/indexing.ts
···122122 const actor = await this.db.models.Actor.findOne({ did })
123123124124 // Skip if recently indexed and not forced
125125- if (!force && actor && this.isHandleRecentlyIndexed(actor, timestamp)) {
125125+ if (!force && actor && this.isHandleRecentlyIndexed(actor, timestamp)) {
126126 return
127127 }
128128···148148 }
149149 }
150150151151+ const existingProfile = await this.db.models.Profile.findOne({ authorDid: did })
152152+ if (existingProfile) {
153153+ console.log('existingProfile: ', existingProfile)
154154+ }
155155+151156 // Update or create actor
152157 await this.db.models.Actor.updateOne(
153158 { did },
154159 {
155160 $set: {
156161 handle,
157157- indexedAt: timestamp
162162+ indexedAt: timestamp,
163163+ ...(existingProfile && existingProfile._id ? {
164164+ profile: existingProfile._id,
165165+ profileCid: existingProfile.cid
166166+ } : {})
158167 },
159168 $setOnInsert: {
160169 uri: `at://${did}/app.bsky.actor.profile`,
···167176 },
168177 { upsert: true }
169178 )
179179+180180+ if (existingProfile) {
181181+ this.logger.info({ did, profileId: existingProfile._id }, 'Linked existing profile to actor during indexing')
182182+ }
170183 } catch (error) {
171184 this.logger.error({ error, did }, 'Error indexing handle')
172185 }
+15-16
services/ingester/src/handlers/actor-handler.ts
···11import { pino } from 'pino'
22import { Database } from '../db/connection.js'
33import type { NormalizedEvent } from '../types/events.js'
44-import { ensureActor } from '../utils/actor-utils.js'
44+import { IndexingService } from '../services/indexing.js'
55+import { BidirectionalResolver } from '../id-resolver.js'
5667const logger = pino({ name: 'actor-handler' })
78···1415 */
1516export async function handleActorReferences(evt: NormalizedEvent, db: Database): Promise<void> {
1617 try {
1818+ const now = new Date().toISOString()
1919+ const resolver = new BidirectionalResolver()
2020+ const indexingService = new IndexingService(db, resolver)
2121+1722 // Always ensure the author DID has an actor
1823 if (evt.did) {
1919- await ensureActor(evt.did, evt.handle || undefined, db)
2424+ await indexingService.indexHandle(evt.did, now)
2025 }
21262227 // Handle subject DIDs for follow, block, like events
···2429 // Subject is usually a DID in format did:plc:12345
2530 const subjectDid = evt.record.subject as string
2631 if (subjectDid && subjectDid.startsWith('did:')) {
2727- await ensureActor(subjectDid, undefined, db)
3232+ await indexingService.indexHandle(subjectDid, now)
2833 }
2934 }
3035···3641 if (reply.root?.uri) {
3742 const rootDid = extractDidFromUri(reply.root.uri)
3843 if (rootDid) {
3939- await ensureActor(rootDid, undefined, db)
4444+ await indexingService.indexHandle(rootDid, now)
4045 }
4146 }
42474348 if (reply.parent?.uri) {
4449 const parentDid = extractDidFromUri(reply.parent.uri)
4550 if (parentDid && parentDid !== extractDidFromUri(reply.root?.uri || '')) {
4646- await ensureActor(parentDid, undefined, db)
5151+ await indexingService.indexHandle(parentDid, now)
4752 }
4853 }
4954 }
···5358 const subjectUri = evt.record.subject.uri as string
5459 const subjectDid = extractDidFromUri(subjectUri)
5560 if (subjectDid) {
5656- await ensureActor(subjectDid, undefined, db)
6161+ await indexingService.indexHandle(subjectDid, now)
5762 }
5863 }
5964 } catch (error) {
···6267}
63686469/**
6565- * Extracts a DID from an AT URI (at://did:plc:12345/...)
6666- *
6767- * @param uri The URI to extract the DID from
6868- * @returns The extracted DID or undefined
7070+ * Helper function to extract DID from a URI
6971 */
7070-function extractDidFromUri(uri: string): string | undefined {
7171- if (!uri) return undefined
7272-7373- // Match a DID in an AT URI format
7474- const match = uri.match(/at:\/\/(did:[a-zA-Z0-9:]+)\//)
7575- return match ? match[1] : undefined
7272+function extractDidFromUri(uri: string): string | null {
7373+ const match = uri.match(/at:\/\/(did:[^/]+)/)
7474+ return match ? match[1] : null
7675}
+61
services/ingester/src/id-resolver.ts
···11+import { pino } from 'pino'
22+33+const logger = pino({ name: 'id-resolver' })
44+55+export interface DidDocument {
66+ did: string
77+ handle?: string
88+}
99+1010+/**
1111+ * Service to handle resolving DIDs to handles and vice versa
1212+ */
1313+export class BidirectionalResolver {
1414+ private logger = pino({ name: 'id-resolver' })
1515+1616+ /**
1717+ * Resolve a DID to its DID document
1818+ */
1919+ async resolveDidToDidDoc(did: string): Promise<DidDocument> {
2020+ try {
2121+ // TODO: Implement actual DID resolution
2222+ // For now, return basic document
2323+ return {
2424+ did,
2525+ }
2626+ } catch (error) {
2727+ this.logger.error({ error, did }, 'Failed to resolve DID to DID document')
2828+ throw error
2929+ }
3030+ }
3131+3232+ /**
3333+ * Resolve a handle to its DID document
3434+ */
3535+ async resolveHandleToDidDoc(handle: string): Promise<DidDocument> {
3636+ try {
3737+ // TODO: Implement actual handle resolution
3838+ // For now, return basic document
3939+ return {
4040+ did: `did:plc:${handle.toLowerCase()}`,
4141+ handle,
4242+ }
4343+ } catch (error) {
4444+ this.logger.error({ error, handle }, 'Failed to resolve handle to DID document')
4545+ throw error
4646+ }
4747+ }
4848+4949+ /**
5050+ * Resolve a DID to its handle
5151+ */
5252+ async resolveDidToHandle(did: string): Promise<string> {
5353+ try {
5454+ const doc = await this.resolveDidToDidDoc(did)
5555+ return doc.handle || did
5656+ } catch (error) {
5757+ this.logger.error({ error, did }, 'Failed to resolve DID to handle')
5858+ throw error
5959+ }
6060+ }
6161+}
+109
services/ingester/src/services/indexing.ts
···11+import { pino } from 'pino'
22+import { Database } from '../db/connection.js'
33+import { BidirectionalResolver } from '../id-resolver.js'
44+55+const logger = pino({ name: 'indexing-service' })
66+77+/**
88+ * Service to handle indexing of actors and their handles
99+ */
1010+export class IndexingService {
1111+ private logger = pino({ name: 'indexing-service' })
1212+1313+ constructor(
1414+ private db: Database,
1515+ private resolver: BidirectionalResolver,
1616+ ) {}
1717+1818+ /**
1919+ * Index or update actor handle information
2020+ *
2121+ * @param did The DID of the actor
2222+ * @param timestamp The timestamp of the operation
2323+ * @param force Force reindexing even if recently indexed
2424+ */
2525+ async indexHandle(did: string, timestamp: string, force = false): Promise<void> {
2626+ try {
2727+ // Find existing actor
2828+ const actor = await this.db.models.Actor.findOne({ did })
2929+3030+ // Skip if recently indexed and not forced
3131+ if (!force && actor && this.isHandleRecentlyIndexed(actor, timestamp)) {
3232+ return
3333+ }
3434+3535+ // Resolve DID to handle
3636+ const didDoc = await this.resolver.resolveDidToDidDoc(did)
3737+3838+ // Verify handle ownership
3939+ let handle: string | undefined = undefined
4040+ if (didDoc.handle) {
4141+ const handleDidDoc = await this.resolver.resolveHandleToDidDoc(didDoc.handle)
4242+ handle = did === handleDidDoc.did ? didDoc.handle.toLowerCase() : undefined
4343+ }
4444+4545+ // Handle conflict resolution - if another actor has this handle
4646+ if (handle) {
4747+ const actorWithHandle = await this.db.models.Actor.findOne({ handle })
4848+ if (actorWithHandle && actorWithHandle.did !== did) {
4949+ // Clear handle from the other actor
5050+ await this.db.models.Actor.updateOne(
5151+ { did: actorWithHandle.did },
5252+ { $set: { handle: null } }
5353+ )
5454+ }
5555+ }
5656+5757+ const existingProfile = await this.db.models.Profile.findOne({ authorDid: did })
5858+5959+ // Update or create actor
6060+ await this.db.models.Actor.updateOne(
6161+ { did },
6262+ {
6363+ $set: {
6464+ handle,
6565+ indexedAt: timestamp,
6666+ ...(existingProfile && existingProfile._id ? {
6767+ profile: existingProfile._id,
6868+ profileCid: existingProfile.cid
6969+ } : {})
7070+ },
7171+ $setOnInsert: {
7272+ uri: `at://${did}/app.bsky.actor.profile`,
7373+ followersCount: 0,
7474+ followingCount: 0,
7575+ postsCount: 0,
7676+ isLabeler: false,
7777+ priorityNotifications: false,
7878+ }
7979+ },
8080+ { upsert: true }
8181+ )
8282+8383+ if (existingProfile) {
8484+ this.logger.info({ did, profileId: existingProfile._id }, 'Linked existing profile to actor during indexing')
8585+ }
8686+ } catch (error) {
8787+ this.logger.error({ error, did }, 'Error indexing handle')
8888+ }
8989+ }
9090+9191+ /**
9292+ * Check if an actor's handle was recently indexed
9393+ */
9494+ private isHandleRecentlyIndexed(actor: any, timestamp: string): boolean {
9595+ if (!actor.indexedAt) return false
9696+9797+ const timeDiff = new Date(timestamp).getTime() - new Date(actor.indexedAt).getTime()
9898+ const ONE_DAY = 24 * 60 * 60 * 1000
9999+ const ONE_HOUR = 60 * 60 * 1000
100100+101101+ // Reindex daily for all actors
102102+ if (timeDiff > ONE_DAY) return false
103103+104104+ // Reindex more frequently for actors without handles
105105+ if (actor.handle === null && timeDiff > ONE_HOUR) return false
106106+107107+ return true
108108+ }
109109+}