🔍

SPEC-STV-09-Search

📜

SPEC-STV-09 · Spec header. Spec ID: SPEC-STV-09 · Title: Search · Version: 1.0.0 · Status: Planned · Authority: Specification · Priority: P1 · Owner role: Backend architect · Reviewers: Frontend lead, DevOps architect · Last reviewed: 2026-05-11 · Sync targets: app/Services/Search/**, Meilisearch config · Depends on: SPEC-STV-HUB, SPEC-STV-02, SPEC-STV-05 · Consumed by: SPEC-STV-03, SPEC-STV-10 · Conflict rule: Hub wins. · Change policy: Backend architect + Frontend lead; Registry bump on driver change.

1 · Driver

  • v1 default: Meilisearch via Laravel Scout. Self-hosted or managed.
  • Fallback: MySQL FULLTEXT index for environments where Meilisearch is unavailable; controlled by search.driver.

2 · Indexes

IndexIndexed entitySearchable attrsFilterable attrsRanking
stv_pagespagestitle, plain_text (denormalized concat of block text)workspace_id, status, visibility, parent_page_id, archived_at, author_idtypo → words → proximity → attribute (title boosted) → exactness
stv_blockspage_blocksplain_textworkspace_id, page_id, typesame; capped at 200 KiB per doc
stv_commentscommentsbodyworkspace_id, page_id, author_id, resolved_atrecency boost
stv_filesfilesoriginal_name, extracted_text (when available)workspace_id, mime, uploader_idname boosted
stv_databasesdocumentation_databasesnameworkspace_id, page_id
stv_db_rowsdatabase_rowsdenormalized concat of all value_text cellsworkspace_id, database_id, archived_atrecency
stv_templatestemplatesname, descriptionworkspace_id, category

3 · Indexing pipeline

  • Eloquent Searchable trait wires Scout.
  • toSearchableArray() populates the denormalized text fields (e.g. for Page: a plain_text field built by concatenating block text via BlockService::renderText($block)).
  • Indexing is queued (Scout's queued mode); listeners on page.updated, block.updated, comment.created, database_row.cells.updated enqueue updates.
  • Bulk reindex: php artisan scout:import per model.

4 · Permission filter at query time

Every search call resolves the caller's accessible page set:

  1. Build a Redis set search:user:{uid}:pages (TTL 60 s) from PermissionsResolver.
  1. Send query to Meilisearch with filter = workspace_id = X AND page_id IN […] (Meilisearch supports IN with up to thousands; use pages_visible_to_user precomputed for fast filter).
  1. For workspaces with > 50k pages, fall back to post-filter on returned IDs (with adaptive page-size).

No result the caller cannot read is ever surfaced.

5 · Cmd / Ctrl + K palette

The global command palette opens with ⌘/Ctrl + K. Three sections:

  • Quick actions — new page, new database, invite member, jump to settings.
  • Recent — last 10 visited pages.
  • Results — federated across the indexes, grouped (Pages, Blocks, Files, Databases, Comments, Templates).

Keyboard nav: arrow keys, Tab to switch group, Enter to open, Cmd/Ctrl + Enter to open in new tab. Mobile: a full-screen sheet with the same structure.

6 · Filters

  • type:page|block|file|database|comment|template
  • in: (page UUID) limits to a subtree
  • author:@me or @user
  • is:archived|favorite
  • after:YYYY-MM-DD, before:YYYY-MM-DD

Parsed by SearchQueryParser; unparsed terms become free text.

7 · API

See SPEC-STV-03 §8. Response shape per group: { group, hits: [{ uuid_or_id, title_or_excerpt, snippet, page_uuid? }], total }.

8 · Latency targets

p95 < 250 ms when index sizes < 1M documents per workspace. Federated query timeout 800 ms; partial results surfaced with a partial: true flag.

9 · Search-inside-page

A separate Cmd/Ctrl + F-style overlay that searches only the open page using client-side string match against the loaded blocks. No API call.