<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Maliik Bryan</title>
    <description>The latest articles on DEV Community by Maliik Bryan (@maliikb).</description>
    <link>https://dev.to/maliikb</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3985691%2Ffd698d3f-f354-4f51-a840-af923594d740.jpeg</url>
      <title>DEV Community: Maliik Bryan</title>
      <link>https://dev.to/maliikb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/maliikb"/>
    <language>en</language>
    <item>
      <title>Turing Tables: a deckbuilder where the opponent might actually be thinking</title>
      <dc:creator>Maliik Bryan</dc:creator>
      <pubDate>Mon, 22 Jun 2026 05:23:35 +0000</pubDate>
      <link>https://dev.to/maliikb/-turing-tables-a-deckbuilder-where-the-opponent-might-actually-be-thinking-2f58</link>
      <guid>https://dev.to/maliikb/-turing-tables-a-deckbuilder-where-the-opponent-might-actually-be-thinking-2f58</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The longest day. Hold the light until dawn.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Turing Tables is a roguelite deckbuilder with one twist: the opponent is Gemini.&lt;/p&gt;

&lt;p&gt;You descend a ladder of machines on the solstice, the longest day of the year, the one day a machine's mind runs thin enough to be fooled, racing to reach the Mainframe before the dark returns. Each machine plays cards against you in a tight, Slay-the-Spire-style combat loop. But here is the catch, and the whole point of the game: on any given turn, the machine's move is &lt;em&gt;either&lt;/em&gt; a real decision made by a live Gemini model, &lt;em&gt;or&lt;/em&gt; a cheap scripted imitation. Roughly 70 percent real, 30 percent fake, mixed in unpredictably.&lt;/p&gt;

&lt;p&gt;So your job is not only to win the fight. It is to play the interrogator. A button labelled &lt;strong&gt;Call Imitation&lt;/strong&gt; lets you bet that the move you just watched was a fake, a script wearing the machine's face. Read it right and you are rewarded with energy. Read it wrong, because the script fooled you, or because the real Gemini move looked too dumb to possibly be real, and you pay bleed.&lt;/p&gt;

&lt;p&gt;And the deception runs both ways. Every move is telegraphed a turn ahead, but a live Gemini machine can &lt;strong&gt;feint&lt;/strong&gt;: show you one intent and then play another. The scripted  imitation never bluffs, so a feint is itself proof you are up against the real thing, a mind reasoning about how to mislead you. You are hunting the machine's fakes while it quietly feeds you its own. The smarter machines feint more often, and only a &lt;strong&gt;Sever&lt;/strong&gt;, a card that cuts a machine's link to the grid, forces the truth out of them.&lt;/p&gt;

&lt;p&gt;That is the imitation game, turned into a card game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Video Demo
&lt;/h2&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/nUcim7yAC7o"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Play it yourself in the browser, no install: &lt;strong&gt;&lt;a href="https://maliik-b.github.io/turing-tables/" rel="noopener noreferrer"&gt;https://maliik-b.github.io/turing-tables/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two ways to play:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No key.&lt;/strong&gt; Every machine runs the scripted brain. You get the full game, the full ladder, and a real fight. The "Call Imitation" reads are easier, because everything is the imitation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With a key.&lt;/strong&gt; Paste a free Google AI Studio key on the title screen and the machines wake up. From DAEMON-1 onward, most of their moves are real Gemini, and the reads get genuinely hard. Your key lives only in your browser's localStorage and is sent to Google and nowhere else. There is no backend, no proxy, no server logging your key. It is your key, your quota, your machine to interrogate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Maliik-B" rel="noopener noreferrer"&gt;
        Maliik-B
      &lt;/a&gt; / &lt;a href="https://github.com/Maliik-B/turing-tables" rel="noopener noreferrer"&gt;
        turing-tables
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A deckbuilder whose opponent is a machine - can you tell when it's really thinking? dev.to June Solstice Game Jam entry.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Turing Tables&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A small web deckbuilder where your opponent is a machine — and the question is whether you can tell when it is &lt;em&gt;really&lt;/em&gt; thinking.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/june-game-jam-2026-06-03" rel="nofollow"&gt;dev.to June Solstice Game Jam&lt;/a&gt; as an ode to Alan Turing (his June birthday and the imitation game inspired it). The Machine's moves are decided by a real LLM (Google Gemini) most of the time and by a scripted "imitation" the rest — and you score by &lt;em&gt;catching the imitation&lt;/em&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Play&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Run locally:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;npm install
npm run dev&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;Open the printed localhost URL. The game is fully playable with &lt;strong&gt;no API key&lt;/strong&gt; — the Machine runs its scripted brain. To face the real Gemini opponent, paste a free &lt;a href="https://aistudio.google.com/" rel="nofollow noopener noreferrer"&gt;Google AI Studio&lt;/a&gt; API key into the in-game field; it is stored only in your browser's &lt;code&gt;localStorage&lt;/code&gt; and used solely to decide the opponent's moves.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Mechanics&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deckbuilder combat&lt;/strong&gt; — energy, attacks, block…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Maliik-B/turing-tables" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How it plays
&lt;/h2&gt;

&lt;p&gt;You start with 50 hull, 3 energy a turn, and a small deck. Standard deckbuilder verbs: strike, block, apply Vulnerable and Weak, lifesteal, corruption that ticks over time, and a &lt;strong&gt;Sever&lt;/strong&gt; that cuts a machine's link and forces it back to its scripted reflexes for a couple of turns. The machines hit back with their own intents, telegraphed one turn ahead (though a thinking machine may &lt;em&gt;feint&lt;/em&gt;, telegraphing a move it never&lt;br&gt;
means to make), and they escalate as you climb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ELIZA-0&lt;/strong&gt; is generation zero. No AI at all, a rule-based automaton that opens every fight the same way and cannot feint, because there is nothing in there to feint with.(More on why this one matters below.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DAEMON-1&lt;/strong&gt; is the first machine that wakes up: Gemini 2.5 Flash-Lite, reading your board, not your cards yet. It softens you with Weak, then shields so your blunted hits glance off..&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ORACLE-2&lt;/strong&gt; is Gemini 2.5 Flash. It counts the cards it has watched you play, sets up Expose-then-strike, and starts to lie, feinting its telegraph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;THE MAINFRAME&lt;/strong&gt; is the boss: Gemini 3 Flash. It knows your entire deck from turn one, remembers how you played in earlier runs, exposes you and then drains to heal it self, resists your corruption, and feints often. A telegraph you trust is a telegraph it wants you to trust.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbh8e5m99dfpbfoogpm7m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbh8e5m99dfpbfoogpm7m.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The further you descend, the warmer and higher the dawn climbs behind the fight. Reach the Mainframe and beat it, and the screen breaks into sunrise. Lose, and the long dark takes you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fujstxqjfb1vr54038g07.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fujstxqjfb1vr54038g07.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I used Gemini (Best Google AI Usage)
&lt;/h2&gt;

&lt;p&gt;This was the design rule I set for myself: the AI is not a chatbot bolted onto a menu. The AI is the &lt;em&gt;opponent&lt;/em&gt;, and the uncertainty about whether it really is the AI is the &lt;em&gt;mechanic&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Here is what actually happens on a machine's turn:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The game assembles a structured context for that enemy: its own abilities and remaining hull, your live board (hull, block, Vulnerable, Weak, Overdrive), and, for the higher tiers, what it knows of your deck. The Mainframe also gets a memory dossier built from your prior trials.&lt;/li&gt;
&lt;li&gt;That context goes to a real Gemini model with a system prompt that frames it as a specific machine with a specific personality and job: pick the move that beats &lt;em&gt;this&lt;/em&gt; board, and say one line in character.&lt;/li&gt;
&lt;li&gt;Gemini returns a structured move (which action, how big, plus a line of dialogue) and a possible feint. The same reducer resolves it whether it came from the model or the script.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The ladder maps onto a real escalation of Gemini models, each step up in capability &lt;em&gt;and&lt;/em&gt; in how much it knows about you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DAEMON-1 → Gemini 2.5 Flash-Lite.&lt;/strong&gt; Cheapest, fastest, just awake. Reads your board, not your cards, and telegraphs honestly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ORACLE-2 → Gemini 2.5 Flash.&lt;/strong&gt; Sharper. Counts the cards it has seen you play this fight, runs Expose-then-strike, and begins to feint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;THE MAINFRAME → Gemini 3 Flash.&lt;/strong&gt; The boss gets the newest model, the most context, and the longest leash: its prompt carries your &lt;em&gt;entire deck list&lt;/em&gt; from turn one plus a dossier of how you played earlier trials, so it anticipates and punishes your habits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I chose fast models on purpose. The whole ladder runs on a free-tier key, so the climb is capability and knowledge, not a heavyweight model the free quota can't sustain. A fight makes several calls, so I tuned the call frequency, the per-model timeouts (a few seconds for the fast tiers, longer for the boss's heavier prompt), and the model choices so a judge can finish the entire run on their own free key. I would rather you reach dawn than gate the good part behind a paywall.&lt;/p&gt;

&lt;p&gt;Two design decisions I am proud of:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fallback is a feature.&lt;/strong&gt; Every call has a per-model timeout and falls back to the scripted brain on any failure: no key, a rate limit, a network blip. That fallback is not just resilience, it &lt;em&gt;is&lt;/em&gt; the 30 percent imitation. The same safety net that keeps the game playable for someone with no key is the thing you are trying to detect when you Call Imitation. The architecture and the theme are the same object.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing cheap gives the test away.&lt;/strong&gt; A naive version would let you cheat the read: spot the AI by its smarter dialogue, by which fancy combos only it can pull, or by how long it pauses. So the scripted brain runs the same setup-and-payoff combos the models do, a third of the &lt;em&gt;real&lt;/em&gt; Gemini turns deliberately speak the scripted brain's flat canned line, and every tier's scripted turns are paced to match that model's real latency. The only honest signals left are behavioral: does this move actually answer&lt;br&gt;
the board in front of it, and did the machine just lie to you with a feint? Only a thinking machine does either.&lt;/p&gt;

&lt;h2&gt;
  
  
  An ode to Alan Turing (Best Ode to Alan Turing)
&lt;/h2&gt;

&lt;p&gt;The imitation game is not the theme of this game. It is the loop.&lt;/p&gt;

&lt;p&gt;Turing's 1950 question was never "can a machine think." It was "can you tell." Turing Tables makes you sit in the interrogator's chair and answer that question, move after move, with something on the line. Every "Call Imitation" is the interrogator's verdict.&lt;/p&gt;

&lt;p&gt;The references are deliberate, not decoration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ELIZA-0&lt;/strong&gt; is generation zero on purpose. It is a nod to Joseph Weizenbaum's 1966 ELIZA, the program that imitated understanding with pure pattern matching and unsettled people anyway. It is the perfect thing to open on: imitation with provably nothing behind it. If you cannot beat the machine that is definitely not thinking, you have no business interrogating the ones that might be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ORACLE-2&lt;/strong&gt; is named for Turing's oracle machine, the 1939 thought experiment about a device that can answer questions a normal computation cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The inversion&lt;/strong&gt; is the part I care about most. While you interrogate the machine, the Mainframe interrogates you. It keeps a dossier on how you play, carries it between runs, and uses it. Observer and observed, which is the genuinely uncomfortable centre of the test. The machine passes by reading you better than you read it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F367vg0fp6qqxyp26b6an.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F367vg0fp6qqxyp26b6an.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the solstice frame: the longest day, its light failing as the machines wake, a glow you are trying to hold until dawn. Turing's own story ended in the dark. Here, dawn is the thing you are fighting for. I will leave it at that.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I built it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;React 19+ TypeScript + Vite&lt;/strong&gt;, &lt;strong&gt;Tailwind v4&lt;/strong&gt;, &lt;strong&gt;Motion&lt;/strong&gt; for animation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fully static.&lt;/strong&gt; No backend, deploys straight to GitHub Pages. The only network call the game ever makes is the player's own browser talking to Google. A Content-Security-Policy restricts the page's connections to the app's own origin and that single Google endpoint, so the key has nowhere else to go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reducer-based engine.&lt;/strong&gt; All combat is pure state transitions, which made the AI integration clean: the brain just returns an intent, and the same reducer resolves it whether it came from Gemini or the script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every visual is CSS or SVG, zero raster assets.&lt;/strong&gt; A geometric solstice sun with rotating rays, escalating machine sigils (ELIZA is a bare hollow ring, the Mainframe a dense radiating core), a ruined-grid horizon under a fading star field, and a "dawn as an arc" background that warms and rises from indigo night to gold as you close on the Mainframe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sound is generated too, no audio files.&lt;/strong&gt; A small Web Audio engine: a low ambient drone whose pitch and brightness rise with the run (the audio twin of that dawn arc), plus synthesized one-shots for card play, hits, the Call Imitation scan, the right and wrong verdicts, and the sunrise swell on a win. The visuals are generated geometry, the audio is generated tones, and the opponent is a generated mind. The whole thing ships with zero asset files.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would do next
&lt;/h2&gt;

&lt;p&gt;More machines, each with a distinct play personality and tell. A proper interrogation log that scores your reads over a run. And a harder Mainframe that uses its dossier more cruelly, because the scariest version of this game is the one where the machine is clearly, demonstrably reading you, and you have to play &lt;em&gt;against your own habits&lt;/em&gt; to win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Category
&lt;/h2&gt;

&lt;p&gt;Submitting for &lt;strong&gt;Best Google AI Usage&lt;/strong&gt; and &lt;strong&gt;Best Ode to Alan Turing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The first because the model is the opponent, the doubt about it is the mechanic, and the whole design works to keep that doubt honest: the model picks real moves against your real board, it can lie to you with a feint, and nothing but its actual reasoning is allowed to give it away. The second because the imitation game is the loop, ELIZA and the Oracle are in the cast, and the machine is taking notes on you the whole time.&lt;/p&gt;

&lt;p&gt;Thanks for reading, and thanks for the jam. Go hold the light.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Play:&lt;/strong&gt; &lt;a href="https://maliik-b.github.io/turing-tables/" rel="noopener noreferrer"&gt;https://maliik-b.github.io/turing-tables/&lt;/a&gt; · &lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/Maliik-B/turing-tables" rel="noopener noreferrer"&gt;https://github.com/Maliik-B/turing-tables&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>gamechallenge</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>PostgREST can't upsert against partial unique indexes</title>
      <dc:creator>Maliik Bryan</dc:creator>
      <pubDate>Mon, 15 Jun 2026 15:00:47 +0000</pubDate>
      <link>https://dev.to/maliikb/postgrest-cant-upsert-against-partial-unique-indexes-4c0g</link>
      <guid>https://dev.to/maliikb/postgrest-cant-upsert-against-partial-unique-indexes-4c0g</guid>
      <description>&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;I split a 3-column unique constraint into two partial unique indexes to support a new nullable column. The next day, every &lt;code&gt;.upsert()&lt;/code&gt; against that table started failing with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;there is no unique or exclusion constraint matching the ON CONFLICT specification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upsert call hadn't changed. The data shape was identical. The error message was technically correct and completely useless: the constraint did exist, it was just partial.&lt;/p&gt;

&lt;p&gt;This post is the unblock for anyone hitting that error on a Supabase table that uses partial unique indexes.&lt;/p&gt;




&lt;h2&gt;
  
  
  The schema change
&lt;/h2&gt;

&lt;p&gt;The original table had a flat 3-column unique constraint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;profile_subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;profile_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;category_type&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;subcategory&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added a nullable &lt;code&gt;source_agency&lt;/code&gt; column. The intent: NULL means "this subscription applies to all agencies" (a default mode), and a non-NULL value means "this subscription is scoped to a specific agency."&lt;/p&gt;

&lt;p&gt;Uniqueness now had two cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For NULL-agency rows, unique on &lt;code&gt;(profile_id, category_type, subcategory)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For non-NULL-agency rows, unique on &lt;code&gt;(profile_id, category_type, subcategory, source_agency)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A naive 4-column unique constraint doesn't work because PostgreSQL treats NULLs as "always distinct" by default. You'd get unlimited duplicate &lt;code&gt;(profile, category, subcategory, NULL)&lt;/code&gt; rows.&lt;/p&gt;

&lt;p&gt;The right tool is two partial unique indexes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_profile_subs_null_agency&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;profile_subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;source_agency&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_profile_subs_with_agency&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;profile_subscriptions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_agency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;source_agency&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works at the database level. Both indexes enforce the right uniqueness. Direct SQL &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt; against the table behave correctly. The migration ran clean. Tests passed. Everything looked fine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why PostgREST breaks
&lt;/h2&gt;

&lt;p&gt;The Supabase JS client builds upsert requests like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile_subscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;onConflict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile_id,category_type,subcategory,source_agency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgREST translates that into roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;profile_subscriptions&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_agency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This SQL is syntactically valid. It still fails. PostgreSQL's &lt;code&gt;ON CONFLICT&lt;/code&gt; requires the inference column list to &lt;strong&gt;match an existing unique constraint or unique index exactly, including any WHERE predicate&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Your unique index has &lt;code&gt;WHERE source_agency IS NOT NULL&lt;/code&gt;. The upsert's &lt;code&gt;ON CONFLICT (...)&lt;/code&gt; doesn't include the predicate. PostgreSQL can't find a matching constraint. You get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;there is no unique or exclusion constraint matching the ON CONFLICT specification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgREST won't generate &lt;code&gt;ON CONFLICT (cols) WHERE source_agency IS NOT NULL&lt;/code&gt; from the client API. There's no way to express the predicate through &lt;code&gt;upsert&lt;/code&gt;'s &lt;code&gt;onConflict&lt;/code&gt; option.&lt;/p&gt;

&lt;p&gt;The obvious workaround, "just put a &lt;code&gt;COALESCE(source_agency, '')&lt;/code&gt; in the index and make it non-partial," also doesn't work. PostgREST can't upsert against expression-based indexes either, for the same root reason.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix: delete-matching + insert
&lt;/h2&gt;

&lt;p&gt;Rather than fight &lt;code&gt;ON CONFLICT&lt;/code&gt;, I dropped down to two operations: delete the matching row(s) by exact predicate, then insert the new state. From &lt;code&gt;apps/api/src/routes/subscriptions.ts:248&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PostgREST cannot upsert against partial unique indexes (no WHERE clause&lt;/span&gt;
&lt;span class="c1"&gt;// support in ON CONFLICT). Use delete-matching + insert instead.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nullAgencySubs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source_agency&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agencySubs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source_agency&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allResults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProfileSubscription&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nullAgencySubs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Delete existing matching rows first&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;nullAgencySubs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile_subscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profileId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;category_type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subcategory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;source_agency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nullAgencySubs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profileId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subcategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inferred&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inferred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;source_agency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile_subscriptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* handle error */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;allResults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...((&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ProfileSubscription&lt;/span&gt;&lt;span class="p"&gt;[]));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// agency-set bucket follows the same pattern with&lt;/span&gt;
&lt;span class="c1"&gt;// .eq('source_agency', sub.source_agency!) instead of .is('source_agency', null)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern in three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Split the input rows by whether the partial-index predicate applies (null-agency vs agency-set).&lt;/li&gt;
&lt;li&gt;For each bucket, delete the matching existing rows. Use &lt;code&gt;.is('source_agency', null)&lt;/code&gt; for the null bucket, because &lt;code&gt;.eq()&lt;/code&gt; will never match NULL in PostgREST.&lt;/li&gt;
&lt;li&gt;Insert the new rows in one batch per bucket.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This avoids &lt;code&gt;ON CONFLICT&lt;/code&gt; entirely. The trade-offs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're doing roughly 2N round trips instead of N. For a small batch (these payloads are usually under 30 rows) it's fine. For high-volume writes, move it into a PL/pgSQL function and call it through Supabase RPC.&lt;/li&gt;
&lt;li&gt;The window between DELETE and INSERT is not transactionally protected at the client level. If your write pattern allows two clients to race on the same &lt;code&gt;(profile_id, category_type, subcategory)&lt;/code&gt;, wrap the whole thing in a Supabase RPC and let Postgres serialize.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For our use case the writes happen during a quiz wizard flow where the user is the only writer on their own subscription rows. The race window is negligible.&lt;/p&gt;




&lt;h2&gt;
  
  
  The deeper trap
&lt;/h2&gt;

&lt;p&gt;ORM abstractions over Postgres leak whenever your schema uses anything beyond column-level constraints. Partial unique indexes are common in production schemas. Anywhere you have NULL-with-meaning, the natural answer is a partial unique index:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Soft-deleted rows where the unique constraint should only apply to live rows&lt;/li&gt;
&lt;li&gt;Multi-tenant uniqueness where &lt;code&gt;tenant_id&lt;/code&gt; is nullable for global rows&lt;/li&gt;
&lt;li&gt;Draft-vs-published states where the slug is unique only among published rows&lt;/li&gt;
&lt;li&gt;Versioned rows where the index applies only to the latest version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The moment you reach for one, you lose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.upsert()&lt;/code&gt; against the index via PostgREST or any client that wraps it&lt;/li&gt;
&lt;li&gt;Any framework that emits &lt;code&gt;INSERT ... ON CONFLICT (cols)&lt;/code&gt; without a predicate (most of them)&lt;/li&gt;
&lt;li&gt;Codegen tools that scan the schema for unique constraints. Partial indexes often get treated as not-a-constraint from the codegen's perspective.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is rarely interesting. The trap is that the failure mode is silent in the most damaging way: the test suite passes, the local SQL queries work, the migration runs clean. The bug only fires on the first real client call, and the error message points at the constraint as if it were missing, not partial.&lt;/p&gt;

&lt;p&gt;If you've added a nullable column with meaningful semantics and the upserts go quiet, check whether you migrated the unique constraint into a partial index. Then pick one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drop down to delete + insert, like above&lt;/li&gt;
&lt;li&gt;Move the operation into a PL/pgSQL function and call it via Supabase RPC&lt;/li&gt;
&lt;li&gt;Petition PostgREST to support &lt;code&gt;ON CONFLICT ... WHERE&lt;/code&gt;. It's a known gap: &lt;a href="https://github.com/PostgREST/postgrest/issues/2123" rel="noopener noreferrer"&gt;PostgREST/postgrest#2123&lt;/a&gt; tracks index-predicate and exclusion-constraint upserts in core, and &lt;a href="https://github.com/supabase/postgrest-js/issues/403" rel="noopener noreferrer"&gt;postgrest-js#403&lt;/a&gt; mirrors it on the JS client. Both are still open.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Notes
&lt;/h2&gt;

&lt;p&gt;This post grew out of fixing a real production bug at &lt;a href="https://trynibble.app" rel="noopener noreferrer"&gt;Nibble&lt;/a&gt;, where the schema in question backs per-agency subscription preferences across 13 countries of recall data. The shipped fix has been in production since Feb 2026 with no observed race issues.&lt;/p&gt;

&lt;p&gt;If you've hit the same trap from a different direction (PostgREST + composite uniqueness + nullable columns), I'd be curious to hear it: github.com/Maliik-B or on X @Don_adijah.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>typescript</category>
      <category>database</category>
    </item>
  </channel>
</rss>
