DEV Community

Nandawula Kabali-Kagwa
Nandawula Kabali-Kagwa

Posted on

I Rebuilt JarvisOS Marketing Wing From Scratch Mid-Sprint — Here's Every Trade-Off

There is a specific kind of dread that arrives when a save + publish button silently breaks in production. No 500 in the logs. No red banner for the user. The data just... stops moving. That was Monday.

This is the technical record of a week inside JarvisOS — an AI operating system I'm building for African founders — where the marketing wing collapsed under its own accumulated shortcuts and I had to decide: patch it again, or tear the architecture down and rebuild it correctly.

I rebuilt it.

The Root Cause: Schema Drift

The save + publish failure traced back to a schema mismatch. Columns I was writing to in the API layer didn't exist in the actual database table — specifically thumbnail_url, image_brief, and external_url. The inserts were silently swallowed because the ORM wasn't set to strict mode. The user saw a success toast. The row was incomplete. Every downstream read that touched those fields returned a 400.

This is a category of bug that feels embarrassing in retrospect but is genuinely easy to accumulate when you are shipping fast and migrating features across wings of a larger system. The fix was:

  1. Add the missing columns via migration
  2. Audit every SELECT and INSERT that touched the marketing content table
  3. Add a NOT NULL constraint with a safe default on external_url to make future drift loud and immediate

Trade-off: Strict constraints slow down exploratory schema work. I accepted that cost. At production scale, silent data corruption is worse than a loud migration failure.

Five Live Crashes, One Sweep

Once I was in the codebase, I ran a full sweep. What I found:

  • withCostLogging not a function — a utility I'd refactored had changed its export signature. Call sites weren't updated. Classic named-vs-default export drift in a JavaScript monorepo.
  • visual_brief.slice() crashvisual_brief was returning null from the DB on older rows. I was calling .slice() directly without a null guard. Fixed with optional chaining: visual_brief?.slice(0, 200) ?? ''.
  • brand_posts lane skipped — when content_calendar was empty, the lane generation function returned early before populating brand posts. The condition was checking the wrong array. One line fix, embarrassing in scale, real in consequence.
  • TikTok StrategyTab syntax errors — apostrophes in JSX string props that weren't escaped. South African copy tends to contract heavily (it's, you're, brand's) and I hadn't sanitised the AI-generated content before rendering it as JSX attributes.
  • JSON crash on paste queue hashtagsJSON.parse() on a value that was already a parsed object. typeof guard added.

Five crashes. One sweep. Three hours.

The Cockpit Architecture Rebuild

The deeper issue the bug sweep revealed: the marketing wing had grown as a series of bolted-on tabs rather than as a coherent architecture. Each platform (TikTok, Threads, etc.) had its own component with its own data-fetching logic, its own state shape, its own styling overrides.

I introduced what I'm calling the platform cockpit architecture. The design principle: every social platform is a wing of the cockpit, and every wing shares a single contract:

interface PlatformWing {
  platformId: string;
  strategyTab: React.FC<WingProps>;
  calendarTab: React.FC<WingProps>;
  analyticsTab: React.FC<WingProps>;
  dataFetcher: (userId: string) => Promise<PlatformData>;
}
Enter fullscreen mode Exit fullscreen mode

This means adding a new platform — say, LinkedIn — requires implementing the interface, not copy-pasting a component tree. The dataFetcher contract also forced me to centralise error handling: one boundary, one logging call, one cost-tracking wrapper.

What I'd do differently: Define this interface at week one, not month four. The refactor cost was non-trivial. The lesson isn't novel but it bears repeating for anyone building multi-platform SaaS: your platforms are a collection, not a list of one-offs.

Threads OAuth + Cloudinary Migration

Two infrastructure moves happened in parallel.

First, Threads OAuth. I shipped the callback route, the deauthorize webhook, and the delete webhook — all required by Meta's API compliance checklist. The deauthorize and delete routes are easy to defer (we'll do it later) and catastrophic to forget when your app goes for review. They're in now.

Second, a full storage migration from Supabase Storage to Cloudinary. Supabase Storage is fine for getting started. Under real media volume — campaign thumbnails, visual briefs, CEO wing assets — the transformation pipeline limitations and egress costs became visible. Cloudinary's upload API with eager transformations gives me WebP conversion, responsive breakpoints, and CDN delivery in a single upload call. The migration script ran against existing rows, updated every storage_url column, and verified the new URLs returned 200 before deleting originals.

The Cron That Keeps the DB Honest

One small but meaningful addition: a monthly pruning cron that runs at 03:00 SAST on the first of every month, sweeping 7 tables of rows older than the retention window. Running on African infrastructure means cost discipline matters at every layer. A database that quietly grows forever is a bill that quietly grows forever.

Git Push → Sprint Board

Finally, a quality-of-life engineering feature that I'll write more about separately: push a commit, Haiku (the AI agent inside JarvisOS's engineering wing) reads the commit message, parses the scope and description, and auto-populates the sprint board task. It sounds small. For a solo founder shipping across 8 apps, it removes the gap between doing the work and tracking the work. That gap is where context goes to die.


The marketing wing is stable now. The cockpit architecture is clean. The schema is honest.

Next week I turn back to HCI — the human-computer interaction layer that makes the AI agents in JarvisOS feel less like tools and more like a team. Phase 4 closed this week. Phase 5 is already open.

Top comments (0)