Skip to content

Instantly share code, notes, and snippets.

@zbeyens
Created July 27, 2025 13:03
Show Gist options
  • Save zbeyens/ee0992c8539c63c632fd37f1b049c1bb to your computer and use it in GitHub Desktop.
Save zbeyens/ee0992c8539c63c632fd37f1b049c1bb to your computer and use it in GitHub Desktop.
convex-ents.md
# 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>&nbsp;<code>edge</code>&nbsp;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