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
| 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 |
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_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 |
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) |
Bundle / outfit tables
| Table | What it holds |
|---|---|
product_bundles | Ordered 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:
| 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_shopify_discount_id | text | — | Id 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
| 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 |
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).