home / skills / greyhaven-ai / claude-code-config / data-modeling

This skill designs multi-tenant database schemas using SQLModel and Drizzle, enforcing tenant_id, RLS, and robust relationships for Grey Haven apps.

npx playbooks add skill greyhaven-ai/claude-code-config --skill data-modeling

Review the files below or copy the command above to add this skill to your agents.

Files (4)
SKILL.md
8.6 KB
---
name: grey-haven-data-modeling
description: Design database schemas for Grey Haven multi-tenant SaaS - SQLModel models, Drizzle schema, multi-tenant isolation with tenant_id and RLS, timestamp fields, foreign keys, indexes, migrations, and relationships. Use when creating database tables.
# v2.0.43: Skills to auto-load for data modeling
skills:
  - grey-haven-code-style
  - grey-haven-database-conventions
# v2.0.74: Tools for data modeling
allowed-tools:
  - Read
  - Write
  - MultiEdit
  - Bash
  - Grep
  - Glob
  - TodoWrite
---

# Grey Haven Data Modeling Standards

Design **database schemas** for Grey Haven Studio's multi-tenant SaaS applications using SQLModel (FastAPI) and Drizzle ORM (TanStack Start) with PostgreSQL and RLS.

## Multi-Tenant Principles

### CRITICAL: Every Table Requires tenant_id

```typescript
// ✅ CORRECT - Drizzle
export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  tenant_id: uuid("tenant_id").notNull(), // REQUIRED!
  created_at: timestamp("created_at").defaultNow().notNull(),
  updated_at: timestamp("updated_at").defaultNow().notNull(),
  // ... other fields
});
```

```python
# ✅ CORRECT - SQLModel
class User(SQLModel, table=True):
    __tablename__ = "users"
    
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    tenant_id: UUID = Field(foreign_key="tenants.id", index=True) # REQUIRED!
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: datetime = Field(default_factory=datetime.utcnow)
    # ... other fields
```

### Naming Conventions

**ALWAYS use snake_case** (never camelCase):

```typescript
// ✅ CORRECT
email_address: text("email_address")
created_at: timestamp("created_at")
is_active: boolean("is_active")
tenant_id: uuid("tenant_id")

// ❌ WRONG
emailAddress: text("emailAddress")  // WRONG!
createdAt: timestamp("createdAt")   // WRONG!
```

### Standard Fields (Required on All Tables)

```typescript
// Every table should have:
id: uuid("id").primaryKey().defaultRandom()
created_at: timestamp("created_at").defaultNow().notNull()
updated_at: timestamp("updated_at").defaultNow().notNull()
tenant_id: uuid("tenant_id").notNull()
deleted_at: timestamp("deleted_at") // For soft deletes (optional)
```

## Core Tables

### 1. Tenants Table (Root)

```typescript
// Drizzle
export const tenants = pgTable("tenants", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  is_active: boolean("is_active").default(true).notNull(),
  created_at: timestamp("created_at").defaultNow().notNull(),
  updated_at: timestamp("updated_at").defaultNow().notNull(),
});
```

```python
# SQLModel
class Tenant(SQLModel, table=True):
    __tablename__ = "tenants"
    
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    name: str = Field(max_length=255)
    slug: str = Field(max_length=100, unique=True)
    is_active: bool = Field(default=True)
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: datetime = Field(default_factory=datetime.utcnow)
```

### 2. Users Table (With Tenant Isolation)

```typescript
// Drizzle
export const users = pgTable("users", {
  id: uuid("id").primaryKey().defaultRandom(),
  tenant_id: uuid("tenant_id").notNull(),
  email_address: text("email_address").notNull().unique(),
  full_name: text("full_name").notNull(),
  is_active: boolean("is_active").default(true).notNull(),
  created_at: timestamp("created_at").defaultNow().notNull(),
  updated_at: timestamp("updated_at").defaultNow().notNull(),
  deleted_at: timestamp("deleted_at"),
});

// Index for tenant_id
export const usersTenantIndex = index("users_tenant_id_idx").on(users.tenant_id);
```

```python
# SQLModel
class User(SQLModel, table=True):
    __tablename__ = "users"
    
    id: UUID = Field(default_factory=uuid4, primary_key=True)
    tenant_id: UUID = Field(foreign_key="tenants.id", index=True)
    email_address: str = Field(max_length=255, unique=True)
    full_name: str = Field(max_length=255)
    is_active: bool = Field(default=True)
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: datetime = Field(default_factory=datetime.utcnow)
    deleted_at: Optional[datetime] = None
```

## Relationships

### One-to-Many

```typescript
// Drizzle - User has many Posts
export const posts = pgTable("posts", {
  id: uuid("id").primaryKey().defaultRandom(),
  tenant_id: uuid("tenant_id").notNull(),
  user_id: uuid("user_id").notNull(),
  title: text("title").notNull(),
  // ... other fields
});

// Relations
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
  user: one(users, {
    fields: [posts.user_id],
    references: [users.id],
  }),
}));
```

### Many-to-Many

```typescript
// Drizzle - User has many Roles through UserRoles
export const user_roles = pgTable("user_roles", {
  id: uuid("id").primaryKey().defaultRandom(),
  tenant_id: uuid("tenant_id").notNull(),
  user_id: uuid("user_id").notNull(),
  role_id: uuid("role_id").notNull(),
  created_at: timestamp("created_at").defaultNow().notNull(),
});

// Indexes for join table
export const userRolesUserIndex = index("user_roles_user_id_idx").on(user_roles.user_id);
export const userRolesRoleIndex = index("user_roles_role_id_idx").on(user_roles.role_id);
```

## RLS Policies

### Enable RLS on All Tables

```sql
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

-- Tenant isolation policy
CREATE POLICY "tenant_isolation"
ON users
FOR ALL
USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Admin override policy
CREATE POLICY "admin_override"
ON users
FOR ALL
TO admin_role
USING (true);
```

## Indexes

### Required Indexes

```typescript
// ALWAYS index tenant_id
export const usersTenantIndex = index("users_tenant_id_idx").on(users.tenant_id);

// Index foreign keys
export const postsUserIndex = index("posts_user_id_idx").on(posts.user_id);

// Composite indexes for common queries
export const postsCompositeIndex = index("posts_tenant_user_idx")
  .on(posts.tenant_id, posts.user_id);
```

## Migrations

### Drizzle Kit

```bash
# Generate migration
bun run db:generate

# Apply migration
bun run db:migrate

# Rollback migration (manual)
```

### Alembic (SQLModel)

```bash
# Generate migration
alembic revision --autogenerate -m "add users table"

# Apply migration
alembic upgrade head

# Rollback migration
alembic downgrade -1
```

## Supporting Documentation

All supporting files are under 500 lines per Anthropic best practices:

- **[examples/](examples/)** - Complete schema examples
  - [drizzle-models.md](examples/drizzle-models.md) - Drizzle schema examples
  - [sqlmodel-models.md](examples/sqlmodel-models.md) - SQLModel examples
  - [relationships.md](examples/relationships.md) - Relationship patterns
  - [rls-policies.md](examples/rls-policies.md) - RLS policy examples
  - [INDEX.md](examples/INDEX.md) - Examples navigation

- **[reference/](reference/)** - Data modeling references
  - [naming-conventions.md](reference/naming-conventions.md) - Field naming rules
  - [indexes.md](reference/indexes.md) - Index strategies
  - [migrations.md](reference/migrations.md) - Migration patterns
  - [INDEX.md](reference/INDEX.md) - Reference navigation

- **[templates/](templates/)** - Copy-paste ready templates
  - [drizzle-table.ts](templates/drizzle-table.ts) - Drizzle table template
  - [sqlmodel-table.py](templates/sqlmodel-table.py) - SQLModel table template

- **[checklists/](checklists/)** - Schema checklists
  - [schema-checklist.md](checklists/schema-checklist.md) - Pre-PR schema validation

## When to Apply This Skill

Use this skill when:
- Creating new database tables
- Designing multi-tenant data models
- Adding relationships between tables
- Creating RLS policies
- Generating database migrations
- Refactoring existing schemas
- Implementing soft deletes
- Adding indexes for performance

## Template Reference

These patterns are from Grey Haven's production templates:
- **cvi-template**: Drizzle ORM + PostgreSQL + RLS
- **cvi-backend-template**: SQLModel + PostgreSQL + Alembic

## Critical Reminders

1. **tenant_id**: Required on EVERY table (no exceptions!)
2. **snake_case**: All fields use snake_case (NEVER camelCase)
3. **Timestamps**: created_at and updated_at on all tables
4. **Indexes**: Always index tenant_id and foreign keys
5. **RLS policies**: Enable RLS on all tables for tenant isolation
6. **Soft deletes**: Use deleted_at instead of hard deletes
7. **Foreign keys**: Explicitly define relationships
8. **Migrations**: Test both up and down migrations
9. **Email fields**: Name as email_address (not email)
10. **Boolean fields**: Use is_/has_/can_ prefix

Overview

This skill designs database schemas for Grey Haven multi-tenant SaaS using SQLModel and Drizzle ORM with PostgreSQL and Row Level Security. It enforces tenant isolation, standard timestamps, naming conventions, indexes, foreign keys, relationships, and migration guidance. Use it when creating or refactoring tables to meet Grey Haven production standards.

How this skill works

The skill generates table definitions and model classes that always include tenant_id, created_at, and updated_at. It produces Drizzle pgTable and SQLModel class patterns, index recommendations, relationship wiring (one-to-many, many-to-many), RLS policy templates, and migration commands for Drizzle Kit and Alembic. Outputs include explicit foreign keys, composite indexes for common query patterns, and soft-delete support via deleted_at.

When to use it

  • Creating new database tables for a multi-tenant service
  • Designing or refactoring multi-tenant data models to enforce isolation
  • Adding relationships (one-to-many, many-to-many) with proper FK and indexes
  • Implementing Row Level Security policies and tenant isolation
  • Generating and testing migrations with Drizzle Kit or Alembic

Best practices

  • Always include tenant_id on every table and index it for query performance
  • Use snake_case for all identifiers and prefixes like is_/has_/can_ for booleans
  • Add created_at and updated_at to every table; use deleted_at for soft deletes
  • Define explicit foreign keys and add indexes on FK columns and composite keys
  • Enable RLS on all tables and create a tenant_isolation policy using current_setting('app.tenant_id')::uuid
  • Test both up and down migrations locally and in CI before deploying

Example use cases

  • Create a tenants table with slug and is_active as the root of tenant isolation
  • Define a users table with tenant_id, email_address, full_name, timestamps, and deleted_at
  • Model posts with tenant_id and user_id, plus composite index on (tenant_id, user_id)
  • Build a user_roles join table for many-to-many roles with tenant_id and indexes on user_id and role_id
  • Add RLS policies to existing tables and roll out current_setting-based tenant enforcement

FAQ

Is tenant_id truly required on every table?

Yes. tenant_id is mandatory on every table for tenant isolation and must be indexed.

Should I use camelCase for fields in models?

No. Always use snake_case for table and column names (e.g., email_address, created_at).