Search API
Search everything the caller can see: pages, blocks, databases, records,
comments, files, users — full-text with prefix and fuzzy matching, type and
team scoping, date filters, cursor pagination. The index (OpenSearch, single
factory-content index) is kept in sync by the search-indexer worker
consuming domain events, with a periodic reconciliation pass; index lag is
typically < 2 s after a write.
Shared conventions (auth, error model, pagination, rate limits): authentication.md.
Permission filtering guarantee: every result is permission-filtered at
query time — the query is scoped to the caller's workspace memberships and
per-document grant snapshots, so a document you do not have VIEW on can
never appear in results, titles, or snippets (not even as null-title
entries). Fail-closed scope (TASK-0097): an explicit workspaceId the
caller is not a member of is a 404 NOT_FOUND (no existence leak); a caller
with zero visible workspaces gets an empty result set without any query.
Every route requires authentication (JWT). All routes live under the global
/api/v1 prefix (shown in full below).
1. Search Everything
POST /api/v1/search
Full-text search over the caller's visible documents. The response is a
live envelope { data, nextCursor, tookMs } — data is the array of
SearchResult objects (below). This is the endpoint the global search page
(⌘K and /search) calls. Every successful search is also auto-recorded into
the caller's recent searches (best-effort; a search never fails on Redis).
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
q |
string (1–200) | yes | Free text. Matching is full-text (BM25) + prefix + fuzzy. q (not query) is the live field name the web client sends — recorded contract choice. Empty → 400 VALIDATION_ERROR. |
types |
array | no | Subset of ["page", "block", "database", "record", "comment", "file", "user"] (7 values), at most 7. Omit = all types. Unknown value → 400 VALIDATION_ERROR. |
workspaceId |
string | no | Restrict to one workspace (default: every workspace the caller belongs to). Must be one the caller is a member of → else 404 NOT_FOUND. |
teamId |
string | no | Restrict to docs owned by a team. |
createdBy |
string | no | Author filter (user id). |
updatedAfter |
ISO-8601 | no | On the document's updatedAt (inclusive). |
updatedBefore |
ISO-8601 | no | On the document's updatedAt (inclusive). |
cursor |
string | no | Opaque token from a previous nextCursor (search-after). |
limit |
int (1–50) | no | Default 20. > 50 → 400 VALIDATION_ERROR. |
{
"q": "atlas migration",
"types": ["page", "record", "database"],
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"teamId": "tm_01J9ZKP8G5I7J9L1M3N5P7Q9R1",
"createdBy": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"updatedAfter": "2025-01-01T00:00:00.000Z",
"updatedBefore": "2025-01-31T00:00:00.000Z",
"cursor": null,
"limit": 20
}
200 OK
{
"data": [
{
"docId": "page:pg_01J9ZKR4A5B7C9D1E3F5A7B9C1",
"type": "page",
"id": "pg_01J9ZKR4A5B7C9D1E3F5A7B9C1",
"title": "Atlas migration",
"snippet": "The Atlas storage migration plan: phase 1 moves read replicas, phase 2 …",
"url": "/w/analytical-engines/pages/pg_01J9ZKR4A5B7C9D1E3F5A7B9C1",
"updatedAt": "2025-01-02T16:00:00.000Z",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"score": 12.42,
"source": "bm25"
},
{
"docId": "record:rpt_01J9ZKR3F4A6B8C0D2E4F6A8B0",
"type": "record",
"id": "rpt_01J9ZKR3F4A6B8C0D2E4F6A8B0",
"title": "Atlas migration",
"snippet": "Status: In Progress · Owner: Augusta Ada · Due: 2025-04-30",
"url": "/w/analytical-engines/records/rpt_01J9ZKR3F4A6B8C0D2E4F6A8B0",
"updatedAt": "2025-01-02T16:00:00.000Z",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"score": 9.87,
"source": "bm25"
}
],
"nextCursor": "ZjBhODVkN2ZiMzc2NjI0NjE5NzA3MzE3YzY2YTY3ZDY4",
"tookMs": 84
}
SearchResult fields (the same object GET /search/query returns, bare):
| Field | Type | Notes |
|---|---|---|
docId |
string | <type>:<id> — the stable fusion/citation key. |
type |
enum | One of the 7 public kinds (page, block, database, record, comment, file, user). Chunk docs surface as their parent's kind. |
id |
string | The source row id (plain ref). |
title |
string | |
snippet |
string | Relevance-centered excerpt (body highlight, ≤ 160 chars) with <em> marks. |
url |
string | Client-side deep-link route (e.g. /w/<ws>/pages/<id>). |
updatedAt |
ISO-8601 | |
workspaceId |
string | |
score |
number | Relevance score (higher = better); results sorted score DESC, ties by updatedAt DESC. |
source |
string | Retrieval path: "bm25" / "vec" / "bm25+vec". |
icon / createdByName |
string | Present when the doc has them. |
pageId / blockId / databaseId / recordId |
string | Deep-link metadata, present per type. |
nextCursorisnullon the last page (fewer hits thanlimit); feed it back ascursorto page. A search over an empty index returnsdata: []— not an error.- Fuzzy behavior: a query
atls migrationmatchesatlas migration(edit distance ≤ 2 for terms ≥ 4 chars). Emptyq→400 VALIDATION_ERROR.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Empty q, bad types, out-of-range limit (1–50), bad cursor/dates. |
| 401 | UNAUTHORIZED |
Missing/invalid credentials. |
| 404 | NOT_FOUND |
workspaceId is not a membership of the caller (no existence leak). |
| 503 | SEARCH_UNAVAILABLE |
OpenSearch down. Generic message; clients should retry with backoff. Reads are never served from Postgres as a fallback (stale data is worse). |
Events emitted: none (read).
2. Command Palette
GET /api/v1/search/query
The ⌘K command palette's query endpoint — the same engine as
POST /search, but it returns a bare array of SearchResult (no
envelope). The web palette types it Promise<SearchResult[]> and does
searchApi.query(...).then(setResults) — a record shape would break it, so
envelope-vs-array is the recorded S3 decision.
Query params:
| Param | Type | Required | Notes |
|---|---|---|---|
q |
string (1–200) | yes | Free text. |
workspaceId |
string | no | Restrict to one workspace (non-member → 404 NOT_FOUND). |
types |
csv | no | Comma-joined type list, e.g. types=page,database (the ⌘K dialog sends one param). Same 7-value enum as POST /search; unknown value → 400 VALIDATION_ERROR. |
limit |
int (1–50) | no | Default 20. |
cursor |
string | no | From a previous response's last-page marker. |
200 OK — BARE array of SearchResult (same object shape as above):
[
{ "docId": "page:pg_…", "type": "page", "id": "pg_…", "title": "Atlas migration", "snippet": "…", "url": "/w/analytical-engines/pages/pg_…", "updatedAt": "2025-01-02T16:00:00.000Z", "workspaceId": "wks_…", "score": 12.42, "source": "bm25" }
]
Errors: 400 VALIDATION_ERROR, 401 UNAUTHORIZED, 404 NOT_FOUND,
503 SEARCH_UNAVAILABLE. Events emitted: none.
3. Suggestions
GET /api/v1/search/suggest
Prefix autocomplete for the search box: top matching docs by prefix on
title (no full-text ranking), ordered by prefix rank. Returns a bare array
of minimal { id, type, title } objects — the suggest UI deep-links by the
source row id, not the docId.
Query params:
| Param | Type | Required | Notes |
|---|---|---|---|
q |
string (1–200) | yes | Prefix text. |
limit |
int (1–20) | no | Default 8. Server cap 20. |
200 OK — BARE array of { id, type, title }:
[
{ "id": "pg_01J9ZKR4A5B7C9D1E3F5A7B9C1", "type": "page", "title": "Atlas migration" },
{ "id": "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0", "type": "database", "title": "Atlas & Borealis backlog" }
]
Errors: 400 VALIDATION_ERROR (empty q, out-of-range limit),
401 UNAUTHORIZED, 503 SEARCH_UNAVAILABLE. Events emitted: none.
4. Recent Searches
Per-user history of the caller's recent queries — newest first, cap 20,
deduped (Redis list, key search:recent:{userId}). POST /search records
automatically; PUT /search/recent upserts manually. The list is a bare
string[] (the query text only).
GET /api/v1/search/recent
200 OK
{ "data": ["atlas migration", "board view"] }
PUT /api/v1/search/recent
Record / upsert one query (dedupe, cap 20, newest first) and return the updated list. Rate-limited to 30 writes/min per user.
Request body: { "q": "atlas migration" } (q required, 1–200).
200 OK
{ "data": ["atlas migration", "board view"] }
Errors: 400 VALIDATION_ERROR (empty q), 429 RATE_LIMITED
(> 30/min), 401 UNAUTHORIZED.
DELETE /api/v1/search/recent
Clear the caller's recent searches.
200 OK
{ "cleared": true }
Events emitted: none (history is not a domain event).
5. curl Examples
# Search pages and records
curl -s -X POST https://factory.example.com/api/v1/search \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{ "q": "atlas migration", "types": ["page", "record", "database"], "limit": 20 }'
# Scoped + date-filtered
curl -s -X POST https://factory.example.com/api/v1/search \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{ "q": "ga date", "workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", "types": ["comment"], "updatedAfter": "2025-01-01T00:00:00.000Z" }'
# Command palette (bare array)
curl -s "https://factory.example.com/api/v1/search/query?q=atlas&types=page,database" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Prefix suggestions (bare array of {id,type,title})
curl -s "https://factory.example.com/api/v1/search/suggest?q=atlas" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Recent searches
curl -s "https://factory.example.com/api/v1/search/recent" \
-H "Authorization: Bearer $ACCESS_TOKEN"
curl -s -X PUT "https://factory.example.com/api/v1/search/recent" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{ "q": "board view" }'
curl -s -X DELETE "https://factory.example.com/api/v1/search/recent" \
-H "Authorization: Bearer $ACCESS_TOKEN"
6. Consistency & Performance Notes
- Index lag: writes propagate through domain events in < 2 s p99; a search immediately after a write may not include it (the reconciliation sweep catches stragglers).
- Trashed/deleted: trashed/archived docs are excluded from results by
default (an explicit
archived: trueopts in); purged content is removed from the index within the same sweep. - Latency budget: < 500 ms p95 end-to-end (Product Spec §26 target);
tookMsin thePOST /searchenvelope is the query duration. - Large results:
limitis capped at 50; to walk an entire workspace use thenextCursor/cursorsearch-after pagination rather than a larger page. - Fail-closed: OpenSearch down →
503 SEARCH_UNAVAILABLE(never a Postgres-ILIKE fallback — stale data is worse); a non-memberworkspaceId→404 NOT_FOUND(no existence leak).