Skip to content

Typesense Search

Overview

Typesense is a fast, typo-tolerant search engine that powers the global search functionality in the Admin Frontend. This document covers the complete setup, configuration, indexing strategy, and API integration for high-performance full-text search across digital assets, folders, and collages.

Architecture

Components

  1. Typesense Server: Search engine server (self-hosted or cloud)
  2. Collections: Indexed data structures (digital_assets, digital_assets_categories, dam_collections)
  3. Indexing Service: Backend service that syncs data to Typesense
  4. Search API: Express.js endpoints for search operations
  5. Frontend Client: Typesense JS client for browser-side search

Typesense Setup

Server Configuration

javascript
// typesense.config.js
module.exports = {
  server: {
    apiKey: process.env.TYPESENSE_API_KEY,
    nodes: [
      {
        host: process.env.TYPESENSE_HOST || 'localhost',
        port: process.env.TYPESENSE_PORT || 8108,
        protocol: process.env.TYPESENSE_PROTOCOL || 'http'
      }
    ],
    connectionTimeoutSeconds: 2
  }
}

Collection Schemas

Digital Assets Collection

javascript
{
  name: 'digital_assets',
  fields: [
    { name: 'id', type: 'string' },
    { name: 'workspace_id', type: 'int32', facet: true },
    { name: 'display_file_name', type: 'string' },
    { name: 'search_name', type: 'string' }, // Lowercase, normalized
    { name: 'description', type: 'string', optional: true },
    { name: 'file_type', type: 'string', facet: true },
    { name: 'file_size', type: 'int64' },
    { name: 'width', type: 'int32', optional: true },
    { name: 'height', type: 'int32', optional: true },
    { name: 'orientation', type: 'string', facet: true }, // horizontal, vertical, square
    { name: 'tags', type: 'string[]', facet: true },
    { name: 'category_id', type: 'int32', optional: true, facet: true },
    { name: 'uploaded_by', type: 'int32', facet: true },
    { name: 'uploaded_by_name', type: 'string' },
    { name: 'visibility', type: 'int32', facet: true }, // 0 = public, 1+ = private
    { name: 'created_at', type: 'int64' },
    { name: 'modified_at', type: 'int64' },
    { name: 'custom_fields', type: 'object', optional: true },
    { name: 's3_key', type: 'string' },
    { name: 'thumbnail_url', type: 'string', optional: true }
  ],
  default_sorting_field: 'modified_at'
}

Folders Collection

javascript
{
  name: 'digital_assets_categories',
  fields: [
    { name: 'id', type: 'string' },
    { name: 'workspace_id', type: 'int32', facet: true },
    { name: 'name', type: 'string' },
    { name: 'search_name', type: 'string' },
    { name: 'description', type: 'string', optional: true },
    { name: 'parent_id', type: 'int32', optional: true },
    { name: 'total_assets', type: 'int32' },
    { name: 'total_subfolders', type: 'int32' },
    { name: 'created_by', type: 'int32', facet: true },
    { name: 'created_by_name', type: 'string' },
    { name: 'visibility', type: 'int32', facet: true },
    { name: 'created_at', type: 'int64' },
    { name: 'modified_at', type: 'int64' }
  ],
  default_sorting_field: 'modified_at'
}

Collages Collection

javascript
{
  name: 'dam_collections',
  fields: [
    { name: 'id', type: 'string' },
    { name: 'workspace_id', type: 'int32', facet: true },
    { name: 'name', type: 'string' },
    { name: 'search_name', type: 'string' },
    { name: 'description', type: 'string', optional: true },
    { name: 'total_assets', type: 'int32' },
    { name: 'created_by', type: 'int32', facet: true },
    { name: 'created_by_name', type: 'string' },
    { name: 'visibility', type: 'int32', facet: true },
    { name: 'created_at', type: 'int64' },
    { name: 'modified_at', type: 'int64' }
  ],
  default_sorting_field: 'modified_at'
}

Backend Implementation

Typesense Client Service

javascript
// services/typesense.js
const Typesense = require('typesense')

class TypesenseService {
  constructor() {
    this.client = new Typesense.Client({
      nodes: [
        {
          host: process.env.TYPESENSE_HOST,
          port: process.env.TYPESENSE_PORT,
          protocol: process.env.TYPESENSE_PROTOCOL
        }
      ],
      apiKey: process.env.TYPESENSE_API_KEY,
      connectionTimeoutSeconds: 2
    })
  }

  async createCollections() {
    const collections = [
      this.getAssetsSchema(),
      this.getFoldersSchema(),
      this.getCollagesSchema()
    ]

    for (const schema of collections) {
      try {
        await this.client.collections().create(schema)
        console.log(`Created collection: ${schema.name}`)
      } catch (error) {
        if (error.httpStatus === 409) {
          console.log(`Collection ${schema.name} already exists`)
        } else {
          throw error
        }
      }
    }
  }

  async indexAsset(asset) {
    const document = {
      id: String(asset.id),
      workspace_id: asset.workspace_id,
      display_file_name: asset.display_file_name,
      search_name: asset.display_file_name.toLowerCase().trim(),
      description: asset.description || '',
      file_type: asset.file_type,
      file_size: asset.file_size,
      width: asset.width || null,
      height: asset.height || null,
      orientation: this.getOrientation(asset),
      tags: asset.tags?.map(t => t.name) || [],
      category_id: asset.category_id || null,
      uploaded_by: asset.uploaded_by,
      uploaded_by_name: asset.uploaded_by_name,
      visibility: asset.visibility || 0,
      created_at: Math.floor(new Date(asset.created_at).getTime() / 1000),
      modified_at: Math.floor(new Date(asset.modified_at).getTime() / 1000),
      custom_fields: this.formatCustomFields(asset.custom_fields),
      s3_key: asset.s3_key,
      thumbnail_url: asset.thumbnail_url || null
    }

    try {
      await this.client.collections('digital_assets').documents().upsert(document)
    } catch (error) {
      console.error('Error indexing asset:', error)
      throw error
    }
  }

  async indexFolder(folder) {
    const document = {
      id: String(folder.id),
      workspace_id: folder.workspace_id,
      name: folder.name,
      search_name: folder.name.toLowerCase().trim(),
      description: folder.description || '',
      parent_id: folder.parent_id || null,
      total_assets: folder.total_assets || 0,
      total_subfolders: folder.total_subfolders || 0,
      created_by: folder.created_by,
      created_by_name: folder.created_by_name,
      visibility: folder.visibility || 0,
      created_at: Math.floor(new Date(folder.created_at).getTime() / 1000),
      modified_at: Math.floor(new Date(folder.modified_at).getTime() / 1000)
    }

    await this.client.collections('digital_assets_categories').documents().upsert(document)
  }

  async indexCollage(collage) {
    const document = {
      id: String(collage.id),
      workspace_id: collage.workspace_id,
      name: collage.name,
      search_name: collage.name.toLowerCase().trim(),
      description: collage.description || '',
      total_assets: collage.total_assets || 0,
      created_by: collage.created_by,
      created_by_name: collage.created_by_name,
      visibility: collage.visibility || 0,
      created_at: Math.floor(new Date(collage.created_at).getTime() / 1000),
      modified_at: Math.floor(new Date(collage.modified_at).getTime() / 1000)
    }

    await this.client.collections('dam_collections').documents().upsert(document)
  }

  async deleteAsset(assetId) {
    await this.client.collections('digital_assets').documents(String(assetId)).delete()
  }

  async deleteFolder(folderId) {
    await this.client.collections('digital_assets_categories').documents(String(folderId)).delete()
  }

  async deleteCollage(collageId) {
    await this.client.collections('dam_collections').documents(String(collageId)).delete()
  }

  async search(request) {
    const { q, collections, filterQuery, commonSearchParams, workspace_id } = request

    const searches = collections.map(collection => ({
      collection,
      q: q || '*',
      query_by: commonSearchParams.query_by || 'search_name',
      filter_by: filterQuery[collection] || '',
      sort_by: commonSearchParams.sort_by || 'modified_at:desc',
      per_page: commonSearchParams.per_page || 10,
      page: commonSearchParams.page || 1,
      facet_by: commonSearchParams.facet_by || '',
      max_facet_values: commonSearchParams.max_facet_values || 10
    }))

    const results = await this.client.multiSearch.perform({ searches }, {})

    return results
  }

  getOrientation(asset) {
    if (!asset.width || !asset.height) return null
    if (asset.width > asset.height) return 'horizontal'
    if (asset.height > asset.width) return 'vertical'
    return 'square'
  }

  formatCustomFields(customFields) {
    if (!customFields) return {}
    const formatted = {}
    customFields.forEach(field => {
      formatted[field.field_key] = field.field_value
    })
    return formatted
  }
}

module.exports = new TypesenseService()

Search API Endpoint

javascript
// api/typesense.js
const express = require('express')
const router = express.Router()
const typesenseService = require('../services/typesense')
const { authenticate } = require('../middleware/auth')

router.post('/search', authenticate, async (req, res) => {
  try {
    const { request, filterQuery, commonSearchParams, workspace_id } = req.body

    // Validate workspace access
    if (!req.user.accessibleWorkspaces.includes(workspace_id)) {
      return res.status(403).json({ error: 'Access denied' })
    }

    // Add workspace filter to all collections
    const workspaceFilter = `workspace_id:=${workspace_id}`
    const enhancedFilterQuery = {}
    
    Object.keys(filterQuery).forEach(collection => {
      const existingFilter = filterQuery[collection]
      enhancedFilterQuery[collection] = existingFilter
        ? `(${workspaceFilter}) && (${existingFilter})`
        : workspaceFilter
    })

    const searchRequest = {
      q: request.q || '*',
      collections: request.collections || ['digital_assets', 'digital_assets_categories', 'dam_collections'],
      filterQuery: enhancedFilterQuery,
      commonSearchParams: {
        query_by: commonSearchParams?.query_by || 'search_name',
        per_page: commonSearchParams?.per_page || 20,
        page: commonSearchParams?.page || 1,
        sort_by: request.sort_by || 'modified_at:desc'
      },
      workspace_id
    }

    const results = await typesenseService.search(searchRequest)

    res.json({ results })
  } catch (error) {
    console.error('Search error:', error)
    res.status(500).json({ error: 'Search failed', message: error.message })
  }
})

module.exports = router

Frontend Implementation

Typesense Client Setup

javascript
// plugins/typesense.js
import Typesense from 'typesense'

export default ({ app, $axios }, inject) => {
  const typesenseClient = new Typesense.Client({
    nodes: [
      {
        host: process.env.TYPESENSE_HOST,
        port: process.env.TYPESENSE_PORT,
        protocol: process.env.TYPESENSE_PROTOCOL
      }
    ],
    apiKey: process.env.TYPESENSE_API_KEY,
    connectionTimeoutSeconds: 2
  })

  inject('typesense', typesenseClient)
}

Search Service

javascript
// utils/searchService.js
export class SearchService {
  constructor(axios, workspaceId) {
    this.axios = axios
    this.workspaceId = workspaceId
  }

  async search(query, options = {}) {
    const {
      collections = ['digital_assets', 'digital_assets_categories', 'dam_collections'],
      filters = {},
      sortBy = 'modified_at:desc',
      perPage = 20,
      page = 1
    } = options

    // Build filter query
    const filterQuery = {}
    collections.forEach(collection => {
      const collectionFilters = []
      
      // Visibility filter
      if (filters.visibility !== undefined) {
        collectionFilters.push(`visibility:=${filters.visibility}`)
      }
      
      // File type filter
      if (filters.fileTypes && collection === 'digital_assets') {
        const typeFilter = filters.fileTypes
          .map(type => `file_type:=${type}`)
          .join(' || ')
        collectionFilters.push(`(${typeFilter})`)
      }
      
      // Tags filter
      if (filters.tags && collection === 'digital_assets') {
        const tagFilter = filters.tags
          .map(tag => `tags:=[${tag}]`)
          .join(' && ')
        collectionFilters.push(`(${tagFilter})`)
      }
      
      // Date filter
      if (filters.dateFrom) {
        const timestamp = Math.floor(new Date(filters.dateFrom).getTime() / 1000)
        collectionFilters.push(`modified_at:>=${timestamp}`)
      }
      
      if (filters.dateTo) {
        const timestamp = Math.floor(new Date(filters.dateTo).getTime() / 1000)
        collectionFilters.push(`modified_at:<=${timestamp}`)
      }
      
      // Custom field filters
      if (filters.customFields) {
        Object.entries(filters.customFields).forEach(([key, value]) => {
          collectionFilters.push(`custom_fields.${key}:=${value}`)
        })
      }

      filterQuery[collection] = collectionFilters.length > 0
        ? `(${collectionFilters.join(' && ')})`
        : ''
    })

    const response = await this.axios.post(
      `/api/workspaces/${this.workspaceId}/typesense/search`,
      {
        request: {
          q: query || '*',
          collections,
          sort_by: sortBy
        },
        filterQuery,
        commonSearchParams: {
          query_by: 'search_name,description',
          per_page: perPage,
          page
        },
        workspace_id: this.workspaceId
      }
    )

    return this.transformResults(response.data.results)
  }

  transformResults(results) {
    const transformed = {
      assets: [],
      folders: [],
      collages: [],
      total: 0
    }

    results.forEach(result => {
      if (result.collection === 'digital_assets') {
        transformed.assets = result.hits.map(hit => ({
          ...hit.document,
          relevance_score: hit.text_match,
          highlights: hit.highlights
        }))
        transformed.total += result.found
      } else if (result.collection === 'digital_assets_categories') {
        transformed.folders = result.hits.map(hit => ({
          ...hit.document,
          relevance_score: hit.text_match
        }))
        transformed.total += result.found
      } else if (result.collection === 'dam_collections') {
        transformed.collages = result.hits.map(hit => ({
          ...hit.document,
          relevance_score: hit.text_match
        }))
        transformed.total += result.found
      }
    })

    // Sort by relevance score
    transformed.assets.sort((a, b) => b.relevance_score - a.relevance_score)
    transformed.folders.sort((a, b) => b.relevance_score - a.relevance_score)
    transformed.collages.sort((a, b) => b.relevance_score - a.relevance_score)

    return transformed
  }
}

Indexing Strategy

Real-time Indexing

Assets, folders, and collages are indexed immediately when created or updated:

javascript
// After asset upload
async function afterAssetUpload(asset) {
  // 1. Save asset to database
  const savedAsset = await db.assets.create(asset)
  
  // 2. Index in Typesense (async, non-blocking)
  typesenseService.indexAsset(savedAsset).catch(err => {
    console.error('Indexing error:', err)
    // Queue for retry
  })
  
  // 3. Generate thumbnail (async)
  generateThumbnail(savedAsset)
  
  return savedAsset
}

Batch Re-indexing

For bulk operations or initial setup:

javascript
// scripts/reindex.js
async function reindexAll(workspaceId) {
  const batchSize = 100
  
  // Re-index assets
  let offset = 0
  while (true) {
    const assets = await db.assets.findAll({
      where: { workspace_id: workspaceId },
      limit: batchSize,
      offset
    })
    
    if (assets.length === 0) break
    
    await Promise.all(
      assets.map(asset => typesenseService.indexAsset(asset))
    )
    
    offset += batchSize
    console.log(`Indexed ${offset} assets`)
  }
  
  // Similar for folders and collages
}

API Design

Endpoint: POST /api/workspaces/:workspace_id/typesense/search

Request Body:

json
{
  "request": {
    "q": "summer campaign",
    "collections": ["digital_assets", "digital_assets_categories", "dam_collections"],
    "sort_by": "modified_at:desc"
  },
  "filterQuery": {
    "digital_assets": "(file_type:=jpg || file_type:=png) && (tags:=[marketing])",
    "digital_assets_categories": "(visibility:=0)",
    "dam_collections": "(visibility:=0)"
  },
  "commonSearchParams": {
    "query_by": "search_name,description",
    "per_page": 20,
    "page": 1,
    "facet_by": "file_type,tags",
    "max_facet_values": 10
  },
  "workspace_id": "123456"
}

Response:

json
{
  "results": [
    {
      "collection": "digital_assets",
      "found": 45,
      "page": 1,
      "search_time_ms": 12,
      "hits": [
        {
          "document": {
            "id": "123",
            "display_file_name": "summer-campaign-banner.jpg",
            "file_type": "jpg",
            "tags": ["marketing", "summer"],
            "modified_at": 1739800483
          },
          "text_match": 95,
          "highlights": [
            {
              "field": "display_file_name",
              "snippet": "<mark>summer</mark>-<mark>campaign</mark>-banner.jpg"
            }
          ]
        }
      ],
      "facet_counts": [
        {
          "field_name": "file_type",
          "counts": [
            { "value": "jpg", "count": 25 },
            { "value": "png", "count": 20 }
          ]
        }
      ]
    }
  ]
}

Workflow

Search Execution Flow

1. User enters search query

2. Frontend builds search request with filters

3. Request sent to backend search API

4. Backend validates workspace access

5. Backend adds workspace filter to all collections

6. Typesense multi-search executed

7. Results returned from Typesense

8. Backend transforms and merges results

9. Results sorted by relevance (text_match score)

10. Response sent to frontend

11. Frontend displays results in tabs (All/Assets/Folders/Collages)

Indexing Flow

1. Asset/Folder/Collage created or updated

2. Database record saved

3. Indexing service triggered (async)

4. Document formatted for Typesense

5. Document upserted to Typesense collection

6. Typesense indexes document

7. Document immediately searchable

Sample Data

Typesense Document Examples

Asset Document

javascript
{
  id: "123",
  workspace_id: 456,
  display_file_name: "summer-campaign-banner.jpg",
  search_name: "summer-campaign-banner.jpg",
  description: "Main banner for summer marketing campaign",
  file_type: "jpg",
  file_size: 2456789,
  width: 1920,
  height: 1080,
  orientation: "horizontal",
  tags: ["marketing", "summer", "campaign", "banner"],
  category_id: 789,
  uploaded_by: 101,
  uploaded_by_name: "John Doe",
  visibility: 0,
  created_at: 1739800483,
  modified_at: 1739800483,
  custom_fields: {
    project_name: "Summer Campaign 2024",
    client_name: "Acme Corp",
    campaign_date: "2024-06-01"
  },
  s3_key: "456/digital_assets/123.jpg",
  thumbnail_url: "https://s3.amazonaws.com/bucket/456/thumbnails/123.jpg"
}

Folder Document

javascript
{
  id: "789",
  workspace_id: 456,
  name: "Marketing Assets",
  search_name: "marketing assets",
  description: "All marketing-related assets",
  parent_id: null,
  total_assets: 125,
  total_subfolders: 3,
  created_by: 101,
  created_by_name: "John Doe",
  visibility: 0,
  created_at: 1739800000,
  modified_at: 1739800483
}

Collage Document

javascript
{
  id: "321",
  workspace_id: 456,
  name: "Summer Campaign Collage",
  search_name: "summer campaign collage",
  description: "Collection of assets for summer campaign",
  total_assets: 15,
  created_by: 101,
  created_by_name: "John Doe",
  visibility: 0,
  created_at: 1739800200,
  modified_at: 1739800400
}