Last Updated: 2026-06-28 Status: Active
Source of truth:
apps/blog-service/src/db/schema.tsORM: Drizzle ORM (type-safe, SQL-first) DB: Neon PostgreSQL — two connections:BLOG_DATABASE_URL(blog tables) +CORE_DATABASE_URL(billing, shared core)
1. Tables at a Glance
| Table | Rows represent | Multi-tenant |
|---|---|---|
blog_posts |
Individual blog posts (draft, published, scheduled, soft-deleted) | 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) |
blog_overview_snapshots |
Stale-while-revalidate cache for the dashboard overview | tenant_id PK |
2. blog_posts
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 |
content_html |
text |
nullable | Pre-rendered HTML cache. Set on every content mutation; re-rendered lazily if missing or version mismatch. |
content_html_version |
smallint |
nullable | Renderer version stamp (RENDERER_VERSION = 1). Triggers re-render when bumped. |
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 |
deleted_at |
timestamp |
nullable | Soft-delete tombstone. NULL = live. Set by DELETE /admin/posts/:id; cleared by POST /admin/posts/:id/restore. |
Indexes
| Index | Columns | Type | Purpose |
|---|---|---|---|
blog_posts_tenant_slug_unique |
(tenant_id, slug) |
Unique partial (WHERE deleted_at IS NULL) |
Prevents duplicate slugs among live posts; soft-deleted posts free their slug |
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 |
blog_posts_tenant_status_published_at_idx |
(tenant_id, status, published_at) |
B-tree | Status-filtered list sorted by publish date |
Business Rules
- 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
statustransitions:draft ↔ published,draft ↔ scheduled,scheduled → publishedpublished_atis set toNOW()on publish, cleared tonullon unpublish- Public API only returns posts where
published_at IS NOT NULL AND deleted_at IS NULL - Soft-delete:
DELETE /admin/posts/:idsetsdeleted_at; admin can view deleted posts via?deleted=true;POST /admin/posts/:id/restoreclears the tombstone. Slug collision on restore → auto-append suffix. - Content HTML cache:
content_htmlis written on every create/update. On public reads, ifcontent_html IS NOT NULL AND content_html_version = RENDERER_VERSION, the cached HTML is served directly; otherwise it is live-rendered and backfilled asynchronously.
3. blog_categories
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() |
Indexes
| 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 |
Notes
- Deleting a category sets
category_id = NULLon all affected posts (FKonDelete: SET NULL) - No
descriptionorcover_image_urlin the current schema (those are in the vision doc) - No
parent_id— categories are flat, not hierarchical (hierarchical is future)
4. blog_tags
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 |
Indexes
| 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 |
Notes
blog_tagshas nocreated_atorupdated_atcolumns — unlike categories. Known gap.- Deleting a tag cascade-deletes all rows in
blog_post_tagswheretag_idmatches
5. blog_post_tags
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
Indexes
| 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 |
Notes
- 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.
6. blog_overview_snapshots
Stale-while-revalidate (SWR) cache for the GET /admin/posts/overview endpoint. One row per tenant; recomputed in the background when stale.
| Column | Type | Notes |
|---|---|---|
tenant_id |
text PK |
One row per tenant |
payload |
jsonb NOT NULL |
Full OverviewPayload object (counts, deltas, health flags, recent edits, upcoming scheduled, top categories/tags) |
computed_at |
timestamp NOT NULL |
Freshness timestamp. TTL: 60 seconds. |
SWR behavior:
- Fresh (
now() - computed_at < 60s) → return immediately - Stale → return stale payload + kick off background recompute via
waitUntil() - Missing → compute synchronously, persist, return
Invalidation: Any mutation (create, update, delete, restore, publish, unpublish, schedule, unschedule) resets computed_at = epoch to force a recompute on the next overview request.
7. Relation Map
blog_categories ──┐
│ one category (nullable FK)
blog_posts ───────┤
│ many tags (via junction)
blog_post_tags ───┤
│
blog_tags ────────┘
blog_overview_snapshots (one row per tenant — independent cache table)- 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)
8. Migrations
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 |
| Later migrations | deleted_at (soft-delete + restore), content_html + content_html_version (HTML cache), blog_overview_snapshots table, blog_posts_tenant_status_published_at_idx index |
Running Migrations
# 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)Adding a New Column
- Edit
apps/blog-service/src/db/schema.ts - Run
npm run generate— creates a new.sqlfile indrizzle/ - Review the generated SQL
- Run
npm run migrateto apply
Never edit existing migration files. Always generate new ones.
9. Env Variables
| Variable | Service | Purpose |
|---|---|---|
BLOG_DATABASE_URL |
blog-service | Neon connection string for the blog database (owns all 5 blog tables) |
CORE_DATABASE_URL |
blog-service | Neon connection string for the core/manager database (tenant_credits, credit_transactions, subscriptions) — used by credit billing. Required for publish to work. |
The two databases can be in the same Neon project (separate databases) or different projects. They share no FK constraints — the relationship is resolved at runtime.