# Findlane > Hosted product search for online stores: typo-tolerant search, facets and sorting, from one API and two small SDKs. Servers upload records, change index settings, and search with `@findlane/server` and a secret workspace API key. Browsers search an index with `@findlane/browser` and the index's public ID, from origins the index allows. Other languages call the HTTP API. Each page below is Markdown. --- # Overview > Findlane is hosted product search for online stores. Upload your catalog from your server, then search it from your storefront with typo tolerance, facets, filters, and sorting. ## How it works 1. **Upload your catalog.** Your server sends product records to an index with a secret API key, using [`@findlane/server`](/docs/server-sdk.md) or the [HTTP API](/docs/api.md). Writes are searchable as soon as they return. 2. **Choose how it searches.** Pick the fields to search and how much each counts, and the fields shoppers can filter, facet, and sort by. You can change them at any time, and the index keeps serving searches while a change applies. 3. **Search from the storefront.** The browser calls the index with its public ID, using [`@findlane/browser`](/docs/browser-sdk.md). Only origins you allow can use it. ## Concepts | Term | What it is | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Workspace** | Your team's account: its members, API keys, indexes, and plan. | | **Index** | A searchable collection of records, such as a store's products, with its own settings. A workspace can have several, for example one per store or language. | | **Record** | A JSON object with a string `objectID`: one product, with the fields you want to search, filter, show, or sort by. | | **API key** | A secret for your server. It has the `search` scope, the `write` scope, or both, and can be limited to one index. | | **Public ID** | An index's ID for browser search (`pub_…`). It is public by design: it can only search, only its own index, and only from allowed origins. | ## Where to start - [Quick start](/docs/quick-start.md): upload a catalog and search it from a page, in about ten minutes. - [Records](/docs/records.md): what a record looks like, and keeping records in sync with your store. - [Search settings](/docs/settings.md): searchable fields, facets, filters, sorts, ranking, and synonyms. - [Searching](/docs/search.md): queries, filters, facets, paging, and highlighting. - [Security](/docs/security.md): API keys, public IDs, and what browser search exposes. ## Packages | Package | Runs in | Authenticates with | | ---------------------------------------- | ----------------------------------------- | -------------------- | | [`@findlane/server`](/docs/server-sdk.md) | Node.js 18+, Bun, Deno, and edge runtimes | A workspace API key | | [`@findlane/browser`](/docs/browser-sdk.md) | Browsers | An index's public ID | Both are small, typed, and have no dependencies. From other languages, call the [HTTP API](/docs/api.md) directly. ## Use with AI assistants Every page is also plain Markdown: add `.md` to its address, as in [/docs/quick-start.md](/docs/quick-start.md), or use **Copy page** at the top. For coding assistants and other LLM tools: - [/llms.txt](/llms.txt) lists every page with a one-line summary, following the [llms.txt convention](https://llmstxt.org). - [/llms-full.txt](/llms-full.txt) has all the docs in one file, to attach or paste as context. --- # Quick start > Create an index, upload products from your server, choose what to search, and search from a web page. You need a Findlane account ([start free](https://app.findlane.dev/dashboard)), and Node.js 18 or later, Bun, or Deno on your server. ## 1. Create an index In the dashboard, choose **New index** and name it, for example `products`. Names use lowercase letters, digits, `_`, and `-`, and start with a letter. A new index searches `title` and `brand.name` and counts values of `brand.name` as a facet, so a catalog with those fields searches well before you change anything. ## 2. Create an API key Open **API keys**, choose **Create key**, and give it the `search` and `write` scopes. Put the key and the **Workspace ID** shown on that page in your server's environment: ```sh FINDLANE_WORKSPACE_ID=your-workspace-id FINDLANE_API_KEY=tsk_your_api_key ``` The key is shown once. Keep it on your server: it can change your catalog. ## 3. Upload products ```sh npm install @findlane/server ``` ```ts import { SearchServerClient } from "@findlane/server" const client = new SearchServerClient({ workspaceId: process.env.FINDLANE_WORKSPACE_ID!, apiKey: process.env.FINDLANE_API_KEY!, }) type Product = { objectID: string title: string brand: { name: string } price: number inStock: boolean } const products = client.index("products") await products.upsert([ { objectID: "sku-1", title: "Trail running shoe", brand: { name: "Acme" }, price: 129, inStock: true, }, { objectID: "sku-2", title: "Waterproof rain jacket", brand: { name: "Northwind" }, price: 189, inStock: false, }, ]) ``` `upsert` adds or replaces up to 1,000 records a call, matched by `objectID`, and they are searchable when it returns. To load or refresh a whole catalog, use [`replaceAllStaged`](/docs/records.md#replace-the-whole-catalog). You can also upload a JSON file on the index's **Records** page. ## 4. Choose what to search and filter Settings refer to fields your records have, so save them after uploading. Fetch the current settings, change them, and save them whole: ```ts const settings = await products.getSettings() await products.replaceSettings({ ...settings, searchableAttributes: [ { field: "title", weight: 8 }, { field: "brand.name", weight: 4 }, ], facetFields: ["brand.name", "inStock"], sorts: [ { id: "price-asc", label: "Price: low to high", field: "price", direction: "asc", thenBy: [] }, ], }) const results = await products.search({ q: "trail sho", facets: ["brand.name"] }) console.log( results.total, results.hits.map((hit) => hit.record.title) ) ``` The index's **Search settings** page does the same, and its **Playground** tries searches. [Search settings](/docs/settings.md) covers every option. ## 5. Search from the browser On the index's **Public access** page, add the origins your storefront runs on under **Allowed origins**, such as `https://shop.example.com` and `http://localhost:5173`. Then copy the **Public ID**. ```sh npm install @findlane/browser ``` ```ts import { SearchBrowserClient } from "@findlane/browser" const client = new SearchBrowserClient({ publicId: "pub_your_public_id" }) const results = await client.search({ q: "trail sho", filters: { inStock: true }, facets: ["brand.name"], highlight: true, limit: 12, }) for (const hit of results.hits) { // Highlights are HTML-escaped, with the matched words in tags. console.log(hit.highlights?.title ?? hit.record.title, hit.record.price) } console.log(results.facets["brand.name"]) // { Acme: 1 } ``` For search as you type, cancel the previous request when the query changes: ```ts const input = document.querySelector("#search")! let pending: AbortController | undefined input.addEventListener("input", async () => { pending?.abort() pending = new AbortController() try { const { hits } = await client.search( { q: input.value, limit: 8, highlight: true }, { signal: pending.signal } ) renderHits(hits) } catch (error) { if ((error as Error).name !== "AbortError") throw error } }) ``` ## Next steps - Keep the index in step with your store: [Records](/docs/records.md#keep-records-in-sync). - Build filters, facets, sorting, and paging: [Searching](/docs/search.md). - Decide what browsers can read before you launch: [Security](/docs/security.md). --- # Records > What a record looks like, how its fields are typed, and the ways to add, replace, and delete records. ## What a record looks like A record is a JSON object with a string `objectID` that is unique in its index. The other fields are yours: include what you search, filter, facet, sort, and show in results. ```json { "objectID": "sku-1042", "title": "Trail running shoe", "description": "A light shoe with a grippy sole for wet trails.", "brand": { "name": "Acme" }, "categories": ["Shoes", "Running"], "price": 129, "inStock": true, "createdAt": 1788220800 } ``` - **Nested fields** are named by their path, such as `brand.name`. - **Lists** work where single values do: a list matches a filter when any of its values does, and each value counts in facets. You can't sort by a list. - **Types** come from your data: text, numbers, and booleans. Store dates as numbers, such as Unix seconds, to filter them by range and sort by them. - **Searchable fields** hold text or lists of text. - **Field names** you search, filter, facet, or sort by use letters, digits, and `_`, and don't start with a digit. Once a field is used as a filter, facet, or sort, new values must keep its type: a write with text in a number field is rejected with a message naming the field. An `objectID` has up to 256 characters, and a record is up to 1 MB of JSON with up to 256 fields. See [Limits](/docs/limits.md). ## Add and update records `upsert` writes up to 1,000 records a call. A record with a new `objectID` is added, and one with an existing ID is replaced whole, so send every field, not only the changed ones. ```ts const receipt = await products.upsert(records) // { acceptedRevision: 42, indexedRevision: 42, recordCount: 18230 } ``` Every write returns a receipt with the index's new revision and record count, and is searchable when it returns. To read a record back, with its version: ```ts const { version, record } = await products.getRecord("sku-1042") // record is null if there's no such record ``` ## Delete records ```ts await products.delete("sku-1042") ``` ## Replace the whole catalog A full sync should replace the catalog, so products removed from your store also leave search. `replaceAllStaged` uploads records in batches under one stage ID, then switches the index to them in one commit. Searches use the current records until the commit. It takes an array, or an async iterable to stream a catalog from your database without holding it in memory: ```ts import { StagedReplacementError } from "@findlane/server" async function* catalog() { for await (const row of db.products.stream()) yield toRecord(row) } try { const stageId = `sync-${Date.now()}` const receipt = await products.replaceAllStaged(catalog(), { stageId }) console.log(`Replaced the catalog: ${receipt.recordCount} records`) } catch (error) { if (error instanceof StagedReplacementError) { // Retry with the same stageId to continue, or abort the stage to start over. await products.abortReplacement(error.stageId) } throw error } ``` - Batches have 500 records by default (`batchSize`, up to 1,000) and at most 8 MiB (`maxBatchBytes`). - Retrying with the same `stageId` and records continues the stage: batches already uploaded aren't added twice, and a stage that already committed returns its receipt. - An index has one open stage at a time. Starting another fails with `409` until the open one is committed or aborted, or has been idle for an hour. > **Warning:** A replacement contains exactly the records you staged, so the commit fails with `409` if other > writes reached the index while it was staged. Pause incremental writes during a full sync, or run > the sync again with a new stage ID. For catalogs up to 100,000 records, `replaceAll(records)` does the same in one request. ## Keep records in sync Most stores combine two paths: - **Changes as they happen.** When a product is created, updated, or removed in your store, for example from a webhook or your admin's save handler, `upsert` or `delete` it. - **A regular full sync.** A nightly `replaceAllStaged` catches anything the incremental path missed, and removes products that no longer exist. ### Writes that must not overwrite newer data When several processes write the same records, `upsertOne` and `deleteOne` write only if the record is still at the version you read. A version of `0` means the record must not exist yet. ```ts import { FindlaneError } from "@findlane/server" const { version, record } = await products.getRecord("sku-1042") try { await products.upsertOne({ ...record!, price: 119 }, { expectedVersion: version }) } catch (error) { if (error instanceof FindlaneError && error.status === 409) { // Someone else changed the record: read it again and retry. } } ``` These calls retry on network errors with one idempotency key, so a retried write is applied once. Pass your own `idempotencyKey` (1 to 64 letters, digits, `_`, or `-`) to make retries across processes safe too. --- # Search settings > Choose the fields to search and their weights, the fields to filter, facet, and sort by, and how results rank. Each index has its own settings. Change them on the index's **Search settings** page, or from your server: ```ts const settings = await products.getSettings() const saved = await products.replaceSettings({ ...settings, facetFields: ["brand.name", "categories"], }) console.log(saved.applied) // "fields" ``` `replaceSettings` saves the full settings, so start from `getSettings()` and change what you need. Facet, filter, and sort fields must appear in at least one record first, because their types come from your data. A new index searches `title` (weight 8) and `brand.name` (weight 4), counts `brand.name` as a facet, and sorts by relevance. ## Settings ```ts { searchableAttributes: [ { field: "title", weight: 8 }, { field: "brand.name", weight: 4 }, { field: "description", weight: 1 }, ], facetFields: ["brand.name", "categories", "inStock"], filterFields: ["price"], sorts: [ { id: "relevance", label: "Relevance", field: "relevance", direction: "desc", thenBy: [] }, { id: "price-asc", label: "Price: low to high", field: "price", direction: "asc", thenBy: [] }, { id: "newest", label: "Newest", field: "createdAt", direction: "desc", thenBy: [] }, ], defaultSort: "relevance", customRanking: [{ field: "popularity", direction: "desc" }], synonyms: [["sneakers", "trainers", "running shoes"]], typoTolerance: true, normalizationProfile: "default-v1", } ``` ### Searchable fields `searchableAttributes` lists one to five text fields that queries match, each with a weight from 0.1 to 100 (1 by default). A match in a heavier field ranks higher, so give short, precise fields like titles, brands, and SKUs more weight than descriptions. An index has two short search fields, which hold up to 1 KB of text each, and three long ones, which hold up to 16 KB each. The first two fields of a new index use the short ones; fields added later use long ones while any are free. A record with too much text in a searchable field is rejected with a message naming the field. To find records by ID, add `objectID` as a searchable field. Its words match exactly, without typos, prefixes, or synonyms. ### Facets and filters `facetFields` are fields to count values for, such as brands and categories, which also makes them filterable. `filterFields` are fields to filter by without counting values, such as prices. Facets and filters work on text, numbers, and booleans, and on lists of them. ### Sorting `sorts` are the orders a search can ask for by `id`, besides `relevance`. Each sorts by one field that holds a single value (not a list), `asc` or `desc`; `thenBy` must be empty for now. `defaultSort` is the sort used when a search doesn't choose one. Sort IDs use lowercase letters, digits, `_`, and `-`, and an index has up to eight. ### Custom ranking `customRanking` orders results that are about equally relevant, within 10% of the best match's score, by up to three fields, such as popularity or stock. Relevance still comes first: a much better match is never pushed down by it. ### Synonyms `synonyms` are groups of words or phrases that match each other: searching for any of them finds the others, ranked slightly below the word itself. An index has up to 1,000 groups of 2 to 20 terms, each of up to four words. ### Typo tolerance With `typoTolerance` on (the default), query words also match: - as prefixes, for words of three or more letters, so `jack` finds `jacket`; - with one typo in words of four to six letters, and two in longer words; - with two neighboring letters swapped, so `hsoe` finds `shoe`. Exact matches rank above prefixes and typos, and numbers always match exactly. Turn it off to match whole words only. ### Text normalization Searches ignore case and accents, so `cafe` finds `Café`. `normalizationProfile` changes how text is folded: - `default-v1`: case and accents. - `icelandic-ascii-v1`: also matches `ð` as `d`, `þ` as `th`, and `æ` as `ae`, for shoppers typing without Icelandic letters. ## How changes apply Searches keep working while settings change. `applied` in the response says how the change took effect: | `applied` | Changes | When it takes effect | | ------------ | ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `instant` | Weights, synonyms, typo tolerance, sort labels and the default sort, and removing or reordering searchable fields | At once | | `fields` | Facet, filter, sort, and custom ranking fields | When the save returns: seconds for tens of thousands of records | | `searchable` | New searchable fields | In the background for large indexes | | `rebuild` | Text normalization | In the background for large indexes | | `none` | Nothing changed | | A `searchable` or `rebuild` change that takes longer than a few seconds continues in the background, with its progress in `rebuild` (also returned by `getSettings`). Searches use the previous settings until it finishes, and writes made meanwhile are included. Wait for it before depending on the new settings: ```ts const saved = await products.replaceSettings(next) if (!saved.rebuilt && saved.rebuild) await products.waitForSettings() ``` > **Note:** An index runs one settings change at a time, and a full catalog replacement can't start while one > runs. Plan large changes around your nightly sync. --- # Searching > Queries, filters, facets, sorting, paging, and highlighting, from the browser or your server. The browser and server SDKs take the same search request. The examples use the browser client: ```ts import { SearchBrowserClient } from "@findlane/browser" const client = new SearchBrowserClient({ publicId: "pub_your_public_id" }) const results = await client.search({ q: "rain jacket", filters: { "brand.name": { in: ["Northwind", "Acme"] }, price: { lte: 200 }, inStock: true }, facets: ["brand.name", "categories"], sort: "price-asc", limit: 24, highlight: true, }) ``` ## Queries `q` is up to 200 characters. Every word must match one of the searchable fields, with [typo tolerance](/docs/settings.md#typo-tolerance) and synonyms, and results rank by how well and where they match. An empty `q` returns every record that passes the filters, in the chosen sort. ## Filters `filters` keeps records whose fields match, for up to 20 [facet or filter fields](/docs/settings.md#facets-and-filters). All filters must match. | Filter | Keeps records where the field | | ------------------------------------------------- | -------------------------------------------------------------- | | `{ inStock: true }` | Is `true`. Text and numbers work the same way. | | `{ "brand.name": { in: ["Acme", "Northwind"] } }` | Is any of up to 100 values | | `{ price: { gte: 50, lte: 200 } }` | Is within a range; either bound can be left out. Numbers only. | A list field matches when any of its values does. ## Facets `facets` asks for value counts of up to eight [facet fields](/docs/settings.md#facets-and-filters): ```ts results.facets // { "brand.name": { Acme: 12, Northwind: 7 }, categories: { Jackets: 15, Shoes: 4 } } ``` To show counts for other values of a field that is filtered, as a list of checkboxes usually does, ask again without that field's filter. ## Sorting `sort` is a sort ID from the index's settings, or `relevance`. Without one, the index's default sort applies. `results.sort` says which sort was used. ## Paging and counts `limit` is the number of hits per request (20 by default; up to 100 from your server and 50 from browsers), and `offset` skips hits, up to 1,000. By default, `total` and facet counts cover the returned page when a query matches more, which keeps searches fast: - `hasMore` is `true` when there are more hits after this page. - `totalRelation` is `"gte"` when `total` is a lower bound. Set `countMode: "exact"` for the total number of matches, and `facetMode: "exact"` for facet counts across all of them. They cost more time on broad queries, so ask for them when you show them, such as on the first page of results. ## Highlighting `highlight: true` returns each hit's matched searchable fields with the matched words in `` tags, under `highlights`. The values are HTML-escaped, so they are safe to insert as HTML. List fields give one string per value. ```ts results.hits[0].highlights // { title: "Waterproof rain jacket" } ``` Choose other tags with `highlight: { preTag: "", postTag: "" }`. ## Fewer fields `attributesToRetrieve` returns only the listed fields of each record, such as `["title", "price", "image"]`, which makes responses smaller. It doesn't keep the other fields private; see [Security](/docs/security.md). ## Responses ```ts { hits: [{ id: "sku-2", score: 12.4, record: { objectID: "sku-2", title: "Waterproof rain jacket", ... } }], total: 1, totalRelation: "eq", hasMore: false, facets: { "brand.name": { Northwind: 1 } }, sort: "relevance", tookMs: 6, } ``` ## Search as you type - Cancel the previous request when the query changes: pass `{ signal }` to the browser client, or a signal as the server client's second argument. - Wait about 100 to 150 ms after a keystroke before searching. Every request counts toward your plan's monthly searches. - Keep the first request light: exact counts and many facets can wait for the results page. --- # Security > API keys, public IDs, allowed origins, and how to limit what browser search can read. ## API keys API keys are secrets for your servers. Create them on the **API keys** page, which only workspace owners can see: - The `write` scope changes records, settings, and browser search restrictions. - The `search` scope searches, for server-rendered pages and backends. - A key can be limited to one index. Give each service a key with only the scopes and index it needs, and keep keys in your server's environment, never in browser code or a repository. If a key leaks, delete it on the **API keys** page and create another. > **Note:** The API refuses API-key requests sent from web pages, so a key put in browser code by mistake > fails instead of quietly working. ## Public IDs and allowed origins Browser search uses an index's public ID instead of a key. It is visible in your page's source, so it can only search, only its own index, and only from the origins on the index's **Public access** page. - Add each origin exactly, with its scheme and any port: `https://shop.example.com`, `https://www.example.com`, `http://localhost:5173`. An index has up to 20. - Browser search is off until an owner saves at least one origin. - Each visitor (IP address) can make 240 searches a minute on an index, and an index takes up to 6,000 a minute in all. Over either limit, public search answers `429` with a `Retry-After` header. - If a public ID is misused, rotate it on the **Public access** page. Searches with the old ID stop within a minute, so put the new one in your storefront right away. > **Warning:** Origin checks stop other websites from using your index from their visitors' browsers. They don't > authenticate other callers: a script can send any `Origin` header. Treat everything browser search > can read as public. ## What browser search can read By default, browser search returns every field of the records you upload, and can search, filter, facet, and sort by everything the index's settings allow. Only upload fields you're happy to publish, and leave out things like cost prices, supplier details, and internal notes. To limit browser search further, set a public search policy under **What browsers can see**, or from your server: ```ts await products.setPublicSearchPolicy({ returnFields: ["objectID", "title", "brand.name", "price", "image", "url"], searchFields: ["title", "brand.name"], filterFields: ["brand.name", "price", "inStock"], facetFields: ["brand.name"], sortIds: ["relevance", "price-asc"], defaultSort: "relevance", }) ``` With a policy, browser searches can only return, search, filter, facet, and sort by the listed fields, and requests for others are rejected. `usePublicIndexDefaults()` removes the policy, and `getPublicSearchAccess()` shows the public ID, origins, and current policy. `attributesToRetrieve` in a search request only asks for fewer fields; it doesn't keep the others private. Use a policy, or leave the fields out of your records. ## Workspace members Owners manage members, API keys, and allowed origins, and change records and settings in the dashboard. Other members can see indexes and their settings, try searches, and read analytics. Owners invite members from **Settings**. --- # Browser SDK > @findlane/browser searches an index from the browser with its public ID. It has no dependencies and is about 1 KB. ## Install ```sh npm install @findlane/browser ``` ## Create a client ```ts import { SearchBrowserClient } from "@findlane/browser" const client = new SearchBrowserClient({ publicId: "pub_your_public_id" }) ``` | Option | | | ---------- | ---------------------------------------------------------------------------------------- | | `publicId` | The index's public ID, from its **Public access** page. | | `baseUrl` | The API to call. Leave it out to use Findlane's hosted API. | | `fetch` | A `fetch` implementation to use instead of the global one, for tests or instrumentation. | Browser search works from the index's allowed origins; see [Security](/docs/security.md#public-ids-and-allowed-origins). ## search(request, options) ```ts type Product = { objectID: string title: string brand: { name: string } price: number image: string } const results = await client.search( { q: "trail shoe", facets: ["brand.name"], limit: 24, highlight: true }, { signal: controller.signal } ) ``` The type parameter types each hit's `record`. `options.signal` cancels the request. ### Request | Field | Type | | | ---------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `q` | `string` | The query, up to 200 characters. | | `filters` | `object` | Field values to keep: a value, `{ in: [...] }`, or `{ gte, lte }` for numbers. See [Filters](/docs/search.md#filters). | | `facets` | `string[]` | Facet fields to count values for, up to eight. | | `sort` | `string` | A sort ID from the index's settings, or `"relevance"`. | | `limit` | `number` | Hits to return, 1 to 50. Default 20. | | `offset` | `number` | Hits to skip, up to 1,000. | | `countMode` | `"page"` or `"exact"` | `"exact"` counts every match in `total`. | | `facetMode` | `"page"` or `"exact"` | `"exact"` counts facet values across every match. | | `highlight` | `boolean` or `{ preTag, postTag }` | Mark matched words in `highlights`. | | `attributesToRetrieve` | `string[]` | Return only these fields. | ### Response | Field | Type | | | --------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | | `hits` | `{ id, score, record, highlights? }[]` | The matching records. `score` is `null` without a query or when sorting by a field. | | `total` | `number` | Matches: a lower bound when `totalRelation` is `"gte"`. | | `totalRelation` | `"eq"` or `"gte"` | Whether `total` is exact. | | `hasMore` | `boolean` | Whether there are hits after this page. | | `facets` | `Record>` | Counts per value of each requested facet. | | `sort` | `string` | The sort used. | | `tookMs` | `number` | Time the search took on the server. | ## Errors A failed request throws a `FindlaneError` with the HTTP `status` and the API's `message`: ```ts import { FindlaneError } from "@findlane/browser" try { await client.search({ q }) } catch (error) { if (error instanceof FindlaneError && error.status === 429) showMessage("Too many searches, try again shortly") else throw error } ``` | Status | Meaning | | ------ | -------------------------------------------------------------------------------- | | `400` | The request isn't valid, such as an unknown facet or sort. The message says why. | | `402` | The workspace has used the searches its plan includes this month. | | `403` | This origin isn't allowed, or the public ID doesn't exist. | | `429` | Too many searches: 240 a minute per visitor, or 6,000 per index. | A cancelled request rejects with the `AbortError` from `fetch`. ## Without a bundler The package is a standard ES module, so a page can load it from a CDN that serves npm packages: ```html ``` --- # Server SDK > @findlane/server uploads and updates catalogs, changes settings, and searches with a workspace API key. It runs in Node.js 18+, Bun, Deno, and edge runtimes. ## Install ```sh npm install @findlane/server ``` ## Create a client ```ts import { SearchServerClient } from "@findlane/server" const client = new SearchServerClient({ workspaceId: process.env.FINDLANE_WORKSPACE_ID!, apiKey: process.env.FINDLANE_API_KEY!, }) const products = client.index("products") ``` | Option | | | ------------- | ------------------------------------------------------------- | | `workspaceId` | The workspace ID, from the **API keys** or **Settings** page. | | `apiKey` | A workspace API key. Keep it on your server. | | `baseUrl` | The API to call. Leave it out to use Findlane's hosted API. | | `fetch` | A `fetch` implementation to use instead of the global one. | `client.index(name)` returns the methods for one index. `T` types your records, which each need a string `objectID`. Every method returns a promise and takes an optional `AbortSignal`. ## Search | Method | Scope | | | -------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | | `search(request, signal?)` | `search` | Search the index. Takes the [same request](/docs/browser-sdk.md#request) as the browser SDK, with up to 100 hits. | ## Records | Method | Scope | | | ------------------------------------- | ------- | ----------------------------------------------------------------- | | `upsert(records, signal?)` | `write` | Add or replace up to 1,000 records by `objectID`. | | `delete(objectID, signal?)` | `write` | Delete a record. | | `getRecord(objectID, signal?)` | either | A record and its version; `record` is `null` if it doesn't exist. | | `upsertOne(record, options)` | `write` | Write one record if it's still at `options.expectedVersion`. | | `deleteOne(objectID, options)` | `write` | Delete one record if it's still at `options.expectedVersion`. | | `replaceAll(records, signal?)` | `write` | Replace the catalog in one request, up to 100,000 records. | | `replaceAllStaged(records, options?)` | `write` | Replace the catalog in batches, switched in one commit. | | `abortReplacement(stageId, signal?)` | `write` | Drop an unfinished staged replacement. | Writes return a receipt: `{ acceptedRevision, indexedRevision, recordCount }`. See [Records](/docs/records.md) for how to use them. `replaceAllStaged` takes an array or an async iterable of records, and these options: | Option | | | --------------- | --------------------------------------------------------------- | | `stageId` | Names the stage, so a retry continues it. Default: a random ID. | | `batchSize` | Records per batch, 1 to 1,000. Default 500. | | `maxBatchBytes` | Bytes per batch. Default 8 MiB. | | `signal` | Cancels the upload. | `upsertOne` and `deleteOne` take `{ expectedVersion, idempotencyKey?, signal? }`. The lower-level `beginReplacement`, `uploadReplacementBatch`, and `commitReplacement` are also available for running the stage steps yourself. ## Settings | Method | Scope | | | ------------------------------------ | ------- | ---------------------------------------------------------------------- | | `getSettings(signal?)` | either | The index's settings, and a settings change in progress in `rebuild`. | | `replaceSettings(settings, signal?)` | `write` | Save the full settings. `applied` says how they took effect. | | `waitForSettings(options?)` | either | Wait until a background settings change finishes; throws if it failed. | See [Search settings](/docs/settings.md) for the fields and how changes apply. ## Indexing status | Method | Scope | | | -------------------------------------- | ------ | ---------------------------------------- | | `getIndexingStatus(signal?)` | either | `{ acceptedRevision, indexedRevision }`. | | `waitUntilIndexed(revision, options?)` | either | Wait until a revision is searchable. | Writes are searchable when they return, so `waitUntilIndexed` returns at once. It's there so code written for search services that index later keeps working. ## Browser search access | Method | Scope | | | ---------------------------------------- | ------- | --------------------------------------------- | | `getPublicSearchAccess(signal?)` | `write` | The public ID, allowed origins, and policy. | | `setPublicSearchPolicy(policy, signal?)` | `write` | Limit what browser search can read and query. | | `usePublicIndexDefaults(signal?)` | `write` | Remove the policy. | Allowed origins are changed in the dashboard. See [Security](/docs/security.md#what-browser-search-can-read). ## Errors A failed request throws a `FindlaneError` with the HTTP `status` and the API's `message`. `replaceAllStaged` throws a `StagedReplacementError` with the `stageId` to retry or abort, and the original error as its `cause`. ```ts import { FindlaneError } from "@findlane/server" try { await products.upsert(records) } catch (error) { if (error instanceof FindlaneError && error.status === 402) alertTeam(error.message) else throw error } ``` The staged replacement and conditional write methods retry network errors and `408`, `429`, `502`, `503`, and `504` responses up to three times. See the [error codes](/docs/api.md#errors). --- # HTTP API > Every endpoint the SDKs use, for calling Findlane from any language. ## Requests The API is at `https://api.findlane.dev`. Requests and responses are JSON: send `Content-Type: application/json` with every request body. Index endpoints are under the workspace and index: ```text /api/teams/{workspaceId}/indexes/{index} ``` Authenticate with an API key as a bearer token: ```sh API=https://api.findlane.dev curl "$API/api/teams/$FINDLANE_WORKSPACE_ID/indexes/products/search" \ -H "Authorization: Bearer $FINDLANE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "q": "trail shoe", "facets": ["brand.name"], "limit": 10 }' ``` ## Errors Failed requests answer with an HTTP status and a message: ```json { "error": "Filter field color is not configured" } ``` | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------- | | `400` | The request isn't valid. The message says why. | | `401` | The API key is missing or invalid. | | `402` | A plan limit: records, indexes, or monthly searches. | | `403` | The key lacks the scope or index, or the origin isn't allowed. | | `404` | The index, or a staged replacement, doesn't exist. | | `409` | A conflict, such as a record version that changed or another write during a staged replacement. | | `415` | The request body isn't sent as `application/json`. | | `429` | A public search rate limit: 240 a minute per visitor, 6,000 per index. Wait for the `Retry-After` seconds. | | `5xx` | A temporary problem. Retry with a backoff. | ## Search | Method | Path | Scope | | | ------ | --------- | -------- | ----------------- | | `POST` | `/search` | `search` | Search the index. | The body is a [search request](/docs/browser-sdk.md#request), with up to 100 hits a request, and the response is a [search response](/docs/browser-sdk.md#response). ### Browser search ```text POST /api/public/indexes/{publicId}/search ``` Browser search needs no key. It answers only requests whose `Origin` header is one of the index's allowed origins, returns up to 50 hits a request, and follows the index's [public search policy](/docs/security.md#what-browser-search-can-read). It supports CORS, so browsers call it directly. ## Records | Method | Path | Scope | Body | | -------- | --------------------- | ------- | ----------------------------------------------------------------------- | | `POST` | `/records` | `write` | `{ "records": [...] }`: add or replace 1 to 1,000 records. | | `PUT` | `/records` | `write` | `{ "records": [...] }`: replace the catalog with up to 100,000 records. | | `DELETE` | `/records/{objectID}` | `write` | | | `GET` | `/records/{objectID}` | either | | | `POST` | `/record-writes` | `write` | A conditional write, below. | Writes answer `202` with a receipt, and are searchable when they do: ```json { "acceptedRevision": 42, "indexedRevision": 42, "recordCount": 18230 } ``` `GET /records/{objectID}` answers `{ "objectID", "version", "record" }`, where `record` is `null` if the record doesn't exist. ### Conditional writes ```json { "operation": "upsert", "objectID": "sku-1042", "record": { "objectID": "sku-1042", "title": "Trail running shoe", "price": 119 }, "expectedVersion": 3, "idempotencyKey": "price-update-8f14e45f" } ``` The write applies only if the record's version is `expectedVersion` (`0` for a record that doesn't exist), and answers `409` otherwise. `operation` is `upsert` or `delete`; a delete has no `record`. Repeating a request with the same `idempotencyKey` (1 to 64 letters, digits, `_`, or `-`) returns the first receipt instead of writing again. ### Staged replacements A staged replacement uploads a catalog in batches and switches the index to it in one commit. `stageId` and `batchId` are 1 to 64 letters, digits, `_`, or `-`. The paths are under `/staged-replacements/{stageId}`, and need the `write` scope: | Method | Path | Body | | -------- | -------------------------------- | ----------------------------------------------------- | | `POST` | `/staged-replacements/{stageId}` | Start the stage, or get its status. | | `POST` | `…/batches/{batchId}` | `{ "records": [...] }`: 1 to 1,000 records. | | `POST` | `…/commit` | `{ "expectedBatches": 12, "expectedRecords": 11408 }` | | `DELETE` | `/staged-replacements/{stageId}` | Abort the stage. | Uploading a batch again with the same records has no effect; with different records it fails with `409`. The commit checks the batch and record counts, and fails with `409` if other writes reached the index since the stage started. Committing again returns the first receipt. ## Settings | Method | Path | Scope | | | ------ | ----------- | -------- | ------------------------------------------------------------------------------ | | `GET` | `/config` | either | The [settings](/docs/settings.md), with a change in progress in `rebuild`. | | `PUT` | `/config` | `write` | Save the full settings. The response adds `applied`, `rebuilt`, and `rebuild`. | | `GET` | `/fields` | `search` | Field paths in the index's records, with their types. | | `GET` | `/indexing` | either | `{ "acceptedRevision", "indexedRevision", "recordCount" }` | ## Browser search access | Method | Path | Scope | | | -------- | ----------------------- | ------- | ------------------------------------------------------------------------------------- | | `GET` | `/public-access` | `write` | `{ "publicId", "allowedOrigins", "policy" }` | | `PUT` | `/public-access/policy` | `write` | `{ "policy": { ... } }`: see [Security](/docs/security.md#what-browser-search-can-read). | | `DELETE` | `/public-access/policy` | `write` | Remove the policy. | Indexes, API keys, and allowed origins are managed in the dashboard. --- # Limits > Plan limits, and the sizes of records, requests, and settings. ## Plans | Plan | Records | Records in one index | Searches a month | Indexes | Members | | ------- | --------- | -------------------- | ---------------- | ------- | ------- | | Free | 10,000 | 10,000 | 10,000 | 1 | 1 | | Starter | 50,000 | 50,000 | 150,000 | 3 | 2 | | Team | 150,000 | 100,000 | 750,000 | 10 | 5 | | Growth | 500,000 | 250,000 | 3,000,000 | 25 | 15 | | Scale | 1,000,000 | 500,000 | 10,000,000 | 50 | 50 | - **Searches** are API and browser searches in a calendar month (UTC), including searches as you type. - **Free** stops at its records and searches: writes that would go over, and searches after the month's are used, answer `402`. - **Paid plans** keep working past their records and searches, and the extra is billed as overage. Records in one index are a hard limit on every plan. - **Members** count pending invitations. The dashboard shows the workspace's usage under **Settings**. ## Records | | Limit | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `objectID` | 1 to 256 characters | | Record size | 1 MB of JSON | | Fields in a record | 256 | | Field path depth, for fields in settings | 8 levels, such as `a.b.c` | | Text in a searchable field | 1 KB in the two short fields, 16 KB in the others; see [Searchable fields](/docs/settings.md#searchable-fields) | ## Requests | | Limit | | ------------------------------------- | -------------------------------------------------- | | Records in an upsert | 1 to 1,000 | | Records in a `replaceAll` | 100,000 | | Records in a staged replacement batch | 1 to 1,000 | | Query length | 200 characters | | Filters | 20 fields, and 100 values in an `in` filter | | Facets | 8 a request | | Hits a request | 100 from your server, 50 from browsers | | Offset | 1,000 | | `attributesToRetrieve` | 64 fields | | Highlight tags | 32 characters each | | Browser searches | 240 a minute per visitor, 6,000 a minute per index | ## Settings | | Limit | | ----------------- | ---------------------------------------------------------------------- | | Searchable fields | 1 to 5, with weights from 0.1 to 100 | | Sorts | 8, besides relevance | | Custom ranking | 3 fields | | Synonyms | 1,000 groups of 2 to 20 terms, each up to four words and 64 characters | | Allowed origins | 20 an index |