Ship

Book · Parallel migration conflicts

Parallel migration conflicts

Draft

Resolve drop, conversion, rename, and opaque SQL conflicts from merged PRs.

On this page · 5 sections

Git merging two migration directories proves only that their filenames do not collide. ship db diff --json --no-input performs the semantic integration check against the merged database declaration.

Drop versus retained data

Branch A adds Users.timezone. Branch B adds Catalog.search_text. If the merged declaration keeps A's column but omits B's, a database at B would need to drop Catalog.search_text to reach the requested End.

ship db diff AppDb \
  --name converge \
  --json \
  --no-input \
  src/Schema.shp

Ship stops before writing a convergence migration:

{
  "status": "migration_conflict",
  "kind": "non_commutative_heads",
  "parents": [
    "483d20ebf564f6f9",
    "c73d54e1a6340ac7"
  ],
  "end": "c73d54e1a6340ac7",
  "errors": [
    "from 483d20ebf564f6f9: dropping column `Catalog.search_text` requires explicit migration intent; rerun with `--allow-destructive` after reviewing data loss",
    "the requested End snapshot contains changes that are not the compatible union of parent c73d54e1a6340ac7 and the other migration heads"
  ]
}

Resolve the schema decision, not the graph:

  • Keep both columns in the declaration and rerun; Ship generates compatible parent histories and one empty unify checkpoint.

  • If removal is intended and B is unpublished, rebase and regenerate B. If B is already live, author an ordinary reviewed reconciliation migration before unifying; an empty checkpoint never performs a drop.

Divergent type changes

If both branches change the same inherited column to different SQL types, they share one lineage ID but disagree on its End facts:

A: Catalog.price_cents Int -> Decimal(12, 2)
B: Catalog.price_cents Int -> String

The merged declaration chooses the product's actual destination type. The original branch SQL programs disagree, so Ship reports non_commutative_heads; it does not create an empty checkpoint. Reconcile on one ordered branch with an ordinary migration such as:

set_data_type(
  from = Column(Start.Catalog, .price_cents),
  to = Column(End.Catalog, .price_cents),
  using = todo "write the SQL USING expression",
);

No unify checkpoint exists until the conversion is authored and every parent history reaches compatible semantics.

Divergent renames

Two branches can preserve one column ID under different names:

A: Users.full_name -> Users.display_name
B: Users.full_name -> Users.name

The merged declaration selects the final name, but that does not make the original rename SQL commute. The branch using the other name needs an ordinary ordered reconciliation migration. If Git leaves both names, Ship asks rename-or-create questions because two physical columns would now exist; it never assumes one should disappear.

Opaque backfill SQL

Raw SQL may read data and objects that are not visible from its declared output columns. Ship does not parse arbitrary SQL and claim a false commutativity proof.

When divergent branch tails contain backfill(...) or raw_sql(...), automatic unification stops unless those effects are already on their common path. Rebase one unpublished migration onto the other branch state, or author an ordinary reconciliation migration after reviewing both SQL programs.

What is always safe

  • The live database frontier never advances without all dependencies.

  • Missing rename or destructive intent never defaults to drop/create.

  • todo migrations have no executable SQL.

  • Every accepted ordinary migration independently satisfies interpret(Start, operations) == End.

  • A unify checkpoint contains no SQL and collapses its complete parent frontier transactionally.