Skip to content

Commit a639aea

Browse files
committed
Rename plain db CLI to plain postgres
Plain is Postgres-only — the generic `db` name was inherited from Django's multi-backend world. All commands (shell, wait, diagnose, drop-unknown-tables, backups) now live under `plain postgres`. - Rename cli/db.py to cli/core.py - Update plain-dev to call `plain postgres wait` and recognize `postgres` as a service command - Update preflight error message, README, skill files, and future docs
1 parent bd5bfc3 commit a639aea

11 files changed

Lines changed: 172 additions & 31 deletions

File tree

.claude/skills/plain-postgres-diagnose/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ Run health checks against the production Postgres database and act on findings l
1111

1212
The diagnose command should run against the **production database**, not the local dev database. Ask the user how they run commands in production if you don't know. Common patterns:
1313

14-
- **Heroku**: `heroku run -a app-name "plain db diagnose --json"`
15-
- **Direct**: `uv run plain db diagnose --json` (if DATABASE_URL points to production)
14+
- **Heroku**: `heroku run -a app-name "plain postgres diagnose --json"`
15+
- **Direct**: `uv run plain postgres diagnose --json` (if DATABASE_URL points to production)
1616
- **Other platforms**: wrap with the platform's run command
1717

1818
For local/dev analysis, run directly:
1919

2020
```
21-
uv run plain db diagnose --json
21+
uv run plain postgres diagnose --json
2222
```
2323

2424
## 2. Interpret the JSON output

future/postgres-first-data-layer/db-rename-to-postgres.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@ related:
55

66
# Rename `plain db``plain postgres`
77

8-
The framework is Postgres-only. `plain db` is a generic name inherited from Django's multi-backend world. Rename to `plain postgres` — existing commands (`shell`, `wait`, `drop-unknown-tables`, `backups`) move under it.
8+
Done. Changed `register_cli("db")` to `register_cli("postgres")`. All commands (`shell`, `wait`, `drop-unknown-tables`, `backups`, `diagnose`) now live under `plain postgres`.
99

10-
This also creates a natural namespace for Postgres-specific insight commands that wouldn't make sense under a generic `db` prefix.
11-
12-
## Open questions
13-
14-
- `plain postgres` or `plain pg` for brevity? Heroku uses `pg`, but `postgres` is more explicit.
10+
Went with `plain postgres` over `plain pg` — more explicit, matches the package name.

future/postgres-first-data-layer/db-schema-command.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@ related:
33
- migrations-schema-check
44
---
55

6-
# db: Schema inspection command
6+
# Schema inspection command
77

8-
`plain db schema` for quick inspection of actual database state using model names instead of table names.
8+
`plain postgres schema` for quick inspection of actual database state using model names instead of table names.
99

1010
## Commands
1111

12-
### `plain db schema <ModelName>`
12+
### `plain postgres schema <ModelName>`
1313

1414
Show column types, constraints, and indexes for a model's table.
1515

1616
```
17-
$ plain db schema Organization
17+
$ plain postgres schema Organization
1818
1919
organizations_organization (16 columns, 247 rows, 96 kB)
2020
@@ -38,12 +38,12 @@ organizations_organization (16 columns, 247 rows, 96 kB)
3838

3939
Accepts model name (`Organization`), qualified name (`organizations.Organization`), or table name (`organizations_organization`).
4040

41-
### `plain db tables`
41+
### `plain postgres tables`
4242

4343
List all tables with row counts and sizes.
4444

4545
```
46-
$ plain db tables
46+
$ plain postgres tables
4747
4848
Table Rows Size
4949
──────────────────────────────────────────────────
@@ -57,6 +57,6 @@ $ plain db tables
5757
18 tables, 14,528 rows, 5.3 MB total
5858
```
5959

60-
## Why not just `plain db shell`
60+
## Why not just `plain postgres shell`
6161

62-
`plain db shell` already exists and opens psql. This is for when you want a quick look without an interactive session — scriptable, uses model names, and could later show expected-vs-actual if `migrations check-schema` is implemented.
62+
`plain postgres shell` already exists and opens psql. This is for when you want a quick look without an interactive session — scriptable, uses model names, and could later show expected-vs-actual if `migrations check-schema` is implemented.

future/postgres-insights/fk-remove-auto-index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ Remove `db_index` parameter from ForeignKeyField entirely. FK fields create no i
1414
## Why this is safe
1515

1616
1. **Preflight check catches missing coverage** — a code-level preflight warning detects FK columns that aren't the leading column of any declared index or constraint (see `preflight-index-checks`)
17-
2. **`plain db diagnose` catches DB-level gaps** — the missing FK indexes check queries the actual catalog
17+
2. **`plain postgres diagnose` catches DB-level gaps** — the missing FK indexes check queries the actual catalog
1818
3. **Most FK columns are already covered** — by UniqueConstraints, composite indexes, or explicit indexes that users would declare anyway
1919

2020
## Why this is better
2121

22-
- No more redundant indexes from the framework (the `plain db diagnose` command found 8 in Plain's own example app, 16 on a production app)
22+
- No more redundant indexes from the framework (the `plain postgres diagnose` command found 8 in Plain's own example app, 16 on a production app)
2323
- Developers are intentional about which FKs get indexed
2424
- Explicit `Index(fields=["user"])` on a model is clearer than a hidden `db_index=True` default on the field class
2525
- Fits Plain's philosophy: painfully obvious over clever

plain-dev/plain/dev/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def run(self, *, reinstall_ssl: bool = False) -> int:
158158
if find_spec("plain.postgres"):
159159
print_event("Waiting for database...", newline=False)
160160
subprocess.run(
161-
[sys.executable, "-m", "plain", "db", "wait"],
161+
[sys.executable, "-m", "plain", "postgres", "wait"],
162162
env=self.plain_env,
163163
check=True,
164164
)

plain-dev/plain/dev/services.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def auto_start_services() -> None:
3030

3131
# Only auto-start services for commands that need the database/runtime
3232
service_commands = {
33-
"db",
33+
"postgres",
3434
"dev",
3535
"makemigrations",
3636
"migrate",

plain-postgres/plain/postgres/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -921,19 +921,19 @@ graph TB
921921
You can run health checks against your database to find issues like missing indexes, redundant indexes, and configuration problems.
922922

923923
```bash
924-
uv run plain db diagnose
924+
uv run plain postgres diagnose
925925
```
926926

927927
Use `--json` for structured output (useful for scripting and AI agents):
928928

929929
```bash
930-
uv run plain db diagnose --json
930+
uv run plain postgres diagnose --json
931931
```
932932

933933
Use `--all` to include issues in installed packages (by default, only your app's issues are shown):
934934

935935
```bash
936-
uv run plain db diagnose --all
936+
uv run plain postgres diagnose --all
937937
```
938938

939939
### Checks
@@ -963,7 +963,7 @@ Each finding is tagged with its **source**:
963963
Run diagnose against your **production database** to get meaningful stats. On Heroku:
964964

965965
```bash
966-
heroku run -a your-app "plain db diagnose --json"
966+
heroku run -a your-app "plain postgres diagnose --json"
967967
```
968968

969969
The `--json` flag must be quoted so Heroku passes it through to the command.

plain-postgres/plain/postgres/agents/.claude/skills/plain-postgres-diagnose/SKILL.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ Run health checks against the production Postgres database and act on findings l
1111

1212
The diagnose command should run against the **production database**, not the local dev database. Ask the user how they run commands in production if you don't know. Common patterns:
1313

14-
- **Heroku**: `heroku run -a app-name "plain db diagnose --json"`
15-
- **Direct**: `uv run plain db diagnose --json` (if DATABASE_URL points to production)
14+
- **Heroku**: `heroku run -a app-name "plain postgres diagnose --json"`
15+
- **Direct**: `uv run plain postgres diagnose --json` (if DATABASE_URL points to production)
1616
- **Other platforms**: wrap with the platform's run command
1717

1818
For local/dev analysis, run directly:
1919

2020
```
21-
uv run plain db diagnose --json
21+
uv run plain postgres diagnose --json
2222
```
2323

2424
## 2. Interpret the JSON output
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from . import db, migrations
1+
from . import core, migrations
22

3-
__all__ = ["db", "migrations"]
3+
__all__ = ["core", "migrations"]
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from __future__ import annotations
2+
3+
import subprocess
4+
import sys
5+
import time
6+
from collections import defaultdict
7+
8+
import click
9+
import psycopg
10+
11+
from plain.cli import register_cli
12+
13+
from ..backups.cli import cli as backups_cli
14+
from ..db import get_connection
15+
from ..dialect import quote_name
16+
from ..migrations.recorder import MIGRATION_TABLE_NAME
17+
from .diagnose import diagnose
18+
19+
20+
@register_cli("postgres")
21+
@click.group()
22+
def cli() -> None:
23+
"""Postgres operations"""
24+
25+
26+
cli.add_command(backups_cli)
27+
cli.add_command(diagnose)
28+
29+
30+
@cli.command()
31+
@click.argument("parameters", nargs=-1)
32+
def shell(parameters: tuple[str, ...]) -> None:
33+
"""Open an interactive database shell"""
34+
conn = get_connection()
35+
try:
36+
conn.runshell(list(parameters))
37+
except FileNotFoundError:
38+
# Note that we're assuming the FileNotFoundError relates to the
39+
# command missing. It could be raised for some other reason, in
40+
# which case this error message would be inaccurate. Still, this
41+
# message catches the common case.
42+
click.secho(
43+
f"You appear not to have the {conn.executable_name!r} program installed or on your path.",
44+
fg="red",
45+
err=True,
46+
)
47+
sys.exit(1)
48+
except subprocess.CalledProcessError as e:
49+
click.secho(
50+
'"{}" returned non-zero exit status {}.'.format(
51+
" ".join(e.cmd),
52+
e.returncode,
53+
),
54+
fg="red",
55+
err=True,
56+
)
57+
sys.exit(e.returncode)
58+
59+
60+
@cli.command("drop-unknown-tables")
61+
@click.option(
62+
"--yes",
63+
is_flag=True,
64+
help="Skip confirmation prompt (for non-interactive use).",
65+
)
66+
def drop_unknown_tables(yes: bool) -> None:
67+
"""Drop all tables not associated with a Plain model"""
68+
conn = get_connection()
69+
db_tables = set(conn.table_names())
70+
model_tables = set(conn.plain_table_names())
71+
unknown_tables = sorted(db_tables - model_tables - {MIGRATION_TABLE_NAME})
72+
73+
if not unknown_tables:
74+
click.echo("No unknown tables found.")
75+
return
76+
77+
unknown_set = set(unknown_tables)
78+
table_count = len(unknown_tables)
79+
tables_label = f"{table_count} table{'s' if table_count != 1 else ''}"
80+
81+
# Find foreign key constraints from kept tables that reference unknown tables
82+
cascade_warnings: defaultdict[str, list[tuple[str, str]]] = defaultdict(list)
83+
with conn.cursor() as cursor:
84+
for table in unknown_tables:
85+
cursor.execute(
86+
"""
87+
SELECT conname, conrelid::regclass
88+
FROM pg_constraint
89+
WHERE confrelid = %s::regclass AND contype = 'f'
90+
""",
91+
[table],
92+
)
93+
for constraint_name, referencing_table in cursor.fetchall():
94+
if str(referencing_table) not in unknown_set:
95+
cascade_warnings[table].append(
96+
(constraint_name, str(referencing_table))
97+
)
98+
99+
click.secho("Unknown tables:", fg="yellow", bold=True)
100+
for table in unknown_tables:
101+
click.echo(f" - {table}")
102+
for constraint_name, referencing_table in cascade_warnings[table]:
103+
click.secho(
104+
f" ⚠ CASCADE will drop constraint {constraint_name} on {referencing_table}",
105+
fg="red",
106+
)
107+
click.echo()
108+
109+
if not yes:
110+
if not click.confirm(f"Drop {tables_label} (CASCADE)? This cannot be undone."):
111+
return
112+
113+
with conn.cursor() as cursor:
114+
for table in unknown_tables:
115+
click.echo(f" Dropping {table}...", nl=False)
116+
cursor.execute(f"DROP TABLE IF EXISTS {quote_name(table)} CASCADE")
117+
click.echo(" OK")
118+
119+
click.secho(f"✓ Dropped {tables_label}.", fg="green")
120+
121+
122+
@cli.command()
123+
def wait() -> None:
124+
"""Wait for the database to be ready"""
125+
attempts = 0
126+
while True:
127+
attempts += 1
128+
waiting_for = False
129+
130+
try:
131+
get_connection().ensure_connection()
132+
except psycopg.OperationalError:
133+
waiting_for = True
134+
135+
if waiting_for:
136+
if attempts > 1:
137+
# After the first attempt, start printing them
138+
click.secho(
139+
f"Waiting for database (attempt {attempts})",
140+
fg="yellow",
141+
)
142+
time.sleep(1.5)
143+
else:
144+
click.secho("✔ Database ready", fg="green")
145+
break

0 commit comments

Comments
 (0)