Skip to content
Melange v0.9.3

Melange v0.9.3

August 21, 2026·pthm
pthm

Melange v0.9.3 records the authorization model in the database at migrate time. melange_migrations previously stored only a checksum, so a mismatch told you that something differed but not what. With the model stored, melange status reports which relations drifted, melange diff classifies changes as additive or breaking, melange schema pull reconstructs the .fga from a live database, and melange migrate --if-deployed-checksum applies only if the database is still at the checksum you expected. Named environment profiles let one --env production flag target any database.

This release also rejects schemas containing OpenFGA conditions, which were previously compiled without them.

No breaking changes. The melange runtime package is unchanged, as is generated check and list SQL. The new columns are added with ADD COLUMN IF NOT EXISTS, and every read degrades gracefully on databases migrated by an earlier version. Run melange migrate once to record the model. A plain rerun backfills it without re-applying the generated functions, so no --force is needed.

What the database records

melange_migrations gains three columns:

ColumnContents
schema_dslThe exact DSL that was applied, and the source for schema pull
model_jsonThe parsed model, the same structures melange compiles to SQL
schema_formatsingle or modular

Both representations are stored. The DSL is the human-readable form; the JSON is the machine-readable form used for diffing. Both already existed at migrate time: the DSL was threaded through to compute the checksum and then discarded.

Every command below reads those columns.

Status reports what drifted

melange status reports the deployed model’s provenance and compares it against the local file:

$ melange status --env production
Schema file:  present
Tuples view:  present
Deployed:     checksum c729989dcba1… · melange v0.9.3 · 2026-08-21T16:57:33Z
Sync:         drift — local schema differs from deployed (`melange diff` to see changes, `melange migrate` to apply)
              1 breaking, 1 additive
              + type audit_log added
              - document.viewer no longer grants [user]

The change list is capped at five lines; melange diff prints the rest. --format json emits the same detail as a drift object containing every change.

A fourth sync state, database_ahead, reports that the deployed model is not any version of your schema in git history. Someone migrated that database from a different checkout, so applying your local schema would overwrite their model rather than move the database forward. The check is conservative and reports only when git can show the deployed model is absent from recent history. It stays silent in a shallow clone (CI checkouts are shallow by default), with a dirty working tree, for modular schemas, and past a 50-commit window.

Semantic diffs

melange diff compares two models and classifies every change:

$ melange diff --env production
Comparing deployed → melange/schema.fga

  additive  type audit_log added
  BREAKING  document.viewer no longer grants [user]

1 breaking, 1 additive

The comparison runs over melange’s parsed model rather than DSL text, so reformatting and comment edits are not changes, and the verdict reflects what the database will enforce. Classification accounts for implied-by closure, intersection subsumption, and exclusion polarity. It is conservative: it may over-report on rare shapes, but it does not under-report a breaking change, which is what makes --exit-code usable as a CI gate.

The comparison source follows the vocabulary generate migration already used: the deployed database by default, --git-ref <ref>, or --previous-schema <path>. --format json emits the structured diff. The same engine feeds melange doctor, which warns when the local schema has breaking changes pending, and melange generate migration, which writes a summary header into the generated SQL.

Drift-safe migrate

melange migrate --if-deployed-checksum <sha> makes migration a compare-and-swap. It applies only if the database is still at the given checksum:

CURRENT=$(melange status --env production --format json | jq -r .deployed.schema_checksum)
# review, plan, get approval
melange migrate --env production --if-deployed-checksum "$CURRENT"

If the database moved in the meantime, migrate exits 1 having changed nothing:

Error: migration aborted: deployed model changed: expected checksum deadbeef but
database has c729989dcba1…; nothing was applied

This covers the window between reading a database’s state and applying to it, where last-writer-wins would otherwise overwrite the other change without reporting it. The checksum is verified before SQL is generated and again inside the apply transaction, so a migration that commits while yours runs aborts yours. It is a precondition rather than a lock: two migrations that read at the same instant can both proceed, but concurrent migrations against one database are unsupported regardless.

Recovering a schema

melange schema pull reconstructs the .fga recorded by the most recent migration. Use it for a database whose source file was lost, or to read what a database is running before changing it:

$ melange schema pull --env production
# Pulled from a melange-migrated database by `melange schema pull`
# Deployed: 2026-08-21T16:57:33Z by melange v0.9.3
# Schema checksum: c729989dcba13e814425bc5aa7c476b75eb1f56009d6db81b8bd743bd2e98050

model
  schema 1.1
…

A single-file schema comes back byte-identical, with a #-comment header that still parses as valid DSL. --no-header omits it. The connection string is never included, since a pulled schema is committed to git and a DSN in its header would be a credential leak. A modular (fga.mod) schema is emitted as the stored manifest plus module bundle and labelled as such; splitting it back into module files is not supported.

melange history lists the audit trail those records form:

$ melange history --limit 5
Migration history (most recent first):
  2026-08-21T16:58:01Z · melange v0.9.3 · checksum 8b484896f280… · single · 41 functions
  2026-08-21T16:57:33Z · melange v0.9.3 · checksum c729989dcba1… · single · 36 functions

Environment profiles

Each command above targets a specific database, so configuration gains named connection profiles:

default_environment: local

environments:
  local:
    database:
      url: postgres://localhost:5432/app
  production:
    database:
      url: ${PROD_DATABASE_URL}

--env is a root-level flag inherited by every subcommand. Selection precedence is --env, then MELANGE_ENV, then default_environment, then the base database block. A profile overlays that base, so it states only what differs. ${VAR} references keep credentials out of the committed file. An explicit --env naming an undefined profile is an error rather than a fall back to the base database.

melange env list shows what is configured, with passwords masked and unexpanded references printed as written:

$ melange env list
Environments:
* local            postgres://test:****@localhost:55432/app?sslmode=disable
  production       ${PROD_DATABASE_URL}

Default: local
Active:  local (marked with *)

config show masks passwords the same way; --reveal-secrets prints them. A command running against a non-base environment prints → environment: production to stderr. With no environments block, behaviour is identical to v0.9.2.

Conditions are rejected

Reported as #81: melange does not support OpenFGA conditions, but the parser discarded them silently. A schema copied from OpenFGA passed melange validate and compiled to a broader grant than written, turning [user with non_expired_grant] into [user]. Parsing now returns an error:

Error: parsing schema: melange: invalid schema: conditions are not supported and
would be silently dropped: document#viewer allows [user with non_expired_grant].
Remove them or see https://melange.sh/docs/reference/openfga-compatibility/

The check names every offending type#relation and condition, and also catches a condition that is declared but never applied to a type restriction. A schema with conditions that compiled before this release was not enforcing them, so check what the database currently grants before upgrading.

Migration notes

Schemas containing conditions are the only upgrade that can break an existing workflow. The runtime package is unchanged. pkg/migrator and pkg/schema gain additive APIs: GetDeployedModel, GetMigrationHistory, MigrateOptions.IfDeployedChecksum, schema.Diff, and MarshalModel / UnmarshalModel.

To record the model:

melange migrate

A rerun on an already-migrated database backfills schema_dsl and model_json through the phase-2 path without re-applying the generated functions. Until then, status reports the model as not recorded, and schema pull reports that it cannot recover a database migrated before model storage existed. Neither is an error.

If you emit migration files rather than applying directly, dry-run output now includes the model columns and their ALTER TABLE statements, so a separately-applied script records a migration identical to a normal migrate.

Under the hood

model_json is usable only if every rule field survives serialization, so MarshalModel / UnmarshalModel ship with a round-trip test covering each rule variant: direct, wildcard, implied, TTU, exclusion, userset, intersection, and excluded-intersection.

Command output now renders through an io.Writer rather than printing to stdout, matching internal/cli/render. The accompanying tests assert the exact strings quoted in this post, so the CLI and its documentation stay aligned.

Try it out

# Install / upgrade CLI
brew install pthm/melange/melange

# Or pull the container image
docker pull ghcr.io/pthm/melange:v0.9.3

# Record the deployed model
melange migrate

# Read what a database is running
melange status --env production
melange schema pull --env production

# Gate CI on breaking changes
melange diff --env production --exit-code

# Go runtime (unchanged this release)
go get github.com/pthm/melange/melange@v0.9.3

# TypeScript runtime
npm install @pthm/melange

Feedback

Thanks to everyone who reported issues this cycle, including #81. Open an issue for bug reports or feature requests.