A claude-quality ragtime adaptor for Datalevin.
Syncopate lets you version and evolve a Datalevin schema (and the data around it) with ragtime's battle-tested migrate/rollback machinery — while fixing the sharp edges that the SQL adaptors and hand-rolled Datalevin adaptors trip over.
It began as a toy adaptor that stored migration records as datoms, sorted history
by wall-clock, and swallowed every error. That works in a demo and fails in
production. Syncopate is a from-scratch redesign informed by the two blessed
ragtime adaptors (ragtime.jdbc, ragtime.next-jdbc), the recurring complaints
in ragtime's issue tracker, and the ideas in
datalevin-surge:
d/schema and Datalog queries, and
works under :closed-schema? true. (Datalevin refuses a second LMDB connection
to the same directory, so Syncopate reuses the connection's own environment.)d/with-transaction by
default, with a per-migration :transaction? false escape for operations that
can't (ragtime issues #107 / #119).ex-info carrying the
migration id, direction, phase and offending step — never silently dropped
(ragtime issues #130 / #146).pending and status tell you what will run
before you run it (ragtime issues #85 / #127).Migrations are EDN-first. Put one file per migration under a directory or
classpath prefix; the id defaults to the filename (0001-add-users).
A migration spec is a map:
{:id "0002-split-names" ; optional; defaults to the filename
:up <step | [step ...]>
:down <step | [step ...]> ; optional if :up is purely additive
:transaction? true ; optional, default true
:irreversible? false} ; optional
A step is one of:
| Step | Meaning |
|---|---|
{:schema {attr def ...}} | add/update attributes (via update-schema) |
{:schema/remove [attr ...]} | retract every datom of the attrs, then drop them |
{:tx [tx-data ...]} | raw transact! data |
a.namespace/fn-symbol | resolved to (fn [conn] ...) and called |
an actual (fn [conn] ...) | only in .clj migration files |
:up/:down may be a single step instead of a vector.:down and Syncopate derives it automatically — iff every :up
step is a purely additive schema delta (adding attrs inverts to removing them).
If :up contains a removal, a :tx, or a function, supply :down explicitly
or set :irreversible? true; otherwise you get a clear build-time error.Purely additive — :down is derived (resources/migrations/0001-add-users.edn):
{:up {:schema {:user/id {:db/valueType :db.type/long
:db/unique :db.unique/identity}
:user/name {:db/valueType :db.type/string}}}}
Schema change plus a data transform referenced by symbol
(resources/migrations/0002-split-names.edn):
{:up [{:schema {:user/given-name {:db/valueType :db.type/string}
:user/family-name {:db/valueType :db.type/string}}}
my-app.migrations/split-user-names
{:schema/remove [:user/name]}]
:down [{:schema {:user/name {:db/valueType :db.type/string}}}
my-app.migrations/join-user-names
{:schema/remove [:user/given-name :user/family-name]}]}
A .clj migration, whose steps may be real functions
(resources/migrations/0003-seed-departments.clj):
{:up [{:schema {:dept/name {:db/valueType :db.type/string
:db/unique :db.unique/identity}}}
(fn [conn]
(datalevin.core/transact! conn [{:dept/name "Engineering"}]))]
:down [(fn [conn] ...)
{:schema/remove [:dept/name]}]}
(require '[datalevin.core :as d]
'[ragtime.core :as ragtime]
'[syncopate.core :as syncopate])
(def conn (d/get-conn "/path/to/db"))
(def store (syncopate/store conn)) ; the ragtime DataStore
(def migs (syncopate/load-resources "migrations"))
;; What's pending?
(syncopate/status store migs)
;; => {:applied [] :pending ["0001-add-users" "0002-split-names" ...]}
;; Apply everything outstanding, then roll the last one back:
(ragtime/migrate-all store {} migs)
(ragtime/rollback-last store {})
ragtime.core/migrate runs the migration body and records the applied-id as two
separate transactions. Syncopate's own helpers fold both writes into a single
d/with-transaction, so the body and its bookkeeping commit — or roll back — as
one unit (a mid-migration failure leaves neither the schema/data change nor the
applied-id behind):
(syncopate/migrate! store migration) ; atomic :up + record
(syncopate/rollback! store migration) ; atomic :down + un-record
(syncopate/migrate-all! store migs) ; apply all pending, each atomically (idempotent)
(syncopate/rollback-last! store migs) ; atomically roll back the most recent
This is possible because the applied-id lives in a KV DBI on the same LMDB
environment as the data. It applies to transactional migrations (the default);
:transaction? false migrations fall back to the two-step path.
syncopate/store options:
| Option | Default | Meaning |
|---|---|---|
:dbi-name | "__syncopate_migrations" | KV DBI holding applied-migration state |
Syncopate logs through trove, a tiny facade that lets a library emit rich logs without forcing a backend on you. Calls are noop until you install a backend, so nothing is printed by default. In your app, wire trove to whatever you already use:
(require '[taoensso.trove :as trove]
'[taoensso.trove.console]) ; or .tools-logging / .slf4j / .telemere / .timbre / .mulog
;; each integration ns exposes get-log-fn:
(trove/set-log-fn! (taoensso.trove.console/get-log-fn))
;; (trove/set-log-fn! nil) ; noop / silence
See trove's docs for the full list of backend integrations and their options.
Every event has a stable :id, a human :msg, and structured :data
(migration id, direction, step counts, elapsed ms). Levels:
| Level | Events |
|---|---|
| info | :syncopate/migrate-all, :migrate-all-done, :migrated, :rolled-back, :rollback-last, :nothing-pending, :nothing-applied, :loaded |
| warn | :syncopate/no-migrations (nothing found at the given path/prefix) |
| error | :syncopate/migration-failed (carries the causing throwable and the failing step) |
| debug | :syncopate/migrating, :rolling-back, :step, :schema-delta, :schema-retract, :recorded, :unrecorded, :auto-down |
Filtering by level/namespace/id is the backend's job (e.g. via tools.logging/slf4j
config). Building :data is deferred, so disabled log levels cost effectively nothing.
clojure -T:build test
The suite runs against a temporary Datalevin database and covers loading/ordering,
auto-:down derivation, the reversibility guard, a full migrate/rollback
round-trip (schema and data, including a symbol-referenced transform and a
.clj migration), applied-id ordering, the no-schema-pollution invariant,
error-context propagation, the atomic helpers, the key atomic-rollback
guarantee (a failing transactional :up leaves neither schema change nor
applied-id behind), and the logging events (captured via a test trove backend).
migrate!/rollback!/migrate-all!/
rollback-last!) — they commit the migration body and its applied-id in one
transaction. The two-transaction seam (body committed, applied-id recorded
after) remains only for ragtime.core/migrate and for :transaction? false
migrations, which have no transaction to fold into; keep those idempotent-friendly.:down only covers additive schema deltas; anything that removes
attributes, transacts data, or runs a function needs an explicit :down (the
prior state can't be inferred).(.lmdb (:store @conn))); verified against Datalevin 1.0.0.--add-opens=java.base/java.nio=ALL-UNNAMED,
--add-opens=java.base/sun.nio.ch=ALL-UNNAMED, and
--enable-native-access=ALL-UNNAMED; on JDK 24+ also
--sun-misc-unsafe-memory-access=allow. The test task sets these for you; add
them to your app/REPL JVM opts when embedding Syncopate.Copyright © 2026 crinklywrappr.
Distributed under the Eclipse Public License 2.0
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |