Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/CI_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ view definitions.

## Workflow

- **CI** runs on: push to `master`, PRs to `master`
- **CI** runs on: push to `main` or `master`, PRs targeting `main` or `master`
- Lint (flake8, black)
- Test matrix (4 Python versions × 3 Postgres versions = 12 jobs)
- Coverage report
Expand All @@ -39,3 +39,15 @@ view definitions.
- Build package
- Publish to PyPI
- Create GitHub release

### `main` vs `master` branch protection (2026-08-08)

`main` is the actual GitHub default branch (`gh repo view --json defaultBranchRef`
confirms this), but only `master` has branch protection configured — 13 required
status checks (`Lint` + the 12-job test matrix), `strict` status checks, no force
pushes/deletions. `main` has **no protection at all**
(`gh api repos/.../branches/main/protection` returns 404). This means PRs merged
into `main` today get no required CI gate. The workflow trigger was fixed here
to run on both branches, but the branch protection *rule* itself still needs to
be applied to `main` — that's a repo-settings change, not a workflow file change,
and hasn't been done yet.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [master]
branches: [main, master]
pull_request:
branches: [master]
branches: [main, master]

env:
PGUSER: postgres
Expand Down
76 changes: 76 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,82 @@

## [Unreleased]

### Added

- **Migration state tracking** (`migradiff_history` table):
- `--status` / `--history` flags to view migration history from a database
- `--record-history` flag to record generated migrations in the target DB
- `--env-label` flag to tag history entries with an environment name
- New module `migra/history.py` with `ensure_history_table()`,
`record_applied()`, `record_rollback()`, `get_applied_hashes()`,
`get_history()`, and `compute_migration_hash()`
- SQLAlchemy Core implementation via `sqlbag`, no new dependencies
- **Multi-environment promotion** (`--promote`):
- Colon-separated chain of 2+ database URLs or environment aliases
- Conflict detection at each hop (target must not have hashes source lacks)
- Composes with `--explain`, `--rollback`, `--advise`, `--force-destructive`
- Environment aliases resolved from `~/.config/migradiff/environments.json`
- `--record-history` integration per hop
- **Rollback execution tracking** (`--record-rollback`):
- Records rollback status (`rolled_back` / `rollback_failed`) in the history table
- Accepts a migration hash or rollback SQL file path
- `--rollback-status` flag to mark failed rollback attempts
- `--status` / `--history` output shows rollback annotations
- **New tests**: `test_history.py`, `test_command_promote.py`,
`test_command_rollback_tracking.py` — all using real Postgres databases
(no mocks), covering idempotent table creation, hash normalization,
conflict detection, empty-diff hops, safe mode, and rollback tracking.
- **Migration execution** (`--apply`):
- Executes the generated migration against `dburl_from` in a single
transaction (all-or-nothing — a failed statement rolls back everything
that ran before it) instead of only printing the SQL
- On success, automatically records the migration in `dburl_from`'s
`migradiff_history` table — no need to also pass `--record-history`
- On failure, nothing is recorded (since nothing was actually applied)
and the command exits with code 4
- Not supported with `--from-file` (no live database to apply to) or
`--promote` (not implemented yet) — both are rejected with a clear
error before anything runs
- New helper `_apply_migration()` in `migra/command.py`; JSON output
(`--output json`) gets a new `"apply"` object with the outcome
- New tests: `test_command_apply.py` (11 tests, real Postgres, including
a direct atomicity test that forces a mid-migration failure)

### Fixed

- **Comma-separated `--schema`/`--exclude_schema` silently matched nothing**:
`--schema public,reporting` was documented as supported but the raw
string was passed straight to `schemainspect`, which compares it for
*exact* equality against each object's schema name — `"public,reporting"`
never equals `"public"` or `"reporting"`, so the diff was always empty
and nothing was reported to the user. Single-schema (`--schema public`)
and no-schema calls were never affected — this only broke 2+ names.
- Fix: `migra/util.py` gains `parse_schema_arg()` (splits and trims the
comma-separated value) and `filter_inspector_schemas()` (post-filters
an inspector's tracked object collections using schemainspect's own
`PROPS` list, so it stays in sync with whatever object types
schemainspect adds in the future).
- `migra/migra.py` gains `_get_inspector()`, used at all 5 of
`Migration`'s inspector-construction call sites (`__init__` x2,
`inspect_from()`, `inspect_target()`, `apply()`). Single-schema/no-schema
calls are passed straight through unchanged (zero behavior change);
only 2+ comma-separated names take the new post-filter path.
- New tests: `test_multischema`, `test_multischema_whitespace_tolerant`,
`test_exclude_multischema` in `test_migra.py`, plus new fixtures under
`tests/FIXTURES/multischema/` and `tests/FIXTURES/exclude_multischema/`
— each includes a third, unlisted schema to prove filtering actually
excludes it, not just that it happens to work with one schema.

### Notes

- This is the foundational layer for the upcoming "Control Plane" feature set.
- Resolved: `migradiff_history` used to only record migrations as
*generated*, not *applied* — `--apply` closes that gap by executing the
migration itself and only recording history on confirmed success. Plain
`--record-history` (without `--apply`) still only means "generated", not
"applied" — see the README "Migration State Tracking" section.
- All new features work without AI extras (`anthropic` optional dependency).

## [1.7.2] - 2026-06-08
- Fix: Use PEP 621 [project] table for PyPI metadata (homepage, repository, bug tracker)

Expand Down
106 changes: 106 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

MigraDiff (package `migradiff`, CLI command `migra`) — an actively maintained fork of the deprecated
`djrobstep/migra`. It compares two PostgreSQL schemas (live connections, `pg_dump -s` files, or a
migrations directory applied to an ephemeral DB) and generates the SQL needed to transform one into
the other.

**Contributing notice:** per `CONTRIBUTION_GUIDE.md` and the README, this repo is not currently
accepting external PRs — keep that in mind if asked to open one.

## Commands

Tests require a live PostgreSQL instance (12+, container uses 16) on `localhost:5432` with trust auth:

```bash
docker compose up -d # start Postgres for the test suite
docker compose down # stop it (add -v to wipe the data volume)
```

```bash
make test # py.test -x -svv --cov-report term-missing --cov migra tests
make lint # flake8 . (config in .flake8: ignores E501, W503)
make fmt # isort . && black .
make clean # remove .pyc, .cache, build artifacts
```

Run a single test: `pytest tests/test_command_promote.py::test_name -svv`

CI (CircleCI, `.circleci/config.yml`) runs `make lint` then `make test` against Postgres 14 on every
branch, and publishes to PyPI + tags a release from `master`.

## Architecture

### Core diff engine (upstream-derived, keep changes surgical)

- `migra/migra.py` — `Migration` class: wraps two `DBInspector`/connection-string/schema-dump sources
(`schemainspect.get_inspector`), and `add_all_changes()` sequences every category of change
(schemas, extensions, enums, sequences, triggers, RLS, constraints, indexes, tables, domains,
privileges, comments, ...) in an order that respects PostgreSQL dependency rules — drops before
creates, dependents before dependencies. **This ordering is load-bearing**; don't reshuffle it
without understanding why each step is where it is.
- `migra/changes.py` — `Changes` class plus the `get_*_changes`/`statements_from_differences`
functions that walk `schemainspect` inspector objects and emit SQL for each object type.
`util.differences()` is the generic added/removed/modified/unmodified diff primitive everything
else is built on.
- `migra/statements.py` — `Statements` (a `list` subclass) accumulates generated SQL and refuses to
render (`.sql`) if any statement contains DROP while `safe=True` (`UnsafeMigrationException`) —
this is the `--unsafe` flag's underlying gate.
- `migra/db_inspector.py` — lower-level live-DB comparison helpers (table sizes, per-object-type
compare functions) used mainly by the AI drift/advise features rather than core diffing.

### CLI (`migra/command.py`, the biggest file — ~57KB)

- `parse_args()` defines every flag; `run()`/`_run_inner()` is the dispatch body; `do_command()` is
the `pyproject.toml` entry point (`migra = 'migra:do_command'`).
- Handles multiple input modes: two DB URLs, `--from-file` (pg_dump output), `--from-migrations-dir`
(applies migration files to an ephemeral DB — supports Supabase/Flyway/numeric naming, see
`discover_migration_files`/`apply_migrations`).
- Post-processes raw diff output for safety/UX: destructive-statement detection
(`_check_for_destructive`), column-rename detection (`detect_column_renames`, vs. a naive
drop+add), risk classification for `--output json` (`classify_sql_statement`), credential
redaction in error output (`redact_credentials`).
- `--status`/`--history`/`--promote`/`--record-rollback`/`--apply` etc. talk to `migra/history.py`.

### Migration state tracking (`migra/history.py`)

A `migradiff_history` table (`HISTORY_TABLE`), keyed by a SHA-256 hash of normalized SQL
(`compute_migration_hash`). This is the basis for `--promote` (multi-environment promotion),
`--record-rollback`, and `--apply`. **Important semantic, easy to get backwards**: a plain
`--record-history` (without `--apply`) writes into `dburl_target`'s history table and only means
"this migration was generated/reviewed for that target" — not that it was executed anywhere.
`--apply` (`_apply_migration()` in `command.py`) is the one path that actually executes SQL: it runs
the migration against `dburl_from` (the database being migrated — see the CLI's own help text and
the README's `psql dburl_from < migration.sql` convention) inside a single transaction, and only on
confirmed success does it record history — into `dburl_from`'s table, not `dburl_target`'s. A failed
`--apply` rolls back everything and records nothing. `--apply` is rejected up front (before any
dispatch) when combined with `--from-file` (dburl_from would be an ephemeral throwaway database) or
`--promote` (the chain's from/to direction is not yet reconciled with `--apply`'s execution target).

### AI features (optional extra: `pip install migradiff[ai]`, needs `ANTHROPIC_API_KEY`)

- `migra/ai_explain.py` — one file holding four related-but-distinct features, each with its own
prompt builder + client class: `AIExplainer` (`--explain`), `AIRollback` (`--rollback`, includes a
fully deterministic non-AI rollback path in `generate_deterministic_rollback` plus AI-assisted
reconstruction using `extract_schema_context`), `AIAdvisor` (`--advise`, performance/lock risk),
`AIGenerator` (`--generate`, plain-English → grounded SQL via `extract_relevant_schema`). Also
owns API key config (`~/.migradiff` style config dir, `--setup-ai`, `resolve_api_key`,
`redact_api_key`).
- `migra/ai_drift.py` — `DriftExplainer` (`--explain-drift`): compares two *live* databases (not a
generated migration) and explains the difference, categorizing BREAKING/WARNING/INFO.
- All AI calls are opt-in per the README's privacy framing ("bring your own API key — no data sent
to MigraDiff servers"); don't add code paths that phone home elsewhere.

### Tests

- `tests/` mirrors the module layout (`test_ai_explain.py`, `test_command_promote.py`, etc.) and
needs the live Postgres from `docker compose up -d`.
- `tests/conftest.py` monkey-patches `sqlbag.createdrop` create/drop/exists functions to work
around a Postgres-specific issue in the upstream `sqlbag` dependency — don't remove this shim
without checking `sqlbag`'s current behavior.
- `tests/characterization/` is a separate CLI characterization suite (`capture.py`, `scenarios.py`)
that snapshots CLI behavior for the AI-flag surface, independent of the DB-driven unit tests.
40 changes: 40 additions & 0 deletions LICENSING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# LICENSING.md

## Model: MIT + Business License Agreement (BLA)

All features are available to everyone. No code gatekeeping.
No feature gates. No license key required to unlock functionality.

### MIT License (Free)

**Who:** Individuals, open-source projects, and teams evaluating
for production — no time limit.

**Covers:** All features, unlimited use, full CLI, all schema diffing,
RLS policies, AI flags (--explain, --rollback, --advise, --generate),
JSON output, GitHub Action, Docker images.

**Support:** Community (GitHub issues)

### Business License Agreement (Paid)

**Who:** Required when your team ships to production commercially
(any company with employees using the tool as part of their work).

**Covers:** Everything in MIT license, plus email support and
legal documentation for compliance auditing.

**Tiers:**
- Small Team (1–10 developers): $599/year
- Mid-Market (10–50 developers): $1,999/year
- Enterprise (50+ developers): $5,000/year

**Simple rule:** Evaluate freely, no time limit. Purchase when
your team ships to production.

Questions? [licensing@lateos.ai](mailto:licensing@lateos.ai)
Full terms: [lateos.ai/migradiff/licensing](https://lateos.ai/migradiff/licensing)

---

Full MIT license text is in the LICENSE file.
Loading
Loading