Convex Backend
Owlat uses Convex as its serverless backend, providing real-time subscriptions, ACID transactions, and TypeScript-first development.
Owlat uses Convex as its serverless backend, providing real-time subscriptions, ACID transactions, and TypeScript-first development.
Directory Structure
apps/api/convex/
├── _generated/ # Auto-generated (never edit)
│ ├── api.d.ts # API type definitions
│ └── dataModel.d.ts # Schema types
├── lib/ # Shared utilities
│ ├── emailProviders/ # Email provider implementations
│ │ ├── ses.ts # SES email sending provider
│ │ ├── sesIdentity.ts # SES domain identity management
│ │ ├── resend.ts # Resend email provider
│ │ ├── domainVerification.ts # Domain validation helpers
│ │ └── types.ts # Provider interfaces
│ ├── permissions.ts # Role-based access control
│ ├── sessionOrganization.ts # Session helpers
│ └── waitlist.ts # Waitlist feature flag helpers
├── schema.ts # Database schema
├── auth.ts # BetterAuth configuration
├── http.ts # HTTP route handlers
├── sesActions.ts # SES domain registration actions
└── *.ts # API functions by domain
Function Types
Queries (Read-only, Real-time)
Queries are reactive - clients automatically receive updates when data changes.
import { query } from './_generated/server';
import { v } from 'convex/values';
export const list = query({
args: { organizationId: v.string() },
handler: async (ctx, args) => {
return ctx.db
.query('contacts')
.withIndex('by_org', (q) => q.eq('organizationId', args.organizationId))
.collect();
},
});
Mutations (Read-write, Transactional)
Mutations are ACID - all changes succeed or fail together.
import { mutation } from './_generated/server';
import { v } from 'convex/values';
export const create = mutation({
args: {
organizationId: v.string(),
email: v.string(),
firstName: v.optional(v.string()),
},
handler: async (ctx, args) => {
const contactId = await ctx.db.insert('contacts', {
...args,
source: 'api',
createdAt: Date.now(),
updatedAt: Date.now(),
});
return contactId;
},
});
Actions (External APIs, Side Effects)
Actions can call external services but don't have transactions.
import { action } from './_generated/server';
import { v } from 'convex/values';
export const sendEmail = action({
args: {
to: v.string(),
subject: v.string(),
html: v.string(),
},
handler: async (ctx, args) => {
const provider = getEmailProvider();
return provider.sendEmail(args);
},
});
HTTP Actions (REST Endpoints)
HTTP actions handle external API requests.
import { httpAction } from 'convex/server';
export const handleWebhook = httpAction(async (ctx, request) => {
const body = await request.json();
// Process webhook
return new Response('OK', { status: 200 });
});
// In http.ts
http.route({
path: '/api/v1/webhook',
method: 'POST',
handler: handleWebhook,
});
Session-Based Functions
To avoid passing organizationId in every request, use session-based helpers:
import { getTeamIdFromSession, getMutationContext } from './lib/sessionOrganization';
// Query using session context
export const listFromSession = query({
args: {},
handler: async (ctx) => {
const organizationId = await getTeamIdFromSession(ctx);
return ctx.db
.query('contacts')
.withIndex('by_org', (q) => q.eq('organizationId', organizationId))
.collect();
},
});
// Mutation using session context
export const createFromSession = mutation({
args: { email: v.string() },
handler: async (ctx, args) => {
const { teamId: organizationId, userId, role } = await getMutationContext(ctx);
// Check permissions if needed
if (!isAdminRole(role)) {
throw new Error('Admin access required');
}
return ctx.db.insert('contacts', {
organizationId,
email: args.email,
source: 'api',
createdAt: Date.now(),
updatedAt: Date.now(),
});
},
});
Database Patterns
Always Use Indexes
// Good - uses index
const contacts = await ctx.db
.query('contacts')
.withIndex('by_org', (q) => q.eq('organizationId', organizationId))
.collect();
// Bad - full table scan
const contacts = await ctx.db
.query('contacts')
.filter((q) => q.eq(q.field('organizationId'), organizationId))
.collect();
Schema Definition
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
contacts: defineTable({
organizationId: v.string(),
email: v.string(),
firstName: v.optional(v.string()),
lastName: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_org', ['organizationId'])
.index('by_team_and_email', ['organizationId', 'email']),
});
Common Field Patterns
// ID references
organizationId: v.string();
contactId: v.id('contacts');
// Timestamps (always milliseconds)
createdAt: v.number();
updatedAt: v.number();
// Status enums
status: v.union(v.literal('draft'), v.literal('published'), v.literal('archived'));
// Optional with default
description: v.optional(v.string());
// JSON stored as string
filters: v.string(); // Parse with JSON.parse()
Permission System
Roles
owner- Full access, can delete teamadmin- Full access, cannot delete teameditor- Limited access, cannot manage team settings
Permission Checks
import { isAdminRole, requirePermission } from './lib/permissions';
export const deleteContact = mutation({
args: { contactId: v.id('contacts') },
handler: async (ctx, args) => {
const { teamId: organizationId, role } = await getMutationContext(ctx);
// Check admin permission
requirePermission(role, 'admin');
await ctx.db.delete(args.contactId);
},
});
Error Handling
// Throw descriptive errors
if (!contact) {
throw new Error('Contact not found');
}
if (!domain.verified) {
throw new Error(`Cannot send email: domain ${domain.name} is not verified`);
}
// Validation errors
if (!args.email.includes('@')) {
throw new Error('Invalid email address');
}
Scheduler (Background Jobs)
import { internal } from './_generated/api';
// Schedule a job to run later
await ctx.scheduler.runAfter(
60000, // 1 minute
internal.automationStepExecutor.executeStep,
{ runId, stepIndex }
);
// Schedule at a specific time
await ctx.scheduler.runAt(
scheduledAt, // timestamp
internal.campaigns.sendBatch,
{ campaignId }
);
File Storage
// Generate upload URL (for client upload)
export const generateUploadUrl = mutation({
handler: async (ctx) => {
return ctx.storage.generateUploadUrl();
},
});
// Get file URL
export const getUrl = query({
args: { storageId: v.id('_storage') },
handler: async (ctx, args) => {
return ctx.storage.getUrl(args.storageId);
},
});
// Delete file
export const deleteFile = mutation({
args: { storageId: v.id('_storage') },
handler: async (ctx, args) => {
await ctx.storage.delete(args.storageId);
},
});
Key API Files
| File | Purpose |
|---|---|
contacts.ts | Contact CRUD operations |
mailingLists.ts | Mailing list management |
campaigns.ts | Campaign creation and sending |
emailTemplates.ts | Template CRUD |
emailBlocks.ts | Saved block management |
segments.ts | Segment filtering |
automations.ts | Workflow management |
transactionalApi.ts | API email sending |
domains.ts | Domain CRUD and type definitions |
sesActions.ts | SES domain registration/deletion |
dnsVerification.ts | DNS record verification action |
dnsVerificationQueries.ts | Internal mutations for domain updates |
organizationSettings.ts | Organization settings |
emails.ts | Email rendering and sending |
waitlist.ts | Waitlist queries and mutations |
Frontend Integration
// In Vue component
import { useConvexQuery, useConvexMutation } from '~/composables/convex';
import { api } from '@owlat/api/convex/_generated/api';
// Query (reactive)
const contacts = useConvexQuery(api.contacts.listFromSession, {});
// Mutation
const createContact = useConvexMutation(api.contacts.createFromSession);
await createContact({ email: 'user@example.com' });