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.tsAPI type definitions
dataModel.d.tsSchema types
lib/Shared utilities
emailProviders/Email provider implementations
ses.tsSES sending
sesIdentity.tsDomain identity
resend.tsResend provider
domainVerification.tsValidation helpers
types.tsProvider interfaces
permissions.tsRole-based access control
sessionOrganization.tsSession helpers
schema.tsDatabase schema
auth.tsBetterAuth configuration
http.tsHTTP route handlers
sesActions.tsSES domain registration
*.tsAPI 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
  • Delete team
  • Transfer ownership
admin
  • Full access
  • Manage members
  • Cannot manage owners
editor
  • Create & edit content
  • No team management

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

contacts.tsContact CRUD
topics.tsTopic management
campaigns.tsCampaign sending
emailTemplates.tsTemplate CRUD
emailBlocks.tsSaved blocks
segments.tsSegment filtering
automations.tsWorkflow management
transactionalApi.tsAPI email sending
domains.tsDomain CRUD
sesActions.tsSES registration
dnsVerification.tsDNS verification
organizationSettings.tsOrg settings
emails.tsRendering & sending

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' });