Just-Use-Convex Docs
Relationships

Convex Ents Overview

Type-safe entity relationships for Convex

Convex Ents Overview

Convex Ents is a library that adds type-safe entity relationships to Convex, similar to an ORM but designed specifically for Convex's reactive model.

Why Convex Ents?

Without Ents (Manual Relationships)

// Query todos with assigned users - manual approach
const todos = await ctx.db.query("todos").collect();

// Manually fetch related data for each todo
const todosWithUsers = await Promise.all(
  todos.map(async (todo) => {
    const assignments = await ctx.db
      .query("todoAssignedUsers")
      .withIndex("todoId", (q) => q.eq("todoId", todo._id))
      .collect();

    const users = await Promise.all(
      assignments.map((a) => ctx.db.get(a.userId))
    );

    return { ...todo, assignedUsers: users };
  })
);

With Ents (Declarative Relationships)

// Same query with Ents
const todos = await ctx.table("todos").collect();

const todosWithUsers = await Promise.all(
  todos.map(async (todo) => ({
    ...todo,
    assignedUsers: await todo.edge("assignedUsers"),
  }))
);

Setting Up Ents

1. Define Entity Schema

// tables/todos.ts
import { defineEnt, defineEntSchema, getEntDefinitions } from "convex-ents";
import { v } from "convex/values";

const entSchema = defineEntSchema({
  todos: defineEnt({
    title: v.string(),
    status: v.union(
      v.literal("todo"),
      v.literal("in_progress"),
      v.literal("done")
    ),
    organizationId: v.string(),
    userId: v.string(),
  })
    .index("organizationId", ["organizationId"])
    .edges("todoAssignedUsers", { ref: true }),

  todoAssignedUsers: defineEnt({
    todoId: v.id("todos"),
    userId: v.string(),
    assignedAt: v.number(),
  })
    .index("todoId_userId", ["todoId", "userId"])
    .edge("todo"),
});

export const entDefinitions = getEntDefinitions(entSchema);

2. Create Table Factory

// functions.ts
import { entsTableFactory } from "convex-ents";
import { entDefinitions } from "./tables/todos";

export const zQuery = zCustomQuery(baseQuery, {
  input: async (ctx) => ({
    ctx: {
      ...ctx,
      table: entsTableFactory(ctx, entDefinitions),
    },
  }),
});

3. Use in Functions

export const list = zQuery({
  args: z.object({}),
  handler: async (ctx) => {
    // ctx.table provides Ents API
    return await ctx.table("todos").collect();
  },
});

Core Concepts

Tables

Access tables through ctx.table():

// Get all documents
const todos = await ctx.table("todos").collect();

// Get by ID
const todo = await ctx.table("todos").get(todoId);

// Query with index
const orgTodos = await ctx.table("todos", "organizationId", (q) =>
  q.eq("organizationId", orgId)
).collect();

Documents (Ents)

Each document returned by Ents has additional methods:

const todo = await ctx.table("todos").get(todoId);

// Navigate edges
const assignedUsers = await todo.edge("assignedUsers");

// Get document data
const { title, status } = todo;

// Get ID
const id = todo._id;

Edges

Edges define relationships between tables:

// One-to-many: Todo has many assigned users
.edges("todoAssignedUsers", { ref: true })

// Many-to-one: Assignment belongs to a todo
.edge("todo")

Table Operations

Insert

const todoId = await ctx.table("todos").insert({
  title: "New todo",
  status: "todo",
  organizationId: ctx.identity.activeOrganizationId,
  userId: ctx.identity.userId,
});

Patch (Update)

await ctx.table("todos").patch(todoId, {
  status: "done",
  updatedAt: Date.now(),
});

Delete

await ctx.table("todos").delete(todoId);

Get by ID

const todo = await ctx.table("todos").get(todoId);
if (!todo) throw new Error("Not found");

Get or Throw

// Throws if not found
const todo = await ctx.table("todos").getX(todoId);

Querying

Basic Query

const todos = await ctx.table("todos").collect();

With Index

const todos = await ctx.table("todos", "organizationId", (q) =>
  q.eq("organizationId", orgId)
).collect();

With Filter

const doneTodos = await ctx.table("todos")
  .filter((q) => q.eq(q.field("status"), "done"))
  .collect();

Order and Limit

const recentTodos = await ctx.table("todos")
  .order("desc") // by _creationTime
  .take(10);

First/Unique

// Get first match
const firstTodo = await ctx.table("todos", "organizationId", (q) =>
  q.eq("organizationId", orgId)
).first();

// Get unique (throws if multiple)
const uniqueTodo = await ctx.table("todos", "someUniqueIndex", (q) =>
  q.eq("uniqueField", value)
).unique();

Next, learn about Edges.