plain.postgres 0.86.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.86.0 → plain_postgres-0.87.0}/PKG-INFO +63 -1
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/CHANGELOG.md +14 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/README.md +62 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +2 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/agents/.claude/skills/plain-postgres-diagnose/SKILL.md +3 -3
- plain_postgres-0.87.0/plain/postgres/cli/__init__.py +3 -0
- plain_postgres-0.86.0/plain/postgres/cli/db.py → plain_postgres-0.87.0/plain/postgres/cli/core.py +2 -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.86.0/plain/postgres/cli/diagnose.py → plain_postgres-0.87.0/plain/postgres/diagnose/checks.py +67 -395
- 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.86.0 → plain_postgres-0.87.0}/plain/postgres/preflight.py +1 -1
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/pyproject.toml +1 -1
- plain_postgres-0.86.0/plain/postgres/cli/__init__.py +0 -3
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/.gitignore +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/CLAUDE.md +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/LICENSE +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/README.md +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/__init__.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/aggregates.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/backups/__init__.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/backups/cli.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/backups/clients.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/backups/core.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/base.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/cli/migrations.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/config.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/connection.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/connections.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/constants.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/constraints.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/database_url.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/db.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/default_settings.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/deletion.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/dialect.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/entrypoints.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/enums.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/exceptions.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/expressions.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/fields/__init__.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/fields/encrypted.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/fields/json.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/fields/mixins.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/fields/related.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/fields/related_descriptors.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/fields/related_lookups.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/fields/related_managers.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/fields/reverse_related.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/fields/timezones.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/forms.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/functions/__init__.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/functions/comparison.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/functions/datetime.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/functions/math.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/functions/mixins.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/functions/text.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/functions/window.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/indexes.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/lookups.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/meta.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/__init__.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/autodetector.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/exceptions.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/executor.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/graph.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/loader.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/migration.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/__init__.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/base.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/fields.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/models.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/special.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/optimizer.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/questioner.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/recorder.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/serializer.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/state.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/utils.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/migrations/writer.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/options.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/otel.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/query.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/query_utils.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/registry.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/schema.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/sql/__init__.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/sql/compiler.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/sql/constants.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/sql/datastructures.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/sql/query.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/sql/where.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/test/__init__.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/test/pytest.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/test/utils.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/transaction.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/types.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/types.pyi +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/plain/postgres/utils.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0001_initial.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/__init__.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/app/examples/models.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/app/settings.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/app/urls.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/test_connection_isolation.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/test_connection_lifecycle.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/test_database_url.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/test_delete_behaviors.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/test_encrypted_fields.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/test_exceptions.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/test_iterator.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/test_manager_assignment.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/test_models.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/test_read_only_transactions.py +0 -0
- {plain_postgres-0.86.0 → plain_postgres-0.87.0}/tests/test_related_descriptors.py +0 -0
- {plain_postgres-0.86.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,19 @@
|
|
|
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
|
+
|
|
3
17
|
## [0.86.0](https://github.com/dropseed/plain/releases/plain-postgres@0.86.0) (2026-03-24)
|
|
4
18
|
|
|
5
19
|
### 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.
|
|
@@ -72,6 +72,8 @@ Run `uv run plain docs postgres --section constraints` for full patterns with co
|
|
|
72
72
|
|
|
73
73
|
Use the `/plain-postgres-diagnose` skill to run database health checks (unused indexes, missing FK indexes, sequence exhaustion, etc.).
|
|
74
74
|
|
|
75
|
+
Run `uv run plain docs postgres --section diagnostics` for check details, thresholds, and production usage.
|
|
76
|
+
|
|
75
77
|
## Differences from Django
|
|
76
78
|
|
|
77
79
|
- Use `Model.query` not `Model.objects`
|
|
@@ -11,14 +11,14 @@ Run health checks against the production Postgres database and act on findings l
|
|
|
11
11
|
|
|
12
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
13
|
|
|
14
|
-
- **Heroku**: `heroku run
|
|
15
|
-
- **Direct**: `uv run plain
|
|
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
16
|
- **Other platforms**: wrap with the platform's run command
|
|
17
17
|
|
|
18
18
|
For local/dev analysis, run directly:
|
|
19
19
|
|
|
20
20
|
```
|
|
21
|
-
uv run plain
|
|
21
|
+
uv run plain postgres diagnose --json
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## 2. Interpret the JSON output
|
plain_postgres-0.86.0/plain/postgres/cli/db.py → plain_postgres-0.87.0/plain/postgres/cli/core.py
RENAMED
|
@@ -17,10 +17,10 @@ from ..migrations.recorder import MIGRATION_TABLE_NAME
|
|
|
17
17
|
from .diagnose import diagnose
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
@register_cli("
|
|
20
|
+
@register_cli("postgres")
|
|
21
21
|
@click.group()
|
|
22
22
|
def cli() -> None:
|
|
23
|
-
"""
|
|
23
|
+
"""Postgres operations"""
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
cli.add_command(backups_cli)
|
|
@@ -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
|