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.
Files changed (124) hide show
  1. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/PKG-INFO +63 -1
  2. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/CHANGELOG.md +26 -0
  3. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/README.md +62 -0
  4. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +6 -0
  5. plain_postgres-0.87.0/plain/postgres/agents/.claude/skills/plain-postgres-diagnose/SKILL.md +76 -0
  6. plain_postgres-0.87.0/plain/postgres/cli/__init__.py +3 -0
  7. plain_postgres-0.85.0/plain/postgres/cli/db.py → plain_postgres-0.87.0/plain/postgres/cli/core.py +4 -2
  8. plain_postgres-0.87.0/plain/postgres/cli/diagnose.py +204 -0
  9. plain_postgres-0.87.0/plain/postgres/diagnose/__init__.py +42 -0
  10. plain_postgres-0.87.0/plain/postgres/diagnose/checks.py +568 -0
  11. plain_postgres-0.87.0/plain/postgres/diagnose/context.py +115 -0
  12. plain_postgres-0.87.0/plain/postgres/diagnose/tables.py +36 -0
  13. plain_postgres-0.87.0/plain/postgres/diagnose/types.py +26 -0
  14. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/preflight.py +108 -1
  15. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/pyproject.toml +1 -1
  16. plain_postgres-0.85.0/plain/postgres/cli/__init__.py +0 -3
  17. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/.gitignore +0 -0
  18. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/CLAUDE.md +0 -0
  19. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/LICENSE +0 -0
  20. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/README.md +0 -0
  21. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/__init__.py +0 -0
  22. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/aggregates.py +0 -0
  23. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/backups/__init__.py +0 -0
  24. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/backups/cli.py +0 -0
  25. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/backups/clients.py +0 -0
  26. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/backups/core.py +0 -0
  27. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/base.py +0 -0
  28. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/cli/migrations.py +0 -0
  29. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/config.py +0 -0
  30. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/connection.py +0 -0
  31. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/connections.py +0 -0
  32. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/constants.py +0 -0
  33. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/constraints.py +0 -0
  34. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/database_url.py +0 -0
  35. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/db.py +0 -0
  36. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/default_settings.py +0 -0
  37. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/deletion.py +0 -0
  38. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/dialect.py +0 -0
  39. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/entrypoints.py +0 -0
  40. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/enums.py +0 -0
  41. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/exceptions.py +0 -0
  42. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/expressions.py +0 -0
  43. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/__init__.py +0 -0
  44. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/encrypted.py +0 -0
  45. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/json.py +0 -0
  46. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/mixins.py +0 -0
  47. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/related.py +0 -0
  48. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/related_descriptors.py +0 -0
  49. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/related_lookups.py +0 -0
  50. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/related_managers.py +0 -0
  51. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  52. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/reverse_related.py +0 -0
  53. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/fields/timezones.py +0 -0
  54. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/forms.py +0 -0
  55. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/__init__.py +0 -0
  56. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/comparison.py +0 -0
  57. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/datetime.py +0 -0
  58. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/math.py +0 -0
  59. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/mixins.py +0 -0
  60. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/text.py +0 -0
  61. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/functions/window.py +0 -0
  62. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/indexes.py +0 -0
  63. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/lookups.py +0 -0
  64. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/meta.py +0 -0
  65. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/__init__.py +0 -0
  66. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/autodetector.py +0 -0
  67. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/exceptions.py +0 -0
  68. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/executor.py +0 -0
  69. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/graph.py +0 -0
  70. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/loader.py +0 -0
  71. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/migration.py +0 -0
  72. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  73. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/base.py +0 -0
  74. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/fields.py +0 -0
  75. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/models.py +0 -0
  76. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/operations/special.py +0 -0
  77. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/optimizer.py +0 -0
  78. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/questioner.py +0 -0
  79. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/recorder.py +0 -0
  80. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/serializer.py +0 -0
  81. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/state.py +0 -0
  82. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/utils.py +0 -0
  83. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/migrations/writer.py +0 -0
  84. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/options.py +0 -0
  85. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/otel.py +0 -0
  86. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/query.py +0 -0
  87. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/query_utils.py +0 -0
  88. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/registry.py +0 -0
  89. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/schema.py +0 -0
  90. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/__init__.py +0 -0
  91. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/compiler.py +0 -0
  92. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/constants.py +0 -0
  93. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/datastructures.py +0 -0
  94. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/query.py +0 -0
  95. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/sql/where.py +0 -0
  96. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/test/__init__.py +0 -0
  97. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/test/pytest.py +0 -0
  98. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/test/utils.py +0 -0
  99. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/transaction.py +0 -0
  100. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/types.py +0 -0
  101. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/types.pyi +0 -0
  102. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/plain/postgres/utils.py +0 -0
  103. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  104. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  105. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  106. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  107. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  108. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  109. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/migrations/__init__.py +0 -0
  110. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/examples/models.py +0 -0
  111. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/settings.py +0 -0
  112. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/app/urls.py +0 -0
  113. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_connection_isolation.py +0 -0
  114. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_connection_lifecycle.py +0 -0
  115. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_database_url.py +0 -0
  116. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_delete_behaviors.py +0 -0
  117. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_encrypted_fields.py +0 -0
  118. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_exceptions.py +0 -0
  119. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_iterator.py +0 -0
  120. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_manager_assignment.py +0 -0
  121. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_models.py +0 -0
  122. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_read_only_transactions.py +0 -0
  123. {plain_postgres-0.85.0 → plain_postgres-0.87.0}/tests/test_related_descriptors.py +0 -0
  124. {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.85.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.
@@ -0,0 +1,3 @@
1
+ from . import core, migrations
2
+
3
+ __all__ = ["core", "migrations"]
@@ -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("db")
20
+ @register_cli("postgres")
20
21
  @click.group()
21
22
  def cli() -> None:
22
- """Database operations"""
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