Skip to content

Database Guide

Sayr uses Drizzle ORM with PostgreSQL for all database operations. This guide covers how to work with the database effectively.

Database code lives in packages/database:

packages/database/
├── src/
│ ├── index.ts # Main exports (db client, helpers)
│ ├── schema/ # Table definitions
│ │ ├── index.ts # Schema exports & extended types
│ │ ├── user.ts # User & session tables
│ │ ├── organization.ts # Organization & member tables
│ │ ├── task.ts # Task & related tables
│ │ └── ...
│ └── functions/ # CRUD operations
│ ├── task.ts
│ ├── organization.ts
│ └── ...
├── drizzle.config.ts # Drizzle configuration
└── package.json
// Database client and schema
import { db, schema } from "@repo/database";
// CRUD functions
import { getTaskById, createTask, updateTask } from "@repo/database";
// Types
import type { TaskWithLabels, OrganizationWithMembers } from "@repo/database";
// Drizzle operators (for custom queries)
import { eq, and, or, inArray } from "drizzle-orm";
packages/database/src/schema/task.ts
import { pgTable, text, timestamp, pgEnum } from "drizzle-orm/pg-core";
import { organization } from "./organization";
import { user } from "./user";
export const statusEnum = pgEnum("status", [
"backlog",
"todo",
"in-progress",
"done",
"canceled",
]);
export const task = pgTable("task", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id),
title: text("title").notNull(),
description: text("description"),
status: statusEnum("status").default("backlog"),
createdById: text("created_by_id").references(() => user.id),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
import { relations } from "drizzle-orm";
export const taskRelations = relations(task, ({ one, many }) => ({
organization: one(organization, {
fields: [task.organizationId],
references: [organization.id],
}),
createdBy: one(user, {
fields: [task.createdById],
references: [user.id],
}),
labels: many(taskLabel),
assignees: many(taskAssignee),
comments: many(taskComment),
}));

Drizzle provides type inference for your schema:

// Infer types from schema
type Task = typeof schema.task.$inferSelect; // SELECT result type
type NewTask = typeof schema.task.$inferInsert; // INSERT input type

For queries with relations, define extended types in packages/database/src/schema/index.ts:

// Task with all related data loaded
export type TaskWithLabels = typeof task.$inferSelect & {
labels: (typeof label.$inferSelect)[];
assignees: {
id: string;
name: string;
image: string | null;
}[];
createdBy: {
id: string;
name: string;
image: string | null;
} | null;
comments: (typeof taskComment.$inferSelect & {
createdBy: {
id: string;
name: string;
image: string | null;
} | null;
})[];
};
// Find one by ID
const task = await db.query.task.findFirst({
where: (t) => eq(t.id, taskId),
});
// Find many with conditions
const tasks = await db.query.task.findMany({
where: (t) => and(
eq(t.organizationId, orgId),
eq(t.status, "in-progress")
),
});
const task = await db.query.task.findFirst({
where: (t) => eq(t.id, taskId),
with: {
labels: {
with: {
label: true, // Load the actual label object
},
},
createdBy: {
columns: {
id: true,
name: true,
image: true,
},
},
assignees: {
with: {
user: {
columns: {
id: true,
name: true,
image: true,
},
},
},
},
},
});
const users = await db
.select({
id: user.id,
name: user.name,
email: user.email,
})
.from(user)
.where(eq(user.organizationId, orgId));
const tasksWithOrgs = await db
.select({
task: task,
orgName: organization.name,
})
.from(task)
.innerJoin(organization, eq(task.organizationId, organization.id))
.where(eq(task.status, "done"));
const [newTask] = await db
.insert(schema.task)
.values({
organizationId: orgId,
title: "New Task",
description: "Task description",
createdById: userId,
})
.returning();
const newLabels = await db
.insert(schema.label)
.values([
{ organizationId: orgId, name: "Bug", color: "#ff0000" },
{ organizationId: orgId, name: "Feature", color: "#00ff00" },
{ organizationId: orgId, name: "Docs", color: "#0000ff" },
])
.returning();
const [updated] = await db
.update(schema.task)
.set({
title: "Updated Title",
status: "done",
updatedAt: new Date(),
})
.where(eq(schema.task.id, taskId))
.returning();
await db
.update(schema.task)
.set({ status: "canceled" })
.where(
and(
eq(schema.task.organizationId, orgId),
eq(schema.task.status, "backlog")
)
);
// Delete single record
await db
.delete(schema.task)
.where(eq(schema.task.id, taskId));
// Delete with conditions
await db
.delete(schema.taskLabel)
.where(
and(
eq(schema.taskLabel.taskId, taskId),
eq(schema.taskLabel.labelId, labelId)
)
);

Create reusable functions in packages/database/src/functions/:

packages/database/src/functions/task.ts
import { db, schema } from "../index";
import { eq, and } from "drizzle-orm";
import type { TaskWithLabels } from "../schema";
/**
* Fetches a single task by ID with all related data.
*/
export async function getTaskById(
taskId: string
): Promise<TaskWithLabels | null> {
const task = await db.query.task.findFirst({
where: (t) => eq(t.id, taskId),
with: {
labels: { with: { label: true } },
assignees: { with: { user: { columns: { id: true, name: true, image: true } } } },
createdBy: { columns: { id: true, name: true, image: true } },
comments: { with: { createdBy: { columns: { id: true, name: true, image: true } } } },
},
});
if (!task) return null;
// Transform join tables to clean arrays
return {
...task,
labels: task.labels.map((l) => l.label),
assignees: task.assignees.map((a) => a.user),
} as TaskWithLabels;
}
/**
* Creates a new task.
*/
export async function createTask(data: {
organizationId: string;
title: string;
description?: string;
createdById: string;
}): Promise<typeof schema.task.$inferSelect | null> {
const [task] = await db
.insert(schema.task)
.values(data)
.returning();
return task ?? null;
}
/**
* Updates a task by ID.
*/
export async function updateTask(
taskId: string,
data: Partial<typeof schema.task.$inferInsert>
): Promise<typeof schema.task.$inferSelect | null> {
const [updated] = await db
.update(schema.task)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.task.id, taskId))
.returning();
return updated ?? null;
}

After modifying schema files, push changes to the database:

Terminal window
pnpm -F @repo/database db:push

Visual database browser:

Terminal window
pnpm -F @repo/database db:studio
Terminal window
pnpm -F @repo/database db:generate

Instead of deleting, mark records as deleted:

// Schema
export const task = pgTable("task", {
// ... other fields
deletedAt: timestamp("deleted_at"),
});
// Query - exclude deleted
const tasks = await db.query.task.findMany({
where: (t) => isNull(t.deletedAt),
});
// Soft delete
await db
.update(schema.task)
.set({ deletedAt: new Date() })
.where(eq(schema.task.id, taskId));
const PAGE_SIZE = 20;
const tasks = await db.query.task.findMany({
where: (t) => eq(t.organizationId, orgId),
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
orderBy: (t, { desc }) => desc(t.createdAt),
});
await db.transaction(async (tx) => {
// All operations use `tx` instead of `db`
const [task] = await tx
.insert(schema.task)
.values({ title: "New Task", organizationId: orgId })
.returning();
await tx
.insert(schema.taskLabel)
.values({ taskId: task.id, labelId: labelId });
// If any operation fails, all are rolled back
});
  1. Always use CRUD functions - Don’t write raw queries in API routes; use functions from packages/database/src/functions/

  2. Return null for not found - Functions should return null when records don’t exist, not throw errors

  3. Use type inference - Let Drizzle infer types from schema rather than manually defining them

  4. Keep relations lean - Only load the columns you need in relations using columns: {}

  5. Transform join tables - Map join table results to clean arrays in your functions

  6. Update updatedAt - Always set updatedAt: new Date() in update operations