Search
One search finds anything in a business. The app’s search box, GET /search
and the search tool over MCP are the same search.
Asking
Section titled “Asking”GET /search?q=kingston&limit=5Authorization: Bearer btl_…| Parameter | What it is |
|---|---|
q |
What to look for. Two characters at least, a hundred at most. |
kinds |
Optional. Only these, comma separated, from the list below. |
limit |
Optional. The most results of each kind, 1 to 20. 5 if left out. |
The answer is a single list, best first across every kind:
{ "items": [ { "kind": "customer", "id": "0192…", "title": "Kingston Fish Bar", "detail": "KT1 1QN", "status": null, "date": null, "archived": false, "path": "/customers/0192…", "score": 0.9 } ]}path is the page in the app that shows the record. score runs from 0 to 1
and compares across kinds, so the best customer and the best invoice can be
put in order against each other.
Kinds and scopes
Section titled “Kinds and scopes”The route needs tenant:read. Each kind then needs the scope that reads it
anywhere else. A kind the caller cannot read is left out of the answer, not
refused, so the same call works for an owner, a driver and a narrow key.
| Kind | Matched on | Needs |
|---|---|---|
customer |
Trading and legal name, email, phone | customers:read |
address |
Name, street, town, postcode with and without the space | customers:read |
order |
Tracking reference, the shop’s reference, a one-off’s name and address | orders:read |
invoice |
Number as printed, number alone, who it is billed to | invoices:read |
product |
Name, short code, SKU | products:read |
person |
Name, email | members:read |
vehicle |
Name, registration with and without the space | rounds:write |
base |
Name, street, town, postcode | rounds:write |
Vehicles and bases need rounds:write because rounds:read alone is a
driver reading their own round, and the fleet is the office’s.
How it matches
Section titled “How it matches”What was typed is split into words on spaces and hyphens. Every word must
appear somewhere in the record’s text, in any order and any case. Splitting on
hyphens is what lets 7K3M-9QWX find a reference stored as 7K3M9QWX, and
INV-BTL00042 find invoice 42.
A word of four letters or more also matches when it is close to a word in the record (trigram word similarity), which forgives a slip of the finger. Shorter words must match as typed.
A phone number is reduced to the same digits Bottle keeps a customer’s phone
as, so +44 20 8546 1234 and 020 8546 1234 find the same customer. A number
on its own is also tried as an invoice number.
Ranking, the same for every kind: the name exactly, then the name starting with what was typed, then a word in the name starting with it, then it appearing anywhere, then it only looking like it. Archived records drop by a quarter.
How it is built
Section titled “How it is built”Search runs in Postgres, not a separate search engine. Every kind with more
than a handful of rows per business has a GIN trigram index (pg_trgm) over
the text it is matched on, with the business first in the index (btree_gin).
A search reads one business’s rows and never scans another’s, however many
businesses share the database.
Each kind is its own small query, run side by side, so one kind never holds up the rest. The search is always up to date: there is no second copy to keep in step, and nothing to rebuild.
People, vehicles and bases have no index of their own. A business has tens of them, and reading its own is already quick.
Both extensions ship with Postgres, and with Amazon RDS and Aurora, so the
search moves with the database. If a single business ever outgrows it, the
search sits behind one repository in the API
(apps/api/src/repositories/search.ts), and moving it to a dedicated engine
changes that class and nothing that calls it.
The index expressions in migration 0089_search.sql and the expressions in
the repository must stay the same. If they drift, Postgres stops using the
index and the search still works, only slower.