Use this file to discover all available pages before exploring further.
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.
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.
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_idexport async function findById(workspaceId: string, id: string) { return db.query.contacts.findFirst({ where: and(eq(contacts.id, id), eq(contacts.workspace_id, workspaceId)), });}
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:
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.
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
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.
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.
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.
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.