Data
Postgres via Drizzle, how migrations work, and the tables you will actually touch.
Railway Postgres, accessed through Drizzle ORM, reached from Workers through Hyperdrive. The schema lives in lib/db/drizzle/schema.ts (49 tables), and data access is grouped by concern in lib/db/.
Migrations
Schema changes happen one way: a SQL file in db/migrations/, then bun run db:migrate. There are 133 of them. Drizzle is used as a query builder and type source, not as a migration generator.
bun run db:status # what has run
bun run db:migrate # apply pending
bun run db:sql "SELECT 1" # ad-hoc query
Production migrations run through a GitHub Action on push to main. That workflow is dormant because main is dormant, but it is the launch-day migration path. Do not delete it.
Naming
camelCase in code, DTOs, and API payloads. snake_case only for actual database column names. The Drizzle schema is the boundary where the two meet.
The tables that matter
| Table | Holds |
|---|---|
offer |
A product or offer, scraped and analyzed. Also carries offer_type (affiliate vs ecom_brand), product_disclosure, and brand_assets (real fonts, colours, type sheet, swipe refs) |
persona |
Buyer personas, linked to an offer |
outputs |
Every saved agent output, by module and offer |
documents |
Editable long-form documents with revision support |
campaigns, campaign_steps, campaign_messages |
The multi-stage campaign builder |
| Table | Holds |
|---|---|
chat_sessions |
One conversation |
chat_messages |
Messages, with the content blocks in metadata.v2.blocks and a per-session sequence that orders them |
background_jobs |
Anything asynchronous, surfaced in the notification bell |
| Table | Holds |
|---|---|
images, videos |
Generated assets, their status, and their R2 location |
image_queue, video_queue |
In-flight generation tasks |
static_ad_batches |
One ad batch: mode and disclosure frozen at concept time, concepts_json, approved_concepts, plan_json |
image_folders |
Media library folders, shared across media types |
user_uploads |
Files the user uploaded, including chat drops |
characters |
A character identity plus its generated character map |
elements |
Locations, props, and products used as visual references |
storyboards, video_pipelines |
Scene-by-scene production state |
image_presets |
Style presets, admin-managed |
| Table | Holds |
|---|---|
user |
People. clerk_user_id joins to Clerk, which owns identity only |
admin_session, admin_verification |
Elevated admin actions |
prompts |
The runtime system and user prompt per module |
ai_models |
Chat, image, and video models with a JSONB config |
app_settings |
Key/value settings, including the default model per type |
subscription_plans, pending_customers |
Polar billing |
api_request_logs, api_usage_logs |
Every external call, with agent attribution |
email_templates, page_templates, themes |
Admin-editable content |
The module table is not in the Drizzle schema file by that name but is central: it holds one row per AI module, keyed to lib/registry.ts. See Prompts.
Media storage
One R2 bucket, adstack-media, served at media.tryadstack.com, shared by staging and production.
Request and usage logging
Every external call is logged to api_request_logs with provider, agent attribution, status, timing, and sanitized payloads. Providers are cloudflare_ai_gateway, firecrawl, and kie_ai. The agent column identifies the caller: host, a specific agent slug, image-prompt, output-verifier, and so on. The admin UI is at /admin/logs.
sanitizeApiLogValue() redacts API keys, tokens, passwords, signed URLs, and data URLs before anything is written, truncates strings over 12K characters, and stops at depth 8.