DocupDocup
Esc

Database

The Postgres tables behind Docup, how drafts and live pages are stored, and how migrations work.

Postgres is the source of truth for everything in Docup. The schema is defined with Drizzle, and the client helper builds a database client per request over Hyperdrive.

Entity overview#

Text
user ──< member >── organization ──< project ──1 docs_site
                        │               ├──< doc_page
                        │               ├──< sync_run
                        │               └──< site_asset
                        └──< github_installation

Application tables#

project#

One repository and one docs site. Columns include the owning organization_id, name, source (public_url or github_app), github_repo_id, full_name, default_branch, is_private, installation_id, status and last_synced_sha. A repository is unique per workspace.

status moves creating → generating (the first run) → draft_ready. last_synced_sha is the commit the docs describe, and updates compare from it.

docs_site#

The hosted site of a project, exactly one per project. It holds the unique subdomain, visibility, published_at, and all site settings:

  • AI steering: instructions, ignore_paths
  • Search: seo_title, seo_description, allow_indexing, allow_ai_crawlers, llms_enabled, robots_extra, og_mode
  • Brand: accent_color, heading_font, body_font, default_theme, show_site_name
  • Features: ask_enabled
  • Custom domains: custom_domain (the verified host), custom_domain_id (Cloudflare SaaS hostname ID), custom_domain_active_at, custom_domain_added_at, custom_domain_checked_at

published_at is null until the first publish.

doc_page#

One page. The key columns come in pairs, draft and live:

Draft Live
markdown published_markdown
path published_path
position published_position
published_at (null while not live)

Other columns: title, summary, source_paths, status (pending, written, failed), error, seo_title, seo_description, noindex, ai_markdown (the last text the AI wrote), edited, suggested_markdown, suggested_sha, sources_removed and updated_at. (project_id, path) is unique.

Publishing runs as a transaction that copies markdown to published_markdown and sets published_at for pages with text that need publishing. It also copies path to published_path and position to published_position for all live pages in the project so their live paths never clash. See Drafts and live pages.

Unpublishing a site clears published_markdown, published_path, published_position, and published_at across all pages of the project, and resets the site's published_at timestamp. Unpublishing a single page clears its live columns; if no live pages remain, the site is unpublished as well.

sync_run#

One run of the pipeline. kind is sync, page_retry or update. It records status, step, from_sha and to_sha, files_read and files_skipped, the changes of an update as JSON, and an error. page_path names the page of a retry. It also tracks AI usage with tokens_in and tokens_out to measure the cost of the run.

site_asset#

A row per uploaded image (og, favicon, logo, logo_dark) with its content type and upload time. The bytes are in R2 under a site folder. The upload time is used for cache busting.

github_installation#

A GitHub App installation a workspace connected: installation_id, account_login and account_type. Saved only after the signed-in user proved to GitHub that they can see it.

Authentication tables#

Generated by Better Auth: user, session, account, verification, organization, member and invitation. Regenerate with pnpm db:auth-schema when the plugin list changes. Keep it in sync with the CLI auth configuration file.

Migrations#

Migrations are SQL files, generated by drizzle-kit.

Bash
pnpm db:generate   # create a migration from schema changes
pnpm db:migrate    # apply migrations
pnpm db:studio     # browse the data

The scripts load .dev.vars first.

Connections#

A Worker cannot reuse a connection pool across requests, so a small pg pool (maximum 4 connections with a 1000ms idle timeout) is built per request and shared. React's cache() shares this pool while rendering a page (such as layouts and components), while cacheForRequest shares it across the wider request lifecycle so API routes and server actions reuse the same pool instead of creating their own. Hyperdrive keeps the real connections warm near the database. Tasks on Trigger.dev connect directly through their own pool.

Documentation written with Docup