BETTER-CONVEX

Ents

Ergonomic database access with relationships.

In this guide, we'll explore Convex Ents - an ergonomic layer over Convex's database. You'll learn to define schemas with relationships, traverse edges between tables, handle cascading deletes, and integrate with cRPC.

Overview

Convex Ents provides a fluent API for database operations:

FeatureBenefit
RelationshipsDefine edges between tables with type-safe traversal
Fluent APIctx.table('user').get(id) instead of ctx.db.get(id)
Field helpersDefault values, indexed fields, unique constraints
RulesCollocate authorization logic with schema definitions
Cascading deletesSoft and scheduled deletion with propagation

Let's set it up.

Installation

First, install Convex Ents:

bun add convex-ents

Setup

Create the ents helper to enable ctx.table:

convex/lib/ents.ts
import type { GenericEnt, GenericEntWriter } from 'convex-ents';
import { entsTableFactory, getEntDefinitions } from 'convex-ents';
import type { TableNames } from '../functions/_generated/dataModel';
import type { MutationCtx, QueryCtx } from '../functions/_generated/server';
import schema from '../functions/schema';

export const entDefinitions = getEntDefinitions(schema);

export type Ent<TableName extends TableNames> = GenericEnt<
  typeof entDefinitions,
  TableName
>;

export type EntWriter<TableName extends TableNames> = GenericEntWriter<
  typeof entDefinitions,
  TableName
>;

export type CtxWithTable<Ctx extends MutationCtx | QueryCtx = QueryCtx> =
  ReturnType<typeof getCtxWithTable<Ctx>>;

export const getCtxWithTable = <Ctx extends MutationCtx | QueryCtx>(ctx: Ctx) => ({
  ...ctx,
  table: entsTableFactory(ctx, entDefinitions),
});

Now integrate with cRPC:

convex/lib/crpc.ts
import { getCtxWithTable } from './ents';

const c = initCRPC
  .dataModel<DataModel>()
  .context({
    query: (ctx) => getCtxWithTable(ctx),
    mutation: (ctx) => getCtxWithTable(ctx),
  })
  .create({ ... });

Now all your procedures have ctx.table available.

Schema Definition

Use defineEntSchema and defineEnt instead of Convex's defineSchema and defineTable:

convex/functions/schema.ts
import { v } from 'convex/values';
import { defineEnt, defineEntSchema, getEntDefinitions } from 'convex-ents';

const schema = defineEntSchema({
  user: defineEnt({
    name: v.string(),
    email: v.string(),
    emailVerified: v.boolean(),
  })
    .field('username', v.string(), { unique: true })
    .index('email', ['email'])
    .edges('sessions', { to: 'session', ref: 'userId' })
    .edges('accounts', { to: 'account', ref: 'userId' }),

  session: defineEnt({
    token: v.string(),
    expiresAt: v.number(),
  })
    .index('token', ['token'])
    .edge('user', { to: 'user', field: 'userId' }),

  account: defineEnt({
    accountId: v.string(),
    providerId: v.string(),
  })
    .index('accountId', ['accountId'])
    .edge('user', { to: 'user', field: 'userId' }),
});

export default schema;
export const entDefinitions = getEntDefinitions(schema);

Field Options

Ents provides helpful field options for common patterns.

Indexed Fields

Creates an index for efficient lookups:

defineEnt({}).field('email', v.string(), { index: true })
// Equivalent to: .index('email', ['email'])

Unique Fields

Enforces uniqueness and creates an index:

defineEnt({}).field('username', v.string(), { unique: true })

Field Defaults

Simplify schema evolution without migrations:

defineEnt({}).field('contentType', v.union(v.literal('text'), v.literal('video')), {
  default: 'text',
})
// Field is required in TypeScript, but defaults if missing in DB

Tip: Default values let you add required fields to existing tables without running migrations.

Relationships

Ents makes defining relationships between tables intuitive.

1:1 Edges

Define one-to-one relationships:

user: defineEnt({})
  .edge('profile', { ref: true }), // profile stores userId

profile: defineEnt({})
  .edge('user', { field: 'userId' }),

Optional 1:1 edge:

profile: defineEnt({})
  .edge('user', { field: 'userId', optional: true }),

1:many Edges

Define one-to-many relationships:

user: defineEnt({})
  .edges('sessions', { to: 'session', ref: 'userId' }),

session: defineEnt({})
  .edge('user', { to: 'user', field: 'userId' }),

many:many Edges

Define many-to-many relationships with a join table:

posts: defineEnt({})
  .edges('tags', { to: 'tags', table: 'postTags' }),

tags: defineEnt({})
  .edges('posts', { to: 'posts', table: 'postTags' }),

The field option defaults to edge name + Id (e.g., tagsId for posts.edges('tags')).

Join Table Patterns

Auto-generated join tables (pure many:many edges) do NOT support custom fields, indexes, or edges. Only use for simple relationships:

// Auto-generated join table - no custom fields possible
posts: defineEnt({})
  .edges('tags', { to: 'tags', table: 'postTags' }),

// If you need TypeScript types or aggregates, define the table explicitly:
postTags: defineEnt({})
  .field('postId', v.id('posts'), { index: true })
  .field('tagId', v.id('tags'), { index: true })
  .index('postId_tagId', ['postId', 'tagId']),

Join tables with extended fields require two 1:many edges instead:

// Join table WITH custom fields, indexes, edges
messageVotes: defineEnt({})
  .field('isDownvoted', v.optional(v.boolean()))  // Custom field
  .edge('user', { to: 'user', field: 'userId' })  // 1:many edge
  .edge('message')                                 // 1:many edge
  .index('userId_messageId', ['userId', 'messageId']),

// Access via edges
messages: defineEnt({})
  .edges('votedBy', { to: 'messageVotes', ref: 'messageId' }),

user: defineEnt({})
  .edges('votedMessages', { ref: true, to: 'messageVotes' }),

Self-Referential Edges

Asymmetrical (followers/following):

user: defineEnt({})
  .edges('following', {
    to: 'user',
    inverse: 'followers',
    table: 'follows',
    field: 'followerId',
    inverseField: 'followingId',
  }),

Symmetrical (friends):

user: defineEnt({})
  .edges('friends', { to: 'user' }), // No inverse = symmetrical

Cascading Deletes

Ents handles related document cleanup automatically.

Default Behavior

  • 1:1/1:many edges: Deleting parent deletes children that store the edge
  • many:many edges: Deleting ent removes edge documents, not connected ents

Soft Deletion

Adds deletionTime field instead of removing:

user: defineEnt({})
  .deletion('soft')
  .edges('sessions', { to: 'session', ref: 'userId', deletion: 'soft' }),

Filter soft-deleted ents:

const activeUsers = await ctx.table('user')
  .filter((q) => q.eq(q.field('deletionTime'), undefined));

Undelete:

await ctx.table('user').getX(userId).patch({ deletionTime: undefined });

Scheduled Deletion

Soft delete immediately, hard delete after delay:

user: defineEnt({})
  .deletion('scheduled', { delayMs: 24 * 60 * 60 * 1000 }), // 24 hours

Requires setup in functions file:

convex/lib/functions.ts
import { scheduledDeleteFactory } from 'convex-ents';
export const scheduledDelete = scheduledDeleteFactory(entDefinitions);

Reading Ents

Let's explore how to read data with Ents.

By ID

// Returns null if not found
const user = await ctx.table('user').get(userId);

// Throws if not found
const user = await ctx.table('user').getX(userId);

// Multiple IDs
const users = await ctx.table('user').getMany([id1, id2]);
const users = await ctx.table('user').getManyX([id1, id2]); // Throws if any missing

By Index

// Single field index
const user = await ctx.table('user').get('email', email);
const user = await ctx.table('user').getX('email', email);

// Compound index
const user = await ctx.table('user').get('nameAndRank', 'Steve', 10);

Listing

// All ents
const users = await ctx.table('user');

// With index filter
const sessions = await ctx.table('session', 'userId', (q) => q.eq('userId', userId));

// Filter
const sessions = await ctx.table('session')
  .filter((q) => q.gt(q.field('expiresAt'), Date.now()));

// Order (default: _creationTime asc)
const sessions = await ctx.table('session').order('desc');

// Limit
const users = await ctx.table('user').take(5);

// First/unique
const latest = await ctx.table('user').order('desc').first();
const latest = await ctx.table('user').order('desc').firstX(); // Throws if none
const counter = await ctx.table('counters').unique(); // Throws if more than 1
const counter = await ctx.table('counters').uniqueX(); // Throws if not exactly 1

// Paginate
const result = await ctx.table('session').paginate(paginationOpts);

// Text search
const users = await ctx.table('user')
  .search('name', (q) => q.search('name', 'john'));

Raw Documents

Return plain documents without ent methods:

const messages = await ctx.table('messages').docs();
const profile = await user.edgeX('profile').doc();

System Tables

Access Convex system tables:

const files = await ctx.table.system('_storage');
const scheduled = await ctx.table.system('_scheduled_functions');

Edge to scheduled functions with auto-cancel:

answers: defineEnt({})
  .edge('action', { to: '_scheduled_functions', deletion: 'hard' }),

Traversing Edges

Here's where Ents really shines - navigating relationships.

1:1 and 1:many

// 1:1 - returns single ent or null
const profile = await ctx.table('user').getX(userId).edge('profile');
const profile = await ctx.table('user').getX(userId).edgeX('profile'); // Throws

// 1:many - returns list
const sessions = await user.edge('sessions');
const recentSessions = await user.edge('sessions').order('desc').take(5);

many:many

const tags = await post.edge('tags');
const posts = await tag.edge('posts');

// Check edge exists
const hasTag = await post.edge('tags').has(tagId);

Map with Edges (Nested Reads)

Include related ents in your response:

// Include related ents in response
const usersWithSessions = await ctx.table('user').map(async (user) => ({
  name: user.name,
  sessions: await user.edge('sessions').take(5),
}));

// Nested traversal
const usersWithData = await ctx.table('user').map(async (user) => ({
  ...user,
  sessions: await user.edge('sessions').map(async (session) => ({
    token: session.token,
    expiresAt: session.expiresAt,
  })),
}));

// Parallel edge loading
const usersWithProfileAndSessions = await ctx.table('user').map(async (user) => {
  const [profile, sessions] = await Promise.all([
    user.edgeX('profile'),
    user.edge('sessions').take(5),
  ]);
  return { name: user.name, profile, sessions };
});

Writing Ents

Now let's look at write operations.

Insert

const userId = await ctx.table('user').insert({ name: 'Alice', email: 'alice@example.com' });

// Insert and get the ent
const user = await ctx.table('user').insert({ name: 'Alice', email: 'alice@example.com' }).get();

// Insert many
const ids = await ctx.table('session').insertMany([
  { token: 'abc', userId, expiresAt: Date.now() + 86400000 },
  { token: 'def', userId, expiresAt: Date.now() + 86400000 },
]);

Update

// Patch (merge)
await ctx.table('user').getX(userId).patch({ name: 'New Name' });

// Replace (overwrite all fields)
await ctx.table('session').getX(sessionId).replace({
  token: 'new-token',
  expiresAt: Date.now() + 86400000,
  userId,
});

// Update via edge
await ctx.table('user').getX(userId).edgeX('profile').patch({ bio: 'Updated' });

Delete

await ctx.table('session').getX(sessionId).delete();

Edge Mutations (many:many)

Manage many:many relationships:

// Add edges
await post.patch({ tags: { add: [tagId] } });

// Remove edges
await post.patch({ tags: { remove: [tagId] } });

// Replace all edges
await post.replace({ title: 'New', tags: [tag1Id, tag2Id] });

// Shorthand methods
await post.edge('tags').add(tagId);
await post.edge('tags').remove(tagId);
await post.edge('tags').replace([tag1Id, tag2Id]);

Triggers Integration

When using triggers, wrap the db before creating ents:

convex/lib/crpc.ts
const c = initCRPC
  .dataModel<DataModel>()
  .context({
    query: (ctx) => getCtxWithTable(ctx),
    mutation: (ctx) => getCtxWithTable(triggers.wrapDB(ctx)),
  })
  .create({ ... });

Common Gotchas

Here are common issues and their solutions:

IssueSolution
ctx.db still usedOnly exception: stream(ctx.db, schema) for streams
Index not foundIndex names must match schema exactly
Edge not workingMust be defined in schema first
Duplicate index errorEdges auto-create indexes - don't add manually
Can't filter edgemany:many edges don't support .filter() - use ctx.table()
Can't search edgeNo edges support .search() - use ctx.table()
Write on edge resultEdge results are read-only - re-fetch via ctx.table()

ctx.db vs ctx.table

Here's when to use each:

ctx.dbctx.table
Returns plain documentsReturns ents with methods
Manual joins via queriesEdge traversal
Generic errorsTyped errors with getX, firstX
Manual cascade deletesAuto cascade based on schema
No table name validationTable names type-checked

Tip: Use ctx.table by default. Only use ctx.db for stream() calls or when you need raw documents for performance reasons.

Migrate from Convex

If you're coming from vanilla Convex, here's what changes.

What stays the same

  • Same underlying database operations
  • Same validators with v
  • Same indexes and queries

What's new

Before (vanilla Convex):

const user = await ctx.db.get(userId);
const sessions = await ctx.db
  .query('session')
  .withIndex('userId', (q) => q.eq('userId', userId))
  .collect();

After (Ents):

const user = await ctx.table('user').getX(userId);
const sessions = await user.edge('sessions');

Key differences:

  • ctx.table instead of ctx.db.query
  • .getX() throws instead of returning null
  • Edge traversal instead of manual joins
  • Cascading deletes handled automatically
  • Schema defines relationships, not just tables

Next Steps

On this page