<?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: Rodrigo Dias</title>
    <description>The latest articles on DEV Community by Rodrigo Dias (@rodrgds).</description>
    <link>https://dev.to/rodrgds</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%2F809826%2F47db790e-5a3f-4ec3-89bc-fed712923098.png</url>
      <title>DEV Community: Rodrigo Dias</title>
      <link>https://dev.to/rodrgds</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rodrgds"/>
    <language>en</language>
    <item>
      <title>The data model I ended up with after trying to publish one post everywhere</title>
      <dc:creator>Rodrigo Dias</dc:creator>
      <pubDate>Thu, 23 Jul 2026 14:05:25 +0000</pubDate>
      <link>https://dev.to/rodrgds/the-data-model-i-ended-up-with-after-trying-to-publish-one-post-everywhere-4fp2</link>
      <guid>https://dev.to/rodrgds/the-data-model-i-ended-up-with-after-trying-to-publish-one-post-everywhere-4fp2</guid>
      <description>&lt;p&gt;A social post looks like a string until you try to send it to more than one platform.&lt;/p&gt;

&lt;p&gt;That was roughly where OpenPost started. There was some source content, a list of accounts, and a queue that would publish it later. For a normal text post, that model is fine.&lt;/p&gt;

&lt;p&gt;Then I started adding real formats.&lt;/p&gt;

&lt;p&gt;X may need a thread. LinkedIn can take a PDF document. Instagram wants a carousel. YouTube needs a title, description, thumbnail and privacy setting. Even two accounts on the same platform may need different copy or media.&lt;/p&gt;

&lt;p&gt;At that point, the “post” was no longer one thing. I could either keep adding exceptions to the original model or admit that I was modelling it wrong.&lt;/p&gt;

&lt;p&gt;I chose the second option.&lt;/p&gt;

&lt;h2&gt;
  
  
  One idea, several actual posts
&lt;/h2&gt;

&lt;p&gt;OpenPost now separates a publication from its renditions.&lt;/p&gt;

&lt;p&gt;The publication is the shared intent: the idea, internal title, source text, content profile, source URL and schedule.&lt;/p&gt;

&lt;p&gt;A simplified version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Publication&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;             &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;WorkspaceID&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Title&lt;/span&gt;          &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;ContentProfile&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;SourceText&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;SourceURL&lt;/span&gt;      &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;         &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;ScheduledAt&lt;/span&gt;    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
    &lt;span class="n"&gt;ActualRunAt&lt;/span&gt;    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important field here is &lt;code&gt;SourceText&lt;/code&gt;. It is the starting point for the person or agent preparing the campaign. OpenPost does not assume that it is already a valid payload for every provider.&lt;/p&gt;

&lt;p&gt;Each destination account gets a rendition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Rendition&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;              &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;PublicationID&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;SocialAccountID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Platform&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Profile&lt;/span&gt;         &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Body&lt;/span&gt;            &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Title&lt;/span&gt;           &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Description&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;SettingsJSON&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;          &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;ExternalID&lt;/span&gt;      &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;ExternalURL&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;ErrorMessage&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I attach renditions to accounts rather than only platforms. That distinction matters more than I expected. My personal LinkedIn account and a company LinkedIn page are both “LinkedIn,” but they are not necessarily supposed to receive the same post.&lt;/p&gt;

&lt;p&gt;Media also belongs at this level. A rendition may need its own order, role, alt text or video thumbnail timestamp. A flat list of attachment IDs stopped being enough quite quickly.&lt;/p&gt;

&lt;p&gt;This model creates more tables. It also removes a lot of increasingly ugly provider-specific logic from the source post itself, which is a trade I am happy with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The source is not the thing that gets validated
&lt;/h2&gt;

&lt;p&gt;One bug that is easy to create here is validating the source and assuming every output is now safe to publish.&lt;/p&gt;

&lt;p&gt;It is not.&lt;/p&gt;

&lt;p&gt;A rendition can replace the source copy and media. That means the effective rendition is what needs to satisfy the provider's limits. A valid source does not help if the X rendition is too long, or if the Instagram rendition replaces its images with something Instagram cannot accept.&lt;/p&gt;

&lt;p&gt;OpenPost uses content profiles such as &lt;code&gt;short_text&lt;/code&gt;, &lt;code&gt;thread&lt;/code&gt;, &lt;code&gt;link_share&lt;/code&gt;, &lt;code&gt;image_post&lt;/code&gt;, &lt;code&gt;carousel&lt;/code&gt;, &lt;code&gt;story&lt;/code&gt;, &lt;code&gt;short_video&lt;/code&gt; and &lt;code&gt;long_video&lt;/code&gt;. The profile describes the general shape. The provider adapter then checks the exact rules for the chosen account and its final rendition.&lt;/p&gt;

&lt;p&gt;This is more work than a universal &lt;code&gt;content string&lt;/code&gt;, but at least the errors happen before a job reaches a provider API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I added agents
&lt;/h2&gt;

&lt;p&gt;The next problem was access.&lt;/p&gt;

&lt;p&gt;I wanted an AI agent to inspect the workspace and prepare destination-specific drafts. I did not want to hand the agent decrypted provider credentials or give every connection publishing access by default.&lt;/p&gt;

&lt;p&gt;OpenPost exposes two MCP scopes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mcp:read&lt;/code&gt; can inspect permitted workspace data;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mcp:full&lt;/code&gt; can also create, edit, upload, schedule and publish.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is enforced on the server. A read-only client does not receive the mutation executor during tool discovery. Mutation operations are removed from search results. Even if a client has cached the name of an older direct mutation tool, the server still rejects the call.&lt;/p&gt;

&lt;p&gt;I also split the MCP execution surface itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;query_operation&lt;/code&gt; accepts only operations classified as read-only;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;execute_operation&lt;/code&gt; accepts state-changing or external operations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is to give clients one reliable place to put an approval boundary. They do not have to guess whether something called &lt;code&gt;prepare_post&lt;/code&gt; is secretly going to publish it.&lt;/p&gt;

&lt;p&gt;There is an important caveat: OpenPost does not enforce a human approval ceremony around every full-access token. If you give an agent &lt;code&gt;mcp:full&lt;/code&gt; and approve its mutation calls, it can publish. The server makes the authority clear and keeps provider credentials private. It cannot choose your trust model for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  I did not want Redis in the default install
&lt;/h2&gt;

&lt;p&gt;Scheduling should survive the HTTP request that created it. It should also survive a restart.&lt;/p&gt;

&lt;p&gt;Using an unmanaged goroutine was never a serious option, so OpenPost stores jobs in the same configured database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Job&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt;          &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Type&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Payload&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;      &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;RunAt&lt;/span&gt;       &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
    &lt;span class="n"&gt;Attempts&lt;/span&gt;    &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;MaxAttempts&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;LastError&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;LockedAt&lt;/span&gt;    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
    &lt;span class="n"&gt;LockedBy&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Workers claim due jobs atomically and keep the lock alive while they work. If a process dies, another worker eventually puts the stale job back into the pending queue. The attempts and errors remain visible instead of disappearing into a log line.&lt;/p&gt;

&lt;p&gt;SQLite is the default, while PostgreSQL is supported for larger and hosted deployments. Keeping the queue portable across both databases is a bit annoying. I still prefer owning that complexity in OpenPost over making every self-hoster deploy Redis just to schedule a post.&lt;/p&gt;

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

&lt;p&gt;The publication/rendition model did not magically make the application simple.&lt;/p&gt;

&lt;p&gt;Inheritance is easy to get wrong. Media needs three different states: inherit the source, replace the source, or intentionally use no media. Those cannot all collapse into an empty array.&lt;/p&gt;

&lt;p&gt;Rescheduling also touches more than one row. The publication, renditions, queue jobs and lifecycle history need to stay consistent. The UI has to make it clear which account you are editing without feeling like a database administration tool.&lt;/p&gt;

&lt;p&gt;There is also old code. OpenPost still has compatibility paths for the original post model while I move the rest of the product to format-first publications. Supporting both is ugly. Breaking every existing client in one release would be uglier.&lt;/p&gt;

&lt;p&gt;The main lesson for me was that provider differences do not disappear when the product hides them. They come back as late validation errors, confusing overrides and &lt;code&gt;if platform == ...&lt;/code&gt; branches spread across the codebase.&lt;/p&gt;

&lt;p&gt;I would rather model the differences directly and then work on making that model feel simple in the interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying it
&lt;/h2&gt;

&lt;p&gt;OpenPost is AGPL-3.0-only and ships as one Go binary or Docker container with the Svelte frontend embedded. It uses SQLite and local media by default, supports PostgreSQL and S3-compatible storage, and does not require Redis.&lt;/p&gt;

&lt;p&gt;The current release is &lt;a href="https://github.com/rodrgds/openpost/releases/tag/v1.1.3" rel="noopener noreferrer"&gt;v1.1.3&lt;/a&gt;. The &lt;a href="https://docs.openpost.social/self-hosting/" rel="noopener noreferrer"&gt;self-hosting guide&lt;/a&gt; covers HTTPS, storage, backups and provider setup.&lt;/p&gt;

&lt;p&gt;I am still conservative about provider claims. Having an adapter in the repository does not prove that every OAuth permission, app-review state, quota and media path works for every real account. The docs separate implemented and preview paths instead of turning them into one large platform number.&lt;/p&gt;

&lt;p&gt;If you have built something similar, I would be interested in how you handled inheritance between the shared source and the provider payloads. That part has taken more thought than most of the API integrations.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>selfhosted</category>
      <category>go</category>
      <category>mcp</category>
    </item>
  </channel>
</rss>
