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