Skip to content

Data Layer Guide ​

Diátaxis type: How-to Guide + Reference — problem-oriented patterns for database setup, schemas, queries, and migrations, with reference tables for configuration.

Table of Contents ​


Overview ​

Scratchy uses Drizzle ORM with PostgreSQL as the primary data layer. This guide covers database configuration, schema design, query patterns, migrations, and caching.

Stack ​

ComponentTechnology
ORMDrizzle ORM
DatabasePostgreSQL (>= 16)
Driverpg (node-postgres)
CacheRedis (DragonflyDB) + async-cache-dedupe
IDsULID (Universally Unique Lexicographically Sortable Identifier)
ValidationZod
MigrationsDrizzle Kit

Database Connection ​

Pool Configuration ​

typescript
// db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

const pool = new Pool({
  connectionString: DATABASE_URL,
  max: 100, // Maximum connections
  min: 10, // Minimum idle connections
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 10_000,
  keepAlive: true,
  keepAliveInitialDelayMillis: 10_000,
});

export const db = drizzle({
  schema: { mySchema, ...schemas },
  client: pool,
  casing: "snake_case",
});

Connection Health ​

  • TCP keepalive: Prevents network equipment from dropping idle connections
  • Pool error handlers: Log errors, don't crash — let the pool reconnect
  • Startup verification: await pool.query("SELECT 1") on boot

Schema Design ​

Entity Pattern ​

Every entity follows this structure:

typescript
// db/schema/<entity>.ts
import { relations } from "drizzle-orm";
import { boolean, index, text, timestamp } from "drizzle-orm/pg-core";
import { mySchema } from "~/db/my-schema.js";
import { timestamps } from "~/db/schema/columns.helpers.js";

// 1. Type exports
export type Post = typeof post.$inferSelect;
export type NewPost = typeof post.$inferInsert;

// 2. Enums
export const postStatus = mySchema.enum("post_status", [
  "draft",
  "published",
  "archived",
]);

// 3. Table definition
export const post = mySchema.table(
  "post",
  {
    id: text().primaryKey(), // ULID
    title: text().notNull(),
    slug: text().notNull().unique(),
    content: text(),
    status: postStatus().default("draft").notNull(),
    authorId: text()
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    publishedAt: timestamp({ withTimezone: true }),
    ...timestamps, // createdAt, updatedAt
  },
  (table) => [
    index("post_slug_idx").on(table.slug),
    index("post_author_idx").on(table.authorId),
    index("post_status_idx").on(table.status),
  ],
);

// 4. Relations
export const postRelations = relations(post, ({ one, many }) => ({
  author: one(user, {
    fields: [post.authorId],
    references: [user.id],
    relationName: "post_author",
  }),
  comments: many(comment, { relationName: "comment_post" }),
  tags: many(postTag, { relationName: "post_tags" }),
}));

Column Helpers ​

Shared columns used across all tables:

typescript
// db/schema/columns.helpers.ts
import { timestamp } from "drizzle-orm/pg-core";

export const timestamps = {
  createdAt: timestamp({ withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp({ withTimezone: true })
    .defaultNow()
    .$onUpdateFn(() => new Date())
    .notNull(),
};

Schema Namespace ​

Always use a custom schema instead of public:

typescript
// db/my-schema.ts
import { pgSchema } from "drizzle-orm/pg-core";

export const mySchema = pgSchema(process.env.DATABASE_SCHEMA || "app");

ID Strategy ​

Use ULID for all entity IDs:

typescript
import { ulid } from "ulid";

// Generate at insert time
const newPost = await db.insert(post).values({
  id: ulid(),
  title: "My Post",
  // ...
});

Why ULID over UUID?

  • Lexicographically sortable (natural time ordering in indexes)
  • Shorter string representation (26 chars vs 36)
  • Contains a timestamp component
  • URL-safe (no hyphens)

Query Patterns ​

Prepared Statements (Module-Scoped) ​

typescript
// db/queries/posts.ts
import { and, desc, eq, sql } from "drizzle-orm";
import { db } from "~/db/index.js";
import { post } from "~/db/schema/post.js";

// Prepared statements MUST be module-scoped
export const findPostById = db
  .select()
  .from(post)
  .where(eq(post.id, sql.placeholder("id")))
  .prepare("find_post_by_id");

export const findPublishedPosts = db
  .select()
  .from(post)
  .where(eq(post.status, "published"))
  .orderBy(desc(post.publishedAt))
  .prepare("find_published_posts");

export const findPostsByAuthor = db
  .select()
  .from(post)
  .where(eq(post.authorId, sql.placeholder("authorId")))
  .orderBy(desc(post.createdAt))
  .prepare("find_posts_by_author");

// Type exports for consumers
export type FindPostById = Awaited<ReturnType<typeof findPostById.execute>>;

Relational Queries ​

typescript
// Fetch with related data
const postsWithAuthors = await db.query.post.findMany({
  with: {
    author: true,
    comments: {
      limit: 5,
      orderBy: (comments, { desc }) => [desc(comments.createdAt)],
    },
  },
  where: eq(post.status, "published"),
  orderBy: (posts, { desc }) => [desc(posts.publishedAt)],
  limit: 20,
});

Pagination ​

typescript
import { count, desc, sql } from "drizzle-orm";

async function paginatedPosts(page: number, limit: number) {
  const offset = (page - 1) * limit;

  const [posts, [{ total }]] = await Promise.all([
    db
      .select()
      .from(post)
      .where(eq(post.status, "published"))
      .orderBy(desc(post.publishedAt))
      .limit(limit)
      .offset(offset),
    db
      .select({ total: count() })
      .from(post)
      .where(eq(post.status, "published")),
  ]);

  return {
    data: posts,
    meta: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  };
}
typescript
import { ilike, or } from "drizzle-orm";

async function searchPosts(query: string) {
  return db
    .select()
    .from(post)
    .where(
      or(ilike(post.title, `%${query}%`), ilike(post.content, `%${query}%`)),
    )
    .limit(50);
}

Mutation Patterns ​

Create ​

typescript
// db/mutations/posts.ts
import { ulid } from "ulid";

export async function createPost(data: Omit<NewPost, "id">) {
  const [newPost] = await db
    .insert(post)
    .values({ id: ulid(), ...data })
    .returning();
  return newPost;
}

Update ​

typescript
export async function updatePost(id: string, data: Partial<NewPost>) {
  const [updated] = await db
    .update(post)
    .set({ ...data, updatedAt: new Date() })
    .where(eq(post.id, id))
    .returning();
  return updated;
}

Delete ​

typescript
export async function deletePost(id: string) {
  const [deleted] = await db.delete(post).where(eq(post.id, id)).returning();
  return deleted;
}

Upsert ​

typescript
export async function upsertPost(data: NewPost) {
  const [result] = await db
    .insert(post)
    .values(data)
    .onConflictDoUpdate({
      target: post.slug,
      set: {
        title: data.title,
        content: data.content,
        updatedAt: new Date(),
      },
    })
    .returning();
  return result;
}

Transactions ​

typescript
export async function publishPostWithNotification(postId: string) {
  return db.transaction(async (tx) => {
    // Update post status
    const [published] = await tx
      .update(post)
      .set({
        status: "published",
        publishedAt: new Date(),
        updatedAt: new Date(),
      })
      .where(eq(post.id, postId))
      .returning();

    // Create notification for followers
    await tx.insert(notification).values({
      id: ulid(),
      type: "new_post",
      message: `New post: ${published.title}`,
      referenceId: published.id,
    });

    return published;
  });
}

Caching Layer ​

async-cache-dedupe ​

Wrap database reads with caching and request deduplication:

typescript
// lib/cache.ts
import { createCache } from "async-cache-dedupe";

export const cache = createCache({
  ttl: 60, // Default TTL in seconds
  stale: 300, // Stale-while-revalidate in seconds
  storage: {
    type: "redis",
    options: { client: redisClient },
  },
});

// Define cached functions
cache.define(
  "getPost",
  {
    ttl: 300,
    references: (args, key, result) => [`post:${args.id}`],
    serialize: ({ id }) => id,
  },
  async ({ id }: { id: string }) => {
    const [result] = await findPostById.execute({ id });
    return result;
  },
);

// Usage
const post = await cache.getPost({ id: "abc123" });

// Invalidation
await cache.invalidateAll([`post:abc123`]);

Cache Invalidation Strategy ​

EventInvalidate
Post createdposts:list
Post updatedpost:{id}, posts:list
Post deletedpost:{id}, posts:list
Comment addedpost:{postId}, comments:post:{postId}

Migrations ​

Workflow ​

bash
# 1. Modify schema files in src/db/schema/

# 2. Generate migration
pnpm drizzle-kit generate --config src/drizzle.config.ts

# 3. Review generated SQL in drizzle/ directory

# 4. Apply migration
pnpm drizzle-kit migrate --config src/drizzle.config.ts

Rules ​

  • Never edit generated .sql files — they are immutable
  • Always review generated SQL before applying
  • Test migrations in a staging environment first
  • Use drizzle-kit push only for development prototyping
  • Back up the database before applying migrations in production

Best Practices ​

  1. One file per entity — keep schemas organized and findable
  2. Module-scoped prepared statements — compile once, reuse everywhere
  3. Always use transactions for multi-table operations
  4. Index foreign keys — Drizzle doesn't auto-create FK indexes
  5. Use returning() — avoid extra SELECT after INSERT/UPDATE
  6. Use withTimezone: true on all timestamp columns
  7. Validate before inserting — use Zod schemas to validate data before passing to Drizzle
  8. Cache read-heavy queries — use async-cache-dedupe for frequently accessed data

Anti-Patterns ​

  • ❌ Don't create prepared statements inside functions — they re-prepare on every call
  • ❌ Don't use the default public schema — always use a custom pgSchema
  • ❌ Don't edit generated .sql migration files — they are immutable
  • ❌ Don't use drizzle-kit push in production — only for development prototyping
  • ❌ Don't skip indexes on foreign keys — Drizzle doesn't auto-create them
  • ❌ Don't return raw database responses to the client — map to response schemas with Zod
  • API Design — How tRPC and REST consume the data layer
  • CLI Scaffolding — Generate schemas, queries, and mutations automatically
  • Testing — Database isolation strategies for tests
  • Architecture — Where the data layer fits in the stack
  • Security — SQL injection prevention via parameterized queries
  • Error Handling — PostgreSQL error code mapping