v0.154.0
  1"""Keeping a shared database safe from a branch's migrations.
  2
  3Forking is the default, so most checkouts own their database outright and none
  4of this applies. But `plain db use` deliberately points several checkouts at one
  5database, and that's where the sharp edge is: applying a branch-only migration
  6to a shared database mutates the schema that main — and every other sharer —
  7depends on.
  8
  9Drift and corruption are not symmetric. A stale fork is benign; you re-fork it.
 10A shared database with someone else's half-finished migration in it is not
 11something you can undo. So when divergence is about to happen, forking is the
 12only automatic behavior — never applying. Applying a branch's migrations to a
 13shared database is always a deliberate two-command act (`plain db use <name>`
 14then `plain postgres sync`), never something that happens by default.
 15"""
 16
 17from __future__ import annotations
 18
 19from pathlib import Path
 20
 21import click
 22
 23from ..state import checkout_id
 24from .cluster import Cluster
 25from .identity import (
 26    database_name_for_checkout,
 27    project_identity,
 28    resolve_database_name,
 29    write_pointer,
 30)
 31from .resolve import is_managed, open_cluster, write_cached_url
 32from .schema_state import pending_migration_count
 33
 34
 35def guard_shared_database(
 36    project_root: Path,
 37    *,
 38    cluster: Cluster,
 39    db_name: str,
 40) -> str:
 41    """Return the database this checkout should actually use.
 42
 43    A no-op unless the database is shared *and* this branch has migrations it
 44    hasn't applied. When it acts it forks — the choice that can't damage anyone
 45    else's data — which is the right default for people, CI, and agents alike.
 46    """
 47    current = checkout_id(project_root)
 48    metadata = cluster.get_metadata(db_name) or {}
 49    owner = metadata.get("checkout")
 50    # Shared means the metadata names a *different* checkout as the owner.
 51    if not owner or owner == current:
 52        return db_name
 53    if pending_migration_count(cluster.url(db_name)) == 0:
 54        return db_name  # shared, but nothing divergent to apply
 55
 56    project_name, _ = project_identity(project_root)
 57    fork_name = database_name_for_checkout(project_name, checkout=project_root)
 58
 59    click.secho(
 60        f"⚠ Database {db_name!r} is shared (owned by {owner}) and this branch "
 61        f"adds migrations it doesn't have — forking so the shared database "
 62        f"isn't changed for everyone using it.",
 63        fg="yellow",
 64    )
 65
 66    # This checkout may still own the database it had before it was pointed at
 67    # the shared one. Going back to it is better than forking over the top of
 68    # it: no data is destroyed, and `postgres sync` brings its schema current.
 69    if cluster.database_exists(fork_name):
 70        write_pointer(project_root, db_name=fork_name)
 71        click.secho(
 72            f"Went back to {fork_name!r} — this checkout's own database — "
 73            f"leaving {db_name!r} untouched.",
 74            fg="green",
 75        )
 76    else:
 77        mechanism = cluster.fork_database(db_name, fork_name)
 78        cluster.record_created(
 79            fork_name,
 80            checkout=current,
 81            created_via=f"fork:guard:{mechanism}",
 82            project_root=project_root,
 83        )
 84        write_pointer(project_root, db_name=fork_name)
 85        click.secho(
 86            f"Forked {db_name!r}{fork_name!r}; this checkout now uses it.",
 87            fg="green",
 88        )
 89
 90    click.secho(
 91        f"To apply this branch's migrations to {db_name!r} on purpose: "
 92        f"plain db use {db_name} && plain postgres sync",
 93        dim=True,
 94    )
 95    return fork_name
 96
 97
 98def guard_dev_database(project_root: Path) -> str | None:
 99    """Dev-flow entry point. Returns a replacement URL if it forked, else None.
100
101    Only ever acts on a database we manage — a bring-your-own database is never
102    touched, because it has no metadata saying we own it and no sentinel set.
103    """
104    if not is_managed(project_root):
105        return None
106
107    cluster = open_cluster(project_root, create=False)
108    if cluster is None:
109        return None
110
111    db_name = resolve_database_name(project_root)
112    new_name = guard_shared_database(project_root, cluster=cluster, db_name=db_name)
113    if new_name == db_name:
114        return None
115
116    url = cluster.url(new_name)
117    write_cached_url(project_root, url=url)
118    return url