User search ()

* Add initial schemas for getUsersSearch and getUsersTypeahead

* Stub-out pds user search/typeahead, add pg indexes to support it

* Implement and test user typeahead method for postgres

* Implement user typeahead for sqlite

* Tidy

* Get user search view working, except pagination

* Get pagination working on pds user search w/ postgres

* Bail on jest snapshots for the conditional pg/sqlite search results

* Refactor user search queries out of user search method

* Apply user search helpers to typeahead

* Tidy user search implementation

* Test bad search input for sqlite

* Fix loading of pg_trgm extension to be friendly with the test suite

* Fix typo

* Query consistency

* All server tests can reference app

* Handle bad user search cursors
This commit is contained in:
devin ivy 2022-10-14 16:16:32 -04:00 committed by GitHub
parent 90b3792d90
commit e314974d1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1501 additions and 65 deletions

@ -0,0 +1,39 @@
{
"lexicon": 1,
"id": "app.bsky.getUsersSearch",
"type": "query",
"description": "Find users matching search criteria",
"parameters": {
"term": {"type": "string", "required": true},
"limit": {"type": "number", "maximum": 100},
"before": {"type": "string"}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["users"],
"properties": {
"users": {
"type": "array",
"items": {
"type": "object",
"required": ["did", "name", "createdAt", "indexedAt", "cursor"],
"properties": {
"did": {"type": "string"},
"name": {"type": "string"},
"displayName": {
"type": "string",
"maxLength": 64
},
"description": {"type": "string"},
"createdAt": {"type": "string", "format": "date-time"},
"indexedAt": {"type": "string", "format": "date-time"},
"cursor": {"type": "string"}
}
}
}
}
}
}
}

@ -0,0 +1,34 @@
{
"lexicon": 1,
"id": "app.bsky.getUsersTypeahead",
"type": "query",
"description": "Find user suggestions for a search term",
"parameters": {
"term": {"type": "string", "required": true},
"limit": {"type": "number", "maximum": 100}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["users"],
"properties": {
"users": {
"type": "array",
"items": {
"type": "object",
"required": ["did", "name"],
"properties": {
"did": {"type": "string"},
"name": {"type": "string"},
"displayName": {
"type": "string",
"maxLength": 64
}
}
}
}
}
}
}
}

@ -39,6 +39,8 @@ import * as AppBskyGetProfile from './types/app/bsky/getProfile'
import * as AppBskyGetRepostedBy from './types/app/bsky/getRepostedBy'
import * as AppBskyGetUserFollowers from './types/app/bsky/getUserFollowers'
import * as AppBskyGetUserFollows from './types/app/bsky/getUserFollows'
import * as AppBskyGetUsersSearch from './types/app/bsky/getUsersSearch'
import * as AppBskyGetUsersTypeahead from './types/app/bsky/getUsersTypeahead'
import * as AppBskyLike from './types/app/bsky/like'
import * as AppBskyMediaEmbed from './types/app/bsky/mediaEmbed'
import * as AppBskyPost from './types/app/bsky/post'
@ -79,6 +81,8 @@ export * as AppBskyGetProfile from './types/app/bsky/getProfile'
export * as AppBskyGetRepostedBy from './types/app/bsky/getRepostedBy'
export * as AppBskyGetUserFollowers from './types/app/bsky/getUserFollowers'
export * as AppBskyGetUserFollows from './types/app/bsky/getUserFollows'
export * as AppBskyGetUsersSearch from './types/app/bsky/getUsersSearch'
export * as AppBskyGetUsersTypeahead from './types/app/bsky/getUsersTypeahead'
export * as AppBskyLike from './types/app/bsky/like'
export * as AppBskyMediaEmbed from './types/app/bsky/mediaEmbed'
export * as AppBskyPost from './types/app/bsky/post'
@ -540,6 +544,30 @@ export class BskyNS {
})
}
getUsersSearch(
params: AppBskyGetUsersSearch.QueryParams,
data?: AppBskyGetUsersSearch.InputSchema,
opts?: AppBskyGetUsersSearch.CallOptions
): Promise<AppBskyGetUsersSearch.Response> {
return this._service.xrpc
.call('app.bsky.getUsersSearch', params, data, opts)
.catch((e) => {
throw AppBskyGetUsersSearch.toKnownErr(e)
})
}
getUsersTypeahead(
params: AppBskyGetUsersTypeahead.QueryParams,
data?: AppBskyGetUsersTypeahead.InputSchema,
opts?: AppBskyGetUsersTypeahead.CallOptions
): Promise<AppBskyGetUsersTypeahead.Response> {
return this._service.xrpc
.call('app.bsky.getUsersTypeahead', params, data, opts)
.catch((e) => {
throw AppBskyGetUsersTypeahead.toKnownErr(e)
})
}
postNotificationsSeen(
params: AppBskyPostNotificationsSeen.QueryParams,
data?: AppBskyPostNotificationsSeen.InputSchema,

@ -2273,6 +2273,111 @@ export const methodSchemas: MethodSchema[] = [
},
},
},
{
lexicon: 1,
id: 'app.bsky.getUsersSearch',
type: 'query',
description: 'Find users matching search criteria',
parameters: {
term: {
type: 'string',
required: true,
},
limit: {
type: 'number',
maximum: 100,
},
before: {
type: 'string',
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['users'],
properties: {
users: {
type: 'array',
items: {
type: 'object',
required: ['did', 'name', 'createdAt', 'indexedAt', 'cursor'],
properties: {
did: {
type: 'string',
},
name: {
type: 'string',
},
displayName: {
type: 'string',
maxLength: 64,
},
description: {
type: 'string',
},
createdAt: {
type: 'string',
format: 'date-time',
},
indexedAt: {
type: 'string',
format: 'date-time',
},
cursor: {
type: 'string',
},
},
},
},
},
},
},
},
{
lexicon: 1,
id: 'app.bsky.getUsersTypeahead',
type: 'query',
description: 'Find user suggestions for a search term',
parameters: {
term: {
type: 'string',
required: true,
},
limit: {
type: 'number',
maximum: 100,
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['users'],
properties: {
users: {
type: 'array',
items: {
type: 'object',
required: ['did', 'name'],
properties: {
did: {
type: 'string',
},
name: {
type: 'string',
},
displayName: {
type: 'string',
maxLength: 64,
},
},
},
},
},
},
},
},
{
lexicon: 1,
id: 'app.bsky.postNotificationsSeen',

@ -0,0 +1,40 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@adxp/xrpc'
export interface QueryParams {
term: string;
limit?: number;
before?: string;
}
export interface CallOptions {
headers?: Headers;
}
export type InputSchema = undefined
export interface OutputSchema {
users: {
did: string,
name: string,
displayName?: string,
description?: string,
createdAt: string,
indexedAt: string,
cursor: string,
}[];
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}

@ -0,0 +1,35 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@adxp/xrpc'
export interface QueryParams {
term: string;
limit?: number;
}
export interface CallOptions {
headers?: Headers;
}
export type InputSchema = undefined
export interface OutputSchema {
users: {
did: string,
name: string,
displayName?: string,
}[];
}
export interface Response {
success: boolean;
headers: Headers;
data: OutputSchema;
}
export function toKnownErr(e: any) {
if (e instanceof XRPCError) {
}
return e
}

@ -28,6 +28,7 @@
"@adxp/plc": "*",
"@adxp/repo": "*",
"@adxp/uri": "*",
"@hapi/bourne": "^3.0.0",
"better-sqlite3": "^7.6.2",
"cors": "^2.8.5",
"dotenv": "^16.0.0",

@ -0,0 +1,99 @@
import { sql } from 'kysely'
import { QueryParams } from '@adxp/api/src/types/app/bsky/getUsersSearch'
import Database from '../../../db'
import { Server } from '../../../lexicon'
import * as locals from '../../../locals'
import {
cleanTerm,
getUserSearchQueryPg,
getUserSearchQuerySqlite,
packCursor,
} from './util/search'
export default function (server: Server) {
server.app.bsky.getUsersSearch(async (params, _input, req, res) => {
let { term, limit } = params
const { before } = params
const { db, auth } = locals.get(res)
auth.getUserDidOrThrow(req)
term = cleanTerm(term)
limit = Math.min(limit ?? 25, 100)
if (!term) {
return {
encoding: 'application/json',
body: {
users: [],
},
}
}
const results =
db.dialect === 'pg'
? await getResultsPg(db, { term, limit, before })
: await getResultsSqlite(db, { term, limit, before })
const users = results.map((result) => ({
did: result.did,
name: result.name,
displayName: result.displayName ?? undefined,
description: result.description ?? undefined,
createdAt: result.createdAt,
indexedAt: result.indexedAt ?? result.createdAt,
cursor: packCursor(result),
}))
return {
encoding: 'application/json',
body: {
users,
},
}
})
}
const getResultsPg: GetResultsFn = async (db, { term, limit, before }) => {
return await getUserSearchQueryPg(db, { term, limit, before })
.leftJoin('app_bsky_profile as profile', 'profile.creator', 'user.did')
.select([
'distance',
'user.did as did',
'user.username as name',
'profile.displayName as displayName',
'profile.description as description',
'user.createdAt as createdAt',
'profile.indexedAt as indexedAt',
])
.execute()
}
const getResultsSqlite: GetResultsFn = async (db, { term, limit, before }) => {
return await getUserSearchQuerySqlite(db, { term, limit, before })
.leftJoin('app_bsky_profile as profile', 'profile.creator', 'user.did')
.select([
sql<number>`0`.as('distance'),
'user.did as did',
'user.username as name',
'profile.displayName as displayName',
'profile.description as description',
'user.createdAt as createdAt',
'profile.indexedAt as indexedAt',
])
.execute()
}
type GetResultsFn = (
db: Database,
opts: QueryParams & { limit: number },
) => Promise<
{
did: string
name: string
displayName: string | null
description: string | null
distance: number
createdAt: string
indexedAt: string | null
}[]
>

@ -0,0 +1,74 @@
import { QueryParams } from '@adxp/api/src/types/app/bsky/getUsersTypeahead'
import Database from '../../../db'
import { Server } from '../../../lexicon'
import * as locals from '../../../locals'
import {
cleanTerm,
getUserSearchQueryPg,
getUserSearchQuerySqlite,
} from './util/search'
export default function (server: Server) {
server.app.bsky.getUsersTypeahead(async (params, _input, req, res) => {
let { term, limit } = params
const { db, auth } = locals.get(res)
auth.getUserDidOrThrow(req)
term = cleanTerm(term)
limit = Math.min(limit ?? 25, 100)
if (!term) {
return {
encoding: 'application/json',
body: {
users: [],
},
}
}
const results =
db.dialect === 'pg'
? await getResultsPg(db, { term, limit })
: await getResultsSqlite(db, { term, limit })
const users = results.map((result) => ({
did: result.did,
name: result.name,
displayName: result.displayName ?? undefined,
}))
return {
encoding: 'application/json',
body: {
users,
},
}
})
}
const getResultsPg: GetResultsFn = async (db, { term, limit }) => {
return await getUserSearchQueryPg(db, { term, limit })
.leftJoin('app_bsky_profile as profile', 'profile.creator', 'user.did')
.select([
'user.did as did',
'user.username as name',
'profile.displayName as displayName',
])
.execute()
}
const getResultsSqlite: GetResultsFn = async (db, { term, limit }) => {
return await getUserSearchQuerySqlite(db, { term, limit })
.leftJoin('app_bsky_profile as profile', 'profile.creator', 'user.did')
.select([
'user.did as did',
'user.username as name',
'profile.displayName as displayName',
])
.execute()
}
type GetResultsFn = (
db: Database,
opts: QueryParams & { limit: number },
) => Promise<{ did: string; name: string; displayName: string | null }[]>

@ -7,6 +7,8 @@ import getProfile from './getProfile'
import getRepostedBy from './getRepostedBy'
import getUserFollowers from './getUserFollowers'
import getUserFollows from './getUserFollows'
import getUsersSearch from './getUsersSearch'
import getUsersTypeahead from './getUsersTypeahead'
import getNotifications from './getNotifications'
import getNotificationCount from './getNotificationCount'
import postNotificationsSeen from './postNotificationsSeen'
@ -20,6 +22,8 @@ export default function (server: Server) {
getRepostedBy(server)
getUserFollowers(server)
getUserFollows(server)
getUsersSearch(server)
getUsersTypeahead(server)
getNotifications(server)
getNotificationCount(server)
postNotificationsSeen(server)

@ -0,0 +1,162 @@
import { sql } from 'kysely'
import { safeParse } from '@hapi/bourne'
import { InvalidRequestError } from '@adxp/xrpc-server'
import Database from '../../../../db'
import { DbRef } from '../../../../db/util'
export const getUserSearchQueryPg = (
db: Database,
opts: { term: string; limit: number; before?: string },
) => {
const { ref } = db.db.dynamic
const { term, limit, before } = opts
// Performing matching by word using "strict word similarity" operator.
// The more characters the user gives us, the more we can ratchet down
// the distance threshold for matching.
const threshold = term.length < 3 ? 0.9 : 0.8
const cursor = before !== undefined ? unpackCursor(before) : undefined
// Matching user accounts based on username
const distanceAccount = distance(term, ref('username'))
const keysetAccount = keyset(cursor, {
username: ref('username'),
distance: distanceAccount,
})
const accountsQb = db.db
.selectFrom('user')
.where(distanceAccount, '<', threshold)
.if(!!keysetAccount, (qb) => (keysetAccount ? qb.where(keysetAccount) : qb))
.select(['user.did as did', distanceAccount.as('distance')])
.orderBy(distanceAccount)
.orderBy('username')
.limit(limit)
// Matching profiles based on display name
const distanceProfile = distance(term, ref('displayName'))
const keysetProfile = keyset(cursor, {
username: ref('username'),
distance: distanceProfile,
})
const profilesQb = db.db
.selectFrom('app_bsky_profile')
.innerJoin('user', 'user.did', 'app_bsky_profile.creator')
.where(distanceProfile, '<', threshold)
.if(!!keysetProfile, (qb) => (keysetProfile ? qb.where(keysetProfile) : qb))
.select(['user.did as did', distanceProfile.as('distance')])
.orderBy(distanceProfile)
.orderBy('username')
.limit(limit)
// Combine user account and profile results, taking best matches from each
const emptyQb = db.db
.selectFrom('user')
.where(sql`1 = 0`)
.select([sql.literal('').as('did'), sql<number>`0`.as('distance')])
const resultsQb = db.db
.selectFrom(
emptyQb
.union(sql`${accountsQb}`) // The sql`` is adding parens
.union(sql`${profilesQb}`)
.as('accounts_and_profiles'),
)
.selectAll()
.distinctOn('did') // Per did, take whichever of account and profile distance is best
.orderBy('did')
.orderBy('distance')
// Sort and paginate all user results
const keysetAll = keyset(cursor, {
username: ref('username'),
distance: ref('distance'),
})
return db.db
.selectFrom(resultsQb.as('results'))
.innerJoin('user', 'user.did', 'results.did')
.if(!!keysetAll, (qb) => (keysetAll ? qb.where(keysetAll) : qb))
.orderBy('distance')
.orderBy('username') // Keep order stable: break ties in distance arbitrarily using username
.limit(limit)
}
export const getUserSearchQuerySqlite = (
db: Database,
opts: { term: string; limit: number; before?: string },
) => {
const { ref } = db.db.dynamic
const { term, limit, before } = opts
// Take the first three words in the search term. We're going to build a dynamic query
// based on the number of words, so to keep things predictable just ignore words 4 and
// beyond. We also remove the special wildcard characters supported by the LIKE operator,
// since that's where these values are heading.
const safeWords = term
.replace(/[%_]/g, '')
.split(/\s+/)
.filter(Boolean)
.slice(0, 3)
if (!safeWords.length) {
// Return no results. This could happen with weird input like ' % _ '.
return db.db.selectFrom('user').where(sql`1 = 0`)
}
// We'll ensure there's a space before each word in both textForMatch and in safeWords,
// so that we can reliably match word prefixes using LIKE operator.
const textForMatch = sql`lower(' ' || ${ref(
'user.username',
)} || ' ' || coalesce(${ref('profile.displayName')}, ''))`
const cursor = before !== undefined ? unpackCursor(before) : undefined
return db.db
.selectFrom('user')
.where((q) => {
safeWords.forEach((word) => {
// Match word prefixes against contents of username and displayName
q = q.where(textForMatch, 'like', `% ${word.toLowerCase()}%`)
})
return q
})
.if(!!cursor, (qb) =>
cursor ? qb.where('username', '>', cursor.name) : qb,
)
.orderBy('username')
.limit(limit)
}
// E.g. { distance: .94827, name: 'pfrazee' } -> '[0.94827,"pfrazee"]'
export const packCursor = (row: { distance: number; name: string }): string => {
const { distance, name } = row
return JSON.stringify([distance, name])
}
export const unpackCursor = (
before: string,
): { distance: number; name: string } => {
const result = safeParse(before)
if (!Array.isArray(result)) {
throw new InvalidRequestError('Malformed cursor')
}
const [distance, name, ...others] = result
if (typeof distance !== 'number' || !name || others.length > 0) {
throw new InvalidRequestError('Malformed cursor')
}
return {
name,
distance,
}
}
// Remove leading @ in case a username is input that way
export const cleanTerm = (term: string) => term.trim().replace(/^@/g, '')
// Uses pg_trgm strict word similarity to check similarity between a search term and a stored value
const distance = (term: string, ref: DbRef) =>
sql<number>`(${term} <<<-> ${ref})`
// Keyset condition for a cursor
const keyset = (cursor, refs: { username: DbRef; distance: DbRef }) => {
if (cursor === undefined) return undefined
return sql`(${refs.distance} > ${cursor.distance}) or (${refs.distance} = ${cursor.distance} and ${refs.username} > ${cursor.name})`
}

@ -1,4 +1,5 @@
import * as auth from '@adxp/auth'
import { AuthRequiredError } from '@adxp/xrpc-server'
import * as uint8arrays from 'uint8arrays'
import { DidResolver } from '@adxp/did-resolver'
import express from 'express'
@ -45,6 +46,14 @@ export class ServerAuth {
return sub
}
getUserDidOrThrow(req: express.Request): string {
const did = this.getUserDid(req)
if (did === null) {
throw new AuthRequiredError()
}
return did
}
verifyUser(req: express.Request, did: string): boolean {
const authorized = this.getUserDid(req)
return authorized === did

@ -1,4 +1,4 @@
import { Kysely } from 'kysely'
import { Kysely, sql } from 'kysely'
import * as user from './tables/user'
import * as repoRoot from './tables/repo-root'
@ -12,6 +12,7 @@ import * as repost from './records/repost'
import * as follow from './records/follow'
import * as profile from './records/profile'
import * as badge from './records/badge'
import { Dialect } from '.'
export type DatabaseSchema = user.PartialDB &
repoRoot.PartialDB &
@ -27,9 +28,23 @@ export type DatabaseSchema = user.PartialDB &
export const createTables = async (
db: Kysely<DatabaseSchema>,
dialect: Dialect,
): Promise<void> => {
if (dialect === 'pg') {
try {
// Add trigram support, supporting user search.
// Explicitly add to public schema, so the extension can be seen in all schemas.
await sql`create extension if not exists pg_trgm with schema public`.execute(
db,
)
} catch (err: any) {
// The "if not exists" isn't bulletproof against races, and we see test suites racing to
// create the extension. So we can just ignore errors indicating the extension already exists.
if (!err?.detail?.includes?.('(pg_trgm) already exists')) throw err
}
}
await Promise.all([
user.createTable(db),
user.createTable(db, dialect),
repoRoot.createTable(db),
record.createTable(db),
invite.createTable(db),
@ -38,7 +53,7 @@ export const createTables = async (
like.createTable(db),
repost.createTable(db),
follow.createTable(db),
profile.createTable(db),
profile.createTable(db, dialect),
badge.createTable(db),
])
}

@ -80,7 +80,8 @@ export class Database {
)
}
pool.on('connect', (client) =>
client.query(`SET search_path TO "${schema}"`),
// Shared objects such as extensions will go in the public schema
client.query(`SET search_path TO "${schema}",public`),
)
}
@ -103,7 +104,7 @@ export class Database {
if (this.schema !== undefined) {
await this.db.schema.createSchema(this.schema).ifNotExists().execute()
}
await createTables(this.db)
await createTables(this.db, this.dialect)
}
async getRepoRoot(did: string): Promise<CID | null> {
@ -333,7 +334,7 @@ export class Database {
export default Database
type Dialect = 'pg' | 'sqlite'
export type Dialect = 'pg' | 'sqlite'
// Can use with typeof to get types for partial queries
export const dbType = new Kysely<DatabaseSchema>({ dialect: dummyDialect })

@ -1,9 +1,10 @@
import { Kysely } from 'kysely'
import { Kysely, sql } from 'kysely'
import { AdxUri } from '@adxp/uri'
import { CID } from 'multiformats/cid'
import * as Profile from '../../lexicon/types/app/bsky/profile'
import { DbRecordPlugin, Notification } from '../types'
import schemas from '../schemas'
import { Dialect } from '..'
const type = 'app.bsky.profile'
const tableName = 'app_bsky_profile'
@ -24,7 +25,10 @@ export interface AppBskyProfileBadge {
badgeCid: string
}
export const createTable = async (db: Kysely<PartialDB>): Promise<void> => {
export const createTable = async (
db: Kysely<PartialDB>,
dialect: Dialect,
): Promise<void> => {
await db.schema
.createTable(tableName)
.addColumn('uri', 'varchar', (col) => col.primaryKey())
@ -35,6 +39,15 @@ export const createTable = async (db: Kysely<PartialDB>): Promise<void> => {
.addColumn('indexedAt', 'varchar', (col) => col.notNull())
.execute()
if (dialect === 'pg') {
await db.schema // Supports user search
.createIndex(`${tableName}_display_name_tgrm_idx`)
.on(tableName)
.using('gist')
.expression(sql`"displayName" gist_trgm_ops`)
.execute()
}
await db.schema
.createTable(supportingTableName)
.addColumn('profileUri', 'varchar', (col) => col.notNull())

@ -1,4 +1,5 @@
import { Kysely } from 'kysely'
import { Kysely, sql } from 'kysely'
import { Dialect } from '..'
export interface User {
did: string
@ -13,7 +14,10 @@ export const tableName = 'user'
export type PartialDB = { [tableName]: User }
export const createTable = async (db: Kysely<PartialDB>): Promise<void> => {
export const createTable = async (
db: Kysely<PartialDB>,
dialect: Dialect,
): Promise<void> => {
await db.schema
.createTable(tableName)
.addColumn('did', 'varchar', (col) => col.primaryKey())
@ -23,4 +27,12 @@ export const createTable = async (db: Kysely<PartialDB>): Promise<void> => {
.addColumn('lastSeenNotifs', 'varchar', (col) => col.notNull())
.addColumn('createdAt', 'varchar', (col) => col.notNull())
.execute()
if (dialect === 'pg') {
await db.schema // Supports user search
.createIndex(`${tableName}_username_tgrm_idx`)
.on(tableName)
.using('gist')
.expression(sql`"username" gist_trgm_ops`)
.execute()
}
}

@ -24,7 +24,7 @@ export const paginate = <QB extends SelectQueryBuilder<any, any, any>>(
opts: {
limit?: number
before?: string
by: RawBuilder | ReturnType<DynamicModule['ref']>
by: DbRef
},
) => {
return qb
@ -47,3 +47,5 @@ export const dummyDialect = {
return new SqliteQueryCompiler()
},
}
export type DbRef = RawBuilder | ReturnType<DynamicModule['ref']>

@ -37,6 +37,8 @@ import * as AppBskyGetProfile from './types/app/bsky/getProfile'
import * as AppBskyGetRepostedBy from './types/app/bsky/getRepostedBy'
import * as AppBskyGetUserFollowers from './types/app/bsky/getUserFollowers'
import * as AppBskyGetUserFollows from './types/app/bsky/getUserFollows'
import * as AppBskyGetUsersSearch from './types/app/bsky/getUsersSearch'
import * as AppBskyGetUsersTypeahead from './types/app/bsky/getUsersTypeahead'
import * as AppBskyPostNotificationsSeen from './types/app/bsky/postNotificationsSeen'
export function createServer(): Server {
@ -246,6 +248,16 @@ export class BskyNS {
return this.server.xrpc.method(schema, handler)
}
getUsersSearch(handler: AppBskyGetUsersSearch.Handler) {
const schema = 'app.bsky.getUsersSearch' // @ts-ignore
return this.server.xrpc.method(schema, handler)
}
getUsersTypeahead(handler: AppBskyGetUsersTypeahead.Handler) {
const schema = 'app.bsky.getUsersTypeahead' // @ts-ignore
return this.server.xrpc.method(schema, handler)
}
postNotificationsSeen(handler: AppBskyPostNotificationsSeen.Handler) {
const schema = 'app.bsky.postNotificationsSeen' // @ts-ignore
return this.server.xrpc.method(schema, handler)

@ -2273,6 +2273,111 @@ export const methodSchemas: MethodSchema[] = [
},
},
},
{
lexicon: 1,
id: 'app.bsky.getUsersSearch',
type: 'query',
description: 'Find users matching search criteria',
parameters: {
term: {
type: 'string',
required: true,
},
limit: {
type: 'number',
maximum: 100,
},
before: {
type: 'string',
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['users'],
properties: {
users: {
type: 'array',
items: {
type: 'object',
required: ['did', 'name', 'createdAt', 'indexedAt', 'cursor'],
properties: {
did: {
type: 'string',
},
name: {
type: 'string',
},
displayName: {
type: 'string',
maxLength: 64,
},
description: {
type: 'string',
},
createdAt: {
type: 'string',
format: 'date-time',
},
indexedAt: {
type: 'string',
format: 'date-time',
},
cursor: {
type: 'string',
},
},
},
},
},
},
},
},
{
lexicon: 1,
id: 'app.bsky.getUsersTypeahead',
type: 'query',
description: 'Find user suggestions for a search term',
parameters: {
term: {
type: 'string',
required: true,
},
limit: {
type: 'number',
maximum: 100,
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['users'],
properties: {
users: {
type: 'array',
items: {
type: 'object',
required: ['did', 'name'],
properties: {
did: {
type: 'string',
},
name: {
type: 'string',
},
displayName: {
type: 'string',
maxLength: 64,
},
},
},
},
},
},
},
},
{
lexicon: 1,
id: 'app.bsky.postNotificationsSeen',

@ -0,0 +1,43 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
export interface QueryParams {
term: string;
limit?: number;
before?: string;
}
export type HandlerInput = undefined
export interface HandlerSuccess {
encoding: 'application/json';
body: OutputSchema;
}
export interface HandlerError {
status: number;
message?: string;
}
export type HandlerOutput = HandlerError | HandlerSuccess
export interface OutputSchema {
users: {
did: string,
name: string,
displayName?: string,
description?: string,
createdAt: string,
indexedAt: string,
cursor: string,
}[];
}
export type Handler = (
params: QueryParams,
input: HandlerInput,
req: express.Request,
res: express.Response
) => Promise<HandlerOutput> | HandlerOutput

@ -0,0 +1,38 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
export interface QueryParams {
term: string;
limit?: number;
}
export type HandlerInput = undefined
export interface HandlerSuccess {
encoding: 'application/json';
body: OutputSchema;
}
export interface HandlerError {
status: number;
message?: string;
}
export type HandlerOutput = HandlerError | HandlerSuccess
export interface OutputSchema {
users: {
did: string,
name: string,
displayName?: string,
}[];
}
export type Handler = (
params: QueryParams,
input: HandlerInput,
req: express.Request,
res: express.Response
) => Promise<HandlerOutput> | HandlerOutput

@ -1,28 +1,10 @@
import { Request } from 'express'
export const readReqBytes = async (req: Request): Promise<Uint8Array> => {
return new Promise((resolve) => {
const chunks: Buffer[] = []
req.on('data', (chunk) => {
chunks.push(chunk)
})
req.on('end', () => {
resolve(new Uint8Array(Buffer.concat(chunks)))
})
})
}
export const parseBooleanParam = (
param: unknown,
defaultTrue = false,
): boolean => {
if (defaultTrue) {
if (param === 'false' || param === 'f') return false
return true
} else {
if (param === 'true' || param === 't') return true
return false
export const debugCatch = <Func extends (...args: any[]) => any>(fn: Func) => {
return async (...args) => {
try {
return await fn(...args)
} catch (err) {
console.error(err)
throw err
}
}
}

@ -9,27 +9,18 @@ import server, { ServerConfig, Database, App } from '../src/index'
import * as GetAuthorFeed from '../src/lexicon/types/app/bsky/getAuthorFeed'
import * as GetHomeFeed from '../src/lexicon/types/app/bsky/getHomeFeed'
const USE_TEST_SERVER = true
const ADMIN_PASSWORD = 'admin-pass'
export type CloseFn = () => Promise<void>
export type TestServerInfo = {
url: string
app?: App
app: App
close: CloseFn
}
export const runTestServer = async (
params: Partial<ServerConfig> = {},
): Promise<TestServerInfo> => {
if (!USE_TEST_SERVER) {
return {
url: 'http://localhost:2583',
close: async () => {},
}
}
const pdsPort = await getPort()
const keypair = await crypto.EcdsaKeypair.create()

@ -22,7 +22,7 @@ describe('account', () => {
let serverUrl: string
let client: AdxServiceClient
let close: util.CloseFn
let app: App | undefined
let app: App
const mailCatcher = new EventEmitter()
let _origSendMail
@ -36,23 +36,19 @@ describe('account', () => {
serverUrl = server.url
client = AdxApi.service(serverUrl)
if (app !== undefined) {
// Catch emails for use in tests
const { mailer } = locals.get(app)
_origSendMail = mailer.transporter.sendMail
mailer.transporter.sendMail = async (opts) => {
const result = await _origSendMail.call(mailer.transporter, opts)
mailCatcher.emit('mail', opts)
return result
}
// Catch emails for use in tests
const { mailer } = locals.get(app)
_origSendMail = mailer.transporter.sendMail
mailer.transporter.sendMail = async (opts) => {
const result = await _origSendMail.call(mailer.transporter, opts)
mailCatcher.emit('mail', opts)
return result
}
})
afterAll(async () => {
if (app !== undefined) {
const { mailer } = locals.get(app)
mailer.transporter.sendMail = _origSendMail
}
const { mailer } = locals.get(app)
mailer.transporter.sendMail = _origSendMail
if (close) {
await close()
}
@ -275,7 +271,6 @@ describe('account', () => {
})
it('allows only unexpired password reset tokens', async () => {
if (app === undefined) throw new Error()
const { config, db } = locals.get(app)
const user = await db.db

@ -0,0 +1,220 @@
import { SeedClient } from './client'
export default async (sc: SeedClient) => {
// @TODO when these are run in parallel, seem to get an intermittent
// "TypeError: fetch failed" while running the tests.
for (const { username, displayName } of users) {
await sc.createAccount(username, {
username: username,
password: 'password',
email: `${username}@bsky.app`,
})
if (displayName !== null) {
await sc.createProfile(sc.dids[username], displayName, '')
}
}
return sc
}
const users = [
{ username: 'Silas77', displayName: 'Tanya Denesik' },
{ username: 'Nicolas_Krajcik10', displayName: null },
{ username: 'Lennie.Strosin', displayName: null },
{ username: 'Aliya.Hodkiewicz', displayName: 'Carlton Abernathy IV' },
{ username: 'Jeffrey.Sawayn87', displayName: 'Patrick Sawayn' },
{ username: 'Kaycee66', displayName: null },
{ username: 'Adrienne49', displayName: 'Kim Streich' },
{ username: 'Magnus53', displayName: 'Sally Funk' },
{ username: 'Charles_Spencer', displayName: null },
{ username: 'Elta48', displayName: 'Dr. Lowell DuBuque' },
{ username: 'Tressa_Senger', displayName: null },
{ username: 'Marietta.Zboncak', displayName: null },
{ username: 'Alexander_Hickle', displayName: 'Winifred Harber' },
{ username: 'Rodger.Maggio24', displayName: 'Yolanda VonRueden' },
{ username: 'Janiya48', displayName: 'Miss Terrell Ziemann' },
{ username: 'Cayla_Marquardt39', displayName: 'Rachel Kshlerin' },
{ username: 'Jonathan_Green', displayName: 'Erica Mertz' },
{ username: 'Brycen_Smith', displayName: null },
{ username: 'Leonel.Koch43', displayName: 'Karl Bosco IV' },
{ username: 'Fidel.Rath', displayName: null },
{ username: 'Raleigh_Metz', displayName: null },
{ username: 'Kim41', displayName: null },
{ username: 'Roderick.Dibbert', displayName: null },
{ username: 'Alec_Bergnaum', displayName: 'Cody Berge' },
{ username: 'Sven70', displayName: null },
{ username: 'Ola.OConnell', displayName: null },
{ username: 'Chauncey_Klein', displayName: 'Kelvin Klein' },
{ username: 'Ariel_Krajcik', displayName: null },
{ username: 'Murphy35', displayName: 'Mrs. Clifford Mertz' },
{ username: 'Joshuah.Parker11', displayName: null },
{ username: 'Dewitt.Wunsch', displayName: null },
{ username: 'Kelton.Nitzsche43', displayName: null },
{ username: 'Dock.Mann91', displayName: 'Miss Danielle Weber' },
{ username: 'Herman.Gleichner95', displayName: 'Kelli Schinner III' },
{ username: 'Gerda_Marquardt', displayName: 'Myron Wolf' },
{ username: 'Jamil_Batz', displayName: null },
{ username: 'Hilario84', displayName: null },
{ username: 'Kayli_Bode', displayName: 'Miss Floyd McClure' },
{ username: 'Elouise28', displayName: 'Alberta Fay' },
{ username: 'Leann49', displayName: null },
{ username: 'Javon24', displayName: null },
{ username: 'Polly.Shanahan45', displayName: null },
{ username: 'Rosamond38', displayName: 'Karl Goyette' },
{ username: 'Fredrick.Mueller', displayName: null },
{ username: 'Reina_Runte33', displayName: 'Pablo Schmidt' },
{ username: 'Bianka33', displayName: null },
{ username: 'Carlos6', displayName: null },
{ username: 'Jermain.Smith', displayName: 'Eileen Stroman' },
{ username: 'Gina97', displayName: null },
{ username: 'Kiera97', displayName: null },
{ username: 'Savannah_Botsford', displayName: 'Darnell Kuvalis' },
{ username: 'Lilliana_Waters', displayName: null },
{ username: 'Hailey_Stroman', displayName: 'Elsa Schaden' },
{ username: 'Dortha_Terry', displayName: 'Nicole Bradtke' },
{ username: 'Hank.Powlowski32', displayName: null },
{ username: 'Ervin.Daugherty', displayName: null },
{ username: 'Nannie18', displayName: null },
{ username: 'Gilberto.Watsica65', displayName: 'Ms. Ida Wilderman' },
{ username: 'Kara.Zieme58', displayName: 'Andres Towne' },
{ username: 'Crystal.Boyle', displayName: null },
{ username: 'Tobin63', displayName: 'Alex Johnson' },
{ username: 'Isai.Kunze72', displayName: 'Marion Dickinson' },
{ username: 'Paris.Swift', displayName: null },
{ username: 'Nestor90', displayName: 'Travis Hoppe' },
{ username: 'Aliyah_Flatley12', displayName: 'Loren Krajcik' },
{ username: 'Maiya42', displayName: null },
{ username: 'Dovie33', displayName: null },
{ username: 'Kendra_Ledner80', displayName: 'Sergio Hane' },
{ username: 'Greyson.Tromp3', displayName: null },
{ username: 'Precious_Fay', displayName: null },
{ username: 'Kiana_Schmitt39', displayName: null },
{ username: 'Rhianna_Stamm29', displayName: null },
{ username: 'Tiara_Mohr', displayName: null },
{ username: 'Eleazar.Balistreri70', displayName: 'Gordon Weissnat' },
{ username: 'Bettie.Bogisich96', displayName: null },
{ username: 'Lura.Jacobi55', displayName: null },
{ username: 'Santa_Hermann78', displayName: 'Melissa Johnson' },
{ username: 'Dylan61', displayName: null },
{ username: 'Ryley_Kerluke', displayName: 'Alexander Purdy' },
{ username: 'Moises.Bins8', displayName: null },
{ username: 'Angelita.Schaefer27', displayName: null },
{ username: 'Natasha83', displayName: 'Dean Romaguera' },
{ username: 'Sydni48', displayName: null },
{ username: 'Darrion91', displayName: 'Jeanette Weimann' },
{ username: 'Reynold.Ortiz', displayName: null },
{ username: 'Hassie.Schuppe', displayName: 'Rita Zieme' },
{ username: 'Clark_Stehr8', displayName: 'Sammy Larkin' },
{ username: 'Preston_Harris', displayName: 'Ms. Bradford Thiel' },
{ username: 'Benedict.Schulist', displayName: 'Todd Stark' },
{ username: 'Alden_Wolff22', displayName: null },
{ username: 'Joel.Gulgowski', displayName: null },
{ username: 'Joanie56', displayName: 'Ms. Darin Cole' },
{ username: 'Israel_Hermann0', displayName: 'Wilbur Schuster' },
{ username: 'Tracy56', displayName: null },
{ username: 'Kyle72', displayName: null },
{ username: 'Gunnar_Dare70', displayName: 'Mrs. Angelo Keeling' },
{ username: 'Justus58', displayName: null },
{ username: 'Brooke24', displayName: 'Clint Ward' },
{ username: 'Angela.Morissette', displayName: 'Jim Kertzmann' },
{ username: 'Amy_Bins', displayName: 'Angelina Hills' },
{ username: 'Susanna81', displayName: null },
{ username: 'Jailyn_Hettinger50', displayName: 'Sheldon Ratke' },
{ username: 'Wendell_Hansen54', displayName: null },
{ username: 'Jennyfer.Spinka', displayName: 'Leticia Blick' },
{ username: 'Alexandrea31', displayName: 'Leslie Von' },
{ username: 'Hazle_Davis', displayName: 'Ella Farrell' },
{ username: 'Alta6', displayName: null },
{ username: 'Sherwood4', displayName: 'Dr. Hattie Nienow I' },
{ username: 'Marilie24', displayName: 'Gene Howell' },
{ username: 'Jimmie_Feeney82', displayName: null },
{ username: 'Trisha_OHara', displayName: null },
{ username: 'Jake_Schuster33', displayName: 'Raymond Price' },
{ username: 'Shane_Torphy52', displayName: 'Sadie Carter' },
{ username: 'Nakia_Kuphal8', displayName: null },
{ username: 'Lea_Trantow', displayName: null },
{ username: 'Joel62', displayName: 'Veronica Nitzsche' },
{ username: 'Roosevelt33', displayName: 'Jay Moen' },
{ username: 'Talon_OKeefe85', displayName: null },
{ username: 'Herman_Dare', displayName: 'Eric White' },
{ username: 'Flavio_Fay', displayName: 'John Lindgren' },
{ username: 'Elyse.Prosacco', displayName: null },
{ username: 'Jessyca.Wiegand23', displayName: 'Debra Lockman' },
{ username: 'Ara_Spencer41', displayName: null },
{ username: 'Frederic_Fadel', displayName: null },
{ username: 'Zora_Gerlach', displayName: 'Noel Hansen' },
{ username: 'Spencer4', displayName: 'Marjorie Gorczany' },
{ username: 'Gage_Wilkinson33', displayName: 'Preston Schoen V' },
{ username: 'Kiley_Runolfsson1', displayName: null },
{ username: 'Ramona80', displayName: 'Sylvia Dietrich' },
{ username: 'Rashad97', displayName: null },
{ username: 'Kylie76', displayName: 'Josefina Pfeffer' },
{ username: 'Alisha.Zieme', displayName: null },
{ username: 'Claud79', displayName: null },
{ username: 'Jairo.Kuvalis', displayName: 'Derrick Jacobson' },
{ username: 'Delfina_Emard', displayName: null },
{ username: 'Waino.Gutmann20', displayName: 'Wesley Kemmer' },
{ username: 'Arvid_Hermiston49', displayName: 'Vernon Towne PhD' },
{ username: 'Hans79', displayName: 'Rex Hartmann' },
{ username: 'Karlee.Greenholt40', displayName: null },
{ username: 'Nels.Cummings', displayName: null },
{ username: 'Andrew_Maggio', displayName: null },
{ username: 'Stephany75', displayName: null },
{ username: 'Alba.Lueilwitz', displayName: null },
{ username: 'Fermin47', displayName: null },
{ username: 'Milo_Quitzon3', displayName: null },
{ username: 'Eudora_Dietrich4', displayName: 'Carol Littel' },
{ username: 'Uriel.Witting12', displayName: 'Sophia Schmidt' },
{ username: 'Reuben.Stracke48', displayName: 'Darrell Walker MD' },
{ username: 'Letitia.Sawayn11', displayName: 'Mrs. Sophie Reilly' },
{ username: 'Macy_Schaden', displayName: 'Lindsey Klein' },
{ username: 'Imelda61', displayName: 'Shannon Beier' },
{ username: 'Oswald_Bailey', displayName: 'Angel Mann' },
{ username: 'Pattie.Fisher34', displayName: null },
{ username: 'Loyce95', displayName: 'Claude Tromp' },
{ username: 'Melyna_Zboncak', displayName: null },
{ username: 'Rowan_Parisian', displayName: 'Mr. Veronica Feeney' },
{ username: 'Lois.Blanda20', displayName: 'Todd Rolfson' },
{ username: 'Turner_Balistreri76', displayName: null },
{ username: 'Dee_Hoppe65', displayName: null },
{ username: 'Nikko_Rosenbaum60', displayName: 'Joann Gutmann' },
{ username: 'Cornell.Romaguera53', displayName: null },
{ username: 'Zack3', displayName: null },
{ username: 'Fredrick41', displayName: 'Julius Kreiger' },
{ username: 'Elwyn62', displayName: null },
{ username: 'Isaias.Hirthe37', displayName: 'Louis Cremin' },
{ username: 'Ronaldo36', displayName: null },
{ username: 'Jesse34', displayName: 'Bridget Schulist' },
{ username: 'Darrel.Mills17', displayName: null },
{ username: 'Euna_Mayert92', displayName: 'Grant Lang II' },
{ username: 'Terrell92', displayName: null },
{ username: 'Alyson_Bogisich', displayName: 'Dana MacGyver' },
{ username: 'Nicolas65', displayName: null },
{ username: 'Bernita8', displayName: null },
{ username: 'Gunner23', displayName: 'Maggie DuBuque' },
{ username: 'Phoebe80', displayName: null },
{ username: 'Cory.Cruickshank', displayName: null },
{ username: 'Conor_Price', displayName: 'Ralph Daugherty III' },
{ username: 'Rae91', displayName: null },
{ username: 'Abigale.Cronin', displayName: null },
{ username: 'Aileen.Reilly90', displayName: 'Charles Stanton' },
{ username: 'Adrianna.Hansen6', displayName: 'Elbert Langworth IV' },
{ username: 'Pierre54', displayName: null },
{ username: 'Jaida_Stark62', displayName: 'Justin Stoltenberg MD' },
{ username: 'Wade.Witting', displayName: null },
{ username: 'Yvonne_Predovic5', displayName: 'Gregory Hamill' },
{ username: 'Spencer.DuBuque', displayName: null },
{ username: 'Randi44', displayName: null },
{ username: 'Maye_Grimes', displayName: null },
{ username: 'Margarette.Effertz', displayName: null },
{ username: 'Aimee98', displayName: null },
{ username: 'Jaren_Veum0', displayName: 'Dr. Omar Wolff' },
{ username: 'Ariel_Abbott54', displayName: 'Emanuel Powlowski' },
{ username: 'Mercedes23', displayName: null },
{ username: 'Jarrett_Orn', displayName: null },
{ username: 'Damion88', displayName: null },
{ username: 'Nayeli_Koss73', displayName: 'Johnny Lang' },
{ username: 'Cara.Wiegand69', displayName: null },
{ username: 'Gideon_OHara51', displayName: null },
{ username: 'Carolina_McDermott77', displayName: 'Latoya Windler' },
{ username: 'Danyka90', displayName: 'Hope Kub' },
]

@ -8,7 +8,7 @@ import { App } from '../../src'
describe('pds notification views', () => {
let client: AdxServiceClient
let close: CloseFn
let app: App | undefined
let app: App
let sc: SeedClient
// account dids, for convenience
@ -58,7 +58,6 @@ describe('pds notification views', () => {
})
it('paginates', async () => {
if (app === undefined) throw new Error()
const { db } = locals.get(app)
const full = await client.app.bsky.getNotifications({}, undefined, {
@ -91,7 +90,6 @@ describe('pds notification views', () => {
})
it('updates notifications last seen', async () => {
if (app === undefined) throw new Error()
const { db } = locals.get(app)
const full = await client.app.bsky.getNotifications({}, undefined, {

@ -0,0 +1,374 @@
import AdxApi, { ServiceClient as AdxServiceClient } from '@adxp/api'
import { runTestServer, forSnapshot, CloseFn } from '../_util'
import { SeedClient } from '../seeds/client'
import usersBulkSeed from '../seeds/users-bulk'
import { App } from '../../src'
import * as locals from '../../src/locals'
describe('pds user search views', () => {
let app: App
let client: AdxServiceClient
let close: CloseFn
let sc: SeedClient
let headers: { [s: string]: string }
beforeAll(async () => {
const server = await runTestServer({
dbPostgresSchema: 'views_user_search',
})
close = server.close
app = server.app
client = AdxApi.service(server.url)
sc = new SeedClient(client)
await usersBulkSeed(sc)
headers = sc.getHeaders(Object.values(sc.dids)[0])
})
afterAll(async () => {
await close()
})
it('typeahead gives relevant results', async () => {
const result = await client.app.bsky.getUsersTypeahead(
{ term: 'car' },
undefined,
{ headers },
)
const names = result.data.users.map((u) => u.name)
const shouldContain = [
'Cara.Wiegand69',
'Eudora_Dietrich4', // Carol Littel
'Shane_Torphy52', // Sadie Carter
'Aliya.Hodkiewicz', // Carlton Abernathy IV
'Carlos6',
'Carolina_McDermott77',
]
shouldContain.forEach((name) => expect(names).toContain(name))
if (locals.get(app).db.dialect === 'pg') {
expect(names).toContain('Cayla_Marquardt39') // Fuzzy match supported by postgres
} else {
expect(names).not.toContain('Cayla_Marquardt39')
}
const shouldNotContain = [
'Sven70',
'Hilario84',
'Santa_Hermann78',
'Dylan61',
'Preston_Harris',
'Loyce95',
'Melyna_Zboncak',
]
shouldNotContain.forEach((name) => expect(names).not.toContain(name))
if (locals.get(app).db.dialect === 'pg') {
expect(forSnapshot(result.data.users)).toEqual(snapTypeaheadPg)
} else {
expect(forSnapshot(result.data.users)).toEqual(snapTypeaheadSqlite)
}
})
it('typeahead gives empty result set when provided empty term', async () => {
const result = await client.app.bsky.getUsersTypeahead(
{ term: '' },
undefined,
{ headers },
)
expect(result.data.users).toEqual([])
})
it('typeahead applies limit', async () => {
const full = await client.app.bsky.getUsersTypeahead(
{ term: 'p' },
undefined,
{ headers },
)
expect(full.data.users.length).toBeGreaterThan(5)
const limited = await client.app.bsky.getUsersTypeahead(
{ term: 'p', limit: 5 },
undefined,
{ headers },
)
expect(limited.data.users).toEqual(full.data.users.slice(0, 5))
})
it('search gives relevant results', async () => {
const result = await client.app.bsky.getUsersSearch(
{ term: 'car' },
undefined,
{ headers },
)
const names = result.data.users.map((u) => u.name)
const shouldContain = [
'Cara.Wiegand69',
'Eudora_Dietrich4', // Carol Littel
'Shane_Torphy52', // Sadie Carter
'Aliya.Hodkiewicz', // Carlton Abernathy IV
'Carlos6',
'Carolina_McDermott77',
]
shouldContain.forEach((name) => expect(names).toContain(name))
if (locals.get(app).db.dialect === 'pg') {
expect(names).toContain('Cayla_Marquardt39') // Fuzzy match supported by postgres
} else {
expect(names).not.toContain('Cayla_Marquardt39')
}
const shouldNotContain = [
'Sven70',
'Hilario84',
'Santa_Hermann78',
'Dylan61',
'Preston_Harris',
'Loyce95',
'Melyna_Zboncak',
]
shouldNotContain.forEach((name) => expect(names).not.toContain(name))
if (locals.get(app).db.dialect === 'pg') {
expect(forSnapshot(result.data.users)).toEqual(snapSearchPg)
} else {
expect(forSnapshot(result.data.users)).toEqual(snapSearchSqlite)
}
})
it('search gives empty result set when provided empty term', async () => {
const result = await client.app.bsky.getUsersSearch(
{ term: '' },
undefined,
{ headers },
)
expect(result.data.users).toEqual([])
})
it('search paginates', async () => {
const full = await client.app.bsky.getUsersSearch(
{ term: 'p' },
undefined,
{ headers },
)
expect(full.data.users.length).toBeGreaterThan(5)
const limited = await client.app.bsky.getUsersSearch(
{ term: 'p', limit: 3, before: full.data.users[0].cursor },
undefined,
{ headers },
)
expect(limited.data.users).toEqual(full.data.users.slice(1, 4))
})
it('search handles bad input', async () => {
// Mostly for sqlite's benefit, since it uses LIKE and these are special characters that will
// get stripped. This input triggers a special case where there are no "safe" words for sqlite to search on.
const result = await client.app.bsky.getUsersSearch(
{ term: ' % _ ' },
undefined,
{ headers },
)
expect(result.data.users).toEqual([])
})
})
// Not using jest snapshots because it doesn't handle the conditional pg/sqlite very well:
// you can achieve it using named snapshots, but when you run the tests for pg the test suite fails
// since the sqlite snapshots appear obsolete to jest (and vice-versa when you run the sqlite suite).
const snapTypeaheadPg = [
{
did: 'user(0)',
name: 'Cara.Wiegand69',
},
{
did: 'user(1)',
displayName: 'Carol Littel',
name: 'Eudora_Dietrich4',
},
{
did: 'user(2)',
displayName: 'Sadie Carter',
name: 'Shane_Torphy52',
},
{
did: 'user(3)',
displayName: 'Carlton Abernathy IV',
name: 'Aliya.Hodkiewicz',
},
{
did: 'user(4)',
name: 'Carlos6',
},
{
did: 'user(5)',
displayName: 'Latoya Windler',
name: 'Carolina_McDermott77',
},
{
did: 'user(6)',
displayName: 'Rachel Kshlerin',
name: 'Cayla_Marquardt39',
},
]
const snapTypeaheadSqlite = [
{
did: 'user(0)',
displayName: 'Carlton Abernathy IV',
name: 'Aliya.Hodkiewicz',
},
{
did: 'user(1)',
name: 'Cara.Wiegand69',
},
{
did: 'user(2)',
name: 'Carlos6',
},
{
did: 'user(3)',
displayName: 'Latoya Windler',
name: 'Carolina_McDermott77',
},
{
did: 'user(4)',
displayName: 'Carol Littel',
name: 'Eudora_Dietrich4',
},
{
did: 'user(5)',
displayName: 'Sadie Carter',
name: 'Shane_Torphy52',
},
]
const snapSearchPg = [
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0.5,"Cara.Wiegand69"]',
did: 'user(0)',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Cara.Wiegand69',
},
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0.57142854,"Eudora_Dietrich4"]',
description: '',
did: 'user(1)',
displayName: 'Carol Littel',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Eudora_Dietrich4',
},
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0.625,"Shane_Torphy52"]',
description: '',
did: 'user(2)',
displayName: 'Sadie Carter',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Shane_Torphy52',
},
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0.6666666,"Aliya.Hodkiewicz"]',
description: '',
did: 'user(3)',
displayName: 'Carlton Abernathy IV',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Aliya.Hodkiewicz',
},
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0.6666666,"Carlos6"]',
did: 'user(4)',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Carlos6',
},
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0.7,"Carolina_McDermott77"]',
description: '',
did: 'user(5)',
displayName: 'Latoya Windler',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Carolina_McDermott77',
},
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0.75,"Cayla_Marquardt39"]',
description: '',
did: 'user(6)',
displayName: 'Rachel Kshlerin',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Cayla_Marquardt39',
},
]
const snapSearchSqlite = [
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0,"Aliya.Hodkiewicz"]',
description: '',
did: 'user(0)',
displayName: 'Carlton Abernathy IV',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Aliya.Hodkiewicz',
},
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0,"Cara.Wiegand69"]',
did: 'user(1)',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Cara.Wiegand69',
},
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0,"Carlos6"]',
did: 'user(2)',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Carlos6',
},
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0,"Carolina_McDermott77"]',
description: '',
did: 'user(3)',
displayName: 'Latoya Windler',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Carolina_McDermott77',
},
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0,"Eudora_Dietrich4"]',
description: '',
did: 'user(4)',
displayName: 'Carol Littel',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Eudora_Dietrich4',
},
{
createdAt: '1970-01-01T00:00:00.000Z',
cursor: '[0,"Shane_Torphy52"]',
description: '',
did: 'user(5)',
displayName: 'Sadie Carter',
indexedAt: '1970-01-01T00:00:00.000Z',
name: 'Shane_Torphy52',
},
]

@ -976,6 +976,11 @@
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==
"@hapi/bourne@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7"
integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==
"@hapi/hoek@^9.0.0":
version "9.3.0"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb"