Database
#Reading
const post = await ctx.db.get(id); // document or null
const all = await ctx.db.query("posts").collect(); // every document, oldest firstA query is built from these steps, in order:
ctx.db
.query("posts") // 1. table
.withIndex("by_author", (q) => q.eq("author", userId)) // 2. optional: index range
.order("desc") // 3. optional: "asc" (default) or "desc"
.filter((q) => q.gt(q.field("views"), 100)) // 4. optional: more conditions
.take(10); // 5. collect() | take(n) | first() | unique() | paginate(opts)| Ending | Returns |
|---|---|
.collect() | every match |
.take(n) | up to n matches |
.first() | the first match or null |
.unique() | the only match or null; throws if more than one matches |
.paginate({ numItems, cursor }) | { page, isDone, continueCursor } |
You can also loop with for await (const post of ctx.db.query("posts")) { ... }.
#Indexes
withIndex reads a slice of an index. Call .eq() on the index's fields in order. After that, you may add one lower bound (gt / gte) and one upper bound (lt / lte) on the next field:
// index "by_author_published": ["author", "published"]
.withIndex("by_author_published", (q) => q.eq("author", userId).eq("published", true))
// every table has "by_creation_time"
.withIndex("by_creation_time", (q) => q.gt("_creationTime", Date.now() - 86_400_000))Results come back in index order: by the index fields, then by _creationTime. Because every index ends with _creationTime, you can also put a range on it after the equality conditions:
// messages in a channel from the last hour
.withIndex("by_channel", (q) => q.eq("channel", channel).gt("_creationTime", Date.now() - 3_600_000))If you get the order wrong or use a field the index doesn't have, the error says which fields the index has and in what order.
#Filters
.filter() accepts either form:
.filter({ published: true, author: userId }) // shorthand: field equals value
.filter((q) => q.and(q.eq(q.field("published"), true), q.gte(q.field("views"), 10)))Builder operators:
- Comparison:
eq,neq,lt,lte,gt,gte - Logic:
and,or,not - Arithmetic:
add,sub,mul,div,mod,neg - Fields:
q.field("a.b")reads a (nested) field.
Filters use indexes automatically. When an index matches the fields you filter on, the database uses it. You don't have to use withIndex. In dev mode, a filter that has to read the whole table logs a warning with the index to add:
warn Query on "posts" reads every document to apply .filter().
Add .index("by_author", ["author"]) to "posts" in schema.ts and it will be used automatically.The callback receives a filter builder, not a document. For logic the builder can't express, filter in JavaScript:
const posts = (await ctx.db.query("posts").collect()).filter((p) => p.title.includes("onebase"));For full-text search, use .withSearchIndex(...) instead of .withIndex(...). See Search.
#Pagination
import { paginationOptsValidator } from "onebase/server";
export const feed = query({
args: { paginationOpts: paginationOptsValidator },
handler: (ctx, { paginationOpts }) => ctx.db.query("posts").order("desc").paginate(paginationOpts),
});Pass { numItems: 20, cursor: null } for the first page, then cursor: continueCursor for the next one. numItems must be a non-negative integer, and a page holds at most 8192 documents: ask for more and you get 8192 with isDone: false. Cursors are stable when documents are inserted. In React, usePaginatedQuery handles this for you and keeps every loaded page live (see Client).
#Writing (mutations only)
const id = await ctx.db.insert("posts", { author, title, tags: [], published: false });
await ctx.db.patch(id, { title: "New title", views: undefined }); // shallow merge; undefined removes a field
await ctx.db.replace(id, { author, title, tags: [], published: true }); // replace the whole document
await ctx.db.delete(id);Every write is validated against the schema. Writes in one mutation are atomic, and if the mutation throws, none of them happen.