Ship

Book · Parallel changes to different tables

Parallel changes to different tables

Draft

Apply one merged PR after another branch migration is already live.

On this page · 4 sections

This is the ordinary agent-first case:

  1. Branch A adds Users.timezone.

  2. Branch B adds Catalog.search_text.

  3. Both started from snapshot S.

  4. A merges and its migration runs while B is still in progress.

  5. B later incorporates A and merges.

Before either PR

repository target: S
production frontier: {S}

S
├── Users(id, email)
└── Catalog(id, title)

Each branch runs ship db diff against the same Start snapshot:

                   c73d54e1 (A: add Users.timezone)
           /
56d441f7 -+
           \
                   483d20eb (B: add Catalog.search_text)

A and B are complete Start -> End edges. Neither timestamp says that one runs after the other.

A merges and runs

Main contains S -> A, and production follows that path:

repository target: A
production frontier: {A}

S --add Users.timezone--> A

The migration and frontier update commit together. B's repository still contains S -> B; nothing rewrites or applies it while B is unmerged.

B incorporates current main

After Git combines A's migration directory, B's migration directory, and the declaration containing both columns, the checkout contains:

            A
           /
S --------+
           \
            B

desired declaration: M = Users.timezone + Catalog.search_text
production frontier: {A}

The two leaves are already deployable. ship db status reports a compatible two-head declaration, and ship db apply can apply whichever parent migration is missing. Until a checkpoint exists the live state is the frontier {A, B}, rather than a fictional single schema marker.

Before authoring the next migration, ship db diff gives that frontier one canonical checkpoint:

ship db diff AppDb --name converge src/Schema.shp

Ship first proves that each branch's typed operations remain valid after the other branch. It then writes one multi-parent checkpoint:

            A
           / \
S --------+   +-- U
           \ /
            B

The checked-in source records ancestry and the canonical combined snapshot:

unify AppDb(
  parents = [
    snapshot("483d20ebf564f6f9"),
    snapshot("c73d54e1a6340ac7"),
  ],
  to = snapshot("adee3c9370044435"),
) {}

A unify checkpoint has no SQL file and no operations. It says that all parent histories are prerequisites and that their compatible union is U:

snapshots/adee3c9370044435.json
shp/20260915072300_converge.shp
no convergence SQL file

What production runs

Production's ledger already contains A. To reach U, ship db apply walks U's ancestors, skips A, applies B's original checked-in migration, and then records the unify checkpoint:

ALTER TABLE catalog ADD COLUMN search_text TEXT;

The database frontier changes from {A} to {A, B} when B commits, then from {A, B} to {U} when the checkpoint commits:

repository target: U
production frontier: {U}

Applied migration nodes: S, A, B, U

The same target works from every live position:

  • at S, apply A, B, then U;

  • at A, apply B, then U;

  • at B, apply A, then U;

  • at {A, B}, record only U.

    Ordinary migrations keep one from snapshot. Only unify has parents, and every parent is a prerequisite rather than an alternative starting state. The full applied ledger lets Ship identify exactly which ancestors are still missing.