logicspike/docs

Blog Engine

Blog System — Database Reference

Last Updated: 2026-06-28 Status: Active

Source of truth: apps/blog-service/src/db/schema.ts ORM: 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
  • 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 AND deleted_at IS NULL
  • Soft-delete: DELETE /admin/posts/:id sets deleted_at; admin can view deleted posts via ?deleted=true; POST /admin/posts/:id/restore clears the tombstone. Slug collision on restore → auto-append suffix.
  • Content HTML cache: content_html is written on every create/update. On public reads, if content_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 = 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. 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

  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.


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.

Blog Engine