Developers
Data model
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.
| Area | Tables | Notes |
|---|---|---|
| Auth | user, session, account, verification | Better Auth's. user also keeps onboarding, profile, notification switches and writing style |
| Files | file | One row per upload: an R2 key in storage_key, or the bytes in data when there's no R2 |
| Shops | shop | One per subdomain (slug is unique). Visibility, paused, and what the shop's agent may do: answer_questions, haggle, lowest_percent (default 15), ask_hold |
| Listings | listing, listing_photo, listing_copy, research_run, listing_message | The item, its photos (an upload or a credited web picture), each take of the words, research progress, and the workspace chat |
| Buying | offer, checkout, orders | An offer may become an order; checkout remembers a buyer's choices while they're away at PayPal |
| After the sale | review, dispute, dispute_event, notice | Reviews (one per order), problems and their timeline, and every timed email already sent |
| PayPal | paypal_account, paypal_event | A seller's connected merchant (one per person), and every webhook taken in, by PayPal's event id |
| Messages | thread, message | One thread per buyer, shop and listing. Per-side read marks; messages can be by_agent |
| Social and stats | follow, favourite, activity | Follows, likes (the heart), and views and shares with where they came from |
| Developer access | api_key, api_usage, api_activity, api_client, webhook_endpoint, webhook_delivery | Keys and agent links (SHA-256 hashes), monthly usage, what keys changed, which apps use them, and outgoing webhooks |
| Sidekick | price_check | Things a buyer checked before buying |
| Not wired | market_login | Signed-in marketplace accounts through Kernel. The table exists; nothing uses it yet |
How they connect
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
| Status | Means |
|---|---|
open | Waiting on the seller |
countered | The seller (or the shop's agent, see countered_by) named a price; waiting on the buyer |
accepted | Agreed; the buyer has until expires_at to pay |
paid | An order exists for it |
declined, withdrawn, expired | Over |
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.statusandprice_check.status:running,done,failed.checkout.status:created,completed,failed.webhook_delivery.status:pending,delivered,failed.noticehas 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.
Search columns
listing.searchis a generatedtsvector: title and name weighted A, one-liner and category B, description C. GIN index.listing.embeddingisvector(1024), a Jina embedding of the public words written on publish. It has an HNSW index withvector_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
- Edit
packages/db/src/schema.ts. - Run
pnpm db:generate. drizzle-kit writes the next SQL file topackages/db/drizzle; give it a readable name and check the SQL. - Run
pnpm db:migratelocally. On Render it runs on its own before each deploy (preDeployCommand), and the tests migrateresell_teston 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.
| # | Migration | Adds |
|---|---|---|
| 0000 | enable-pgvector | CREATE EXTENSION vector |
| 0001 | init | Auth tables, file, shop, listing and its photos, copy, research runs, chat |
| 0002 | file-storage-key | file.storage_key for R2 |
| 0003 | offers-orders | offer, orders |
| 0004 | paypal | paypal_account, checkout, fee and payout columns on orders |
| 0005 | messages-follows | thread, message, follow |
| 0006 | stats | activity, favourite |
| 0007 | api-keys-webhooks | api_key, api_usage, webhook_endpoint |
| 0008 | paypal-demo | paypal_account.demo |
| 0009 | disputes-reminders | dispute, dispute_event, notice, paypal_event, refund and cancel columns |
| 0010 | store-agent | countered_by and agent_note on offer, by_agent on message, needs_seller on thread |
| 0011 | reviews-visits | review, user.account_seen_at and follow_mailed_at |
| 0012 | agent-activity-webhook-retries | api_activity, api_client, webhook_delivery, api_key.max_offer_cents |
| 0013 | price-checks | price_check (the Shopping sidekick) |
| 0014 | account-visit | user.account_visit_from |
| 0015 | market-logins | market_login (not used yet) |
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.