plain.postgres 0.102.0__tar.gz → 0.103.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 (208) hide show
  1. plain_postgres-0.102.0/plain/postgres/README.md → plain_postgres-0.103.0/PKG-INFO +45 -11
  2. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/CHANGELOG.md +12 -0
  3. plain_postgres-0.102.0/PKG-INFO → plain_postgres-0.103.0/plain/postgres/README.md +32 -24
  4. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -1
  5. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/convergence/__init__.py +6 -0
  6. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/convergence/analysis.py +100 -2
  7. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/convergence/fixes.py +42 -0
  8. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/convergence/planning.py +13 -0
  9. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/introspection/health/checks_cumulative.py +205 -9
  10. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/introspection/health/runner.py +19 -0
  11. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/introspection/schema.py +55 -0
  12. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/options.py +11 -4
  13. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/pyproject.toml +1 -1
  14. plain_postgres-0.103.0/tests/app/examples/migrations/0018_storageparametersexample.py +18 -0
  15. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/__init__.py +1 -0
  16. plain_postgres-0.103.0/tests/app/examples/models/storage_parameters.py +14 -0
  17. plain_postgres-0.103.0/tests/internal/test_convergence_storage_parameters.py +222 -0
  18. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_diagnose.py +1 -0
  19. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/.gitignore +0 -0
  20. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/CLAUDE.md +0 -0
  21. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/LICENSE +0 -0
  22. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/README.md +0 -0
  23. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/__init__.py +0 -0
  24. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/adapters.py +0 -0
  25. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  26. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/aggregates.py +0 -0
  27. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/base.py +0 -0
  28. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/cli/__init__.py +0 -0
  29. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/cli/converge.py +0 -0
  30. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/cli/core.py +0 -0
  31. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/cli/decorators.py +0 -0
  32. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/cli/diagnose.py +0 -0
  33. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/cli/migrations.py +0 -0
  34. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/cli/schema.py +0 -0
  35. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/cli/sync.py +0 -0
  36. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/config.py +0 -0
  37. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/connection.py +0 -0
  38. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/constants.py +0 -0
  39. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/constraints.py +0 -0
  40. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/database_url.py +0 -0
  41. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/db.py +0 -0
  42. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/ddl.py +0 -0
  43. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/default_settings.py +0 -0
  44. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/deletion.py +0 -0
  45. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/dialect.py +0 -0
  46. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/entrypoints.py +0 -0
  47. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/enums.py +0 -0
  48. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/exceptions.py +0 -0
  49. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/expressions.py +0 -0
  50. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/__init__.py +0 -0
  51. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/base.py +0 -0
  52. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/binary.py +0 -0
  53. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/boolean.py +0 -0
  54. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/duration.py +0 -0
  55. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/encrypted.py +0 -0
  56. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/json.py +0 -0
  57. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/mixins.py +0 -0
  58. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/network.py +0 -0
  59. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/numeric.py +0 -0
  60. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/primary_key.py +0 -0
  61. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/related.py +0 -0
  62. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/related_descriptors.py +0 -0
  63. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/related_lookups.py +0 -0
  64. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/related_managers.py +0 -0
  65. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  66. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/reverse_related.py +0 -0
  67. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/temporal.py +0 -0
  68. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/text.py +0 -0
  69. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/timezones.py +0 -0
  70. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/fields/uuid.py +0 -0
  71. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/forms.py +0 -0
  72. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/functions/__init__.py +0 -0
  73. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/functions/comparison.py +0 -0
  74. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/functions/datetime.py +0 -0
  75. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/functions/math.py +0 -0
  76. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/functions/mixins.py +0 -0
  77. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/functions/random.py +0 -0
  78. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/functions/text.py +0 -0
  79. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/functions/uuid.py +0 -0
  80. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/functions/window.py +0 -0
  81. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/indexes.py +0 -0
  82. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/introspection/__init__.py +0 -0
  83. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/introspection/health/__init__.py +0 -0
  84. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  85. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  86. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/introspection/health/context.py +0 -0
  87. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/introspection/health/helpers.py +0 -0
  88. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/introspection/health/ownership.py +0 -0
  89. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/introspection/health/types.py +0 -0
  90. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/lookups.py +0 -0
  91. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/meta.py +0 -0
  92. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/middleware.py +0 -0
  93. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/__init__.py +0 -0
  94. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/autodetector.py +0 -0
  95. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/exceptions.py +0 -0
  96. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/executor.py +0 -0
  97. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/graph.py +0 -0
  98. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/loader.py +0 -0
  99. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/migration.py +0 -0
  100. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  101. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/operations/base.py +0 -0
  102. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/operations/fields.py +0 -0
  103. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/operations/models.py +0 -0
  104. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/operations/special.py +0 -0
  105. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/optimizer.py +0 -0
  106. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/questioner.py +0 -0
  107. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/recorder.py +0 -0
  108. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/serializer.py +0 -0
  109. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/state.py +0 -0
  110. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/utils.py +0 -0
  111. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/migrations/writer.py +0 -0
  112. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/otel.py +0 -0
  113. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/preflight.py +0 -0
  114. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/query.py +0 -0
  115. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/query_utils.py +0 -0
  116. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/registry.py +0 -0
  117. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/schema.py +0 -0
  118. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/sources.py +0 -0
  119. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/sql/__init__.py +0 -0
  120. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/sql/compiler.py +0 -0
  121. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/sql/constants.py +0 -0
  122. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/sql/datastructures.py +0 -0
  123. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/sql/query.py +0 -0
  124. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/sql/where.py +0 -0
  125. {plain_postgres-0.102.0/tests/app/examples/migrations → plain_postgres-0.103.0/plain/postgres/test}/__init__.py +0 -0
  126. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/test/database.py +0 -0
  127. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/test/pytest.py +0 -0
  128. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/transaction.py +0 -0
  129. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/types.py +0 -0
  130. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/types.pyi +0 -0
  131. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/plain/postgres/utils.py +0 -0
  132. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/forms.py +0 -0
  133. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  134. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  135. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  136. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  137. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  138. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  139. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  140. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  141. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  142. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  143. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  144. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  145. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  146. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  147. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  148. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  149. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  150. {plain_postgres-0.102.0/plain/postgres/test → plain_postgres-0.103.0/tests/app/examples/migrations}/__init__.py +0 -0
  151. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/constraints.py +0 -0
  152. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/defaults.py +0 -0
  153. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/delete.py +0 -0
  154. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/encrypted.py +0 -0
  155. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/forms.py +0 -0
  156. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/indexes.py +0 -0
  157. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/iteration.py +0 -0
  158. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/mixins.py +0 -0
  159. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/nullability.py +0 -0
  160. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/querysets.py +0 -0
  161. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/relationships.py +0 -0
  162. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/trees.py +0 -0
  163. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/models/unregistered.py +0 -0
  164. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/urls.py +0 -0
  165. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/examples/views.py +0 -0
  166. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/settings.py +0 -0
  167. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/app/urls.py +0 -0
  168. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/conftest.py +0 -0
  169. {plain_postgres-0.102.0 → plain_postgres-0.103.0}/tests/conftest_convergence.py +0 -0
  170. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_autodetector_not_null_errors.py +0 -0
  171. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_autodetector_type_change.py +0 -0
  172. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_connection_isolation.py +0 -0
  173. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_connection_lifecycle.py +0 -0
  174. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_connection_pool.py +0 -0
  175. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_constraint_violation_error.py +0 -0
  176. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_convergence.py +0 -0
  177. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_convergence_constraints.py +0 -0
  178. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_convergence_defaults.py +0 -0
  179. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_convergence_fk.py +0 -0
  180. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_convergence_indexes.py +0 -0
  181. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_convergence_nullability.py +0 -0
  182. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_convergence_timeouts.py +0 -0
  183. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_db_expression_defaults.py +0 -0
  184. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_executor_connection_hook.py +0 -0
  185. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_health.py +0 -0
  186. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_introspection.py +0 -0
  187. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_literal_default_persistence.py +0 -0
  188. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_management_connection.py +0 -0
  189. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_migration_executor.py +0 -0
  190. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_no_callable_defaults.py +0 -0
  191. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_otel_metrics.py +0 -0
  192. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_schema_normalize_type.py +0 -0
  193. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/internal}/test_schema_timeouts.py +0 -0
  194. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_database_url.py +0 -0
  195. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_delete_behaviors.py +0 -0
  196. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_encrypted_fields.py +0 -0
  197. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_exceptions.py +0 -0
  198. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_field_defaults.py +0 -0
  199. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_functions_uuid.py +0 -0
  200. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_iterator.py +0 -0
  201. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_m2m.py +0 -0
  202. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_manager_assignment.py +0 -0
  203. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_mixins.py +0 -0
  204. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_modelform_roundtrip.py +0 -0
  205. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_random_string_field.py +0 -0
  206. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_raw_query.py +0 -0
  207. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_read_only_transactions.py +0 -0
  208. {plain_postgres-0.102.0/tests → plain_postgres-0.103.0/tests/public}/test_related.py +0 -0
@@ -1,3 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.postgres
3
+ Version: 0.103.0
4
+ Summary: Model your data and store it in a database.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: plain<1.0.0,>=0.134.0
10
+ Requires-Dist: psycopg-pool>=3.2
11
+ Requires-Dist: psycopg>=3.2
12
+ Description-Content-Type: text/markdown
13
+
1
14
  # plain.postgres
2
15
 
3
16
  **Model your data and store it in a database.**
@@ -687,7 +700,7 @@ Or use `RunSQL` with explicit cascade if the relationship is large.
687
700
 
688
701
  ### Convergence
689
702
 
690
- Convergence compares the indexes, constraints, foreign keys, and nullability declared on your models against what actually exists in the database, then applies fixes to make them match. You don't need to create migrations for these — just declare them on your model and run `postgres sync`.
703
+ Convergence compares the indexes, constraints, foreign keys, nullability, and [storage parameters](#storage-parameters) declared on your models against what actually exists in the database, then applies fixes to make them match. You don't need to create migrations for these — just declare them on your model and run `postgres sync`.
691
704
 
692
705
  ```python
693
706
  @postgres.register_model
@@ -1078,6 +1091,26 @@ A `code` becomes `ValidationError.code` — useful for test assertions, error tr
1078
1091
 
1079
1092
  `UniqueConstraint` accepts the same `violation_error`. With a single-field unique constraint, a string `violation_error="That email is taken."` auto-routes to that field; otherwise (multi-field, expressions, or a CheckConstraint) errors land on `NON_FIELD_ERRORS` unless you pass the dict form. See [BaseConstraint](./constraints.py#BaseConstraint) for the full signature.
1080
1093
 
1094
+ ### Storage parameters
1095
+
1096
+ Per-table Postgres [storage parameters](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-STORAGE-PARAMETERS) (`pg_class.reloptions`) are declared on `model_options` and managed by [convergence](#convergence) — `postgres sync` issues the `ALTER TABLE … SET/RESET (...)` to make the live table match. They're not serialized into migrations.
1097
+
1098
+ ```python
1099
+ class CachedItem(postgres.Model):
1100
+ ...
1101
+
1102
+ model_options = postgres.Options(
1103
+ storage_parameters={
1104
+ # Tighter autovacuum on a churn-heavy table
1105
+ "autovacuum_vacuum_scale_factor": 0.1,
1106
+ # TOAST has its own autovacuum schedule — prefix with `toast.`
1107
+ "toast.autovacuum_vacuum_scale_factor": 0.05,
1108
+ },
1109
+ )
1110
+ ```
1111
+
1112
+ Models are the source of truth: undeclared parameters set on the live table are reset on the next sync. Use this for autovacuum tuning, `fillfactor`, TOAST options, etc. — anything you'd otherwise apply by hand with `ALTER TABLE … SET (...)`.
1113
+
1081
1114
  ### Schema design
1082
1115
 
1083
1116
  #### Index fields used in filters and ordering
@@ -1277,16 +1310,17 @@ fix in their model code. (In JSON output each finding still carries
1277
1310
  it.) Each finding still carries the exact SQL in its suggestion for anyone
1278
1311
  who wants to act.
1279
1312
 
1280
- | Finding | What it reports |
1281
- | ------------------- | -------------------------------------------------------------------------------- |
1282
- | **Stats freshness** | Tables whose planner statistics are missing (never analyzed) or stale |
1283
- | **Vacuum health** | Tables with >10% dead tuples |
1284
- | **Index bloat** | btree indexes with significant estimated wasted space (≥10 MB, ioguix estimator) |
1313
+ | Finding | What it reports |
1314
+ | ------------------- | ------------------------------------------------------------------------------------------ |
1315
+ | **Stats freshness** | Tables whose planner statistics are missing (never analyzed) or stale |
1316
+ | **Vacuum health** | Tables with >10% dead tuples |
1317
+ | **Table bloat** | Tables with significant estimated wasted space (≥100 MB AND ≥25% bloat, ioguix estimator) |
1318
+ | **Index bloat** | btree indexes with significant estimated wasted space (≥100 MB AND ≥30%, ioguix estimator) |
1285
1319
 
1286
- If a future release exposes per-table autovacuum / fillfactor parameters in
1287
- `model_options` (see the `postgres-model-storage-parameters` arc), these
1288
- findings can graduate back to the warning tier because the remedy will be
1289
- expressible in code.
1320
+ Now that per-table autovacuum / fillfactor knobs are expressible in
1321
+ [storage parameters](#storage-parameters) on `model_options`, these
1322
+ findings may graduate back to the warning tier in a future release the
1323
+ remedy is now in code.
1290
1324
 
1291
1325
  ### Informational context
1292
1326
 
@@ -1335,7 +1369,7 @@ heroku run -a your-app "plain postgres diagnose --json"
1335
1369
 
1336
1370
  The `--json` flag must be quoted so Heroku passes it through to the command.
1337
1371
 
1338
- Cumulative-stat checks (`stats_freshness`, `vacuum_health`, `unused_indexes`, `missing_index_candidates`, `index_bloat`) need cumulative stat history after the last reset to be reliable. Check the `stats_reset` informational to see how much history you have. (Note: this list spans both the warning and operational tiers — the common thread is that all five depend on counters that `pg_stat_reset()` wipes.)
1372
+ Cumulative-stat checks (`stats_freshness`, `vacuum_health`, `unused_indexes`, `missing_index_candidates`, `table_bloat`, `index_bloat`) need cumulative stat history after the last reset to be reliable. Check the `stats_reset` informational to see how much history you have. (Note: this list spans both the warning and operational tiers — the common thread is that all five depend on counters that `pg_stat_reset()` wipes.)
1339
1373
 
1340
1374
  ### Preflight checks
1341
1375
 
@@ -1,5 +1,17 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.103.0](https://github.com/dropseed/plain/releases/plain-postgres@0.103.0) (2026-05-06)
4
+
5
+ ### What's changed
6
+
7
+ - **New `storage_parameters` on `model_options`, managed by convergence.** Declare per-table Postgres storage parameters (`pg_class.reloptions`) on the model — autovacuum tuning, `fillfactor`, TOAST options, anything you'd otherwise set with `ALTER TABLE … SET (...)` — and `plain postgres sync` reconciles them via instant catalog-only `ALTER TABLE … SET / RESET (...)` statements. Models are the source of truth: parameters set on the live table that aren't declared on the model get reset, matching how indexes and constraints work. TOAST parameters use a `toast.` prefix (`toast.autovacuum_vacuum_scale_factor`) and are stored on the toast relation. Storage parameters are not serialized into migrations. New public API: `StorageParameterDrift`, `SetStorageParameterFix`, `ResetStorageParameterFix`. ([7fe40f72](https://github.com/dropseed/plain/commit/7fe40f72))
8
+ - **New `table_bloat` health check.** Estimates per-table page-level bloat using the ioguix estimator (same heuristic as pghero). Complements `vacuum_health`: dead-tuple counts only show what autovacuum hasn't reclaimed yet, but a table that's been vacuumed regularly can still carry gigabytes of bloat because plain `VACUUM` marks pages reusable without returning space to the OS. Surfaces tables with both >100 MB wasted bytes AND >25% bloat ratio, with `pg_repack` / `pg_squeeze` / `VACUUM FULL` suggestions. Cross-check caveats now link `vacuum_health` and `table_bloat` findings on the same table. ([ee9dc1d5](https://github.com/dropseed/plain/commit/ee9dc1d5))
9
+ - **Tightened `index_bloat` thresholds.** Now requires both >100 MB wasted bytes AND >30% bloat ratio (was 10 MB only). The previous floor surfaced too many small, healthy indexes; the higher percentage bar reflects that `REINDEX CONCURRENTLY` is cheap so it's only worth flagging genuinely degraded indexes. Results are also capped at 100 rows per check. ([ee9dc1d5](https://github.com/dropseed/plain/commit/ee9dc1d5))
10
+
11
+ ### Upgrade instructions
12
+
13
+ - No changes required. To opt into the new `storage_parameters` API, declare them on `model_options = postgres.Options(storage_parameters={...})` and run `plain postgres sync`. After upgrading, expect previously-noisy `index_bloat` findings to disappear (now require ≥100 MB AND ≥30%) and the new `table_bloat` check to appear in `plain postgres diagnose`.
14
+
3
15
  ## [0.102.0](https://github.com/dropseed/plain/releases/plain-postgres@0.102.0) (2026-05-05)
4
16
 
5
17
  ### What's changed
@@ -1,16 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: plain.postgres
3
- Version: 0.102.0
4
- Summary: Model your data and store it in a database.
5
- Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
- License-Expression: BSD-3-Clause
7
- License-File: LICENSE
8
- Requires-Python: >=3.13
9
- Requires-Dist: plain<1.0.0,>=0.134.0
10
- Requires-Dist: psycopg-pool>=3.2
11
- Requires-Dist: psycopg>=3.2
12
- Description-Content-Type: text/markdown
13
-
14
1
  # plain.postgres
15
2
 
16
3
  **Model your data and store it in a database.**
@@ -700,7 +687,7 @@ Or use `RunSQL` with explicit cascade if the relationship is large.
700
687
 
701
688
  ### Convergence
702
689
 
703
- Convergence compares the indexes, constraints, foreign keys, and nullability declared on your models against what actually exists in the database, then applies fixes to make them match. You don't need to create migrations for these — just declare them on your model and run `postgres sync`.
690
+ Convergence compares the indexes, constraints, foreign keys, nullability, and [storage parameters](#storage-parameters) declared on your models against what actually exists in the database, then applies fixes to make them match. You don't need to create migrations for these — just declare them on your model and run `postgres sync`.
704
691
 
705
692
  ```python
706
693
  @postgres.register_model
@@ -1091,6 +1078,26 @@ A `code` becomes `ValidationError.code` — useful for test assertions, error tr
1091
1078
 
1092
1079
  `UniqueConstraint` accepts the same `violation_error`. With a single-field unique constraint, a string `violation_error="That email is taken."` auto-routes to that field; otherwise (multi-field, expressions, or a CheckConstraint) errors land on `NON_FIELD_ERRORS` unless you pass the dict form. See [BaseConstraint](./constraints.py#BaseConstraint) for the full signature.
1093
1080
 
1081
+ ### Storage parameters
1082
+
1083
+ Per-table Postgres [storage parameters](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-STORAGE-PARAMETERS) (`pg_class.reloptions`) are declared on `model_options` and managed by [convergence](#convergence) — `postgres sync` issues the `ALTER TABLE … SET/RESET (...)` to make the live table match. They're not serialized into migrations.
1084
+
1085
+ ```python
1086
+ class CachedItem(postgres.Model):
1087
+ ...
1088
+
1089
+ model_options = postgres.Options(
1090
+ storage_parameters={
1091
+ # Tighter autovacuum on a churn-heavy table
1092
+ "autovacuum_vacuum_scale_factor": 0.1,
1093
+ # TOAST has its own autovacuum schedule — prefix with `toast.`
1094
+ "toast.autovacuum_vacuum_scale_factor": 0.05,
1095
+ },
1096
+ )
1097
+ ```
1098
+
1099
+ Models are the source of truth: undeclared parameters set on the live table are reset on the next sync. Use this for autovacuum tuning, `fillfactor`, TOAST options, etc. — anything you'd otherwise apply by hand with `ALTER TABLE … SET (...)`.
1100
+
1094
1101
  ### Schema design
1095
1102
 
1096
1103
  #### Index fields used in filters and ordering
@@ -1290,16 +1297,17 @@ fix in their model code. (In JSON output each finding still carries
1290
1297
  it.) Each finding still carries the exact SQL in its suggestion for anyone
1291
1298
  who wants to act.
1292
1299
 
1293
- | Finding | What it reports |
1294
- | ------------------- | -------------------------------------------------------------------------------- |
1295
- | **Stats freshness** | Tables whose planner statistics are missing (never analyzed) or stale |
1296
- | **Vacuum health** | Tables with >10% dead tuples |
1297
- | **Index bloat** | btree indexes with significant estimated wasted space (≥10 MB, ioguix estimator) |
1300
+ | Finding | What it reports |
1301
+ | ------------------- | ------------------------------------------------------------------------------------------ |
1302
+ | **Stats freshness** | Tables whose planner statistics are missing (never analyzed) or stale |
1303
+ | **Vacuum health** | Tables with >10% dead tuples |
1304
+ | **Table bloat** | Tables with significant estimated wasted space (≥100 MB AND ≥25% bloat, ioguix estimator) |
1305
+ | **Index bloat** | btree indexes with significant estimated wasted space (≥100 MB AND ≥30%, ioguix estimator) |
1298
1306
 
1299
- If a future release exposes per-table autovacuum / fillfactor parameters in
1300
- `model_options` (see the `postgres-model-storage-parameters` arc), these
1301
- findings can graduate back to the warning tier because the remedy will be
1302
- expressible in code.
1307
+ Now that per-table autovacuum / fillfactor knobs are expressible in
1308
+ [storage parameters](#storage-parameters) on `model_options`, these
1309
+ findings may graduate back to the warning tier in a future release the
1310
+ remedy is now in code.
1303
1311
 
1304
1312
  ### Informational context
1305
1313
 
@@ -1348,7 +1356,7 @@ heroku run -a your-app "plain postgres diagnose --json"
1348
1356
 
1349
1357
  The `--json` flag must be quoted so Heroku passes it through to the command.
1350
1358
 
1351
- Cumulative-stat checks (`stats_freshness`, `vacuum_health`, `unused_indexes`, `missing_index_candidates`, `index_bloat`) need cumulative stat history after the last reset to be reliable. Check the `stats_reset` informational to see how much history you have. (Note: this list spans both the warning and operational tiers — the common thread is that all five depend on counters that `pg_stat_reset()` wipes.)
1359
+ Cumulative-stat checks (`stats_freshness`, `vacuum_health`, `unused_indexes`, `missing_index_candidates`, `table_bloat`, `index_bloat`) need cumulative stat history after the last reset to be reliable. Check the `stats_reset` informational to see how much history you have. (Note: this list spans both the warning and operational tiers — the common thread is that all five depend on counters that `pg_stat_reset()` wipes.)
1352
1360
 
1353
1361
  ### Preflight checks
1354
1362
 
@@ -36,7 +36,7 @@ Get approval before writing any model code or generating migrations.
36
36
  `uv run plain postgres sync` runs three steps: create migrations → apply migrations → converge schema.
37
37
 
38
38
  - **Migrations** handle tables and columns (CreateModel, AddField, AlterField, etc.)
39
- - **Convergence** handles indexes, constraints, and FK constraints — declared on the model but NOT serialized into migration files. (FK _columns_ like `team_id bigint` are created by migrations; the actual `FOREIGN KEY` constraint is added by convergence.)
39
+ - **Convergence** handles indexes, constraints, FK constraints, and storage parameters — declared on the model but NOT serialized into migration files. (FK _columns_ like `team_id bigint` are created by migrations; the actual `FOREIGN KEY` constraint is added by convergence.)
40
40
 
41
41
  This means: when you add an `Index` or `UniqueConstraint` to a model, no migration is generated. The converge step reads the live model class and syncs the database directly. Don't worry about serializing constraint expressions (like `Lower()`) for migrations — they never go there.
42
42
 
@@ -12,6 +12,7 @@ from .analysis import (
12
12
  ModelAnalysis,
13
13
  NullabilityDrift,
14
14
  ReadOnlyConnectionError,
15
+ StorageParameterDrift,
15
16
  analyze_model,
16
17
  )
17
18
  from .fixes import (
@@ -26,8 +27,10 @@ from .fixes import (
26
27
  RebuildIndexFix,
27
28
  RenameConstraintFix,
28
29
  RenameIndexFix,
30
+ ResetStorageParameterFix,
29
31
  SetColumnDefaultFix,
30
32
  SetNotNullFix,
33
+ SetStorageParameterFix,
31
34
  ValidateConstraintFix,
32
35
  )
33
36
  from .planning import (
@@ -70,8 +73,11 @@ __all__ = [
70
73
  "RebuildIndexFix",
71
74
  "RenameConstraintFix",
72
75
  "RenameIndexFix",
76
+ "ResetStorageParameterFix",
73
77
  "SetColumnDefaultFix",
74
78
  "SetNotNullFix",
79
+ "SetStorageParameterFix",
80
+ "StorageParameterDrift",
75
81
  "ValidateConstraintFix",
76
82
  "analyze_model",
77
83
  "can_auto_fix",
@@ -191,8 +191,44 @@ class ColumnDefaultDrift:
191
191
  )
192
192
 
193
193
 
194
+ @dataclass
195
+ class StorageParameterDrift:
196
+ """Mismatch between declared and live `pg_class.reloptions` for a table.
197
+
198
+ `key` carries a `toast.` prefix when the parameter belongs to the table's
199
+ TOAST relation; convergence emits and reads it accordingly.
200
+ """
201
+
202
+ kind: DriftKind
203
+ table: str
204
+ key: str
205
+ declared_value: str | None = None
206
+ actual_value: str | None = None
207
+
208
+ def describe(self) -> str:
209
+ match self.kind:
210
+ case DriftKind.MISSING:
211
+ return (
212
+ f"{self.table}: storage parameter {self.key} missing "
213
+ f"(expected {self.declared_value})"
214
+ )
215
+ case DriftKind.CHANGED:
216
+ return (
217
+ f"{self.table}: storage parameter {self.key} mismatch — "
218
+ f"db has {self.actual_value}, model declares "
219
+ f"{self.declared_value}"
220
+ )
221
+ case _:
222
+ return (
223
+ f"{self.table}: storage parameter {self.key} not declared "
224
+ f"(db has {self.actual_value})"
225
+ )
226
+
227
+
194
228
  type ColumnDrift = NullabilityDrift | ColumnDefaultDrift
195
- type Drift = IndexDrift | ConstraintDrift | ForeignKeyDrift | ColumnDrift
229
+ type Drift = (
230
+ IndexDrift | ConstraintDrift | ForeignKeyDrift | ColumnDrift | StorageParameterDrift
231
+ )
196
232
 
197
233
 
198
234
  # Status objects — analysis results with optional drift
@@ -236,6 +272,7 @@ class ModelAnalysis:
236
272
  columns: list[ColumnStatus] = field(default_factory=list)
237
273
  indexes: list[IndexStatus] = field(default_factory=list)
238
274
  constraints: list[ConstraintStatus] = field(default_factory=list)
275
+ storage_parameter_drifts: list[StorageParameterDrift] = field(default_factory=list)
239
276
 
240
277
  @cached_property
241
278
  def drifts(self) -> list[Drift]:
@@ -249,15 +286,17 @@ class ModelAnalysis:
249
286
  for con in self.constraints:
250
287
  if con.drift:
251
288
  result.append(con.drift)
289
+ result.extend(self.storage_parameter_drifts)
252
290
  return result
253
291
 
254
292
  @cached_property
255
293
  def issue_count(self) -> int:
256
- """Total issues (table + columns + indexes + constraints)."""
294
+ """Total issues (table + columns + indexes + constraints + storage)."""
257
295
  count = len(self.table_issues)
258
296
  count += sum(1 for col in self.columns if col.issue)
259
297
  count += sum(1 for idx in self.indexes if idx.issue)
260
298
  count += sum(1 for con in self.constraints if con.issue)
299
+ count += len(self.storage_parameter_drifts)
261
300
  return count
262
301
 
263
302
  def to_dict(self) -> dict[str, Any]:
@@ -300,6 +339,15 @@ class ModelAnalysis:
300
339
  }
301
340
  for con in self.constraints
302
341
  ],
342
+ "storage_parameter_drifts": [
343
+ {
344
+ "key": d.key,
345
+ "kind": d.kind,
346
+ "declared_value": d.declared_value,
347
+ "actual_value": d.actual_value,
348
+ }
349
+ for d in self.storage_parameter_drifts
350
+ ],
303
351
  }
304
352
 
305
353
 
@@ -328,9 +376,59 @@ def analyze_model(
328
376
  columns=_compare_columns(model, db, table_name, cursor),
329
377
  indexes=_compare_indexes(cursor, model, db, table_name),
330
378
  constraints=_compare_constraints(cursor, model, db, table_name),
379
+ storage_parameter_drifts=_compare_storage_parameters(model, db, table_name),
331
380
  )
332
381
 
333
382
 
383
+ def _compare_storage_parameters(
384
+ model: type[Model], db: TableState, table: str
385
+ ) -> list[StorageParameterDrift]:
386
+ """Diff declared `model_options.storage_parameters` against `pg_class.reloptions`.
387
+
388
+ Declared keys missing from the DB → MISSING. Mismatched values → CHANGED.
389
+ Live keys not declared → UNDECLARED (so convergence can RESET them, keeping
390
+ the model as the source of truth — matches how indexes/constraints work).
391
+ """
392
+ declared = model.model_options.storage_parameters
393
+ actual = db.storage_parameters
394
+ drifts: list[StorageParameterDrift] = []
395
+
396
+ for key, declared_value in declared.items():
397
+ actual_value = actual.get(key)
398
+ if actual_value is None:
399
+ drifts.append(
400
+ StorageParameterDrift(
401
+ kind=DriftKind.MISSING,
402
+ table=table,
403
+ key=key,
404
+ declared_value=declared_value,
405
+ )
406
+ )
407
+ elif actual_value != declared_value:
408
+ drifts.append(
409
+ StorageParameterDrift(
410
+ kind=DriftKind.CHANGED,
411
+ table=table,
412
+ key=key,
413
+ declared_value=declared_value,
414
+ actual_value=actual_value,
415
+ )
416
+ )
417
+
418
+ for key, actual_value in actual.items():
419
+ if key not in declared:
420
+ drifts.append(
421
+ StorageParameterDrift(
422
+ kind=DriftKind.UNDECLARED,
423
+ table=table,
424
+ key=key,
425
+ actual_value=actual_value,
426
+ )
427
+ )
428
+
429
+ return drifts
430
+
431
+
334
432
  # Column comparison
335
433
 
336
434
 
@@ -4,6 +4,9 @@ from abc import ABC, abstractmethod
4
4
  from dataclasses import dataclass
5
5
  from typing import TYPE_CHECKING, ClassVar
6
6
 
7
+ import psycopg
8
+ import psycopg.sql
9
+
7
10
  from plain.logs import get_framework_logger
8
11
  from plain.runtime import settings as plain_settings
9
12
 
@@ -555,3 +558,42 @@ class DropIndexFix(Fix):
555
558
  sql = f"DROP INDEX CONCURRENTLY IF EXISTS {quote_name(self.name)}"
556
559
  _execute_autocommit(sql)
557
560
  return sql
561
+
562
+
563
+ @dataclass
564
+ class SetStorageParameterFix(Fix):
565
+ """Set a single `pg_class.reloptions` parameter (catalog-only, instant)."""
566
+
567
+ pass_order = 2
568
+
569
+ table: str
570
+ key: str
571
+ value: str
572
+
573
+ def describe(self) -> str:
574
+ return f"{self.table}: set storage parameter {self.key} = {self.value}"
575
+
576
+ def apply(self) -> str:
577
+ conn = get_connection()
578
+ quoted = psycopg.sql.quote(self.value, conn.connection)
579
+ sql = f"ALTER TABLE {quote_name(self.table)} SET ({self.key} = {quoted})"
580
+ _execute_and_commit(sql)
581
+ return sql
582
+
583
+
584
+ @dataclass
585
+ class ResetStorageParameterFix(Fix):
586
+ """Reset a single `pg_class.reloptions` parameter (catalog-only, instant)."""
587
+
588
+ pass_order = 2
589
+
590
+ table: str
591
+ key: str
592
+
593
+ def describe(self) -> str:
594
+ return f"{self.table}: reset storage parameter {self.key}"
595
+
596
+ def apply(self) -> str:
597
+ sql = f"ALTER TABLE {quote_name(self.table)} RESET ({self.key})"
598
+ _execute_and_commit(sql)
599
+ return sql
@@ -14,6 +14,7 @@ from .analysis import (
14
14
  ForeignKeyDrift,
15
15
  IndexDrift,
16
16
  NullabilityDrift,
17
+ StorageParameterDrift,
17
18
  analyze_model,
18
19
  )
19
20
  from .fixes import (
@@ -29,8 +30,10 @@ from .fixes import (
29
30
  RenameConstraintFix,
30
31
  RenameIndexFix,
31
32
  ReplaceForeignKeyFix,
33
+ ResetStorageParameterFix,
32
34
  SetColumnDefaultFix,
33
35
  SetNotNullFix,
36
+ SetStorageParameterFix,
34
37
  ValidateConstraintFix,
35
38
  )
36
39
 
@@ -140,6 +143,16 @@ def _plan_drift(drift: Drift) -> PlanItem:
140
143
  return PlanItem(drift, SetColumnDefaultFix(t, col, default_sql))
141
144
  case ColumnDefaultDrift(kind=DriftKind.UNDECLARED, table=t, column=col):
142
145
  return PlanItem(drift, DropColumnDefaultFix(t, col))
146
+ case StorageParameterDrift(
147
+ kind=DriftKind.MISSING | DriftKind.CHANGED,
148
+ table=t,
149
+ key=k,
150
+ declared_value=v,
151
+ ):
152
+ assert v is not None
153
+ return PlanItem(drift, SetStorageParameterFix(t, k, v))
154
+ case StorageParameterDrift(kind=DriftKind.UNDECLARED, table=t, key=k):
155
+ return PlanItem(drift, ResetStorageParameterFix(t, k))
143
156
  case _:
144
157
  raise ValueError(f"Unhandled drift: {drift}")
145
158