Instruction file imported from Omed0/convex-selfhost-starterkit (
.cursor/rules/convex-ents.mdc). Copyright stays with the author.
Convex Ents
Convex Ents are an ergonomic layer on top of the Convex
built-in ctx.db API for reading from and
writing to the database.
Convex Ents:
- Build upon the relational capabilities of the database to provide an easier way to query related documents.
- Allow defining default values for easier document shape evolution.
- Simplify backend code by collocating common authorization rules in a single place.
- And more!
Convex Ents provide similar capabilities to Prisma ORM, but target only Convex, and use neither proprietary schema language nor SQL concepts nor code generation. Convex Ents are a pure TypeScript/JavaScript library.
Examples
Check out these code snippets for comparison of Prisma, Convex and Convex Ents.
SaaS Starter is a full project template built out using Convex Ents.
<Tabs items={["Example query", "mutation", "schema"]}>
<Tabs.Tab>
export const listTeamInvites = query({
args: { teamId: v.id('teams') },
async handler(ctx, { teamId }) {
return await ctx
.table('teams')
.getX(teamId)
.edge('invites')
.map(async (invite) => ({
_id: invite._id,
email: invite.email,
role: (await invite.edge('role')).name,
})); // `{ _id: Id<"invites">, email: string, role: string }[]`
},
});
</Tabs.Tab> <Tabs.Tab>
export const acceptInvite = mutation({
args: { inviteId: v.id('invites') },
async handler(ctx, { inviteId }) {
const invite = await ctx.table('invites').getX(inviteId);
await ctx.table('member').insert({
teamId: invite.teamId,
userId: ctx.viewerId,
roleId: invite.roleId,
});
await invite.delete();
return (await invite.edge('team')).slug;
},
});
</Tabs.Tab> <Tabs.Tab>
const schema = defineEntSchema({
teams: defineEnt({
name: v.string(),
})
.field('slug', v.string(), { unique: true })
.edges('member', { ref: true })
.edges('invites', { ref: true }),
members: defineEnt({}).edge('team').edge('user').edge('role'),
invites: defineEnt({})
.field('email', v.string(), { unique: true })
.edge('team')
.edge('role'),
roles: defineEnt({
isDefault: v.boolean(),
}).field('name', v.union(v.literal('Admin'), v.literal('Member')), {
unique: true,
}),
users: defineEnt({}).edges('member', { ref: true }),
});
</Tabs.Tab>
I'm intrigued, what now?
Read the Ent Schema page to understand the improved data modeling that Convex Ents enable, and the Reading Ents and Writing Ents to see the more powerful interface to the database.
If you're sold, head over to Setup to get started.
Create a schema
Create your schema file, for example:
import { v } from 'convex/values';
import { defineEnt, defineEntSchema, getEntDefinitions } from 'convex-ents';
const schema = defineEntSchema({
messages: defineEnt({
text: v.string(),
})
.edge('user')
.edges('tags'),
users: defineEnt({
name: v.string(),
}).edges('messages', { ref: true }),
tags: defineEnt({
name: v.string(),
}).edges('messages'),
});
export default schema;
export const entDefinitions = getEntDefinitions(schema);
Modify your schema
Update your schema file. These changes will have no effect on your existing code
using ctx.db.
- Replace
defineSchemawithdefineEntSchema - Replace all
defineTablewithdefineEnt - Add
entDefinitionsas shown
Example changes:
- import { defineSchema, defineTable } from "convex/server";
+ import { defineEnt, defineEntSchema, getEntDefinitions } from "convex-ents";
import { v } from "convex/values";
- const schema = defineSchema({
+ const schema = defineEntSchema({
- messages: defineTable({
+ messages: defineEnt({
text: v.string(),
}),
- users: defineTable({
+ users: defineEnt({
name: v.string(),
}),
});
export default schema;
+ export const entDefinitions = getEntDefinitions(schema);
For more details on declaring the schema see Ent Schema.
Add helper types
Add a types.ts file with the following contents:
import { GenericEnt, GenericEntWriter } from 'convex-ents';
import { CustomCtx } from 'convex-helpers/server/customFunctions';
import { TableNames } from './_generated/dataModel';
import { mutation, query } from './functions';
import { entDefinitions } from './schema';
export type QueryCtx = CustomCtx<typeof query>;
export type MutationCtx = CustomCtx<typeof mutation>;
export type Ent<TableName extends TableNames> = GenericEnt<
typeof entDefinitions,
TableName
>;
export type EntWriter<TableName extends TableNames> = GenericEntWriter<
typeof entDefinitions,
TableName
>;
Use custom functions to read and write ents
You can now replace function constructors from _generated with the custom ones
you just set up. This will have no impact on your existing functions:
import { v } from "convex/values";
- import { mutation, query } from "./_generated/server";
+ import { mutation, query } from "./functions";
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("messages");
+ // You can now use ctx.table as well:
+ return await ctx.table("messages");
},
});
Similarly replace QueryCtx and MutationCtx with imports from ./types.
You can now use ctx.table for new code and to replace existing code.
- The
tableAPI comes with an additional level of security - The
tableAPI preserves invariants, such as:- fields having unique values
- 1:1 edges being unique on each end of the edge
- deleting ents deletes corresponding edges
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:
defineEntSchemareplacesdefineSchemafromconvex/serverdefineEntreplacesdefineTablefromconvex/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.
Ents add field configurations beyond vanilla Convex.
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:
defineEntSchema({
users: defineEnt({}).field('email', v.string(), { index: true }),
});
declares that "user" 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 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).
defineEntSchema({
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.
Adding fields to all union variants
If you use a union as an ent's schema, you can add a field to all the variants:
defineEntSchema({
posts: defineEnt(
v.union(
v.object({
type: v.literal('text'),
content: v.string(),
}),
v.object({
type: v.literal('video'),
link: v.string(),
})
)
).field('author', v.id('user')),
});
adds an author field to both text and video posts.
Edges
An edge is a representation of some business logic modeled by the database. Some examples are:
- User A liking post X
- User B authoring post Y
- User C is friends with user D
- Folder F is a child of folder G
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 "user" 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).
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', { ref: 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 "user" table's documents do not store the edge, because the
ref: true option specifies that the edge a "refers" to the field on the other
end of the edge.
The syntax shown is actually a shortcut for the following declaration:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge('profile', { to: 'profiles', ref: 'userId' }),
profiles: defineEnt({
bio: v.string(),
}).edge('user', { to: 'user', field: 'userId' }),
});
The available options are:
tois the table storing ents on the other end of the edge. It defaults to edge name suffixed withs(edgeprofile-> table"profiles"). You'll want to specify it when this simple pluralization doesn't work (like edgecategoryand table"categories").refsignifies that the edge is stored in a field on the other ent. It can either be the literaltrue, or the actual field's name. You must specify the name when you want to have another field edge between the same pair of tables.fieldis the name of the field that stores the foreign key. It defaults to the edge name suffixed withId(edgeuser-> fielduserId).
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 the field storing the edge optional with the optional option.
Shortcut syntax:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge('profile', { ref: true }),
profiles: defineEnt({
bio: v.string(),
}).edge('user', { field: 'userId', optional: true }),
});
You must specify the field name when using optional.
Fully specified:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge('profile', { to: 'profiles', ref: 'userId' }),
profiles: defineEnt({
bio: v.string(),
}).edge('user', { to: 'user', field: 'userId', optional: true }),
});
In this example a profile can be created without a set userId.
1:1 edges to system tables
You can connect ents to documents in system tables via 1:1 edges, see File Storage and Scheduled Functions for details.
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 "user"
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 example each user can
have multiple associated messages.
The syntax shown is actually a shortcut for the following declaration:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges('messages', { to: 'messages', ref: 'userId' }),
messages: defineEnt({
text: v.string(),
}).edge('user', { to: 'user', field: 'userId' }),
});
The available options are:
tois the table storing ents on the other end of the edge.- for the
edgesmethod, it defaults to the edge name (edgesmessages-> table"messages") - for the
edgemethod, it defaults to the edge name suffixed withs(edgeuser-> table"user"). - You'll need to specify
towhen the defaults don't match your table names.
- for the
refsignifies that the edge is stored in a field on the other ent. It can either be the literaltrue, or the actual field's name. You must specify the name when you want to have another field edge between the same pair of tables (to identify the inverse edge).fieldis the name of the field that stores the foreign key. It defaults to the edge name suffixed withId(edgeuser-> fielduserId).
Optional 1:many edges
You can make the field storing the edge optional with the optional option.
Shortcut syntax:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges('messages', { ref: true }),
messages: defineEnt({
text: v.string(),
}).edge('user', { field: 'userId', optional: true }),
});
You must specify the field name when using optional.
Fully specified:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edges('messages', { to: 'messages', ref: 'userId' }),
messages: defineEnt({
text: v.string(),
}).edge('user', { to: 'user', field: 'userId', optional: true }),
});
In this case a message can be created without a set userId.
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.
The syntax shown is actually a shortcut for the following declaration:
defineEntSchema({
messages: defineEnt({
name: v.string(),
}).edges('tags', {
to: 'tags',
table: 'tags_to_messages',
field: 'tagsId',
}),
tags: defineEnt({
text: v.string(),
}).edges('messages', {
to: 'messages',
table: 'tags_to_messages',
field: 'messagesId',
}),
});
The available options are:
tois the table storing ents on the other end of the edge. It defaults to the edge name (edgestags-> table"tags"). You can specify it if you want to call the edge something more specific.tableis the name of the table storing the edges. This table will have two ID fields, one for each end of the edge. You must specify the name when you want to have multiple different edges connecting the same pair of tables. These tables are only used by the framework under the hood, and won't appear in your code.fieldis the name of the field ontablethat stores the ID of the ent on this end of the edge. It defaults to the edge name with suffixed withId(edgetags-> fieldtagsIdstores the message id). These fields will only be used by the framework under the hood, and won't appear in your code.
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: 'user', 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: 'user' }),
});
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.
Helper Types
These types are helpful when you want to break down your backend code and pass IDs and ents around.
Ent ID type
Use the built-in Id type:
import { Id } from './_generated/dataModel';
// Note that a `MutationCtx` also satisfies the `QueryCtx` interface
export function myReadHelper(ctx: QueryCtx, id: Id<'tasks'>) {
/* ... */
}
Ent types & ctx types
import { Ent, EntWriter } from './shared/types';
export function myReadHelper(ctx: PublicQueryCtx, task: Ent<'tasks'>) {
/* ... */
}
export function myWriteHelper(
ctx: PublicMutationCtx,
task: EntWriter<'tasks'>
) {
/* ... */
}
Cascading Deletes
Convex Ents are designed to simplify the creation of interconnected graphs of documents in the database. Deleting ents connected through edges poses three main challenges:
-
Propagating deletion across edges. When an ent is required on one end of an edge, and it is deleted, the edge and potentially the ent on the other end must be deleted as well.
Example: Consider an app with "teams" of "user". When a team is deleted, its members, projects and other data belonging to the team should be deleted as well.
-
Handling the volume of deleted documents. It is not possible to instantly erase a very large number of documents, from any database. Eventually there can be too many documents to delete, especially inside a single transaction.
Example: A team can have thousands of members and tens of thousands of projects. These cannot all be deleted instantly.
-
Soft deleting and retaining data before final deletion. Often the data should not be immediately erased from the database.
Example: A team admin can delete a team, but you want to have the ability to easily reinstate their data, in case the admin changes their mind, or the request was fradulent.
Example: A user can leave a team and later rejoin it, reacquiring attribution to data that was previously connected to them.
Default deletion behavior
Without any additional configuration, ents and their edges are deleted immediately. We'll also refer to this as "hard" deleted.
If the edge is required, as is the case for 1:many and 1:1 edges for the ents storing the edge as a field, the ents on the other side of the edge are deleted as well.
The following scenarios are currently supported:
- 1:1 edge between ent A and ent B, ents A store the edge.
- When ent A is deleted, only it is deleted.
- When ent B is deleted, the ent A connected to it is deleted as well (which might cause more edge and ent deletions).
- 1:many edge between ent A and ent B, ents A store the edge.
- When ent A is deleted, only it is deleted.
- When ent B is deleted, ents A connected to it are deleted as well (which might cause more edge and ent deletions).
- many:many edge between ent A and ent B.
- When ent A is deleted, the documents storing the edges to ents B are all deleted, but ents B are not deleted.
- When ent B is deleted, the documents storing the edges to ents A are all deleted, but ents A are not deleted.
Overriding deletion direction for 1:1 edges
For 1:1 edges, you can additionally configure deletion to propagate from the ent
that stores the edge to the other ent, by setting the deletion option on the
edge declaration:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).edge('profile', { optional: true }),
profiles: defineEnt({
bio: v.string(),
}).edge('user', { deletion: 'hard' }),
});
In this example, when a user is deleted, their profile is deleted as well, which is the default behavior, but also when a profile is deleted, the user is deleted.
Soft deletion behavior
You can configure an ent to use the "soft" deletion behavior with the
deletion method in your schema:
defineEntSchema({
users: defineEnt({
name: v.string(),
}).deletion('soft'),
});
This behavior adds a deletionTime field to the ent. When the ent itself is
"deleted", via the delete method, the deletionTime field is set to the
current server time.
If the ent is being deleted as a result of cascading hard deletion, it is hard deleted.
Filtering soft deleted ents
You can include or exclude soft deleted ents from results by filtering:
const notDeletedUsers = await ctx
.table('user')
.filter((q) => q.eq(q.field('deletionTime'), undefined));
Undeleting soft deleted ents
The ents can be "undeleted" by unsetting the deletionTime field, for example:
await ctx.table('user').getX(userId).patch({ deletionTime: undefined });
Soft edge deletion
1:1 and 1:many edges
By default soft deletion doesn't propagate. You can configure cascading deletes
for soft deletions for individual 1:1 and 1:many edges via the deletion option
on edge declarations:
defineEntSchema({
users: defineEnt({
email: v.string(),
})
.deletion('soft')
.edges('profiles', { ref: true, deletion: 'soft' }),
profiles: defineEnt({
name: v.string(),
})
.deletion('soft')
.edge('user'),
});
The ent on the other end of the edge has to have the "soft" deletion behavior.
In this example, when a user is deleted, it is soft deleted, and all its profiles are also soft deleted immediately. When a profile itself is deleted, it is also only soft deleted.
Soft deletion of edges happens immediately, within the same transaction.
If an ent is connected to a large number of other ents, such that propagating soft deletion to them all could fail the mutation, you should instead filter out the soft deleted ents after traversing the edge.
many:many edges
Soft deletion doesn't affect many:many edges.
Scheduled deletion behavior
The scheduled deletion behavior expands on the soft deletion behavior. The ent is first immediately soft deleted. An actual hard deletion is then scheduled.
Additional configuration
To enable scheduled ent deletion you need to add two lines of code to your
functions.ts file:
// Add this import
import { scheduledDeleteFactory } from 'convex-ents';
// Add this export
export const scheduledDelete = scheduledDeleteFactory(entDefinitions);
This will expose an internal Convex mutation used by ctx.table when scheduling
deletions.
Expose the mutation somewhere in your convex folder, and its reference to the
factory function:
import { scheduledDeleteFactory } from 'convex-ents';
import { entDefinitions } from './schema';
export const myNameForScheduledDelete = scheduledDeleteFactory(entDefinitions, {
scheduledDelete: internal.someFileName.myNameForScheduledDelete,
});
Also pass the function's reference in functions.ts file, wherever you set up
your custom mutation and internalMutation function constructors:
export const mutation = customMutation(
baseMutation,
customCtx(async (ctx) => {
return {
table: entsTableFactory(ctx, entDefinitions, {
scheduledDelete: internal.someFileName.myNameForScheduledDelete,
}),
};
})
);
export const internalMutation = customMutation(
baseInternalMutation,
customCtx(async (ctx) => {
return {
table: entsTableFactory(ctx, entDefinitions, {
scheduledDelete: internal.someFileName.myNameForScheduledDelete,
}),
};
})
);
Defining scheduled deletion behavior
You can configure an ent to use the "scheduled" deletion behavior with the
deletion method in your schema:
defineEntSchema({
users: defineEnt({
email: v.string(),
})
.deletion('scheduled')
.edges('profiles', { ref: true }),
profiles: defineEnt({
name: v.string(),
}).edge('user'),
});
When the ent is deleted, it is first soft deleted. Soft edge deletion can apply as well. This all happens within the same mutation.
The hard deletion is scheduled to a separate mutation/mutations. Cascading deletes are performed first, then the ent itself is hard deleted. There is no guarantee on how long this can take, as it depends on the number of documents that need to be deleted to finish the cascading deletion.
The hard deletion can be delayed into the future with the delayMs option:
defineEntSchema({
users: defineEnt({
email: v.string(),
})
.deletion('scheduled', { delayMs: 24 * 60 * 60 * 1000 })
.edges('profiles', { ref: true }),
profiles: defineEnt({
name: v.string(),
}).edge('user'),
});
In this example the user ent is soft deleted first, then after 24 hours its profiles and the user itself are hard deleted.
The delay is only applied if the ent is itself being deleted, not when it is being deleted as a result of a cascading delete.
Canceling scheduled deletion
You can cancel a scheduled deletion by unsetting the deletionTime field, for
example:
await ctx.table('user').getX(userId).patch({ deletionTime: undefined });
Correctness
You should make sure that while a scheduled hard deletion is running, there are no new ents being inserted that would also be eligible for the same cascading deletion.
You can do this by checking for the soft deletion state of the deleted ent in your code.
Scheduled Functions
You can connect scheduled functions to your ents.
The basic way to do this is the same as in vanilla Convex, adding a scheduled function ID field:
defineEntSchema({
answers: defineEnt({
question: v.string(),
actionId: v.id('_scheduled_functions'),
}),
});
You can then retrieve the scheduled function status and cancel it. For example, to retrieve all answers with the status of their action:
return ctx.table('answers').map(async ({ question, actionId }) => ({
question,
status:
(await ctx.table('_scheduled_functions').get(actionId)?.state.kind) ??
'stale',
}));
Using edges for connecting scheduled functions
You can simplify the code above by declaring a 1:1 edge to scheduled functions:
defineEntSchema({
answers: defineEnt({
question: v.string(),
}).edge('action', { to: '_scheduled_functions' }),
});
The field name is derived from the edge name (here actionId), you can also
specify it with the field option.
You can then retrieve scheduled function status via the edge:
return ctx.table('answers').map(async (answer) => ({
question: answer.question,
status: (await answer.edge('action')?.state.kind) ?? 'stale',
}));
Automatic cancelation during cascading deletion
You can automatically cancel a connected scheduled function via the deletion
option:
defineEntSchema({
answers: defineEnt({
question: v.string(),
}).edge('action', { to: '_scheduled_functions', deletion: 'hard' }),
});
Only the "hard" value is valid, since there is no way to mark soft deletion on
a scheduled function.
In this example when an answer is deleted, its action, if pending or in-progress, is canceled.
Note that if you use ctx.storage.delete to delete a file that is referenced in
other ents, Ents will not cascade that deletion. Ideally do not use
ctx.storage.delete, or handle any other required deletions manually.
Rules
The ents in your database are only accessible via server-side functions, and so you can rely on their implementation to enforce authorization rules (also known as "row level security").
But you might have multiple functions accessing the same data, and you might be using the different methods provided by Convex Ents to access them:
- To read:
get,getX,edge,edgeX,unique,uniqueX,first,firstX,take, etc. - To write:
insert,insertMany,patch,replace,delete
Enforcing rules about when an ent can be read, created, updated or deleted at every callsite can be onerous and error-prone.
For this reason you can optionally define a set of "rules" implementations that
are automatically enforced by the ctx.table API. This is an advanced feature,
and so it requires a bit more setup.
Setup
Before setting up rules, make sure you understand how Convex Ents are configured via custom functions, see Configuring Functions.
Define your rules
Add a rules.ts file with the following contents:
import { addEntRules } from 'convex-ents';
import { entDefinitions } from './schema';
import { QueryCtx } from './types';
export function getEntDefinitionsWithRules(
ctx: QueryCtx
): typeof entDefinitions {
return addEntRules(entDefinitions, {
// "secrets" is one of our tables
secrets: {
read: async (secret) => {
// Example: Only the viewer can see their secret
return ctx.viewerId === secret.userId;
},
},
});
}
// Example: Retrieve viewer ID using `ctx.auth`:
export async function getViewerId(
ctx: Omit<QueryCtx, 'table' | 'viewerId' | 'viewer' | 'viewerX'>
): Promise<Id<'user'> | null> {
const user = await authComponent.safeGetAuthUser(ctx);
if (user === null) {
return null;
}
const viewer = await ctx.skipRules
.table('user')
.get('tokenIdentifier', user.tokenIdentifier);
return viewer?._id;
}
The rules are defined in the second argument to addEntRules, which takes
entDefinitions from our schema, adds any rules you specify and returns
augmented entDefinitions.
Authorization commonly has a concept of a viewer, although this is totally up to
your use case. The rules.ts file is a good place for defining how to retrieve
the viewer ID.
Apply rules
Replace your functions.ts file with the following code, which uses your
implementations from rules.ts:
export const query = customQuery(
baseQuery,
customCtx(async (baseCtx) => {
return await queryCtx(baseCtx);
})
);
export const internalQuery = customQuery(
baseInternalQuery,
customCtx(async (baseCtx) => {
return await queryCtx(baseCtx);
})
);
export const mutation = customMutation(
baseMutation,
customCtx(async (baseCtx) => {
return await mutationCtx(baseCtx);
})
);
export const internalMutation = customMutation(
baseInternalMutation,
customCtx(async (baseCtx) => {
return await mutationCtx(baseCtx);
})
);
async function queryCtx(baseCtx: QueryCtx) {
const ctx = {
db: baseCtx.db as unknown as undefined,
skipRules: { table: entsTableFactory(baseCtx, entDefinitions) },
};
const entDefinitionsWithRules = getEntDefinitionsWithRules(ctx as any);
const viewerId = await getViewerId({ ...baseCtx, ...ctx });
(ctx as any).viewerId = viewerId;
const table = entsTableFactory(baseCtx, entDefinitionsWithRules);
(ctx as any).table = table;
// Example: add `viewer` and `viewerX` helpers to `ctx`:
const viewer = async () =>
viewerId !== null ? await table('user').get(viewerId) : null;
(ctx as any).viewer = viewer;
const viewerX = async () => {
const ent = await viewer();
if (ent === null) {
throw new Error('Expected authenticated viewer');
}
return ent;
};
(ctx as any).viewerX = viewerX;
return { ...ctx, table, viewer, viewerX, viewerId };
}
async function mutationCtx(baseCtx: MutationCtx) {
const ctx = {
db: baseCtx.db as unknown as undefined,
skipRules: { table: entsTableFactory(baseCtx, entDefinitions) },
};
const entDefinitionsWithRules = getEntDefinitionsWithRules(ctx as any);
const viewerId = await getViewerId({ ...baseCtx, ...ctx });
(ctx as any).viewerId = viewerId;
const table = entsTableFactory(baseCtx, entDefinitionsWithRules);
(ctx as any).table = table;
// Example: add `viewer` and `viewerX` helpers to `ctx`:
const viewer = async () =>
viewerId !== null ? await table('user').get(viewerId) : null;
(ctx as any).viewer = viewer;
const viewerX = async () => {
const ent = await viewer();
if (ent === null) {
throw new Error('Expected authenticated viewer');
}
return ent;
};
(ctx as any).viewerX = viewerX;
return { ...ctx, table, viewer, viewerX, viewerId };
}
In this example we pulled out the logic for defining query and mutation ctx
into helper functions, so we don't have to duplicate the code between public and
internal constructors (but you can inline this code if you actually need
different setup for each).
The logic for setting up the query and mutation ctxs is the same, but we
define them separately to get the right types inferred by TypeScript.
// The `ctx` object is mutated as we build it out.
// It starts off with `ctx.skipRules.table`, a version `ctx.table`
// that doesn't use the rules we defined in `rules.ts`:
const ctx = {
db: undefined,
skipRules: { table: entsTableFactory(baseCtx, entDefinitions) },
};
// We bind our rule implementations to this `ctx` object:
const entDefinitionsWithRules = getEntDefinitionsWithRules(ctx as any);
// We retrieve the viewer ID, without using rules (as our rules
// depend on having the viewer loaded), and add it to `ctx`:
const viewerId = await getViewerId({ ...baseCtx, ...ctx });
(ctx as any).viewerId = viewerId;
// We get a `ctx.table` using rules and add it to `ctx`:
const table = entsTableFactory(baseCtx, entDefinitionsWithRules);
(ctx as any).table = table;
// As an example we define helpers that allow retrieving
// the viewer as an ent. These have to be functions, to allow
// our rule implementations to use them as well.
// Anything that we want our rule implementations to have
// access to has to be added to the `ctx`.
const viewer = async () =>
viewerId !== null ? await table('user').get(viewerId) : null;
(ctx as any).viewer = viewer;
const viewerX = async () => {
const ent = await viewer();
if (ent === null) {
throw new Error('Expected authenticated viewer');
}
return ent;
};
(ctx as any).viewerX = viewerX;
// Finally we again list everything we want our
// functions to have access to. We have to do this
// for TypeScript to correctly infer the `ctx` type.
return { ...ctx, table, viewer, viewerX, viewerId };
Read rules
For each table storing ents you can define a read rule implementation. The
implementation is given the ent that is being retrieved, and should return a
boolean of whether the ent is readable. This code runs before ents are
returned by ctx.table:
- If the retrieval method can return
null, and the rule returnsfalse, thennullis returned. Examples:get,first,uniqueetc. - If the retrieval method throws when the ent does not exist, it will also throw
when the ent cannot be read. Examples:
getX,firstX,uniqueX - If the retrieval method returns a list of ents, then any ents that cannot be
read will be filtered out.
- except for
getManyX, which will throw anError
- except for
Understanding read rules performance
A read rule is essentially a filter, performed in the Convex runtime running
your query or mutation. This means that adding a read rule to a table
fundamentally changes the way methods like first, unique and take are
implemented. These methods need to paginate through the underlying table (or
index range), on top of the scanning that is performed by the built-in db API.
You should be mindful of how many ents your read rules might filter out for a
given query.
Common read rule patterns
Delegating to another ent
Example: When the user connected to the profile can be read, the profile can be read:
return addEntRules(entDefinitions, {
profiles: {
read: async (profile) => {
return (await profile.edge('user')) !== null;
},
},
});
Watch out for infinite loops between read rules, and break them up using
ctx.skipRules.
Testing for an edge
Example: A user ent can be read when it is the viewer or when there is a
"friends" edge between the viewer and the user:
return addEntRules(entDefinitions, {
users: {
read: async (user) => {
return (
ctx.viewerId !== null &&
(ctx.viewerId === user._id ||
(await user.edge('friends').has(ctx.viewerId)))
);
},
},
});
Write rules
Write rules determine whether ents can be created, updated or deleted. They can
be specified using the write key:
return addEntRules(entDefinitions, {
// "secrets" is one of our tables
secrets: {
// Note: The read rule is always checked for existing ents
// for any updates or deletions
read: async (secret) => {
return ctx.viewerId === secret.userId;
},
write: async ({ operation, ent: secret, value }) => {
if (operation === 'delete') {
// Example: No one is allowed to delete secrets
return false;
}
if (operation === 'create') {
// Example: Only the viewer can create secrets
return ctx.viewerId === value.ownerId;
}
// Example: secret's user edge is immutable
return value.ownerId === undefined || value.ownerId === secret.ownerId;
},
},
});
If defined, the read rule is always checked first before ents are updated or
deleted.
The write rule is given an object with
operation, one of"create","update"or"delete"ent, the existing ent if this is an update or deletevalue, the value provided to.insert(),.replace()or.patch().
The methods insert, insertMany, patch, replace and delete throw an
Error if the write rule returns false.
Ignoring rules
Sometimes you might want to read from or write to the database without abiding
by the rules you defined. Perhaps you are running with ctx that isn't
authenticated, or your code needs to perform some operation on behalf of a user
who isn't the current viewer.
For this purpose the Setup section above defines
ctx.skipRules.table, which is a version of ctx.table that can read and write
to the database without checking the rules.
Remember that methods called on ents retrieved using ctx.skipRules.table
also ignore rules! For this reason it's best to return plain documents or IDs
when using ctx.skipRules.table:
// Avoid!!!
return await ctx.skipRules.table('foos').get(someId);
// Return an ID instead:
return (await ctx.skipRules.table('foos').get(someId))._id;
It is preferable to still use Convex Ents over using the built-in ctx.db API
for this purpose, to maintain invariants around edges and unique fields. See
Exposing built-in db.
import { Aside } from "../components/Aside.tsx";
Reading Ents from the Database
Convex Ents provide a ctx.table method which replaces the built-in ctx.db
object in Convex queries.
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 and
strict schema validation 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);
const id = ctx.db.normalize('tasks', taskId);
if (id === null) {
return null;
}
const task = await ctx.db.get(id);
with the addition of checking that taskId belongs to "tasks".
Reading a single ent by indexed field
const task = await ctx.table('user').get('email', 'steve@apple.com');
const task = await ctx.db
.query('user')
.withIndex('email', (q) => q.eq('email', 'steve@apple.com'))
.unique();
Reading a single ent via a compound index
const task = await ctx.table('user').get('nameAndRank', 'Steve', 10);
const task = await ctx.db
.query('user')
.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('user').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]);
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('user');
const tasks = await ctx.db.query('user').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)
);
const posts = await ctx.db
.query('posts')
.withIndex('numLikes', (q) => q.gt('numLikes', 100))
.collect();
Searching ents via text search
To use text search call the search method:
const awesomeVideoPosts = await ctx
.table('posts')
.search('text', (q) => q.search('text', 'awesome').eq('type', 'video'));
const awesomeVideoPosts = await ctx.db
.query('posts')
.withSearchIndex('text', (q) =>
q.search('text', 'awesome').eq('type', 'video')
)
.collect();
Filtering
Use the filter method, which works the same as the
built-in filter method:
const posts = await ctx
.table('posts')
.filter((q) => q.gt(q.field('numLikes'), 100));
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');
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');
const posts = await ctx
.table('posts')
.withIndex('numLikes')
.order('desc')
.collect();
Limiting results
Taking first n ents
const users = await ctx.table('user').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
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('user').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('user').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 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.
You might also access documents in system tables through edges, see File Storage and Scheduled Functions for details.
Traversing edges
Convex Ents allow easily traversing edges declared in the
schema with the edge method.
Traversing 1:1 edge
For 1:1 edges, the edge method returns:
- The ent required on the other end of the edge (ex: given a
profile, load theuser) - The ent or
nullon the optional end of the edge (ex: given auser, load theirprofile)
const user = await ctx.table('profiles').getX(profileId).edge('user');
const profileOrNull = await ctx.table('user').getX(userId).edge('profile');
const user = await ctx.db
.query('user')
.withIndex('profileId', (q) => q.eq('profileId', profileId))
.unique();
The edgeX method (pronounced "edge or throw") throws if the edge does not
exist:
const profile = await ctx.table('user').getX(userId).edgeX('profile');
Traversing 1:many edges
For 1:many edges, the edge method returns:
- The ent required on the other end of the edge (ex: given a
message, load theuser) - A list of ents on the end of the multiple edges (ex: given a
user, load all themessages)
const user = await ctx.table('message').getX(messageId).edge('user');
const messages = await ctx.table('user').getX(userId).edge('messages');
const messages = await ctx.db
.query('messages')
.withIndex('userId', (q) => q.eq('userId', userId))
.collect();
In the case where a list is returned, filtering, ordering and limiting results applies and can be used.
Traversing many:many edges
For many:many edges, the edge method returns a list of ents on the other end
of the multiple edges:
const tags = await ctx.table('messages').getX(messageId).edge('tags');
const messages = await ctx.table('tags').getX(tagId).edge('messages');
const tags = await Promise.all(
(
await ctx.db
.query('messages_to_tags')
.withIndex('messagesId', (q) => q.eq('messagesId', messageId))
.collect()
).map((edge) => ctx.db.get(edge.tagsId))
);
The results are ordered by _creationTime of the edge, from oldest created to
newest created. The order can be changed with the order method
and the results can be limited.
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);
const hasTag =
(await ctx.db
.query('messages_to_tags')
.withIndex('messagesId_tagsId', (q) =>
q.eq('messagesId', message._id).eq('tagsId', tagId)
)
.first()) !== null;
Retrieving related ents
Sometimes you want to retrieve both the ents from one table and the related ents from another table. Other database systems refer to these as a joins or nested reads.
Traversing edges from individual retrieved ents
All the methods for reading ents return the underlying document(s), enriched
with the edge and edgeX methods. This allows traversing edges after loading
the ent from the database:
const user = await ctx.table('user').firstX();
const profile = user.edgeX('profile');
Mapping ents to include edges
The map method can be used to perform arbitrary transformation for each
returned ent. It can be used to add related ents via the edge and edgeX
methods:
const usersWithMessages = await ctx.table('user').map(async (user) => {
return {
name: user.name,
messages: await user.edge('messages').take(5),
};
});
const usersWithProfileAndMessages = await ctx.table("user").map(async (user) => {
const [messages, profile] = await Promise.all([
profile: user.edgeX("profile"),
user.edge("messages").take(5),
])
return {
name: user.name,
profile,
messages,
};
});
const usersWithMessagesAndProfile = await Promise.all(
(await ctx.db.query('user').collect()).map(async (user) => {
const [posts, profile] = Promise.all([
ctx.db
.query('messages')
.withIndex('userId', (q) => q.eq('userId', user._id))
.collect(),
(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 with ID "${user._id}"`
);
}
return profile;
})(),
]);
return { name: user.name, posts, profile };
})
);
As shown in this example, map can be used to transform the results by
selecting or excluding the returned fields, limiting the related ents, or
requiring that related ents exist.
The map method can beused to load ents by traversing more than one edge:
const usersWithMessagesAndTags = await ctx.table('user').map(async (user) => ({
...user,
messages: await user.edge('messages').map(async (message) => ({
text: message.text,
tags: await message.edge('tags'),
})),
}));
Returning documents from functions
Consider the following query:
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:
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:
import { query } from './functions';
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.table('messages').docs();
},
});
impor
*Truncated - read the full file at https://github.com/Omed0/convex-selfhost-starterkit/blob/6acad06c917570aa9ef4c042a7ec23aa8ccfe192/.cursor/rules/convex-ents.mdc.*