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 )