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).

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
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_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

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)

Bundle / outfit tables

TableWhat it holds
product_bundlesOrdered complement slots per primary product (see below)

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:

ColumnTypeDefaultPurpose
bundle_enabledbooleanfalseMaster toggle — feature is off until set
bundle_discount_percentint15Store-wide bundle discount %
bundle_shopify_discount_idtextId of the Shopify discount synced for this %

bundle_shopify_discount_id is written/cleared by the store-settings PATCH handler when the bundle is enabled/disabled.

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

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).