39 lines
2.8 KiB
Markdown
39 lines
2.8 KiB
Markdown
# 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
|