Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4a91c09
Split state mutations into focused handlers
dsecurity49 Aug 27, 2026
fb615d2
Validate caches before installing them
dsecurity49 Aug 27, 2026
28e6316
Run release checks on every supported platform
dsecurity49 Aug 27, 2026
4cb3665
Route graph checkpoints through DependencyGraph
dsecurity49 Aug 27, 2026
7306c5c
Move schema mutations into their own module
dsecurity49 Aug 27, 2026
95cfda8
Set the development version to 0.7.0
dsecurity49 Aug 27, 2026
dc05e04
Drop the unused generic dependency query
dsecurity49 Aug 27, 2026
4774b98
Split catalog loading into typed helpers
dsecurity49 Aug 27, 2026
b98b7c2
Exercise rollback with generated state sequences
dsecurity49 Aug 27, 2026
e97fe84
Keep schema cascade cleanup with schema mutations
dsecurity49 Aug 27, 2026
9aad85b
Move role and privilege mutations into their own module
dsecurity49 Aug 27, 2026
84138a0
Pin the cache and report output contracts
dsecurity49 Aug 27, 2026
6974ad9
Record the optimized 0.7.0 benchmark baseline
dsecurity49 Aug 27, 2026
03734d7
Reuse parsed SQL when building location reports
dsecurity49 Aug 28, 2026
487a7de
Move policy and trigger mutations into their own module
dsecurity49 Aug 28, 2026
ba188a1
Update the docs for 0.7.0
dsecurity49 Aug 28, 2026
a09bc91
feat: complete 0.7.0 reliability hardening
dsecurity49 Aug 29, 2026
58ebbf1
feat: harden v0.7 migration state tracking
dsecurity49 Aug 30, 2026
fc98b08
fix: exclude incomplete column dependency differential
dsecurity49 Aug 30, 2026
3fda9db
fix: enable maintain privilege for PostgreSQL 17
dsecurity49 Aug 30, 2026
7c521da
docs: focus README on migration users
dsecurity49 Aug 30, 2026
657d10a
fix: preserve state for unknown drop targets
dsecurity49 Aug 30, 2026
0e9d59c
docs: summarize v0.7 release changes
dsecurity49 Aug 30, 2026
7b0c61c
fix: harden cache-backed state transitions
dsecurity49 Aug 30, 2026
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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/database-feedback.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ body:
attributes:
label: safe-migrate version
description: Paste the output of `safe-migrate --version`.
placeholder: safe-migrate 0.6.2
placeholder: safe-migrate 0.7.0
validations:
required: true

Expand Down
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,16 @@ jobs:
- name: Build (locked)
run: cargo build --locked --verbose

- name: Verify frozen-cache fixtures
working-directory: live_tests
run: ./run.sh

- name: Run tests (locked)
run: cargo test --locked --verbose

- name: Verify crate package
run: cargo package --locked

- name: Run generated migration fuzz corpus
run: scripts/fuzz

Expand All @@ -59,6 +66,47 @@ jobs:
test "$(cargo-audit --version)" = "cargo-audit 0.22.2"
cargo audit

msrv:
name: Rust 1.94 MSRV
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install Rust 1.94
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable 2026-07-16
with:
toolchain: 1.94.0

- name: Check all targets with the MSRV
run: cargo check --all-targets --locked

platform-smoke:
name: Runtime smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]

steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable 2026-07-16

- name: Build runtime
run: cargo build --locked

- name: Exercise CLI runtime
run: cargo test --locked --test cli_tests test_cli_help

live-differential:
name: PostgreSQL ${{ matrix.postgres }} differential harness
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ graph.json
AGENTS.md
.git/
*.json
!tests/golden/*.json
!live_tests/differential_baseline.sql
!live_tests/differential_manifest.json
!live_tests/**/*.sql
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ commits and pull requests. Published binaries, checksums, and generated release
notes are available on the
[GitHub Releases page](https://github.com/dsecurity49/safe-migrate/releases).

## v0.7.0 — 2026-08-30

- Added semantic Cache V6 validation and durable cache replacement; invalid
cache, configuration, rollback, and report state now produces diagnostics.
- Matched PostgreSQL's 63-byte identifier behavior, including UTF-8-safe
truncation, and added PostgreSQL 17 `MAINTAIN`/`GRANT ALL` support.
- Added inline foreign-key, CHECK, exclusion, and `NOT VALID` constraint state,
with validation tracking and generated-constraint name reservation.
- Fixed cascade cleanup for foreign keys, views, indexes, triggers, and
sequence-backed defaults, including cross-schema `DROP SCHEMA ... CASCADE`.
- Made scoped multi-target drops atomic and tightened validation for sequence
ownership, trigger targets, partition changes, and dependent routines.
- Improved view dependency extraction and catalog filtering, preventing casts,
function expressions, and unrelated catalog rows from creating false edges.
- Added conservative handling for incomplete baseline evidence, including
expression indexes, inherited/publication tables, and type rewrite safety.
- Routed unsupported `ALTER TABLE`/type/view/materialized-view actions,
copied-table forms, unmodeled role options, and incomplete domain/type forms
to explicit tainted analysis rather than recording an exact no-op.
- Preserved relevant safety findings when an operation is skipped because cache
evidence is incomplete, including irreversible drops and `WITH GRANT OPTION`.

## v0.6.2 — 2026-08-27

- Added reproducible, ignored performance scenarios for large synchronized
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "safe-migrate"
version = "0.6.2"
version = "0.7.0"
edition = "2024"
rust-version = "1.94"
description = "Sync PostgreSQL metadata, then lint migrations offline"
Expand Down
35 changes: 11 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,9 @@ SQL alone cannot show the existing schema, table statistics, dependencies,
role and search-path context, or inherited timeout settings. `sync` captures
that baseline once, so later `lint` runs are offline and review the same state.

Cache V6 includes all routine kinds, publications, and redacted subscription
metadata as well as ordinary schema state. It never stores subscription
connection strings. Refresh publisher-side state, or a publication edit that
does not use `ONLY`, remains `Tainted` when PostgreSQL inheritance or remote
publisher state would decide the outcome.
The cache records schema, dependency, privilege, and statistics metadata, but
never connection credentials or password hashes. Treat it as sensitive and do
not publish it.

Sync with the database, role, and defaults used by the migration runner; it
only reads them. Refresh the cache when that baseline changes. If the runner
Expand Down Expand Up @@ -240,8 +238,8 @@ no command-line flag to enable it. Use `--no-auto-sync` to suppress it for one
lint run. If refresh fails, safe-migrate prints the cause and continues with the
previous readable cache; the old cache is replaced only after a new cache has
been written successfully. `--no-cache` also bypasses automatic sync. The
previous cache must already be V6; an unsupported V1–V5 cache cannot be reused
after a failed refresh.
existing cache must use a supported format; otherwise rerun `sync` once
database access is available.

### Cache encryption

Expand All @@ -257,10 +255,6 @@ The key is accepted only through the environment. Encrypted mode rejects
plaintext caches, and plaintext mode rejects encrypted caches. Changing modes
requires a fresh `sync`.

Cache files contain schema and role names, dependencies, privileges, and
statistics. They do not contain connection credentials or password hashes.
Treat cache files as sensitive and do not publish them.

### Cache compatibility

When safe-migrate encounters an unsupported cache format, rebuild it from the
Expand All @@ -270,9 +264,8 @@ database:
safe-migrate sync
```

v0.6.0 introduces Cache V6 for synchronized timeout provenance, the complete
routine namespace, publications, and redacted subscriptions. Every V1–V5 cache
requires resynchronization.
Cache formats are checked before use. If a format is unsupported, rebuild the
cache with `safe-migrate sync`; no migration SQL is changed.

Use `safe-migrate cache inspect` to view cache provenance and redacted object
and role counts without connecting to PostgreSQL. It never lists role names or
Expand All @@ -292,14 +285,8 @@ Pull-request job
GitHub Actions cache -> runner baseline file -> lint-chain -> reports
```

The Action uses
`~/.cache/safe-migrate-action/baselines/<baseline>/baseline-v6.cache` on the
runner. After a successful sync, it saves that file in GitHub Actions cache
under the `default` baseline name. A pull-request run restores the file to the
same managed path, then runs `lint-chain` with it; it does not connect to
PostgreSQL or run `sync` again. GitHub-hosted runners are discarded after the
job; on self-hosted runners the Action clears the selected baseline before each
restore.
The trusted workflow refreshes the baseline. Pull-request workflows restore it
and run `lint-chain` without connecting to PostgreSQL.

### 1. Refresh the baseline

Expand All @@ -310,7 +297,7 @@ and GitHub cache contents are not signed. Store a 64-character hexadecimal key
as `SAFE_MIGRATE_CACHE_KEY` and pass it to both workflows.

```yaml
- uses: dsecurity49/safe-migrate@v0.6.2
- uses: dsecurity49/safe-migrate@v0.7.0
env:
DATABASE_URL: ${{ secrets.SAFE_MIGRATE_DATABASE_URL }}
SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }}
Expand All @@ -329,7 +316,7 @@ Replace `public` with the schemas that contain your migrations, or omit
Add this after checkout in the pull-request workflow:

```yaml
- uses: dsecurity49/safe-migrate@v0.6.2
- uses: dsecurity49/safe-migrate@v0.7.0
env:
SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }}
with:
Expand Down
110 changes: 109 additions & 1 deletion docs/BENCHMARKS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Local benchmark baseline

This document records reproducible, non-CI performance scenarios. The values
are comparison points for later `0.6.x` work, not performance guarantees.
are comparison points for the later `0.7.0` work, not performance guarantees.
Run them with:

```sh
Expand Down Expand Up @@ -36,3 +36,111 @@ intentionally avoid timing thresholds in CI. Allocation, peak-memory,
checkpoint-capture, and isolated dependency-query measurements require a
profiler or allocator instrumentation and are deliberately not inferred from
these wall-clock samples.

## Optimized-profile `v0.7.0` structural baseline

Captured on 2026-08-28 from commit `b639b04` using Rust 1.98.0 on the same
aarch64 Android Linux environment. This run uses Cargo's optimized `release`
profile and is the comparison point for evidence-gated `v0.7.0` work; it is not
comparable to the debug timings above and is not a performance guarantee.

| Scenario | Statements | Elapsed |
| --- | ---: | ---: |
| ordered thousand-statement chain | 1,000 | 6,652 ms |
| large synchronized-baseline hydration | 1,000 relations | 54 ms |
| cache encode/compress/encrypt/decrypt/decompress/decode | 1,000 relations | 24 ms |
| long transaction rollback | 503 | 2,673 ms |
| repeated savepoint rollback | 752 | 308 ms |
| failed multi-action statement rollback | 3 | 1 ms |
| rename and cascade dependency graph | 304 | 1,477 ms |
| location-rich reports with many findings | 250 | 922 ms |

### Location-report parsing improvement

On 2026-08-28, the location-report scenario was sampled five times after
reusing the parse already required to calculate statement ranges. The samples
were 250, 231, 238, 235, and 235 ms (median **235 ms**). This is a 74.5%
reduction from the structural baseline; both the CLI location test and the
scenario's state assertions remained green. This measurement is specific to
the same aarch64 Android Linux host and optimized profile described above.

Run future comparisons with the same command and profile:

```sh
cargo test --release --locked --test performance_scenarios -- --ignored --nocapture --test-threads=1
```

The allocation scenarios use a process-global counting allocator. Run them
alone (or keep `--test-threads=1`) so allocations from another test cannot be
attributed to the scenario under measurement.

## Phase 2 state-copying measurements

Captured on 2026-08-28 in the same optimized profile and environment. The
pre-optimization samples were taken immediately before the Phase 2 changes;
the optimized samples include the statement undo checkpoint and incremental
`PreState` capture. All scenarios retain their exact final-state and rollback
assertions.

| Scenario | Structural baseline | Phase 2 median | Change |
| --- | ---: | ---: | ---: |
| long transaction rollback | 2,673 ms | 1,052 ms | -60.6% |
| repeated savepoint rollback | 308 ms | 127 ms | -58.8% |
| ordered thousand-statement chain | 6,652 ms | 6,529 ms | -1.8% |

The long-transaction samples were 1,124, 849, 767, 1,094, and 1,052 ms. The
savepoint samples were 127, 130, 97, 113, and 242 ms. The ordered-chain samples
were 6,371, 6,529, and 10,201 ms; the outlier illustrates why these local
timings are comparisons rather than release thresholds. Its median shows no
material small/ordinary-chain regression against the checked-in structural
baseline.

The 50-statement chain over a 1,000-relation synchronized baseline provides a
less load-sensitive allocation comparison:

| Measurement | Before Phase 2 | After Phase 2 | Change |
| --- | ---: | ---: | ---: |
| allocations | 1,027,883 | 724,358 | -29.5% |
| allocated bytes | 168,246,639 | 114,914,704 | -31.7% |

Reserving and reusing the public `PreState` map storage also reduced a fresh
1,000-relation capture from 2,060,848 to 1,061,260 allocated bytes (-48.5%).
The returned public fields and values remain unchanged; an equivalence test
compares incremental capture with a fresh capture after update, insertion, and
removal mutations.

Cache V6 decoding now streams decompressed bytes through the bounded bincode
reader instead of retaining a second, fully decompressed byte vector. This is a
structural peak-memory reduction, not an RSS claim: authenticated decryption
still completes before decompression, the 256 MiB decoded-size bound remains
enforced, and a regression test rejects trailing decompressed payload data.

## Phase 3 dependency-graph measurements

Captured on 2026-08-28 from the Phase 3 worktree using the debug test profile
on the same aarch64 Android Linux host. These samples are intentionally kept
separate from the optimized-profile baseline above.

An initial eager index regressed the existing 304-statement rename/cascade
scenario from a five-sample median of 1,048 ms to 1,285 ms. A lazy index still
measured 1,093 ms. Both designs were rejected. The retained design preserves
canonical scans below 1,024 edges and lazily builds a referenced-object index
only for larger cascade graphs. It also omits derived indexes when cloning a
graph for a statement checkpoint.

The unchanged-size rename/cascade scenario then measured 1,042, 1,015, and
1,013 ms (median **1,015 ms**, 3.1% below the 1,048 ms pre-change median). The
large isolated scenario includes initial index construction and compares the
same 1,000 lookups over 10,000 edges:

| Lookup path | Elapsed |
| --- | ---: |
| lazy referenced-object index | 447,268 us |
| canonical full-edge scan | 2,834,576 us |

The indexed path was about **6.3x faster** while returning the same edge count.
Run the isolated comparison with:

```sh
cargo test --locked --jobs 1 --test performance_scenarios large_dependency_graph_lookup_index -- --ignored --nocapture --test-threads=1
```
Loading
Loading