Skip to main content

Database Schema

folksbase uses Neon (serverless Postgres) with Drizzle ORM. The schema lives in packages/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 (except workspace_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_id foreign key pointing to workspaces
  • All queries filter by workspace_id to 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 accepts workspaceId as a parameter and includes it in the WHERE clause. This keeps the code straightforward and the database simple.
// Every repository query includes workspace_id
export async function findById(workspaceId: string, id: string) {
  return db.query.contacts.findFirst({
    where: and(eq(contacts.id, id), eq(contacts.workspace_id, workspaceId)),
  });
}

Tables

workspaces

The top-level tenant. Every other table references this.
ColumnTypeConstraintsDescription
iduuidPK, auto-generatedWorkspace identifier
namevarchar(200)NOT NULLDisplay name
slugvarchar(100)UNIQUE, NOT NULLURL-friendly identifier
created_attimestampDEFAULT now()Creation timestamp

contacts

The core entity. Stores contact information with support for custom fields.
ColumnTypeConstraintsDescription
iduuidPK, auto-generatedContact identifier
workspace_iduuidFK → workspaces (CASCADE), NOT NULLOwning workspace
emailvarchar(254)NOT NULLEmail address
first_namevarchar(100)nullableFirst name
last_namevarchar(100)nullableLast name
phonevarchar(50)nullablePhone number
companyvarchar(200)nullableCompany name
avatar_urltextnullableGravatar URL (auto-fetched)
notestextnullableFree-text notes
custom_fieldsjsonbnullable, GIN indexedArbitrary key-value pairs
is_unsubscribedbooleanNOT NULL, DEFAULT falseUnsubscribe flag
import_iduuidnullableWhich import created this contact
created_attimestampDEFAULT now()Creation timestamp
updated_attimestampDEFAULT now()Last update timestamp
Indexes:
  • 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 — on workspace_id. Speeds up workspace-scoped queries.
  • contacts_email_idx — on email. Speeds up email lookups.
  • contacts_custom_fields_gin_idx — GIN index on custom_fields. Enables fast JSONB queries.
  • contacts_created_at_idx — on created_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

The custom_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:
{ "Department": "Engineering", "Employee ID": "12345" }
A GIN index on this column enables fast queries against custom field values. GIN (Generalized Inverted Index) is Postgres’s way of indexing structured data inside JSONB — it creates an index entry for each key-value pair, so queries like “find all contacts where custom_fields contains key X” are fast regardless of how many different custom fields exist.

tags

Labels that can be attached to contacts for organization and filtering.
ColumnTypeConstraintsDescription
iduuidPK, auto-generatedTag identifier
workspace_iduuidFK → workspaces (CASCADE), NOT NULLOwning workspace
namevarchar(100)NOT NULLTag name
colorvarchar(20)nullableDisplay color
emojivarchar(10)nullableDisplay emoji
Indexes:
  • 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.
ColumnTypeConstraintsDescription
contact_iduuidFK → contacts (CASCADE), NOT NULLThe contact
tag_iduuidFK → tags (CASCADE), NOT NULLThe tag
Primary key: Composite on (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.
ColumnTypeConstraintsDescription
iduuidPK, auto-generatedImport identifier
workspace_iduuidFK → workspaces (CASCADE), NOT NULLOwning workspace
filenamevarchar(500)NOT NULLOriginal filename (sanitized)
blob_urltextnullableVercel Blob storage URL
statusvarchar(50)DEFAULT ‘pending’One of: pending, processing, completed, failed
total_rowsintegerDEFAULT 0Total rows in the CSV
processed_rowsintegerDEFAULT 0Rows successfully inserted
updated_rowsintegerDEFAULT 0Rows updated (duplicates)
failed_rowsintegerDEFAULT 0Rows that failed validation
column_mappingjsonbnullableUser-confirmed column mapping
error_logjsonbnullableError samples from failed rows
ai_suggestionsjsonbnullableAI-generated column mapping suggestions
summarytextnullableAI-generated import summary
created_attimestampDEFAULT now()Upload timestamp
completed_attimestampnullableProcessing completion timestamp

csv_exports

Tracks CSV export jobs and their output files.
ColumnTypeConstraintsDescription
iduuidPK, auto-generatedExport identifier
workspace_iduuidFK → workspaces (CASCADE), NOT NULLOwning workspace
filenamevarchar(500)NOT NULLOutput filename
blob_urltextnullableVercel Blob storage URL
statusvarchar(50)DEFAULT ‘pending’One of: pending, processing, completed, failed
total_rowsintegerDEFAULT 0Total rows exported
processed_rowsintegerDEFAULT 0Rows written so far
tag_idsjsonbnullableTag filter applied to the export
error_messagetextnullableError message if failed
created_attimestampDEFAULT now()Export start timestamp
completed_attimestampnullableExport completion timestamp

workspace_settings

Per-workspace configuration. Uses workspace_id as the primary key (one-to-one with workspaces).
ColumnTypeConstraintsDescription
workspace_iduuidPK, FK → workspaces (CASCADE)Owning workspace
resend_api_keytextnullableEncrypted Resend API key (AES-256-GCM)
notify_on_import_completebooleanDEFAULT trueEmail on import completion
notify_on_export_completebooleanDEFAULT trueEmail on export completion
notify_weekly_digestbooleanDEFAULT trueWeekly digest email

Naming Conventions

ElementConventionExample
Table namessnake_case pluralcontacts, csv_imports, contact_tags
Column namessnake_caseworkspace_id, first_name, created_at
Index names{table}_{column}_idxcontacts_workspace_id_idx
Unique constraints{table}_{columns}_uniquecontacts_email_workspace_unique

Why These Choices?

Why UUIDs for primary keys?

UUIDs are generated client-side (or by Postgres), so there’s no need to query the database to get the next ID. This matters for batch inserts during CSV import — you can generate all IDs upfront without round-trips.

Why JSONB for custom fields?

Custom fields are inherently schema-less — every CSV file can have different columns. JSONB lets you store arbitrary key-value pairs without altering the table schema. The GIN index keeps queries fast even as the variety of custom fields grows.

Why CASCADE deletes?

When a workspace is deleted, all its data should go with it. CASCADE deletes handle this automatically at the database level, so the application code doesn’t need to manually clean up related records.

What’s Next?