<?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: Claudio Ibe</title>
    <description>The latest articles on DEV Community by Claudio Ibe (@0xclaudi0).</description>
    <link>https://dev.to/0xclaudi0</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%2F1376158%2Fb9f29324-e15e-4739-b462-a50f2448c1d7.jpg</url>
      <title>DEV Community: Claudio Ibe</title>
      <link>https://dev.to/0xclaudi0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/0xclaudi0"/>
    <language>en</language>
    <item>
      <title>Two errors, one error code, and an hour in the wrong editor : What i learnt building WP-Supabase Sync</title>
      <dc:creator>Claudio Ibe</dc:creator>
      <pubDate>Fri, 07 Aug 2026 18:38:46 +0000</pubDate>
      <link>https://dev.to/0xclaudi0/two-errors-one-error-code-and-an-hour-in-the-wrong-editor-what-i-learnt-building-wp-supabase-22mh</link>
      <guid>https://dev.to/0xclaudi0/two-errors-one-error-code-and-an-hour-in-the-wrong-editor-what-i-learnt-building-wp-supabase-22mh</guid>
      <description>&lt;p&gt;If you write to Supabase from anything, sooner or later Postgres hands you&lt;br&gt;
SQLSTATE &lt;code&gt;42501&lt;/code&gt;. It means one of two things:&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="n"&gt;permission&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;wp_content&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="n"&gt;violates&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="nv"&gt;"wp_content"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first says your &lt;code&gt;GRANT&lt;/code&gt; is missing. Postgres refused before row level&lt;br&gt;
security was ever consulted, so your policies are irrelevant. You can rewrite&lt;br&gt;
them all afternoon and nothing will change. The second says the grant is fine and&lt;br&gt;
a policy's &lt;code&gt;WITH CHECK&lt;/code&gt; rejected the row.&lt;/p&gt;

&lt;p&gt;Same code, opposite fixes. And the advice you find first is almost always the&lt;br&gt;
policy one, because that's the interesting failure and it's what people blog&lt;br&gt;
about. So you go read your policies, and they look correct, because they are.&lt;/p&gt;

&lt;p&gt;I built a WordPress plugin recently that pushes content into Supabase, and this&lt;br&gt;
specific confusion is the thing it's organised around.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/0xclaudi0/wp-supabase-sync" rel="noopener noreferrer"&gt;WP Supabase Sync&lt;/a&gt; mirrors&lt;br&gt;
published WordPress posts into a Postgres table on your Supabase project.&lt;br&gt;
WordPress stays the editor and the source of truth. Supabase becomes the read&lt;br&gt;
layer your Next.js app or mobile client queries directly, with the anon key and&lt;br&gt;
RLS on top, instead of going through the WP REST API.&lt;/p&gt;

&lt;p&gt;That part is not hard. Post saves, hook fires, row gets upserted. Any competent&lt;br&gt;
afternoon produces a working version.&lt;/p&gt;

&lt;p&gt;What takes the time is everything after "working."&lt;/p&gt;
&lt;h2&gt;
  
  
  The diagnostics are the product
&lt;/h2&gt;

&lt;p&gt;There's a &lt;code&gt;wp supabase doctor&lt;/code&gt; command, and a matching admin screen, that runs&lt;br&gt;
twelve checks in dependency order. When something fails it names the layer that&lt;br&gt;
refused you, quotes what Postgres actually returned, and prints the SQL that&lt;br&gt;
fixes it.&lt;/p&gt;

&lt;p&gt;For the two &lt;code&gt;42501&lt;/code&gt; cases above, that's the difference between "check your RLS&lt;br&gt;
policies" and:&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;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wp_content&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distinction is pinned by a test that drops the grant and asserts the wording.&lt;br&gt;
Which sounds like overkill for an error message, except a wrong one sends you&lt;br&gt;
somewhere else for an hour.&lt;/p&gt;

&lt;p&gt;Two smaller decisions in the same spirit:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failures don't cascade.&lt;/strong&gt; If the project is unreachable, the eleven downstream&lt;br&gt;
checks report as &lt;em&gt;skipped&lt;/em&gt;, not failed. A privilege failure upstream makes "can&lt;br&gt;
you write?" unanswerable, so the honest answer is to not answer it. One accurate&lt;br&gt;
red line beats eight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every translated error is recorded from a real response.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;tests/fixtures/errors/&lt;/code&gt; holds actual PostgREST replies, captured by deliberately&lt;br&gt;
provoking each one against a live stack: &lt;code&gt;PGRST205&lt;/code&gt;, &lt;code&gt;PGRST204&lt;/code&gt;, &lt;code&gt;PGRST301&lt;/code&gt;,&lt;br&gt;
both &lt;code&gt;42501&lt;/code&gt; variants, &lt;code&gt;23505&lt;/code&gt;, &lt;code&gt;23502&lt;/code&gt;, &lt;code&gt;22P02&lt;/code&gt;, &lt;code&gt;42703&lt;/code&gt;. None of the strings&lt;br&gt;
the translator matches on is one I imagined. That's the only reason I'm willing&lt;br&gt;
to claim the &lt;code&gt;42501&lt;/code&gt; disambiguation works.&lt;/p&gt;

&lt;p&gt;I couldn't provoke &lt;code&gt;42P01 relation does not exist&lt;/code&gt; at all. PostgREST checks&lt;br&gt;
its own schema cache first and returns &lt;code&gt;PGRST205&lt;/code&gt; before Postgres sees the query,&lt;br&gt;
so it never surfaces over the Data API. The translator handles it anyway, since&lt;br&gt;
it can arrive via an RPC call into a function referencing a dropped table, but&lt;br&gt;
there's no fixture and the docs say so rather than quietly implying coverage.&lt;/p&gt;
&lt;h2&gt;
  
  
  Things testing taught me that I'd have got wrong
&lt;/h2&gt;

&lt;p&gt;I wrote a spec first, then built against a real local stack, and kept a file of&lt;br&gt;
every place reality contradicted the plan. A few worth passing on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;identity&lt;/code&gt; and &lt;code&gt;serial&lt;/code&gt; are not interchangeable, and nothing tells you so until&lt;br&gt;
a locked-down role can't insert.&lt;/strong&gt; I wanted to confirm that &lt;code&gt;service_role&lt;/code&gt; could&lt;br&gt;
insert without an explicit sequence grant. First measurement said yes. First&lt;br&gt;
measurement was garbage: Postgres's default privileges had already granted &lt;code&gt;service_role&lt;/code&gt;&lt;br&gt;
UPDATE on the sequence, and &lt;code&gt;nextval()&lt;/code&gt; accepts USAGE &lt;em&gt;or&lt;/em&gt; UPDATE, so the test&lt;br&gt;
proved nothing.&lt;/p&gt;

&lt;p&gt;Revoking everything and comparing both column styles directly:&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;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;NO&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SUCCEEDED&lt;/span&gt;
&lt;span class="nb"&gt;SERIAL&lt;/span&gt;   &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;NO&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FAILED&lt;/span&gt;
  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;permission&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="n"&gt;wpsb_serial_probe_id_seq&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An identity column's sequence is owned by the table and its privileges aren't&lt;br&gt;
checked separately. A &lt;code&gt;serial&lt;/code&gt; column's sequence is its own object and needs its&lt;br&gt;
own grant. It is a one-word schema choice that silently decides whether your&lt;br&gt;
inserts work under a locked-down role.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No API key doesn't mean no access, locally.&lt;/strong&gt; My spec had "is the project&lt;br&gt;
reachable" and "is the key valid" as separate checks against &lt;code&gt;GET /rest/v1/&lt;/code&gt;.&lt;br&gt;
They aren't separable there: with no key at all, a local stack returns 200 and&lt;br&gt;
the full OpenAPI document, and &lt;code&gt;GET /rest/v1/posts?limit=1&lt;/code&gt; returns rows, because&lt;br&gt;
no key behaves as &lt;code&gt;anon&lt;/code&gt;. Hosted Supabase returns 401. So a check written against&lt;br&gt;
local behaviour would mean something different in production.&lt;/p&gt;

&lt;p&gt;The fix was better than the original plan anyway. The reachability check sends&lt;br&gt;
&lt;em&gt;no&lt;/em&gt; credentials and passes on any HTTP response including a 401, since all it&lt;br&gt;
asks is whether there's a Supabase there. The auth check sends the key and fails&lt;br&gt;
on 401/403. Now "wrong URL" and "wrong key" are cleanly separated and behave&lt;br&gt;
identically local and hosted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every key is a JWT any more.&lt;/strong&gt; The spec said decode the key and read the&lt;br&gt;
&lt;code&gt;role&lt;/code&gt; claim. That only works for legacy keys. A current &lt;code&gt;supabase start&lt;/code&gt; emits&lt;br&gt;
both generations, and &lt;code&gt;sb_secret_…&lt;/code&gt; and &lt;code&gt;sb_publishable_…&lt;/code&gt; are opaque. A JWT-only&lt;br&gt;
check would report "malformed key" for a perfectly good secret key on any modern&lt;br&gt;
project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;human_time_diff()&lt;/code&gt; is unsigned.&lt;/strong&gt; My cron check rendered an event that was due&lt;br&gt;
three hours ago as "next run due in 3 hours." A stopped scheduler, described as a&lt;br&gt;
healthy one, by the plugin whose entire pitch is not doing that. I found it by&lt;br&gt;
looking at a screenshot, not from a test. It now checks the direction and says&lt;br&gt;
the schedule was due N ago and hasn't run. Four regression assertions hold it&lt;br&gt;
there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unglamorous parts
&lt;/h2&gt;

&lt;p&gt;Writes go through a queue table rather than straight out over HTTP. A unique key&lt;br&gt;
on &lt;code&gt;(object_type, object_id)&lt;/code&gt; coalesces events, so editing 100 posts fires&lt;br&gt;
several hundred hooks and produces exactly 100 rows. Batches of 50, one request&lt;br&gt;
per action. Backoff at &lt;code&gt;2^attempts&lt;/code&gt; minutes capped at an hour, retrying only 5xx,&lt;br&gt;
429 and timeouts — a 401 or a missing table dead-letters immediately instead of&lt;br&gt;
burning eight attempts pretending it might resolve itself.&lt;/p&gt;

&lt;p&gt;Claiming a batch uses a token unique to the call, not a &lt;code&gt;claimed_at&lt;/code&gt; timestamp.&lt;br&gt;
Two overlapping cron runs can stamp the same second and each would then read the&lt;br&gt;
other's rows.&lt;/p&gt;

&lt;p&gt;Three other choices:&lt;/p&gt;

&lt;p&gt;The plugin talks to the Data API over HTTPS rather than opening a Postgres&lt;br&gt;
connection. PHP's request-per-process model exhausts a pool fast, &lt;code&gt;pdo_pgsql&lt;/code&gt; is&lt;br&gt;
often missing on managed WordPress hosts, and port 443 survives restrictive&lt;br&gt;
egress rules.&lt;/p&gt;

&lt;p&gt;The plugin never runs DDL. &lt;code&gt;wp supabase schema --print&lt;/code&gt; emits a migration for you&lt;br&gt;
to read and apply. Handing a WordPress plugin authority to alter your schema with&lt;br&gt;
a key that bypasses RLS is more power than it needs to do its job.&lt;/p&gt;

&lt;p&gt;Syncing is off by default and stays off until you've run the diagnostics.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try it
&lt;/h2&gt;

&lt;p&gt;GPL-2.0, plain PHP, no build step, WordPress 6.4+ and PHP 8.1+.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/0xclaudi0/wp-supabase-sync" rel="noopener noreferrer"&gt;https://github.com/0xclaudi0/wp-supabase-sync&lt;/a&gt;&lt;br&gt;
Not affiliated with Supabase.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>supabase</category>
      <category>webdev</category>
      <category>php</category>
    </item>
    <item>
      <title>test!</title>
      <dc:creator>Claudio Ibe</dc:creator>
      <pubDate>Thu, 06 Aug 2026 23:59:50 +0000</pubDate>
      <link>https://dev.to/0xclaudi0/test-3354</link>
      <guid>https://dev.to/0xclaudi0/test-3354</guid>
      <description>&lt;p&gt;Test post for my blog&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
