<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Postgresql on Tiberiu Petre - Software Engineer</title><link>https://petretiberiu.dev/tags/postgresql/</link><description>Recent content in Postgresql on Tiberiu Petre - Software Engineer. Currently exploring backend/systems/infrastructure roles — get in touch: https://petretiberiu.dev/contact/</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Wed, 26 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://petretiberiu.dev/tags/postgresql/index.xml" rel="self" type="application/rss+xml"/><item><title>Sovereign AI Nexus, Part 3: Making It Actually Talk</title><link>https://petretiberiu.dev/posts/sovereign-ai-nexus-03-making-it-talk/</link><pubDate>Wed, 26 Aug 2026 00:00:00 +0000</pubDate><guid>https://petretiberiu.dev/posts/sovereign-ai-nexus-03-making-it-talk/</guid><description>&lt;p&gt;Part 2 was about the scaffold breaking in four different ways before it worked. This one is about the day the app actually started talking back and about three more bugs, quieter than the ones before.&lt;/p&gt;
&lt;h2 id="postgres-and-a-client-that-shouldnt-be-reborn-every-request"&gt;Postgres, and a client that shouldn&amp;rsquo;t be reborn every request&lt;/h2&gt;
&lt;p&gt;Wiring up Postgres was the easy part: a table for &lt;code&gt;exchanges&lt;/code&gt; (prompt, response, timestamp) and a &lt;code&gt;DatabaseClient&lt;/code&gt; wrapping a SQLAlchemy connection. The first version created a new client and a new connection on every single request. It worked, but it&amp;rsquo;s the wrong shape: a real app should open one connection when it starts and reuse it, not pay connection overhead on every prompt.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Part 2 was about the scaffold breaking in four different ways before it worked. This one is about the day the app actually started talking back and about three more bugs, quieter than the ones before.</p>
<h2 id="postgres-and-a-client-that-shouldnt-be-reborn-every-request">Postgres, and a client that shouldn&rsquo;t be reborn every request</h2>
<p>Wiring up Postgres was the easy part: a table for <code>exchanges</code> (prompt, response, timestamp) and a <code>DatabaseClient</code> wrapping a SQLAlchemy connection. The first version created a new client and a new connection on every single request. It worked, but it&rsquo;s the wrong shape: a real app should open one connection when it starts and reuse it, not pay connection overhead on every prompt.</p>
<p>The fix uses FastAPI&rsquo;s <code>lifespan</code> context manager: the client is created once, as a global, when the app starts, and closed once, when it stops. It&rsquo;s a small thing, but the difference between &ldquo;works in a demo&rdquo; and &ldquo;works under real traffic.&rdquo; is very important.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>db_client <span style="color:#f92672">=</span> DatabaseClient()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@asynccontextmanager</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">def</span> <span style="color:#a6e22e">lifespan</span>(app: FastAPI):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">yield</span>
</span></span><span style="display:flex;"><span>    db_client<span style="color:#f92672">.</span>close()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> FastAPI(lifespan<span style="color:#f92672">=</span>lifespan)
</span></span></code></pre></div><p>One more bug here, easy to miss: the Postgres container&rsquo;s credentials were hardcoded directly in <code>docker-compose.yml</code> (<code>admin</code>/<code>example</code>), while the backend correctly read them from <code>.env</code>. They happened to match, so everything worked right up until someone changes <code>.env</code> and the two sides silently disagree. This was fixed by making the database service read the same environment variables as the backend.</p>
<h2 id="teaching-the-backend-to-delegate">Teaching the backend to delegate</h2>
<p>The interesting part: how does a Python backend running in a container actually talk to Claude Code?</p>
<p>The obvious-looking answer is to mount the host&rsquo;s <code>~/.claude</code> directory into the container but it turns out to be the wrong one. The real mechanism, already proven in a different part of this same infrastructure (a local AI agent I run for other things), is simpler: <code>claude setup-token</code> generates a long-lived OAuth token once and the CLI picks it up from a <code>CLAUDE_CODE_OAUTH_TOKEN</code> environment variable. No mounted credentials directory, no interactive login inside the container.</p>
<p>The rest is a normal Docker build: <code>claude</code> installed via npm (Node&rsquo;s official Alpine build, chosen specifically to avoid repeating a glibc-vs-musl binary compatibility trap I&rsquo;d already hit once with a different tool), a persistent named volume for the CLI&rsquo;s own config so it doesn&rsquo;t reset on every restart, and a minimal, empty working directory. For now, there is no need for a real project, since the backend is using Claude Code as a text-generation engine here, not as a coding agent that needs to touch real files.</p>
<p>Delegation itself is a subprocess call, deliberately async so one slow request doesn&rsquo;t block every other one:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>proc <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> asyncio<span style="color:#f92672">.</span>create_subprocess_exec(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;claude&#34;</span>, <span style="color:#e6db74">&#34;-p&#34;</span>, full_prompt,
</span></span><span style="display:flex;"><span>    cwd<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;/workspace&#34;</span>,
</span></span><span style="display:flex;"><span>    stdout<span style="color:#f92672">=</span>asyncio<span style="color:#f92672">.</span>subprocess<span style="color:#f92672">.</span>PIPE,
</span></span><span style="display:flex;"><span>    stderr<span style="color:#f92672">=</span>asyncio<span style="color:#f92672">.</span>subprocess<span style="color:#f92672">.</span>PIPE,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>stdout, stderr <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> proc<span style="color:#f92672">.</span>communicate()
</span></span></code></pre></div><p>It worked on the first real test. Measured round-trip for a simple prompt: about 3.6 seconds for a full CLI process starting up. Worth knowing before this becomes the path for anything latency-sensitive.</p>
<h2 id="the-bugs-that-dont-announce-themselves">The bugs that don&rsquo;t announce themselves</h2>
<p>The endpoint needed conversation history. I&rsquo;ve decided to get the last N exchanges and fed them back in as context so the model isn&rsquo;t starting cold every time. This is where the quiet bugs live.</p>
<p><strong>Bug one, the loud one.</strong> The query to fetch recent exchanges was written as:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> (prompt, response, created_at) <span style="color:#66d9ef">FROM</span> exchanges ...
</span></span></code></pre></div><p>Those parentheses around the column list look harmless. My wrong assumption was to prevent SQL Injection attacks without knowing Postgres reads that as <em>one</em> composite column, not three separate ones. The first test request worked fine, because there was no history yet to fetch. The second one crashed:</p>
<pre tabindex="0"><code>AttributeError: Could not locate column in row for column &#39;prompt&#39;
</code></pre><p>A one-word explanation, once you see it: no parentheses, three real columns.</p>
<p><strong>Bug two, the silent one.</strong> The timestamp was meant to be captured the moment a request arrives right before it goes anywhere near the LLM. The code looked right:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>response <span style="color:#f92672">=</span> ChatResponse(
</span></span><span style="display:flex;"><span>    prompt<span style="color:#f92672">=</span>msg<span style="color:#f92672">.</span>prompt,
</span></span><span style="display:flex;"><span>    response<span style="color:#f92672">=</span><span style="color:#66d9ef">await</span> call_llm(msg<span style="color:#f92672">.</span>prompt),   <span style="color:#75715e"># ~3.6s</span>
</span></span><span style="display:flex;"><span>    created_at<span style="color:#f92672">=</span>datetime<span style="color:#f92672">.</span>now(timezone<span style="color:#f92672">.</span>utc),
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>But&hellip; Python evaluates keyword arguments in the order they&rsquo;re written, not by name. The <code>created_at</code> argument is the last one on the page, so it&rsquo;s the last thing evaluated after the multi-second LLM call already finished. The timestamp was quietly measuring the wrong moment. Fixed by capturing it as its own variable, first line of the function, before anything else runs.</p>
<p><strong>Bug three, the one that ate its own fix.</strong> Recent-history queries naturally come back newest-first. Fed straight into a prompt, that reads backwards to the model: the last message first and the first message last. The fix is to reverse the list in Python before building the context, except the first attempt changed the SQL to sort oldest-first <em>and</em> added the Python-side reverse. Confirmed by literally asking the model to recite the conversation back in order and it gave the order correctly, 1 through 4.</p>
<p>None of these three would show up in a five-minute demo. Two of them only show up once there&rsquo;s enough data or enough history for the wrong behavior to matter. It is exactly the kind of bug that&rsquo;s cheap to fix now and expensive to debug later, in production, with a confused conversation to untangle.</p>
<h2 id="whats-next">What&rsquo;s next</h2>
<p>Backend and database talk to each other, and the backend talks to Claude Code. What&rsquo;s still missing is a way for a person to talk to any of it. Task 4 is the chat UI. No deadline on when that happens; it happens when it happens.</p>
<hr>
<p>→ <a href="/series/sovereign-ai-nexus/">Full build series</a> · <a href="/projects/sovereign-ai-nexus/">Project page</a></p>
]]></content:encoded></item><item><title>Sovereign AI Nexus, Part 4: Shipping v1</title><link>https://petretiberiu.dev/posts/sovereign-ai-nexus-04-shipping-v1/</link><pubDate>Wed, 26 Aug 2026 00:00:00 +0000</pubDate><guid>https://petretiberiu.dev/posts/sovereign-ai-nexus-04-shipping-v1/</guid><description>&lt;p&gt;Part 3 ended with the backend able to talk to Claude Code, but no way for a person to talk to any of it. This one covers the rest of the distance to v1: a UI, a Docker Compose stack that actually works end to end, a way to see and delete conversation history, and lastly, a test suite, built specifically so the next change doesn&amp;rsquo;t need a live browser to verify.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Part 3 ended with the backend able to talk to Claude Code, but no way for a person to talk to any of it. This one covers the rest of the distance to v1: a UI, a Docker Compose stack that actually works end to end, a way to see and delete conversation history, and lastly, a test suite, built specifically so the next change doesn&rsquo;t need a live browser to verify.</p>
<h2 id="a-ui-and-a-bug-that-only-shows-up-in-markdown">A UI, and a bug that only shows up in markdown</h2>
<p>The chat UI itself is unremarkable in the good way: a controlled input, Enter to send, Shift+Enter for a new line, disabled while a response is pending. The more interesting part is what happens to the response once it arrives. Claude Code&rsquo;s replies come back as markdown and rendering that as plain text loses all of it. Using the <code>react-markdown</code> plus <code>remark-gfm</code> modules fixed that in about ten lines; user messages stay plain text, since they&rsquo;re not meant to be formatted.</p>
<h2 id="the-bug-that-only-exists-in-the-built-version">The bug that only exists in the built version</h2>
<p>Docker Compose was already running backend, frontend, and Postgres separately in dev (<code>pnpm dev</code>, <code>docker compose up</code> for the rest). The actual test: a full <code>docker compose up</code> from nothing, then a real browser hitting <code>:3000</code> is where things fell apart, in two layers.</p>
<p>The first layer was the one I expected: no CORS headers on the backend, so a browser calling <code>:8000</code> directly from <code>:3000</code> gets blocked. The fix was small: use the <code>CORSMiddleware</code> module scoped to a <code>FRONTEND_ORIGIN</code> environment variable. CORS stands for Cross-Origin Resource Sharing and is a security feature used by web browsers to control how a website on one domain can request and access resources from a different domain. It was chosen over a reverse proxy specifically because this app still isn&rsquo;t exposed publicly; a proxy earns its complexity once that changes, not before.</p>
<p>The second layer was quieter, and CORS headers wouldn&rsquo;t have fixed it. The frontend&rsquo;s <code>fetch('/chat')</code> and <code>fetch('/history')</code> calls use relative paths. In <code>pnpm dev</code>, Vite&rsquo;s own proxy rewrites those transparently to <code>:8000</code> so it looked correct. But the Docker frontend serves a static production build (<code>serve -s dist</code>), with no proxy layer at all. A relative <code>/history</code> request there just hits the frontend&rsquo;s own static server, finds no matching file, and falls through to <code>index.html</code>. This caused the app&rsquo;s own shell to be silently returned in place of JSON. No error, no crash, just the wrong response shape. It surfaced by literally running <code>curl :3000/history</code> and noticing HTML come back where JSON should have been. This is the kind of gap that only shows up when you run the real, built artifact, not when you re-read the code that produced it.</p>
<p>The fix: bake the backend&rsquo;s real origin into the static build itself as a <code>VITE_API_BASE_URL</code> build argument threaded through <code>frontend/Dockerfile</code> and <code>docker-compose.yml</code>. Vite inlines <code>VITE_*</code> variables at build time, so the compiled JS calls the backend directly, origin and all, with no proxy required.</p>
<h2 id="conversation-history-for-real-this-time">Conversation history, for real this time</h2>
<p>The <code>exchanges</code> table always had an <code>id</code> column but nothing before now had ever selected it. Adding it to the response model was the easy part; three endpoints followed naturally: <code>GET /history</code> for the full conversation, <code>DELETE /history/{id}</code> for a single exchange, and <code>DELETE /history</code> for everything.</p>
<p>The UI treats a prompt/response pair as the actual unit of storage, which makes it the natural unit of deletion too so history loads on page mount (a refresh won&rsquo;t lose the conversation in this way), each exchange gets its own delete control, and clearing everything sits behind an explicit confirm/cancel step, since there&rsquo;s no undo once it&rsquo;s gone.</p>
<h2 id="tests-so-the-browser-stops-being-the-test">Tests, so the browser stops being the test</h2>
<p>Every one of the bugs above and the two from Part 3 were caught by hand: reading a stack trace, running <code>curl</code>, opening a real browser. That works, but it doesn&rsquo;t scale, and it doesn&rsquo;t repeat automatically. The last piece of v1 was a test suite built specifically so a future change can be verified without a browser at all.</p>
<p>The one real decision here was the backend&rsquo;s test database: a disposable Postgres, not SQLite. It would have been faster to fake it but the raw SQL in this codebase is Postgres-specific, and two of the bugs already found (the composite-column <code>SELECT</code> and the timestamp-ordering one) are exactly the kind that a friendlier substitute database could paper over instead of catching. Testing against the real thing paid for itself immediately: writing the fixtures surfaced a genuine deadlock where the app&rsquo;s read methods never closed their implicit transaction, which was harmless in a long-lived production connection but left a lock held just long enough to hang the next test&rsquo;s cleanup. Something SQLite would never have shown at all. This could&rsquo;ve been prevented by explictly calling SQLAlchemy&rsquo;s own <code>.commit()</code> on the connection after a query was called.</p>
<p>The two regression tests for Part 3&rsquo;s bugs weren&rsquo;t just written to check current behavior. This was done so each one can be verified by literally reintroducing the original bug, watching the test fail, then reverting it. The frontend side is smaller in comparison: Vitest and React Testing Library, <code>fetch</code> mocked, covering send-and-render, per-exchange delete, and the clear-all confirmation gate.</p>
<h2 id="where-it-stands">Where it stands</h2>
<p>The v1 is done: the chat, the persisted and deletable history, a Docker Compose stack that survives a clean checkout, and a test suite that means the next change doesn&rsquo;t have to be verified by hand. What comes after this is a larger, deliberately undated idea. Turning this into something closer to a content system than a chat demo and it stays exactly that: available whenever there&rsquo;s real appetite for it, not scheduled. This would include a deliberate stage for automatic deployment of the app over to Kubernetes, using a lightwight version of it called K3s.</p>
<hr>
<p><strong><a href="/series/sovereign-ai-nexus/">All posts in this series →</a></strong> · <strong><a href="/projects/sovereign-ai-nexus/">Project page →</a></strong></p>
]]></content:encoded></item></channel></rss>