DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our free plan hides creators with a window function, and the locked count is the same predicate with a "not" in front

Nakodo's free plan runs a campaign exactly like a paid one. It searches the same way, checks the same creators, scores them the same way, and then shows you the best 20 of the ones it could write to. The rest are counted, not thrown away, and they appear if you upgrade. The pricing page says "Best 20" on the free row and "All" on the others.

The obvious implementation is LIMIT 20 on the results query. That was wrong, and the reason it was wrong is a decent lesson about where a product rule should live.

Eight call sites need the same subset, and one of them is its complement

"Which results can this account see" is not a question only the results list asks. Counting the uses in our read models and server actions, it is asked eight times, including by:

  • the count in the campaign header, and the funnel under it
  • the outreach queue, which may only write to creators the brand can see
  • a single creator's own page, which has to 404 for a creator outside the visible set rather than render one
  • the campaign list, which shows a per-campaign count for every campaign at once
  • the progress panel, which needs the inverse: how many good fits are locked behind an upgrade

LIMIT composes with none of that. A count(*) with LIMIT 20 is not 20, it is the count of the first 20 rows the planner happened to produce, and the complement of a LIMIT is not expressible at all. What those callers need is a boolean they can drop in a WHERE.

So the rule lives in one file as a SQL fragment, and the signature is the whole idea:

export function shownSql(alias: string, cap: number | null, scope: SQL): SQL
Enter fullscreen mode Exit fullscreen mode

An alias (the table is joined under different names in different queries), the plan's cap, and the scope the ranking happens in. It returns a predicate. Every caller puts it in a WHERE, and one caller puts not in front of it.

Paid plans pay nothing

if (cap === null) return sql`${col("stage")} <> 'rejected'`;
Enter fullscreen mode Exit fullscreen mode

creatorsPerCampaign is 20 on the free plan and null on the paid ones, and null means "all". On a paid account the entire expression collapses to one inequality, so the window function below never runs for the accounts with the most data. The feature costs nothing where it does not apply, which is the opposite of what happens when a cap is bolted into a shared view.

The ranking, and why it has three tie-breaks

row_number() over (
  partition by u.campaign_id
  order by u.score desc, u.created_at, u.channel_id
) as place
Enter fullscreen mode Exit fullscreen mode

score desc is the product rule: best fits first. The other two terms are there to stop the visible set from moving.

Scores are integers out of 100 and ties are common, so with order by score desc alone Postgres is free to return tied rows in any order it likes, and it does not have to pick the same order twice. The 20th and 21st rows would swap between two page renders. A creator you were reading about would vanish on reload, their page would 404, and the count in the header would stay the same while the list changed underneath you.

created_at then channel_id makes the ordering total. channel_id alone would have done it, but ordering by age first means that when a new result ties with an older one, the one the brand has already seen keeps its place and the newcomer goes below it. Stability is worth more here than freshness.

The three things that count as visible

(
  stage <> 'rejected' and (
    exists (select 1 from outreach_threads t
            where t.campaign_id = cc.campaign_id and t.channel_id = cc.channel_id)
    or (cc.campaign_id, cc.channel_id) in (
      select r.campaign_id, r.channel_id from (
        select u.campaign_id, u.channel_id,
          row_number() over (partition by u.campaign_id
                             order by u.score desc, u.created_at, u.channel_id) as place
        from campaign_channels u where <scope> and <useful>
      ) r where r.place <= 20
    )
    or (cc.stage in ('discovered', 'enriched', 'analyzed') and cc.campaign_id not in (
      select u.campaign_id from campaign_channels u
      where <scope> and <useful> group by u.campaign_id having count(*) >= 20
    ))
  )
)
Enter fullscreen mode Exit fullscreen mode

Branch two is the cap. The other two are the bugs I would have shipped without them.

Anyone already written to stays visible. If outreach has started a conversation with a creator, they are visible regardless of rank, forever. Without this branch, a creator could be pushed past the cap by better results arriving later, and the brand would have a live email thread with somebody their account cannot show them. Rank decides what you are offered, never what you are already in the middle of.

Results still being checked are visible until the cap is full. A result goes through stages before it has a score. If only the top 20 scored rows counted, a campaign in its first minutes would show an empty list while work was obviously happening. So unscored rows are visible for as long as the campaign has fewer than 20 creators with a published email, and stop being visible once it has them. The having count(*) >= 20 subquery is that switch.

<useful> in there is the other half of the rule: a fit with an email address we can actually write to. Not a raw score threshold, because a creator with no published business email cannot be contacted, and offering a free account 20 creators it cannot do anything with would be a worse product than offering it 20 it can.

The complement, for free

const locked =
  cap === null
    ? sql`false`
    : sql`${campaignChannels.stage} = 'scored'
          and not ('not_a_creator' = any(${campaignChannels.flags}))
          and not ${shownSql("campaign_channels", cap, inCampaign(campaignId))}`;
Enter fullscreen mode Exit fullscreen mode

That is the number behind "and more waiting" in the progress panel. It is exactly not shown, which means the two numbers cannot drift: there is no second definition of the cap to keep in sync, and an upgrade cannot reveal a different set than the one that was counted.

The UI on top of that is deliberately blunt. The locked rows are drawn as blurred placeholder rows with an upgrade link, and the placeholders hold invented text rather than real creators, because a blur is a rendering of a string and the string should not be somebody's name.

What I would watch out for

shownSql takes its alias as a string and interpolates it with sql.raw, which means I am the escaping. Only two things go in raw: the alias, which comes from our own call sites and never from input, and the cap, floored to an integer first. Every other value in the fragment is parameterised the normal way. That is a rule I would write on the wall of any codebase that builds SQL out of pieces.

The honest gap: this fragment has no unit test. Our pure modules have plenty, because they are pure on purpose, but this one is a string of SQL whose behaviour only exists against a database with a campaign in it, so it has been checked by hand on real campaigns and not by an assertion. It is the piece of this repository I would most like a test for, and writing one means a test database, which is a bigger decision than this post.

If you want to see the plan rule from the outside, it is on the pricing page, and how it works describes the pipeline the predicate sits at the end of.

Top comments (1)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

Making “what you can see” a predicate instead of a LIMIT is the right call, and the reason generalizes: it is the same decision as putting the rule in RLS instead of in each query. Nine call sites that each re-apply the subset is nine chances for one of them to ship without it; a predicate the database enforces is one place. The complement you mention — the locked count is the same predicate with a not — is exactly where that bites: if the predicate lives in app code, the list and the count can drift, and a drift in the count leaks the existence and size of rows the user cannot see. Move the visibility rule into an RLS policy and the list, the count, and the single-creator 404 all read the same enforced predicate and can’t disagree. The thing to test is the negative: the locked count for account A must equal exactly the rows A’s policy denies — no more, no fewer. A two-account probe for that: github.com/cekuu35/supabase-rls-le...