Created
July 27, 2025 13:03
-
-
Save zbeyens/ee0992c8539c63c632fd37f1b049c1bb to your computer and use it in GitHub Desktop.
convex-ents.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Convex Ents | |
Convex Ents are an ergonomic layer on top of the [Convex](https://convex.dev) | |
built-in [`ctx.db`](https://docs.convex.dev/database) API for reading from and | |
writing to the database. | |
Convex Ents: | |
1. Build upon the relational capabilities of the database to provide an easier | |
way to query related documents. | |
2. Allow defining default values for easier document shape evolution. | |
3. Simplify backend code by collocating common authorization rules in a single | |
place. | |
4. And more! | |
Convex Ents provide similar capabilities to | |
[Prisma ORM](https://www.prisma.io/client), 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](https://labs.convex.dev/convex-vs-prisma) for | |
comparison of Prisma, Convex and Convex Ents. | |
[SaaS Starter](https://github.com/xixixao/saas-starter) is a full project | |
template built out using Convex Ents. | |
<Tabs items={["Example query", "mutation", "schema"]}> | |
<Tabs.Tab> | |
```ts filename="convex/teams.ts" | |
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> | |
```ts filename="convex/teams.ts" | |
export const acceptInvite = mutation({ | |
args: { inviteId: v.id('invites') }, | |
async handler(ctx, { inviteId }) { | |
const invite = await ctx.table('invites').getX(inviteId); | |
await ctx.table('members').insert({ | |
teamId: invite.teamId, | |
userId: ctx.viewerId, | |
roleId: invite.roleId, | |
}); | |
await invite.delete(); | |
return (await invite.edge('team')).slug; | |
}, | |
}); | |
``` | |
</Tabs.Tab> | |
<Tabs.Tab> | |
```ts filename="convex/schema.ts" | |
const schema = defineEntSchema({ | |
teams: defineEnt({ | |
name: v.string(), | |
}) | |
.field('slug', v.string(), { unique: true }) | |
.edges('members', { 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('members', { ref: true }), | |
}); | |
``` | |
</Tabs.Tab> | |
</Tabs> | |
## I'm intrigued, what now? | |
Read the [Ent Schema](/schema) page to understand the improved data modeling | |
that Convex Ents enable, and the [Reading Ents](/read) and | |
[Writing Ents](/write) to see the more powerful interface to the database. | |
If you're sold, head over to [Setup](/setup) to get started. | |
### Create a schema | |
Create your schema file, for example: | |
```ts filename="convex/schema.ts" | |
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 `defineSchema` with `defineEntSchema` | |
- Replace all `defineTable` with `defineEnt` | |
- Add `entDefinitions` as shown | |
Example changes: | |
```diff filename="convex/schema.ts" | |
- 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](/schema). | |
### Add helper types | |
Add a `types.ts` file with the following contents: | |
<Aside title="Click to show"> | |
```ts filename="convex/types.ts" | |
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 | |
>; | |
``` | |
</Aside> | |
### 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: | |
```diff filename="convex/messages.ts" | |
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. | |
1. The `table` API comes with an [additional level of security](/read#security) | |
2. The `table` API 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: | |
```ts filename="convex/schema.ts" | |
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](https://docs.convex.dev/database/schemas): | |
- `defineEntSchema` replaces `defineSchema` from `convex/server` | |
- `defineEnt` replaces `defineTable` from `convex/server` | |
- Besides exporting the `schema`, which is used by Convex for schema validation, | |
you also export `entDefinitions`, 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](#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: | |
```ts | |
defineEntSchema({ | |
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: | |
```ts | |
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). | |
```ts | |
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: | |
```ts | |
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: | |
```ts | |
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](https://docs.convex.dev/database/schemas#unions) as an | |
ent's schema, you can add a field to all the variants: | |
```ts | |
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('users')), | |
}); | |
``` | |
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: | |
```ts | |
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: | |
1. _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. | |
2. _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](https://docs.convex.dev/database/schemas#circular-references)). | |
Here's a basic example of a 1:1 edge, defined for each ent using the `edge` | |
(singular) method: | |
<Graphic src={edge} dark={edgeDark} alt="1:1 edges pictogram" /> | |
```ts | |
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 "users" 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: | |
```ts | |
defineEntSchema({ | |
users: defineEnt({ | |
name: v.string(), | |
}).edge('profile', { to: 'profiles', ref: 'userId' }), | |
profiles: defineEnt({ | |
bio: v.string(), | |
}).edge('user', { to: 'users', field: 'userId' }), | |
}); | |
``` | |
The available options are: | |
- `to` is the table storing ents on the other end of the edge. It defaults to | |
edge name suffixed with `s` (edge `profile` -> table `"profiles"`). You'll | |
want to specify it when this simple pluralization doesn't work (like edge | |
`category` and table `"categories"`). | |
- `ref` signifies that the edge is stored in a field on the other ent. It can | |
either be the literal `true`, 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. | |
- `field` is the name of the field that stores the foreign key. It defaults to | |
the edge name suffixed with `Id` (edge `user` -> field `userId`). | |
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: | |
```ts | |
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: | |
```ts | |
defineEntSchema({ | |
users: defineEnt({ | |
name: v.string(), | |
}).edge('profile', { to: 'profiles', ref: 'userId' }), | |
profiles: defineEnt({ | |
bio: v.string(), | |
}).edge('user', { to: 'users', 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](/schema/files) and [Scheduled Functions](/schema/schedule) 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: | |
```ts | |
defineEntSchema({ | |
users: defineEnt({ | |
name: v.string(), | |
}).edges('messages', { ref: true }), | |
messages: defineEnt({ | |
text: v.string(), | |
}).edge('user'), | |
}); | |
``` | |
<Graphic src={edgesRef} dark={edgesRefDark} alt="1:many edge pictogram" /> | |
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 example each user can | |
have multiple associated messages. | |
The syntax shown is actually a shortcut for the following declaration: | |
```ts | |
defineEntSchema({ | |
users: defineEnt({ | |
name: v.string(), | |
}).edges('messages', { to: 'messages', ref: 'userId' }), | |
messages: defineEnt({ | |
text: v.string(), | |
}).edge('user', { to: 'users', field: 'userId' }), | |
}); | |
``` | |
The available options are: | |
- `to` is the table storing ents on the other end of the edge. | |
- for the `edges` method, it defaults to the edge name (edges `messages` -> | |
table `"messages"`) | |
- for the `edge` method, it defaults to the edge name suffixed with `s` (edge | |
`user` -> table `"users"`). | |
- You'll need to specify `to` when the defaults don't match your table names. | |
- `ref` signifies that the edge is stored in a field on the other ent. It can | |
either be the literal `true`, 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). | |
- `field` is the name of the field that stores the foreign key. It defaults to | |
the edge name suffixed with `Id` (edge `user` -> field `userId`). | |
#### Optional 1:many edges | |
You can make the field storing the edge optional with the `optional` option. | |
Shortcut syntax: | |
```ts | |
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: | |
```ts | |
defineEntSchema({ | |
users: defineEnt({ | |
name: v.string(), | |
}).edges('messages', { to: 'messages', ref: 'userId' }), | |
messages: defineEnt({ | |
text: v.string(), | |
}).edge('user', { to: 'users', 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: | |
```ts | |
defineEntSchema({ | |
messages: defineEnt({ | |
name: v.string(), | |
}).edges('tags'), | |
tags: defineEnt({ | |
text: v.string(), | |
}).edges('messages'), | |
}); | |
``` | |
<Graphic src={edges} dark={edgesDark} alt="many:many edge pictogram" /> | |
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: | |
```ts | |
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: | |
- `to` is the table storing ents on the other end of the edge. It defaults to | |
the edge name (edges `tags` -> table `"tags"`). You can specify it if you want | |
to call the edge something more specific. | |
- `table` is 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. | |
- `field` is the name of the field on `table` that stores the ID of the ent on | |
this end of the edge. It defaults to the edge name with suffixed with `Id` | |
(edge `tags` -> field `tagsId`). 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. | |
```ts | |
defineEntSchema({ | |
users: defineEnt({ | |
name: v.string(), | |
}).edges('followers', { to: 'users', inverse: 'followees' }), | |
}); | |
``` | |
<Graphic | |
src={edgesSelfAsymmetric} | |
dark={edgesSelfAsymmetricDark} | |
alt="many:many edge pictogram" | |
/> | |
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: | |
```ts | |
defineEntSchema({ | |
users: defineEnt({ | |
name: v.string(), | |
}).edges('friends', { to: 'users' }), | |
}); | |
``` | |
<Graphic | |
src={edgesSelfSymmetric} | |
dark={edgesSelfSymmetricDark} | |
alt="many:many edge pictogram" | |
/> | |
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](/schema/rules). | |
# 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: | |
```ts filename=convex/myFunctions.ts | |
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 | |
```ts filename=convex/myFunctions.ts | |
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: | |
1. 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 "users". When a team is deleted, | |
> its members, projects and other data belonging to the team should be | |
> deleted as well. | |
2. 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. | |
3. 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](/schema#understanding-how-edges-are-stored), | |
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: | |
```ts | |
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: | |
```ts | |
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: | |
```ts | |
const notDeletedUsers = await ctx | |
.table('users') | |
.filter((q) => q.eq(q.field('deletionTime'), undefined)); | |
``` | |
### Undeleting soft deleted ents | |
The ents can be "undeleted" by unsetting the `deletionTime` field, for example: | |
```ts | |
await ctx.table('users').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: | |
```ts | |
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. | |
<Aside title="What if an ent has too many 1:many edges to soft delete immediately?"> | |
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. | |
</Aside> | |
#### 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](/setup/config): | |
```ts | |
// 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. | |
<Aside title="Alternatively, you can configure the mutation explicitly"> | |
Expose the mutation somewhere in your `convex` folder, and its reference to the | |
factory function: | |
```ts filename="convex/someFileName.ts" | |
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: | |
```ts filename="convex/functions.ts" {14, 40-42, 52-54} | |
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, | |
}), | |
}; | |
}) | |
); | |
``` | |
</Aside> | |
### Defining scheduled deletion behavior | |
You can configure an ent to use the `"scheduled"` deletion behavior with the | |
`deletion` method in your schema: | |
```ts | |
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](#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: | |
```ts | |
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: | |
```ts | |
await ctx.table('users').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](https://docs.convex.dev/scheduling/scheduled-functions) to | |
your ents. | |
The basic way to do this is the same as in vanilla Convex, adding a scheduled | |
function ID field: | |
```ts | |
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: | |
```ts | |
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: | |
```ts | |
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: | |
```ts | |
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: | |
```ts | |
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](/setup/config). | |
<Steps> | |
### Define your rules | |
Add a `rules.ts` file with the following contents: | |
```ts filename="convex/rules.ts" {8-16} | |
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<'users'> | null> { | |
const user = await ctx.auth.getUserIdentity(); | |
if (user === null) { | |
return null; | |
} | |
const viewer = await ctx.skipRules | |
.table('users') | |
.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`: | |
```ts filename="convex/functions.ts" {15} | |
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('users').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('users').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 `ctx`s is the same, but we | |
define them separately to get the right types inferred by TypeScript. | |
<Aside title="Here's an annotated version of the code with explanation of each step:"> | |
```ts | |
// 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('users').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 }; | |
``` | |
</Aside> | |
</Steps> | |
## 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 returns `false`, then | |
`null` is returned. Examples: `get`, `first`, `unique` etc. | |
- 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 an `Error` | |
### 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. | |
<Aside title="How exactly do `first`, `unique` and `take` paginate?"> | |
The methods first try to load the requested number of ents (`1`, `2` or `n` | |
respectively). If the ents loaded first get filtered out, the method loads 2 | |
times more documents, performs the filtering, and if again there aren't enough | |
ents, it doubles the number again, and so on, for a maximum of 64 ents being | |
evaluated at a time. | |
</Aside> | |
### Common read rule patterns | |
#### Delegating to another ent | |
Example: _When the user connected to the profile can be read, the profile can be | |
read_: | |
```ts | |
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_: | |
```ts | |
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: | |
```ts | |
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 delete | |
- `value`, 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](#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`: | |
```ts | |
// 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`](/setup/config#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](https://docs.convex.dev/functions/query-functions). | |
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](https://docs.convex.dev/functions/args-validation) and | |
[strict schema validation](https://docs.convex.dev/database/schemas) 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`](/setup/config) object. | |
## Reading a single ent by ID | |
```ts | |
const task = await ctx.table('tasks').get(taskId); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
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"`. | |
</Aside> | |
## Reading a single ent by indexed field | |
```ts | |
const task = await ctx.table('users').get('email', '[email protected]'); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const task = await ctx.db | |
.query('users') | |
.withIndex('email', (q) => q.eq('email', '[email protected]')) | |
.unique(); | |
``` | |
</Aside> | |
## Reading a single ent via a compound index | |
```ts | |
const task = await ctx.table('users').get('nameAndRank', 'Steve', 10); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const task = await ctx.db | |
.query('users') | |
.withIndex('nameAndRank', (q) => q.eq('name', 'Steve').eq('rank', 10)) | |
.unique(); | |
``` | |
</Aside> | |
## Reading a single ent or throwing | |
The `getX` method (pronounced "get or throw") throws an `Error` if the read | |
produced no ents: | |
```ts | |
const task = await ctx.table('tasks').getX(taskId); | |
``` | |
```ts | |
const task = await ctx.table('users').getX('email', '[email protected]'); | |
``` | |
## Reading multiple ents by IDs | |
Retrieve a list of ents or nulls: | |
```ts | |
const tasks = await ctx.table('tasks').getMany([taskId1, taskId2]); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const task = await Promise.all([taskId1, taskId2].map(ctx.db.get)); | |
``` | |
</Aside> | |
## 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: | |
```ts | |
const tasks = await ctx.table('tasks').getManyX([taskId1, taskId2]); | |
``` | |
Also throws if any of the ents fail a [read rule](/schema/rules). | |
## Listing all ents | |
To list all ents backed by a single table, simply `await` the `ctx.table` call: | |
```ts | |
const tasks = await ctx.table('users'); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const tasks = await ctx.db.query('users').collect(); | |
``` | |
</Aside> | |
## 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`: | |
```ts | |
const posts = await ctx.table('posts', 'numLikes', (q) => | |
q.gt('numLikes', 100) | |
); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const posts = await ctx.db | |
.query('posts') | |
.withIndex('numLikes', (q) => q.gt('numLikes', 100)) | |
.collect(); | |
``` | |
</Aside> | |
## Searching ents via text search | |
To use [text search](https://docs.convex.dev/search) call the `search` method: | |
```ts | |
const awesomeVideoPosts = await ctx | |
.table('posts') | |
.search('text', (q) => q.search('text', 'awesome').eq('type', 'video')); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const awesomeVideoPosts = await ctx.db | |
.query('posts') | |
.withSearchIndex('text', (q) => | |
q.search('text', 'awesome').eq('type', 'video') | |
) | |
.collect(); | |
``` | |
</Aside> | |
## Filtering | |
Use the `filter` method, which works the same as the | |
[built-in `filter` method](https://docs.convex.dev/database/reading-data#filtering): | |
```ts | |
const posts = await ctx | |
.table('posts') | |
.filter((q) => q.gt(q.field('numLikes'), 100)); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const posts = await ctx.db | |
.query('posts') | |
.filter((q) => q.gt(q.field('numLikes'), 100)) | |
.collect(); | |
``` | |
</Aside> | |
## Ordering | |
Use the `order` method. The default sort is by `_creationTime` in ascending | |
order (from oldest created to newest created): | |
```ts | |
const posts = await ctx.table('posts').order('desc'); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const posts = await ctx.db.query('posts').order('desc').collect(); | |
``` | |
</Aside> | |
## Ordering by index | |
The `order` method takes an index name as a shortcut to sorting by a given | |
index: | |
```ts | |
const posts = await ctx.table('posts').order('desc', 'numLikes'); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const posts = await ctx | |
.table('posts') | |
.withIndex('numLikes') | |
.order('desc') | |
.collect(); | |
``` | |
</Aside> | |
## Limiting results | |
### Taking first `n` ents | |
```ts | |
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](https://docs.convex.dev/database/pagination) | |
method. | |
```ts | |
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`. | |
```ts | |
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: | |
```ts | |
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: | |
```ts | |
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: | |
```ts | |
const counter = await ctx.table('counters').uniqueX(); | |
``` | |
## Accessing system tables | |
[System tables](https://docs.convex.dev/database/advanced/system-tables) can be | |
read with the `ctx.table.system` method: | |
```ts | |
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](/schema/files) and [Scheduled Functions](/schema/schedule) for | |
details. | |
## Traversing edges | |
Convex Ents allow easily traversing edges declared in the | |
[schema](/schema#edges) 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 the | |
`user`) | |
- The ent or `null` on the optional end of the edge (ex: given a `user`, load | |
their `profile`) | |
```ts | |
const user = await ctx.table('profiles').getX(profileId).edge('user'); | |
const profileOrNull = await ctx.table('users').getX(userId).edge('profile'); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const user = await ctx.db | |
.query('users') | |
.withIndex('profileId', (q) => q.eq('profileId', profileId)) | |
.unique(); | |
``` | |
</Aside> | |
The `edgeX` method (pronounced "edge or throw") throws if the edge does not | |
exist: | |
```ts | |
const profile = await ctx.table('users').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 the | |
`user`) | |
- A list of ents on the end of the multiple edges (ex: given a `user`, load all | |
the `messages`) | |
```ts | |
const user = await ctx.table('message').getX(messageId).edge('user'); | |
const messages = await ctx.table('users').getX(userId).edge('messages'); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const messages = await ctx.db | |
.query('messages') | |
.withIndex('userId', (q) => q.eq('userId', userId)) | |
.collect(); | |
``` | |
</Aside> | |
In the case where a list is returned, [filtering](#filtering), | |
[ordering](#ordering) and [limiting results](#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: | |
```ts | |
const tags = await ctx.table('messages').getX(messageId).edge('tags'); | |
const messages = await ctx.table('tags').getX(tagId).edge('messages'); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
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)) | |
); | |
``` | |
</Aside> | |
The results are ordered by `_creationTime` of the edge, from oldest created to | |
newest created. The order can be changed with the [`order` method](#ordering) | |
and [the results can be limited](#limiting-results). | |
### Testing many:many edge presence | |
You can efficiently test whether two ents are connected by a many:many edge | |
using the `has` method: | |
```ts | |
const hasTag = ctx.table('messages').getX(messageId).edge('tags').has(tagId); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const hasTag = | |
(await ctx.db | |
.query('messages_to_tags') | |
.withIndex('messagesId_tagsId', (q) => | |
q.eq('messagesId', message._id).eq('tagsId', tagId) | |
) | |
.first()) !== null; | |
``` | |
</Aside> | |
## 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: | |
```ts | |
const user = await ctx.table('users').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: | |
```ts | |
const usersWithMessages = await ctx.table('users').map(async (user) => { | |
return { | |
name: user.name, | |
messages: await user.edge('messages').take(5), | |
}; | |
}); | |
``` | |
```ts | |
const usersWithProfileAndMessages = await ctx.table("users").map(async (user) => { | |
const [messages, profile] = await Promise.all([ | |
profile: user.edgeX("profile"), | |
user.edge("messages").take(5), | |
]) | |
return { | |
name: user.name, | |
profile, | |
messages, | |
}; | |
}); | |
``` | |
<Aside title="This code is equivalent to this code using only built-in Convex and JavaScript | |
functionality:"> | |
```ts | |
const usersWithMessagesAndProfile = await Promise.all( | |
(await ctx.db.query('users').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 }; | |
}) | |
); | |
``` | |
</Aside> | |
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: | |
```ts | |
const usersWithMessagesAndTags = await ctx.table('users').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: | |
```ts filename="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: | |
```ts filename="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: | |
```ts filename="convex/messages.ts" | |
import { query } from './functions'; | |
export const list = query({ | |
args: {}, | |
handler: async (ctx) => { | |
return await ctx.table('messages').docs(); | |
}, | |
}); | |
``` | |
```ts filename="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, | |
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. | |
import { Aside } from "../components/Aside.tsx"; | |
# Writing Ents to the Database | |
Just like for [reading](/read) ents from the database, for writing Convex Ents | |
provide a `ctx.table` method which replaces the built-in `ctx.db` object in | |
[Convex mutations](https://docs.convex.dev/functions/mutation-functions). | |
## Security | |
The same [added level of security](/read#security) applies to the writing ents | |
as it does to reading them. | |
## Inserting a new ent | |
You can insert new ents into the database with the `insert` method chained to | |
the result of calling `ctx.table`: | |
```ts | |
const taskId = await ctx.table('tasks').insert({ text: 'Win at life' }); | |
``` | |
You can retrieve the just created ent with the `get` method: | |
```ts | |
const task = await ctx.table('tasks').insert({ text: 'Win at life' }).get(); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const taskId = await ctx.db.insert('tasks', { text: 'Win at life' }); | |
const task = (await ctx.db.get(taskId))!; | |
``` | |
</Aside> | |
## Inserting many new ents | |
```ts | |
const taskIds = await ctx | |
.table('tasks') | |
.insertMany([{ text: 'Buy socks' }, { text: 'Buy shoes' }]); | |
``` | |
## Updating existing ents | |
To update an existing ent, call the `patch` or `replace` method on a | |
[lazy `Promise`](/read) of an ent, or on an already retrieved ent: | |
```ts | |
await ctx.table('tasks').getX(taskId).patch({ text: 'Changed text' }); | |
await ctx.table('tasks').getX(taskId).replace({ text: 'Changed text' }); | |
``` | |
```ts | |
const task = await ctx.table('tasks').getX(taskId); | |
await task.patch({ text: 'Changed text' }); | |
await task.replace({ text: 'Changed text' }); | |
``` | |
See the | |
[docs for the built-in `patch` and `replace` methods](https://docs.convex.dev/database/writing-data#updating-existing-documents) | |
for the difference between them. | |
## Deleting ents | |
To delete an ent, call the `delete` method on a [lazy `Promise`](/read) of an | |
ent, or on an already retrieved ent: | |
```ts | |
await ctx.table('tasks').getX(taskId).delete(); | |
``` | |
```ts | |
const task = await ctx.table('tasks').getX(taskId); | |
await task.delete(); | |
``` | |
### Cascading deletes | |
See the [Cascading Deletes](/schema/deletes) page for how to configure how | |
deleting an ent affects its edges and other ents connected to it. | |
## Writing edges | |
Edges can be created together with ents using the `insert` and `insertMany` | |
methods, or they can be created and deleted for two existing ents using the | |
`replace` and `patch` methods. | |
### Writing 1:1 and 1:many edges | |
A 1:1 or 1:many edge can be created by specifying the ID of the other ent on | |
[the ent which stores the edge](/schema#understanding-how-edges-are-stored), | |
either when inserting: | |
```ts | |
// First we need a user, which can have an optional profile edge | |
const userId = await ctx.table('users').insert({ name: 'Alice' }); | |
// Now we can create a profile with the 1:1 edge to the user | |
const profileId = await ctx | |
.table('profiles') | |
.insert({ bio: 'In Wonderland', userId }); | |
``` | |
or when updating: | |
```ts | |
const profileId = await ctx.table('profiles').getX(profileId).patch({ userId }); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const posts = await ctx.db.patch(profileId, { userId }); | |
``` | |
with the addition of checking that `profileId` belongs to `"profiles"`. | |
</Aside> | |
### Writing many:many edges | |
Many:many edges can be created by listing the IDs of the other ents when | |
inserting ents on either side of the edge: | |
```ts | |
// First we need a tag, which can have many:many edge to messages | |
const tagId = await ctx.table('tags').insert({ name: 'Blue' }); | |
// Now we can create a message with a many:many edge to the tag | |
const messageId = await ctx | |
.table('messages') | |
.insert({ text: 'Hello world', tags: [tagId] }); | |
``` | |
But we could have equally created a message first, and then created a tag with a | |
list of message IDs. | |
The `replace` method can be used to create and delete many:many edges: | |
```ts | |
await ctx | |
.table('messages') | |
.getX(messageId) | |
.replace({ text: 'Changed message', tags: [tagID, otherTagID] }); | |
``` | |
If a list is specified, the edges that need to be created are created, and all | |
other existing edges are deleted. If the edge name is ommited entirely, the | |
edges are left unchanged: | |
```ts | |
await ctx | |
.table('messages') | |
.getX(messageId) | |
.replace({ text: 'Changed message' /* no `tags:`, so tags don't change */ }); | |
``` | |
The `patch` method on the other hand expects a description of the changes that | |
should be made, a list of IDs to `add` and `remove` edges for: | |
```ts | |
const message = await ctx.table('messages').getX(messageId); | |
await message.patch({ tags: { add: [tagID] } }); | |
await message.patch({ | |
tags: { add: [tagID, otherTagID], remove: [tagToDeleteID] }, | |
}); | |
``` | |
Any edges in the `add` list that didn't exist are created, and any edges in the | |
`remove` list that did exist are deleted. Edges to ents with ID not listed in | |
either list are not affected by `patch`. | |
## Updating ents connected by edges | |
The `patch`, `replace` and `delete` methods can be chained after `edge` calls to | |
update the ent on the other end of an edge: | |
```ts | |
await ctx | |
.table('users') | |
.getX(userId) | |
.edgeX('profile') | |
.patch({ bio: "I'm the first user" }); | |
``` | |
<Aside title="This is equivalent to the built-in:"> | |
```ts | |
const profile = await ctx.db | |
.query('profiles') | |
.withIndex('userId', (q) => q.eq('userId', userId)) | |
.unique(); | |
if (profile === null) { | |
throw new Error( | |
`Edge "profile" does not exist for document wit ID "${userId}"` | |
); | |
} | |
await ctx.db.patch(profile._id, { bio: "I'm the first user" }); | |
``` | |
</Aside> | |
<Aside | |
title={ | |
<> | |
<b>Limitation:</b> <code>edge</code> called on a loaded ent | |
</> | |
} | |
> | |
The following code does not typecheck [currently](https://github.com/xixixao/convex-ents/issues/8): | |
```ts | |
const user = await ctx.table('users').getX(userId); | |
await user.edgeX('profile').patch({ bio: "I'm the first user" }); | |
``` | |
You can either disable typechecking via `// @ts-expect-error` or preferably | |
start with `ctx.table`: | |
```ts | |
const user = ... // loaded or passed via `map` | |
await ctx | |
.table("users") | |
.getX(user._id).edgeX("profile").patch({ bio: "I'm the first user" }); | |
``` | |
</Aside> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment