Convex Ents is an ergonomic layer of the built-in Convex database API (ctx.db) that provides a more powerful and developer-friendly way to work with relational data. Ents (short for entities) allow you to explicitly declare relationships between documents, define default values, incorporate authorization rules, and much more.
⚠️ Note: Convex Ents is currently in maintenance mode. The Convex team will accept PRs and ensure it doesn't break, but there will not be active feature development.
Always define your Ent schema in convex/schema.ts. Unlike standard Convex schemas, Ent schemas require:
import { v } from "convex/values";
import { defineEnt, defineEntSchema, getEntDefinitions } from "convex-ents";
const schema = defineEntSchema({
// Table definitions using defineEnt()
});
export default schema;
// Must export entDefinitions for runtime use
export const entDefinitions = getEntDefinitions(schema);Define an Ent using defineEnt() instead of defineTable():
const schema = defineEntSchema({
users: defineEnt({
name: v.string(),
email: v.string(),
}),
messages: defineEnt({
text: v.string(),
}),
});Use the field() method with index: true to create a simple index:
users: defineEnt({}).field("email", v.string(), { index: true })This is equivalent to:
users: defineEnt({
email: v.string(),
}).index("email", ["email"])Use the unique: true option to enforce uniqueness:
users: defineEnt({}).field("email", v.string(), { unique: true })Define default values for fields to simplify schema evolution:
posts: defineEnt({}).field(
"contentType",
v.union(v.literal("text"), v.literal("video")),
{ default: "text" }
)Ents have a powerful feature called "edges" to represent relationships between documents. There are three main types of edges:
One-to-one relationships between two tables:
users: defineEnt({
name: v.string(),
}).edge("profile", { ref: true }),
profiles: defineEnt({
bio: v.string(),
}).edge("user"),One-to-many relationships (one document can be related to many documents):
users: defineEnt({
name: v.string(),
}).edges("messages", { ref: true }),
messages: defineEnt({
text: v.string(),
}).edge("user"),Many-to-many relationships (documents in both tables can be related to multiple documents in the other):
messages: defineEnt({
text: v.string(),
}).edges("tags"),
tags: defineEnt({
name: v.string(),
}).edges("messages"),For relationships within the same table:
// Asymmetrical (e.g., followers)
users: defineEnt({
name: v.string(),
}).edges("followers", { to: "users", inverse: "followees" }),
// Symmetrical (e.g., friends)
users: defineEnt({
name: v.string(),
}).edges("friends", { to: "users" }),Replace ctx.db.get() with the Ents equivalent:
// By ID
const task = await ctx.table("tasks").get(taskId);
// By indexed field
const user = await ctx.table("users").get("email", "user@example.com");
// Throw if not found
const task = await ctx.table("tasks").getX(taskId);// Get all documents from a table
const allUsers = await ctx.table("users");
// Filter by an index
const popularPosts = await ctx.table("posts", "numLikes", (q) =>
q.gt("numLikes", 100)
);
// Using search
const results = await ctx.table("posts").search("text", (q) =>
q.search("text", "search term").eq("type", "video")
);
// Using filter
const filteredPosts = await ctx.table("posts")
.filter((q) => q.gt(q.field("numLikes"), 100));// Order by _creationTime (default)
const newestFirst = await ctx.table("posts").order("desc");
// Order by a specific field
const mostLiked = await ctx.table("posts").order("desc", "numLikes");
// Limit results
const top5 = await ctx.table("users").take(5);
// Get first result
const latestUser = await ctx.table("users").order("desc").first();// 1:1 edge
const user = await ctx.table("profiles").getX(profileId).edge("user");
const profile = await ctx.table("users").getX(userId).edge("profile");
// 1:many edge
const messages = await ctx.table("users").getX(userId).edge("messages");
// many:many edge
const tags = await ctx.table("messages").getX(messageId).edge("tags");// Map ents to include related data
const usersWithMessages = await ctx.table("users").map(async (user) => {
return {
_id: user._id,
name: user.name,
messages: await user.edge("messages").take(5),
};
});// Insert a single ent
const taskId = await ctx.table("tasks").insert({ text: "New task" });
// Insert and get the ent
const task = await ctx.table("tasks").insert({ text: "New task" }).get();
// Insert multiple ents
const taskIds = await ctx.table("tasks").insertMany(
{ text: "Task 1" },
{ text: "Task 2" }
);// Update with patch (partial update)
await ctx.table("tasks").getX(taskId).patch({ text: "Updated text" });
// Replace (complete replacement)
await ctx.table("tasks").getX(taskId).replace({ text: "New version" });// Delete an ent
await ctx.table("tasks").getX(taskId).delete();// When inserting
const userId = await ctx.table("users").insert({ name: "Alice" });
const profileId = await ctx.table("profiles")
.insert({ bio: "Developer", userId });
// When updating
await ctx.table("profiles").getX(profileId).patch({ userId });// When inserting
const tagId = await ctx.table("tags").insert({ name: "Important" });
const messageId = await ctx.table("messages")
.insert({ text: "Hello", tags: [tagId] });
// Using replace (replaces all edges)
await ctx.table("messages").getX(messageId)
.replace({ text: "Updated", tags: [tagId, otherTagId] });
// Using patch (add/remove specific edges)
await ctx.table("messages").getX(messageId)
.patch({ tags: { add: [newTagId], remove: [oldTagId] } });You can define rules to control when ents can be read, created, updated, or deleted. This centralizes your authorization logic.
- Create a
rules.tsfile:
import { addEntRules } from "convex-ents";
import { entDefinitions } from "./schema";
import { QueryCtx } from "./types";
export function getEntDefinitionsWithRules(ctx: QueryCtx) {
return addEntRules(entDefinitions, {
tasks: {
read: async (task) => {
// Check if user can read this task
return ctx.viewer?._id === task.userId;
},
write: async ({ operation, ent, value }) => {
// Check if user can write/update/delete
if (operation === "create") {
return ctx.viewer !== null;
}
return ctx.viewer?._id === ent.userId;
}
}
});
}- Implement the viewer context in your function configuration.
tasks: {
read: async (task) => {
// Task can be read if its project can be read
const project = await task.edge("project");
return project !== null;
}
}documents: {
read: async (document) => {
// Document can be read by its owner or members of its team
if (ctx.viewerId === document.ownerId) return true;
const team = await document.edge("team");
return team !== null && await team.edge("members").has(ctx.viewerId);
}
}- When using
ctx.table().map(), always include proper type annotations for async callbacks. - For function returns, consider using the
.doc()method to convert ents to plain documents. - Use
Id<'tableName'>type for ID parameters to be explicit about which table they belong to.
Convex Ents provides elegant integration with Convex's file storage system, allowing you to create relationships between your Ents and stored files.
Define edges to the Convex system storage table (_storage) to create relationships between your Ents and files:
const schema = defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge("avatar", { to: "_storage", system: true }),
documents: defineEnt({
title: v.string(),
}).edges("attachments", { to: "_storage", system: true }),
});When working with files, it's helpful to define a type for file metadata:
type FileMetadata = {
_id: Id<"_storage">;
_creationTime: number;
contentType?: string;
sha256: string;
size: number;
};- Generate Upload URLs:
export const generateUploadUrl = mutation({
args: {
contentType: v.string(),
},
handler: async (ctx, args) => {
return await ctx.storage.generateUploadUrl(args.contentType);
},
});- Associate Files with Ents:
export const setUserAvatar = mutation({
args: {
fileId: v.id("_storage"),
},
handler: async (ctx, args) => {
const viewer = await ctx.viewerX();
// Set the avatar file for the user
await viewer.patch({ avatarId: args.fileId });
return viewer._id;
},
});- Associate Multiple Files with an Ent:
export const addDocumentAttachment = mutation({
args: {
documentId: v.id("documents"),
fileId: v.id("_storage"),
},
handler: async (ctx, args) => {
// Add file to the document's attachments
await ctx.table("documents").getX(args.documentId)
.patch({ attachments: { add: [args.fileId] } });
return null;
},
});- Get a Single File:
// Get user's avatar file
const avatarFile = await ctx.table("users").getX(userId).edge("avatar");- Get Multiple Files:
// Get all attachments for a document
const attachments = await ctx.table("documents").getX(docId).edge("attachments");- Generate Download URLs:
export const getUserAvatarUrl = query({
args: {
userId: v.id("users"),
},
handler: async (ctx, args) => {
const user = await ctx.table("users").getX(args.userId);
const avatarFile = await user.edge("avatar");
if (!avatarFile) {
return null;
}
// Generate a URL for downloading the file
return ctx.storage.getUrl(avatarFile._id);
},
});-
Verify File Metadata: Always check file size, type, and other metadata before allowing upload operations.
-
Clean up Unused Files: Implement a system to remove orphaned files.
-
Implement Access Control: Apply rules on both the Ent and its associated files:
documents: {
read: async (document) => {
return ctx.viewerId === document.ownerId;
}
},
// Rules also apply when accessing files through edges- Handle File Deletion Properly: When deleting Ents, consider whether associated files should also be deleted.
-
Use
.doc()for returning to clients: Convert ents to plain documents before returning them from functions.return await ctx.table("users").map(async (user) => ({ _id: user._id, name: user.name, profile: await user.edge("profile").doc() }));
-
Chain edge traversals efficiently: Use the fluent API to minimize database operations.
-
Centralize authorization logic: Define rules in one place instead of checking permissions in each function.
-
Use field defaults for schema evolution: When adding new fields, provide defaults to avoid breaking existing code.
-
For complex operations, consider transactions: Remember that each edge traversal is a separate database operation.
-
Prefer
ctx.tableoverctx.db: For better type safety and relationship handling. -
Use
skipRuleswith caution: When you need to bypass authorization rules, prefer returning IDs or plain documents.