v0.154.0
  1"""`plain db` — manage this project's development databases.
  2
  3A database is a branch of data: something you create, copy, point at, and throw
  4away, with a lifecycle that follows your work rather than your server. These
  5commands are the handle on that. The logic lives in `plain.dev.postgres`.
  6"""
  7
  8from __future__ import annotations
  9
 10import json as json_lib
 11from pathlib import Path
 12
 13import click
 14
 15from plain.cli import register_cli
 16from plain.runtime import APP_PATH
 17
 18from .postgres.backends import (
 19    _inspect_container,
 20    list_managed_containers,
 21    remove_container,
 22    stop_container,
 23)
 24from .postgres.cluster import Cluster, DevDatabase
 25from .postgres.identity import (
 26    InvalidDatabaseName,
 27    PostgresConfig,
 28    clear_pointer,
 29    cluster_name,
 30    project_identity,
 31    read_pointer,
 32    resolve_database_name,
 33    validate_database_name,
 34    volume_name,
 35    write_pointer,
 36)
 37from .postgres.resolve import (
 38    clear_cached_url,
 39    ensure_database,
 40    open_cluster,
 41    write_cached_url,
 42)
 43from .postgres.schema_state import pending_migration_count
 44from .state import checkout_id, find_project_root
 45
 46
 47def _project_root() -> Path:
 48    # Same definition `setup()` uses. The app directory is often a level below
 49    # the project (`example/`, `backend/`), so anchoring on it directly would
 50    # give this CLI a different project — and therefore a different database —
 51    # than the resolution that configured the app.
 52    return find_project_root(Path(APP_PATH).parent)
 53
 54
 55def _valid(name: str) -> str:
 56    """Validate a name a person typed, as a click error rather than a traceback."""
 57    try:
 58        return validate_database_name(name)
 59    except InvalidDatabaseName as e:
 60        raise click.ClickException(str(e)) from e
 61
 62
 63def _open() -> tuple[Path, Cluster, str, str]:
 64    """Return (project_root, cluster, project_name, this checkout's db name)."""
 65    project_root = _project_root()
 66    cluster = open_cluster(project_root)
 67    if cluster is None:
 68        raise click.ClickException(
 69            "No managed Postgres available. Start Docker, run a local Postgres, "
 70            "or set PLAIN_POSTGRES_URL to use your own."
 71        )
 72    project_name, _ = project_identity(project_root)
 73    db_name = resolve_database_name(project_root)
 74
 75    # These commands always talk to the real server, so they're also the place
 76    # a stale cache gets repaired — the TCP probe can tell that *a* server is
 77    # listening, but not that it's still the right one (a project's cluster
 78    # identity moves if its git identity does).
 79    write_cached_url(project_root, url=cluster.url(db_name))
 80
 81    return project_root, cluster, project_name, db_name
 82
 83
 84def _format_size(size_bytes: int) -> str:
 85    return f"{size_bytes / 1024 / 1024:.1f} MB"
 86
 87
 88@register_cli("db")
 89@click.group()
 90def cli() -> None:
 91    """Manage this project's databases — which ones exist, and which one this checkout uses."""
 92
 93
 94@cli.command()
 95@click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
 96def status(as_json: bool) -> None:
 97    """Show this checkout's database."""
 98    # `_open()` errors when Postgres is disabled, so handle the off-case first
 99    # and report it gracefully.
100    config = PostgresConfig.load(_project_root())
101    if config.backend == "off":
102        if as_json:
103            click.echo(json_lib.dumps({"backend": "off"}, indent=2))
104            return
105        click.secho("Backend:   off", bold=True)
106        click.echo("Managed Postgres is disabled for this project.")
107        return
108
109    project_root, cluster, project_name, db_name = _open()
110    source = (
111        "pointer (plain db use)"
112        if read_pointer(project_root)
113        else "derived from directory"
114    )
115    exists = cluster.database_exists(db_name)
116    database = (
117        next(
118            (d for d in cluster.list_databases(project_name) if d.name == db_name), None
119        )
120        if exists
121        else None
122    )
123
124    # The container/image/volume only exist for the docker backend; a local
125    # Postgres has none of them.
126    container = cluster.server.container
127    image = None
128    if container:
129        state = _inspect_container(container)
130        image = state.image if state else None
131    volume = volume_name(project_root) if container else None
132
133    if as_json:
134        click.echo(
135            json_lib.dumps(
136                {
137                    "database": db_name,
138                    "source": source,
139                    "backend": cluster.server.backend,
140                    "port": cluster.server.port,
141                    "url": cluster.url(db_name),
142                    "exists": exists,
143                    "size_bytes": database.size_bytes if database else None,
144                    "branch": database.branch if database else None,
145                    "created_via": database.created_via if database else None,
146                    "pending_migrations": pending_migration_count(cluster.url(db_name))
147                    if exists
148                    else None,
149                    "container": container,
150                    "image": image,
151                    "volume": volume,
152                },
153                indent=2,
154            )
155        )
156        return
157
158    click.secho(f"Database:  {db_name}", bold=True)
159    click.echo(f"Source:    {source}")
160    click.echo(f"Server:    {cluster.server.backend} on port {cluster.server.port}")
161    click.echo(f"URL:       {cluster.url(db_name)}")
162    click.echo(f"Exists:    {'yes' if exists else 'no'}")
163
164    if exists and database:
165        click.echo(f"Size:      {_format_size(database.size_bytes)}")
166        if database.branch:
167            click.echo(f"Branch:    {database.branch}")
168
169    if exists:
170        pending = pending_migration_count(cluster.url(db_name))
171        if pending:
172            click.secho(
173                f"Pending:   {pending} migration(s) not yet applied", fg="yellow"
174            )
175
176    if container:
177        click.echo(f"Container: {container}")
178        if image:
179            click.echo(f"Image:     {image}")
180        click.echo(f"Volume:    {volume}")
181
182
183@cli.command()
184def url() -> None:
185    """Print this checkout's database URL, and nothing else.
186
187    Ensures the server and database exist first, so a script can do
188    `export PLAIN_POSTGRES_URL="$(plain db url)"` and rely on it being usable.
189    """
190    project_root, cluster, _, db_name = _open()
191    ensure_database(cluster, project_root=project_root, db_name=db_name)
192    click.echo(cluster.url(db_name))
193
194
195@cli.command(name="list")
196@click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
197def list_(as_json: bool) -> None:
198    """List this project's databases."""
199    project_root, cluster, project_name, current = _open()
200    databases = cluster.list_databases(project_name)
201
202    if as_json:
203        click.echo(
204            json_lib.dumps(
205                [
206                    {
207                        "name": d.name,
208                        "size_bytes": d.size_bytes,
209                        "checkout": d.checkout,
210                        "checkout_exists": d.checkout_exists,
211                        "branch": d.branch,
212                        "created_via": d.created_via,
213                        "current": d.name == current,
214                        "is_project_main": d.name == project_name,
215                        "is_test": d.is_test,
216                    }
217                    for d in databases
218                ],
219                indent=2,
220            )
221        )
222        return
223
224    if not databases:
225        click.echo("No databases yet.")
226        return
227
228    for database in databases:
229        marker = (
230            click.style("  <- current", fg="green") if database.name == current else ""
231        )
232        detail = database.checkout or ""
233        if database.checkout and not database.checkout_exists:
234            detail = click.style(f"{database.checkout} (gone)", fg="yellow")
235        elif database.is_test:
236            detail = click.style("(test)", dim=True)
237        click.echo(
238            f"  {database.name:<34} {_format_size(database.size_bytes):>10}  {detail}{marker}"
239        )
240
241
242@cli.command()
243@click.argument("name", required=False)
244def create(name: str | None) -> None:
245    """Create an empty database (default: this checkout's name)."""
246    project_root, cluster, _, db_name = _open()
247    name = _valid(name) if name else db_name
248    if cluster.database_exists(name):
249        click.echo(f"{name!r} already exists.")
250        return
251
252    cluster.create_database(name)
253    cluster.record_created(
254        name,
255        checkout=checkout_id(project_root) if name == db_name else None,
256        created_via="create",
257        project_root=project_root,
258    )
259    click.secho(f"✔ Created {name}.", fg="green")
260
261
262@cli.command()
263@click.argument("dest")
264@click.option(
265    "--from", "source", default=None, help="Source database (default: project main)."
266)
267@click.option(
268    "--force", is_flag=True, help="Terminate source connections and use TEMPLATE."
269)
270def fork(dest: str, source: str | None, force: bool) -> None:
271    """Copy a database, data and all."""
272    project_root, cluster, project_name, _ = _open()
273    dest = _valid(dest)
274    source = _valid(source) if source else project_name
275
276    if not cluster.database_exists(source):
277        raise click.ClickException(f"Source database {source!r} does not exist.")
278    if cluster.database_exists(dest):
279        raise click.ClickException(f"Destination database {dest!r} already exists.")
280
281    click.echo(f"Forking {source}{dest} ...")
282    try:
283        mechanism = cluster.fork_database(source, dest, force=force)
284    except Exception as e:
285        # fork_database drops the half-copied dest before re-raising; surface
286        # the reason as a clean CLI error rather than a raw traceback.
287        raise click.ClickException(str(e)) from e
288    cluster.record_created(
289        dest,
290        checkout=None,
291        created_via=f"fork:{mechanism}",
292        project_root=project_root,
293    )
294    click.secho(f"✔ Forked via {mechanism}.", fg="green")
295
296
297@cli.command()
298@click.argument("name", required=False)
299def use(name: str | None) -> None:
300    """Point this checkout at a different database (no name: back to the derived one)."""
301    project_root, cluster, _, _ = _open()
302
303    if name is None:
304        if not read_pointer(project_root):
305            click.echo("This checkout already uses its derived database.")
306            return
307        clear_pointer(project_root)
308        db_name = resolve_database_name(project_root)
309        write_cached_url(project_root, url=cluster.url(db_name))
310        click.secho(f"✔ Back to {db_name!r}.", fg="green")
311        return
312
313    name = _valid(name)
314    write_pointer(project_root, db_name=name)
315    write_cached_url(project_root, url=cluster.url(name))
316
317    click.secho(f"✔ This checkout now uses {name!r}.", fg="green")
318    if not cluster.database_exists(name):
319        click.secho(
320            "  It doesn't exist yet — `plain db create` or `plain db fork` will make it.",
321            dim=True,
322        )
323
324
325@cli.command()
326@click.option("--yes", "-y", is_flag=True)
327def reset(yes: bool) -> None:
328    """Drop and recreate this checkout's database, empty."""
329    project_root, cluster, _, db_name = _open()
330    if not yes and not click.confirm(
331        f"Drop and recreate {db_name!r}? All its data is lost."
332    ):
333        return
334
335    cluster.drop_database(db_name)
336    cluster.create_database(db_name)
337    cluster.record_created(
338        db_name,
339        checkout=checkout_id(project_root),
340        created_via="reset",
341        project_root=project_root,
342    )
343    click.secho(
344        f"✔ Reset {db_name}. Run `plain postgres sync` to build the schema.", fg="green"
345    )
346
347
348@cli.command()
349@click.argument("name")
350@click.option("--yes", "-y", is_flag=True)
351def drop(name: str, yes: bool) -> None:
352    """Drop a database."""
353    project_root, cluster, project_name, db_name = _open()
354    name = _valid(name)
355    if not cluster.database_exists(name):
356        click.echo(f"{name!r} does not exist.")
357        return
358
359    if name == project_name:
360        # Every other checkout forks from this one. Dropping it doesn't just lose
361        # its data — it turns every future worktree into an empty database, which
362        # is the thing this whole feature exists to avoid.
363        click.secho(
364            f"{name!r} is the project's main database — every new checkout is "
365            "forked from it.",
366            fg="yellow",
367        )
368        if not click.confirm("Drop it anyway?", default=False):
369            return
370    elif not yes and not click.confirm(f"Drop {name!r}? This cannot be undone."):
371        return
372
373    cluster.drop_database(name)
374    if name == db_name:
375        # The cache would otherwise keep pointing at a database that's gone.
376        clear_cached_url(project_root)
377    click.secho(f"✔ Dropped {name}.", fg="green")
378
379
380@cli.command()
381@click.option("--yes", "-y", is_flag=True)
382def clean(yes: bool) -> None:
383    """Reclaim database debris — orphaned checkouts and stale test databases.
384
385    Two kinds of debris get reclaimed:
386
387    - Databases whose recorded checkout directory no longer exists. Forking is a
388      full copy, so deleted worktrees leave real disk behind. A database with no
389      recorded owner is left alone.
390    - Test databases with no active connections. A normal test run drops its
391      database on exit, so one that's still here — and that nothing is connected
392      to — is left over from a crashed run. One with live connections is a run
393      in progress and is never touched.
394
395    The project's main database is never a candidate, whatever its metadata
396    says. It's the fork source for every checkout, so a stale owner path on it
397    is a reason to correct the metadata, not to reclaim the disk. A test
398    database can never be the project main — the `test_` prefix rules it out.
399    """
400    project_root, cluster, project_name, current = _open()
401
402    databases = cluster.list_databases(project_name)
403    orphans: list[DevDatabase] = [
404        database
405        for database in databases
406        if database.checkout
407        and not database.checkout_exists
408        and database.name != current
409        and database.name != project_name
410    ]
411    stale_tests: list[DevDatabase] = [
412        database
413        for database in databases
414        if database.is_test
415        and database.name != current
416        and cluster.connection_count(database.name) == 0
417    ]
418    candidates = orphans + stale_tests
419    if not candidates:
420        click.echo("Nothing to clean.")
421        return
422
423    reclaimed = sum(d.size_bytes for d in candidates)
424    click.echo(f"Reclaimable databases ({_format_size(reclaimed)} total):")
425    for database in candidates:
426        detail = "(test database)" if database.is_test else database.checkout
427        click.echo(
428            f"  {database.name:<34} {_format_size(database.size_bytes):>10}  {detail}"
429        )
430
431    if not yes and not click.confirm("Drop these?"):
432        return
433
434    for database in candidates:
435        cluster.drop_database(database.name)
436    click.secho(
437        f"✔ Dropped {len(candidates)}, reclaiming {_format_size(reclaimed)}.",
438        fg="green",
439    )
440
441
442@cli.group()
443def server() -> None:
444    """Manage the Postgres server itself, not the databases on it."""
445
446
447@server.command(name="list")
448def server_list() -> None:
449    """List every plain-dev Postgres container on this machine.
450
451    One per project, created on demand and never removed automatically. This is
452    how you find the ones you've stopped needing.
453    """
454    containers = list_managed_containers()
455    if not containers:
456        click.echo("No managed Postgres containers.")
457        return
458
459    current = cluster_name(_project_root())
460    running = sum(1 for c in containers if c.running)
461
462    for container in containers:
463        state = (
464            click.style("running", fg="green")
465            if container.running
466            else click.style("stopped", dim=True)
467        )
468        marker = (
469            click.style("  <- this project", fg="green")
470            if container.name == current
471            else ""
472        )
473        click.echo(f"  {container.name:<44} {state}  {container.image}{marker}")
474
475    click.echo(f"\n{len(containers)} container(s), {running} running.")
476    if running > 1:
477        click.secho(
478            "Each running server holds memory even when idle — "
479            "`plain db server stop` in a project you're done with.",
480            dim=True,
481        )
482
483
484@server.command(name="stop")
485def server_stop() -> None:
486    """Stop this project's Postgres server.
487
488    Safe: the data volume is untouched, and the next command that needs a
489    database starts it again.
490    """
491    container = cluster_name(_project_root())
492    if stop_container(container):
493        click.secho(f"✔ Stopped {container}.", fg="green")
494    else:
495        click.echo(f"{container} isn't running.")
496
497
498@server.command(name="remove")
499@click.option(
500    "--keep-data", is_flag=True, help="Remove the container but keep the volume."
501)
502@click.option("--yes", "-y", is_flag=True)
503def server_remove(keep_data: bool, yes: bool) -> None:
504    """Remove this project's Postgres server, and its data by default."""
505    project_root = _project_root()
506    container = cluster_name(project_root)
507    volume = None if keep_data else volume_name(project_root)
508
509    warning = (
510        f"Remove {container}?"
511        if keep_data
512        else f"Remove {container} AND every database on it? This cannot be undone."
513    )
514    if not yes and not click.confirm(warning):
515        return
516
517    remove_container(container, volume=volume)
518    click.secho(f"✔ Removed {container}.", fg="green")
519    if keep_data:
520        click.secho(
521            f"  Volume {volume_name(project_root)} kept; the next run reuses it.",
522            dim=True,
523        )