Guides
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.
{
"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. - Locations are objects with
latandlngnumbers, such as"_geoloc": { "lat": 64.1466, "lng": -21.9426 }. Make one the index's location field to search by location.
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 record and the field, such as Record 3: Invalid number value at price.
An objectID has up to 256 characters and can't be . or .., and a record is up to 100 KB of JSON with up to 256 fields. Each plan includes 4 KB of data per record it includes, so bigger records fit as long as the workspace's data does. See Limits.
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. A call is written whole or not at all: if one record is invalid, none of them are written, and the error says which.
const receipt = await products.upsert(records)
// { acceptedRevision: 42, indexedRevision: 42, recordCount: 18230, added: 3, changed: 12, unchanged: 985, removed: 0 }Every write returns a receipt with the index's new revision and record count, and is searchable when it returns. The receipt also says what the write did: how many records were added, changed, sent unchanged and removed. A record sent exactly as the index holds it changes nothing, and doesn't count toward the plan's record changes.
To read a record back, with its version:
const { version, record } = await products.getRecord("sku-1042")
// record is null if there's no such recordDelete records
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:
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
stageIdand 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
409until the open one is committed or aborted, or has been idle for 10 minutes. - A catalog of more than 20,000 records commits in steps, and the commit returns once every step is done (about a minute for 200,000 records). Meanwhile other writes to the index wait for it, searches can show some of the new records before the rest, and the stage can't be aborted. Retrying with the same
stageIdwaits for the same commit.
For catalogs up to 100,000 records, replaceAll(records) does the same in one request.
See what came in
The Updates page, under Records in the dashboard, shows what has come into an index:
- when data last arrived and when it last changed anything, which aren't the same when a sync finds nothing new;
- how many records were added, changed and removed, by hour or day, for up to 90 days;
- each source: the API key by name, a product feed, the dashboard, Shopify, WooCommerce or an app connected over MCP;
- writes that were refused, with the reason and an example object ID;
- full replacements under way, with how far they've got, and the latest operations.
The numbers are exact, and they update as soon as a write ends. A write refused because its key is wrong, revoked or can't write doesn't appear, as it's turned away before it reaches your index.
Your own code reads the same with a key that has the analytics scope, from your server: GET /writes and /writes/log in the API, or the server SDK's writes:
const { status, sources } = await products.writes.get({ range: "7d" })
// status.lastData: the last sync, even one that changed nothing; status.lastChange: the last one that did
const { rows } = await products.writes.log({ problems: true })
// each refused write's reason, with a refused record's objectID as exampleIdAI agents read them with the MCP server's get_writes, for example to check what an upload did.
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,
upsertordeleteit. - A regular full sync. A nightly
replaceAllStagedcatches anything the incremental path missed, and removes products that no longer exist.
A store whose platform makes a Google Shopping or Meta catalog feed can skip the code: connect the feed, and Findlane reads it every hour.
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.
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.