or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

advanced.mdauth.mddatabase.mdindex.mdnextjs.mdreact.mdschema.mdserver-functions.mdvalues-validators.md
tile.json

schema.mddocs/

Schema Definition

Basic Schema

import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  messages: defineTable({
    author: v.string(),
    body: v.string(),
    likes: v.number(),
  }),

  users: defineTable({
    name: v.string(),
    email: v.string(),
    age: v.number(),
  }),
});

Indexes

Simple Index

defineTable({
  author: v.string(),
  body: v.string(),
}).index('by_author', ['author'])

Compound Index

defineTable({
  author: v.string(),
  category: v.string(),
  timestamp: v.number(),
})
  .index('by_author_and_category', ['author', 'category'])
  .index('by_category_and_time', ['category', 'timestamp'])

Multiple Indexes

defineTable({
  author: v.string(),
  status: v.string(),
  timestamp: v.number(),
})
  .index('by_author', ['author'])
  .index('by_status', ['status'])
  .index('by_time', ['timestamp'])

Search Indexes

defineTable({
  title: v.string(),
  body: v.string(),
  author: v.string(),
  category: v.string(),
}).searchIndex('search_body', {
  searchField: 'body',
  filterFields: ['author', 'category'],
})

// Usage in query
const results = await ctx.db.query('posts')
  .withSearchIndex('search_body', q =>
    q.search('body', 'keyword')
      .eq('author', 'Alice')
      .eq('category', 'tech')
  )
  .collect();

Vector Indexes

defineTable({
  content: v.string(),
  embedding: v.array(v.float64()),
  category: v.string(),
  userId: v.id('users'),
}).vectorIndex('by_embedding', {
  vectorField: 'embedding',
  dimensions: 1536,  // Must match your embedding model
  filterFields: ['category', 'userId'],
})

// Usage in action
const results = await ctx.vectorSearch('documents', 'by_embedding', {
  vector: embeddingArray,
  limit: 10,
  filter: q => q.eq('category', 'news'),
});

Field Types

defineTable({
  // Primitives
  text: v.string(),
  count: v.number(),
  bigNumber: v.bigint(),
  flag: v.boolean(),
  data: v.bytes(),

  // References
  userId: v.id('users'),

  // Optional
  nickname: v.optional(v.string()),
  metadata: v.optional(v.object({ key: v.string() })),

  // Arrays
  tags: v.array(v.string()),
  scores: v.array(v.number()),

  // Nested objects
  profile: v.object({
    bio: v.string(),
    avatar: v.string(),
    settings: v.object({
      theme: v.string(),
      notifications: v.boolean(),
    }),
  }),

  // Records (dynamic keys)
  metadata: v.record(v.string(), v.any()),
  scores: v.record(v.string(), v.number()),

  // Unions
  status: v.union(
    v.literal('active'),
    v.literal('inactive'),
    v.literal('pending')
  ),

  // Nullable
  parentId: v.nullable(v.id('items')),
})

System Fields

All documents automatically include:

{
  _id: Id<'tableName'>,          // Unique ID
  _creationTime: number,          // Unix timestamp (ms)
  // ... your fields
}

Schema Validation

export default defineSchema(
  {
    messages: defineTable({ ... }),
  },
  {
    schemaValidation: true,  // Strict (default)
    // schemaValidation: false,  // No validation
    // schemaValidation: 'warn',  // Warnings only
  }
);

Complex Examples

Blog Post

defineTable({
  title: v.string(),
  slug: v.string(),
  content: v.string(),
  authorId: v.id('users'),
  authorName: v.string(),
  tags: v.array(v.string()),
  published: v.boolean(),
  publishedAt: v.optional(v.number()),
  views: v.number(),
  likes: v.number(),
})
  .index('by_slug', ['slug'])
  .index('by_author', ['authorId'])
  .index('by_published', ['published', 'publishedAt'])
  .searchIndex('search_content', {
    searchField: 'content',
    filterFields: ['published', 'authorId'],
  })

E-commerce Product

defineTable({
  name: v.string(),
  description: v.string(),
  price: v.number(),
  category: v.string(),
  brand: v.string(),
  inStock: v.boolean(),
  stockCount: v.number(),
  images: v.array(v.string()),  // Storage IDs
  attributes: v.record(v.string(), v.string()),
  tags: v.array(v.string()),
  embedding: v.array(v.float64()),
})
  .index('by_category', ['category'])
  .index('by_brand', ['brand'])
  .index('by_category_and_price', ['category', 'price'])
  .searchIndex('search_name', {
    searchField: 'name',
    filterFields: ['category', 'brand', 'inStock'],
  })
  .vectorIndex('by_embedding', {
    vectorField: 'embedding',
    dimensions: 768,
    filterFields: ['category', 'inStock'],
  })

User with Nested Profile

defineTable({
  email: v.string(),
  emailVerified: v.boolean(),
  name: v.string(),
  profile: v.object({
    bio: v.optional(v.string()),
    avatar: v.optional(v.string()),
    website: v.optional(v.string()),
    social: v.optional(v.object({
      twitter: v.optional(v.string()),
      github: v.optional(v.string()),
    })),
  }),
  settings: v.object({
    theme: v.union(v.literal('light'), v.literal('dark')),
    notifications: v.object({
      email: v.boolean(),
      push: v.boolean(),
    }),
  }),
  role: v.union(v.literal('user'), v.literal('admin'), v.literal('moderator')),
  createdAt: v.number(),
  lastLoginAt: v.optional(v.number()),
})
  .index('by_email', ['email'])
  .index('by_role', ['role'])

Best Practices

Index Design

  • Index fields used in withIndex() queries
  • Put equality filters before range filters in compound indexes
  • Limit indexes to frequently-used queries (each index adds overhead)

Field Naming

  • Use camelCase for field names
  • Avoid reserved names: _id, _creationTime

Optional vs Nullable

  • v.optional(): Field may be absent (undefined)
  • v.nullable(): Field is present but may be null
  • For new fields in existing tables, use v.optional() for backwards compatibility

Nested vs Flat

  • Nest related fields: profile.bio, settings.theme
  • Keep flat for fields used in indexes (can't index nested fields directly)