Ent Schema
Ents (short for entity) are Convex documents, which allow explicitly declaring
edges (relationships) to other documents. The simplest ent has no fields besides
the built-in _id
and _creationTime
fields, and no declared edges. Ents can
contain all the same field types Convex documents can. Ents are stored in tables
in the Convex database.
Unlike bare documents, ents require a schema. Here's a minimal example of the
convex/schema.ts
file using Ents:
import { v } from "convex/values";
import { defineEnt, defineEntSchema, getEntDefinitions } from "convex-ents";
const schema = defineEntSchema({
messages: defineEnt({
text: v.string(),
}).edge("user"),
users: defineEnt({
name: v.string(),
}).edges("messages", { ref: true }),
});
export default schema;
export const entDefinitions = getEntDefinitions(schema);
Compared to a vanilla schema file (opens in a new tab):
defineEntSchema
replacesdefineSchema
fromconvex/server
defineEnt
replacesdefineTable
fromconvex/server
- Besides exporting the
schema
, which is used by Convex for schema validation, you also exportentDefinitions
, which include the runtime information needed to enable retrieving Ents via edges and other features.
Fields
An Ent field is a field in its backing document. Some types of edges add additional fields not directly specified in the schema.
For Ents fields can be further configured.
Indexed fields
defineEnt
provides a shortcut for declaring a field and a simple index over
the field. Indexes allow efficient point and range lookups and efficient sorting
of the results by the indexed field. The following schema:
defineSchema({
users: defineEnt({}).field("email", v.string(), { index: true }),
});
declares that "users" ents have one field, "email"
of type string
, and one
index called "email"
over the "email"
field. It is exactly equivalent to the
following schema:
defineEntSchema({
users: defineEnt({
email: v.string(),
}).index("email", ["email"]),
});
Unique fields
Similar to an indexed field, you can declare that a field must be unique in the table. This comes at a cost: On every write to the backing a table, it must be checked for an existing document with the same field value. Every unique field is also an indexed field (as the index is used for an efficient lookup).
defineSchema({
users: defineEnt({}).field("email", v.string(), { unique: true }),
});
Field defaults
When evolving a schema, especially in production, the simplest way to modify the
shape of documents in the database is to add an optional field. Having an
optional field means that your code either always has to handle the "missing"
value case (the value is undefined
), or you need to perform a careful
migration to backfill all the documents and set the field value.
Ents simplify this shape evolution by allowing to specify a default for a field. The following schema:
defineEntSchema({
posts: defineEnt({}).field(
"contentType",
v.union(v.literal("text"), v.literal("video")),
{ default: "text" },
),
});
declares that "posts" ents have one required field "contentType"
,
containing a string, either "text"
or "video"
. When the value is missing
from the backing document, the default value "text"
is returned. Without
specifying the default value, the schema could look like:
defineEntSchema({
posts: defineEnt({
contentType: v.optional(v.union(v.literal("text"), v.literal("video"))),
}),
});
but for this schema the contentType
field missing must be handled by the code
reading the ent.
Edges
An edge is a representation of some business logic modelled by the database. Some examples are:
- User A liking post X can be represented by an edge.
- User B authoring post Y can be represented by an edge.
- User C is friends with user D can be represented by an edge.
Every edge has two "ends", each being an Ent. Those Ents can be stored in the same or in 2 different tables. Edges can represent symmetrical relationships. The "friends with" edge is an example. If user C is friends with user D, then user D is friends with user C. Symmetrical edge only make sense if both ends of the edge point to Ents in the same table. Edges which are not symmetrical have two names, one for each direction. For the user liking a post example, one direction can be called "likedPosts" (from users to posts), the other "likers" (from posts to users).
Edges can also declare how many Ents can be connected through the same edge. For each end, there can be 0, 1 or many Ents connected to the same Ent on the other side of the edge. For example, a user (represented by an Ent) has a profile (represented by an Ent). In this case we call this a 1:1 edge. If we ask "how many profiles does a user X have?" the answer is always 1. Similarly there can be 1:many edges, such as a user with the messages they authored, when each message has only a single author; and many:many edges, such as messages to tags, where each message can have many tags, and each tag can be attached to many messages.
Now that you understand all the properties of edges, here's how you can declare them: Edges are always declared on the ents that constitute its ends. Let's take the example of users authoring messages:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges("messages", { ref: true }),
messages: defineEnt({
text: v.string(),
}).edge("user"),
});
In this example, the edge between "users" and "messages" is 1:many, each user can have many associated messages, but each message has only a single associated user. The syntax is explained below.
Understanding how edges are stored
To further understand the ways in which edges can be declared, we need to understand the difference in how they are stored. There are two ways edges are stored in the database:
- Field edges are stored as a single foreign key column in one of the two connected tables. All 1:1 and 1:many edges are field edges.
- Table edges are stored as documents in a separate table. All many:many edges are table edges.
In the example above the edge is stored as an Id<"users>
on the "messages"
document.
1:1 edges
1:1 edges are in a way a special case of 1:many edges. In Convex, one end of the
edge must be optional, because there is no way to "allocate" IDs before
documents are created (see
circular references (opens in a new tab)).
Here's a basic example of a 1:1 edge, defined for each ent using the edge
(singular) method:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge("profile", { optional: true }),
profiles: defineEnt({
bio: v.string(),
}).edge("user"),
});
In this case, each user can have 1 profile, and each profile must have 1
associated user. This is a field edge stored on the "profiles" table as a
foreign key. The table that stores the edge is determined by the
optional: true
option of the edge
method - it's the table where the edge is
not optional.
This is the shortest syntax for declaring edges: based on the edge name "user",
the table on the other end is "users" (s
is added) and the field storing the
edge is called "userId" (Id
is added).
You can control the name of the field. This becomes necessary if you want to have another field edge between the same pair of tables:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge("profile", { ref: "ownerId", optional: true }),
profiles: defineEnt({
bio: v.string(),
}).edge("user", { field: "ownerId" }),
});
You have to provide the field name on both ends of the edge (via the ref
and
field
options respectively).
You can also specify explicitly which table is at the other end of the edge via
the to
option. This way the edge name doesn't have match the table name (this
is helpful when the simple pluralization doesn't work, like edge "category" and
table "categories"):
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge("info", { to: "profiles", ref: "ownerId", optional: true }),
profiles: defineEnt({
bio: v.string(),
}).edge("owner", { to: "users", field: "ownerId" }),
});
The edge names are used when querying the edges, but they are not stored in the database (the field name is, as part of each document that stores its value).
Optional 1:1 edges
You can make both ends of a 1:1 edge optional:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge("profile", { optional: true }),
profiles: defineEnt({
bio: v.string(),
}).edge("user", { field: "userId", optional: true }),
});
In this case a profile can be created without a set userId
.
You have to set the field
option to determine which side of the edge stores
the edge.
1:many edges
1:many edges are very common, and map clearly to foreign keys. Take this
example, where the edge is defined via the edge
(singular) and edges
(plural) method:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges("messages", { ref: true }),
messages: defineEnt({
text: v.string(),
}).edge("user"),
});
This is a 1:many edge because the edges
(plural) method is used on the "users"
ent and the ref: true
option is specified. The ref
option declares that the
edges are stored on a field in the other table. In this case each user can have
multiple associated messages. Just like for 1:1 edges, based on the edge name
"user", the table on the other end is "users" (s
is added) and the field
storing the edge is called "userId" (Id
is added).
You can control the name of the field. This becomes necessary if you want to have another field edge between the same pair of tables:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges("messages", { ref: "authorId" }),
messages: defineEnt({
text: v.string(),
}).edge("user", { field: "authorId" }),
});
You can also specify explicitly which table is at the other end of the edge via
the to
option. This way the edge names don't have match the table name (this
is helpful when the simple pluralization doesn't work, like edge "category" and
table "categories"):
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges("authoredMessages", { to: "messages", ref: "authorId" }),
messages: defineEnt({
text: v.string(),
}).edge("author", { to: "users", field: "authorId" }),
});
Optional 1:many edges
You can make the foreign key optional:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges("messages", { ref: true }),
messages: defineEnt({
text: v.string(),
}).edge("user", { field: "userId", optional: true }),
});
In this case a message can be created without a set userId
.
You have to set the field
option to signify that the singular end of the edge
stores the foreign key.
many:many edges
Many:many edges are always stored in a separate table, and both ends of the edge
use the edges
(plural) method:
defineEntSchema({
messages: defineEnt({
name: v.string(),
}).edges("tags"),
tags: defineEnt({
text: v.string(),
}).edges("messages"),
});
In this case the table storing the edge is called messages_to_tags
, based on
the tables storing each end of the edge.
You can control the name of the table storing the edges. This becomes necessary if you want to have multiple edges connecting the same pair of tables:
defineEntSchema({
messages: defineEnt({
name: v.string(),
}).edges("tags", { table: "messages_to_assignedTags" }),
tags: defineEnt({
text: v.string(),
}).edges("messages", { table: "messages_to_assignedTags" }),
});
This table will have two ID fields, one for each end of the edge. Their names
will be inferred from the table names of the connected ents. You can specify
them explicitly using the field
option:
defineEntSchema({
messages: defineEnt({
name: v.string(),
}).edges("tags", { table: "messages_to_assignedTags", field: "messageId" }),
tags: defineEnt({
text: v.string(),
}).edges("messages", {
table: "messages_to_assignedTags",
field: "assignedTagId",
}),
});
You can also specify explicitly which table is at the other end of the edge via
the to
option. This way the edge names don't have to match the table name
(this is helpful when the simple pluralization doesn't work, like edge
"category" and table "categories"):
defineEntSchema({
messages: defineEnt({
name: v.string(),
}).edges("assignedTags", { to: "tags", table: "messages_to_assignedTags" }),
tags: defineEnt({
text: v.string(),
}).edges("taggedMessages", {
to: "messages",
table: "messages_to_assignedTags",
}),
});
Asymmetrical self-directed many:many edges
Self-directed edges have the ents on both ends of the edge stored in the same table.
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges("followers", { to: "users", inverse: "followees" }),
});
Self-directed edges point to the same table on which they are defined via the
to
option. For the edge to be asymmetrical, it has to specify the inverse
name. In this example, if this edge is between user A and user B, B is a
"followee" of A (is being followed by A), and A is a "follower" of B.
The table storing the edges is named after the edges, and so are its fields. You
can also specify the table
, field
and inverseField
options to control how
the edge is stored and to allow multiple self-directed edges.
Symmetrical self-directed many:many edges
Symmetrical edges also have the ents on both ends of the edge stored in the same table, but additionally they "double-write" the edge for both directions:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges("friends", { to: "users" }),
});
By not specifying the inverse
name, you're declaring the edge as symmetrical.
You can also specify the table
, field
and inverseField
options to control
how the edge is stored and to allow multiple symmetrical self-directed edges.
Other kinds of edges are possible, but less common.
Rules
Rules allow collocating the logic for when an ent can be created, read, updated or deleted.
See the Rules page.