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

database.mddocs/

Database Operations

CRUD Operations

Get by ID

const doc = await ctx.db.get(id);  // Document | null
if (!doc) throw new Error('Not found');

Insert (Mutations only)

const id = await ctx.db.insert('tableName', {
  field1: 'value',
  field2: 123,
});
// Returns: Id<'tableName'>

Update (Mutations only)

// Patch: partial update
await ctx.db.patch(id, { field1: 'newValue' });

// Replace: complete replacement
await ctx.db.replace(id, {
  field1: 'value',
  field2: 456,
});

Delete (Mutations only)

await ctx.db.delete(id);

Queries

Basic Query

// All documents
const docs = await ctx.db.query('tableName').collect();

// First N
const docs = await ctx.db.query('tableName').take(10);

// First one
const doc = await ctx.db.query('tableName').first();  // Doc | null

// Count
const count = await ctx.db.query('tableName').count();

Ordering

// Default: ascending by _creationTime
const docs = await ctx.db.query('tableName').collect();

// Explicit order
const docs = await ctx.db.query('tableName').order('desc').collect();
const docs = await ctx.db.query('tableName').order('asc').collect();

Filtering

// Simple filter
const docs = await ctx.db.query('tableName')
  .filter(q => q.gt(q.field('age'), 18))
  .collect();

// Multiple conditions with and/or
const docs = await ctx.db.query('tableName')
  .filter(q => q.and(
    q.gte(q.field('price'), 10),
    q.lte(q.field('price'), 100)
  ))
  .collect();

const docs = await ctx.db.query('tableName')
  .filter(q => q.or(
    q.eq(q.field('status'), 'active'),
    q.eq(q.field('status'), 'pending')
  ))
  .collect();

// Nested field access
const docs = await ctx.db.query('users')
  .filter(q => q.eq(q.field('profile.verified'), true))
  .collect();

Filter Operators

// Comparison
q.eq(left, right)    // ==
q.neq(left, right)   // !=
q.lt(left, right)    // <
q.lte(left, right)   // <=
q.gt(left, right)    // >
q.gte(left, right)   // >=

// Logical
q.and(left, right)
q.or(left, right)
q.not(expr)

// Arithmetic
q.add(left, right)
q.sub(left, right)
q.mul(left, right)
q.div(left, right)
q.mod(left, right)
q.neg(operand)

Index Queries

Equality

const docs = await ctx.db.query('messages')
  .withIndex('by_author', q => q.eq('author', 'Alice'))
  .collect();

Range

// Greater than
const docs = await ctx.db.query('messages')
  .withIndex('by_time', q => q.gt('_creationTime', startTime))
  .collect();

// Bounded range
const docs = await ctx.db.query('messages')
  .withIndex('by_time', q =>
    q.gte('_creationTime', start).lt('_creationTime', end)
  )
  .collect();

Compound Index

// Schema
defineTable({ author: v.string(), timestamp: v.number() })
  .index('by_author_and_time', ['author', 'timestamp'])

// Query: author exact + time range
const docs = await ctx.db.query('messages')
  .withIndex('by_author_and_time', q =>
    q.eq('author', 'Alice').gt('timestamp', startTime)
  )
  .collect();

Search Index (Full-text)

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

// Query
const docs = await ctx.db.query('posts')
  .withSearchIndex('search_body', q =>
    q.search('body', 'keyword').eq('category', 'tech')
  )
  .collect();

Pagination

// Backend
import { paginationOptsValidator } from 'convex/server';

export const list = query({
  args: { paginationOpts: paginationOptsValidator },
  handler: async (ctx, args) =>
    await ctx.db.query('messages')
      .order('desc')
      .paginate(args.paginationOpts),
});

// Returns: { page: Doc[], isDone: boolean, continueCursor: string }

// React
import { usePaginatedQuery } from 'convex/react';

const { results, status, loadMore } = usePaginatedQuery(
  api.messages.list,
  {},
  { initialNumItems: 20 }
);

if (status === 'CanLoadMore') {
  <button onClick={() => loadMore(20)}>Load More</button>
}

System Tables

// Scheduled functions
const jobs = await ctx.db.system.query('_scheduled_functions').collect();

// Storage files
const files = await ctx.db.system.query('_storage').collect();

// Get by ID
const job = await ctx.db.system.get(jobId);  // jobId: Id<'_scheduled_functions'>
const file = await ctx.db.system.get(storageId);  // storageId: Id<'_storage'>

ID Validation

// Validate string is valid ID
const id = ctx.db.normalizeId('tableName', stringId);
if (!id) return null;  // Invalid ID

const doc = await ctx.db.get(id);

Async Iteration

const results = [];
for await (const doc of ctx.db.query('tableName')) {
  results.push(processDoc(doc));
  if (results.length >= 100) break;
}

Common Patterns

Unique Lookup

const user = await ctx.db.query('users')
  .withIndex('by_email', q => q.eq('email', email))
  .unique();  // Throws if multiple matches

Existence Check

const exists = await ctx.db.query('users')
  .withIndex('by_username', q => q.eq('username', username))
  .first() !== null;

Batch Operations

const ids = [id1, id2, id3];
const docs = await Promise.all(ids.map(id => ctx.db.get(id)));

// Filter nulls
const validDocs = docs.filter(d => d !== null);

Conditional Update

const doc = await ctx.db.get(id);
if (doc && doc.status === 'pending') {
  await ctx.db.patch(id, { status: 'processed' });
}

Upsert Pattern

const existing = await ctx.db.query('users')
  .withIndex('by_email', q => q.eq('email', email))
  .unique();

if (existing) {
  await ctx.db.patch(existing._id, { lastSeen: Date.now() });
} else {
  await ctx.db.insert('users', { email, lastSeen: Date.now() });
}