plain.postgres 0.111.2__tar.gz → 0.112.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 (230) hide show
  1. plain_postgres-0.111.2/plain/postgres/README.md → plain_postgres-0.112.0/PKG-INFO +33 -0
  2. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/CHANGELOG.md +14 -0
  3. plain_postgres-0.111.2/PKG-INFO → plain_postgres-0.112.0/plain/postgres/README.md +20 -13
  4. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/cli/converge.py +28 -8
  5. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/cli/core.py +23 -9
  6. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/cli/decorators.py +17 -1
  7. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/cli/migrations.py +70 -54
  8. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/cli/schema.py +9 -9
  9. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/cli/sync.py +7 -3
  10. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/convergence/__init__.py +6 -6
  11. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/convergence/analysis.py +3 -3
  12. plain_postgres-0.111.2/plain/postgres/convergence/fixes.py → plain_postgres-0.112.0/plain/postgres/convergence/corrections.py +18 -18
  13. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/convergence/planning.py +67 -57
  14. plain_postgres-0.112.0/plain/postgres/databases.py +190 -0
  15. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/default_settings.py +7 -0
  16. plain_postgres-0.112.0/plain/postgres/schema_lock.py +194 -0
  17. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/sources.py +2 -2
  18. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/sql/compiler.py +21 -20
  19. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/test/database.py +36 -56
  20. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/transaction.py +12 -1
  21. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/pyproject.toml +1 -1
  22. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/nullability.py +1 -1
  23. plain_postgres-0.112.0/tests/internal/test_apply_replan.py +56 -0
  24. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_autodetector_not_null_errors.py +1 -1
  25. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_convergence.py +112 -70
  26. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_convergence_constraints.py +81 -73
  27. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_convergence_defaults.py +102 -71
  28. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_convergence_fk.py +65 -40
  29. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_convergence_indexes.py +95 -64
  30. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_convergence_nullability.py +75 -55
  31. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_convergence_storage_parameters.py +15 -15
  32. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_convergence_timeouts.py +51 -47
  33. plain_postgres-0.112.0/tests/internal/test_databases_not_on_runtime_path.py +118 -0
  34. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_db_expression_defaults.py +1 -1
  35. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_literal_default_persistence.py +1 -1
  36. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_otel_metrics.py +21 -0
  37. plain_postgres-0.112.0/tests/internal/test_rollback_exc_attribution.py +85 -0
  38. plain_postgres-0.112.0/tests/internal/test_schema_lock.py +138 -0
  39. plain_postgres-0.112.0/tests/public/test_databases.py +182 -0
  40. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/.gitignore +0 -0
  41. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/CLAUDE.md +0 -0
  42. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/LICENSE +0 -0
  43. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/README.md +0 -0
  44. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/__init__.py +0 -0
  45. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/adapters.py +0 -0
  46. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  47. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  48. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/aggregates.py +0 -0
  49. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/base.py +0 -0
  50. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/cli/__init__.py +0 -0
  51. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/cli/diagnose.py +0 -0
  52. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/config.py +0 -0
  53. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/connection.py +0 -0
  54. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/constants.py +0 -0
  55. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/constraints.py +0 -0
  56. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/database_url.py +0 -0
  57. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/db.py +0 -0
  58. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/ddl.py +0 -0
  59. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/deletion.py +0 -0
  60. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/dialect.py +0 -0
  61. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/entrypoints.py +0 -0
  62. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/enums.py +0 -0
  63. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/exceptions.py +0 -0
  64. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/expressions.py +0 -0
  65. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/__init__.py +0 -0
  66. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/base.py +0 -0
  67. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/binary.py +0 -0
  68. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/boolean.py +0 -0
  69. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/duration.py +0 -0
  70. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/encrypted.py +0 -0
  71. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/json.py +0 -0
  72. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/mixins.py +0 -0
  73. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/network.py +0 -0
  74. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/numeric.py +0 -0
  75. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/primary_key.py +0 -0
  76. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/related.py +0 -0
  77. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/related_descriptors.py +0 -0
  78. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/related_lookups.py +0 -0
  79. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/related_managers.py +0 -0
  80. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  81. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/reverse_related.py +0 -0
  82. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/temporal.py +0 -0
  83. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/text.py +0 -0
  84. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/timezones.py +0 -0
  85. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/fields/uuid.py +0 -0
  86. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/forms.py +0 -0
  87. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/functions/__init__.py +0 -0
  88. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/functions/comparison.py +0 -0
  89. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/functions/datetime.py +0 -0
  90. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/functions/math.py +0 -0
  91. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/functions/mixins.py +0 -0
  92. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/functions/random.py +0 -0
  93. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/functions/text.py +0 -0
  94. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/functions/uuid.py +0 -0
  95. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/functions/window.py +0 -0
  96. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/indexes.py +0 -0
  97. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/introspection/__init__.py +0 -0
  98. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/introspection/health/__init__.py +0 -0
  99. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/introspection/health/checks_cumulative.py +0 -0
  100. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/introspection/health/checks_snapshot.py +0 -0
  101. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/introspection/health/checks_structural.py +0 -0
  102. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/introspection/health/context.py +0 -0
  103. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/introspection/health/helpers.py +0 -0
  104. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/introspection/health/ownership.py +0 -0
  105. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/introspection/health/runner.py +0 -0
  106. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/introspection/health/types.py +0 -0
  107. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/introspection/schema.py +0 -0
  108. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/lookups.py +0 -0
  109. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/meta.py +0 -0
  110. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/middleware.py +0 -0
  111. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/__init__.py +0 -0
  112. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/autodetector.py +0 -0
  113. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/exceptions.py +0 -0
  114. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/executor.py +0 -0
  115. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/graph.py +0 -0
  116. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/loader.py +0 -0
  117. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/migration.py +0 -0
  118. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  119. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/operations/base.py +0 -0
  120. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/operations/fields.py +0 -0
  121. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/operations/models.py +0 -0
  122. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/operations/special.py +0 -0
  123. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/optimizer.py +0 -0
  124. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/questioner.py +0 -0
  125. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/recorder.py +0 -0
  126. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/serializer.py +0 -0
  127. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/state.py +0 -0
  128. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/utils.py +0 -0
  129. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/migrations/writer.py +0 -0
  130. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/options.py +0 -0
  131. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/otel.py +0 -0
  132. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/preflight/__init__.py +0 -0
  133. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/preflight/database.py +0 -0
  134. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/preflight/indexes.py +0 -0
  135. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/preflight/models.py +0 -0
  136. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/query.py +0 -0
  137. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/query_utils.py +0 -0
  138. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/registry.py +0 -0
  139. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/schema.py +0 -0
  140. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/sql/__init__.py +0 -0
  141. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/sql/constants.py +0 -0
  142. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/sql/datastructures.py +0 -0
  143. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/sql/query.py +0 -0
  144. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/sql/where.py +0 -0
  145. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/test/__init__.py +0 -0
  146. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/test/pytest.py +0 -0
  147. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/types.py +0 -0
  148. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/types.pyi +0 -0
  149. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/plain/postgres/utils.py +0 -0
  150. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/forms.py +0 -0
  151. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  152. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  153. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  154. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  155. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  156. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  157. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  158. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  159. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  160. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  161. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0011_defaultsexample.py +0 -0
  162. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0012_iterationexample.py +0 -0
  163. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +0 -0
  164. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +0 -0
  165. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0015_dbdefaultsexample.py +0 -0
  166. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0016_formsexample.py +0 -0
  167. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0017_random_string_token.py +0 -0
  168. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/0018_storageparametersexample.py +0 -0
  169. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/migrations/__init__.py +0 -0
  170. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/__init__.py +0 -0
  171. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/constraints.py +0 -0
  172. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/defaults.py +0 -0
  173. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/delete.py +0 -0
  174. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/encrypted.py +0 -0
  175. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/forms.py +0 -0
  176. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/indexes.py +0 -0
  177. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/iteration.py +0 -0
  178. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/mixins.py +0 -0
  179. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/querysets.py +0 -0
  180. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/relationships.py +0 -0
  181. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/storage_parameters.py +0 -0
  182. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/trees.py +0 -0
  183. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/models/unregistered.py +0 -0
  184. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/urls.py +0 -0
  185. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/examples/views.py +0 -0
  186. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/settings.py +0 -0
  187. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/app/urls.py +0 -0
  188. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/conftest.py +0 -0
  189. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/conftest_convergence.py +0 -0
  190. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_autodetector_type_change.py +0 -0
  191. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_connection_isolation.py +0 -0
  192. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_connection_lifecycle.py +0 -0
  193. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_connection_pool.py +0 -0
  194. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_connection_self_heal.py +0 -0
  195. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_constraint_violation_error.py +0 -0
  196. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_diagnose.py +0 -0
  197. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_executor_connection_hook.py +0 -0
  198. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_fk_characterization.py +0 -0
  199. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_health.py +0 -0
  200. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_introspection.py +0 -0
  201. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_management_connection.py +0 -0
  202. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_migration_executor.py +0 -0
  203. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_no_callable_defaults.py +0 -0
  204. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_preflight_duplicate_indexes.py +0 -0
  205. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_preflight_fk_composite_hint.py +0 -0
  206. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_preflight_fk_coverage.py +0 -0
  207. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_schema_normalize_type.py +0 -0
  208. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/internal/test_schema_timeouts.py +0 -0
  209. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_create_update.py +0 -0
  210. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_database_url.py +0 -0
  211. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_deferred_loading.py +0 -0
  212. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_delete_behaviors.py +0 -0
  213. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_encrypted_fields.py +0 -0
  214. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_exceptions.py +0 -0
  215. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_field_defaults.py +0 -0
  216. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_functions_uuid.py +0 -0
  217. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_integrity_error_mapping.py +0 -0
  218. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_iterator.py +0 -0
  219. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_m2m.py +0 -0
  220. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_manager_assignment.py +0 -0
  221. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_manual_pk.py +0 -0
  222. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_mixins.py +0 -0
  223. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_modelform_roundtrip.py +0 -0
  224. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_queryset_repr.py +0 -0
  225. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_queryset_slicing.py +0 -0
  226. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_random_string_field.py +0 -0
  227. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_raw_query.py +0 -0
  228. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_read_only_transactions.py +0 -0
  229. {plain_postgres-0.111.2 → plain_postgres-0.112.0}/tests/public/test_related.py +0 -0
  230. {plain_postgres-0.111.2 → plain_postgres-0.112.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.112.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:
@@ -1,5 +1,19 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.112.0](https://github.com/dropseed/plain/releases/plain-postgres@0.112.0) (2026-07-21)
4
+
5
+ ### What's changed
6
+
7
+ - **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))
8
+ - **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))
9
+ - 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))
10
+ - 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))
11
+ - 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))
12
+
13
+ ### Upgrade instructions
14
+
15
+ - 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.
16
+
3
17
  ## [0.111.2](https://github.com/dropseed/plain/releases/plain-postgres@0.111.2) (2026-07-15)
4
18
 
5
19
  ### 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:
@@ -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,7 @@ 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 .decorators import cli_schema_lock, database_management_command
11
11
 
12
12
 
13
13
  @click.command()
@@ -33,8 +33,12 @@ def sync(check: bool) -> None:
33
33
  if settings.DEBUG:
34
34
  _create_migrations()
35
35
 
36
- _migrate()
37
- _converge()
36
+ with cli_schema_lock() as verify_lock:
37
+ _migrate()
38
+ # If the lock session died during migrations, another process may
39
+ # already be converging — stop instead of interleaving with it.
40
+ verify_lock()
41
+ _converge()
38
42
 
39
43
 
40
44
  def _check() -> None:
@@ -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