<?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: Osman Ahmadzai</title>
    <description>The latest articles on DEV Community by Osman Ahmadzai (@osmanahmadxai).</description>
    <link>https://dev.to/osmanahmadxai</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%2F4044307%2F6e23ffb9-d120-4644-ac97-87dbc2647c10.png</url>
      <title>DEV Community: Osman Ahmadzai</title>
      <link>https://dev.to/osmanahmadxai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/osmanahmadxai"/>
    <language>en</language>
    <item>
      <title>Syncle: keep any two databases in sync, live and across engines</title>
      <dc:creator>Osman Ahmadzai</dc:creator>
      <pubDate>Thu, 23 Jul 2026 18:07:39 +0000</pubDate>
      <link>https://dev.to/osmanahmadxai/syncle-keep-any-two-databases-in-sync-live-and-across-engines-3656</link>
      <guid>https://dev.to/osmanahmadxai/syncle-keep-any-two-databases-in-sync-live-and-across-engines-3656</guid>
      <description>&lt;p&gt;You have a row in Postgres. You want that same row in MongoDB — not tonight in a batch job, but the &lt;em&gt;instant&lt;/em&gt; it changes. And next month you'll want it in Redis too, and maybe POSTed to some webhook.&lt;/p&gt;

&lt;p&gt;Today that's a Kafka cluster, a Debezium connector, a sink connector, a schema registry, and a weekend. For a job that is, at its heart, one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;a source → one or more destinations → kept in sync.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Syncle&lt;/strong&gt; is an open-source tool that does exactly that sentence, and nothing you didn't ask for. Connect your databases, draw a &lt;strong&gt;bridge&lt;/strong&gt; from a source to one or more destinations, and the moment a row changes in the source it's written to every destination you linked. Any engine to any engine — &lt;strong&gt;PostgreSQL · MySQL/MariaDB · SQLite · MongoDB · Redis&lt;/strong&gt; — plus HTTP endpoints when you need them.&lt;/p&gt;

&lt;p&gt;This post is a tour of what it does and, for the curious, how it's built.&lt;/p&gt;




&lt;h2&gt;
  
  
  The core idea: a bridge
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;bridge&lt;/strong&gt; reads rows from a source and writes each one to its &lt;strong&gt;destinations&lt;/strong&gt;. A destination is either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;another database&lt;/strong&gt; — the headline feature. Postgres → MongoDB, MySQL → SQLite, MongoDB → Redis. One bridge can fan out to several databases at once, and bridges can chain (A → B → C).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;an HTTP endpoint&lt;/strong&gt; — POST/PUT/PATCH each row to a URL with a payload you design, for feeding a service instead of a database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting part isn't that it copies data — plenty of things copy data. It's the guarantees around &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  No duplicates, ever
&lt;/h3&gt;

&lt;p&gt;Every database write is an &lt;strong&gt;idempotent upsert&lt;/strong&gt;, keyed by columns you choose. So replays, retries, and at-least-once redeliveries never double-write. Under the hood each engine does it with its own native atomic operation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Upsert&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL / SQLite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MongoDB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;updateOne(filter, ..., { upsert: true })&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Inserts, updates, &lt;strong&gt;and deletes&lt;/strong&gt; all propagate — a delete routes to a keyed delete on each target.&lt;/p&gt;

&lt;h3&gt;
  
  
  Missing table? It builds it
&lt;/h3&gt;

&lt;p&gt;If the destination table or collection doesn't exist, Syncle creates it from the source's shape, translating types across the engine boundary (a Postgres &lt;code&gt;timestamptz&lt;/code&gt; becomes something sensible in SQLite, a document in Mongo, a hash in Redis). Or you map columns yourself: &lt;em&gt;write this column into that column over there&lt;/em&gt;, rename, drop, pick keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  You pick how it fires
&lt;/h3&gt;

&lt;p&gt;Three trigger modes, same everything-else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Replay&lt;/strong&gt; — a one-shot job. Stream all (or selected) rows once, then finish. Perfect for the initial backfill or a migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch&lt;/strong&gt; — poll the source on a cursor (auto-increment id, an &lt;code&gt;updated_at&lt;/code&gt; column, or a primary-key diff) and sync new rows as they appear. Works on every engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CDC&lt;/strong&gt; — true change-data-capture straight from the database's change log, in real time, no polling: Postgres logical replication, MySQL binlog, MongoDB change streams, Redis keyspace notifications.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You build the whole thing visually — browse the source table, toggle columns, pick destinations, and watch a live preview of exactly what will be written before you commit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Watch it happen
&lt;/h2&gt;

&lt;p&gt;Reliability you can't see isn't reassuring, so every delivery shows up on a live timeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 green — synced&lt;/li&gt;
&lt;li&gt;🔴 red — failed&lt;/li&gt;
&lt;li&gt;🟡 amber — skipped&lt;/li&gt;
&lt;li&gt;⬜ slate — queued&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Click any cell to see the exact row written, the result, timing, and any error. Runs survive restarts, resume where they stopped, and can be cancelled. You can skip rows by range, or retry only the failed ones in place — and watch the failed cells flip green.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it in one command
&lt;/h2&gt;

&lt;p&gt;A fresh machine needs only Docker. Everything else — Node, Postgres, Redis — runs in containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/osmanahmadxai/SYNCLE/main/install.sh | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That installs a native &lt;code&gt;syncle&lt;/code&gt; command, builds the stack, and opens the GUI at &lt;code&gt;http://localhost:3002&lt;/code&gt;. After that it's just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;syncle up      &lt;span class="c"&gt;# start everything, open the GUI&lt;/span&gt;
syncle down    &lt;span class="c"&gt;# stop it&lt;/span&gt;
syncle logs    &lt;span class="c"&gt;# follow the logs&lt;/span&gt;
syncle update  &lt;span class="c"&gt;# pull latest + rebuild&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer to run from source?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nb"&gt;install&lt;/span&gt;                  &lt;span class="c"&gt;# frontend + backend&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;          &lt;span class="c"&gt;# postgres (metadata) + redis (job queue)&lt;/span&gt;
pnpm start                    &lt;span class="c"&gt;# env files + migrations + run the whole app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;p&gt;Syncle is a pnpm monorepo with a strict one-way dependency flow: &lt;code&gt;web → api → core&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;syncle/
├─ packages/
│  └─ core/            @syncle/core — framework-agnostic domain (pure TS)
│     ├─ adapters/       DatabaseAdapter interface + one file per engine
│     │                  (raw drivers: pg, mysql2, better-sqlite3, mongodb, ioredis)
│     └─ hooks/          column mapping + cross-engine translation, payload
│                        transform, shared bridge schemas (Zod)
├─ apps/
│  ├─ api/             @syncle/api — NestJS backend
│  │  ├─ hooks/          bridge store · run processor · CDC providers ·
│  │  │                  sink router → database sink + HTTP delivery
│  │  ├─ connections/    Prisma-backed store · live adapter pool
│  │  └─ common/         crypto · Zod validation · exception filter
│  └─ web/             @syncle/web — Next.js 15 + shadcn/ui + TanStack
└─ docker-compose.yml  Postgres (metadata) + Redis (run queue)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few design decisions did most of the work of keeping it small.&lt;/p&gt;

&lt;h3&gt;
  
  
  One sink, two destination kinds
&lt;/h3&gt;

&lt;p&gt;Every trigger — replay, watch, CDC — funnels rows through a &lt;strong&gt;single sink router&lt;/strong&gt;. It dispatches to the &lt;strong&gt;database sink&lt;/strong&gt; (map columns → auto-create if needed → native upsert or keyed delete) or to &lt;strong&gt;HTTP delivery&lt;/strong&gt; (template render → POST with retries). The runner, the timeline, and the exactly-once accounting don't care which. So adding a new &lt;em&gt;kind&lt;/em&gt; of destination is one module, not a rewrite.&lt;/p&gt;

&lt;h3&gt;
  
  
  CDC behind one interface
&lt;/h3&gt;

&lt;p&gt;Each engine captures change its own way — logical replication, binlog, change streams, keyspace notifications — but they all implement the same small contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CdcProvider&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;readiness&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Readiness&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// is the source configured for capture?&lt;/span&gt;
  &lt;span class="nf"&gt;provision&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// create slot/publication if needed&lt;/span&gt;
  &lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;AsyncIterable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Change&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// insert/update/delete, each tagged with its op&lt;/span&gt;
  &lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Cursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                  &lt;span class="c1"&gt;// where we are, for resume&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service around them owns the run lifecycle and the shared &lt;strong&gt;dedupe → map → write → record → checkpoint&lt;/strong&gt; pipeline. Adding a new engine's CDC is a single file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two data layers, two right tools
&lt;/h3&gt;

&lt;p&gt;This is my favourite tension in the codebase. The databases you connect &lt;em&gt;to&lt;/em&gt; have unknown, runtime-discovered schemas — so those adapters use &lt;strong&gt;raw drivers with fully parameterized queries&lt;/strong&gt;. An ORM literally can't introspect an arbitrary schema it's never seen.&lt;/p&gt;

&lt;p&gt;But Syncle's &lt;em&gt;own&lt;/em&gt; store — saved connections, bridges, runs, deliveries — has a fixed schema we control. So that uses &lt;strong&gt;Prisma with migrations&lt;/strong&gt;. Same app, opposite tools, each because of what it actually knows about the schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  Durable runs
&lt;/h3&gt;

&lt;p&gt;A replay run is one BullMQ job (&lt;code&gt;jobId = runId&lt;/code&gt;). It streams the source a page at a time with keyset pagination (millions of rows, flat memory), syncs sequentially for natural backpressure, and checkpoints progress. A crash auto-resumes from where it left off.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The north star: &lt;strong&gt;adding an engine = implement &lt;code&gt;DatabaseAdapter&lt;/code&gt; and register it.&lt;/strong&gt; The connection form, schema browser, and feature gating all derive from that one registration.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  A note on security
&lt;/h2&gt;

&lt;p&gt;Syncle handles credentials, so a few things are non-negotiable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Connection passwords and hook secrets are &lt;strong&gt;encrypted at rest (AES-256-GCM)&lt;/strong&gt; and only ever returned to the browser redacted.&lt;/li&gt;
&lt;li&gt;All user values are &lt;strong&gt;bound parameters&lt;/strong&gt;; identifiers are dialect-quoted.&lt;/li&gt;
&lt;li&gt;HTTP payloads are built by &lt;strong&gt;structured token substitution&lt;/strong&gt; (&lt;code&gt;{{column}}&lt;/code&gt;, &lt;code&gt;{{$row}}&lt;/code&gt;, &lt;code&gt;{{$op}}&lt;/code&gt;, …) — no string injection, no code execution.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It runs locally with no auth layer by default. Add authentication and restrict which destinations a bridge may write to before exposing it to an untrusted network.&lt;/p&gt;




&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;NestJS · BullMQ + Redis · Prisma + PostgreSQL · Next.js 15 · React 19 · TypeScript · Tailwind · shadcn/ui · TanStack Query &amp;amp; Table · Monaco · React Flow · Zod · Vitest.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/osmanahmadxai/SYNCLE" rel="noopener noreferrer"&gt;https://github.com/osmanahmadxai/SYNCLE&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-liner:&lt;/strong&gt; &lt;code&gt;curl -fsSL https://raw.githubusercontent.com/osmanahmadxai/SYNCLE/main/install.sh | sh -s -- up&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License:&lt;/strong&gt; MIT&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've ever wanted "just keep these two databases in sync" without standing up a streaming platform to do it, I'd love for you to try it and tell me where it breaks. Issues and PRs welcome.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the messiest cross-database sync you've had to build by hand? Tell me in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>typescript</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
