Skip to main content

Database Schema

Forja uses PostgreSQL 16 as its primary data store. Database access is handled through SQLx with compile-time checked queries. Migrations are managed by SQLx's built-in migration runner.

Required Extensions

The following PostgreSQL extensions are enabled in the first migration:

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";   -- UUID generation (uuid_generate_v4)
CREATE EXTENSION IF NOT EXISTS "citext"; -- Case-insensitive text type
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Trigram indexing for fuzzy search

Custom ENUM Types

The schema uses PostgreSQL enums for type-safe status fields:

EnumValues
environment_typedevelopment, staging, production
text_directionltr, rtl
user_roleowner, admin, editor, author, viewer
content_statusdraft, in_review, scheduled, published, archived
translation_statuspending, in_progress, review, approved, outdated
storage_providerlocal, cloudinary, s3, gcs, azure
block_typeparagraph, heading, image, list, code, quote, embed, divider, table
cv_entry_typework, education, volunteer, certification, project
page_typestatic, landing, contact, blog_index, custom
legal_doc_typecookie_consent, privacy_policy, terms_of_service, imprint, disclaimer
media_variant_typeoriginal, thumbnail, small, medium, large, webp, avif
skill_categoryprogramming, framework, database, devops, language, soft_skill, tool, other
section_typehero, features, cta, gallery, testimonials, pricing, faq, contact, custom
audit_actioncreate, read, update, delete, publish, unpublish, archive, login, logout, password_change, permission_grant, permission_revoke
api_key_permissionmaster, admin, write, read
api_key_statusactive, blocked, expired, revoked

Multi-Tenancy

All content in Forja is scoped to a site. The sites table is the root of the tenant hierarchy, and nearly every content table has a site_id foreign key with ON DELETE CASCADE.

sites
├── blogs
├── pages
│ └── page_sections
├── media
│ └── media_variants
├── navigation_menus
│ └── navigation_items
├── legal_documents
│ └── legal_groups
│ └── legal_items
├── cv_entries
│ └── skills
├── social_links
├── tags
├── categories
├── api_keys
├── webhooks
├── redirects
├── notifications
├── audit_logs
├── site_members
├── site_locales
└── site_settings

This design means:

  • A single deployment serves multiple independent sites.
  • Deleting a site cascades and removes all its content.
  • API keys can be scoped to a specific site or granted cross-site access.

Tables by Domain

Core Infrastructure

TablePurpose
sitesTenant root -- name, domain, description, default locale
localesGlobal locale registry (en, de, fr, etc.)
site_localesWhich locales are enabled for a given site
environmentsEnvironment definitions per site (dev, staging, prod)
usersUser records (synced from Clerk)
site_membersMaps Clerk users to sites with a role
system_adminsClerk user IDs with system-wide admin access

Content

TablePurpose
blogsBlog post metadata (slug, status, author, featured image)
blog_localizationsLocalized blog content (title, body, excerpt per locale)
pagesPage metadata (slug, type, status)
page_sectionsOrdered sections within a page
section_localizationsLocalized section content
documentsUploadable document files
content_templatesReusable content templates

Portfolio

TablePurpose
cv_entriesCV / resume entries (work, education, certifications)
skillsTechnical and soft skills with proficiency levels
TablePurpose
legal_documentsLegal document containers (privacy policy, imprint, etc.)
legal_groupsSections within a legal document
legal_itemsIndividual clauses or paragraphs within a group

Media

TablePurpose
mediaUploaded media files (images, documents, videos)
media_variantsGenerated variants (thumbnail, small, medium, large, webp, avif)
media_foldersFolder hierarchy for organizing media
TablePurpose
navigation_menusNamed menus per site (primary, footer, sidebar)
navigation_itemsMenu items with parent-child hierarchy
navigation_item_localizationsLocalized titles for menu items

Taxonomy

TablePurpose
tagsTags scoped to a site
categoriesCategories scoped to a site
content_tagsMany-to-many: content to tags
content_categoriesMany-to-many: content to categories

Social

TablePurpose
social_linksSocial media profile links per site

Operations

TablePurpose
api_keysAPI key records (hashed key, permission, rate limits)
audit_logsAction audit trail (who did what, when)
webhooksRegistered webhook endpoints per site
webhook_deliveriesDelivery attempts and status for each webhook event
notificationsIn-app notifications for site members
redirectsURL redirect rules (301/302) per site

Localization Pattern

Forja uses a content + localization table pattern for multilingual content. The base table holds language-independent fields (slug, status, timestamps), while a companion *_localizations table holds the translated fields (title, body, excerpt).

blogs                          blog_localizations
+--------+---------+ +--------+---------+--------+-------+
| id | site_id | | id | blog_id | locale | title |
| slug | status | 1 ───> * | body | excerpt | | |
| ... | ... | | ... | ... | | |
+--------+---------+ +--------+---------+--------+-------+

This pattern is used for:

  • Blogs (blog_localizations)
  • Page sections (section_localizations)
  • Navigation items (navigation_item_localizations)

Helper Functions

A shared trigger function automatically updates the updated_at column on row modification:

CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE 'plpgsql';

This trigger is attached to all tables that have an updated_at column.

Migrations

Migrations are SQL files in backend/migrations/, named with a timestamp prefix. They are run automatically on application startup via sqlx::migrate!("./migrations").

backend/migrations/
├── 20240101000000_extensions_and_enums.sql
├── 20240101000001_core_infrastructure.sql
├── 20240101000002_media.sql
├── 20240101000003_content.sql
├── 20240101000004_blogs.sql
├── 20240101000005_cv.sql
├── 20240101000006_pages.sql
├── 20240101000007_legal.sql
├── 20240101000008_social_navigation.sql
├── 20240101000009_taxonomy.sql
├── 20240101000010_audit.sql
├── 20240101000011_api_keys.sql
├── ...
└── 20240101000026_content_templates.sql

To create a new migration:

sqlx migrate add <description>

Migrations are applied in order and tracked in the _sqlx_migrations table. They are forward-only -- there are no down migrations.

Indexing Strategy

Key indexes include:

  • Primary keys: All tables use UUID primary keys generated by uuid_generate_v4().
  • Foreign keys: All site_id columns are indexed for fast tenant-scoped queries.
  • Unique constraints: Slugs are unique per site (e.g., UNIQUE(site_id, slug)).
  • Trigram indexes: Used on text fields for fuzzy search via pg_trgm.
  • Composite indexes: Common query patterns like (site_id, status) and (site_id, locale) have dedicated indexes.