Data Model
All data lives in Supabase Postgres. Access is server-side only via the admin client
(lib/server/supabase-admin.ts, service-role key). Storage uses three buckets
(input / output / merchant images).
Reading or writing the PRODUCTION database from a console or a script needs the owner’s approval for that operation, first (#451) — one gated door, fail-closed, reads and writes approved separately. See Deployment → Touching the production database. Nothing on this page describes the deployed app’s own runtime, which is not gated.
Core tables
| Table | What it holds |
|---|---|
stores | One row per installed store: shop_domain, plan/billing state, trial dates, generation provider/model, custom-plan fields, usage counters, first-touch acquisition_channel/acquisition_ref/acquired_at (see Install Attribution). #656: uninstalled_at (the 30-day erasure clock, set once by app/uninstalled, cleared by an install) and legal_hold_reason (non-null = never erased; see Webhooks → shop/redact) |
platform_connections | Shopify access tokens (encrypted) + scopes per store |
catalog_products | Synced Shopify products. shopify_updated_at = Shopify’s own updated_at for the last payload applied, normalized to UTC — the input to the products/update staleness guard (nullable; NULL = unknown = apply). Distinct from updated_at, which is our write time. See Webhooks → products/* |
catalog_product_images | Product images (incl. primary reference image) |
product_tryon_settings | Per-product widget enable/disable + overrides |
store_collection_categories | Merchant map: Shopify collection → try-on category (tier 3 of lib/widget/resolve-product-category.ts). Opt-in per store, unique on (store_id, collection_external_id), indexed on store_id. Since #40 the value not-tryonable is a segment hide: it is gate-eligible, so resolveStorefrontWidgetAccess returns widgetVisible:false for every product in that collection (perfume / tools / gift cards). Managed in /products → Collection rules (since #323 edits stage into the page’s one save bar — per-row upsert on Save, enumerated outcome, nothing writes on change); written only through PUT /api/shopify/collection-categories (category allowlisted via coerceCategory) |
store_widget_config | Widget placement/config per store (incl. hide_on_sold_out boolean, default true — B17: hide the button on fully sold-out products) |
storefront_theme_settings | Theme/embed detection state, plus the bundleDiscountTiers named key (see below) |
product_groups | Sibling products linked as colours (Combined Listings / recognised metafield / storefront-scan / manual). See Product Groups |
product_group_scans | Per-store storefront-scan progress for product_groups. See Product Groups |
product_variant_references | Merchant-uploaded per-variant/per-colour shade reference images + a colour code (#547, see below) |
product_variant_references (#547)
Merchant-uploaded shade reference images + a first-class colour code, one row per scope — same
shape and precedence as #406’s product_variant_settings: option:<name>=<value> (a colour-level
rule) or variant:<gid> (a single-variant override). See Try-On Pipeline → Phase 3 — merchant-uploaded
shade references.
Columns: store_id, product_id, scope_key, option_name / option_value |
variant_external_id (scope fields, same as product_variant_settings), colour_code text
(≤64 chars), storage_bucket, images jsonb (array of {path, mimeType, sizeBytes, uploadedAt}). Unique on (product_id, scope_key). RLS on — service-role only, like the other
merchant-catalog tables.
Try-on flow tables
| Table | What it holds |
|---|---|
tryon_sessions | A shopper try-on session (status, shopper identity, product) |
tryon_input_images | Uploaded person images (storage bucket + path, expiry) |
tryon_provider_jobs | The provider task (KIE/fal task id, status, payloads) |
tryon_outputs | Generated result images (storage bucket + path) |
analytics_events | Funnel events (widget click, upload, generation, result view, add-to-cart, purchase) |
tried_products | Product-level proof rows written after successful try-on results; one row per tried primary/bundle/related product |
attributed_order_lines | Line-level purchase attribution proof; one row per purchased line item credited to Tryvio |
attributed_orders | Backward-compatible order summary rows linked to try-on sessions |
shopper_rate_limit_usage | Per-shopper rate-limit counters + email-gate bonus state |
tryon_billing_ledger | Immutable, range-sliceable billing source of truth — one row per billable try-on, written atomically with the gate counter increment (see Billing Pipeline) |
tryon_feedback | Shopper 👍/👎 on a result — one row per session (upsert; they may flip their mind), plus the provider/model that produced it (see below) |
tryon_feedback.provider / .model — the model that ACTUALLY ran (#303)
These two columns must always come from the session’s own latest completed tryon_provider_jobs
row (provider + request_payload->>'generationModel'), resolved server-side by
getSessionGenerationAttribution(storeId, sessionId). Never from stores.generation_provider/_model.
Why this is a rule and not a preference. Those stores columns are the configured default.
Since smart routing (#107, live ~2026-07-27) the provider is picked per generation — kie/fal by
live health, the intimates pin (#59), the per-product tuning override (#87) — so the default and the
actual generator routinely differ. Writing the default is what made 100% of the first 45 days of
feedback (527 prod rows) read fal.ai / nano-banana-lite while kie.ai ran the large majority, which
in turn fed the “>5 downs → pin nano-banana-2” auto-proposal a fiction.
NULLmeans “unknown”, never “the default” — no completed job resolves (old session, mock mode, a session belonging to another store). Nothing may fill it in with a guess.tryon_provider_jobscarries nostore_id(#291), so tenant scoping goes throughtryon_sessions.- Reads:
getTryOnFeedbackStats(storeId, …)is the tenant-scoped, merchant-shaped one — counts only, no provider/model in the type. The cross-store operator view isgetTryOnFeedbackStatsAllStores()(#289); which model ran is operator information.
Bundle / outfit tables
| Table | What it holds |
|---|---|
product_bundles | Ordered complement slots per primary product (see below) |
copurchase_pairs | Products bought together in the same Shopify order, computed nightly (see below, #393) |
product_bundles
Columns: id, store_id, primary_product_id, complement_product_id,
position smallint (1 or 2), active, created_at.
Unique constraint: (store_id, primary_product_id, position) — one row per slot per primary
product. The position column was added in migration 20260624_outfit_bundles.sql, which
backfills legacy single-complement rows to position = 1.
Access via getProductBundleSlots, slot-aware upsertProductBundle, deleteProductBundle,
listProductBundles (all in lib/server/supabase-admin.ts). Every query filters store_id.
stores bundle columns
Three columns added in migration 20260617_product_bundles.sql, plus two more added in
migration 20260820_copurchase_pairs.sql (#393):
| Column | Type | Default | Purpose |
|---|---|---|---|
bundle_enabled | boolean | false | Master toggle — feature is off until set |
bundle_discount_percent | int | 15 | Store-wide bundle discount % |
bundle_upsell_count | smallint | 1 | How many complements the AUTO path may suggest (CHECK between 1 and 2, #243/#70). Pinned outfits use their own slot count. Default 1 = pre-#243 behaviour |
bundle_shopify_discount_id | text | — | Id of the Shopify discount synced for this % |
copurchase_synced_at | timestamptz | null | When the co-purchase cron last computed pairs for this store; null = never ran (#393) |
copurchase_status | text | null | Outcome of the last co-purchase compute: ok | scope_missing | token_missing | error; null = never ran (#393) |
bundle_shopify_discount_id is written/cleared by the store-settings PATCH handler when
the bundle is enabled/disabled. copurchase_synced_at/copurchase_status are written by
setCopurchaseStatus at the end of each store’s cron pass.
bundleDiscountTiers (#601)
An optional tier ladder on top of the single bundle_discount_percent, e.g. 2 products → −20%,
3 → −25%, 4 → −30%. Stored as the additive named key bundleDiscountTiers inside
storefront_theme_settings.metadata (the #511 named-key pattern — read-modify-write merge, no
migration). Shape: [{ count, percent, shopifyDiscountId }], valid count 2–4 (the #523-proven
look caps). Parsed/validated by the pure module lib/billing/bundle-tiers.ts
(resolveTierForCount, tiersForStorefront).
One Shopify discount code is auto-managed per tier, extending the TRYVIOBUNDLE rails: count 2
reuses TRYVIOBUNDLE itself, higher tiers get TRYVIOBUNDLE3 / TRYVIOBUNDLE4 (same
“Tryvio:“-prefixed title ownership guard). Codes are synced on save and deleted on tier removal
or bundle-off in the store-settings PATCH handler; the ladder configuration itself survives
bundle-off. When a ladder is saved, stores.bundle_discount_percent is kept in sync with the
lowest tier’s percent. See Try-On Pipeline → Tiered bundle discounts
for the proxy config and cart-application details.
A stored tier above count 2 with a null shopifyDiscountId is configured but not served
(#592) — its discount code hasn’t been created in Shopify yet, so tiersForStorefront withholds
it until the sync completes. The admin/editor contract is unchanged either way: GET/PATCH
/api/shopify/store-settings still return bundleDiscountTiers as {count, percent} only —
codes and node ids stay server-owned.
copurchase_pairs (#393)
Products bought together in the same Shopify order, recomputed daily by
GET /api/cron/copurchase-pairs. Added in migration 20260820_copurchase_pairs.sql. No shopper
PII — product ids and counts only.
Columns: store_id (FK stores, on delete cascade), product_a_external_id,
product_b_external_id (Shopify product GIDs; a < b lexicographically, so a pair is stored
once), orders_together, orders_a, orders_b (int), window_from, window_to
(timestamptz — the order date range the pair was computed over), computed_at (timestamptz,
default now()), dismissed_at (timestamptz, nullable — set when a merchant dismisses the
suggestion).
Primary key (store_id, product_a_external_id, product_b_external_id); index on
(store_id, product_b_external_id); RLS enabled.
Access via listCopurchaseStores, replaceCopurchasePairs, setCopurchaseStatus,
getCopurchaseStatus, listCopurchasePairs, listCopurchasePartners,
setCopurchasePairDismissed, listStorefrontProductsByExternalIds (matches both external_id
spellings per #193, keyed by the canonical GID) — all in lib/server/supabase-admin.ts. Every
query filters store_id. replaceCopurchasePairs upserts without touching dismissed_at, then
deletes only rows with an older computed_at than the current recompute — a merchant’s
dismissal survives the nightly refresh.
Analytics attribution tables
tried_products and attributed_order_lines were added by
20260630_analytics_roi_attribution.sql so merchant ROI can be calculated from product-level proof,
not broad session influence.
tried_products
One row means: this shopper successfully saw this product in a Tryvio result. Bundle/outfit results write one row for the primary product and one row for each complement. Related-product try-ons write their own proof row.
Important columns: store_id, tryon_session_id, product_id, product_handle,
external_product_id, external_variant_id, shopper_session_id, shopper_pseudo_id, role,
source, tried_at, expires_at, attribution_key.
attributed_order_lines
One row means: this purchased line item matched a tried-product proof inside the 30-day window.
The table is idempotent by (store_id, idempotency_key).
Important columns: order_id, order_line_id, product_id, product_handle,
external_product_id, external_variant_id, quantity, line_value, currency,
tryon_session_id, tried_product_id, attribution_mode, proof, metadata.
See Analytics & ROI for the business rules and dashboard semantics.
Growth / ops tables
| Table | What it holds |
|---|---|
captured_emails | Emails captured via the widget email gate and landing demo (source=landing_demo) and contact inquiries (source=contact_inquiry). Conflict key (store_id, email) |
discount_codes | Shopify discount codes created for post-try-on incentives |
audit_events | IP/abuse audit + rate-limit window enforcement |
webhook_events | Received Shopify webhooks (idempotency/log) |
sync_jobs | Product sync job tracking |
app_logs | Structured app logs (queryable from the admin Logs tab) |
app_config | Misc app-level config (e.g. log level) |
billing_events | Append-only billing audit ledger — plan changes (from→to), per-period try-on usage snapshots, overage charges, subscription status transitions, and failures (success/error_message). Written by recordBillingEvent() from every billing path; see Billing Pipeline |
install_events | Append-only install lifecycle: one row per install / reinstall (completeShopifyInstall — OAuth callback AND token exchange) and uninstall (webhook), with the attribution signal observed AT that event and billing (state before/after, #531). Survives shop/redact (store_id ON DELETE SET NULL, read by shop_domain). See Install Attribution |
order_fees | #441 order-fee ledger — one row per (store_id, order_external_id, kind) with the rate, the source that produced it, and the classifier’s evidence. Service-role only, RLS on. Since #656 a settled row outlives its store with store_id NULL (see Webhooks → shop/redact) |
store_erasures | #656 one row per erasure attempt of a former merchant (shop_redact / day30_sweep; started → completed / failed, or skipped), written BEFORE any delete: per-table row_counts, retained, rollup_carry, error. Domain, store id, note, error and carry are cleared after 90 days (reduced_at). Service-role only, RLS on |
platform_monthly_totals | #656 anonymous monthly totals across all stores, see below |
platform_monthly_totals (#656)
Anonymous monthly totals across all stores (is_test excluded), so a former merchant’s
contribution survives its erasure. It holds no store id, domain or hash. It is not used for AI
tuning and not shared with third parties (Shopify API License §2, §6.2.8).
- Key
(month, metric, bucket), plusvalueandshops. Metrics:installs,uninstalls,reinstalls(frominstall_events, complete since #531),lifetime_days(sum over shops that uninstalled that month),tryons,generations,attributed_revenue(bucket = currency, one order counted once) andshops_by_plan(bucket = plan, point-in-time, so only for the month that just closed). - Every metric is defined once, in
platform_month_cells(from, store?). Rows are grouped bycreated_at, so a late row lands in a month that has not been captured yet. capture_platform_monthly_totals()writes each closed month once (on conflict do nothing). It runs daily in log-maintenance and right before every erasure. Any cell covering < 10 shops is folded into bucketother. Forattributed_revenue,otheradds up amounts in different currencies, so read it as an order of magnitude only.- A store erased before its last months are captured leaves
platform_monthly_carry(store)on itsstore_erasuresrow. The capture of that month adds the carry forcompletederasures, so nothing is lost and nothing is counted twice.
order_fees (#441)
One row per order we decided about — including the ones we decided NOT to charge, which is the point of it. A row records what we concluded at the time and why, so a merchant question six weeks later is answered from the ledger instead of re-running a rule that may have changed since.
| Column | Notes |
|---|---|
(store_id, order_external_id, kind) | UNIQUE — this index IS the idempotency guarantee. The cron re-reads an overlapping window nightly; the second look must be a no-op, and a settled row must never be re-accrued. The code uses insert-ignore, never upsert |
base_amount, currency | The order’s merchandise subtotal after discounts, net of returns AND refunds (resolveRefundAdjustedBase takes the lower of Shopify’s two views) |
fee_percent, fee_percent_source | The rate AND which link of the chain won (override / config_plan / config_global / plan). Without the source, a bill cannot be explained after an app_config edit |
status | accrued · settled · excluded · skipped_cap · skipped_no_subscription · skipped_terms_not_approved. A CHECK constraint refuses anything else — no invisible bucket of money |
usage_record_id | The Shopify usage record that settled the row. A settlement we cannot point at is a claim, not evidence |
evidence | jsonb: session id, matched line ids + roles, the tried-pair proof, discount codes, refund totals, classifier reason. Ids and amounts only — no customer PII (STANDARDS §9.7) |
stores order-fee columns (#441)
| Column | Notes |
|---|---|
order_fee_percent_override | numeric NULL. NULL = use the resolution chain; 0 is a real rate (“this merchant pays nothing”), which is why it is nullable rather than defaulted to 0 |
usage_terms_version | int NOT NULL DEFAULT 1. 1 = the merchant approved terms that mention overage only, and is never charged an order fee. 2 = terms that name the fee. The default grandfathers every existing store by the migration itself — there is no backfill to get wrong |
🚨 Both columns must appear in EVERY explicit stores select the order-fee run touches — notably
getActiveSubscribedStores. A column missing from an explicit list is not a type error, it is
undefined at runtime (the #246 class). order-fee-run.test.ts asserts the select list by parsing
the source, because that is the only failure here that would OVER-charge.
Notable patterns
captured_emailsis reused as the store for demo leads and contact inquiries (no dedicated tables) — distinguished by thesourcecolumn andmetadataJSON. The demo store id (teststoretryon) is used as thestore_idfor these “system” records.stores.custom_plan+custom_price/custom_try_on_limit/custom_overage_ratedrive custom plans;getPlanLimit()/getOverageRate()inlib/billing/plans.tsread them.reconcileStoreBillingis the authority: it keeps these set while the active sub is the custom one and clears them (CLEARED_CUSTOM_PLAN_FIELDS) the moment the store lands on a standard tier or trial.- Usage/period columns on
stores:try_ons_used+overage_pendingare the live counters (atomic RPCincrement_try_ons);current_period_starts_at/current_period_ends_attrack the active 30-day Shopify cycle. The dailybilling-overagecron resets the counters whencurrent_period_ends_atadvances (Shopify fires no webhook on renewal). - Trial dates are written once on first install in
upsertStore(only whentrial_starts_at IS NULL).