Files

2.8 KiB

10 — Full-text Search (Meilisearch)

Meilisearch holds five indexes kept in sync by domain events (search_sync jobs). Postgres remains the source of truth; the index is always rebuildable.

Indexes & documents

  • projects{id, title, description, status, member_ids[]} — filterable: member_ids
  • issues{id, project_id, project_issue_id, title, description, status, priority, labels[], assignee, epic_title} — filterable: project_id
  • epics{id, project_id, title, description, status} — filterable: project_id
  • comments{id, project_id, issue_id, issue_num, issue_title, body, author} — filterable: project_id
  • wiki_pages{id, project_id, slug, title, body} — filterable: project_id
  • Searchable attributes ordered (title > description/body > rest); typo tolerance default; doc ids = Postgres ids
  • Index settings applied idempotently at startup (create-if-missing, update settings)

Sync

  • Event → sync mapping in the dispatcher's search consumer:
    • *.created / *.updated (incl. label/status/assignee changes on issues) → {index, doc_id, op: upsert} job
    • *.deleted (soft delete!) → {op: delete} job — soft-deleted content must leave the index immediately
    • project.member_added/removed → upsert the project doc (member_ids changed)
  • search_sync job handler loads current row from Postgres (not the event payload — always index latest state); row gone or soft-deleted → delete op
  • Jobs idempotent (upsert/delete by id); retries via queue backoff
  • POST /admin/search/reindex + CLI solopm reindex: drop-and-rebuild all five indexes from Postgres, streaming in batches of 1000

Query path

  • GET /api/v1/search?q=&types=&project_id=:
    • Resolve caller's project memberships once
    • projects index filtered member_ids CONTAINS user; other indexes filtered project_id IN (memberships) (narrowed to project_id= param when present)
    • Multi-index federated query; merge by Meilisearch ranking score; cap 50 results
    • Response items carry enough to render + navigate: type, project, title/snippet (highlighted), route params (issue_num, slug…)
  • Permission guarantee: a user must never see results (even titles) from projects they're not a member of — enforced by the filter, verified by an integration test
  • Meilisearch down/unconfigured → 503 with {"error":{"code":"search_unavailable"}}; rest of the app unaffected

Frontend

  • Command palette (Cmd/Ctrl+K, 13-frontend.md): debounced search-as-you-type against /search, grouped by type, arrow-key navigation, Enter → route; recent visits shown when query empty
  • /search page: same query with type filter tabs and full result list