plain.postgres 0.85.0__tar.gz → 0.87.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/PKG-INFO +63 -1
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/CHANGELOG.md +26 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/README.md +62 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +6 -0
- plain_postgres-0.87.0/plain/postgres/agents/.claude/skills/plain-postgres-diagnose/SKILL.md +76 -0
- plain_postgres-0.87.0/plain/postgres/cli/__init__.py +3 -0
- plain_postgres-0.85.0/plain/postgres/cli/db.py → plain_postgres-0.87.0/plain/postgres/cli/core.py +4 -2
- plain_postgres-0.87.0/plain/postgres/cli/diagnose.py +204 -0
- plain_postgres-0.87.0/plain/postgres/diagnose/__init__.py +42 -0
- plain_postgres-0.87.0/plain/postgres/diagnose/checks.py +568 -0
- plain_postgres-0.87.0/plain/postgres/diagnose/context.py +115 -0
- plain_postgres-0.87.0/plain/postgres/diagnose/tables.py +36 -0
- plain_postgres-0.87.0/plain/postgres/diagnose/types.py +26 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/preflight.py +108 -1
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/pyproject.toml +1 -1
- plain_postgres-0.85.0/plain/postgres/cli/__init__.py +0 -3
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/.gitignore +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/CLAUDE.md +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/LICENSE +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/README.md +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/backups/__init__.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/backups/cli.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/backups/clients.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/backups/core.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/connections.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/query.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/test/utils.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/models.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_connection_isolation.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_delete_behaviors.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_encrypted_fields.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_exceptions.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_manager_assignment.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_models.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_read_only_transactions.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_related_descriptors.py +0 -0
- {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_related_manager_api.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plain.postgres
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.87.0
|
|
4
4
|
Summary: Model your data and store it in a database.
|
|
5
5
|
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -23,6 +23,7 @@ Description-Content-Type: text/markdown
|
|
|
23
23
|
- [Constraints](#constraints)
|
|
24
24
|
- [Forms](#forms)
|
|
25
25
|
- [Architecture](#architecture)
|
|
26
|
+
- [Diagnostics](#diagnostics)
|
|
26
27
|
- [Settings](#settings)
|
|
27
28
|
- [FAQs](#faqs)
|
|
28
29
|
- [Installation](#installation)
|
|
@@ -927,6 +928,67 @@ graph TB
|
|
|
927
928
|
- [`SQLCompiler`](./sql/compiler.py#SQLCompiler) - Transforms a Query into executable SQL
|
|
928
929
|
- [`DatabaseConnection`](./postgres/connection.py#DatabaseConnection) - PostgreSQL connection and query execution
|
|
929
930
|
|
|
931
|
+
## Diagnostics
|
|
932
|
+
|
|
933
|
+
You can run health checks against your database to find issues like missing indexes, redundant indexes, and configuration problems.
|
|
934
|
+
|
|
935
|
+
```bash
|
|
936
|
+
uv run plain postgres diagnose
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
Use `--json` for structured output (useful for scripting and AI agents):
|
|
940
|
+
|
|
941
|
+
```bash
|
|
942
|
+
uv run plain postgres diagnose --json
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
Use `--all` to include issues in installed packages (by default, only your app's issues are shown):
|
|
946
|
+
|
|
947
|
+
```bash
|
|
948
|
+
uv run plain postgres diagnose --all
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
### Checks
|
|
952
|
+
|
|
953
|
+
| Check | What it finds | Severity |
|
|
954
|
+
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
|
955
|
+
| **Invalid indexes** | Broken indexes from failed `CREATE INDEX CONCURRENTLY` — maintained on writes, never used for reads | Warning |
|
|
956
|
+
| **Duplicate indexes** | Indexes where one is a column-prefix of another on the same table (e.g., an auto FK index that's redundant with a composite index) | Warning |
|
|
957
|
+
| **Unused indexes** | Indexes with zero scans since stats reset (>1 MB). Excludes unique indexes, constraint-backing indexes, and indexes that are the sole coverage for a FK column | Warning |
|
|
958
|
+
| **Missing FK indexes** | Foreign key columns without any index coverage — parent DELETE/UPDATE operations will sequentially scan the child table | Warning |
|
|
959
|
+
| **Sequence exhaustion** | Identity sequences approaching their type max (>50% warning, >90% critical) | Warning/Critical |
|
|
960
|
+
| **XID wraparound** | Transaction ID age approaching the 2 billion wraparound limit (>25% warning, >40% critical) | Warning/Critical |
|
|
961
|
+
| **Cache hit ratio** | Heap buffer hit ratio below 98.5% — indicates insufficient `shared_buffers` or RAM | Warning |
|
|
962
|
+
| **Index hit ratio** | Index buffer hit ratio below 98.5% | Warning |
|
|
963
|
+
| **Vacuum health** | Tables with significant dead tuple accumulation (>10% of live rows) where autovacuum may be falling behind | Warning |
|
|
964
|
+
|
|
965
|
+
### App vs package issues
|
|
966
|
+
|
|
967
|
+
Each finding is tagged with its **source**:
|
|
968
|
+
|
|
969
|
+
- **App** — your code, fully actionable
|
|
970
|
+
- **Package** — owned by an installed package (e.g., `plain-jobs`). These appear in the footer summary by default; use `--all` to see details
|
|
971
|
+
- **Unmanaged** — tables not managed by any Plain model. The suggestion includes exact SQL to run
|
|
972
|
+
|
|
973
|
+
### Production usage
|
|
974
|
+
|
|
975
|
+
Run diagnose against your **production database** to get meaningful stats. On Heroku:
|
|
976
|
+
|
|
977
|
+
```bash
|
|
978
|
+
heroku run -a your-app "plain postgres diagnose --json"
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
The `--json` flag must be quoted so Heroku passes it through to the command.
|
|
982
|
+
|
|
983
|
+
### Preflight checks
|
|
984
|
+
|
|
985
|
+
Two related checks run automatically during `uv run plain preflight` (and `uv run plain check`):
|
|
986
|
+
|
|
987
|
+
- **`postgres.missing_fk_indexes`** — warns about FK fields without index coverage in your model definitions
|
|
988
|
+
- **`postgres.duplicate_indexes`** — warns about prefix-redundant indexes in your model constraints
|
|
989
|
+
|
|
990
|
+
These are static, code-level checks that catch issues before you deploy. The `diagnose` command complements them with runtime stats from the actual database.
|
|
991
|
+
|
|
930
992
|
## Settings
|
|
931
993
|
|
|
932
994
|
Connection settings are configured via `DATABASE_URL` or individual `POSTGRES_*` settings.
|
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# plain-postgres changelog
|
|
2
2
|
|
|
3
|
+
## [0.87.0](https://github.com/dropseed/plain/releases/plain-postgres@0.87.0) (2026-03-25)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- **Renamed `plain db` CLI to `plain postgres`** — all subcommands (`migrate`, `diagnose`, `wait`, `backups`, etc.) are now under `plain postgres` ([a639aeacbf8d](https://github.com/dropseed/plain/commit/a639aeacbf8d))
|
|
8
|
+
- **Extracted diagnose checks into `plain.postgres.diagnose` package** — the monolithic diagnose module is now split into individual check modules for better maintainability ([91f354108202](https://github.com/dropseed/plain/commit/91f354108202))
|
|
9
|
+
- **FK-aware index checks** — duplicate index detection now recognizes that FK fields auto-create indexes, avoiding false positives when a composite index covers the FK column ([c116f808ac0b](https://github.com/dropseed/plain/commit/c116f808ac0b))
|
|
10
|
+
- Added Diagnostics documentation section to README with check details, thresholds, and production usage guidance ([c116f808ac0b](https://github.com/dropseed/plain/commit/c116f808ac0b))
|
|
11
|
+
- Show slow queries in diagnose human-readable output and fix Heroku command quoting in the diagnose skill ([6feaad54065d](https://github.com/dropseed/plain/commit/6feaad54065d))
|
|
12
|
+
|
|
13
|
+
### Upgrade instructions
|
|
14
|
+
|
|
15
|
+
- Replace `plain db` with `plain postgres` in all scripts, CI configs, and documentation. The old `plain db` command no longer exists.
|
|
16
|
+
|
|
17
|
+
## [0.86.0](https://github.com/dropseed/plain/releases/plain-postgres@0.86.0) (2026-03-24)
|
|
18
|
+
|
|
19
|
+
### What's changed
|
|
20
|
+
|
|
21
|
+
- **New `plain db diagnose` command** — runs health checks against your Postgres database and reports issues as structured JSON. Checks for unused indexes, duplicate indexes, missing foreign key indexes, sequence exhaustion, transaction ID wraparound, vacuum health, and slow queries (via `pg_stat_statements`). Each finding includes table ownership info (app vs package) and actionable suggestions ([91994604b60d](https://github.com/dropseed/plain/commit/91994604b60d))
|
|
22
|
+
- **New preflight checks** for missing foreign key indexes and duplicate indexes — these run automatically during `plain check` and flag issues before they hit production ([3703fe8ab38d](https://github.com/dropseed/plain/commit/3703fe8ab38d))
|
|
23
|
+
- New `plain-postgres-diagnose` AI skill for guided database health check workflow ([91994604b60d](https://github.com/dropseed/plain/commit/91994604b60d))
|
|
24
|
+
|
|
25
|
+
### Upgrade instructions
|
|
26
|
+
|
|
27
|
+
- No changes required.
|
|
28
|
+
|
|
3
29
|
## [0.85.0](https://github.com/dropseed/plain/releases/plain-postgres@0.85.0) (2026-03-22)
|
|
4
30
|
|
|
5
31
|
### What's changed
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
- [Constraints](#constraints)
|
|
12
12
|
- [Forms](#forms)
|
|
13
13
|
- [Architecture](#architecture)
|
|
14
|
+
- [Diagnostics](#diagnostics)
|
|
14
15
|
- [Settings](#settings)
|
|
15
16
|
- [FAQs](#faqs)
|
|
16
17
|
- [Installation](#installation)
|
|
@@ -915,6 +916,67 @@ graph TB
|
|
|
915
916
|
- [`SQLCompiler`](./sql/compiler.py#SQLCompiler) - Transforms a Query into executable SQL
|
|
916
917
|
- [`DatabaseConnection`](./postgres/connection.py#DatabaseConnection) - PostgreSQL connection and query execution
|
|
917
918
|
|
|
919
|
+
## Diagnostics
|
|
920
|
+
|
|
921
|
+
You can run health checks against your database to find issues like missing indexes, redundant indexes, and configuration problems.
|
|
922
|
+
|
|
923
|
+
```bash
|
|
924
|
+
uv run plain postgres diagnose
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
Use `--json` for structured output (useful for scripting and AI agents):
|
|
928
|
+
|
|
929
|
+
```bash
|
|
930
|
+
uv run plain postgres diagnose --json
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
Use `--all` to include issues in installed packages (by default, only your app's issues are shown):
|
|
934
|
+
|
|
935
|
+
```bash
|
|
936
|
+
uv run plain postgres diagnose --all
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
### Checks
|
|
940
|
+
|
|
941
|
+
| Check | What it finds | Severity |
|
|
942
|
+
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
|
943
|
+
| **Invalid indexes** | Broken indexes from failed `CREATE INDEX CONCURRENTLY` — maintained on writes, never used for reads | Warning |
|
|
944
|
+
| **Duplicate indexes** | Indexes where one is a column-prefix of another on the same table (e.g., an auto FK index that's redundant with a composite index) | Warning |
|
|
945
|
+
| **Unused indexes** | Indexes with zero scans since stats reset (>1 MB). Excludes unique indexes, constraint-backing indexes, and indexes that are the sole coverage for a FK column | Warning |
|
|
946
|
+
| **Missing FK indexes** | Foreign key columns without any index coverage — parent DELETE/UPDATE operations will sequentially scan the child table | Warning |
|
|
947
|
+
| **Sequence exhaustion** | Identity sequences approaching their type max (>50% warning, >90% critical) | Warning/Critical |
|
|
948
|
+
| **XID wraparound** | Transaction ID age approaching the 2 billion wraparound limit (>25% warning, >40% critical) | Warning/Critical |
|
|
949
|
+
| **Cache hit ratio** | Heap buffer hit ratio below 98.5% — indicates insufficient `shared_buffers` or RAM | Warning |
|
|
950
|
+
| **Index hit ratio** | Index buffer hit ratio below 98.5% | Warning |
|
|
951
|
+
| **Vacuum health** | Tables with significant dead tuple accumulation (>10% of live rows) where autovacuum may be falling behind | Warning |
|
|
952
|
+
|
|
953
|
+
### App vs package issues
|
|
954
|
+
|
|
955
|
+
Each finding is tagged with its **source**:
|
|
956
|
+
|
|
957
|
+
- **App** — your code, fully actionable
|
|
958
|
+
- **Package** — owned by an installed package (e.g., `plain-jobs`). These appear in the footer summary by default; use `--all` to see details
|
|
959
|
+
- **Unmanaged** — tables not managed by any Plain model. The suggestion includes exact SQL to run
|
|
960
|
+
|
|
961
|
+
### Production usage
|
|
962
|
+
|
|
963
|
+
Run diagnose against your **production database** to get meaningful stats. On Heroku:
|
|
964
|
+
|
|
965
|
+
```bash
|
|
966
|
+
heroku run -a your-app "plain postgres diagnose --json"
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
The `--json` flag must be quoted so Heroku passes it through to the command.
|
|
970
|
+
|
|
971
|
+
### Preflight checks
|
|
972
|
+
|
|
973
|
+
Two related checks run automatically during `uv run plain preflight` (and `uv run plain check`):
|
|
974
|
+
|
|
975
|
+
- **`postgres.missing_fk_indexes`** — warns about FK fields without index coverage in your model definitions
|
|
976
|
+
- **`postgres.duplicate_indexes`** — warns about prefix-redundant indexes in your model constraints
|
|
977
|
+
|
|
978
|
+
These are static, code-level checks that catch issues before you deploy. The `diagnose` command complements them with runtime stats from the actual database.
|
|
979
|
+
|
|
918
980
|
## Settings
|
|
919
981
|
|
|
920
982
|
Connection settings are configured via `DATABASE_URL` or individual `POSTGRES_*` settings.
|
|
@@ -68,6 +68,12 @@ Run `uv run plain docs postgres --section querying` for full patterns with code
|
|
|
68
68
|
|
|
69
69
|
Run `uv run plain docs postgres --section constraints` for full patterns with code examples.
|
|
70
70
|
|
|
71
|
+
## Diagnostics
|
|
72
|
+
|
|
73
|
+
Use the `/plain-postgres-diagnose` skill to run database health checks (unused indexes, missing FK indexes, sequence exhaustion, etc.).
|
|
74
|
+
|
|
75
|
+
Run `uv run plain docs postgres --section diagnostics` for check details, thresholds, and production usage.
|
|
76
|
+
|
|
71
77
|
## Differences from Django
|
|
72
78
|
|
|
73
79
|
- Use `Model.query` not `Model.objects`
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plain-postgres-diagnose
|
|
3
|
+
description: Run Postgres health checks and fix issues. Use when asked to check database health, optimize the database, find unused indexes, or diagnose Postgres problems.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Database Diagnostics
|
|
7
|
+
|
|
8
|
+
Run health checks against the production Postgres database and act on findings locally.
|
|
9
|
+
|
|
10
|
+
## 1. Determine how to run the command
|
|
11
|
+
|
|
12
|
+
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:
|
|
13
|
+
|
|
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)
|
|
16
|
+
- **Other platforms**: wrap with the platform's run command
|
|
17
|
+
|
|
18
|
+
For local/dev analysis, run directly:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
uv run plain postgres diagnose --json
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 2. Interpret the JSON output
|
|
25
|
+
|
|
26
|
+
The output has two sections:
|
|
27
|
+
|
|
28
|
+
**`checks`** — an array of health check results, each with:
|
|
29
|
+
|
|
30
|
+
- `name` — check identifier
|
|
31
|
+
- `status` — `ok`, `warning`, `critical`, `skipped`, or `error`
|
|
32
|
+
- `items` — specific findings, each with:
|
|
33
|
+
- `table`, `name`, `detail` — what was found
|
|
34
|
+
- `source` — `"app"` (user's code), `"package"` (framework/third-party dependency), or `""` (unmanaged)
|
|
35
|
+
- `package` — the package label (e.g., `"jobs"`, `"observer"`) if source is `"package"`
|
|
36
|
+
- `suggestion` — what to do about it
|
|
37
|
+
- `message` — additional context
|
|
38
|
+
|
|
39
|
+
**`context`** — supporting information:
|
|
40
|
+
|
|
41
|
+
- `tables` — all tables with row counts, sizes, index counts, `source`, and `package`
|
|
42
|
+
- `connections` — active vs max connections
|
|
43
|
+
- `stats_reset` — when pg_stat_user_indexes was last reset (affects "unused" interpretation)
|
|
44
|
+
- `pg_stat_statements` — whether query analysis is available
|
|
45
|
+
- `slow_queries` — top 10 queries by total execution time (if available)
|
|
46
|
+
|
|
47
|
+
## 3. Fix issues
|
|
48
|
+
|
|
49
|
+
Get the JSON results from production, then make fixes locally in the codebase.
|
|
50
|
+
|
|
51
|
+
Process checks in priority order: critical first, then warnings.
|
|
52
|
+
|
|
53
|
+
**App items** (`source: "app"`): The user's own code. Fully actionable:
|
|
54
|
+
|
|
55
|
+
- Unused/duplicate indexes → find the index in the model's constraints, remove it, run `uv run plain makemigrations`
|
|
56
|
+
- Missing FK indexes → add an Index to the model, run `uv run plain makemigrations`
|
|
57
|
+
|
|
58
|
+
**Package items** (`source: "package"`): Owned by a framework or third-party package. Present these to the user with context:
|
|
59
|
+
|
|
60
|
+
- **Duplicate indexes** on package tables are likely framework bugs — mention them so the user is aware, and suggest reporting upstream
|
|
61
|
+
- **Unused indexes** on package tables may support features the app hasn't activated yet — these are not necessarily problems. Note them but don't recommend removal
|
|
62
|
+
|
|
63
|
+
**Unmanaged items** (`source: ""`): Tables not owned by any Plain model. Use direct SQL:
|
|
64
|
+
|
|
65
|
+
- Run the `suggestion` SQL directly (the command provides exact DDL)
|
|
66
|
+
|
|
67
|
+
**Operational issues** (sequence exhaustion, XID wraparound, vacuum health):
|
|
68
|
+
|
|
69
|
+
- These require direct database operations regardless of source
|
|
70
|
+
- For sequence exhaustion, alter the column type and create a migration
|
|
71
|
+
- For XID wraparound, investigate autovacuum configuration
|
|
72
|
+
- For vacuum health, check if autovacuum is keeping up
|
|
73
|
+
|
|
74
|
+
## 4. Re-run to verify
|
|
75
|
+
|
|
76
|
+
After deploying changes, run the diagnose command again on production to confirm the issues are resolved.
|
plain_postgres-0.85.0/plain/postgres/cli/db.py → plain_postgres-0.87.0/plain/postgres/cli/core.py
RENAMED
|
@@ -14,15 +14,17 @@ from ..backups.cli import cli as backups_cli
|
|
|
14
14
|
from ..db import get_connection
|
|
15
15
|
from ..dialect import quote_name
|
|
16
16
|
from ..migrations.recorder import MIGRATION_TABLE_NAME
|
|
17
|
+
from .diagnose import diagnose
|
|
17
18
|
|
|
18
19
|
|
|
19
|
-
@register_cli("
|
|
20
|
+
@register_cli("postgres")
|
|
20
21
|
@click.group()
|
|
21
22
|
def cli() -> None:
|
|
22
|
-
"""
|
|
23
|
+
"""Postgres operations"""
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
cli.add_command(backups_cli)
|
|
27
|
+
cli.add_command(diagnose)
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
@cli.command()
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ..db import get_connection
|
|
10
|
+
from ..diagnose import CheckItem, CheckResult, build_table_owners, run_all_checks
|
|
11
|
+
|
|
12
|
+
STATUS_SYMBOLS = {
|
|
13
|
+
"ok": ("✓", "green"),
|
|
14
|
+
"warning": ("!", "yellow"),
|
|
15
|
+
"critical": ("!!", "red"),
|
|
16
|
+
"skipped": ("—", None),
|
|
17
|
+
"error": ("✗", "red"),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_human(
|
|
22
|
+
results: list[CheckResult],
|
|
23
|
+
context: dict[str, Any],
|
|
24
|
+
*,
|
|
25
|
+
show_all: bool = False,
|
|
26
|
+
) -> None:
|
|
27
|
+
# Split items into actionable (app + unmanaged) vs package
|
|
28
|
+
def _actionable_items(r: CheckResult) -> list[CheckItem]:
|
|
29
|
+
return [i for i in r["items"] if i["source"] != "package"]
|
|
30
|
+
|
|
31
|
+
def _package_items(r: CheckResult) -> list[CheckItem]:
|
|
32
|
+
return [i for i in r["items"] if i["source"] == "package"]
|
|
33
|
+
|
|
34
|
+
# Compute effective status (only actionable items trigger warnings unless --all)
|
|
35
|
+
def _effective_status(r: CheckResult) -> str:
|
|
36
|
+
if show_all:
|
|
37
|
+
return r["status"]
|
|
38
|
+
if r["status"] in ("ok", "skipped", "error"):
|
|
39
|
+
return r["status"]
|
|
40
|
+
if r["items"] and not _actionable_items(r):
|
|
41
|
+
return "ok"
|
|
42
|
+
return r["status"]
|
|
43
|
+
|
|
44
|
+
# Summary table
|
|
45
|
+
label_width = max(len(r["label"]) for r in results)
|
|
46
|
+
summaries: list[str] = []
|
|
47
|
+
for r in results:
|
|
48
|
+
if _effective_status(r) == r["status"]:
|
|
49
|
+
summaries.append(r["summary"])
|
|
50
|
+
else:
|
|
51
|
+
summaries.append("ok")
|
|
52
|
+
summary_width = max(len(s) for s in summaries)
|
|
53
|
+
|
|
54
|
+
click.echo()
|
|
55
|
+
for r, summary_text in zip(results, summaries):
|
|
56
|
+
status = _effective_status(r)
|
|
57
|
+
symbol, color = STATUS_SYMBOLS.get(status, ("?", None))
|
|
58
|
+
label = r["label"].ljust(label_width)
|
|
59
|
+
summary = summary_text.ljust(summary_width)
|
|
60
|
+
click.echo(f" {label} {summary} ", nl=False)
|
|
61
|
+
click.secho(symbol, fg=color)
|
|
62
|
+
|
|
63
|
+
# Counts
|
|
64
|
+
statuses = [_effective_status(r) for r in results]
|
|
65
|
+
ok_count = statuses.count("ok")
|
|
66
|
+
warn_count = statuses.count("warning")
|
|
67
|
+
critical_count = statuses.count("critical")
|
|
68
|
+
error_count = statuses.count("error")
|
|
69
|
+
|
|
70
|
+
parts = []
|
|
71
|
+
if ok_count:
|
|
72
|
+
parts.append(f"{ok_count} passed")
|
|
73
|
+
if warn_count:
|
|
74
|
+
parts.append(f"{warn_count} warnings")
|
|
75
|
+
if critical_count:
|
|
76
|
+
parts.append(f"{critical_count} critical")
|
|
77
|
+
if error_count:
|
|
78
|
+
parts.append(f"{error_count} errors")
|
|
79
|
+
click.echo(f"\n {', '.join(parts)}")
|
|
80
|
+
|
|
81
|
+
# Details
|
|
82
|
+
for r in results:
|
|
83
|
+
if _effective_status(r) in ("ok", "skipped"):
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
items_to_show = r["items"] if show_all else _actionable_items(r)
|
|
87
|
+
if items_to_show:
|
|
88
|
+
click.echo()
|
|
89
|
+
click.secho(f" {r['label']}", bold=True)
|
|
90
|
+
for item in items_to_show:
|
|
91
|
+
if item["table"]:
|
|
92
|
+
line = f" {item['name']} on {item['table']} ({item['detail']})"
|
|
93
|
+
else:
|
|
94
|
+
line = f" {item['name']} ({item['detail']})"
|
|
95
|
+
|
|
96
|
+
if item["source"] == "package":
|
|
97
|
+
click.secho(line, dim=True)
|
|
98
|
+
click.secho(
|
|
99
|
+
f" {item['package']} package — not your code",
|
|
100
|
+
dim=True,
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
if item["source"] == "app" and item["package"]:
|
|
104
|
+
click.echo(f"{line} [{item['package']}]")
|
|
105
|
+
else:
|
|
106
|
+
click.echo(line)
|
|
107
|
+
click.secho(f" {item['suggestion']}", dim=True)
|
|
108
|
+
|
|
109
|
+
if r["message"]:
|
|
110
|
+
click.echo()
|
|
111
|
+
click.secho(f" {r['label']}: {r['message']}", bold=True)
|
|
112
|
+
|
|
113
|
+
# Package issues footnote (only when not --all)
|
|
114
|
+
all_package_items: list[tuple[str, CheckItem]] = []
|
|
115
|
+
if not show_all:
|
|
116
|
+
for r in results:
|
|
117
|
+
for item in _package_items(r):
|
|
118
|
+
all_package_items.append((r["label"], item))
|
|
119
|
+
|
|
120
|
+
if all_package_items:
|
|
121
|
+
click.echo()
|
|
122
|
+
# Group by package
|
|
123
|
+
by_package: dict[str, list[tuple[str, CheckItem]]] = {}
|
|
124
|
+
for check_label, item in all_package_items:
|
|
125
|
+
by_package.setdefault(item["package"], []).append((check_label, item))
|
|
126
|
+
|
|
127
|
+
pkg_parts = []
|
|
128
|
+
for pkg, items in sorted(by_package.items()):
|
|
129
|
+
check_names = sorted({label.lower() for label, _ in items})
|
|
130
|
+
pkg_parts.append(f"{pkg} ({len(items)} — {', '.join(check_names)})")
|
|
131
|
+
|
|
132
|
+
click.secho(
|
|
133
|
+
f" Also found {len(all_package_items)} issues in installed packages: {'; '.join(pkg_parts)}",
|
|
134
|
+
dim=True,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Slow queries
|
|
138
|
+
slow_queries = context.get("slow_queries", [])
|
|
139
|
+
if slow_queries:
|
|
140
|
+
click.echo()
|
|
141
|
+
click.secho(" Slowest queries (by total time)", bold=True)
|
|
142
|
+
for q in slow_queries:
|
|
143
|
+
click.echo(
|
|
144
|
+
f" {q['total_time_ms']:>10.0f}ms total"
|
|
145
|
+
f" {q['mean_time_ms']:>8.0f}ms avg"
|
|
146
|
+
f" {q['calls']:>8,} calls"
|
|
147
|
+
f" ({q['pct_total_time']:.1f}%)"
|
|
148
|
+
)
|
|
149
|
+
query_preview = q["query"].replace("\n", " ").strip()
|
|
150
|
+
click.secho(f" {query_preview}", dim=True)
|
|
151
|
+
|
|
152
|
+
# Footer
|
|
153
|
+
click.echo()
|
|
154
|
+
stats_reset = context.get("stats_reset")
|
|
155
|
+
click.secho(
|
|
156
|
+
f" Stats reset: {stats_reset[:10] if stats_reset else 'never'}",
|
|
157
|
+
dim=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
pgss = context.get("pg_stat_statements")
|
|
161
|
+
if pgss == "not_installed":
|
|
162
|
+
click.secho(
|
|
163
|
+
" pg_stat_statements: not installed (install for query analysis)",
|
|
164
|
+
dim=True,
|
|
165
|
+
)
|
|
166
|
+
elif pgss == "no_permission":
|
|
167
|
+
click.secho(
|
|
168
|
+
" pg_stat_statements: installed but not accessible (insufficient privileges)",
|
|
169
|
+
dim=True,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
conn = context.get("connections", {})
|
|
173
|
+
if conn:
|
|
174
|
+
pct = round(100 * conn["active"] / conn["max"]) if conn["max"] else 0
|
|
175
|
+
click.secho(f" Connections: {conn['active']}/{conn['max']} ({pct}%)", dim=True)
|
|
176
|
+
|
|
177
|
+
click.echo()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@click.command()
|
|
181
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
182
|
+
@click.option(
|
|
183
|
+
"--all", "show_all", is_flag=True, help="Include package issues in detail"
|
|
184
|
+
)
|
|
185
|
+
def diagnose(output_json: bool, show_all: bool) -> None:
|
|
186
|
+
"""Run health checks against the database"""
|
|
187
|
+
conn = get_connection()
|
|
188
|
+
table_owners = build_table_owners()
|
|
189
|
+
|
|
190
|
+
with conn.cursor() as cursor:
|
|
191
|
+
results, context = run_all_checks(cursor, table_owners)
|
|
192
|
+
|
|
193
|
+
if output_json:
|
|
194
|
+
output = {
|
|
195
|
+
"checks": results,
|
|
196
|
+
"context": context,
|
|
197
|
+
}
|
|
198
|
+
click.echo(json.dumps(output, indent=2, default=str))
|
|
199
|
+
else:
|
|
200
|
+
format_human(results, context, show_all=show_all)
|
|
201
|
+
|
|
202
|
+
# Exit 1 if any critical
|
|
203
|
+
if any(r["status"] == "critical" for r in results):
|
|
204
|
+
sys.exit(1)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .checks import ALL_CHECKS
|
|
6
|
+
from .context import gather_context
|
|
7
|
+
from .tables import build_table_owners
|
|
8
|
+
from .types import CheckItem, CheckResult, TableOwner
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ALL_CHECKS",
|
|
12
|
+
"CheckItem",
|
|
13
|
+
"CheckResult",
|
|
14
|
+
"TableOwner",
|
|
15
|
+
"build_table_owners",
|
|
16
|
+
"gather_context",
|
|
17
|
+
"run_all_checks",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_all_checks(
|
|
22
|
+
cursor: Any, table_owners: dict[str, TableOwner]
|
|
23
|
+
) -> tuple[list[CheckResult], dict[str, Any]]:
|
|
24
|
+
results: list[CheckResult] = []
|
|
25
|
+
for check_fn in ALL_CHECKS:
|
|
26
|
+
try:
|
|
27
|
+
result = check_fn(cursor, table_owners)
|
|
28
|
+
except Exception as e:
|
|
29
|
+
result = CheckResult(
|
|
30
|
+
name=check_fn.__name__.removeprefix("check_"),
|
|
31
|
+
label=check_fn.__name__.removeprefix("check_")
|
|
32
|
+
.replace("_", " ")
|
|
33
|
+
.title(),
|
|
34
|
+
status="error",
|
|
35
|
+
summary="error",
|
|
36
|
+
items=[],
|
|
37
|
+
message=str(e),
|
|
38
|
+
)
|
|
39
|
+
results.append(result)
|
|
40
|
+
|
|
41
|
+
context = gather_context(cursor, table_owners)
|
|
42
|
+
return results, context
|