Last Updated: 2026-05-06
Status: Active
Source of truth : apps/blog-service/src/db/schema.ts
ORM : Drizzle ORM (type-safe, SQL-first)
DB : Neon PostgreSQL (serverless)
Table
Rows represent
Multi-tenant
blog_posts
Individual blog posts (draft, published, scheduled)
tenant_id NOT NULL
blog_categories
Post categories (one category per post)
tenant_id NOT NULL
blog_tags
Tags (many tags per post, via junction)
tenant_id NOT NULL
blog_post_tags
M:N junction between posts and tags
(inherits from FK)
The main table. Every post belongs to exactly one tenant; categories and tags are optional.
Column
Type
Constraints
Notes
id
text
PK
Format: post_{uuid} — generated by post.utils.ts
tenant_id
text
NOT NULL
Multi-tenant isolation key
author_id
text
nullable
Set from x-user-id header at create time
category_id
text
FK → blog_categories.id , onDelete: SET NULL
Null = uncategorized; cascades to null if category deleted
title
text
NOT NULL
Max 255 chars (Zod validation)
slug
text
NOT NULL, unique per tenant
Auto-generated from title; validated unique within tenant
content_json
jsonb
NOT NULL, defaults {}
TipTap editor JSON document
excerpt
text
nullable
Short summary for cards and meta description fallback
featured_image_url
text
nullable
Full URL to cover/header image
status
text
NOT NULL
"draft" | "published" | "scheduled"
seo_title
text
nullable
Custom <title> tag (overrides post title)
seo_description
text
nullable
Meta description
scheduled_for
timestamp
nullable
Target publish time; status = "scheduled" when set
published_at
timestamp
nullable
Set on publish, cleared on unpublish
created_at
timestamp
NOT NULL, defaultNow()
Immutable after insert
updated_at
timestamp
NOT NULL, defaultNow()
Updated on every save
Index
Columns
Type
Purpose
blog_posts_tenant_slug_unique
(tenant_id, slug)
Unique
Prevents duplicate slugs within a tenant; enforces SEO URL uniqueness
blog_posts_tenant_idx
tenant_id
B-tree
Base filter — nearly every query starts with WHERE tenant_id = ?
blog_posts_tenant_status_idx
(tenant_id, status)
B-tree
Admin list filter by status (draft/published/scheduled)
blog_posts_tenant_slug_idx
(tenant_id, slug)
B-tree
Public slug lookup: WHERE tenant_id = ? AND slug = ?
blog_posts_tenant_published_at_idx
(tenant_id, published_at)
B-tree
Public list hot path: WHERE tenant_id = ? AND published_at IS NOT NULL ORDER BY published_at DESC
blog_posts_tenant_updated_at_idx
(tenant_id, updated_at)
B-tree
Admin list hot path: ORDER BY updated_at DESC
blog_posts_tenant_category_idx
(tenant_id, category_id)
B-tree
Category filter on both admin and public list
Slug is auto-generated from title: lowercase, non-alphanumeric → - , leading/trailing - stripped
Slug uniqueness is checked within tenant_id — two tenants can have the same slug
On collision: appends -1 , -2 , ... up to 100 attempts; falls back to 8-char UUID fragment
Slug is re-generated when title changes — old URLs break; there is no redirect handling
status transitions: draft ↔ published , draft ↔ scheduled , scheduled → published
published_at is set to NOW() on publish, cleared to null on unpublish
Public API only returns posts where published_at IS NOT NULL
One category per post (a post has at most one primary category). Categories are tenant-scoped.
Column
Type
Constraints
Notes
id
text
PK
Format: cat_{uuid}
tenant_id
text
NOT NULL
name
text
NOT NULL
Max 100 chars (Zod validation)
slug
text
NOT NULL, unique per tenant
Auto-generated from name
created_at
timestamp
NOT NULL, defaultNow()
Index
Columns
Purpose
blog_categories_tenant_slug_unique
(tenant_id, slug)
Unique per tenant
blog_categories_tenant_idx
tenant_id
Filter by tenant
blog_categories_tenant_slug_idx
(tenant_id, slug)
Slug lookup
Deleting a category sets category_id = NULL on all affected posts (FK onDelete: SET NULL )
No description or cover_image_url in the current schema (those are in the vision doc)
No parent_id — categories are flat, not hierarchical (hierarchical is future)
Flat tag system. A post can have many tags via the junction table.
Column
Type
Constraints
Notes
id
text
PK
Format: tag_{uuid}
tenant_id
text
NOT NULL
name
text
NOT NULL
Max 50 chars (Zod validation)
slug
text
NOT NULL, unique per tenant
Auto-generated from name
Index
Columns
Purpose
blog_tags_tenant_slug_unique
(tenant_id, slug)
Unique per tenant
blog_tags_tenant_idx
tenant_id
Filter by tenant
blog_tags_tenant_slug_idx
(tenant_id, slug)
Slug lookup
blog_tags has no created_at or updated_at columns — unlike categories. Known gap.
Deleting a tag cascade-deletes all rows in blog_post_tags where tag_id matches
M:N junction table between posts and tags.
Column
Type
Constraints
post_id
text
FK → blog_posts.id , onDelete: CASCADE
tag_id
text
FK → blog_tags.id , onDelete: CASCADE
Primary key : composite (post_id, tag_id) — enforces uniqueness of the pair
Index
Columns
Purpose
blog_post_tags_post_id_idx
post_id
Fetch all tags for a post
blog_post_tags_tag_id_idx
tag_id
Fetch all posts for a tag
Both cascades are onDelete: CASCADE — deleting a post removes all its tag junctions; deleting a tag removes all post associations
Tag updates on a post are done as delete-all-then-insert, not diff. This is safe because the whole operation is in a transaction.
blog_categories ──┐
│ one category (nullable FK)
blog_posts ───────┤
│ many tags (via junction)
blog_post_tags ───┤
│
blog_tags ────────┘
One post → one category (or null)
One post → many tags (via blog_post_tags )
One category → many posts
One tag → many posts (via blog_post_tags )
Migration files live in apps/blog-service/drizzle/ . They are applied with drizzle-kit.
File
What it created
0000_wild_sage.sql
Initial schema: blog_posts
0001_fluffy_hedge_knight.sql
blog_categories , blog_tags , blog_post_tags ; added category_id FK to blog_posts
0002_new_maelstrom.sql
Added featured_image_url , scheduled_for ; added performance indexes
# From apps/blog-service/
npm run migrate # Apply pending migrations
npm run generate # Generate new migration from schema changes
npm run db:push # Push schema directly (dev only — bypasses migration files)
npm run studio # Open Drizzle Studio (DB browser)
Edit apps/blog-service/src/db/schema.ts
Run npm run generate — creates a new .sql file in drizzle/
Review the generated SQL
Run npm run migrate to apply
Never edit existing migration files. Always generate new ones.
Variable
Service
Purpose
BLOG_DATABASE_URL
blog-service
Neon connection string for the blog database
CORE_DATABASE_URL
blog-service
Neon connection string for the core/manager database (tenant credits, subscriptions)
Both must point to the same Neon project (different databases or branches are fine; the project-level connection pooler is shared).