Reading Ents

Reading Ents from the Database

Convex Ents provide a ctx.table method which replaces the built-in ctx.db object in Convex queries (opens in a new tab). The result of calling the method is a custom object which acts as a lazy Promise. If you await it, you will get a list of results. But you can instead call another method on it which will return a different lazy Promise, and so on. This enables a powerful and efficient fluent API.

Security

The Convex Ents API was designed to add an additional level of security to your backend code. The built-in ctx.db object allows reading data from and writing data to any table. Therefore in vanilla Convex you must correctly use both argument validation (opens in a new tab) and strict schema validation (opens in a new tab) to avoid exposing a function which could be misused by an attacker to act on a table it was not designed to act on.

All Convex Ents APIs require specifying a table up-front. If you need to read data from an arbitrary table, you must the use the built-in ctx.db object.

Reading a single ent by ID

const task = await ctx.table("tasks").get(taskId);
This is equivalent to the built-in:
const task = await ctx.db.get(taskId);

with the addition of checking that taskId belongs to "tasks".

Reading a single ent by indexed field

const task = await ctx.table("users").get("email", "steve@apple.com");
This is equivalent to the built-in:
const task = await ctx.db
  .query("users")
  .withIndex("email", (q) => q.eq("email", "steve@apple.com"))
  .unique();

Reading a single ent via a compound index

const task = await ctx.table("users").get("nameAndRank", "Steve", 10);
This is equivalent to the built-in:
const task = await ctx.db
  .query("users")
  .withIndex("nameAndRank", (q) => q.eq("name", "Steve").eq("rank", 10))
  .unique();

Reading a single ent or throwing

The getX method (pronounced "get or throw") throws an Error if the read produced no ents:

const task = await ctx.table("tasks").getX(taskId);
const task = await ctx.table("users").getX("email", "steve@apple.com");

Reading multiple ents by IDs

Retrieve a list of ents or nulls:

const tasks = await ctx.table("tasks").getMany([taskId1, taskId2]);
This is equivalent to the built-in:
const task = await Promise.all([taskId1, taskId2].map(ctx.db.get));

Reading multiple ents by IDs or throwing

Retrieve a list of ents or throw an Error if any of the IDs didn't map to an existing ent:

const tasks = await ctx.table("tasks").getManyX([taskId1, taskId2]);

Also throws if any of the ents fail a read rule.

Listing all ents

To list all ents backed by a single table, simply await the ctx.table call:

const tasks = await ctx.table("users");
This is equivalent to the built-in:
const tasks = await ctx.db.query("users").collect();

Listing ents filtered by index

To list ents from a table and efficiently filter them by an index, pass the index name and filter callback to ctx.table:

const posts = await ctx.table("posts", "numLikes", (q) =>
  q.gt("numLikes", 100),
);
This is equivalent to the built-in:
const posts = await ctx.db
  .query("posts")
  .withIndex("numLikes", (q) => q.gt("numLikes", 100))
  .collect();

Filtering

Use the filter method, which works the same as the built-in filter method (opens in a new tab):

const posts = await ctx
  .table("posts")
  .filter((q) => q.gt(q.field("numLikes"), 100));
This is equivalent to the built-in:
const posts = await ctx.db
  .query("posts")
  .filter((q) => q.gt(q.field("numLikes"), 100))
  .collect();

Ordering

Use the order method. The default sort is by _creationTime in ascending order (from oldest created to newest created):

const posts = await ctx.table("posts").order("desc");
This is equivalent to the built-in:
const posts = await ctx.db.query("posts").order("desc").collect();

Ordering by index

The order method takes an index name as a shortcut to sorting by a given index:

const posts = await ctx.table("posts").order("desc", "numLikes");
This is equivalent to the built-in:
const posts = await ctx
  .table("posts")
  .withIndex("numLikes")
  .order("desc")
  .collect();

Limiting results

Taking first n ents

const users = await ctx.table("users").take(5);

Loading a page of ents

The paginate method returns a page of ents. It takes the same argument object as the the built-in pagination (opens in a new tab) method.

const result = await ctx.table("posts").paginate(paginationOpts);

Finding the first ent

Useful chained, either to a ctx.table call using an index, or after filter, order, edge.

const latestUser = await ctx.table("users").order("desc").first();

Finding the first ent or throwing

The firstX method (pronounced "first or throw") throws an Error if the read produced no ents:

const latestUser = await ctx.table("users").order("desc").firstX();

Finding a unique ent

Throws if the listing produced more than 1 result, returns null if there were no results:

const counter = await ctx.table("counters").unique();

Finding a unique ent or throwing

The uniqueX method (pronounced "unique or throw") throws if the listing produced more or less than 1 result:

const counter = await ctx.table("counters").uniqueX();

Accessing system tables

System tables (opens in a new tab) can be read with the ctx.table.system method:

const filesMetadata = await ctx.table.system("_storage");
const scheduledFunctions = await ctx.table.system("_scheduled_functions");

All the previously listed methods are also supported by ctx.table.system. Edges to and from system tables are currently not supported.

Traversing edges

Convex Ents allow easily traversing edges declared in the schema.

Traversing 1:1 edge

The edge method returns an ent for 1:1 edges, or null if starting from the "optional" end of the edge:

const profile = await ctx.table("users").getX(userId).edge("profile");
This is equivalent to the built-in:
const profile = await ctx.db
  .query("profiles")
  .withIndex("userId", (q) => q.eq("userId", userId))
  .unique();

The edgeX method (pronounced "edge or throw") throws if the edge does not exist:

const profile = await ctx.table("users").getX(userId).edgeX("profile");

Traversing 1:many edges

The edge method returns either a list of ents, or a single ent (or null) for 1:many edges, depending on which end of the edge is used:

const messages = await ctx.table("users").getX(userId).edge("messages");
const user = await ctx.table("message").getX(messageId).edge("user");

Traversing many:many edges

The edge method returns either a list of ents for many:many edges:

const tags = await ctx.table("messages").getX(messageId).edge("tags");
const messages = await ctx.table("tags").getX(tagId).edge("messages");

Testing many:many edge presence

You can efficiently test whether two ents are connected by a many:many edge using the has method:

const hasTag = ctx.table("messages").getX(messageId).edge("tags").has(tagId);
This is equivalent to the built-in:
const hasTag =
  (await ctx.db
    .query("messages_to_tags")
    .withIndex("messagesId", (q) =>
      q.eq("messagesId", message._id).eq("tagsId", tagId),
    )
    .first()) !== null;

Traversing edges on retrieved ents

All the methods for reading ents return the underlying document(s), enriched with the edge and edgeX methods. This allows traversing edges even after loading the ent from the database:

const user = await ctx.table("users").firstX();
const profile = user.edgeX("profile");

Mapping ents

To map, that is to produce a new Array by calling a function on each element of the original Array, using async functions is quite unwieldy in JavaScript without the use of libraries. For this reason all lazy promises of a list of ents support the map method:

const usersWithMessagesAndProfile = await ctx
  .table("users")
  .map(async (user) => ({
    name: user.name,
    messages: await user.edge("messages"),
    profile: await user.edgeX("profile"),
  }));
This code is equivalent to this code using only built-in Convex and JavaScript functionality:
const usersWithMessagesAndProfile = await Promise.all(
  (await ctx.db.query("users").collect()).map(async (user) => ({
    name: user.name,
    posts: await ctx.db
      .query("messages")
      .withIndex("userId", (q) => q.eq("userId", user._id))
      .collect(),
    profile: (async () => {
      const profile = await ctx.db
        .query("profiles")
        .withIndex("userId", (q) => q.eq("userId", user._id))
        .unique();
      if (profile === null) {
        throw new Error(
          `Edge "profile" does not exist for document wit ID "${user._id}"`,
        );
      }
      return profile;
    })(),
  })),
);

Returning documents from functions

Consider the following query:

convex/messages.ts
import { query } from "./functions";
 
export const list = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.table("messages");
  },
});

It returns all ents stored in the messages table. Ents include methods which are not preserved when they are returned to the client. While this works fine at runtime, TypeScript will think that the methods are still available even on the client.

Note that it is generally not a good idea to return full documents directly to clients. If you add a new field to an ent, you might not want that field to be sent to clients. For this reason, and for backwards compatibility in general, it's a good idea to pick specifically which fields to send:

convex/messages.ts
import { query } from "./functions";
 
export const list = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.table("messages").map((message) => ({
      _id: message._id,
      _creationTime: message._creationTime,
      text: message.text,
      userId: message.userId,
    }));
  },
});

That said, you can easily retrieve raw documents, instead of ents with methods, from the database using the docs and doc methods:

convex/messages.ts
import { query } from "./functions";
 
export const list = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.table("messages").docs();
  },
});
convex/users.ts
import { query } from "./functions";
 
export const list = query({
  args: {},
  handler: async (ctx) => {
    const usersWithMessagesAndProfile = await ctx
      .table("users")
      .map(async (user) => ({
        ...user,
        messages: await user.edge("messages").docs(),
        profile: await user.edgeX("profile").doc(),
      }));
  },
});

You can also call the doc method on a retrieved ent, which returns the same object, but is typed as a plain Convex document.