# 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](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