You have a row in Postgres. You want that same row in MongoDB — not tonight in a batch job, but the instant it changes. And next month you'll want it in Redis too, and maybe POSTed to some webhook.

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:

a source → one or more destinations → kept in sync.

Syncle is an open-source tool that does exactly that sentence, and nothing you didn't ask for. Connect your databases, draw a bridge 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 — PostgreSQL · MySQL/MariaDB · SQLite · MongoDB · Redis — plus HTTP endpoints when you need them.

This post is a tour of what it does and, for the curious, how it's built.


The core idea: a bridge

A bridge reads rows from a source and writes each one to its destinations. A destination is either:

  • another database — 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).
  • an HTTP endpoint — POST/PUT/PATCH each row to a URL with a payload you design, for feeding a service instead of a database.

The interesting part isn't that it copies data — plenty of things copy data. It's the guarantees around how.

No duplicates, ever

Every database write is an idempotent upsert, 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:

Engine Upsert
PostgreSQL / SQLite INSERT ... ON CONFLICT
MySQL INSERT ... ON DUPLICATE KEY UPDATE
MongoDB updateOne(filter, ..., { upsert: true })

Inserts, updates, and deletes all propagate — a delete routes to a keyed delete on each target.

Missing table? It builds it

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 timestamptz becomes something sensible in SQLite, a document in Mongo, a hash in Redis). Or you map columns yourself: write this column into that column over there, rename, drop, pick keys.

You pick how it fires

Three trigger modes, same everything-else:

  • Replay — a one-shot job. Stream all (or selected) rows once, then finish. Perfect for the initial backfill or a migration.
  • Watch — poll the source on a cursor (auto-increment id, an updated_at column, or a primary-key diff) and sync new rows as they appear. Works on every engine.
  • CDC — 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.

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.


Watch it happen

Reliability you can't see isn't reassuring, so every delivery shows up on a live timeline:

  • 🟢 green — synced
  • 🔴 red — failed
  • 🟡 amber — skipped
  • ⬜ slate — queued

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.


Try it in one command

A fresh machine needs only Docker. Everything else — Node, Postgres, Redis — runs in containers:

curl -fsSL https://raw.githubusercontent.com/osmanahmadxai/SYNCLE/main/install.sh | sh -s -- up

Enter fullscreen mode Exit fullscreen mode

That installs a native syncle command, builds the stack, and opens the GUI at http://localhost:3002. After that it's just:

syncle up      # start everything, open the GUI
syncle down    # stop it
syncle logs    # follow the logs
syncle update  # pull latest + rebuild

Enter fullscreen mode Exit fullscreen mode

Prefer to run from source?

pnpm install                  # frontend + backend
docker compose up -d          # postgres (metadata) + redis (job queue)
pnpm start                    # env files + migrations + run the whole app

Enter fullscreen mode Exit fullscreen mode


How it's built

Syncle is a pnpm monorepo with a strict one-way dependency flow: web → api → core.

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)

Enter fullscreen mode Exit fullscreen mode

A few design decisions did most of the work of keeping it small.

One sink, two destination kinds

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

CDC behind one interface

Each engine captures change its own way — logical replication, binlog, change streams, keyspace notifications — but they all implement the same small contract:

interface CdcProvider {
  readiness(): Promise<Readiness>;   // is the source configured for capture?
  provision(): Promise<void>;        // create slot/publication if needed
  stream(): AsyncIterable<Change>;   // insert/update/delete, each tagged with its op
  cursor(): Cursor;                  // where we are, for resume
}

Enter fullscreen mode Exit fullscreen mode

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

Two data layers, two right tools

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

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

Durable runs

A replay run is one BullMQ job (jobId = runId). 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.

The north star: adding an engine = implement DatabaseAdapter and register it. The connection form, schema browser, and feature gating all derive from that one registration.


A note on security

Syncle handles credentials, so a few things are non-negotiable:

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

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.


The stack

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


Try it

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.

What's the messiest cross-database sync you've had to build by hand? Tell me in the comments.