Brand Studio docs
Engineering manualDatabase declarations

supabase-brand-studio-pulls

Exact checked-in database declaration: docs/supabase-brand-studio-pulls.sql

Source: docs/supabase-brand-studio-pulls.sql. Declared objects: index:brand_studio_brands_user_updated_idx, index:brand_studio_pull_diffs_brand_created_idx, index:brand_studio_pull_diffs_to_pull_idx, index:brand_studio_pulls_brand_created_idx, index:brand_studio_pulls_user_brand_created_idx, index:brand_studio_pulls_user_status_idx, table:brand_studio_brands, table:brand_studio_pull_diffs, table:brand_studio_pulls. This is repository DDL, not a claim about live deployment.

All database declarations · Data structures and lineage

-- Brand Studio — brands, pulls, and typed diffs.
--
-- Run AFTER docs/supabase-brand-studio-auth.sql. This migration is ADDITIVE and
-- reversible: brand_studio_runs is not dropped, renamed, or altered, and the
-- live server keeps reading and writing it while the UI is moved over. Nothing
-- here orphans an existing row.
--
-- Three things this schema does that RLS alone cannot:
--
--  1. Cross-table ownership. `user_id = auth.uid()` on each table does NOT stop
--     a pull from referencing another user's brand_id. The composite foreign
--     keys below make that physically impossible rather than merely forbidden.
--  2. Server-side enforcement. The live server uses the service-role key, which
--     BYPASSES RLS entirely. RLS is the backstop; the server must still check
--     ownership itself. Documented here so nobody assumes RLS is the gate.
--  3. Honest nulls. A null score means "not graded", never zero — a run that
--     was never scored must not render as a bad run.

create extension if not exists pgcrypto;

-- ---------------------------------------------------------------- brands ----
-- The diff anchor: a brand a user tracks over time.
create table if not exists public.brand_studio_brands (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  brand_key text not null,
  domain text not null default '',
  display_name text not null default '',
  pull_profile_id text not null default 'standard',
  -- The pinned baseline drift is measured against (see anchor_drift in
  -- src/local/pull-diff.mjs). Null until the owner pins one.
  anchor_pull_id uuid,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (user_id, brand_key)
);

-- Required so pulls can carry a composite FK back to (id, user_id).
create unique index if not exists brand_studio_brands_id_user_idx
  on public.brand_studio_brands (id, user_id);

create index if not exists brand_studio_brands_user_updated_idx
  on public.brand_studio_brands (user_id, updated_at desc);

-- ----------------------------------------------------------------- pulls ----
-- One capture of one brand at one time. Carries every field the existing run
-- library UI reads, so rows copy from brand_studio_runs losslessly.
create table if not exists public.brand_studio_pulls (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  brand_id uuid not null,
  run_id text not null,

  -- carried over from brand_studio_runs (lossless copy)
  source_url text not null default '',
  domain text not null default '',
  title text not null default '',
  summary jsonb not null default '{}'::jsonb,
  signals jsonb not null default '{}'::jsonb,
  result_path text not null default '',
  trace_path text not null default '',
  elapsed_ms integer not null default 0,

  route text not null default 'local' check (route in ('local', 'hosted')),
  status text not null default 'running' check (status in ('running', 'passed', 'partial', 'failed')),

  -- comparability: the hash of RESOLVED budgets, not the preset label
  pull_profile_id text not null default 'standard',
  pull_profile_hash text not null default '',
  escalations jsonb not null default '[]'::jsonb,

  -- scores; null means NOT GRADED and must never render as zero
  section_quality jsonb,
  analysis_quality jsonb,
  speed jsonb,
  sources jsonb,
  overall_score numeric check (overall_score is null or (overall_score >= 1 and overall_score <= 5)),

  -- reproducibility: a score is meaningless without knowing what produced it
  scorer_version text not null default '',
  gold_spec_version text not null default '',
  langsmith_run_id text not null default '',

  created_at timestamptz not null default now(),
  unique (user_id, run_id),
  -- A pull can only ever belong to a brand the SAME user owns.
  constraint brand_studio_pulls_brand_owner_fk
    foreign key (brand_id, user_id)
    references public.brand_studio_brands (id, user_id)
    on delete cascade
);

create unique index if not exists brand_studio_pulls_id_user_idx
  on public.brand_studio_pulls (id, user_id);

create index if not exists brand_studio_pulls_user_brand_created_idx
  on public.brand_studio_pulls (user_id, brand_id, created_at desc);
create index if not exists brand_studio_pulls_brand_created_idx
  on public.brand_studio_pulls (brand_id, created_at desc);   -- previous-pull lookup
create index if not exists brand_studio_pulls_user_status_idx
  on public.brand_studio_pulls (user_id, status);

-- THE ATOMIC RUN GUARD (G7 review #1, round 7). "One running pull per
-- account" was enforced by a check-then-insert pair in
-- comprehensive-pull-handler.mjs, and two concurrent reservations could both
-- pass the check before either row existed — two pulls, two spends, one
-- credit. This partial unique index makes the INSERT itself the guard: the
-- second concurrent insert fails with SQLSTATE 23505, which the handler
-- answers as a named, retryable 409 (reservation_race_lost), never a second
-- charge. The handler ALSO verifies its reservation after the insert
-- (post-insert running/balance re-check with a deterministic winner), so a
-- deployment whose database has not applied this index yet is still closed —
-- but the index is the clean fix and should be applied.
--
-- MIGRATION NOTE (2026-08-25, added after the table shipped): apply with
--   create unique index concurrently if not exists ... -- (outside a transaction)
-- on a live database. Rows already violating it (two `running` pulls for one
-- account) must be resolved first — reap the stale one via the reaper's
-- normal path (status='failed', never charged) before creating the index.
create unique index if not exists brand_studio_pulls_one_running_per_user_idx
  on public.brand_studio_pulls (user_id) where status = 'running';

-- ----------------------------------------------------------------- diffs ----
create table if not exists public.brand_studio_pull_diffs (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  brand_id uuid not null,
  from_pull_id uuid not null,
  to_pull_id uuid not null,

  changes jsonb not null default '[]'::jsonb,
  change_count integer not null default 0,
  alertable_changes integer not null default 0,
  confounded_changes integer not null default 0,
  anchor_drift jsonb not null default '[]'::jsonb,
  has_material_change boolean not null default false,

  -- An incomparable diff must say so rather than be quietly trusted.
  comparable boolean not null default true,
  incomparable_reason text not null default '',

  created_at timestamptz not null default now(),
  unique (from_pull_id, to_pull_id),
  -- A diff cannot compare a pull to itself.
  constraint brand_studio_pull_diffs_distinct_pulls check (from_pull_id <> to_pull_id),
  constraint brand_studio_pull_diffs_brand_owner_fk
    foreign key (brand_id, user_id)
    references public.brand_studio_brands (id, user_id) on delete cascade,
  constraint brand_studio_pull_diffs_from_owner_fk
    foreign key (from_pull_id, user_id)
    references public.brand_studio_pulls (id, user_id) on delete cascade,
  constraint brand_studio_pull_diffs_to_owner_fk
    foreign key (to_pull_id, user_id)
    references public.brand_studio_pulls (id, user_id) on delete cascade
);

create index if not exists brand_studio_pull_diffs_brand_created_idx
  on public.brand_studio_pull_diffs (brand_id, created_at desc);
create index if not exists brand_studio_pull_diffs_to_pull_idx
  on public.brand_studio_pull_diffs (to_pull_id);

-- ------------------------------------------------------------------ RLS ----
alter table public.brand_studio_brands enable row level security;
alter table public.brand_studio_pulls enable row level security;
alter table public.brand_studio_pull_diffs enable row level security;

do $$
declare
  t text;
begin
  foreach t in array array['brand_studio_brands', 'brand_studio_pulls', 'brand_studio_pull_diffs']
  loop
    execute format('drop policy if exists %I_select_own on public.%I', t, t);
    execute format('create policy %I_select_own on public.%I for select using (user_id = auth.uid())', t, t);
    execute format('drop policy if exists %I_insert_own on public.%I', t, t);
    execute format('create policy %I_insert_own on public.%I for insert with check (user_id = auth.uid())', t, t);
    execute format('drop policy if exists %I_update_own on public.%I', t, t);
    execute format('create policy %I_update_own on public.%I for update using (user_id = auth.uid()) with check (user_id = auth.uid())', t, t);
    execute format('drop policy if exists %I_delete_own on public.%I', t, t);
    execute format('create policy %I_delete_own on public.%I for delete using (user_id = auth.uid())', t, t);
  end loop;
end $$;

-- ------------------------------------------------------------ backfill ----
-- Phase 1 of a two-phase migration: create + dual-write. brand_studio_runs
-- stays authoritative for the existing UI until phase 2 moves it to pulls.
--
-- brand_key defaults to '' on the live table, so it is derived from the domain
-- when empty. A row with neither is REPORTED, never invented — see the final
-- select.

insert into public.brand_studio_brands (user_id, brand_key, domain, display_name)
select distinct
  r.user_id,
  coalesce(nullif(r.brand_key, ''), nullif(r.domain, '')) as brand_key,
  coalesce(r.domain, ''),
  coalesce(nullif(r.title, ''), coalesce(r.domain, ''))
from public.brand_studio_runs r
where coalesce(nullif(r.brand_key, ''), nullif(r.domain, '')) is not null
on conflict (user_id, brand_key) do nothing;

insert into public.brand_studio_pulls (
  user_id, brand_id, run_id, source_url, domain, title, summary, signals,
  result_path, trace_path, elapsed_ms, route, status, created_at
)
select
  r.user_id,
  b.id,
  r.run_id,
  coalesce(r.source_url, ''),
  coalesce(r.domain, ''),
  coalesce(r.title, ''),
  coalesce(r.summary, '{}'::jsonb),
  coalesce(r.signals, '{}'::jsonb),
  coalesce(r.result_path, ''),
  coalesce(r.trace_path, ''),
  coalesce(r.elapsed_ms, 0),
  -- Historical rows predate the hosted route; 'local' is the honest value,
  -- not a guess.
  'local',
  case when r.status in ('running', 'passed', 'partial', 'failed') then r.status else 'passed' end,
  coalesce(r.created_at, now())
from public.brand_studio_runs r
join public.brand_studio_brands b
  on b.user_id = r.user_id
 and b.brand_key = coalesce(nullif(r.brand_key, ''), nullif(r.domain, ''))
on conflict (user_id, run_id) do nothing;

-- Rows that could not be mapped to a brand. Reported, never invented.
select
  count(*) as unmapped_runs,
  'runs with neither brand_key nor domain — left in brand_studio_runs, not backfilled' as note
from public.brand_studio_runs r
where coalesce(nullif(r.brand_key, ''), nullif(r.domain, '')) is null;

-- 2026-08-10: the full capture payload. Until this column existed the pull row
-- stored only `summary` (leg names, statuses, evidence COUNTS) — the actual
-- content (page markdown, social post records, seo keywords, voice analysis)
-- was captured in memory during the invocation and DISCARDED. The customer got
-- a receipt, not the goods; found by the owner inspecting a pull and asking
-- where the site scrape was. Owner-scoped by the table's existing RLS.
alter table public.brand_studio_pulls
  add column if not exists capture_result jsonb;