plain.postgres 0.111.2__tar.gz → 0.113.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 (231) hide show
  1. plain_postgres-0.111.2/plain/postgres/README.md → plain_postgres-0.113.0/PKG-INFO +36 -2
  2. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/CHANGELOG.md +32 -0
  3. plain_postgres-0.111.2/PKG-INFO → plain_postgres-0.113.0/plain/postgres/README.md +23 -15
  4. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -1
  5. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/converge.py +28 -8
  6. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/core.py +23 -9
  7. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/decorators.py +17 -1
  8. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/migrations.py +70 -54
  9. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/schema.py +9 -9
  10. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/sync.py +8 -4
  11. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/convergence/__init__.py +6 -6
  12. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/convergence/analysis.py +3 -3
  13. plain_postgres-0.111.2/plain/postgres/convergence/fixes.py → plain_postgres-0.113.0/plain/postgres/convergence/corrections.py +18 -18
  14. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/convergence/planning.py +67 -57
  15. plain_postgres-0.113.0/plain/postgres/databases.py +190 -0
  16. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/default_settings.py +7 -0
  17. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/expressions.py +3 -5
  18. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/base.py +53 -18
  19. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/binary.py +5 -4
  20. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/encrypted.py +56 -104
  21. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/json.py +12 -5
  22. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/related.py +8 -30
  23. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/related_managers.py +8 -4
  24. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/reverse_related.py +3 -9
  25. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/forms.py +4 -11
  26. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/lookups.py +2 -2
  27. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/autodetector.py +26 -16
  28. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/query.py +4 -87
  29. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/query_utils.py +6 -50
  30. plain_postgres-0.113.0/plain/postgres/schema_lock.py +194 -0
  31. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sources.py +2 -2
  32. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/__init__.py +1 -2
  33. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/compiler.py +36 -153
  34. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/datastructures.py +1 -36
  35. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/query.py +26 -284
  36. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/where.py +4 -42
  37. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/test/database.py +36 -56
  38. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/transaction.py +12 -1
  39. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/types.pyi +36 -0
  40. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/pyproject.toml +1 -1
  41. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/encrypted.py +1 -1
  42. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/nullability.py +1 -1
  43. plain_postgres-0.113.0/tests/internal/test_apply_replan.py +56 -0
  44. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_autodetector_not_null_errors.py +87 -43
  45. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence.py +112 -70
  46. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_constraints.py +81 -73
  47. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_defaults.py +102 -71
  48. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_fk.py +65 -40
  49. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_indexes.py +95 -64
  50. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_nullability.py +75 -55
  51. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_storage_parameters.py +15 -15
  52. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_convergence_timeouts.py +51 -47
  53. plain_postgres-0.113.0/tests/internal/test_databases_not_on_runtime_path.py +118 -0
  54. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_db_expression_defaults.py +1 -1
  55. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_literal_default_persistence.py +43 -5
  56. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_otel_metrics.py +21 -0
  57. plain_postgres-0.113.0/tests/internal/test_rollback_exc_attribution.py +85 -0
  58. plain_postgres-0.113.0/tests/internal/test_schema_lock.py +138 -0
  59. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_schema_normalize_type.py +7 -0
  60. plain_postgres-0.113.0/tests/public/test_databases.py +182 -0
  61. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_encrypted_fields.py +13 -3
  62. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_m2m.py +37 -0
  63. plain_postgres-0.113.0/tests/public/test_only_empty_defaults.py +76 -0
  64. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/.gitignore +0 -0
  65. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/CLAUDE.md +0 -0
  66. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/LICENSE +0 -0
  67. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/README.md +0 -0
  68. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/__init__.py +0 -0
  69. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/adapters.py +0 -0
  70. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  71. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/aggregates.py +0 -0
  72. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/base.py +0 -0
  73. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/__init__.py +0 -0
  74. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/cli/diagnose.py +0 -0
  75. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/config.py +0 -0
  76. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/connection.py +0 -0
  77. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/constants.py +0 -0
  78. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/constraints.py +0 -0
  79. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/database_url.py +0 -0
  80. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/db.py +0 -0
  81. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/ddl.py +0 -0
  82. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/deletion.py +0 -0
  83. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/dialect.py +0 -0
  84. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/entrypoints.py +0 -0
  85. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/enums.py +0 -0
  86. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/exceptions.py +0 -0
  87. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/__init__.py +0 -0
  88. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/boolean.py +0 -0
  89. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/duration.py +0 -0
  90. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/mixins.py +0 -0
  91. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/network.py +0 -0
  92. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/numeric.py +0 -0
  93. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/primary_key.py +0 -0
  94. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/related_descriptors.py +0 -0
  95. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/related_lookups.py +0 -0
  96. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  97. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/temporal.py +0 -0
  98. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/text.py +0 -0
  99. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/timezones.py +0 -0
  100. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/fields/uuid.py +0 -0
  101. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/__init__.py +0 -0
  102. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/comparison.py +0 -0
  103. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/datetime.py +0 -0
  104. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/math.py +0 -0
  105. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/mixins.py +0 -0
  106. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/random.py +0 -0
  107. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/text.py +0 -0
  108. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/uuid.py +0 -0
  109. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/functions/window.py +0 -0
  110. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/indexes.py +0 -0
  111. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/__init__.py +0 -0
  112. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/__init__.py +0 -0
  113. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  114. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  115. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  116. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/context.py +0 -0
  117. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/helpers.py +0 -0
  118. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/ownership.py +0 -0
  119. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/runner.py +0 -0
  120. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/health/types.py +0 -0
  121. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/introspection/schema.py +0 -0
  122. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/meta.py +0 -0
  123. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/middleware.py +0 -0
  124. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/__init__.py +0 -0
  125. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/exceptions.py +0 -0
  126. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/executor.py +0 -0
  127. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/graph.py +0 -0
  128. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/loader.py +0 -0
  129. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/migration.py +0 -0
  130. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  131. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/base.py +0 -0
  132. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/fields.py +0 -0
  133. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/models.py +0 -0
  134. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/operations/special.py +0 -0
  135. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/optimizer.py +0 -0
  136. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/questioner.py +0 -0
  137. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/recorder.py +0 -0
  138. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/serializer.py +0 -0
  139. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/state.py +0 -0
  140. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/utils.py +0 -0
  141. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/migrations/writer.py +0 -0
  142. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/options.py +0 -0
  143. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/otel.py +0 -0
  144. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/preflight/__init__.py +0 -0
  145. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/preflight/database.py +0 -0
  146. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/preflight/indexes.py +0 -0
  147. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/preflight/models.py +0 -0
  148. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/registry.py +0 -0
  149. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/schema.py +0 -0
  150. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/sql/constants.py +0 -0
  151. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/test/__init__.py +0 -0
  152. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/test/pytest.py +0 -0
  153. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/types.py +0 -0
  154. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/plain/postgres/utils.py +0 -0
  155. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/forms.py +0 -0
  156. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  157. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  158. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  159. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  160. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  161. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  162. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  163. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  164. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  165. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  166. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  167. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  168. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  169. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  170. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  171. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  172. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  173. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  174. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/migrations/__init__.py +0 -0
  175. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/__init__.py +0 -0
  176. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/constraints.py +0 -0
  177. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/defaults.py +0 -0
  178. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/delete.py +0 -0
  179. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/forms.py +0 -0
  180. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/indexes.py +0 -0
  181. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/iteration.py +0 -0
  182. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/mixins.py +0 -0
  183. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/querysets.py +0 -0
  184. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/relationships.py +0 -0
  185. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/storage_parameters.py +0 -0
  186. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/trees.py +0 -0
  187. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/models/unregistered.py +0 -0
  188. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/urls.py +0 -0
  189. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/examples/views.py +0 -0
  190. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/settings.py +0 -0
  191. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/app/urls.py +0 -0
  192. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/conftest.py +0 -0
  193. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/conftest_convergence.py +0 -0
  194. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_autodetector_type_change.py +0 -0
  195. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_connection_isolation.py +0 -0
  196. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_connection_lifecycle.py +0 -0
  197. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_connection_pool.py +0 -0
  198. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_connection_self_heal.py +0 -0
  199. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_constraint_violation_error.py +0 -0
  200. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_diagnose.py +0 -0
  201. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_executor_connection_hook.py +0 -0
  202. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_fk_characterization.py +0 -0
  203. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_health.py +0 -0
  204. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_introspection.py +0 -0
  205. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_management_connection.py +0 -0
  206. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_migration_executor.py +0 -0
  207. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_no_callable_defaults.py +0 -0
  208. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  209. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
  210. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  211. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/internal/test_schema_timeouts.py +0 -0
  212. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_create_update.py +0 -0
  213. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_database_url.py +0 -0
  214. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_deferred_loading.py +0 -0
  215. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_delete_behaviors.py +0 -0
  216. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_exceptions.py +0 -0
  217. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_field_defaults.py +0 -0
  218. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_functions_uuid.py +0 -0
  219. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_integrity_error_mapping.py +0 -0
  220. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_iterator.py +0 -0
  221. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_manager_assignment.py +0 -0
  222. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_manual_pk.py +0 -0
  223. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_mixins.py +0 -0
  224. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_modelform_roundtrip.py +0 -0
  225. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_queryset_repr.py +0 -0
  226. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_queryset_slicing.py +0 -0
  227. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_random_string_field.py +0 -0
  228. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_raw_query.py +0 -0
  229. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_read_only_transactions.py +0 -0
  230. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_related.py +0 -0
  231. {plain_postgres-0.111.2 → plain_postgres-0.113.0}/tests/public/test_related_instance_filter.py +0 -0
@@ -1,3 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.postgres
3
+ Version: 0.113.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.**
@@ -775,6 +788,26 @@ Environment overrides: every setting accepts `PLAIN_POSTGRES_*` env vars, so you
775
788
  PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT=30s plain migrations apply
776
789
  ```
777
790
 
791
+ ### Schema lock
792
+
793
+ Schema-changing commands — `plain postgres sync`, `plain migrations apply`, `plain postgres converge`, and `plain postgres drop-unknown-tables` — serialize on a single session-level advisory lock, so two deploy processes running at once (a retried migrate job, overlapping release phases) can't interleave schema changes. You don't have to do anything to get this.
794
+
795
+ A second process warns and waits, retrying until the holder finishes:
796
+
797
+ ```python
798
+ # app/settings.py — defaults shown (waits up to an hour total)
799
+ POSTGRES_SCHEMA_LOCK_RETRY_INTERVAL = 5.0
800
+ POSTGRES_SCHEMA_LOCK_MAX_RETRIES = 720
801
+ ```
802
+
803
+ The wait is generous by default because a legitimate holder can be mid index build. If the budget runs out, the command fails with the holder's `pid` so you can see what's blocking. A crashed holder is not a problem — the lock releases automatically when its database session closes.
804
+
805
+ The lock is held on its own connection, separate from the one running DDL, so non-transactional operations (`CREATE INDEX CONCURRENTLY`, `VALIDATE CONSTRAINT`) work normally while it's held. Session-level locks don't survive transaction-mode poolers like pgbouncer — if your `POSTGRES_URL` points at one, set [`POSTGRES_MANAGEMENT_URL`](#bypassing-a-connection-pooler-for-management-operations) to a direct connection.
806
+
807
+ The lock connection sits idle while your DDL runs, so it enables TCP keepalives to survive NAT and load-balancer idle timeouts. A server-side `idle_session_timeout` would still kill it (releasing the lock mid-run) — don't set one for the role that runs migrations. `sync` re-verifies the lock between its migrate and converge phases and stops with a clear error if the session died.
808
+
809
+ To see the lock live: `SELECT * FROM pg_locks WHERE locktype = 'advisory' AND objid = 1047265496`.
810
+
778
811
  ## Fields
779
812
 
780
813
  You can use many field types for different data:
@@ -908,6 +941,7 @@ Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived
908
941
 
909
942
  - **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported.
910
943
  - **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this.
944
+ - **Only `default=""`** — on `EncryptedTextField` (paired with `required=False`), the empty string is stored as plaintext `''`, so it's the one value expressible as a column `DEFAULT` (declare it to add the field to a populated table). Any other default would need ciphertext, which is non-deterministic. `EncryptedJSONField` accepts no default at all — even `{}` serializes to text that would need ciphertext; use `allow_null=True`.
911
945
 
912
946
  **Key rotation:**
913
947
 
@@ -1459,9 +1493,9 @@ See [`default_settings.py`](./default_settings.py) for more details.
1459
1493
 
1460
1494
  Add the field to your model class, then run `plain migrations create` to create a migration.
1461
1495
 
1462
- If the field is required (no `default=` and not `allow_null=True`), the autodetector refuses to generate the migration, since there's no value to seed existing rows with. You have two options:
1496
+ If the field has no `default=` and isn't `allow_null=True`, the autodetector refuses to generate the migration, since there's no value to seed existing rows with — `required=False` alone is not enough (it only affects Python-side validation, not the column). You have two options:
1463
1497
 
1464
- 1. Declare a `default=` on the field so the new column has a value for existing rows.
1498
+ 1. Declare a `default=` on the field so the new column has a value for existing rows. For an optional string field the idiom is `required=False, default=""` (for `BinaryField`, `default=b""`).
1465
1499
  2. Add the field with `allow_null=True`, scaffold a data migration with `plain migrations create --empty --name backfill_<field>` to populate existing rows, then remove `allow_null=True` from the field — convergence applies `NOT NULL` on the next `postgres sync`.
1466
1500
 
1467
1501
  #### How do I make an existing column `NOT NULL`?
@@ -1,5 +1,37 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.113.0](https://github.com/dropseed/plain/releases/plain-postgres@0.113.0) (2026-08-02)
4
+
5
+ ### What's changed
6
+
7
+ - **`QuerySet.extra()` has been removed**, along with all of its supporting machinery (`extra_select`, `extra_tables`, `extra_order_by`, `ExtraWhere`). Use `annotate()` with `RawSQL` or other expressions instead. ([3fed0b32f1](https://github.com/dropseed/plain/commit/3fed0b32f1))
8
+ - **`FilteredRelation` has been removed** — it's no longer importable from `plain.postgres` or usable in `annotate()`. Express conditional joins with `Q` filters or subqueries instead. ([434f57852f](https://github.com/dropseed/plain/commit/434f57852f))
9
+ - **`QuerySet.alias()` and logical XOR have been removed** — `Q(...) ^ Q(...)` and `queryset ^ queryset` are no longer supported. Use `annotate()` in place of `alias()`, and compose AND/OR/NOT in place of XOR. ([c12cf81af4](https://github.com/dropseed/plain/commit/c12cf81af4))
10
+ - **Encrypted and binary fields now accept their empty value as a declared `default=`.** `EncryptedTextField` (now a `TextField` subclass) accepts exactly `default=""` and `BinaryField` accepts exactly `default=b""`, each paired with `required=False` — the empty value is stored as plaintext so it's the one default expressible as a column `DEFAULT`, which is what lets the field be added to a populated table. `default=None` requires `allow_null=True` + `required=False`. The `types.pyi` stub overloads encode the same coupling. ([2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
11
+ - **The migration autodetector no longer waves through `required=False` fields with no declared default.** Adding a non-nullable field without a `default=` now refuses at `plain migrations create` time with a field-accurate remedy (previously these could generate an `AddField` that failed at apply time on any populated table). The optional-string idiom is `required=False, default=""`. ([2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
12
+ - **`EncryptedJSONField` is now a `JSONField` subclass** and `EncryptedTextField` a `TextField` subclass, instead of parallel `ColumnField` implementations — behavior like max_length validation and JSON encoding now comes from the real base classes. The encrypted lookup surface is still just `exact`/`isnull`, and JSON key transforms are blocked (they'd operate on ciphertext). `EncryptedJSONField` accepts no `default=` at all — even `{}` would need ciphertext; use `allow_null=True`. ([5153ceb106](https://github.com/dropseed/plain/commit/5153ceb106), [2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
13
+ - Model forms no longer prefill a `BinaryField`'s `b""` default as a text input's initial value (it rendered as the literal `b''`), and encrypted fields now map to form fields through the regular `TextField`/`JSONField` branches. ([2a86968e5a](https://github.com/dropseed/plain/commit/2a86968e5a))
14
+ - M2M prefetches now expose the through-table join column via `annotate(RawSQL(...))` instead of the removed `extra()`, and internal path-info plumbing lost its `filtered_relation` threading. ([3fed0b32f1](https://github.com/dropseed/plain/commit/3fed0b32f1), [434f57852f](https://github.com/dropseed/plain/commit/434f57852f))
15
+
16
+ ### Upgrade instructions
17
+
18
+ - Replace `QuerySet.extra()` calls: `extra(select=...)` becomes `annotate(name=RawSQL("...", []))`, `extra(where=...)` becomes `filter()` with expressions or `RawSQL`, and `extra(order_by=...)` becomes `order_by()` on an annotation.
19
+ - Replace `FilteredRelation` annotations with filtered subqueries or `Q` conditions, and `QuerySet.alias()` with `annotate()`.
20
+ - Replace `Q(a) ^ Q(b)` / `qs1 ^ qs2` with explicit AND/OR logic: `(Q(a) | Q(b)) & ~(Q(a) & Q(b))`.
21
+ - If `plain migrations create` now refuses a field it previously accepted, follow the error's remedy: declare `required=False, default=""` (or `default=b""` / an appropriate literal) so existing rows get a value, or add it with `allow_null=True` plus a backfill data migration.(https://github.com/dropseed/plain/releases/plain-postgres@0.112.0) (2026-07-21)
22
+
23
+ ### What's changed
24
+
25
+ - **Schema-changing commands now serialize on an advisory schema lock.** `plain postgres sync`, `plain migrations apply`, `plain postgres converge`, and `plain postgres drop-unknown-tables` take a session-level advisory lock, so two deploy processes running at once (a retried migrate job, overlapping release phases) can't interleave schema changes. A second process warns and waits, retrying up to an hour by default (`POSTGRES_SCHEMA_LOCK_RETRY_INTERVAL`, `POSTGRES_SCHEMA_LOCK_MAX_RETRIES`), and each command re-checks its plan after acquiring the lock — `migrations apply` skips work another process already did, `converge` refuses to execute corrections the operator never approved, and `drop-unknown-tables` skips tables that became known while waiting. The lock is held on its own connection so `CREATE INDEX CONCURRENTLY` still works, and it releases automatically if the holder crashes. ([f12f5f1bd4](https://github.com/dropseed/plain/commit/f12f5f1bd4))
26
+ - **New `plain.postgres.databases` module** for cluster-level database management — `create_database` (with `TEMPLATE` support), `drop_database`, `database_exists`, `list_databases`, database comments, connection counting/termination, and a `maintenance_cursor` helper. It's a development/test capability (it needs `CREATEDB` and a connection to the `postgres` maintenance database) and is what powers plain-dev's managed development databases; test database creation now uses it too. ([0a747b8933](https://github.com/dropseed/plain/commit/0a747b8933))
27
+ - Fixed a stale `rollback_exc` on reused connections: entering an outermost atomic block now clears it, and a rollback caused by an exception inside a non-savepoint block records that exception — so a broken-transaction error chains from the real cause instead of a stale or absent one. ([350f34b291](https://github.com/dropseed/plain/commit/350f34b291))
28
+ - Convergence terminology changed from "fix" to "correction" in CLI output and internals — `plain postgres converge` now reports "N corrections to apply" and `plain postgres schema` marks drift as `(auto-correct)`. ([08da420a9b](https://github.com/dropseed/plain/commit/08da420a9b))
29
+ - The unconfigured-database error now points at `PLAIN_POSTGRES_URL` (or `POSTGRES_URL` in settings) instead of `DATABASE_URL`-first wording. ([8b7aa38e95](https://github.com/dropseed/plain/commit/8b7aa38e95))
30
+
31
+ ### Upgrade instructions
32
+
33
+ - No changes required. If your migration role's Postgres server sets `idle_session_timeout`, note the schema lock holds an idle session for the duration of a schema command — exempt that role, or the lock can be lost mid-run. If `POSTGRES_URL` points at a transaction-mode pooler (pgbouncer), set `POSTGRES_MANAGEMENT_URL` to a direct connection so the session-level lock works.
34
+
3
35
  ## [0.111.2](https://github.com/dropseed/plain/releases/plain-postgres@0.111.2) (2026-07-15)
4
36
 
5
37
  ### What's changed
@@ -1,16 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: plain.postgres
3
- Version: 0.111.2
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.**
@@ -788,6 +775,26 @@ Environment overrides: every setting accepts `PLAIN_POSTGRES_*` env vars, so you
788
775
  PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT=30s plain migrations apply
789
776
  ```
790
777
 
778
+ ### Schema lock
779
+
780
+ Schema-changing commands — `plain postgres sync`, `plain migrations apply`, `plain postgres converge`, and `plain postgres drop-unknown-tables` — serialize on a single session-level advisory lock, so two deploy processes running at once (a retried migrate job, overlapping release phases) can't interleave schema changes. You don't have to do anything to get this.
781
+
782
+ A second process warns and waits, retrying until the holder finishes:
783
+
784
+ ```python
785
+ # app/settings.py — defaults shown (waits up to an hour total)
786
+ POSTGRES_SCHEMA_LOCK_RETRY_INTERVAL = 5.0
787
+ POSTGRES_SCHEMA_LOCK_MAX_RETRIES = 720
788
+ ```
789
+
790
+ The wait is generous by default because a legitimate holder can be mid index build. If the budget runs out, the command fails with the holder's `pid` so you can see what's blocking. A crashed holder is not a problem — the lock releases automatically when its database session closes.
791
+
792
+ The lock is held on its own connection, separate from the one running DDL, so non-transactional operations (`CREATE INDEX CONCURRENTLY`, `VALIDATE CONSTRAINT`) work normally while it's held. Session-level locks don't survive transaction-mode poolers like pgbouncer — if your `POSTGRES_URL` points at one, set [`POSTGRES_MANAGEMENT_URL`](#bypassing-a-connection-pooler-for-management-operations) to a direct connection.
793
+
794
+ The lock connection sits idle while your DDL runs, so it enables TCP keepalives to survive NAT and load-balancer idle timeouts. A server-side `idle_session_timeout` would still kill it (releasing the lock mid-run) — don't set one for the role that runs migrations. `sync` re-verifies the lock between its migrate and converge phases and stops with a clear error if the session died.
795
+
796
+ To see the lock live: `SELECT * FROM pg_locks WHERE locktype = 'advisory' AND objid = 1047265496`.
797
+
791
798
  ## Fields
792
799
 
793
800
  You can use many field types for different data:
@@ -921,6 +928,7 @@ Values are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with a key derived
921
928
 
922
929
  - **No lookups** — encrypted values are non-deterministic (same plaintext produces different ciphertext each time), so filtering on encrypted fields doesn't work. Only `isnull` lookups are supported.
923
930
  - **No indexes or constraints** — encrypted fields cannot be used in indexes or unique constraints. Preflight checks will catch this.
931
+ - **Only `default=""`** — on `EncryptedTextField` (paired with `required=False`), the empty string is stored as plaintext `''`, so it's the one value expressible as a column `DEFAULT` (declare it to add the field to a populated table). Any other default would need ciphertext, which is non-deterministic. `EncryptedJSONField` accepts no default at all — even `{}` serializes to text that would need ciphertext; use `allow_null=True`.
924
932
 
925
933
  **Key rotation:**
926
934
 
@@ -1472,9 +1480,9 @@ See [`default_settings.py`](./default_settings.py) for more details.
1472
1480
 
1473
1481
  Add the field to your model class, then run `plain migrations create` to create a migration.
1474
1482
 
1475
- If the field is required (no `default=` and not `allow_null=True`), the autodetector refuses to generate the migration, since there's no value to seed existing rows with. You have two options:
1483
+ If the field has no `default=` and isn't `allow_null=True`, the autodetector refuses to generate the migration, since there's no value to seed existing rows with — `required=False` alone is not enough (it only affects Python-side validation, not the column). You have two options:
1476
1484
 
1477
- 1. Declare a `default=` on the field so the new column has a value for existing rows.
1485
+ 1. Declare a `default=` on the field so the new column has a value for existing rows. For an optional string field the idiom is `required=False, default=""` (for `BinaryField`, `default=b""`).
1478
1486
  2. Add the field with `allow_null=True`, scaffold a data migration with `plain migrations create --empty --name backfill_<field>` to populate existing rows, then remove `allow_null=True` from the field — convergence applies `NOT NULL` on the next `postgres sync`.
1479
1487
 
1480
1488
  #### How do I make an existing column `NOT NULL`?
@@ -79,7 +79,7 @@ Run `uv run plain docs postgres` for full patterns with code examples.
79
79
  - Indexes: `{table}_{column(s)}_idx`
80
80
  - Constraints: `{table}_{column(s)}_{type}` (e.g., `_unique`, `_check`)
81
81
  - Choose `on_delete` deliberately: CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional references
82
- - No `allow_null` on string fields — use `default=""`
82
+ - No `allow_null` on string fields — use `default=""`. Optional string fields are `required=False, default=""` — the declared default is what lets the column be added to a populated table (`required=False` alone only affects Python-side validation)
83
83
 
84
84
  Run `uv run plain docs postgres` for full patterns with code examples.
85
85
 
@@ -5,7 +5,7 @@ import sys
5
5
  import click
6
6
 
7
7
  from ..convergence import execute_plan, plan_convergence
8
- from .decorators import database_management_command
8
+ from .decorators import cli_schema_lock, database_management_command
9
9
 
10
10
 
11
11
  @click.command()
@@ -17,16 +17,16 @@ from .decorators import database_management_command
17
17
  )
18
18
  @database_management_command
19
19
  def converge(yes: bool) -> None:
20
- """Fix schema mismatches between models and the database.
20
+ """Correct schema mismatches between models and the database.
21
21
 
22
- Detects and fixes:
22
+ Detects and corrects:
23
23
  - Missing indexes (using CONCURRENTLY)
24
24
  - Missing constraints (check, unique)
25
25
  - NOT VALID constraints needing validation
26
26
  - Undeclared indexes and constraints (dropped automatically)
27
27
 
28
- Each fix is applied and committed independently so partial
29
- failures don't block subsequent fixes.
28
+ Each correction is applied and committed independently so partial
29
+ failures don't block subsequent corrections.
30
30
  """
31
31
  plan = plan_convergence()
32
32
  items = plan.executable()
@@ -34,7 +34,7 @@ def converge(yes: bool) -> None:
34
34
 
35
35
  if items:
36
36
  click.secho(
37
- f"{len(items)} fix{'es' if len(items) != 1 else ''} to apply:\n",
37
+ f"{len(items)} correction{'s' if len(items) != 1 else ''} to apply:\n",
38
38
  bold=True,
39
39
  )
40
40
  for item in items:
@@ -48,7 +48,27 @@ def converge(yes: bool) -> None:
48
48
 
49
49
  click.echo()
50
50
 
51
- result = execute_plan(items)
51
+ with cli_schema_lock():
52
+ # Re-plan under the lock — another converge or sync may have
53
+ # already applied (or changed) these corrections while we waited.
54
+ confirmed = {item.describe() for item in items}
55
+ plan = plan_convergence()
56
+ items = plan.executable()
57
+
58
+ new_items = [item for item in items if item.describe() not in confirmed]
59
+ if new_items and not yes:
60
+ # The plan grew while we waited at the prompt or for the
61
+ # lock — don't execute work the operator never approved.
62
+ click.secho(
63
+ "The schema changed while waiting — new corrections not in the confirmed plan:",
64
+ fg="red",
65
+ bold=True,
66
+ )
67
+ for item in new_items:
68
+ click.secho(f" {item.describe()}", fg="red")
69
+ raise click.ClickException("Re-run to review the updated plan.")
70
+
71
+ result = execute_plan(items)
52
72
 
53
73
  for r in result.results:
54
74
  if r.ok:
@@ -73,4 +93,4 @@ def converge(yes: bool) -> None:
73
93
  if not success:
74
94
  sys.exit(1)
75
95
  elif not items:
76
- click.secho("Schema is converged — nothing to fix.", fg="green")
96
+ click.secho("Schema is converged — nothing to correct.", fg="green")
@@ -16,7 +16,7 @@ from ..database_url import postgres_cli_args, postgres_cli_env
16
16
  from ..db import get_connection
17
17
  from ..dialect import quote_name
18
18
  from .converge import converge
19
- from .decorators import database_management_command
19
+ from .decorators import cli_schema_lock, database_management_command
20
20
  from .diagnose import diagnose
21
21
  from .schema import schema
22
22
  from .sync import sync
@@ -25,7 +25,7 @@ from .sync import sync
25
25
  @register_cli("postgres")
26
26
  @click.group()
27
27
  def cli() -> None:
28
- """Postgres operations"""
28
+ """Work inside the connected database — schema, health, shell."""
29
29
 
30
30
 
31
31
  cli.add_command(converge)
@@ -126,13 +126,27 @@ def drop_unknown_tables(yes: bool) -> None:
126
126
  if not click.confirm(f"Drop {tables_label} (CASCADE)? This cannot be undone."):
127
127
  return
128
128
 
129
- with conn.cursor() as cursor:
130
- for table in unknown_tables:
131
- click.echo(f" Dropping {table}...", nl=False)
132
- cursor.execute(f"DROP TABLE IF EXISTS {quote_name(table)} CASCADE")
133
- click.echo(" OK")
134
-
135
- click.secho(f"✓ Dropped {tables_label}.", fg="green")
129
+ with cli_schema_lock():
130
+ # Re-check under the lock — while we sat at the prompt or waited for
131
+ # the lock, another process may have claimed one of these names for a
132
+ # real table (e.g. a migration creating a model table). Only drop
133
+ # what was unknown at confirm time AND is still unknown now.
134
+ still_unknown = set(get_unknown_tables(conn))
135
+ dropped_count = 0
136
+ with conn.cursor() as cursor:
137
+ for table in unknown_tables:
138
+ if table not in still_unknown:
139
+ click.secho(f" Skipping {table} — no longer unknown.", fg="yellow")
140
+ continue
141
+ click.echo(f" Dropping {table}...", nl=False)
142
+ cursor.execute(f"DROP TABLE IF EXISTS {quote_name(table)} CASCADE")
143
+ click.echo(" OK")
144
+ dropped_count += 1
145
+
146
+ click.secho(
147
+ f"✓ Dropped {dropped_count} table{'s' if dropped_count != 1 else ''}.",
148
+ fg="green",
149
+ )
136
150
 
137
151
 
138
152
  @cli.command()
@@ -1,10 +1,26 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import functools
4
- from collections.abc import Callable
4
+ from collections.abc import Callable, Iterator
5
+ from contextlib import contextmanager
5
6
  from typing import Any
6
7
 
8
+ import click
9
+
7
10
  from ..db import use_management_connection
11
+ from ..schema_lock import SchemaLockLost, SchemaLockTimeout, schema_lock
12
+
13
+
14
+ @contextmanager
15
+ def cli_schema_lock() -> Iterator[Callable[[], None]]:
16
+ """`schema_lock()` for CLI commands: a timeout or lost lock surfaces as a
17
+ clean one-line error instead of a traceback burying it. Yields the lock's
18
+ verify callable for multi-phase commands to check between phases."""
19
+ try:
20
+ with schema_lock() as verify:
21
+ yield verify
22
+ except (SchemaLockTimeout, SchemaLockLost) as e:
23
+ raise click.ClickException(str(e)) from e
8
24
 
9
25
 
10
26
  def database_management_command[F: Callable[..., Any]](f: F) -> F:
@@ -27,7 +27,7 @@ from ..migrations.recorder import MigrationRecorder
27
27
  from ..migrations.state import ModelState, ProjectState
28
28
  from ..migrations.writer import MigrationWriter
29
29
  from ..registry import models_registry
30
- from .decorators import database_management_command
30
+ from .decorators import cli_schema_lock, database_management_command
31
31
 
32
32
  if TYPE_CHECKING:
33
33
  from ..connection import DatabaseConnection
@@ -511,68 +511,84 @@ def apply(
511
511
  click.secho(package_label, dim=True)
512
512
  click.echo() # Add newline after package
513
513
 
514
- pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
515
-
516
514
  if migration_plan:
517
- # Determine whether to use atomic batch
518
- use_atomic_batch = False
519
- atomic_batch_message = None
520
- if len(migration_plan) > 1:
521
- # Check if all migrations support atomic
522
- non_atomic_migrations = [m for m in migration_plan if not m.atomic]
523
-
524
- if atomic_batch is True:
525
- # User explicitly requested atomic batch
526
- if non_atomic_migrations:
527
- names = ", ".join(
528
- f"{m.package_label}.{m.name}" for m in non_atomic_migrations[:3]
529
- )
530
- if len(non_atomic_migrations) > 3:
531
- names += f", and {len(non_atomic_migrations) - 3} more"
532
- raise click.UsageError(
533
- f"--atomic-batch requested but these migrations have atomic=False: {names}"
534
- )
535
- use_atomic_batch = True
536
- atomic_batch_message = (
537
- f"Running {len(migration_plan)} migrations in atomic batch"
538
- )
539
- elif atomic_batch is False:
540
- # User explicitly disabled atomic batch
541
- use_atomic_batch = False
542
- if len(migration_plan) > 1:
543
- atomic_batch_message = (
544
- f"Running {len(migration_plan)} migrations separately"
515
+ with cli_schema_lock():
516
+ # Re-plan under the lock — another process may have applied some
517
+ # or all of these migrations while we waited for it.
518
+ executor = MigrationExecutor(get_connection(), migration_progress_callback)
519
+ migration_plan = executor.migration_plan(targets)
520
+ if not migration_plan:
521
+ if not quiet:
522
+ click.echo(
523
+ "No migrations to apply (another process already applied them)."
545
524
  )
546
- else:
547
- # Auto-detect (atomic_batch is None)
548
- # Use atomic batch by default
549
- if not non_atomic_migrations:
525
+ return
526
+
527
+ # Determine whether to use atomic batch
528
+ use_atomic_batch = False
529
+ atomic_batch_message = None
530
+ if len(migration_plan) > 1:
531
+ # Check if all migrations support atomic
532
+ non_atomic_migrations = [m for m in migration_plan if not m.atomic]
533
+
534
+ if atomic_batch is True:
535
+ # User explicitly requested atomic batch
536
+ if non_atomic_migrations:
537
+ names = ", ".join(
538
+ f"{m.package_label}.{m.name}"
539
+ for m in non_atomic_migrations[:3]
540
+ )
541
+ if len(non_atomic_migrations) > 3:
542
+ names += f", and {len(non_atomic_migrations) - 3} more"
543
+ raise click.UsageError(
544
+ f"--atomic-batch requested but these migrations have atomic=False: {names}"
545
+ )
550
546
  use_atomic_batch = True
551
547
  atomic_batch_message = (
552
548
  f"Running {len(migration_plan)} migrations in atomic batch"
553
549
  )
554
- else:
550
+ elif atomic_batch is False:
551
+ # User explicitly disabled atomic batch
555
552
  use_atomic_batch = False
556
553
  if len(migration_plan) > 1:
557
- atomic_batch_message = f"Running {len(migration_plan)} migrations separately (some have atomic=False)"
558
-
559
- if not quiet:
560
- click.echo() # Add blank line before applying
554
+ atomic_batch_message = (
555
+ f"Running {len(migration_plan)} migrations separately"
556
+ )
557
+ else:
558
+ # Auto-detect (atomic_batch is None)
559
+ # Use atomic batch by default
560
+ if not non_atomic_migrations:
561
+ use_atomic_batch = True
562
+ atomic_batch_message = (
563
+ f"Running {len(migration_plan)} migrations in atomic batch"
564
+ )
565
+ else:
566
+ use_atomic_batch = False
567
+ if len(migration_plan) > 1:
568
+ atomic_batch_message = f"Running {len(migration_plan)} migrations separately (some have atomic=False)"
569
+
570
+ if not quiet:
571
+ click.echo() # Add blank line before applying
572
+
573
+ if not quiet:
574
+ if atomic_batch_message:
575
+ click.secho(
576
+ f"Applying migrations ({atomic_batch_message.lower()}):",
577
+ bold=True,
578
+ )
579
+ else:
580
+ click.secho("Applying migrations:", bold=True)
561
581
 
562
- if not quiet:
563
- if atomic_batch_message:
564
- click.secho(
565
- f"Applying migrations ({atomic_batch_message.lower()}):", bold=True
566
- )
567
- else:
568
- click.secho("Applying migrations:", bold=True)
569
- post_migrate_state = executor.migrate(
570
- targets,
571
- plan=migration_plan,
572
- state=pre_migrate_state.clone(),
573
- fake=fake,
574
- atomic_batch=use_atomic_batch,
575
- )
582
+ pre_migrate_state = executor._create_project_state(
583
+ with_applied_migrations=True
584
+ )
585
+ post_migrate_state = executor.migrate(
586
+ targets,
587
+ plan=migration_plan,
588
+ state=pre_migrate_state.clone(),
589
+ fake=fake,
590
+ atomic_batch=use_atomic_batch,
591
+ )
576
592
  # post_migrate signals have access to all models. Ensure that all models
577
593
  # are reloaded in case any are delayed.
578
594
  post_migrate_state.clear_delayed_models_cache()
@@ -6,7 +6,7 @@ import sys
6
6
  import click
7
7
 
8
8
  from ..convergence.analysis import ModelAnalysis, analyze_model
9
- from ..convergence.planning import can_auto_fix
9
+ from ..convergence.planning import can_auto_correct
10
10
  from ..db import get_connection
11
11
  from ..introspection import MANAGED_CONSTRAINT_TYPES, get_unknown_tables
12
12
  from ..registry import models_registry
@@ -21,8 +21,8 @@ def _err(msg: str) -> None:
21
21
  click.secho(f" ✗ {msg}", fg="red")
22
22
 
23
23
 
24
- def _fixable(msg: str) -> None:
25
- click.secho(f" ~ {msg} (auto-fix)", fg="yellow")
24
+ def _correctable(msg: str) -> None:
25
+ click.secho(f" ~ {msg} (auto-correct)", fg="yellow")
26
26
 
27
27
 
28
28
  def _unmanaged(type_label: str) -> None:
@@ -57,8 +57,8 @@ def _render_model(analysis: ModelAnalysis) -> None:
57
57
 
58
58
  click.echo(f" {col_display:30s} {' '.join(type_parts)}", nl=False)
59
59
 
60
- if col.issue and col.drifts and all(can_auto_fix(d) for d in col.drifts):
61
- _fixable(col.issue)
60
+ if col.issue and col.drifts and all(can_auto_correct(d) for d in col.drifts):
61
+ _correctable(col.issue)
62
62
  elif col.issue:
63
63
  _err(col.issue)
64
64
  else:
@@ -75,8 +75,8 @@ def _render_model(analysis: ModelAnalysis) -> None:
75
75
 
76
76
  if idx.access_method:
77
77
  _unmanaged(idx.access_method)
78
- elif idx.issue and idx.drift and can_auto_fix(idx.drift):
79
- _fixable(idx.issue)
78
+ elif idx.issue and idx.drift and can_auto_correct(idx.drift):
79
+ _correctable(idx.issue)
80
80
  elif idx.issue:
81
81
  _err(idx.issue)
82
82
  else:
@@ -98,8 +98,8 @@ def _render_model(analysis: ModelAnalysis) -> None:
98
98
 
99
99
  if con.constraint_type not in MANAGED_CONSTRAINT_TYPES:
100
100
  _unmanaged(con.constraint_type.label)
101
- elif con.issue and con.drift and can_auto_fix(con.drift):
102
- _fixable(con.issue)
101
+ elif con.issue and con.drift and can_auto_correct(con.drift):
102
+ _correctable(con.issue)
103
103
  elif con.issue:
104
104
  _err(con.issue)
105
105
  else:
@@ -7,7 +7,8 @@ import click
7
7
  from plain.runtime import settings
8
8
 
9
9
  from ..convergence import execute_plan, plan_convergence
10
- from .decorators import database_management_command
10
+ from ..db import get_connection
11
+ from .decorators import cli_schema_lock, database_management_command
11
12
 
12
13
 
13
14
  @click.command()
@@ -33,8 +34,12 @@ def sync(check: bool) -> None:
33
34
  if settings.DEBUG:
34
35
  _create_migrations()
35
36
 
36
- _migrate()
37
- _converge()
37
+ with cli_schema_lock() as verify_lock:
38
+ _migrate()
39
+ # If the lock session died during migrations, another process may
40
+ # already be converging — stop instead of interleaving with it.
41
+ verify_lock()
42
+ _converge()
38
43
 
39
44
 
40
45
  def _check() -> None:
@@ -108,7 +113,6 @@ def _create_migrations() -> None:
108
113
 
109
114
 
110
115
  def _migrate() -> None:
111
- from ..db import get_connection
112
116
  from ..migrations.executor import MigrationExecutor
113
117
 
114
118
  click.secho("Applying migrations...", bold=True)
@@ -5,8 +5,8 @@ This is not a public import API. End users drive convergence through those CLI
5
5
  commands, not by importing from here, and it is intentionally absent from the
6
6
  top-level ``plain.postgres`` surface. The names re-exported below exist for the
7
7
  rest of ``plain.postgres`` (the CLI) to use; the drift types, ``DriftKind``,
8
- the ``Fix`` classes, and the ``Status`` records are convergence-internal and
9
- live in the ``.analysis`` / ``.fixes`` / ``.planning`` submodules.
8
+ the ``Correction`` classes, and the ``Status`` records are convergence-internal and
9
+ live in the ``.analysis`` / ``.corrections`` / ``.planning`` submodules.
10
10
  """
11
11
 
12
12
  from .analysis import (
@@ -17,9 +17,9 @@ from .analysis import (
17
17
  from .planning import (
18
18
  ConvergencePlan,
19
19
  ConvergenceResult,
20
- FixResult,
20
+ CorrectionResult,
21
21
  PlanItem,
22
- can_auto_fix,
22
+ can_auto_correct,
23
23
  execute_plan,
24
24
  plan_convergence,
25
25
  plan_model_convergence,
@@ -28,12 +28,12 @@ from .planning import (
28
28
  __all__ = [
29
29
  "ConvergencePlan",
30
30
  "ConvergenceResult",
31
- "FixResult",
31
+ "CorrectionResult",
32
32
  "ModelAnalysis",
33
33
  "PlanItem",
34
34
  "ReadOnlyConnectionError",
35
35
  "analyze_model",
36
- "can_auto_fix",
36
+ "can_auto_correct",
37
37
  "execute_plan",
38
38
  "plan_convergence",
39
39
  "plan_model_convergence",
@@ -220,7 +220,7 @@ class ColumnShouldBeNotNullDrift:
220
220
 
221
221
  table: str
222
222
  column: str
223
- has_null_rows: bool = False # existing NULL rows block an auto-fix
223
+ has_null_rows: bool = False # existing NULL rows block an auto-correction
224
224
 
225
225
  def describe(self) -> str:
226
226
  if self.has_null_rows:
@@ -1166,7 +1166,7 @@ def _compare_check_constraints(
1166
1166
  )
1167
1167
 
1168
1168
  # Build set of framework-owned temp NOT NULL check names so leftover
1169
- # artifacts from a partially-completed SetNotNullFix are silently
1169
+ # artifacts from a partially-completed SetNotNullCorrection are silently
1170
1170
  # ignored rather than surfaced as undeclared user constraints.
1171
1171
  internal_checks = {
1172
1172
  generate_notnull_check_name(table, f.column)
@@ -1326,7 +1326,7 @@ def _compare_foreign_keys(
1326
1326
  def generate_notnull_check_name(table: str, column: str) -> str:
1327
1327
  """Generate a hashed name for the temporary NOT NULL check constraint.
1328
1328
 
1329
- Used by SetNotNullFix for the CHECK NOT VALID → VALIDATE → SET NOT NULL
1329
+ Used by SetNotNullCorrection for the CHECK NOT VALID → VALIDATE → SET NOT NULL
1330
1330
  pattern, and by analysis to recognize (and ignore) leftover temp checks.
1331
1331
  """
1332
1332
  from ..utils import generate_identifier_name