Data Model

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

TableWhat it holds
storesOne 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_connectionsShopify access tokens (encrypted) + scopes per store
catalog_productsSynced 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_imagesProduct images (incl. primary reference image)
product_tryon_settingsPer-product widget enable/disable + overrides
store_collection_categoriesMerchant 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_configWidget placement/config per store (incl. hide_on_sold_out boolean, default true — B17: hide the button on fully sold-out products)
storefront_theme_settingsTheme/embed detection state, plus the bundleDiscountTiers named key (see below)
product_groupsSibling products linked as colours (Combined Listings / recognised metafield / storefront-scan / manual). See Product Groups
product_group_scansPer-store storefront-scan progress for product_groups. See Product Groups
product_variant_referencesMerchant-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

TableWhat it holds
tryon_sessionsA shopper try-on session (status, shopper identity, product)
tryon_input_imagesUploaded person images (storage bucket + path, expiry)
tryon_provider_jobsThe provider task (KIE/fal task id, status, payloads)
tryon_outputsGenerated result images (storage bucket + path)
analytics_eventsFunnel events (widget click, upload, generation, result view, add-to-cart, purchase)
tried_productsProduct-level proof rows written after successful try-on results; one row per tried primary/bundle/related product
attributed_order_linesLine-level purchase attribution proof; one row per purchased line item credited to Tryvio
attributed_ordersBackward-compatible order summary rows linked to try-on sessions
shopper_rate_limit_usagePer-shopper rate-limit counters + email-gate bonus state
tryon_billing_ledgerImmutable, range-sliceable billing source of truth — one row per billable try-on, written atomically with the gate counter increment (see Billing Pipeline)
tryon_feedbackShopper 👍/👎 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.

  • NULL means “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_jobs carries no store_id (#291), so tenant scoping goes through tryon_sessions.
  • Reads: getTryOnFeedbackStats(storeId, …) is the tenant-scoped, merchant-shaped one — counts only, no provider/model in the type. The cross-store operator view is getTryOnFeedbackStatsAllStores() (#289); which model ran is operator information.

Bundle / outfit tables

TableWhat it holds
product_bundlesOrdered complement slots per primary product (see below)
copurchase_pairsProducts 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):

ColumnTypeDefaultPurpose
bundle_enabledbooleanfalseMaster toggle — feature is off until set
bundle_discount_percentint15Store-wide bundle discount %
bundle_upsell_countsmallint1How 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_idtext—Id of the Shopify discount synced for this %
copurchase_synced_attimestamptznullWhen the co-purchase cron last computed pairs for this store; null = never ran (#393)
copurchase_statustextnullOutcome 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

TableWhat it holds
captured_emailsEmails captured via the widget email gate and landing demo (source=landing_demo) and contact inquiries (source=contact_inquiry). Conflict key (store_id, email)
discount_codesShopify discount codes created for post-try-on incentives
audit_eventsIP/abuse audit + rate-limit window enforcement
webhook_eventsReceived Shopify webhooks (idempotency/log)
sync_jobsProduct sync job tracking
app_logsStructured app logs (queryable from the admin Logs tab)
app_configMisc app-level config (e.g. log level)
billing_eventsAppend-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_eventsAppend-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), plus value and shops. Metrics: installs, uninstalls, reinstalls (from install_events, complete since #531), lifetime_days (sum over shops that uninstalled that month), tryons, generations, attributed_revenue (bucket = currency, one order counted once) and shops_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 by created_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 bucket other. For attributed_revenue, other adds 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 its store_erasures row. The capture of that month adds the carry for completed erasures, 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.

ColumnNotes
(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, currencyThe order’s merchandise subtotal after discounts, net of returns AND refunds (resolveRefundAdjustedBase takes the lower of Shopify’s two views)
fee_percent, fee_percent_sourceThe 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
statusaccrued · settled · excluded · skipped_cap · skipped_no_subscription · skipped_terms_not_approved. A CHECK constraint refuses anything else — no invisible bucket of money
usage_record_idThe Shopify usage record that settled the row. A settlement we cannot point at is a claim, not evidence
evidencejsonb: 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)

ColumnNotes
order_fee_percent_overridenumeric 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_versionint 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_emails is reused as the store for demo leads and contact inquiries (no dedicated tables) — distinguished by the source column and metadata JSON. The demo store id (teststoretryon) is used as the store_id for these “system” records.
  • stores.custom_plan + custom_price/custom_try_on_limit/custom_overage_rate drive custom plans; getPlanLimit() / getOverageRate() in lib/billing/plans.ts read them. reconcileStoreBilling is 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_pending are the live counters (atomic RPC increment_try_ons); current_period_starts_at/current_period_ends_at track the active 30-day Shopify cycle. The daily billing-overage cron resets the counters when current_period_ends_at advances (Shopify fires no webhook on renewal).
  • Trial dates are written once on first install in upsertStore (only when trial_starts_at IS NULL).