User search (#238)
* 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:
parent
90b3792d90
commit
e314974d1b
lexicons/bsky.app
packages
yarn.lock
39
lexicons/bsky.app/getUsersSearch.json
Normal file
39
lexicons/bsky.app/getUsersSearch.json
Normal file
@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
34
lexicons/bsky.app/getUsersTypeahead.json
Normal file
34
lexicons/bsky.app/getUsersTypeahead.json
Normal file
@ -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',
|
||||
|
40
packages/api/src/types/app/bsky/getUsersSearch.ts
Normal file
40
packages/api/src/types/app/bsky/getUsersSearch.ts
Normal file
@ -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
|
||||
}
|
35
packages/api/src/types/app/bsky/getUsersTypeahead.ts
Normal file
35
packages/api/src/types/app/bsky/getUsersTypeahead.ts
Normal file
@ -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",
|
||||
|
99
packages/server/src/api/app/bsky/getUsersSearch.ts
Normal file
99
packages/server/src/api/app/bsky/getUsersSearch.ts
Normal file
@ -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
|
||||
}[]
|
||||
>
|
74
packages/server/src/api/app/bsky/getUsersTypeahead.ts
Normal file
74
packages/server/src/api/app/bsky/getUsersTypeahead.ts
Normal file
@ -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)
|
||||
|
162
packages/server/src/api/app/bsky/util/search.ts
Normal file
162
packages/server/src/api/app/bsky/util/search.ts
Normal file
@ -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',
|
||||
|
43
packages/server/src/lexicon/types/app/bsky/getUsersSearch.ts
Normal file
43
packages/server/src/lexicon/types/app/bsky/getUsersSearch.ts
Normal file
@ -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
|
||||
|
220
packages/server/tests/seeds/users-bulk.ts
Normal file
220
packages/server/tests/seeds/users-bulk.ts
Normal file
@ -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, {
|
||||
|
374
packages/server/tests/views/user-search.test.ts
Normal file
374
packages/server/tests/views/user-search.test.ts
Normal file
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user