Database Schema
folksbase uses Neon (serverless Postgres) with Drizzle ORM. The schema lives inpackages/db/src/schema/ and is shared across the API and background jobs.
This page documents every table, their relationships, and the design decisions behind the data model.
Entity Relationships
Every table (exceptworkspace_settings, which uses workspace_id as its primary key) has a UUID primary key generated by Postgres. All data is scoped to a workspace.
Multi-Tenancy Model
folksbase uses workspace-scoped multi-tenancy. This means:- Every table has a
workspace_idforeign key pointing toworkspaces - All queries filter by
workspace_idto isolate data between workspaces - Data isolation happens at the query level, not at the database level (no separate schemas or databases per tenant)
Why query-level isolation?
It’s the simplest approach that works for the scale folksbase targets. There’s no need for separate Postgres schemas or row-level security policies — every repository method acceptsworkspaceId as a parameter and includes it in the WHERE clause. This keeps the code straightforward and the database simple.
Tables
workspaces
The top-level tenant. Every other table references this.| Column | Type | Constraints | Description |
|---|---|---|---|
id | uuid | PK, auto-generated | Workspace identifier |
name | varchar(200) | NOT NULL | Display name |
slug | varchar(100) | UNIQUE, NOT NULL | URL-friendly identifier |
created_at | timestamp | DEFAULT now() | Creation timestamp |
contacts
The core entity. Stores contact information with support for custom fields.| Column | Type | Constraints | Description |
|---|---|---|---|
id | uuid | PK, auto-generated | Contact identifier |
workspace_id | uuid | FK → workspaces (CASCADE), NOT NULL | Owning workspace |
email | varchar(254) | NOT NULL | Email address |
first_name | varchar(100) | nullable | First name |
last_name | varchar(100) | nullable | Last name |
phone | varchar(50) | nullable | Phone number |
company | varchar(200) | nullable | Company name |
avatar_url | text | nullable | Gravatar URL (auto-fetched) |
notes | text | nullable | Free-text notes |
custom_fields | jsonb | nullable, GIN indexed | Arbitrary key-value pairs |
is_unsubscribed | boolean | NOT NULL, DEFAULT false | Unsubscribe flag |
import_id | uuid | nullable | Which import created this contact |
created_at | timestamp | DEFAULT now() | Creation timestamp |
updated_at | timestamp | DEFAULT now() | Last update timestamp |
contacts_email_workspace_unique— UNIQUE on(email, workspace_id). Prevents duplicate emails within a workspace. Used by upsert during CSV import.contacts_workspace_id_idx— onworkspace_id. Speeds up workspace-scoped queries.contacts_email_idx— onemail. Speeds up email lookups.contacts_custom_fields_gin_idx— GIN index oncustom_fields. Enables fast JSONB queries.contacts_created_at_idx— oncreated_at. Speeds up the 30-day growth query.contacts_workspace_created_idx— on(workspace_id, created_at). Speeds up workspace-scoped time-range queries.
The custom_fields JSONB Column
Thecustom_fields column stores arbitrary key-value pairs as JSONB. When a CSV file has columns that don’t map to standard contact fields (email, name, phone, etc.), those columns are stored here.
For example, if a CSV has a “Department” column that the user doesn’t map to any standard field, it becomes:
tags
Labels that can be attached to contacts for organization and filtering.| Column | Type | Constraints | Description |
|---|---|---|---|
id | uuid | PK, auto-generated | Tag identifier |
workspace_id | uuid | FK → workspaces (CASCADE), NOT NULL | Owning workspace |
name | varchar(100) | NOT NULL | Tag name |
color | varchar(20) | nullable | Display color |
emoji | varchar(10) | nullable | Display emoji |
tags_workspace_name_unique— UNIQUE on(workspace_id, name). Prevents duplicate tag names within a workspace.
contact_tags
Junction table for the many-to-many relationship between contacts and tags. A contact can have multiple tags, and a tag can be applied to multiple contacts.| Column | Type | Constraints | Description |
|---|---|---|---|
contact_id | uuid | FK → contacts (CASCADE), NOT NULL | The contact |
tag_id | uuid | FK → tags (CASCADE), NOT NULL | The tag |
(contact_id, tag_id). This prevents duplicate tag assignments and serves as the index for lookups in both directions.
Both foreign keys use CASCADE delete — when a contact or tag is deleted, the junction rows are automatically cleaned up.
csv_imports
Tracks CSV file uploads and their processing status.| Column | Type | Constraints | Description |
|---|---|---|---|
id | uuid | PK, auto-generated | Import identifier |
workspace_id | uuid | FK → workspaces (CASCADE), NOT NULL | Owning workspace |
filename | varchar(500) | NOT NULL | Original filename (sanitized) |
blob_url | text | nullable | Vercel Blob storage URL |
status | varchar(50) | DEFAULT ‘pending’ | One of: pending, processing, completed, failed |
total_rows | integer | DEFAULT 0 | Total rows in the CSV |
processed_rows | integer | DEFAULT 0 | Rows successfully inserted |
updated_rows | integer | DEFAULT 0 | Rows updated (duplicates) |
failed_rows | integer | DEFAULT 0 | Rows that failed validation |
column_mapping | jsonb | nullable | User-confirmed column mapping |
error_log | jsonb | nullable | Error samples from failed rows |
ai_suggestions | jsonb | nullable | AI-generated column mapping suggestions |
summary | text | nullable | AI-generated import summary |
created_at | timestamp | DEFAULT now() | Upload timestamp |
completed_at | timestamp | nullable | Processing completion timestamp |
csv_exports
Tracks CSV export jobs and their output files.| Column | Type | Constraints | Description |
|---|---|---|---|
id | uuid | PK, auto-generated | Export identifier |
workspace_id | uuid | FK → workspaces (CASCADE), NOT NULL | Owning workspace |
filename | varchar(500) | NOT NULL | Output filename |
blob_url | text | nullable | Vercel Blob storage URL |
status | varchar(50) | DEFAULT ‘pending’ | One of: pending, processing, completed, failed |
total_rows | integer | DEFAULT 0 | Total rows exported |
processed_rows | integer | DEFAULT 0 | Rows written so far |
tag_ids | jsonb | nullable | Tag filter applied to the export |
error_message | text | nullable | Error message if failed |
created_at | timestamp | DEFAULT now() | Export start timestamp |
completed_at | timestamp | nullable | Export completion timestamp |
workspace_settings
Per-workspace configuration. Usesworkspace_id as the primary key (one-to-one with workspaces).
| Column | Type | Constraints | Description |
|---|---|---|---|
workspace_id | uuid | PK, FK → workspaces (CASCADE) | Owning workspace |
resend_api_key | text | nullable | Encrypted Resend API key (AES-256-GCM) |
notify_on_import_complete | boolean | DEFAULT true | Email on import completion |
notify_on_export_complete | boolean | DEFAULT true | Email on export completion |
notify_weekly_digest | boolean | DEFAULT true | Weekly digest email |
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Table names | snake_case plural | contacts, csv_imports, contact_tags |
| Column names | snake_case | workspace_id, first_name, created_at |
| Index names | {table}_{column}_idx | contacts_workspace_id_idx |
| Unique constraints | {table}_{columns}_unique | contacts_email_workspace_unique |