@findlane/server
v0.39.0
Published
Findlane server SDK: upload catalogs, manage index settings, and search with a secret workspace API key.
Maintainers
Readme
@findlane/server
The Findlane server SDK: upload and update catalogs, manage index settings, and search, with a secret workspace API key. It runs wherever fetch does: Node.js 18+, Bun, Deno, and edge runtimes. Never bundle it, or its key, into browser code; use @findlane/browser there.
Docs: https://findlane.dev/docs/server-sdk
Install
npm install @findlane/serverQuick start
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")
await products.upsert([{ objectID: "sku-1", title: "Trail shoe", brand: { name: "Acme" }, price: 12900 }])
const results = await products.search({ q: "trail shoe", limit: 10 })
console.log(results.total, results.hits[0]?.record.title)The workspace ID is under Settings in the dashboard, and keys are under API keys. A key has one or both scopes:
search:search().write: record writes, settings, public search restrictions, and indexing status.
Searching for a visitor
When your server searches for someone, such as a shopper loading a server-rendered search page, pass the visitor. The index's limits and bot rules then apply to them as they do to browser search, and searches from verified crawlers like Googlebot are free:
const results = await products.search({ q }, { visitor: { ip: req.ip, userAgent: req.get("user-agent") } })suggest() and recommendations() take the same option. Without it, a search counts as your server's own.
An index's methods
client.index<T>(name) returns these; T types your records, which each need a string objectID.
| Method | What it does |
| --- | --- |
| search(request, options?) | Search with a query, filters, facets, a sort, paging, and highlighting. explain: true says why each hit matched and what ordered it, and appliedRules lists the index's query rules the query fired. options takes signal and visitor (below). |
| querySuggestions({ q, limit? }), popularSearches({ limit? }) | Whole searches to offer as a visitor types, [{ q, typed }], and the index's popular searches for an empty box. Not counted as searches. |
| upsert(records) | Add or update up to 1,000 records by objectID. |
| replaceAll(records) | Replace the whole catalog in one request (up to 100,000 records). |
| replaceAllStaged(records, options) | Replace a larger catalog in batches under one stage ID, switched in one commit. Retrying with the same stageId resumes. abortReplacement(stageId) drops a stage. |
| delete(objectID) | Delete a record. |
| getRecord(objectID) | A record and its version. |
| upsertOne(record, { expectedVersion }), deleteOne(objectID, { expectedVersion }) | Write only if the record is still at expectedVersion (0 for one that never existed); retried with one idempotency key. |
| getSettings(), replaceSettings(settings), waitForSettings() | Read and change searchable fields, facets, filters, sorts, custom ranking, synonyms, languages, typo tolerance, and text normalization. |
| compareSettings(settings, { queries, searched }?) | Compare settings with the index's own without saving them: its search tests and most searched queries, each searched with both. Says which tests would fail and whose results change. |
| getSearchTests(), setSearchTests(tests) | Searches that must keep working, saved apart from the settings, such as a record in the top 3 or at least 100 results. compareSettings checks them. |
| getIndexingStatus(), waitUntilIndexed(revision) | Writes return an accepted revision and are searchable when accepted, so waitUntilIndexed returns at once. |
| getPublicSearchAccess(), setPublicSearchPolicy(policy), usePublicIndexDefaults() | Restrict what browser search may read and query. |
| events.send(events, { visitorId, visitId, abTest }?), events.importOrders(orders), events.cancelOrder(orderID) | Orders and other events for the index's Events page and Bought together; the key needs the events or write scope. Your own anonymous visitor IDs link a shopper's events, when the index takes them. abTest, from the shopper's search answer, puts them in their A/B test group. |
| abTests.get(), abTests.estimate({ share }), analytics.abTest() | The index's A/B tests of its settings, with their results, and what a test would take. Owners start and end them in the dashboard. |
| events.forget(ids) | Deletes the events sent with these visitor or visit IDs, within a week. |
| events.proxy(request), events.relay(body, visitor) | A route on your own site for the browser SDK's events (its eventsUrl), which ad blockers let through. See Events. |
| analytics.searches(), .queries({ list, limit }), .filters(), .events(), .products(), .productCounts(), .product(objectID) | The index's analytics, for your own admin: searches over time, top queries with their clicks, queries without results or clicks, the filters used, events, and each product's views, add-to-carts and orders. The key needs the analytics scope, and works only from a server. See Analytics. |
| writes.get({ range }), writes.log({ problems, before, limit }) | What has come into the index: when data last arrived and last changed anything, by source, and writes refused with their reasons. The key needs the analytics scope, and works only from a server. See Updates. |
Settings
replaceSettings sends the full settings; fetch them first to keep the other fields. Its applied says how the change took effect:
instant: query-time settings, and removing or reordering searchable fields.fields: facet, filter, sort, and ranking fields, filled in place.searchable: new searchable fields, filled in.rebuild: text normalization; the index is rebuilt.
A large searchable or rebuild change continues in the background, with its progress in rebuild (also returned by getSettings). Searches use the previous settings until it finishes, and waitForSettings() waits for it.
Before saving a change to rules, synonyms, typo tolerance, or ranking, compareSettings shows what it does, and {} runs the search tests with the current settings:
const comparison = await products.compareSettings({ rules: nextRules })
const failing = comparison.tests.filter((test) => test.before && !test.after)
if (!failing.length) await products.replaceSettings({ ...(await products.getSettings()), rules: nextRules })Browser search
Only upload fields you intend to make public if browser search is enabled. getPublicSearchAccess() shows the current mode. setPublicSearchPolicy({ returnFields, searchFields, filterFields, facetFields, sortIds, defaultSort }) restricts the index at the API, and usePublicIndexDefaults() removes the restrictions. Only a workspace owner can change allowed browser origins, in the dashboard.
Signed keys
For a logged-in person's browser searches, sign a short-lived key with a signing key from the dashboard's API keys page. It makes no request, and its restrictions replace the index's public settings:
import { signSearchKey } from "@findlane/server"
const signedKey = await signSearchKey(process.env.FINDLANE_SIGNING_KEY!, {
validUntil: new Date(Date.now() + 60 * 60 * 1000),
indexes: ["products"],
filters: { customerGroup: user.group },
userToken: user.id,
})The browser SDK takes it as signedKey. See Logged-in visitors.
Errors
Failed requests throw a FindlaneError with the HTTP status and the API's message. A 402 means a plan limit, such as records or monthly searches. replaceAllStaged throws a StagedReplacementError with the stageId to retry. Every method takes an optional AbortSignal.
License
MIT
