Developers

Data model

33 tables in one Postgres database, defined in packages/db/src/schema.ts with Drizzle. Here's how they fit together and how to change them.

Tables by area

Ids are text: random UUIDs for app rows, Better Auth's own ids for its four tables. Columns are snake_case in Postgres and camelCase in TypeScript (Drizzle's casing: "snake_case"). Status columns are text with a TypeScript union type, not Postgres enums, so adding a value needs no migration.

AreaTablesNotes
Authuser, session, account, verificationBetter Auth's. user also keeps onboarding, profile, notification switches and writing style
FilesfileOne row per upload: an R2 key in storage_key, or the bytes in data when there's no R2
ShopsshopOne per subdomain (slug is unique). Visibility, paused, and what the shop's agent may do: answer_questions, haggle, lowest_percent (default 15), ask_hold
Listingslisting, listing_photo, listing_copy, research_run, listing_messageThe item, its photos (an upload or a credited web picture), each take of the words, research progress, and the workspace chat
Buyingoffer, checkout, ordersAn offer may become an order; checkout remembers a buyer's choices while they're away at PayPal
After the salereview, dispute, dispute_event, noticeReviews (one per order), problems and their timeline, and every timed email already sent
PayPalpaypal_account, paypal_eventA seller's connected merchant (one per person), and every webhook taken in, by PayPal's event id
Messagesthread, messageOne thread per buyer, shop and listing. Per-side read marks; messages can be by_agent
Social and statsfollow, favourite, activityFollows, likes (the heart), and views and shares with where they came from
Developer accessapi_key, api_usage, api_activity, api_client, webhook_endpoint, webhook_deliveryKeys and agent links (SHA-256 hashes), monthly usage, what keys changed, which apps use them, and outgoing webhooks
Sidekickprice_checkThings a buyer checked before buying
Not wiredmarket_loginSigned-in marketplace accounts through Kernel. The table exists; nothing uses it yet

How they connect

Relationships
user ─┬─ shop ─── listing ─┬─ listing_photo ── file
      │                     ├─ listing_copy, research_run, listing_message
      │                     ├─ offer ──→ orders ─┬─ review
      │                     │   (buyer = user)   ├─ dispute ── dispute_event
      │                     │                    └─ checkout (PayPal round trip)
      │                     └─ thread ── message (buyer = user)
      ├─ paypal_account, api_key, webhook_endpoint, price_check
      └─ follow (→ shop), favourite (→ listing), activity (→ shop, listing)

Deleting a user or shop cascades to most of what hangs off it. Orders are the exception: orders references its listing, shop and buyer with restrict, so a sale can't silently disappear.

Statuses

listing.status

draft → live → sold. step records the furthest workspace step reached (research, details, photos, words, publish), and visibility is everyone or link. slug is set on publish and unique within the shop. Sold listings stay public and read-only.

offer.status

StatusMeans
openWaiting on the seller
counteredThe seller (or the shop's agent, see countered_by) named a price; waiting on the buyer
acceptedAgreed; the buyer has until expires_at to pay
paidAn order exists for it
declined, withdrawn, expiredOver

Each new offer, counter or acceptance gets 48 hours (OFFER_HOURS in commerce.ts). The offer sweep marks lapsed ones expired.

orders.status

paid → shipped → completed, or cancelled / refunded. An order completes when the buyer confirms it arrived, or when the order sweep releases it 13 days after shipping. delivered is part of the type (and the API's filter) but nothing sets it yet, since there's no carrier tracking; delivered_at is filled in when the order completes. The money is held until then; released_at is set when PayPal pays the seller. payment_provider is paypal, or mock for test checkouts. A partial unique index allows only one order per listing that isn't cancelled or refunded, so two buyers can't both win a race.

dispute.status

open → escalated (someone asked resell.store to step in) → refunded or closed. source is buyer or paypal (opened in PayPal, synced by the webhook). At most one open or escalated dispute per order, and while there is one the money stays held.

The rest

  • research_run.status and price_check.status: running, done, failed.
  • checkout.status: created, completed, failed.
  • webhook_delivery.status: pending, delivered, failed.
  • notice has no status: its primary key is (kind, ref_id), and inserting a row is how a sweep claims a reminder.

Money

Every amount is an integer number of US cents in a column ending _cents: listing.price_cents, lowest_cents and shipping_cents; offer.amount_cents, counter_cents and deposit_cents; and on orders, item_cents, shipping_cents, total_cents, platform_fee_cents, paypal_fee_cents, seller_net_cents and refunded_cents. The app converts at the edges with lib/money.ts; the public API speaks dollars.

  • listing.search is a generated tsvector: title and name weighted A, one-liner and category B, description C. GIN index.
  • listing.embedding is vector(1024), a Jina embedding of the public words written on publish. It has an HNSW index with vector_cosine_ops. Migration 0000 turns pgvector on.

Search runs both and merges them with reciprocal rank fusion (k = 60), ignoring vector matches under 0.3 similarity. Rows without an embedding are simply found by text.

Changing the schema

  1. Edit packages/db/src/schema.ts.
  2. Run pnpm db:generate. drizzle-kit writes the next SQL file to packages/db/drizzle; give it a readable name and check the SQL.
  3. Run pnpm db:migrate locally. On Render it runs on its own before each deploy (preDeployCommand), and the tests migrate resell_test on every run.

drizzle-kit reads DATABASE_URL from apps/app/.env.local, or from the environment. pnpm db:studio opens a browser view of the data.

#MigrationAdds
0000enable-pgvectorCREATE EXTENSION vector
0001initAuth tables, file, shop, listing and its photos, copy, research runs, chat
0002file-storage-keyfile.storage_key for R2
0003offers-ordersoffer, orders
0004paypalpaypal_account, checkout, fee and payout columns on orders
0005messages-followsthread, message, follow
0006statsactivity, favourite
0007api-keys-webhooksapi_key, api_usage, webhook_endpoint
0008paypal-demopaypal_account.demo
0009disputes-remindersdispute, dispute_event, notice, paypal_event, refund and cancel columns
0010store-agentcountered_by and agent_note on offer, by_agent on message, needs_seller on thread
0011reviews-visitsreview, user.account_seen_at and follow_mailed_at
0012agent-activity-webhook-retriesapi_activity, api_client, webhook_delivery, api_key.max_offer_cents
0013price-checksprice_check (the Shopping sidekick)
0014account-visituser.account_visit_from
0015market-loginsmarket_login (not used yet)
Migrations only ever go forward. Never edit one that's been applied anywhere; add a new one.

Seed data

pnpm --filter app seed fills the database from apps/app/seed/stores.ts: ten stores, four buyers, about 70 listings with photos and embeddings, a few sales with reviews, questions in messages, follows, likes and a month of views. Everything it creates belongs to users whose id starts with seed_, so it only ever replaces its own rows. It refuses a database that isn't on your machine unless you pass --remote. See Run it locally and Deploy to Render.