plain.postgres 0.94.2__tar.gz → 0.96.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 (197) hide show
  1. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/PKG-INFO +169 -41
  2. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/CHANGELOG.md +78 -0
  3. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/README.md +168 -40
  4. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/__init__.py +5 -7
  5. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +1 -1
  6. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/base.py +84 -38
  7. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/cli/migrations.py +39 -14
  8. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/cli/schema.py +1 -1
  9. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/cli/sync.py +18 -10
  10. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/connection.py +4 -1
  11. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/convergence/__init__.py +8 -0
  12. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/convergence/analysis.py +203 -27
  13. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/convergence/fixes.py +191 -22
  14. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/convergence/planning.py +30 -1
  15. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/ddl.py +24 -0
  16. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/default_settings.py +18 -0
  17. plain_postgres-0.96.0/plain/postgres/deletion.py +48 -0
  18. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/dialect.py +45 -0
  19. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/enums.py +9 -15
  20. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/expressions.py +4 -2
  21. plain_postgres-0.96.0/plain/postgres/fields/__init__.py +51 -0
  22. plain_postgres-0.96.0/plain/postgres/fields/base.py +867 -0
  23. plain_postgres-0.96.0/plain/postgres/fields/binary.py +65 -0
  24. plain_postgres-0.96.0/plain/postgres/fields/boolean.py +38 -0
  25. plain_postgres-0.96.0/plain/postgres/fields/duration.py +51 -0
  26. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/fields/encrypted.py +34 -43
  27. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/fields/json.py +16 -48
  28. plain_postgres-0.96.0/plain/postgres/fields/network.py +101 -0
  29. plain_postgres-0.96.0/plain/postgres/fields/numeric.py +278 -0
  30. plain_postgres-0.96.0/plain/postgres/fields/primary_key.py +86 -0
  31. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/fields/related.py +103 -87
  32. plain_postgres-0.96.0/plain/postgres/fields/related_lookups.py +103 -0
  33. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/fields/reverse_related.py +15 -21
  34. plain_postgres-0.96.0/plain/postgres/fields/temporal.py +381 -0
  35. plain_postgres-0.96.0/plain/postgres/fields/text.py +131 -0
  36. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/fields/timezones.py +27 -15
  37. plain_postgres-0.96.0/plain/postgres/fields/uuid.py +78 -0
  38. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/forms.py +52 -31
  39. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/functions/__init__.py +6 -0
  40. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/functions/datetime.py +13 -6
  41. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/functions/mixins.py +8 -2
  42. plain_postgres-0.96.0/plain/postgres/functions/random.py +51 -0
  43. plain_postgres-0.96.0/plain/postgres/functions/uuid.py +9 -0
  44. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/introspection/__init__.py +2 -0
  45. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/introspection/schema.py +251 -14
  46. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/meta.py +1 -1
  47. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/autodetector.py +109 -45
  48. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/exceptions.py +9 -0
  49. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/operations/fields.py +2 -23
  50. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/operations/special.py +12 -2
  51. plain_postgres-0.96.0/plain/postgres/migrations/questioner.py +109 -0
  52. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/recorder.py +1 -2
  53. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/serializer.py +13 -2
  54. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/state.py +0 -14
  55. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/query.py +25 -19
  56. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/query_utils.py +7 -13
  57. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/schema.py +122 -245
  58. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/sql/compiler.py +31 -93
  59. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/sql/query.py +15 -75
  60. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/sql/where.py +0 -24
  61. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/types.py +2 -0
  62. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/types.pyi +27 -165
  63. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/pyproject.toml +1 -1
  64. plain_postgres-0.96.0/tests/app/examples/forms.py +48 -0
  65. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +2 -29
  66. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +4 -13
  67. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +1 -1
  68. plain_postgres-0.96.0/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +81 -0
  69. plain_postgres-0.96.0/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +47 -0
  70. plain_postgres-0.96.0/tests/app/examples/migrations/0010_hideableitem.py +20 -0
  71. plain_postgres-0.96.0/tests/app/examples/migrations/0011_defaultsexample.py +28 -0
  72. plain_postgres-0.96.0/tests/app/examples/migrations/0012_iterationexample.py +21 -0
  73. plain_postgres-0.96.0/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +36 -0
  74. plain_postgres-0.96.0/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +64 -0
  75. plain_postgres-0.96.0/tests/app/examples/migrations/0015_dbdefaultsexample.py +22 -0
  76. plain_postgres-0.96.0/tests/app/examples/migrations/0016_formsexample.py +41 -0
  77. plain_postgres-0.96.0/tests/app/examples/migrations/0017_random_string_token.py +18 -0
  78. plain_postgres-0.96.0/tests/app/examples/models/__init__.py +18 -0
  79. plain_postgres-0.96.0/tests/app/examples/models/constraints.py +28 -0
  80. plain_postgres-0.96.0/tests/app/examples/models/defaults.py +50 -0
  81. plain_postgres-0.96.0/tests/app/examples/models/delete.py +188 -0
  82. plain_postgres-0.96.0/tests/app/examples/models/encrypted.py +16 -0
  83. plain_postgres-0.96.0/tests/app/examples/models/forms.py +35 -0
  84. plain_postgres-0.96.0/tests/app/examples/models/indexes.py +19 -0
  85. plain_postgres-0.96.0/tests/app/examples/models/iteration.py +20 -0
  86. plain_postgres-0.96.0/tests/app/examples/models/mixins.py +26 -0
  87. plain_postgres-0.96.0/tests/app/examples/models/nullability.py +17 -0
  88. plain_postgres-0.96.0/tests/app/examples/models/querysets.py +41 -0
  89. plain_postgres-0.96.0/tests/app/examples/models/relationships.py +44 -0
  90. plain_postgres-0.96.0/tests/app/examples/models/trees.py +17 -0
  91. plain_postgres-0.96.0/tests/app/examples/models/unregistered.py +7 -0
  92. plain_postgres-0.96.0/tests/app/examples/urls.py +36 -0
  93. plain_postgres-0.96.0/tests/app/examples/views.py +66 -0
  94. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/app/urls.py +3 -4
  95. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/conftest_convergence.py +30 -2
  96. plain_postgres-0.96.0/tests/test_autodetector_not_null_errors.py +211 -0
  97. plain_postgres-0.96.0/tests/test_autodetector_type_change.py +105 -0
  98. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_convergence.py +203 -165
  99. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_convergence_constraints.py +365 -241
  100. plain_postgres-0.96.0/tests/test_convergence_defaults.py +471 -0
  101. plain_postgres-0.96.0/tests/test_convergence_fk.py +501 -0
  102. plain_postgres-0.96.0/tests/test_convergence_indexes.py +600 -0
  103. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_convergence_nullability.py +85 -46
  104. plain_postgres-0.96.0/tests/test_convergence_timeouts.py +350 -0
  105. plain_postgres-0.96.0/tests/test_db_expression_defaults.py +495 -0
  106. plain_postgres-0.96.0/tests/test_delete_behaviors.py +561 -0
  107. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_encrypted_fields.py +1 -1
  108. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_exceptions.py +26 -17
  109. plain_postgres-0.96.0/tests/test_field_defaults.py +146 -0
  110. plain_postgres-0.96.0/tests/test_functions_uuid.py +31 -0
  111. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_introspection.py +177 -45
  112. plain_postgres-0.96.0/tests/test_iterator.py +101 -0
  113. plain_postgres-0.96.0/tests/test_literal_default_persistence.py +212 -0
  114. plain_postgres-0.96.0/tests/test_m2m.py +114 -0
  115. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_manager_assignment.py +1 -1
  116. plain_postgres-0.96.0/tests/test_mixins.py +19 -0
  117. plain_postgres-0.96.0/tests/test_modelform_roundtrip.py +295 -0
  118. plain_postgres-0.96.0/tests/test_no_callable_defaults.py +55 -0
  119. plain_postgres-0.96.0/tests/test_random_string_field.py +124 -0
  120. plain_postgres-0.96.0/tests/test_raw_query.py +44 -0
  121. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_read_only_transactions.py +16 -16
  122. plain_postgres-0.94.2/tests/test_related_descriptors.py → plain_postgres-0.96.0/tests/test_related.py +176 -5
  123. plain_postgres-0.96.0/tests/test_schema_timeouts.py +129 -0
  124. plain_postgres-0.94.2/plain/postgres/deletion.py +0 -476
  125. plain_postgres-0.94.2/plain/postgres/fields/__init__.py +0 -1937
  126. plain_postgres-0.94.2/plain/postgres/fields/related_lookups.py +0 -223
  127. plain_postgres-0.94.2/plain/postgres/migrations/questioner.py +0 -314
  128. plain_postgres-0.94.2/tests/app/examples/models.py +0 -223
  129. plain_postgres-0.94.2/tests/test_convergence_fk.py +0 -365
  130. plain_postgres-0.94.2/tests/test_convergence_indexes.py +0 -483
  131. plain_postgres-0.94.2/tests/test_delete_behaviors.py +0 -70
  132. plain_postgres-0.94.2/tests/test_iterator.py +0 -99
  133. plain_postgres-0.94.2/tests/test_models.py +0 -197
  134. plain_postgres-0.94.2/tests/test_related_manager_api.py +0 -154
  135. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/.gitignore +0 -0
  136. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/CLAUDE.md +0 -0
  137. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/LICENSE +0 -0
  138. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/README.md +0 -0
  139. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  140. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/aggregates.py +0 -0
  141. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/cli/__init__.py +0 -0
  142. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/cli/converge.py +0 -0
  143. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/cli/core.py +0 -0
  144. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/cli/diagnose.py +0 -0
  145. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/config.py +0 -0
  146. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/connections.py +0 -0
  147. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/constants.py +0 -0
  148. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/constraints.py +0 -0
  149. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/database_url.py +0 -0
  150. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/db.py +0 -0
  151. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/entrypoints.py +0 -0
  152. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/exceptions.py +0 -0
  153. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/fields/mixins.py +0 -0
  154. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/fields/related_descriptors.py +0 -0
  155. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/fields/related_managers.py +0 -0
  156. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  157. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/functions/comparison.py +0 -0
  158. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/functions/math.py +0 -0
  159. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/functions/text.py +0 -0
  160. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/functions/window.py +0 -0
  161. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/indexes.py +0 -0
  162. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/introspection/health.py +0 -0
  163. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/lookups.py +0 -0
  164. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/__init__.py +0 -0
  165. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/executor.py +0 -0
  166. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/graph.py +0 -0
  167. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/loader.py +0 -0
  168. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/migration.py +0 -0
  169. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  170. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/operations/base.py +0 -0
  171. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/operations/models.py +0 -0
  172. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/optimizer.py +0 -0
  173. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/utils.py +0 -0
  174. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/migrations/writer.py +0 -0
  175. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/options.py +0 -0
  176. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/otel.py +0 -0
  177. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/preflight.py +0 -0
  178. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/registry.py +0 -0
  179. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/sql/__init__.py +0 -0
  180. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/sql/constants.py +0 -0
  181. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/sql/datastructures.py +0 -0
  182. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/test/__init__.py +0 -0
  183. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/test/pytest.py +0 -0
  184. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/test/utils.py +0 -0
  185. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/transaction.py +0 -0
  186. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/plain/postgres/utils.py +0 -0
  187. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  188. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  189. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  190. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  191. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/app/examples/migrations/__init__.py +0 -0
  192. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/app/settings.py +0 -0
  193. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_connection_isolation.py +0 -0
  194. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_connection_lifecycle.py +0 -0
  195. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_database_url.py +0 -0
  196. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_migration_executor.py +0 -0
  197. {plain_postgres-0.94.2 → plain_postgres-0.96.0}/tests/test_schema_normalize_type.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.postgres
3
- Version: 0.94.2
3
+ Version: 0.96.0
4
4
  Summary: Model your data and store it in a database.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -19,7 +19,8 @@ Description-Content-Type: text/markdown
19
19
  - [Querying](#querying)
20
20
  - [Schema management](#schema-management)
21
21
  - [Syncing](#syncing)
22
- - [Migrations](#migrations)
22
+ - [Structural migrations](#structural-migrations)
23
+ - [Data migrations](#data-migrations)
23
24
  - [Convergence](#convergence)
24
25
  - [Fields](#fields)
25
26
  - [Relationships](#relationships)
@@ -47,7 +48,7 @@ class User(postgres.Model):
47
48
  email: str = types.EmailField()
48
49
  password = PasswordField()
49
50
  is_admin: bool = types.BooleanField(default=False)
50
- created_at: datetime = types.DateTimeField(auto_now_add=True)
51
+ created_at: datetime = types.DateTimeField(create_now=True)
51
52
 
52
53
  def __str__(self) -> str:
53
54
  return self.email
@@ -445,20 +446,49 @@ Read-only mode must be set outside a transaction — calling it inside `atomic()
445
446
 
446
447
  ## Schema management
447
448
 
448
- Your database schema is managed by two complementary systems: **migrations** and **convergence**. Migrations handle structural changes creating tables, adding columns, renaming things. Convergence handles everything declarative — indexes, constraints, foreign keys, and NOT NULL enforcement. You declare these on your models and convergence makes the database match.
449
+ Schema changes fall into three categories, each with a different author and apply model:
449
450
 
450
- ```
451
- Migrations Convergence
452
- (imperative, ordered) (declarative, idempotent)
453
- ───────────────────────────── ─────────────────────────────
454
- Create / drop tables Indexes
455
- Add / remove / rename columns Check constraints
456
- Change column types Unique constraints
457
- Data migrations (RunPython) Foreign key constraints
458
- Custom SQL (RunSQL) NOT NULL enforcement
459
- ```
451
+ - **Convergence** — declarative properties like indexes, constraints, NOT NULL, and FK `on_delete`. Derived from model definitions and applied automatically using online-safe DDL (`CREATE INDEX CONCURRENTLY`, `NOT VALID` + `VALIDATE`, etc.). The framework owns the safe apply pattern.
452
+ - **Structural migrations** — tables, columns, renames, column type changes. Framework-generated from the model diff, but you review them and decide when to deploy (a column type change can rewrite the table; a column drop is destructive).
453
+ - **Data migrations** — backfills, transformations, one-time cleanup. Authored by you via `RunPython` or `RunSQL`. The framework only sequences them.
454
+
455
+ | Change | Category | Safe apply pattern |
456
+ | ------------------------------------- | -------------------- | --------------------------------------------------- |
457
+ | Add / drop index | Convergence | `CREATE INDEX CONCURRENTLY` |
458
+ | Add / drop unique or check constraint | Convergence | `ADD CONSTRAINT NOT VALID` + `VALIDATE` |
459
+ | Add / remove NOT NULL | Convergence | `CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL` |
460
+ | Change FK `on_delete` action | Convergence | drop + re-add with `NOT VALID` + `VALIDATE` |
461
+ | Set / change / drop column `DEFAULT` | Convergence | catalog-only `ALTER COLUMN SET/DROP DEFAULT` |
462
+ | Create / drop table | Structural migration | framework-generated, you review |
463
+ | Add / drop / rename column | Structural migration | framework-generated, you review |
464
+ | Column type change (safe widening) | Structural migration | framework-generated `ALTER TYPE` with implicit cast |
465
+ | Column type change (other) | Data migration | you author (explicit `RunSQL` with `USING`) |
466
+ | Data backfill or transformation | Data migration | you author (`RunPython` / `RunSQL`) |
467
+ | One-time cleanup, seeding | Data migration | you author |
468
+
469
+ **The principle: who authors the change, and can the framework guarantee safety?** If the framework can derive both the change and a universally-safe apply pattern from model definitions, it belongs to convergence. If the framework can generate the DDL but safety depends on context (table size, deploy timing, destructiveness), it's a structural migration — you review it before deploying. If only you know what to do, it's a data migration.
470
+
471
+ Many convergence-managed changes produce DB-enforced behavior — cascading deletes (`ON DELETE`), validation (`CHECK`, `NOT NULL`), default generation. Whether a change is "behavioral" doesn't determine the category; whether the framework can guarantee a safe apply does.
472
+
473
+ | Property | Convergence | Migrations |
474
+ | ------------------------ | --------------------------------------------------------------------- | ----------------------------------------------------------- |
475
+ | Authored by | Framework (derived from models) | Framework (structural) or you (data) |
476
+ | When it runs | Every `sync`, by diffing models vs database | Once, in recorded timestamp order |
477
+ | Drift correction | Yes — reverts undeclared DB changes on next sync | No — manual DB changes persist |
478
+ | Reversible (intentional) | Implicit — roll back code, re-sync re-derives | No — forward-only, fix-forward |
479
+ | Failure behavior | Per-operation commits — partial progress on failure (re-run to retry) | Batch transaction — failure rolls back the entire migration |
480
+ | Files on disk | None — derived from models live | `.py` files in `migrations/` |
481
+ | Safe DDL | Framework-applied patterns (CONCURRENTLY, NOT VALID + VALIDATE) | Generated DDL; you review before deploy |
482
+
483
+ **Drift correction is a convergence-only behavior.** Convergence re-runs on every `sync` and compares models against the database. An index created manually outside a model declaration will be dropped on the next run because models are the source of truth. Migrations don't behave this way — once applied, they're recorded and never re-applied.
460
484
 
461
- This split exists because structural changes (adding a column) must happen in a specific order and can't be retried, while declarative objects (indexes, constraints) can be compared against model definitions and fixed automatically even if a previous attempt failed.
485
+ **Caveats.** The safety promise isn't absolute. Structural migrations aren't lint-checked yet: adding a column with a volatile default (`gen_random_uuid()`, `now()`) on a large table will rewrite it without warning. Review structural migrations before deploying to production.
486
+
487
+ **Column type changes.** The autodetector only auto-generates `AlterField` for a small allowlist of lossless widenings (`smallint → integer`, `smallint → bigint`, `integer → bigint`) and for parameter-only changes like `max_length`. Every other base-type change rejects with guidance — arbitrary `USING col::newtype` casts either fail at apply time (e.g. timestamp → uuid) or silently corrupt data (e.g. bigint FK → text stringifies PKs), and migrations are forward-only. For anything outside the allowlist, scaffold `plain migrations create --empty --name alter_<model>_<field>_type` and author an explicit `RunSQL` with a `USING` expression you've reviewed.
488
+
489
+ "Safe" here means data-integrity safe, not operationally cheap. An `ALTER COLUMN ... TYPE` that changes on-disk width (any of the allowlisted widenings) takes `ACCESS EXCLUSIVE` and rewrites the table — on a large table this can block writes for minutes. Deploy these during a maintenance window, not in the middle of traffic.
490
+
491
+ **Out of scope for convergence.** Triggers, views, stored procedures, and other non-standard DDL stay outside convergence — it won't create them from models, and it won't drop them if they exist. Manage them with `RunSQL` data migrations.
462
492
 
463
493
  ### Syncing
464
494
 
@@ -492,9 +522,9 @@ In development (`DEBUG=True`), sync auto-generates migrations before applying th
492
522
  | `plain postgres schema --json` | Machine-readable schema output |
493
523
  | `plain postgres converge` | Run convergence alone (advanced) |
494
524
 
495
- ### Migrations
525
+ ### Structural migrations
496
526
 
497
- Migrations track structural changes to your models. They are Python files stored in your app's `migrations/` directory.
527
+ Structural migrations are framework-generated from model changes tables, columns, renames, column type changes. They are Python files stored in your app's `migrations/` directory. You don't author them by hand; you edit models and run `plain migrations create`.
498
528
 
499
529
  ```bash
500
530
  plain migrations create
@@ -504,12 +534,9 @@ Key flags:
504
534
 
505
535
  - `--dry-run` — Show what migrations would be created (with operations and SQL) without writing files
506
536
  - `--check` — Exit non-zero if migrations are needed (for CI)
507
- - `--empty <package>` — Create an empty migration for custom data migrations
508
537
  - `--name <name>` — Set the migration filename
509
538
 
510
- Only write migrations by hand if they are custom data migrations.
511
-
512
- Other migration commands:
539
+ Shared commands (apply equally to structural and data migrations):
513
540
 
514
541
  | Command | Purpose |
515
542
  | ------------------------------------------- | ---------------------------------------------------- |
@@ -572,6 +599,53 @@ Over time a package can accumulate dozens of migrations. Once **every environmen
572
599
  - Data migrations (`RunPython`) in the deleted history are gone, which is fine since they've already run everywhere.
573
600
  - If CI runs `migrations create --check` or `migrations apply --check`, the reset PR must be merged and deployed before those checks pass in other branches.
574
601
 
602
+ ### Data migrations
603
+
604
+ Data migrations are user-authored operations — backfills, transformations, seeding, cleanup. The framework has no way to derive these from models; you write the logic and it gets sequenced in timestamp order alongside structural migrations.
605
+
606
+ Create an empty migration to author one:
607
+
608
+ ```bash
609
+ plain migrations create --empty <package>
610
+ ```
611
+
612
+ Add a `RunPython` or `RunSQL` operation inside:
613
+
614
+ ```python
615
+ def forwards(models, schema_editor):
616
+ User = models.get_model("users", "User")
617
+ # Use .update() for batch SQL — a row-by-row save loop can lock a large table.
618
+ User.query.filter(full_name="").update(full_name="pending")
619
+ ```
620
+
621
+ For large tables, chunk the work (e.g. by ID range) and commit between batches so no single transaction holds locks for too long.
622
+
623
+ See [Structural migrations](#structural-migrations) for shared commands (`apply`, `list`, `squash`, `prune`).
624
+
625
+ #### Cascading deletes inside data migrations
626
+
627
+ `Model.delete()` and `QuerySet.delete()` rely on Postgres `ON DELETE` clauses (`CASCADE`, `SET NULL`, `RESTRICT`) — Plain does not walk relationships in Python.
628
+
629
+ Foreign key constraints are added by **convergence** (step 3 of `postgres sync`), not by migrations. During a fresh `migrations apply` (before convergence has run), FK constraints don't exist yet. A `RunPython` data migration that calls `.delete()` on a model with cascading children will:
630
+
631
+ - Delete only the parent row — children become orphans
632
+ - Cause convergence's later `VALIDATE CONSTRAINT` step to fail because of the orphans
633
+
634
+ This only affects fresh applies. Existing databases keep their FK constraints across syncs, so incremental data migrations are unaffected.
635
+
636
+ **If your data migration needs to delete rows that have cascading children, handle the cascade explicitly:**
637
+
638
+ ```python
639
+ def forwards(models, schema_editor):
640
+ Parent = models.get_model("myapp", "Parent")
641
+ Child = models.get_model("myapp", "Child")
642
+ # Delete children first, then parent — explicit, no constraint reliance
643
+ Child.query.filter(parent__name="old").delete()
644
+ Parent.query.filter(name="old").delete()
645
+ ```
646
+
647
+ Or use `RunSQL` with explicit cascade if the relationship is large.
648
+
575
649
  ### Convergence
576
650
 
577
651
  Convergence compares the indexes, constraints, foreign keys, and nullability declared on your models against what actually exists in the database, then applies fixes to make them match. You don't need to create migrations for these — just declare them on your model and run `postgres sync`.
@@ -622,6 +696,43 @@ Some changes can't be applied automatically. For example, if you add `NOT NULL`
622
696
 
623
697
  When you remove an index or constraint from a model, convergence automatically drops the undeclared database object on the next `postgres sync`. Models are the source of truth — if it's not declared, it gets removed.
624
698
 
699
+ ### DDL timeouts
700
+
701
+ Every framework-issued DDL statement — both in migrations and in convergence — is wrapped with `lock_timeout` and `statement_timeout` so a deploy can't hang indefinitely waiting for a lock, and so a backfill against an unexpectedly large table fails fast instead of holding `ACCESS EXCLUSIVE` for minutes.
702
+
703
+ ```python
704
+ # app/settings.py — defaults shown
705
+ POSTGRES_MIGRATION_LOCK_TIMEOUT = "3s"
706
+ POSTGRES_MIGRATION_STATEMENT_TIMEOUT = "3s"
707
+ POSTGRES_CONVERGENCE_LOCK_TIMEOUT = "3s"
708
+ POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT = "3s"
709
+ ```
710
+
711
+ `lock_timeout` applies to every DDL. `statement_timeout` applies only to statements that take `ACCESS EXCLUSIVE` — non-blocking operations (`CREATE INDEX CONCURRENTLY`, `VALIDATE CONSTRAINT`) run unbounded because they can't cascade the lock queue.
712
+
713
+ If a migration issues a row-touching UPDATE (e.g. a hand-written `RunPython` or `RunSQL` backfill), the 3s `statement_timeout` will kill it on any non-tiny table. That's intentional — the right fix is a batched data migration, not a single long-running UPDATE. The common first-time failure mode is applying migrations against a pre-seeded dev or staging database: raise the ceiling for that one run, then lower it back for production deploys.
714
+
715
+ Use `RunSQL(no_timeout=True)` to opt out for a specific operation:
716
+
717
+ ```python
718
+ from plain.postgres.migrations.operations import RunSQL
719
+
720
+ operations = [
721
+ RunSQL(
722
+ "UPDATE orders SET status = 'pending' WHERE status IS NULL",
723
+ no_timeout=True,
724
+ ),
725
+ ]
726
+ ```
727
+
728
+ Non-atomic migrations (`Migration.atomic = False`, used for `CREATE INDEX CONCURRENTLY` in a migration) skip the timeout prelude automatically — `SET LOCAL` is a no-op outside a transaction block. Manage timeouts inside your own `RunSQL` if you need them.
729
+
730
+ Environment overrides: every setting accepts `PLAIN_POSTGRES_*` env vars, so you can raise the ceiling for a specific deploy without a code change:
731
+
732
+ ```bash
733
+ PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT=30s plain migrations apply
734
+ ```
735
+
625
736
  ## Fields
626
737
 
627
738
  You can use many field types for different data:
@@ -646,8 +757,8 @@ class Product(postgres.Model):
646
757
  is_active: bool = types.BooleanField(default=True)
647
758
 
648
759
  # Date and time fields
649
- created_at: datetime = types.DateTimeField(auto_now_add=True)
650
- updated_at: datetime = types.DateTimeField(auto_now=True)
760
+ created_at: datetime = types.DateTimeField(create_now=True)
761
+ updated_at: datetime = types.DateTimeField(update_now=True)
651
762
  ```
652
763
 
653
764
  **Text fields:**
@@ -675,10 +786,11 @@ class Product(postgres.Model):
675
786
  **Other fields:**
676
787
 
677
788
  - [`BooleanField`](./fields/__init__.py#BooleanField) - True/False
678
- - [`UUIDField`](./fields/__init__.py#UUIDField) - UUID
789
+ - [`UUIDField`](./fields/__init__.py#UUIDField) - UUID (pass `generate=True` for a per-row `gen_random_uuid()` default)
679
790
  - [`BinaryField`](./fields/__init__.py#BinaryField) - Raw binary data
680
791
  - [`JSONField`](./fields/json.py#JSONField) - JSON data
681
792
  - [`GenericIPAddressField`](./fields/__init__.py#GenericIPAddressField) - IPv4 or IPv6 address
793
+ - [`RandomStringField`](./fields/text.py#RandomStringField) - Per-row random hex string generated by Postgres (`length=`) — use for tokens, slugs, short IDs instead of a Python callable default. Slices `gen_random_uuid()` directly; values are uniform hex characters
682
794
 
683
795
  **Encrypted fields:**
684
796
 
@@ -708,8 +820,8 @@ from plain.postgres import types
708
820
 
709
821
  # Regular Python class for shared fields
710
822
  class TimestampedMixin:
711
- created_at: datetime = types.DateTimeField(auto_now_add=True)
712
- updated_at: datetime = types.DateTimeField(auto_now=True)
823
+ created_at: datetime = types.DateTimeField(create_now=True)
824
+ updated_at: datetime = types.DateTimeField(update_now=True)
713
825
 
714
826
 
715
827
  # Models inherit from the mixin AND postgres.Model
@@ -939,14 +1051,14 @@ model_options = postgres.Options(
939
1051
 
940
1052
  #### Choose `on_delete` deliberately
941
1053
 
942
- CASCADE for owned children, PROTECT for referenced data, SET_NULL for optional references.
1054
+ CASCADE for owned children, RESTRICT for referenced data, SET_NULL for optional references.
943
1055
 
944
1056
  ```python
945
1057
  # Bad — blindly using CASCADE everywhere
946
1058
  company: Company = types.ForeignKeyField("Company", on_delete=postgres.CASCADE) # deleting company deletes invoices!
947
1059
 
948
- # Good — protect referenced data
949
- company: Company = types.ForeignKeyField("Company", on_delete=postgres.PROTECT)
1060
+ # Good — block the delete while invoices reference the company
1061
+ company: Company = types.ForeignKeyField("Company", on_delete=postgres.RESTRICT)
950
1062
  ```
951
1063
 
952
1064
  #### No `allow_null` on string fields
@@ -1100,17 +1212,21 @@ When `DATABASE_URL` is set, it is parsed into the individual connection settings
1100
1212
 
1101
1213
  Set `DATABASE_URL=none` to explicitly disable the database (e.g. during Docker image builds).
1102
1214
 
1103
- | Setting | Type | Default | Env var |
1104
- | ----------------------------- | ------------- | ------- | ----------------------------------- |
1105
- | `POSTGRES_HOST` | `str` | — | `PLAIN_POSTGRES_HOST` |
1106
- | `POSTGRES_PORT` | `int \| None` | `None` | `PLAIN_POSTGRES_PORT` |
1107
- | `POSTGRES_DATABASE` | `str` | — | `PLAIN_POSTGRES_DATABASE` |
1108
- | `POSTGRES_USER` | `str` | — | `PLAIN_POSTGRES_USER` |
1109
- | `POSTGRES_PASSWORD` | `Secret[str]` | — | `PLAIN_POSTGRES_PASSWORD` |
1110
- | `POSTGRES_CONN_MAX_AGE` | `int` | `600` | `PLAIN_POSTGRES_CONN_MAX_AGE` |
1111
- | `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
1112
- | `POSTGRES_OPTIONS` | `dict` | `{}` | — |
1113
- | `POSTGRES_TIME_ZONE` | `str \| None` | `None` | `PLAIN_POSTGRES_TIME_ZONE` |
1215
+ | Setting | Type | Default | Env var |
1216
+ | ---------------------------------------- | ------------- | ------- | ---------------------------------------------- |
1217
+ | `POSTGRES_HOST` | `str` | — | `PLAIN_POSTGRES_HOST` |
1218
+ | `POSTGRES_PORT` | `int \| None` | `None` | `PLAIN_POSTGRES_PORT` |
1219
+ | `POSTGRES_DATABASE` | `str` | — | `PLAIN_POSTGRES_DATABASE` |
1220
+ | `POSTGRES_USER` | `str` | — | `PLAIN_POSTGRES_USER` |
1221
+ | `POSTGRES_PASSWORD` | `Secret[str]` | — | `PLAIN_POSTGRES_PASSWORD` |
1222
+ | `POSTGRES_CONN_MAX_AGE` | `int` | `600` | `PLAIN_POSTGRES_CONN_MAX_AGE` |
1223
+ | `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
1224
+ | `POSTGRES_OPTIONS` | `dict` | `{}` | — |
1225
+ | `POSTGRES_TIME_ZONE` | `str \| None` | `None` | `PLAIN_POSTGRES_TIME_ZONE` |
1226
+ | `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
1227
+ | `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
1228
+ | `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
1229
+ | `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` |
1114
1230
 
1115
1231
  See [`default_settings.py`](./default_settings.py) for more details.
1116
1232
 
@@ -1118,7 +1234,19 @@ See [`default_settings.py`](./default_settings.py) for more details.
1118
1234
 
1119
1235
  #### How do I add a field to an existing model?
1120
1236
 
1121
- Add the field to your model class, then run `plain migrations create` to create a migration. If the field is required (no default value and not nullable), you'll be prompted to provide a default value for existing rows.
1237
+ Add the field to your model class, then run `plain migrations create` to create a migration.
1238
+
1239
+ 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:
1240
+
1241
+ 1. Declare a `default=` on the field so the new column has a value for existing rows.
1242
+ 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`.
1243
+
1244
+ #### How do I make an existing column `NOT NULL`?
1245
+
1246
+ Edit the field to remove `allow_null=True`. `plain migrations create` won't detect a schema change — nullability is managed by convergence. Run `plain postgres sync`:
1247
+
1248
+ - If the column has no `NULL` rows, convergence applies the change with a non-blocking `CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL` pattern.
1249
+ - If `NULL` rows exist, convergence blocks and prints the table and column to backfill. Scaffold a data migration with `plain migrations create --empty --name backfill_<field>`, write the backfill, and run `postgres sync` again.
1122
1250
 
1123
1251
  #### How do I create a unique constraint on multiple fields?
1124
1252
 
@@ -1,5 +1,83 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.96.0](https://github.com/dropseed/plain/releases/plain-postgres@0.96.0) (2026-04-17)
4
+
5
+ ### What's changed
6
+
7
+ - **`DateTimeField` gained `create_now=True` / `update_now=True` kwargs; `auto_now_add` and `auto_now` are removed.** `create_now=True` installs a persistent `DEFAULT STATEMENT_TIMESTAMP()` column default — raw-SQL inserts now get a value, not just ORM-driven ones. `update_now=True` stamps the column on every `save()` via `pre_save`. Preflight requires `update_now=True` to be paired with `create_now=True` or `allow_null=True` so existing rows have a backfill path. `default=` is no longer accepted on `DateTimeField`. ([5d145e4](https://github.com/dropseed/plain/commit/5d145e4), [a44e5ec](https://github.com/dropseed/plain/commit/a44e5ec), [091bac7](https://github.com/dropseed/plain/commit/091bac7))
8
+ - **`UUIDField` gained `generate=True`; `default=GenRandomUUID()` is no longer accepted.** `generate=True` installs `DEFAULT gen_random_uuid()` on the column, so Postgres produces a fresh UUID per row (raw-SQL inserts included). ([a44e5ec](https://github.com/dropseed/plain/commit/a44e5ec))
9
+ - **Added `RandomStringField(length=N)`** for per-row DB-generated random hex strings. Backed by a `DEFAULT` that slices `gen_random_uuid()::text`; use in place of Python `default=secrets.token_hex` callables for tokens, slugs, and short IDs. Alphabet is always hex — an earlier draft accepted `alphabet=` but it was dropped because the generated expression grew to ~4 KB for a 40-char token. ([34858ab](https://github.com/dropseed/plain/commit/34858ab), [0918702](https://github.com/dropseed/plain/commit/0918702))
10
+ - **Added `GenRandomUUID()` function.** Exported at `plain.postgres.functions.GenRandomUUID`. No longer valid as `default=`; use `UUIDField(generate=True)` or reference it in annotations/expressions. ([da58230](https://github.com/dropseed/plain/commit/da58230))
11
+ - **Callable `default=` is banned on model fields.** `default=uuid.uuid4`, `default=secrets.token_hex`, `default=dict`, `default=lambda: ...`, etc. raise `TypeError` at field construction. Use DB-side generation (`UUIDField(generate=True)`, `RandomStringField`, `DateTimeField(create_now=True)`) or a static literal. Empty-collection defaults use literal `{}` / `[]` — the value is deep-copied on each `get_default()` call. ([091bac7](https://github.com/dropseed/plain/commit/091bac7))
12
+ - **Literal `default=X` values now persist as column `DEFAULT` in the catalog and are reconciled by convergence.** Previously `default=` was Python-side only; now it is compiled to a DDL `DEFAULT <literal>` clause. Raw-SQL `INSERT`s get the default, and drift is detected if someone edits it out-of-band. ([c59473d](https://github.com/dropseed/plain/commit/c59473d), [6ed95fe](https://github.com/dropseed/plain/commit/6ed95fe), [161c7f9](https://github.com/dropseed/plain/commit/161c7f9))
13
+ - **Column nullability and DEFAULT transitions now go through convergence, not the schema editor.** `AlterField` is a no-op when only `allow_null` or `default=` changed; `plain postgres sync` applies the change with online-safe DDL (`CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL` for NOT NULL flips; catalog-only `SET`/`DROP DEFAULT` for default changes). The old 4-way NULL → NOT NULL backfill in the schema editor is gone — if a column has NULL rows, convergence now blocks with guidance instead of silently backfilling. ([3e10ab2](https://github.com/dropseed/plain/commit/3e10ab2), [c59473d](https://github.com/dropseed/plain/commit/c59473d))
14
+ - **Every framework-issued DDL statement now emits `SET LOCAL lock_timeout` and, where relevant, `SET LOCAL statement_timeout`.** Defaults are `3s` each and apply to both migration operations and convergence fixes. Non-blocking operations (`CREATE INDEX CONCURRENTLY`, `VALIDATE CONSTRAINT`) skip `statement_timeout`. Configure via new settings `POSTGRES_MIGRATION_LOCK_TIMEOUT`, `POSTGRES_MIGRATION_STATEMENT_TIMEOUT`, `POSTGRES_CONVERGENCE_LOCK_TIMEOUT`, `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` (all `PLAIN_POSTGRES_*` env-var compatible). `RunSQL(no_timeout=True)` opts a single operation out — useful for batched backfills that manage their own timeouts. ([11d903b](https://github.com/dropseed/plain/commit/11d903b))
15
+ - **The autodetector rejects unsafe column type changes.** Base-type changes outside a lossless widening allowlist (`smallint → integer`, `smallint → bigint`, `integer → bigint`) raise `MigrationSchemaError` with scaffold guidance instead of emitting an `AlterField` that would compile to a blind `ALTER COLUMN ... TYPE ... USING col::newtype`. Parameter-only changes (e.g. `max_length`) and the widening allowlist still auto-generate. ([073a9af](https://github.com/dropseed/plain/commit/073a9af))
16
+ - **The autodetector rejects adding a NOT NULL column without a default.** Previously Plain prompted interactively for a one-shot value; now the autodetector errors out with two remediation options: declare a `default=`, or add the field as nullable, backfill, and drop `allow_null=True` via convergence. The `MigrationQuestioner.ask_not_null_*` prompts are gone. ([091bac7](https://github.com/dropseed/plain/commit/091bac7))
17
+ - **`AddField` / `AlterField` no longer accept `preserve_default`.** The argument is removed from both operation classes and from `ProjectState.add_field` / `alter_field`. Existing migration files that pass it will fail to load — regenerate them or remove the kwarg. ([c0a117f](https://github.com/dropseed/plain/commit/c0a117f))
18
+ - **Backslashes are banned in string `default=` values.** `default=r"C:\path"` raises `ValueError` at construction to prevent spurious DEFAULT drift on every convergence run. ([f8b6227](https://github.com/dropseed/plain/commit/f8b6227))
19
+ - **`choices=` is now only accepted on `TextField` (and `TimeZoneField`).** Other fields (`IntegerField`, `BooleanField`, etc.) reject `choices=` at call time. ([01584dc](https://github.com/dropseed/plain/commit/01584dc))
20
+ - **Removed `IntegerChoices` and the `Choices` base class.** Only `TextChoices` remains; it now subclasses `str, enum.Enum` directly. ([96acf13](https://github.com/dropseed/plain/commit/96acf13))
21
+ - **`max_length=` is now only accepted on `TextField`, `BinaryField`, and `EncryptedTextField`.** Other fields reject it. ([aaa0fb6](https://github.com/dropseed/plain/commit/aaa0fb6))
22
+ - **`default=` is no longer accepted on `ForeignKeyField`, `ManyToManyField`, `BinaryField`, `EncryptedTextField`, or `EncryptedJSONField`.** ([60299dc](https://github.com/dropseed/plain/commit/60299dc), [99ba5c2](https://github.com/dropseed/plain/commit/99ba5c2))
23
+ - **`ManyToManyField` signature is now explicit** — it rejects `required=`, `allow_null=`, `default=`, and `validators=` with `TypeError`. ([be7fd86](https://github.com/dropseed/plain/commit/be7fd86))
24
+ - **Removed `error_messages=` from model fields and `ModelForm.Meta`.** Form-field `error_messages` is unchanged; this only affects the model layer. ([4dee5ec](https://github.com/dropseed/plain/commit/4dee5ec))
25
+ - **`PrimaryKeyField` takes no arguments.** It is always `bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL`. Removed kwargs for `required`, `allow_null`, `default`, and `validators`; the type stub now matches the runtime signature. ([ca122c9](https://github.com/dropseed/plain/commit/ca122c9), [0ecd71e](https://github.com/dropseed/plain/commit/0ecd71e))
26
+ - **`plain postgres sync --check` now prints pending work.** Previously `--check` only exited non-zero; it now enumerates pending migrations, convergence items, and blocked items with guidance. ([0de289d](https://github.com/dropseed/plain/commit/0de289d))
27
+ - **Fixed index drift false positive for `DESC` / `NULLS FIRST|LAST` columns.** Indexes like `Index(fields=["-created_at"])` were rebuilt on every `postgres sync` because the introspection parser misread the sort direction as an opclass. ([07cb500](https://github.com/dropseed/plain/commit/07cb500))
28
+ - **Fixed `Field.deconstruct()` over-shortening import paths** — `plain.postgres.fields.<submod>.X` now shortens to `plain.postgres.X` only when `X` is actually re-exported at the top level. ([34858ab](https://github.com/dropseed/plain/commit/34858ab))
29
+ - **`ModelForm` no longer marks DB-expression-default and auto-filled fields as `required`.** Fields with `db_returning=True` (e.g. `create_now=True`, `generate=True`, `RandomStringField`) and `auto_fills_on_save=True` (`update_now=True`) produce form fields with `required=False` and preserve the `DATABASE_DEFAULT` sentinel through `construct_instance` so INSERT emits `DEFAULT` instead of NULL on empty submissions. `modelfield_to_formfield` now returns `None` for non-column-backed fields (M2M, etc.). ([6ed95fe](https://github.com/dropseed/plain/commit/6ed95fe))
30
+ - **Internal restructuring.** `Field` is split into `ColumnField` → `DefaultableField` → `ChoicesField` with kwargs scoped to the fields that actually accept them. `plain.postgres.fields.__init__` is split into per-type modules (`base`, `text`, `numeric`, `temporal`, `boolean`, `binary`, `uuid`, `network`, `duration`, `primary_key`). `PrimaryKeyField` moved off the `BigIntegerField → IntegerField` chain onto `ColumnField[int]` directly. `non_db_attrs` renamed to `non_migration_attrs`. Removed dead Django-era internals: `SubqueryConstraint`, `MultiColSource`, multi-column FK machinery, multi-table-inheritance UPDATE machinery, `Field.description`, `Field.value_to_string()`, `Field.get_limit_choices_to()`. ([476e1ae](https://github.com/dropseed/plain/commit/476e1ae), [9ed8cc6](https://github.com/dropseed/plain/commit/9ed8cc6), [ca122c9](https://github.com/dropseed/plain/commit/ca122c9), [21cf85f](https://github.com/dropseed/plain/commit/21cf85f), [18080ca](https://github.com/dropseed/plain/commit/18080ca), [9d4ff49](https://github.com/dropseed/plain/commit/9d4ff49), [07b5f0b](https://github.com/dropseed/plain/commit/07b5f0b), [176f56e](https://github.com/dropseed/plain/commit/176f56e), [16e4fcd](https://github.com/dropseed/plain/commit/16e4fcd), [cb98bfa](https://github.com/dropseed/plain/commit/cb98bfa))
31
+
32
+ ### Upgrade instructions
33
+
34
+ - **Replace `auto_now_add=True` with `create_now=True`** on every `DateTimeField`.
35
+ - **Replace `auto_now=True` with `update_now=True`**. If the field was `NOT NULL`, also set `create_now=True` (or `allow_null=True`) — preflight will fail otherwise.
36
+ - **Replace `DateTimeField(default=timezone.now)` / `default=Now()`** with `DateTimeField(create_now=True)`. `DateTimeField(default=...)` is no longer accepted.
37
+ - **Replace `UUIDField(default=uuid.uuid4)` and `UUIDField(default=GenRandomUUID())`** with `UUIDField(generate=True)`. `UUIDField(default=...)` is no longer accepted.
38
+ - **Replace `default=secrets.token_hex` / `default=secrets.token_urlsafe`** with `RandomStringField(length=N)` (hex output only).
39
+ - **Replace `default=dict` / `default=list`** with `default={}` / `default=[]`. Any other callable passed as `default=` will now raise `TypeError`.
40
+ - **Remove `choices=` from non-text fields** (`IntegerField`, `BooleanField`, etc.).
41
+ - **Replace `IntegerChoices` usages** with `TextChoices` or a plain `enum.IntEnum`. `Choices` (the base class) is also gone.
42
+ - **Remove `max_length=` from any field that isn't `TextField`, `BinaryField`, or `EncryptedTextField`.**
43
+ - **Remove `default=` from `ForeignKeyField`, `BinaryField`, `EncryptedTextField`, and `EncryptedJSONField`.**
44
+ - **Remove `required=`, `allow_null=`, `default=`, and `validators=` from `ManyToManyField`** — its signature is now explicit (`to`, `through`, `through_fields`, `related_query_name`, `limit_choices_to`, `symmetrical`).
45
+ - **Remove kwargs from `PrimaryKeyField()`** — it no longer accepts any.
46
+ - **Remove `error_messages=` from model-level fields and `ModelForm.Meta`.** (Form-field `error_messages` on standalone form fields is unchanged.)
47
+ - **Escape backslashes in string `default=` values.** `default="C:\\path"` is fine; `default=r"C:\path"` now raises at construction.
48
+ - **Edit or regenerate migration files that pass `preserve_default=...`** to `AddField` / `AlterField` — the kwarg was removed.
49
+ - **Rename `non_db_attrs` to `non_migration_attrs`** in any custom field subclass.
50
+ - **If your migrations hit the new 3s `statement_timeout`** against a large dev/staging DB, raise it for that run via `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT=30s`, or pass `RunSQL(sql, no_timeout=True)` on individual long-running operations.
51
+ - **Run `plain postgres sync`** after upgrading to let convergence install persisted column DEFAULTs on existing tables.
52
+
53
+ ## [0.95.0](https://github.com/dropseed/plain/releases/plain-postgres@0.95.0) (2026-04-14)
54
+
55
+ ### What's changed
56
+
57
+ - **Deletes now run as a single DELETE statement and cascade through Postgres `ON DELETE` clauses.** The Python `Collector` (which walked relationships in Python to fire per-table DELETEs) has been removed. `Model.delete()` and `QuerySet.delete()` issue one statement and let Postgres do the cascading via the FK actions installed by convergence. The old Collector path required N queries per cascade; the new path requires exactly one. ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
58
+ - **`Model.delete()` and `QuerySet.delete()` now return `int`** (the directly-deleted row count). They previously returned a `(count, {label: count})` tuple — Postgres does not report cascaded counts, and the per-label dict was Collector-only bookkeeping. ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
59
+ - **`on_delete` constants are now `OnDelete` instances, not bare functions.** `ForeignKeyField` rejects any non-`OnDelete` value at construction, and the declared action is emitted as the FK's Postgres `ON DELETE` clause. ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
60
+ - **Removed `PROTECT`, `SET()`, `SET_DEFAULT`, `ProtectedError`, and `RestrictedError`.** `PROTECT` and `SET(callable)` had no Postgres equivalent (prefer `RESTRICT`). `SET_DEFAULT` was removed because Plain does not currently persist Python model defaults as DB-level column defaults — emitting `ON DELETE SET DEFAULT` would set children to `NULL` on bypass-the-ORM deletes, contradicting the model's intent. `SET_DEFAULT` will return once DB-level defaults are supported. `RESTRICT` now surfaces as `psycopg.errors.IntegrityError` directly. ([670dab428ad2](https://github.com/dropseed/plain/commit/670dab428ad2), [29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
61
+ - **Renamed `DO_NOTHING` to `NO_ACTION`** to match Postgres's SQL term. No behavior change. ([5fcf8aa9ced3](https://github.com/dropseed/plain/commit/5fcf8aa9ced3))
62
+ - **Convergence now owns FK `on_delete` drift.** `plain postgres sync` introspects `pg_constraint.confdeltype`, compares it to the declared `on_delete`, and replaces the constraint when they drift. Replacement uses `ADD CONSTRAINT … NOT VALID` + `VALIDATE` to minimize lock time. Existing databases auto-upgrade on their next sync. ([211840197e1e](https://github.com/dropseed/plain/commit/211840197e1e))
63
+ - **Preflight rejects `db_constraint=False` with a non-`NO_ACTION` `on_delete`.** Without a constraint there is no place to attach a deletion action. ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
64
+ - **Tightened types.** `on_delete` is now typed as `OnDelete` everywhere (was `Any`). `ForeignKeyField.remote_field` narrows to `ForeignKeyRel` so `remote_field.on_delete` is non-optional. `ForeignObjectRel`, `ForeignKeyRel`, and `ManyToManyRel` `__init__` are kwarg-only. ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
65
+ - **Known limitation: data migrations + cascading deletes.** On a fresh `migrations apply`, FK constraints don't exist yet (they're added by convergence in step 3 of `postgres sync`). A `RunPython` data migration that calls `.delete()` on a parent with cascading children will orphan the children, and the subsequent convergence `VALIDATE` will fail. Existing databases are unaffected. Documented with a workaround in the Postgres README (delete children explicitly first, or use `RunSQL`). ([29e10dba51d9](https://github.com/dropseed/plain/commit/29e10dba51d9))
66
+ - **Rewrote the Schema management docs** to distinguish convergence, structural migrations, and data migrations by who authors the change and whether the framework can guarantee a safe apply. Added a per-change-type table covering safe apply patterns (`CREATE INDEX CONCURRENTLY`, `NOT VALID` + `VALIDATE`, etc.) and split the Migrations section into “Structural migrations” and “Data migrations.” ([8ae39e2cef78](https://github.com/dropseed/plain/commit/8ae39e2cef78), [49d2b2452dea](https://github.com/dropseed/plain/commit/49d2b2452dea))
67
+
68
+ ### Upgrade instructions
69
+
70
+ - **Adapt callers of `.delete()`.** `.delete()` now returns an `int`, not a `(count, by_label)` tuple.
71
+ - Before: `count, _ = qs.delete()` or `count = qs.delete()[0]`
72
+ - After: `count = qs.delete()`
73
+ - **Rename `DO_NOTHING` to `NO_ACTION`** at all import and usage sites. Regenerate or hand-edit migration files that reference `DO_NOTHING`.
74
+ - **Replace `PROTECT` with `RESTRICT`.** Catch `psycopg.errors.IntegrityError` instead of `ProtectedError` / `RestrictedError`.
75
+ - **Replace `SET(callable)` usages.** There is no one-line equivalent — the Python-callable path doesn't exist in Postgres. Either switch to a supported action (`SET_NULL`, `RESTRICT`, `CASCADE`, `NO_ACTION`) or handle the affected rows explicitly before deletion.
76
+ - **Replace `SET_DEFAULT` usages.** Pick a different `on_delete`, or set the default explicitly in application code before deletion. `SET_DEFAULT` will return once Plain persists column defaults.
77
+ - **Run `plain postgres sync`** after upgrading. Convergence will install the correct `ON DELETE` clauses on existing FKs — no migration file, no manual step.
78
+ - **If you set `db_constraint=False` on a FK with a non-`NO_ACTION` `on_delete`**, change the action to `NO_ACTION` — preflight will now fail otherwise.
79
+ - **Review `RunPython` migrations that call `.delete()` on parents with cascading children.** On a fresh `migrations apply` before convergence runs, children become orphans and break the subsequent `VALIDATE`. Delete children explicitly first, or use `RunSQL`.
80
+
3
81
  ## [0.94.2](https://github.com/dropseed/plain/releases/plain-postgres@0.94.2) (2026-04-13)
4
82
 
5
83
  ### What's changed