Querying Data
Accessing Collections
Section titled “Accessing Collections”Access any collection through client.data.<collectionName> (camelCase, auto-converted to snake_case) or client.data.collection<Record<string, unknown>>("slug") (explicit slug):
// Property-style access (camelCase → snake_case slug)client.data.blogPosts // → slug "blog_posts"client.data.users // → slug "users"
// Dynamic access by slugclient.data.collection<Record<string, unknown>>("blog_posts")Strict mode (generated SDK): When you pass the generated
collectionsDictionarytocreateRebaseClient, the data proxy validates property accesses at access time. A typo likeclient.data.prodcutswill throw immediately with a helpful error and a nearest-match suggestion instead of producing a confusing 404 later. Useclient.data.collection<Record<string, unknown>>("slug")to bypass validation for dynamic or runtime-determined slugs.
CRUD Operations
Section titled “CRUD Operations”Find (List)
Section titled “Find (List)”// All products (default limit: 50)const { data, meta } = await client.data.products.find();
// With pagination, filtering, and sortingconst { data, meta } = await client.data.products.find({ where: { active: ["==", true], price: [">=", 100] }, orderBy: ["created_at", "desc"], limit: 25, offset: 0});
// data is Entity<M>[] — each item has { id, values, path }// meta has { total, limit, offset, hasMore }Find by ID
Section titled “Find by ID”const product = await client.data.products.findById(42);// Returns Entity<M> | undefinedCreate
Section titled “Create”const newProduct = await client.data.products.create({ name: "New Product", price: 29.99, active: true});
// With a specific IDconst newProduct = await client.data.products.create( { name: "Custom ID Product" }, "my-custom-id");Update
Section titled “Update”const updated = await client.data.products.update(42, { name: "Updated Name", price: 39.99});Delete
Section titled “Delete”await client.data.products.delete(42);const total = await client.data.products.count();
// With filtersconst activeCount = await client.data.products.count({ where: { active: ["==", true] }});Batch Writes
Section titled “Batch Writes”Three operations write many rows in a single request and a single transaction. Every row still runs the normal pipeline — callbacks, relations, row-level security — so a batch is not a shortcut past your own rules; the win is one round trip and one transaction instead of N of each.
All three are all-or-nothing. If any row is rejected, none of them land and the error names the offending index.
// Createawait client.data.products.createMany([ { name: "Widget", price: 9.99 }, { name: "Gadget", price: 19.99 }]);
// Update — each entry names its row and the fields to changeawait client.data.orders.updateMany([ { id: "o-1", data: { status: "shipped" } }, { id: "o-2", data: { status: "shipped" } }]);
// Delete — by idawait client.data.sessions.deleteMany(["s-1", "s-2"]);Why { id, data } rather than flat rows
Section titled “Why { id, data } rather than flat rows”createMany takes flat rows because a row being created is its columns.
updateMany names the address separately, because on a table keyed on something
other than id — a sku, a composite key — a flat row cannot say whether a
column is the address or a value to write. This mirrors single-row
update(id, data) exactly.
Why deleteMany takes ids, not a filter
Section titled “Why deleteMany takes ids, not a filter”A filter-shaped bulk delete is a different and far more dangerous operation: the failure mode is an omitted or mistyped condition emptying a table, and it cannot be reviewed at the call site the way an explicit list can. Read first, then pass the ids you meant:
const stale = await client.data.sessions.findAll({ where: { expires_at: ["<", cutoff] }});await client.data.sessions.deleteMany(stale.map(s => s.id as string));Retries and duplicates
Section titled “Retries and duplicates”A client that never sees the response cannot know whether the batch committed, so it retries — and without a key the server cannot tell that retry from a second genuine batch. Pass an idempotency key on anything that may be resent:
const attemptKey = crypto.randomUUID();await client.data.products.createMany(rows, { idempotencyKey: attemptKey });A key names one request, not a job: it is recorded against the method, the path
and the body it was sent with. Re-sending that exact request replays its answer;
the same key on a different request is refused with IDEMPOTENCY_KEY_REUSED
(422). So mint one per call rather than reusing a business id — an importId
shared by the createMany and the deleteMany of one import would leave the
delete silently unperformed.
A retry that arrives while the first attempt is still being answered gets
IDEMPOTENCY_KEY_IN_PROGRESS (409): send it again, and it will be answered with
the first attempt’s result once that lands. Keys are honoured for 24 hours, and
only for a signed-in caller — there is no principal to scope one to otherwise.
The offline queue sets a key automatically on every replay.
Limits
Section titled “Limits”Batches are capped server-side (1000 rows by default), because one batch holds
its locks for the whole transaction. Going over is a BULK_TOO_LARGE error that
names both the limit and your row count, so chunk to it:
for (const chunk of chunks(rows, 1000)) { await client.data.products.createMany(chunk, { upsert: true });}A data source that cannot write atomically reports BULK_UNSUPPORTED rather
than quietly looping single writes — which would give you neither the atomicity
nor the single round trip you reached for a batch to get.
Fluent Query Builder
Section titled “Fluent Query Builder”Chain methods for more expressive queries:
const { data } = await client.data.products .where("price", ">=", 100) .where("active", "==", true) .orderBy("created_at", "desc") .limit(10) .find();Available Methods
Section titled “Available Methods”| Method | Description | Example |
|---|---|---|
.where(field, op, value) |
Add a filter condition | .where("age", ">=", 18) |
.orderBy(field, dir) |
Sort results | .orderBy("name", "asc") |
.limit(n) |
Limit result count | .limit(25) |
.offset(n) |
Skip first N results | .offset(50) |
.search(text) |
Text search — see Search | .search("laptop") |
.vectorSearch(prop, vector, opts?) |
Nearest-neighbour search over a vector property |
.vectorSearch("embedding", vec) |
.include(...relations) |
Include related entities | .include("author", "tags") |
.find() |
Execute the query | Returns FindResponse<M> |
.listen(onUpdate, onError?) |
Subscribe to real-time updates | Returns unsubscribe() |
Filter Operators
Section titled “Filter Operators”| Operator | Alias | Description |
|---|---|---|
"==" |
"eq" |
Equal |
"!=" |
"neq" |
Not equal |
">" |
"gt" |
Greater than |
">=" |
"gte" |
Greater than or equal |
"<" |
"lt" |
Less than |
"<=" |
"lte" |
Less than or equal |
"in" |
Value in array | |
"not-in" |
"nin" |
Value not in array |
"array-contains" |
"cs" |
Array field contains value |
"array-contains-any" |
"csa" |
Array field contains any of values |
Where Clause Syntaxes
Section titled “Where Clause Syntaxes”The where parameter in find() supports two formats:
// 1. Tuple syntax — [operator, value] (recommended)await client.data.products.find({ where: { status: ["==", "active"], featured: ["==", true], price: [">=", 100], category: ["in", ["electronics", "gadgets"]], deleted_at: ["!=", null] }});
// 2. Pre-serialized PostgREST string syntax (advanced)await client.data.products.find({ where: { status: "eq.published", price: "gte.100" }});Note: Pre-serialized PostgREST strings (format 2) are an escape hatch for passing filter values that are already in wire format. Prefer tuple syntax for type safety and readability.
Pagination
Section titled “Pagination”// Offset-based paginationconst page1 = await client.data.products.find({ limit: 20, offset: 0 });const page2 = await client.data.products.find({ limit: 20, offset: 20 });
// Check if more pages existif (page1.meta.hasMore) { // fetch next page}
// Page-number pagination (1-indexed)const page = await client.data.products.find({ page: 2, limit: 20 });limit must be a whole number between 1 and 1000. A larger one — or a zero,
negative, or fractional one — is refused with a 400 INVALID_LIMIT rather than
clamped, because a silently smaller page cannot be told apart from the last one.
To read past that ceiling, walk the pages with iterate() or findAll().
Sorting
Section titled “Sorting”// Sort by field (format: ["field", "direction"])const { data } = await client.data.products.find({ orderBy: ["created_at", "desc"]});
// Fluent styleconst { data } = await client.data.products .orderBy("price", "asc") .find();Text Search
Section titled “Text Search”// Via find paramsconst { data } = await client.data.products.find({ searchString: "wireless headphones"});
// Fluent styleconst { data } = await client.data.products .search("wireless headphones") .limit(10) .find();By default this is a case-insensitive substring match across the
collection’s top-level string properties. It is not full-text search: it does
not reach inside map or array properties, does not stem or rank, and cannot
use an index.
A Postgres collection can opt in to real full-text search by declaring a
search block, which also makes results rankable by _score. See
Search.
Vector Search
Section titled “Vector Search”For collections with a vector property, order rows by similarity to a query
embedding. Rows come back closest-first, each carrying a _distance.
const { data } = await client.data.docs .vectorSearch("embedding", queryVector, { threshold: 0.35 }) .where("status", "==", "published") .limit(10) .find();where and orderBy on the same query act as filters applied before the
ordering — this returns the nearest rows that also match, not the nearest rows
filtered afterwards. Producing queryVector is your job: Rebase stores and
searches embeddings, it does not compute them.
What you have to provide
Section titled “What you have to provide”- pgvector. A
vectorproperty compiles to aVECTOR(n)column, and that type comes from thevectorextension. Rebase does not install it — an extension needs a server image that carries it and a role allowed to create it. The scaffold’spostgres:18-alpinedoes not ship pgvector; use an image that does (for examplepgvector/pgvector:pg18) and runCREATE EXTENSION vector;once. Without it the first boot fails, naming this. - An index, if you want one. Rebase creates no HNSW or IVFFlat index, so
every
vectorSearchcomputes the distance for every row and sorts: exact results, sequential cost. Create the index yourself in a migration (CREATE INDEX … USING hnsw (embedding vector_cosine_ops)) if your table is large enough to need one.
vectorSearch is a query, not a subscription: .listen() on one is refused
rather than served as a plain listing, because nothing recomputes distances on a
write.
Fetching Relations
Section titled “Fetching Relations”Relations can be included so that related entities are returned alongside the primary data, instead of just their foreign key IDs.
Using include() (Fluent)
Section titled “Using include() (Fluent)”// Include specific relationsconst { data } = await client.data.posts .include("author", "categories") .find();
// Include all defined relationsconst { data } = await client.data.posts .include("*") .find();Using find({ include }) (Params)
Section titled “Using find({ include }) (Params)”const { data } = await client.data.posts.find({ include: ["author", "categories"]});Combining with Filters
Section titled “Combining with Filters”const { data } = await client.data.posts .where("status", "==", "published") .include("author") .orderBy("published_at", "desc") .limit(10) .find();Reading Relation Data
Section titled “Reading Relation Data”When relations are included, the response contains both the scalar foreign key and the hydrated relation object:
const { data } = await client.data .collection<{ author_id: string; author?: { name: string } }>("posts") .include("author") .find();
for (const post of data) { // Scalar foreign key — always present console.log(post.author_id); // "uuid-1234"
// Hydrated relation — present when included console.log(post.author?.name); // "Jane Doe"}Note: Without
.include("author"), only the scalarauthor_idfield is returned. The hydratedauthorobject will beundefined.
Relation Names
Section titled “Relation Names”The relation names you pass to include() must match the relationName defined in the collection’s relations array:
// Collection definitionrelations: [ { relationName: "author", target: () => usersCollection, ... }, { relationName: "categories", target: () => categoriesCollection, ... }]
// SDK usage — names must matchclient.data.articles.include("author", "categories").find()Custom Endpoints
Section titled “Custom Endpoints”Call custom server endpoints registered via the functions system:
// Using client.functions.invoke()const result = await client.functions.invoke<{ summary: string }>( "generate-summary", { articleId: 42 });
// With optionsconst result = await client.functions.invoke<{ status: string }>( "process-order", { orderId: 123 }, { method: "POST", path: "status/check" });
// Shorthand via client.call()const result = await client.call<{ summary: string }>( "functions/generate-summary", { articleId: 42 });Next Steps
Section titled “Next Steps”- Authentication — Sign in, sign up, OAuth, sessions
- Realtime Subscriptions — Live data with WebSockets
- Storage & Files — Upload, download, and manage files
- Relations — Define relations between collections
