In Part 6, we secured our Pi Calculus ecosystem. We stopped unauthorized clients in their tracks by implementing strict MQTT authentication across our Node-RED orchestration layer, our pi-console terminal client, and our pi-wasm Blazor browser application. With secure, credentialed access over WebSockets, our dynamic handshakes were finally safe.
But as our system matured, a new challenge emerged: State Persistence.
Our Node-RED flows were doing an excellent job of instantly spinning up dynamic, private MQTT channels (the νz in our Pi Calculus model) and feeding custom UI layouts to connected clients. However, this orchestration was entirely ephemeral. If Node-RED restarted, or if we needed to audit historical connection records, that session data was gone forever.
We needed a backend capable of durably recording every handshake and saving the exact layout payloads delivered to each device. To solve this, we added pi-functions: a .NET 10 Minimal API that acts as a serverless-style backend, writing session states directly to a PostgreSQL database.
Enter pi-functions and Minimal APIs
When clients connect and request a UI, Node-RED negotiates the private channel. We wanted to seamlessly map that negotiation into a database. Rather than building a bulky web application, .NET 10 Minimal APIs provided the perfect “serverless” development experience—lightweight, extremely fast, and highly focused.
We created a simple Program.cs that spins up an ASP.NET Core web application, registers Entity Framework Core for our data access layer, and exposes essential HTTP endpoints:
// Function 1: Node-RED calls this to save a handshake state
app.MapPost("/api/state", async (SessionState state, PiCalculusDbContext db) =>
{
// 1. Check if this client already has a session in the DB
var existingSession = await db.SessionStates
.FirstOrDefaultAsync(s => s.ClientId == state.ClientId);
if (existingSession is not null)
{
// 2. UPDATE: The client exists, just update their active properties
existingSession.Status = state.Status;
existingSession.ActiveChannel = state.ActiveChannel;
existingSession.CurrentUiState = state.CurrentUiState;
existingSession.LastUpdatedAt = DateTimeOffset.UtcNow;
}
else
{
// 3. INSERT: Brand new client
db.SessionStates.Add(state);
}
// 4. Save changes
await db.SaveChangesAsync();
return Results.Ok(state);
});
This acts as a seamless webhook for Node-RED. During the MQTT handshake process, an HTTP Request node can silently POST the session details to http://pi-functions:8080/api/state.
The Magic of PostgreSQL’s JSONB
Our UI layouts and dynamic menus are JSON objects pushed from Node-RED down the MQTT pipe. Creating rigid relational database tables to represent the infinite possibilities of a dynamic UI would be tedious and fragile.
Instead, we utilized PostgreSQL 16. Postgres offers native support for the JSONB data type, which stores JSON data in a decomposed binary format. This makes it incredibly fast to process and query, without the overhead of parsing raw text on the fly.
By simply tagging our CurrentUiState string property using the Fluent API in Entity Framework Core, we mapped it directly to a native JSONB column:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Add an index to ClientId so your Node-RED lookups are blazing fast
modelBuilder.Entity<SessionState>()
.HasIndex(s => s.ClientId)
.IsUnique();
// Store the raw Node-RED UI payload natively.
modelBuilder.Entity<SessionState>()
.Property(b => b.CurrentUiState)
.HasColumnType("jsonb");
}
Now, the entire pi-console or pi-wasm configuration schema is durably saved exactly as it was requested, ready to be analyzed or restored at a moment’s notice.
Orchestrating the Ecosystem with Podman
With a new API and a database to manage, our local developer environment grew from two containers to four. Here’s a sample of what that Podman Compose architecture looks like (with credentials replaced by placeholders):
postgres:
image: postgres:16
container_name: postgres
restart: unless-stopped
ports:
- "5432:5432"
environment:
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=<YOUR_STRONG_PASSWORD>
- POSTGRES_DB=PiCalculusDb
volumes:
- postgres_data:/var/lib/postgresql/data
pi-functions:
build:
context: ../Development/pi-console
dockerfile: pi-functions/Dockerfile
container_name: pi-functions
restart: unless-stopped
ports:
- "5001:8080"
environment:
- ConnectionStrings__DefaultConnection=Host=postgres;Database=PiCalculusDb;Username=admin;Password=<YOUR_STRONG_PASSWORD>
depends_on:
- postgres
Podman Compose easily links the pi-functions bridge directly to the Postgres host postgres:5432. It builds the container straight from our C# workspace using a multi-stage Dockerfile.
The Evolution of the Pi Calculus Ecosystem
What started as a fun terminal experiment using Spectre.Console has bloomed into a highly decoupled, real-time distributed application framework.
By treating our UI configurations as data that moves over dynamic channels (the Pi Calculus model), we completely abstracted the concept of a graphical layout away from the client application. Now, with the addition of pi-functions and PostgreSQL, that ephemeral real-time orchestration is given memory and historical context.
Whether we are connecting through an SSH terminal in pi-console, or booting up the WebAssembly clone in pi-wasm, our central backend immediately authenticates the client, provisions a secure dynamic channel, saves the current UI layout state in Postgres via our minimal API, and paints the screen with the exact commands needed.
The ecosystem is robust, secure, and fully stateful. Next time, we’ll dive into building out custom sensor modules and watching the telemetry flow back upstream!
