Database Patterns

Doc Status: Good | ✓ Clear summary | ✓ Easy to read | ✓ Matches code | ✓ Good structure | ✓ Professional look | ✓ Visual components
App data lives in Convex — a managed document/relational hybrid database with end-to-end TypeScript types, automatic realtime subscriptions, and native Convex Auth.
Marketing content (blog, hero, FAQ, instructor bios) lives in Sanity. Convex stores operational data: products, form submissions, Piano Mirror rooms.

Schema

// packages/backend/convex/schema.ts
import { authTables } from '@convex-dev/auth/server';
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  ...authTables,

  users: defineTable({
    name: v.optional(v.string()),
    image: v.optional(v.string()),
    email: v.optional(v.string()),
    emailVerificationTime: v.optional(v.number()),
    phone: v.optional(v.string()),
    phoneVerificationTime: v.optional(v.number()),
    isAnonymous: v.optional(v.boolean()),
    // RBAC extension (Convex Auth replaced Clerk 2026-05-08):
    role: v.optional(v.union(v.literal('admin'), v.literal('staff'), v.literal('student'))),
  })
    .index('email', ['email'])
    .index('phone', ['phone']),

  products: defineTable({
    name: v.string(),
    sku: v.string(),
    price: v.number(),
    status: v.union(v.literal('active'), v.literal('archived')),
  }).index('by_sku', ['sku']),

  formSubmissions: defineTable({
    formType: v.union(
      v.literal('student-info'),
      v.literal('leave-request'),
      v.literal('transfer'),
      v.literal('hold'),
      v.literal('change')
    ),
    payload: v.any(),
    contactPhone: v.optional(v.string()),
    contactName: v.optional(v.string()),
    larkStatus: v.union(v.literal('pending'), v.literal('sent'), v.literal('failed')),
    larkError: v.optional(v.string()),
    larkAttempts: v.number(),
    submittedAt: v.number(),
  })
    .index('by_type_and_submitted', ['formType', 'submittedAt'])
    .index('by_lark_status', ['larkStatus'])
    .index('by_phone', ['contactPhone']),

  rooms: defineTable({
    slug: v.string(),
    kind: v.union(v.literal('one-on-one'), v.literal('class')),
    hostUserId: v.id('users'),
    status: v.union(v.literal('open'), v.literal('closed')),
    closedReason: v.optional(v.string()),
  }).index('by_slug', ['slug']),

  roomPeers: defineTable({
    roomId: v.id('rooms'),
    userId: v.id('users'),
    joinedAt: v.number(),
    lastSeenAt: v.number(),
    displayName: v.optional(v.string()),
    isHost: v.boolean(),
    status: v.union(v.literal('joined'), v.literal('left')),
  })
    .index('by_room', ['roomId'])
    .index('by_user', ['userId']),

  signalingMessages: defineTable({
    roomId: v.id('rooms'),
    fromUserId: v.id('users'),
    toUserId: v.id('users'),
    type: v.union(v.literal('offer'), v.literal('answer'), v.literal('ice')),
    payload: v.any(),
    expiresAt: v.number(),
  })
    .index('by_room_and_to_and_creation', ['roomId', 'toUserId'])
    .index('by_expires', ['expiresAt']),
});

Validators

ValidatorUse
v.string()String fields
v.number()Numbers (timestamps, prices)
v.boolean()Booleans
v.id('table')Typed Convex FK
v.optional(v.string())Optional string
v.union(v.literal('a'), v.literal('b'))Union of literals
v.array(v.string())Arrays
v.record(v.string(), v.unknown())Key-value records

Indexes

Define indexes inline on the table:
.index('by_slug', ['slug'])
.index('by_center_status', ['centerId', 'status'])
Query via withIndex:
ctx.db
  .query('students')
  .withIndex('by_center_status', (q) => q.eq('centerId', centerId).eq('status', 'active'))
  .order('desc')
  .take(20);

Pagination

Use Convex’s built-in paginator — do not hand-roll cursor logic:
import { paginationOptsValidator } from 'convex/server';

export const listByCenter = query({
  args: {
    centerId: v.id('centers'),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, { centerId, paginationOpts }) => {
    return await ctx.db
      .query('students')
      .withIndex('by_center_status', (q) => q.eq('centerId', centerId))
      .order('desc')
      .paginate(paginationOpts);
  },
});

Transactions

Mutations are atomic by default — the entire mutation handler runs as a single transaction. Throw to abort.
export const enrollStudent = mutation({
  args: { studentId: v.id('students'), courseId: v.id('courses') },
  handler: async (ctx, { studentId, courseId }) => {
    const student = await ctx.db.get(studentId);
    if (!student) throw new Error('NOT_FOUND: student');
    const course = await ctx.db.get(courseId);
    if (!course) throw new Error('NOT_FOUND: course');

    await ctx.db.patch(studentId, {
      enrolledCourseIds: [...student.enrolledCourseIds, courseId],
    });
    await ctx.db.patch(courseId, { enrolledCount: course.enrolledCount + 1 });
    // Throw here and both patches roll back
  },
});

Auth in Queries / Mutations

import { getAuthUserId } from '@convex-dev/auth/server';

export const myProfile = query({
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) return null;
    return await ctx.db.get(userId);
  },
});

Key Tables

TableDescription
usersAuth identity from authTables + role extension
productsProduct catalog (name, sku, price, status)
formSubmissionsAudit log for phiếu forms with Lark delivery status
roomsPiano Mirror rooms (slug, kind, host, status)
roomPeersRoom membership (user, joinedAt, lastSeenAt, status)
signalingMessagesWebRTC signaling messages (offer/answer/ICE)

Migrations

Convex schema is declarative — edit schema.ts, run npx convex dev, the dashboard shows a diff and applies it. For one-shot backfills after schema changes:
// packages/backend/convex/migrations/add_status.ts
import { internalMutation } from '../_generated/server';

export const backfillStatus = internalMutation({
  args: {},
  handler: async (ctx) => {
    const rows = await ctx.db.query('students').collect();
    for (const row of rows) {
      if (!('status' in row)) {
        await ctx.db.patch(row._id, { status: 'active' });
      }
    }
  },
});

Forbidden

  • Drizzle ORM, Cloudflare D1, HyperDrive — deprecated
  • Raw SQL anywhere in app code
  • Marketing content (descriptions, hero images) in Convex — belongs in Sanity
  • Operational state (price, stock) in Sanity — belongs in Convex