logicspike/docs

Blog Engine

Blog System — Database Reference

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)


1. Tables at a Glance

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)

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
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

Indexes

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

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
  • 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

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 = 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)

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_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

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. Relation Map

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)

7. 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

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

  1. Edit apps/blog-service/src/db/schema.ts
  2. Run npm run generate — creates a new .sql file in drizzle/
  3. Review the generated SQL
  4. Run npm run migrate to apply

Never edit existing migration files. Always generate new ones.


8. Env Variables

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).

Blog Engine