plain.postgres 0.95.0__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 (194) hide show
  1. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/PKG-INFO +99 -38
  2. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/CHANGELOG.md +50 -0
  3. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/README.md +98 -37
  4. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/__init__.py +3 -2
  5. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/base.py +66 -33
  6. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/cli/migrations.py +39 -14
  7. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/cli/schema.py +1 -1
  8. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/cli/sync.py +18 -10
  9. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/convergence/__init__.py +8 -0
  10. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/convergence/analysis.py +153 -21
  11. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/convergence/fixes.py +135 -23
  12. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/convergence/planning.py +13 -0
  13. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/ddl.py +24 -0
  14. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/default_settings.py +18 -0
  15. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/dialect.py +45 -0
  16. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/enums.py +9 -15
  17. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/expressions.py +4 -2
  18. plain_postgres-0.96.0/plain/postgres/fields/__init__.py +51 -0
  19. plain_postgres-0.96.0/plain/postgres/fields/base.py +867 -0
  20. plain_postgres-0.96.0/plain/postgres/fields/binary.py +65 -0
  21. plain_postgres-0.96.0/plain/postgres/fields/boolean.py +38 -0
  22. plain_postgres-0.96.0/plain/postgres/fields/duration.py +51 -0
  23. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/fields/encrypted.py +34 -43
  24. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/fields/json.py +16 -48
  25. plain_postgres-0.96.0/plain/postgres/fields/network.py +101 -0
  26. plain_postgres-0.96.0/plain/postgres/fields/numeric.py +278 -0
  27. plain_postgres-0.96.0/plain/postgres/fields/primary_key.py +86 -0
  28. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/fields/related.py +76 -68
  29. plain_postgres-0.96.0/plain/postgres/fields/related_lookups.py +103 -0
  30. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/fields/reverse_related.py +3 -7
  31. plain_postgres-0.96.0/plain/postgres/fields/temporal.py +381 -0
  32. plain_postgres-0.96.0/plain/postgres/fields/text.py +131 -0
  33. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/fields/timezones.py +27 -15
  34. plain_postgres-0.96.0/plain/postgres/fields/uuid.py +78 -0
  35. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/forms.py +52 -31
  36. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/functions/__init__.py +6 -0
  37. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/functions/datetime.py +13 -6
  38. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/functions/mixins.py +8 -2
  39. plain_postgres-0.96.0/plain/postgres/functions/random.py +51 -0
  40. plain_postgres-0.96.0/plain/postgres/functions/uuid.py +9 -0
  41. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/introspection/__init__.py +2 -0
  42. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/introspection/schema.py +249 -14
  43. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/meta.py +1 -1
  44. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/autodetector.py +109 -45
  45. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/exceptions.py +9 -0
  46. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/operations/fields.py +2 -23
  47. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/operations/special.py +12 -2
  48. plain_postgres-0.96.0/plain/postgres/migrations/questioner.py +109 -0
  49. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/recorder.py +1 -2
  50. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/serializer.py +2 -2
  51. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/state.py +0 -14
  52. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/query.py +12 -5
  53. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/query_utils.py +7 -13
  54. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/schema.py +122 -245
  55. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/sql/compiler.py +31 -93
  56. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/sql/query.py +15 -75
  57. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/sql/where.py +0 -24
  58. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/types.py +2 -0
  59. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/types.pyi +24 -163
  60. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/pyproject.toml +1 -1
  61. plain_postgres-0.96.0/tests/app/examples/forms.py +48 -0
  62. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +4 -4
  63. plain_postgres-0.96.0/tests/app/examples/migrations/0011_defaultsexample.py +28 -0
  64. plain_postgres-0.96.0/tests/app/examples/migrations/0012_iterationexample.py +21 -0
  65. plain_postgres-0.96.0/tests/app/examples/migrations/0013_indexexample_constraintexample_nullabilityexample.py +36 -0
  66. plain_postgres-0.96.0/tests/app/examples/migrations/0014_widget_rename_feature_tag_remove_carfeature_car_and_more.py +64 -0
  67. plain_postgres-0.96.0/tests/app/examples/migrations/0015_dbdefaultsexample.py +22 -0
  68. plain_postgres-0.96.0/tests/app/examples/migrations/0016_formsexample.py +41 -0
  69. plain_postgres-0.96.0/tests/app/examples/migrations/0017_random_string_token.py +18 -0
  70. plain_postgres-0.96.0/tests/app/examples/models/__init__.py +18 -0
  71. plain_postgres-0.96.0/tests/app/examples/models/constraints.py +28 -0
  72. plain_postgres-0.96.0/tests/app/examples/models/defaults.py +50 -0
  73. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/models/delete.py +1 -7
  74. plain_postgres-0.96.0/tests/app/examples/models/encrypted.py +16 -0
  75. plain_postgres-0.96.0/tests/app/examples/models/forms.py +35 -0
  76. plain_postgres-0.96.0/tests/app/examples/models/indexes.py +19 -0
  77. plain_postgres-0.96.0/tests/app/examples/models/iteration.py +20 -0
  78. plain_postgres-0.96.0/tests/app/examples/models/mixins.py +26 -0
  79. plain_postgres-0.96.0/tests/app/examples/models/nullability.py +17 -0
  80. plain_postgres-0.96.0/tests/app/examples/models/querysets.py +41 -0
  81. plain_postgres-0.96.0/tests/app/examples/models/relationships.py +44 -0
  82. plain_postgres-0.96.0/tests/app/examples/models/trees.py +17 -0
  83. plain_postgres-0.96.0/tests/app/examples/models/unregistered.py +7 -0
  84. plain_postgres-0.96.0/tests/app/examples/urls.py +36 -0
  85. plain_postgres-0.96.0/tests/app/examples/views.py +66 -0
  86. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/urls.py +3 -4
  87. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/conftest_convergence.py +15 -2
  88. plain_postgres-0.96.0/tests/test_autodetector_not_null_errors.py +211 -0
  89. plain_postgres-0.96.0/tests/test_autodetector_type_change.py +105 -0
  90. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_convergence.py +203 -165
  91. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_convergence_constraints.py +365 -241
  92. plain_postgres-0.96.0/tests/test_convergence_defaults.py +471 -0
  93. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_convergence_fk.py +90 -95
  94. plain_postgres-0.96.0/tests/test_convergence_indexes.py +600 -0
  95. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_convergence_nullability.py +85 -46
  96. plain_postgres-0.96.0/tests/test_convergence_timeouts.py +350 -0
  97. plain_postgres-0.96.0/tests/test_db_expression_defaults.py +495 -0
  98. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_delete_behaviors.py +39 -41
  99. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_encrypted_fields.py +1 -1
  100. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_exceptions.py +26 -17
  101. plain_postgres-0.96.0/tests/test_field_defaults.py +146 -0
  102. plain_postgres-0.96.0/tests/test_functions_uuid.py +31 -0
  103. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_introspection.py +177 -45
  104. plain_postgres-0.96.0/tests/test_iterator.py +101 -0
  105. plain_postgres-0.96.0/tests/test_literal_default_persistence.py +212 -0
  106. plain_postgres-0.96.0/tests/test_m2m.py +114 -0
  107. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_manager_assignment.py +1 -1
  108. plain_postgres-0.96.0/tests/test_mixins.py +19 -0
  109. plain_postgres-0.96.0/tests/test_modelform_roundtrip.py +295 -0
  110. plain_postgres-0.96.0/tests/test_no_callable_defaults.py +55 -0
  111. plain_postgres-0.96.0/tests/test_random_string_field.py +124 -0
  112. plain_postgres-0.96.0/tests/test_raw_query.py +44 -0
  113. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_read_only_transactions.py +16 -16
  114. plain_postgres-0.95.0/tests/test_related_descriptors.py → plain_postgres-0.96.0/tests/test_related.py +176 -5
  115. plain_postgres-0.96.0/tests/test_schema_timeouts.py +129 -0
  116. plain_postgres-0.95.0/plain/postgres/fields/__init__.py +0 -1937
  117. plain_postgres-0.95.0/plain/postgres/fields/related_lookups.py +0 -223
  118. plain_postgres-0.95.0/plain/postgres/migrations/questioner.py +0 -314
  119. plain_postgres-0.95.0/tests/app/examples/models/__init__.py +0 -139
  120. plain_postgres-0.95.0/tests/test_convergence_indexes.py +0 -483
  121. plain_postgres-0.95.0/tests/test_iterator.py +0 -99
  122. plain_postgres-0.95.0/tests/test_models.py +0 -197
  123. plain_postgres-0.95.0/tests/test_related_manager_api.py +0 -154
  124. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/.gitignore +0 -0
  125. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/CLAUDE.md +0 -0
  126. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/LICENSE +0 -0
  127. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/README.md +0 -0
  128. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  129. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  130. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/aggregates.py +0 -0
  131. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/cli/__init__.py +0 -0
  132. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/cli/converge.py +0 -0
  133. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/cli/core.py +0 -0
  134. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/cli/diagnose.py +0 -0
  135. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/config.py +0 -0
  136. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/connection.py +0 -0
  137. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/connections.py +0 -0
  138. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/constants.py +0 -0
  139. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/constraints.py +0 -0
  140. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/database_url.py +0 -0
  141. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/db.py +0 -0
  142. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/deletion.py +0 -0
  143. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/entrypoints.py +0 -0
  144. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/exceptions.py +0 -0
  145. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/fields/mixins.py +0 -0
  146. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/fields/related_descriptors.py +0 -0
  147. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/fields/related_managers.py +0 -0
  148. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  149. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/functions/comparison.py +0 -0
  150. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/functions/math.py +0 -0
  151. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/functions/text.py +0 -0
  152. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/functions/window.py +0 -0
  153. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/indexes.py +0 -0
  154. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/introspection/health.py +0 -0
  155. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/lookups.py +0 -0
  156. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/__init__.py +0 -0
  157. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/executor.py +0 -0
  158. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/graph.py +0 -0
  159. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/loader.py +0 -0
  160. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/migration.py +0 -0
  161. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  162. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/operations/base.py +0 -0
  163. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/operations/models.py +0 -0
  164. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/optimizer.py +0 -0
  165. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/utils.py +0 -0
  166. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/migrations/writer.py +0 -0
  167. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/options.py +0 -0
  168. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/otel.py +0 -0
  169. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/preflight.py +0 -0
  170. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/registry.py +0 -0
  171. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/sql/__init__.py +0 -0
  172. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/sql/constants.py +0 -0
  173. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/sql/datastructures.py +0 -0
  174. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/test/__init__.py +0 -0
  175. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/test/pytest.py +0 -0
  176. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/test/utils.py +0 -0
  177. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/transaction.py +0 -0
  178. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/plain/postgres/utils.py +0 -0
  179. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  180. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  181. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  182. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  183. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  184. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +0 -0
  185. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/migrations/0008_setsentinelparent_diamondparenta_midparent_and_more.py +0 -0
  186. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/migrations/0009_circb_circa_circb_partner.py +0 -0
  187. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/migrations/0010_hideableitem.py +0 -0
  188. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/examples/migrations/__init__.py +0 -0
  189. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/app/settings.py +0 -0
  190. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_connection_isolation.py +0 -0
  191. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_connection_lifecycle.py +0 -0
  192. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_database_url.py +0 -0
  193. {plain_postgres-0.95.0 → plain_postgres-0.96.0}/tests/test_migration_executor.py +0 -0
  194. {plain_postgres-0.95.0 → 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.95.0
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
@@ -48,7 +48,7 @@ class User(postgres.Model):
48
48
  email: str = types.EmailField()
49
49
  password = PasswordField()
50
50
  is_admin: bool = types.BooleanField(default=False)
51
- created_at: datetime = types.DateTimeField(auto_now_add=True)
51
+ created_at: datetime = types.DateTimeField(create_now=True)
52
52
 
53
53
  def __str__(self) -> str:
54
54
  return self.email
@@ -452,35 +452,42 @@ Schema changes fall into three categories, each with a different author and appl
452
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
453
  - **Data migrations** — backfills, transformations, one-time cleanup. Authored by you via `RunPython` or `RunSQL`. The framework only sequences them.
454
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
- | Create / drop table | Structural migration | framework-generated, you review |
462
- | Add / drop / rename column | Structural migration | framework-generated, you review |
463
- | Column type change | Structural migration | may rewrite the table |
464
- | Data backfill or transformation | Data migration | you author (`RunPython` / `RunSQL`) |
465
- | One-time cleanup, seeding | Data migration | you author |
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 |
466
468
 
467
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.
468
470
 
469
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.
470
472
 
471
- | Property | Convergence | Migrations |
472
- | ---------------- | --------------------------------------------------------------- | --------------------------------------- |
473
- | Authored by | Framework (derived from models) | Framework (structural) or you (data) |
474
- | When it runs | Every `sync`, by diffing models vs database | Once, in recorded timestamp order |
475
- | Drift correction | Yes — reverts undeclared DB changes on next sync | No — manual DB changes persist |
476
- | Reversible | Implicit (roll back code, re-sync re-derives) | No — forward-only, fix-forward |
477
- | Files on disk | Nonederived from models live | `.py` files in `migrations/` |
478
- | Safe DDL | Framework-applied patterns (CONCURRENTLY, NOT VALID + VALIDATE) | Generated DDL; you review before deploy |
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 |
479
482
 
480
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.
481
484
 
482
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.
483
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
+
484
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.
485
492
 
486
493
  ### Syncing
@@ -689,6 +696,43 @@ Some changes can't be applied automatically. For example, if you add `NOT NULL`
689
696
 
690
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.
691
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
+
692
736
  ## Fields
693
737
 
694
738
  You can use many field types for different data:
@@ -713,8 +757,8 @@ class Product(postgres.Model):
713
757
  is_active: bool = types.BooleanField(default=True)
714
758
 
715
759
  # Date and time fields
716
- created_at: datetime = types.DateTimeField(auto_now_add=True)
717
- 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)
718
762
  ```
719
763
 
720
764
  **Text fields:**
@@ -742,10 +786,11 @@ class Product(postgres.Model):
742
786
  **Other fields:**
743
787
 
744
788
  - [`BooleanField`](./fields/__init__.py#BooleanField) - True/False
745
- - [`UUIDField`](./fields/__init__.py#UUIDField) - UUID
789
+ - [`UUIDField`](./fields/__init__.py#UUIDField) - UUID (pass `generate=True` for a per-row `gen_random_uuid()` default)
746
790
  - [`BinaryField`](./fields/__init__.py#BinaryField) - Raw binary data
747
791
  - [`JSONField`](./fields/json.py#JSONField) - JSON data
748
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
749
794
 
750
795
  **Encrypted fields:**
751
796
 
@@ -775,8 +820,8 @@ from plain.postgres import types
775
820
 
776
821
  # Regular Python class for shared fields
777
822
  class TimestampedMixin:
778
- created_at: datetime = types.DateTimeField(auto_now_add=True)
779
- 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)
780
825
 
781
826
 
782
827
  # Models inherit from the mixin AND postgres.Model
@@ -1167,17 +1212,21 @@ When `DATABASE_URL` is set, it is parsed into the individual connection settings
1167
1212
 
1168
1213
  Set `DATABASE_URL=none` to explicitly disable the database (e.g. during Docker image builds).
1169
1214
 
1170
- | Setting | Type | Default | Env var |
1171
- | ----------------------------- | ------------- | ------- | ----------------------------------- |
1172
- | `POSTGRES_HOST` | `str` | — | `PLAIN_POSTGRES_HOST` |
1173
- | `POSTGRES_PORT` | `int \| None` | `None` | `PLAIN_POSTGRES_PORT` |
1174
- | `POSTGRES_DATABASE` | `str` | — | `PLAIN_POSTGRES_DATABASE` |
1175
- | `POSTGRES_USER` | `str` | — | `PLAIN_POSTGRES_USER` |
1176
- | `POSTGRES_PASSWORD` | `Secret[str]` | — | `PLAIN_POSTGRES_PASSWORD` |
1177
- | `POSTGRES_CONN_MAX_AGE` | `int` | `600` | `PLAIN_POSTGRES_CONN_MAX_AGE` |
1178
- | `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
1179
- | `POSTGRES_OPTIONS` | `dict` | `{}` | — |
1180
- | `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` |
1181
1230
 
1182
1231
  See [`default_settings.py`](./default_settings.py) for more details.
1183
1232
 
@@ -1185,7 +1234,19 @@ See [`default_settings.py`](./default_settings.py) for more details.
1185
1234
 
1186
1235
  #### How do I add a field to an existing model?
1187
1236
 
1188
- 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.
1189
1250
 
1190
1251
  #### How do I create a unique constraint on multiple fields?
1191
1252
 
@@ -1,5 +1,55 @@
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
+
3
53
  ## [0.95.0](https://github.com/dropseed/plain/releases/plain-postgres@0.95.0) (2026-04-14)
4
54
 
5
55
  ### What's changed
@@ -36,7 +36,7 @@ class User(postgres.Model):
36
36
  email: str = types.EmailField()
37
37
  password = PasswordField()
38
38
  is_admin: bool = types.BooleanField(default=False)
39
- created_at: datetime = types.DateTimeField(auto_now_add=True)
39
+ created_at: datetime = types.DateTimeField(create_now=True)
40
40
 
41
41
  def __str__(self) -> str:
42
42
  return self.email
@@ -440,35 +440,42 @@ Schema changes fall into three categories, each with a different author and appl
440
440
  - **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).
441
441
  - **Data migrations** — backfills, transformations, one-time cleanup. Authored by you via `RunPython` or `RunSQL`. The framework only sequences them.
442
442
 
443
- | Change | Category | Safe apply pattern |
444
- | ------------------------------------- | -------------------- | ----------------------------------------------- |
445
- | Add / drop index | Convergence | `CREATE INDEX CONCURRENTLY` |
446
- | Add / drop unique or check constraint | Convergence | `ADD CONSTRAINT NOT VALID` + `VALIDATE` |
447
- | Add / remove NOT NULL | Convergence | `CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL` |
448
- | Change FK `on_delete` action | Convergence | drop + re-add with `NOT VALID` + `VALIDATE` |
449
- | Create / drop table | Structural migration | framework-generated, you review |
450
- | Add / drop / rename column | Structural migration | framework-generated, you review |
451
- | Column type change | Structural migration | may rewrite the table |
452
- | Data backfill or transformation | Data migration | you author (`RunPython` / `RunSQL`) |
453
- | One-time cleanup, seeding | Data migration | you author |
443
+ | Change | Category | Safe apply pattern |
444
+ | ------------------------------------- | -------------------- | --------------------------------------------------- |
445
+ | Add / drop index | Convergence | `CREATE INDEX CONCURRENTLY` |
446
+ | Add / drop unique or check constraint | Convergence | `ADD CONSTRAINT NOT VALID` + `VALIDATE` |
447
+ | Add / remove NOT NULL | Convergence | `CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL` |
448
+ | Change FK `on_delete` action | Convergence | drop + re-add with `NOT VALID` + `VALIDATE` |
449
+ | Set / change / drop column `DEFAULT` | Convergence | catalog-only `ALTER COLUMN SET/DROP DEFAULT` |
450
+ | Create / drop table | Structural migration | framework-generated, you review |
451
+ | Add / drop / rename column | Structural migration | framework-generated, you review |
452
+ | Column type change (safe widening) | Structural migration | framework-generated `ALTER TYPE` with implicit cast |
453
+ | Column type change (other) | Data migration | you author (explicit `RunSQL` with `USING`) |
454
+ | Data backfill or transformation | Data migration | you author (`RunPython` / `RunSQL`) |
455
+ | One-time cleanup, seeding | Data migration | you author |
454
456
 
455
457
  **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.
456
458
 
457
459
  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.
458
460
 
459
- | Property | Convergence | Migrations |
460
- | ---------------- | --------------------------------------------------------------- | --------------------------------------- |
461
- | Authored by | Framework (derived from models) | Framework (structural) or you (data) |
462
- | When it runs | Every `sync`, by diffing models vs database | Once, in recorded timestamp order |
463
- | Drift correction | Yes — reverts undeclared DB changes on next sync | No — manual DB changes persist |
464
- | Reversible | Implicit (roll back code, re-sync re-derives) | No — forward-only, fix-forward |
465
- | Files on disk | Nonederived from models live | `.py` files in `migrations/` |
466
- | Safe DDL | Framework-applied patterns (CONCURRENTLY, NOT VALID + VALIDATE) | Generated DDL; you review before deploy |
461
+ | Property | Convergence | Migrations |
462
+ | ------------------------ | --------------------------------------------------------------------- | ----------------------------------------------------------- |
463
+ | Authored by | Framework (derived from models) | Framework (structural) or you (data) |
464
+ | When it runs | Every `sync`, by diffing models vs database | Once, in recorded timestamp order |
465
+ | Drift correction | Yes — reverts undeclared DB changes on next sync | No — manual DB changes persist |
466
+ | Reversible (intentional) | Implicit roll back code, re-sync re-derives | No — forward-only, fix-forward |
467
+ | Failure behavior | Per-operation commits — partial progress on failure (re-run to retry) | Batch transaction failure rolls back the entire migration |
468
+ | Files on disk | None derived from models live | `.py` files in `migrations/` |
469
+ | Safe DDL | Framework-applied patterns (CONCURRENTLY, NOT VALID + VALIDATE) | Generated DDL; you review before deploy |
467
470
 
468
471
  **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.
469
472
 
470
473
  **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.
471
474
 
475
+ **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.
476
+
477
+ "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.
478
+
472
479
  **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.
473
480
 
474
481
  ### Syncing
@@ -677,6 +684,43 @@ Some changes can't be applied automatically. For example, if you add `NOT NULL`
677
684
 
678
685
  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.
679
686
 
687
+ ### DDL timeouts
688
+
689
+ 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.
690
+
691
+ ```python
692
+ # app/settings.py — defaults shown
693
+ POSTGRES_MIGRATION_LOCK_TIMEOUT = "3s"
694
+ POSTGRES_MIGRATION_STATEMENT_TIMEOUT = "3s"
695
+ POSTGRES_CONVERGENCE_LOCK_TIMEOUT = "3s"
696
+ POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT = "3s"
697
+ ```
698
+
699
+ `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.
700
+
701
+ 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.
702
+
703
+ Use `RunSQL(no_timeout=True)` to opt out for a specific operation:
704
+
705
+ ```python
706
+ from plain.postgres.migrations.operations import RunSQL
707
+
708
+ operations = [
709
+ RunSQL(
710
+ "UPDATE orders SET status = 'pending' WHERE status IS NULL",
711
+ no_timeout=True,
712
+ ),
713
+ ]
714
+ ```
715
+
716
+ 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.
717
+
718
+ Environment overrides: every setting accepts `PLAIN_POSTGRES_*` env vars, so you can raise the ceiling for a specific deploy without a code change:
719
+
720
+ ```bash
721
+ PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT=30s plain migrations apply
722
+ ```
723
+
680
724
  ## Fields
681
725
 
682
726
  You can use many field types for different data:
@@ -701,8 +745,8 @@ class Product(postgres.Model):
701
745
  is_active: bool = types.BooleanField(default=True)
702
746
 
703
747
  # Date and time fields
704
- created_at: datetime = types.DateTimeField(auto_now_add=True)
705
- updated_at: datetime = types.DateTimeField(auto_now=True)
748
+ created_at: datetime = types.DateTimeField(create_now=True)
749
+ updated_at: datetime = types.DateTimeField(update_now=True)
706
750
  ```
707
751
 
708
752
  **Text fields:**
@@ -730,10 +774,11 @@ class Product(postgres.Model):
730
774
  **Other fields:**
731
775
 
732
776
  - [`BooleanField`](./fields/__init__.py#BooleanField) - True/False
733
- - [`UUIDField`](./fields/__init__.py#UUIDField) - UUID
777
+ - [`UUIDField`](./fields/__init__.py#UUIDField) - UUID (pass `generate=True` for a per-row `gen_random_uuid()` default)
734
778
  - [`BinaryField`](./fields/__init__.py#BinaryField) - Raw binary data
735
779
  - [`JSONField`](./fields/json.py#JSONField) - JSON data
736
780
  - [`GenericIPAddressField`](./fields/__init__.py#GenericIPAddressField) - IPv4 or IPv6 address
781
+ - [`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
737
782
 
738
783
  **Encrypted fields:**
739
784
 
@@ -763,8 +808,8 @@ from plain.postgres import types
763
808
 
764
809
  # Regular Python class for shared fields
765
810
  class TimestampedMixin:
766
- created_at: datetime = types.DateTimeField(auto_now_add=True)
767
- updated_at: datetime = types.DateTimeField(auto_now=True)
811
+ created_at: datetime = types.DateTimeField(create_now=True)
812
+ updated_at: datetime = types.DateTimeField(update_now=True)
768
813
 
769
814
 
770
815
  # Models inherit from the mixin AND postgres.Model
@@ -1155,17 +1200,21 @@ When `DATABASE_URL` is set, it is parsed into the individual connection settings
1155
1200
 
1156
1201
  Set `DATABASE_URL=none` to explicitly disable the database (e.g. during Docker image builds).
1157
1202
 
1158
- | Setting | Type | Default | Env var |
1159
- | ----------------------------- | ------------- | ------- | ----------------------------------- |
1160
- | `POSTGRES_HOST` | `str` | — | `PLAIN_POSTGRES_HOST` |
1161
- | `POSTGRES_PORT` | `int \| None` | `None` | `PLAIN_POSTGRES_PORT` |
1162
- | `POSTGRES_DATABASE` | `str` | — | `PLAIN_POSTGRES_DATABASE` |
1163
- | `POSTGRES_USER` | `str` | — | `PLAIN_POSTGRES_USER` |
1164
- | `POSTGRES_PASSWORD` | `Secret[str]` | — | `PLAIN_POSTGRES_PASSWORD` |
1165
- | `POSTGRES_CONN_MAX_AGE` | `int` | `600` | `PLAIN_POSTGRES_CONN_MAX_AGE` |
1166
- | `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
1167
- | `POSTGRES_OPTIONS` | `dict` | `{}` | — |
1168
- | `POSTGRES_TIME_ZONE` | `str \| None` | `None` | `PLAIN_POSTGRES_TIME_ZONE` |
1203
+ | Setting | Type | Default | Env var |
1204
+ | ---------------------------------------- | ------------- | ------- | ---------------------------------------------- |
1205
+ | `POSTGRES_HOST` | `str` | — | `PLAIN_POSTGRES_HOST` |
1206
+ | `POSTGRES_PORT` | `int \| None` | `None` | `PLAIN_POSTGRES_PORT` |
1207
+ | `POSTGRES_DATABASE` | `str` | — | `PLAIN_POSTGRES_DATABASE` |
1208
+ | `POSTGRES_USER` | `str` | — | `PLAIN_POSTGRES_USER` |
1209
+ | `POSTGRES_PASSWORD` | `Secret[str]` | — | `PLAIN_POSTGRES_PASSWORD` |
1210
+ | `POSTGRES_CONN_MAX_AGE` | `int` | `600` | `PLAIN_POSTGRES_CONN_MAX_AGE` |
1211
+ | `POSTGRES_CONN_HEALTH_CHECKS` | `bool` | `True` | `PLAIN_POSTGRES_CONN_HEALTH_CHECKS` |
1212
+ | `POSTGRES_OPTIONS` | `dict` | `{}` | — |
1213
+ | `POSTGRES_TIME_ZONE` | `str \| None` | `None` | `PLAIN_POSTGRES_TIME_ZONE` |
1214
+ | `POSTGRES_MIGRATION_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_LOCK_TIMEOUT` |
1215
+ | `POSTGRES_MIGRATION_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT` |
1216
+ | `POSTGRES_CONVERGENCE_LOCK_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_LOCK_TIMEOUT` |
1217
+ | `POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` | `str` | `"3s"` | `PLAIN_POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT` |
1169
1218
 
1170
1219
  See [`default_settings.py`](./default_settings.py) for more details.
1171
1220
 
@@ -1173,7 +1222,19 @@ See [`default_settings.py`](./default_settings.py) for more details.
1173
1222
 
1174
1223
  #### How do I add a field to an existing model?
1175
1224
 
1176
- 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.
1225
+ Add the field to your model class, then run `plain migrations create` to create a migration.
1226
+
1227
+ 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:
1228
+
1229
+ 1. Declare a `default=` on the field so the new column has a value for existing rows.
1230
+ 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`.
1231
+
1232
+ #### How do I make an existing column `NOT NULL`?
1233
+
1234
+ 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`:
1235
+
1236
+ - If the column has no `NULL` rows, convergence applies the change with a non-blocking `CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL` pattern.
1237
+ - 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.
1177
1238
 
1178
1239
  #### How do I create a unique constraint on multiple fields?
1179
1240
 
@@ -9,7 +9,7 @@ from .constraints import CheckConstraint, UniqueConstraint
9
9
  from .db import get_connection
10
10
  from .deletion import CASCADE, NO_ACTION, RESTRICT, SET_NULL
11
11
  from .expressions import F
12
- from .enums import IntegerChoices, TextChoices
12
+ from .enums import TextChoices
13
13
  from .fields import (
14
14
  BigIntegerField,
15
15
  BinaryField,
@@ -23,6 +23,7 @@ from .fields import (
23
23
  GenericIPAddressField,
24
24
  IntegerField,
25
25
  PrimaryKeyField,
26
+ RandomStringField,
26
27
  SmallIntegerField,
27
28
  TextField,
28
29
  TimeField,
@@ -54,7 +55,6 @@ __all__ = [
54
55
  "CheckConstraint",
55
56
  "UniqueConstraint",
56
57
  # From enums
57
- "IntegerChoices",
58
58
  "TextChoices",
59
59
  # From fields
60
60
  "BigIntegerField",
@@ -69,6 +69,7 @@ __all__ = [
69
69
  "GenericIPAddressField",
70
70
  "IntegerField",
71
71
  "PrimaryKeyField",
72
+ "RandomStringField",
72
73
  "SmallIntegerField",
73
74
  "TextField",
74
75
  "TimeField",