2.8 KiB
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_idsissues—{id, project_id, project_issue_id, title, description, status, priority, labels[], assignee, epic_title}— filterable:project_idepics—{id, project_id, title, description, status}— filterable:project_idcomments—{id, project_id, issue_id, issue_num, issue_title, body, author}— filterable:project_idwiki_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 immediatelyproject.member_added/removed→ upsert the project doc (member_ids changed)
search_syncjob 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+ CLIsolopm 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
projectsindex filteredmember_ids CONTAINS user; other indexes filteredproject_id IN (memberships)(narrowed toproject_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 /searchpage: same query with type filter tabs and full result list