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 (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.contacts
The core entity. Stores contact information with support for custom fields.
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— 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.
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.
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.csv_exports
Tracks CSV export jobs and their output files.workspace_settings
Per-workspace configuration. Usesworkspace_id as the primary key (one-to-one with workspaces).
Naming Conventions
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?
Backend Architecture
How the API accesses this schema through the repository layer.
Streaming Architecture
How large CSV files interact with the database efficiently.