Trovella Wiki

Drizzle Query Patterns

Common Drizzle ORM patterns used across the codebase -- selects, inserts, updates, deletes, relations, and raw SQL.

This page catalogs the Drizzle ORM patterns used in the Trovella codebase. All examples assume tx (or ctx.db in tRPC handlers) is a tenant-scoped transaction from withTenantContext.

Select with Conditions

Use eq, and, desc, asc from drizzle-orm for typed, composable conditions:

import { and, desc, eq } from "drizzle-orm";

// Single condition
const [plan] = await ctx.db
  .select()
  .from(researchPlan)
  .where(eq(researchPlan.id, input.planId))
  .limit(1);

// Multiple conditions
const conditions = [eq(planAuditLog.planId, input.planId)];
if (input.eventType) {
  conditions.push(eq(planAuditLog.eventType, input.eventType));
}

const logs = await ctx.db
  .select()
  .from(planAuditLog)
  .where(and(...conditions))
  .orderBy(desc(planAuditLog.createdAt))
  .limit(input.limit)
  .offset(input.offset);

The dynamic conditions pattern (building an array and spreading into and()) is used throughout the research plan and AI logs routers when filters are optional.

Relational Queries

Drizzle's relational query API (ctx.db.query.*) supports with for eager loading related records:

const members = await ctx.db.query.member.findMany({
  with: {
    user: {
      columns: { id: true, name: true, email: true, image: true },
    },
  },
});

Use findFirst for single-record lookups:

const memberRecord = await ctx.db.query.member.findFirst({
  where: and(eq(member.userId, ctx.session.user.id), eq(member.organizationId, ctx.organizationId)),
});

The relational API requires that the Drizzle instance was created with the schema (which @repo/db does in client.ts).

Column Selection

Select specific columns to reduce data transfer:

const org = await ctx.db.query.organization.findFirst({
  where: eq(organization.id, ctx.organizationId),
  columns: { type: true },
});

Or with the select builder for computed/aggregated columns:

const rows = await ctx.db
  .select({
    sourceTable: documentChunk.sourceTable,
    count: count(),
  })
  .from(documentChunk)
  .groupBy(documentChunk.sourceTable);

Insert

Standard single-row insert:

await tx.insert(researchPlan).values({
  id: planId,
  organizationId,
  userId,
  name,
  researchQuestion,
  status: "planning",
  createdBySession: sessionId ?? null,
  outputFormattingNotes: outputFormattingNotes ?? null,
});

Multi-row insert (used in seed scripts, but also valid in application code):

await db.insert(user).values([
  { id: "user_1", name: "Alice", ... },
  { id: "user_2", name: "Bob", ... },
]);

For tenant-scoped inserts, include the organizationId in the values. RLS will reject the insert if the organization_id does not match the session variable.

Update with Returning

Use .returning() to get the updated row back without a separate SELECT:

const [updated] = await ctx.db
  .update(organization)
  .set({
    ...(input.name !== undefined && { name: input.name }),
    ...(input.slug !== undefined && { slug: input.slug }),
  })
  .where(eq(organization.id, ctx.organizationId))
  .returning();

if (!updated) {
  throw new TRPCError({ code: "NOT_FOUND" });
}

The conditional spread pattern (...(input.name !== undefined && { name: input.name })) allows partial updates where only the provided fields are changed.

Soft Delete

The PAT module uses a soft-revoke pattern -- setting a timestamp instead of deleting the row:

const [updated] = await db
  .update(personalAccessToken)
  .set({ revokedAt: new Date() })
  .where(
    and(
      eq(personalAccessToken.id, tokenId),
      eq(personalAccessToken.userId, userId),
      isNull(personalAccessToken.revokedAt),
    ),
  )
  .returning({ id: personalAccessToken.id });

The isNull(personalAccessToken.revokedAt) condition prevents revoking an already-revoked token.

Hard Delete

await ctx.db.delete(member).where(eq(member.id, input.memberId));

RLS ensures this only affects rows in the current tenant. A cross-tenant delete silently affects zero rows (the row is invisible).

Raw SQL

Use tx.execute() for queries that cannot be expressed with the Drizzle builder:

// Aggregate subquery
const avgResult = await ctx.db.execute<{ avg: number }>(
  sql`SELECT round(avg(cnt)) AS avg
      FROM (SELECT count(*) AS cnt FROM ${planStep} GROUP BY ${planStep.planId}) sub`,
);
const avgStepsRow = avgResult.rows[0];

The sql template tag from drizzle-orm handles parameter interpolation. Table references like ${planStep} resolve to the table's SQL name.

Vector Search with Raw SQL

pgvector queries require raw SQL because Drizzle does not have native vector operators:

const vectorStr = `[${queryEmbedding.join(",")}]`;

const result = await tx.execute(sql`
  SELECT id, source_table, source_id, embedded_text,
         (embedding <#> ${vectorStr}::halfvec(1536)) * -1 AS similarity
  FROM document_chunk
  WHERE source_table = ${sourceTable}
  ORDER BY embedding <#> ${vectorStr}::halfvec(1536)
  LIMIT ${limit}
`);

The <#> operator computes negative inner product distance. Multiplying by -1 converts it to a similarity score (higher = more similar).

Count Queries

For pagination, run a parallel count query:

const [items, [totalRow]] = await Promise.all([
  ctx.db
    .select()
    .from(researchArtifact)
    .where(where)
    .orderBy(desc(researchArtifact.createdAt))
    .limit(input.limit)
    .offset(input.offset),
  ctx.db.select({ total: count() }).from(researchArtifact).where(where),
]);

return { items, total: totalRow?.total ?? 0 };

See Performance -- Parallel Queries for when this is safe inside a transaction.

Aggregation with SQL Functions

const rows = await ctx.db
  .select({
    totalCalls: count(),
    totalInputTokens: sum(aiUsage.inputTokens),
    totalOutputTokens: sum(aiUsage.outputTokens),
    totalCost: sum(aiUsage.estimatedCost),
    avgLatency: sql<number>`round(avg(${aiUsage.latencyMs}))`,
    errorCount: sql<number>`count(*) filter (where ${aiUsage.stopReason} = 'error')`,
  })
  .from(aiUsage);

Use sql<Type> for inline SQL expressions with type annotations. The FILTER (WHERE ...) clause is PostgreSQL-specific and useful for conditional aggregation without CASE expressions.

Group By with Ordering

const rows = await ctx.db
  .select({
    model: aiUsage.model,
    calls: count(),
    cost: sum(aiUsage.estimatedCost),
  })
  .from(aiUsage)
  .groupBy(aiUsage.model)
  .orderBy(desc(count()));

Date Truncation

For time-series aggregation:

const truncFn = sql`date_trunc('day', ${aiUsage.createdAt})`;

const rows = await ctx.db
  .select({
    period: truncFn,
    calls: count(),
    inputTokens: sum(aiUsage.inputTokens),
  })
  .from(aiUsage)
  .groupBy(truncFn)
  .orderBy(truncFn);

On this page