Versioned SQLite migrations for Bun, Node, and Deno — usable as a library or a CLI.
litevolve reads a directory of numbered SQL files ({version}_name.sql, {version}_name.down.sql, optional {version}_name.seed.sql) and applies them up or down against a SQLite database to reach a target schema version. Each step runs in a single BEGIN IMMEDIATE transaction so a failed seed rolls back its schema change too. The current schema version is tracked in SQLite's native PRAGMA user_version; a sticky init_seeds flag is recorded in an internal _db_meta table so seed behavior stays consistent across subsequent upgrades.
| package | source | master copy |
|---|---|---|
litevolve-bun |
runtimes/bun |
✅ |
litevolve-node / for Deno too |
runtimes/node |
Not distributed yet:
- standalone binaries — built and smoke-tested in CI, not attached to releases
- the docker image —
scripts/Dockerfileexists but no image is published
How litevolve stacks up against three other JS/TS-ecosystem SQLite migration tools. drizzle-orm
is compared on its migration tooling (drizzle-kit) only, not its query builder / ORM layer.
litevolve |
sqlite-auto-migrator |
deno-nessie |
drizzle-orm (migrations only) |
|
|---|---|---|---|---|
| Migration format | Hand-written .sql files |
Auto-generated .mjs from a schema-file diff |
Function- or class-based (up/down), SQL or JS |
TS schema to SQL generated by drizzle-kit |
| Runtimes | ✅ Bun, Node, Deno | Node, Bun | Deno only | ✅ Node, Bun, Deno |
Rollback (down) |
✅ .down.sql file per version |
✅ Generated down function |
✅ Explicit down() per migration |
Manual |
| Seeding | ✅ Built-in .seed.sql (each version has valid seeds) |
Not built-in | ✅ Built-in | Not built-in |
| Runtime dependencies | ✅ No runtime dependencies | sqlite3 (Node) or Bun's native SQLite |
Per-client DB drivers | Drizzle ORM + driver of choice |
| Docker image | Coming soon | None | ✅ halvardm/nessie (unmaintained, last updated ~5 years ago) |
None |
| Executables | Coming soon | None | None | None |
| Package manager | npm + coming soon via most common managers (HomeBrew, Snap, ...) | npm only | deno.land/x, nest.land, not on npm or Homebrew | npm only |
| Stars | 1 | 7 | 519 | 35594 |
| Open issues | 2 | ✅ 0 | 8 | 1983 |
| Latest release | ✅ 2026-08-26 | 2025-05-17 | 2023-09-24 | 2026-03-27 |
| Latest commit | ✅ 2026-08-26 | 2025-05-17 | 2023-09-24 | 2026-07-23 |
Comparison stats last fetched: 2026-08-26
# Bun
bun add litevolve-bun
# Node: npm / pnpm / yarn
npm install litevolve-node
pnpm add litevolve-node
yarn add litevolve-node
# Deno
deno add npm:litevolve-node
# Homebrew (CLI only) Not available yet
brew install litevolveNode caveat:
litevolve-nodeuses the built-innode:sqlite(DatabaseSync), added in Node 22.5.0 and still marked experimental (Stability 1.2 — Release Candidate). It emits anExperimentalWarningand its API may shift in a minor/patch release. Requiresnode >= 22.5.
See the example folder for usage examples in different runtimes.
// Import from the runtime-specific package: litevolve-bun or litevolve-node.
import { migrate_db } from "litevolve-bun"
// Apply migrations up (or down) to reach version 2.
// Returns the open Bun Database handle.
const db = migrate_db(
"./migrations", // migrations_path: directory holding the .sql files
"./data/birds.db", // db_path: SQLite file (or ":memory:")
true, // init_seeds: only honored on a fresh DB at v0
2, // apply_version: target schema version, or undefined for "latest"
)migrate_db function is defined per runtime in runtimes/<runtime>/src/index.ts.
It opens the database, sets journal_mode = WAL and foreign_keys = ON, then delegates to the shared migrate_with_adapter in src/core/migrate.ts.
It returns the open handle: a Database from bun:sqlite for the Bun package, a DatabaseSync from node:sqlite for the Node one. The error type is migration_error in src/core/migration_error.ts.
No binaries are published yet: CI build is available, but binaries are not published yet. bunx and npx commands are available.
The CLI takes the same four inputs as named flags. --apply_version is optional — omit it to migrate up to the highest-numbered migration file in migrations_path:
litevolve \
--apply_version=2 \
--db_path=./data/birds.db \
--migrations_path=./migrations \
--init_seedsEach package declares a litevolve bin, so it runs via bunx / npx without installing anything:
bunx litevolve-bun --apply_version=2 --db_path=./data/birds.db --migrations_path=./migrations
npx litevolve-node --apply_version=2 --db_path=./data/birds.db --migrations_path=./migrationsFrom a clone, or as a binary you compile yourself:
bun run runtimes/bun/src/run_litevolve.ts \
--apply_version=2 --db_path=./data/birds.db --migrations_path=./migrations
make ci_binary TARGET=bun-darwin-arm64 # -> dist/litevolve
dist/litevolve --apply_version=2 --db_path=./data/birds.db --migrations_path=./migrationsNo image is published yet — scripts/Dockerfile builds the multi-arch binaries but nothing pushes it to a registry. The intended usage, once it is:
To run migrations during a Docker build without installing litevolve's runtime in your image, copy the binary from the official image in a multi-stage build:
FROM litevolve:latest AS migrator
FROM debian:bookworm-slim
COPY --from=migrator /usr/local/bin/litevolve /usr/local/bin/litevolve
COPY ./migrations /migrations
RUN litevolve --apply_version=3 --db_path=./data/app.db --migrations_path=/migrationsFor Alpine-based images, use the musl-linked variant:
FROM litevolve:musl AS migratorThis pattern is suited for baking a pre-seeded read-only SQLite file into an image. For runtime migrations against a writable volume, run litevolve at container startup instead.
Files in the migrations directory are validated by a strict regex:
0*[1-9][0-9]*_([a-z]|[A-Z]|[0-9]|_)+\.(sql|seed\.sql|down\.sql)
Breakdown:
0*— optional leading zeros for zero-padding (padding is not required).[1-9][0-9]*— the numeric version: a non-zero leading digit followed by any digits._— separator.([a-z]|[A-Z]|[0-9]|_)+— a[a-zA-Z0-9_]+description.\.(sql|seed\.sql|down\.sql)— one of three extensions.
| Extension | Direction | When applied |
|---|---|---|
.sql |
up | when current_version < N ≤ target |
.down.sql |
down | when target < N ≤ current_version |
.seed.sql |
up + init_seeds | optional, same transaction as .sql |
Files that do not match the regex throw a migration_error and abort the run — the directory is read non-recursively and every entry in it must be a migration. Keep auxiliary files (notes, fixtures, sub-directories) out of the migrations directory, including README.md.
Sort order is numeric after stripping leading zeros, not lexicographic. 0999_x.sql sorts before 01000_y.sql because parseInt("0999") is 999 and parseInt("01000") is 1000. Padding is optional and its width can vary across migrations without breaking the order: 1_…, 0042_…, 0999_…, 01000_… all sort correctly together, and an unpadded 42_… sorts identically to 0042_….
Valid examples: 0001_create_initial_schema.sql, 1234_create_users_table.sql, 0042_add_users_language_column.down.sql, 01000_split_audit_log.seed.sql, 0004_iso8601_timestamps.down.sql.
Invalid: 0000_foo.sql (no non-zero digit), 0_foo.sql (version 0), 0001-foo.sql (hyphen not allowed), 0001_foo.txt (wrong extension).
Notes about the parser (see run_sql_statements in runtimes/bun/src/core/migrate.ts):
- Line comments
-- …are stripped, then the file is split on;and each statement is run on its own —bun:sqliteonly surfaces the error of the last statement in a multi-statement string, so a batched migration could silently COMMIT past a failure - The splitter is deliberately naive: no
;or--inside string literals, noBEGIN … ENDtriggers - Down migrations never apply seeds. Each
.down.sqlis responsible for its own data cleanup before dropping columns or tables
init_seeds is sticky: it is only honored when the database is at version 0 (fresh or fully rolled back). The chosen value is recorded in _db_meta and reused for every subsequent up-migration on the same database. Passing --init_seeds to a partially-migrated DB is silently ignored — this guarantees that a database either consistently has its seed rows or consistently does not. See the behavior contract in runtimes/bun/src/migrate.test.ts (the init_seeds_* tests).
The migrations/working/ directory in this repository ships a runnable three-version example modelling a bird-observation system (the sibling migrations/broken/ holds an intentionally-invalid migration used only by the test suite):
- v1 (
0001_create_initial_schema.sql) — minimal core, four tables:observation_sites (id, name)birders (id, name, joined_at)time_slots (id, site_id, starts_at, ends_at, reserved)sightings (id, birder_id, site_id, species_common_name, observed_at, status)
Optional seed populates 3 sites, 8 birders (Alice Johnson, Bob Smith, …), 32 two-hour observation windows, and 3 sightings (pending / verified / rejected).
- v2 (
0002_expand_schema.sql) — adds richer metadata viaALTER TABLE ADD COLUMNand creates two intake tables:observation_sitesgainslatitude,longitude,habitat_type,timezone.birdersgainsemail,skill_level,favorite_species,timezone.time_slotsgainsweather.sightingsgainsspecies_scientific_name,individual_count.- New tables
incoming_reports (id, source, raw_payload, received_at)andincoming_reports_archive (…, archived_at).
Optional seed back-fills coordinates, skill levels, scientific names, weather notes, and sets timezone = 'America/New_York' for Central Park, Alice, and Bob.
- v3 (
0003_add_birder_mentors.sql) — addsmentor_birder_id TEXT REFERENCES birders(id)tobirders(a self-referential FK). Optional seed marks Alice as the mentor of Carol/Dan/Eve and Bob as the mentor of Frank/Grace.
The down migration demonstrates the NULL-before-drop pattern for foreign-key removal:
-- 0003_add_birder_mentors.down.sql
UPDATE birders SET mentor_birder_id = NULL;
ALTER TABLE birders DROP COLUMN mentor_birder_id;Clearing the FK values first is the right habit even when DROP COLUMN would also strip the inline REFERENCES constraint — it's the pattern you must use when removing a FK constraint while keeping the column, since SQLite has no ALTER TABLE DROP CONSTRAINT and the alternative is a CREATE TABLE … / INSERT SELECT / DROP / RENAME table-rebuild that would otherwise copy stale references into the new table.
Drive it from the Makefile:
make migrate_seeds DB_PATH=./birds.db VERSION=2 MIGRATIONS_PATH=./migrations/working
sqlite3 ./birds.db "SELECT name, timezone FROM observation_sites;"MIGRATIONS_PATH is required here: it defaults to the repository root, which holds no migrations and would throw on the first non-matching filename.
runtimes/bun/ litevolve-bun — master copy of src/core, plus the bun:sqlite adapter
runtimes/node/ litevolve-node — generated src/core, plus the node:sqlite adapter
migrations/ working/ example database, broken/ fixture used by the test suite
scripts/ every CI step, as plain shell — the Makefile only calls into these
(plus check_bun_version.sh, which is local-only)
examples/ useful examples on how to use the library in Bun, Node, and Deno
Refer to Makefile for a comprehensive list of available helping commands. make ci_checks runs everything CI does, plus make check_version — which checks your installed Bun against .bun-version and has no CI equivalent, since CI installs Bun from that file.
- OSX is recommended for development
- if you have any experience contributing to this library under Linux please share your setup
litevolvebasic ecosystem is Bun, so you need Bun installed in your machine, possibly to the .bun-version version- a fix to
src/coregoes intoruntimes/bunand reaches the others throughmake align_artifacts— editing a generated copy directly will failmake ci_check_align - dependency and toolchain versions are Renovate's job
- the
enginesfield is a floor: the oldest runtime the package supports, not the version we build with. It is raised by hand, only when a breaking change raises the real minimum - Makefile approach is opinionated (sorry)
- Use any editor but don't push any related configuration of it, keep it in your machine
- I currently use Helix editor
Versions are driven by changesets, one project per runtime.
# on a dev branch: bump package.json + CHANGELOG.md from the changeset files
make yield_version
# open a PR, get it reviewed, rebase main
# on main, after the merge: tag the merged commit <package>@<semver>
make yield_new_version
git push --follow-tagsThe tag is what publishes. .github/workflows/publish.yml triggers on litevolve-{bun,node}@* tags, refuses any tag that is not an ancestor of main, re-checks the tag against package.json, then builds, tests, packs and publishes that one package to npm with trusted publishing and a provenance attestation.
