plain.postgres 0.91.0__tar.gz → 0.92.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 (137) hide show
  1. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/PKG-INFO +122 -31
  2. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/CHANGELOG.md +38 -1
  3. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/README.md +121 -30
  4. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/cli/converge.py +9 -9
  5. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/cli/schema.py +17 -7
  6. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/cli/sync.py +28 -23
  7. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/connection.py +2 -4
  8. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/constraints.py +11 -1
  9. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/convergence/__init__.py +10 -0
  10. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/convergence/analysis.py +568 -110
  11. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/convergence/fixes.py +138 -3
  12. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/convergence/planning.py +37 -1
  13. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/dialect.py +0 -5
  14. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/indexes.py +2 -53
  15. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/introspection/__init__.py +10 -2
  16. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/introspection/schema.py +111 -34
  17. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/autodetector.py +1 -37
  18. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/executor.py +2 -7
  19. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/questioner.py +0 -26
  20. plain_postgres-0.92.0/plain/postgres/schema.py +570 -0
  21. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/utils.py +25 -0
  22. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/pyproject.toml +1 -1
  23. plain_postgres-0.92.0/tests/app/examples/migrations/0007_treenode_unconstrainedchild.py +51 -0
  24. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/app/examples/models.py +24 -0
  25. plain_postgres-0.92.0/tests/conftest_convergence.py +117 -0
  26. plain_postgres-0.92.0/tests/test_convergence.py +857 -0
  27. plain_postgres-0.92.0/tests/test_convergence_constraints.py +984 -0
  28. plain_postgres-0.92.0/tests/test_convergence_fk.py +365 -0
  29. plain_postgres-0.92.0/tests/test_convergence_indexes.py +497 -0
  30. plain_postgres-0.92.0/tests/test_convergence_nullability.py +299 -0
  31. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_introspection.py +86 -28
  32. plain_postgres-0.92.0/tests/test_migration_executor.py +93 -0
  33. plain_postgres-0.91.0/plain/postgres/schema.py +0 -1652
  34. plain_postgres-0.91.0/tests/test_convergence.py +0 -1652
  35. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/.gitignore +0 -0
  36. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/CLAUDE.md +0 -0
  37. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/LICENSE +0 -0
  38. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/README.md +0 -0
  39. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/__init__.py +0 -0
  40. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/agents/.claude/rules/plain-postgres.md +0 -0
  41. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/agents/.claude/skills/plain-postgres-doctor/SKILL.md +0 -0
  42. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/aggregates.py +0 -0
  43. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/base.py +0 -0
  44. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/cli/__init__.py +0 -0
  45. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/cli/core.py +0 -0
  46. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/cli/diagnose.py +0 -0
  47. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/cli/migrations.py +0 -0
  48. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/config.py +0 -0
  49. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/connections.py +0 -0
  50. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/constants.py +0 -0
  51. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/database_url.py +0 -0
  52. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/db.py +0 -0
  53. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/ddl.py +0 -0
  54. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/default_settings.py +0 -0
  55. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/deletion.py +0 -0
  56. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/entrypoints.py +0 -0
  57. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/enums.py +0 -0
  58. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/exceptions.py +0 -0
  59. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/expressions.py +0 -0
  60. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/fields/__init__.py +0 -0
  61. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/fields/encrypted.py +0 -0
  62. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/fields/json.py +0 -0
  63. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/fields/mixins.py +0 -0
  64. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/fields/related.py +0 -0
  65. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/fields/related_descriptors.py +0 -0
  66. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/fields/related_lookups.py +0 -0
  67. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/fields/related_managers.py +0 -0
  68. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/fields/reverse_descriptors.py +0 -0
  69. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/fields/reverse_related.py +0 -0
  70. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/fields/timezones.py +0 -0
  71. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/forms.py +0 -0
  72. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/functions/__init__.py +0 -0
  73. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/functions/comparison.py +0 -0
  74. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/functions/datetime.py +0 -0
  75. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/functions/math.py +0 -0
  76. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/functions/mixins.py +0 -0
  77. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/functions/text.py +0 -0
  78. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/functions/window.py +0 -0
  79. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/introspection/health.py +0 -0
  80. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/lookups.py +0 -0
  81. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/meta.py +0 -0
  82. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/__init__.py +0 -0
  83. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/exceptions.py +0 -0
  84. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/graph.py +0 -0
  85. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/loader.py +0 -0
  86. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/migration.py +0 -0
  87. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/operations/__init__.py +0 -0
  88. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/operations/base.py +0 -0
  89. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/operations/fields.py +0 -0
  90. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/operations/models.py +0 -0
  91. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/operations/special.py +0 -0
  92. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/optimizer.py +0 -0
  93. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/recorder.py +0 -0
  94. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/serializer.py +0 -0
  95. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/state.py +0 -0
  96. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/utils.py +0 -0
  97. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/migrations/writer.py +0 -0
  98. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/options.py +0 -0
  99. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/otel.py +0 -0
  100. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/preflight.py +0 -0
  101. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/query.py +0 -0
  102. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/query_utils.py +0 -0
  103. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/registry.py +0 -0
  104. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/sql/__init__.py +0 -0
  105. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/sql/compiler.py +0 -0
  106. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/sql/constants.py +0 -0
  107. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/sql/datastructures.py +0 -0
  108. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/sql/query.py +0 -0
  109. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/sql/where.py +0 -0
  110. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/test/__init__.py +0 -0
  111. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/test/pytest.py +0 -0
  112. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/test/utils.py +0 -0
  113. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/transaction.py +0 -0
  114. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/types.py +0 -0
  115. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/plain/postgres/types.pyi +0 -0
  116. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/app/examples/migrations/0001_initial.py +0 -0
  117. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/app/examples/migrations/0002_test_field_removed.py +0 -0
  118. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/app/examples/migrations/0003_deleteparent_childsetnull_childsetdefault_and_more.py +0 -0
  119. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/app/examples/migrations/0004_defaultquerysetmodel_mixintestmodel_and_more.py +0 -0
  120. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/app/examples/migrations/0005_feature_carfeature_car_features.py +0 -0
  121. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/app/examples/migrations/0006_secretstore.py +0 -0
  122. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/app/examples/migrations/__init__.py +0 -0
  123. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/app/settings.py +0 -0
  124. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/app/urls.py +0 -0
  125. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_connection_isolation.py +0 -0
  126. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_connection_lifecycle.py +0 -0
  127. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_database_url.py +0 -0
  128. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_delete_behaviors.py +0 -0
  129. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_encrypted_fields.py +0 -0
  130. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_exceptions.py +0 -0
  131. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_iterator.py +0 -0
  132. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_manager_assignment.py +0 -0
  133. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_models.py +0 -0
  134. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_read_only_transactions.py +0 -0
  135. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_related_descriptors.py +0 -0
  136. {plain_postgres-0.91.0 → plain_postgres-0.92.0}/tests/test_related_manager_api.py +0 -0
  137. {plain_postgres-0.91.0 → plain_postgres-0.92.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.91.0
3
+ Version: 0.92.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
@@ -17,7 +17,10 @@ Description-Content-Type: text/markdown
17
17
  - [Overview](#overview)
18
18
  - [Database connection](#database-connection)
19
19
  - [Querying](#querying)
20
- - [Migrations](#migrations)
20
+ - [Schema management](#schema-management)
21
+ - [Syncing](#syncing)
22
+ - [Migrations](#migrations)
23
+ - [Convergence](#convergence)
21
24
  - [Fields](#fields)
22
25
  - [Relationships](#relationships)
23
26
  - [Constraints](#constraints)
@@ -440,49 +443,86 @@ conn.set_read_only(False) # back to normal
440
443
 
441
444
  Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
442
445
 
443
- ## Migrations
446
+ ## Schema management
444
447
 
445
- Migrations track changes to your models and update the database schema accordingly. They are Python files stored in your app's `migrations/` directory.
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.
446
449
 
447
- ### Creating migrations
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
+ ```
460
+
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.
462
+
463
+ ### Syncing
464
+
465
+ The primary command for all schema management is `postgres sync`. It runs migrations and convergence together:
448
466
 
449
467
  ```bash
450
- plain migrations create
468
+ plain postgres sync
451
469
  ```
452
470
 
453
- Key flags:
471
+ ```
472
+ plain postgres sync
473
+
474
+ ├─ 1. Create migrations (development only)
475
+ │ Detects model changes, generates migration files
476
+
477
+ ├─ 2. Apply migrations
478
+ │ Runs pending migrations in a single transaction
479
+
480
+ └─ 3. Converge
481
+ Compares indexes, constraints, FKs, and nullability
482
+ against model declarations — applies fixes independently
483
+ ```
454
484
 
455
- - `--dry-run` Show what migrations would be created (with operations and SQL) without writing files
456
- - `--check` — Exit non-zero if migrations are needed (for CI)
457
- - `--empty <package>` — Create an empty migration for custom data migrations
458
- - `--name <name>` — Set the migration filename
459
- - `-v 3` — Show full migration file contents
485
+ In development (`DEBUG=True`), sync auto-generates migrations before applying them. In production, it only applies existing migrations and converges.
460
486
 
461
- Only write migrations by hand if they are custom data migrations.
487
+ | Command | Purpose |
488
+ | --------------------------------------- | --------------------------------------------------------------------- |
489
+ | `plain postgres sync` | Create + apply migrations + converge (the one command for everything) |
490
+ | `plain postgres sync --check` | Exit non-zero if anything would change (for CI) |
491
+ | `plain postgres sync --drop-undeclared` | Also remove indexes/constraints not declared on any model |
492
+ | `plain postgres schema` | Show schema state with drift detection |
493
+ | `plain postgres schema --json` | Machine-readable schema output |
494
+ | `plain postgres converge` | Run convergence alone (advanced) |
462
495
 
463
- ### Running migrations
496
+ ### Migrations
497
+
498
+ Migrations track structural changes to your models. They are Python files stored in your app's `migrations/` directory.
464
499
 
465
500
  ```bash
466
- plain migrations apply
501
+ plain migrations create
467
502
  ```
468
503
 
469
504
  Key flags:
470
505
 
471
- - `--plan` — Show what migrations would run without applying them
472
- - `--check` — Exit non-zero if unapplied migrations exist (for CI)
473
- - `--fake`Mark migrations as applied without running them
474
-
475
- ### Viewing migration status
506
+ - `--dry-run` — Show what migrations would be created (with operations and SQL) without writing files
507
+ - `--check` — Exit non-zero if migrations are needed (for CI)
508
+ - `--empty <package>` Create an empty migration for custom data migrations
509
+ - `--name <name>` — Set the migration filename
476
510
 
477
- ```bash
478
- plain migrations list
479
- ```
511
+ Only write migrations by hand if they are custom data migrations.
480
512
 
481
- `migrations apply` has no `--list` or `--status` flag. Use `plain migrations list`.
513
+ Other migration commands:
482
514
 
483
- - `--format plan` — Show in dependency order instead of grouped by package
515
+ | Command | Purpose |
516
+ | ------------------------------------------- | ---------------------------------------------------- |
517
+ | `plain migrations apply` | Apply pending migrations |
518
+ | `plain migrations apply --plan` | Preview what would run |
519
+ | `plain migrations apply --check` | Exit non-zero if unapplied migrations exist (for CI) |
520
+ | `plain migrations apply --fake` | Mark as applied without running SQL |
521
+ | `plain migrations list` | View migration status by package |
522
+ | `plain migrations squash <pkg> <migration>` | Squash migrations into one |
523
+ | `plain migrations prune` | Remove stale migration records |
484
524
 
485
- ### Development workflow
525
+ #### Development workflow
486
526
 
487
527
  During development, iterating on models often produces multiple small migrations (0002, 0003, 0004...). Clean these up before committing.
488
528
 
@@ -509,7 +549,7 @@ Use this when migrations have already been committed or deployed to other enviro
509
549
  | Migrations are committed but not deployed | Delete-and-recreate (if all developers reset) or squash |
510
550
  | Migrations are deployed to production | Squash or full reset |
511
551
 
512
- ### Resetting migrations
552
+ #### Resetting migrations
513
553
 
514
554
  Over time a package can accumulate dozens of migrations. Once **every environment** (dev, staging, production) has applied all of them, you can replace the entire history with a single fresh `0001_initial`.
515
555
 
@@ -533,10 +573,61 @@ Over time a package can accumulate dozens of migrations. Once **every environmen
533
573
  - Data migrations (`RunPython`) in the deleted history are gone, which is fine since they've already run everywhere.
534
574
  - 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.
535
575
 
536
- ### Other migration commands
576
+ ### Convergence
577
+
578
+ 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`.
579
+
580
+ ```python
581
+ @postgres.register_model
582
+ class User(postgres.Model):
583
+ email: str = types.EmailField()
584
+ username: str = types.TextField(max_length=150)
585
+ age: int = types.IntegerField()
586
+
587
+ model_options = postgres.Options(
588
+ indexes=[
589
+ postgres.Index(fields=["email"], name="users_email_idx"),
590
+ ],
591
+ constraints=[
592
+ postgres.UniqueConstraint(fields=["email"], name="users_email_uniq"),
593
+ postgres.CheckConstraint(check=postgres.Q(age__gte=0), name="users_age_positive"),
594
+ ],
595
+ )
596
+ ```
597
+
598
+ When you run `postgres sync`, convergence detects that these indexes and constraints are missing and creates them — using non-blocking DDL operations where possible (e.g. `CREATE INDEX CONCURRENTLY`, `ADD CONSTRAINT ... NOT VALID` followed by `VALIDATE CONSTRAINT`).
599
+
600
+ **Key properties:**
601
+
602
+ - **Idempotent** — safe to run repeatedly. If the database already matches, nothing happens.
603
+ - **Non-blocking** — indexes are built with `CONCURRENTLY`, constraints use `NOT VALID` + `VALIDATE` to avoid locking writes.
604
+ - **Per-operation commits** — each fix is committed independently so a single failure doesn't roll back other fixes.
605
+ - **Self-healing** — detects and rebuilds `INVALID` indexes (e.g. from a previously failed `CREATE INDEX CONCURRENTLY`).
606
+ - **Rename-aware** — detects renamed indexes and constraints by matching their structure, avoiding unnecessary drop + recreate.
607
+
608
+ **Inspecting schema state:**
609
+
610
+ Use `postgres schema` to see what convergence would do. It shows every model's columns, indexes, and constraints compared against the database, with issues highlighted:
611
+
612
+ ```bash
613
+ plain postgres schema # all models
614
+ plain postgres schema User # single model
615
+ plain postgres schema --json # machine-readable output
616
+ ```
617
+
618
+ **Staged rollouts:**
619
+
620
+ Some changes can't be applied automatically. For example, if you add `NOT NULL` to a column that has existing `NULL` rows, convergence will report this as a blocked change and tell you to backfill the data first. Run `postgres sync` again after the backfill.
621
+
622
+ **Cleanup:**
623
+
624
+ When you remove an index or constraint from a model, the database object still exists. By default, `postgres sync` reports undeclared objects but doesn't drop them. Use `--drop-undeclared` to remove them:
625
+
626
+ ```bash
627
+ plain postgres sync --drop-undeclared
628
+ ```
537
629
 
538
- - `plain migrations squash <package> <migration>` Squash migrations into one
539
- - `plain migrations prune` — Remove stale migration records
630
+ Undeclared constraints block sync (they affect query behavior), while undeclared indexes are just reported as warnings.
540
631
 
541
632
  ## Fields
542
633
 
@@ -795,7 +886,7 @@ Field-level validation happens automatically based on field types and constraint
795
886
 
796
887
  ### Indexes and constraints
797
888
 
798
- You can optimize queries and ensure data integrity with indexes and constraints:
889
+ You can optimize queries and ensure data integrity with indexes and constraints. These are managed automatically by [convergence](#convergence) — just declare them on the model and run `postgres sync`.
799
890
 
800
891
  ```python
801
892
  class User(postgres.Model):
@@ -1,5 +1,42 @@
1
1
  # plain-postgres changelog
2
2
 
3
+ ## [0.92.0](https://github.com/dropseed/plain/releases/plain-postgres@0.92.0) (2026-03-30)
4
+
5
+ ### What's changed
6
+
7
+ - **Foreign key constraints are now managed by convergence, not migrations.** The schema editor no longer creates, drops, or alters FK constraints — convergence handles them declaratively using `ADD CONSTRAINT ... NOT VALID` followed by `VALIDATE CONSTRAINT`. FK constraint names are deterministic and match the old migration-generated names. ([b2b968297fea](https://github.com/dropseed/plain/commit/b2b968297fea), [8658be035a46](https://github.com/dropseed/plain/commit/8658be035a46))
8
+
9
+ - **NOT NULL enforcement is now managed by convergence.** Column nullability drift is detected and fixed automatically — convergence uses the safe `CHECK NOT VALID → VALIDATE → SET NOT NULL` pattern to avoid long table locks. Columns with existing NULL rows are reported as blocked, requiring a backfill before convergence can proceed. ([5ea3dc589453](https://github.com/dropseed/plain/commit/5ea3dc589453))
10
+
11
+ - **Managed type boundaries** — convergence now distinguishes managed vs unmanaged index types and constraint types. Only btree/hash indexes and check/unique/FK constraints participate in drift detection and rename matching. Unmanaged types (GIN, GiST, BRIN, exclusion, trigger) are displayed for informational purposes but are never modified or reported as undeclared. ([f123eae2fa56](https://github.com/dropseed/plain/commit/f123eae2fa56))
12
+
13
+ - **Unique constraint drift detection** — convergence now compares unique constraint definitions (not just column lists), detecting behavioral changes like modified WHERE clauses, opclasses, or expressions. Index-only uniques (partial, expression, or opclass) are correctly handled through both pg_constraint and pg_index. ([09b439e8448a](https://github.com/dropseed/plain/commit/09b439e8448a))
14
+
15
+ - **Full index definition matching** — index drift detection now compares normalized `CREATE INDEX` definitions instead of just column lists, catching changes to conditions, expressions, opclasses, and include columns. ([70d7a6725498](https://github.com/dropseed/plain/commit/70d7a6725498))
16
+
17
+ - Removed dead index/constraint/deferred SQL infrastructure and primary-key transition code from the schema editor. ([266b0635f0bf](https://github.com/dropseed/plain/commit/266b0635f0bf), [4a92f5479e4e](https://github.com/dropseed/plain/commit/4a92f5479e4e))
18
+
19
+ - Rewrote the introspection layer to mirror Postgres catalog structures — `TableState` now uses a unified `constraints` dict keyed by constraint name with `ConType` enum, replacing the separate `unique_constraints`, `check_constraints`, and `foreign_keys` dicts. ([f123eae2fa56](https://github.com/dropseed/plain/commit/f123eae2fa56))
20
+
21
+ - Expanded schema management documentation with a comprehensive overview of the migrations + convergence split, sync workflow, and convergence behavior. ([57caeee5ff89](https://github.com/dropseed/plain/commit/57caeee5ff89))
22
+
23
+ ### Upgrade instructions
24
+
25
+ - If you have custom code that interacts with `TableState.unique_constraints`, `TableState.check_constraints`, or `TableState.foreign_keys`, update it to use the unified `TableState.constraints` dict with `ConType` filtering instead.
26
+ - FK constraints in existing databases are left as-is. New FKs will be created by convergence on the next `postgres sync`.
27
+ - NOT NULL enforcement is automatic — `postgres sync` will detect and fix nullability drift. If columns have existing NULL rows, you'll need to backfill before convergence can apply NOT NULL.
28
+
29
+ ## [0.91.1](https://github.com/dropseed/plain/releases/plain-postgres@0.91.1) (2026-03-29)
30
+
31
+ ### What's changed
32
+
33
+ - Indented `sync` and `converge` sub-items under section headers for readability in environments without ANSI colors (e.g. Heroku deploy logs). ([b6b494dcc698](https://github.com/dropseed/plain/commit/b6b494dcc698))
34
+ - `sync` now uses `MigrationExecutor` directly instead of calling through the CLI layer, giving cleaner indented output. ([b6b494dcc698](https://github.com/dropseed/plain/commit/b6b494dcc698))
35
+
36
+ ### Upgrade instructions
37
+
38
+ - No changes required.
39
+
3
40
  ## [0.91.0](https://github.com/dropseed/plain/releases/plain-postgres@0.91.0) (2026-03-29)
4
41
 
5
42
  ### What's changed
@@ -22,7 +59,7 @@
22
59
 
23
60
  1. **Replace `migrate` with `postgres sync` in deploy scripts and CI.** `postgres sync` applies migrations and runs convergence in a single step. For CI checks, use `postgres sync --check` instead of `migrate --check` / `makemigrations --check`. The lower-level commands are still available as `migrations create` and `migrations apply`.
24
61
 
25
- 2. **Remove index/constraint operations from migration files.** Delete any `AddIndex`, `RemoveIndex`, `RenameIndex`, `AddConstraint`, and `RemoveConstraint` operations from your migration files — these classes no longer exist and will cause import errors. If removing an operation leaves `operations = []`, delete the migration file and run `plain migrations prune --yes`. Indexes and constraints declared on your models will be created automatically by convergence.
62
+ 2. **Remove index/constraint operations from migration files.** Delete any `AddIndex`, `RemoveIndex`, `RenameIndex`, `AddConstraint`, and `RemoveConstraint` operations from your migration files — these classes no longer exist and will cause import errors. It's fine to leave a migration with `operations = []`. Indexes and constraints declared on your models will be created automatically by convergence.
26
63
 
27
64
  3. **Replace `PositiveIntegerField`** (and `PositiveBigIntegerField`, `PositiveSmallIntegerField`) with `IntegerField` (or `BigIntegerField`, `SmallIntegerField`) in both models and migration files. Add a `CheckConstraint` if you need to enforce positive values.
28
65
 
@@ -5,7 +5,10 @@
5
5
  - [Overview](#overview)
6
6
  - [Database connection](#database-connection)
7
7
  - [Querying](#querying)
8
- - [Migrations](#migrations)
8
+ - [Schema management](#schema-management)
9
+ - [Syncing](#syncing)
10
+ - [Migrations](#migrations)
11
+ - [Convergence](#convergence)
9
12
  - [Fields](#fields)
10
13
  - [Relationships](#relationships)
11
14
  - [Constraints](#constraints)
@@ -428,49 +431,86 @@ conn.set_read_only(False) # back to normal
428
431
 
429
432
  Read-only mode must be set outside a transaction — calling it inside `atomic()` raises `TransactionManagementError`.
430
433
 
431
- ## Migrations
434
+ ## Schema management
432
435
 
433
- Migrations track changes to your models and update the database schema accordingly. They are Python files stored in your app's `migrations/` directory.
436
+ 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.
434
437
 
435
- ### Creating migrations
438
+ ```
439
+ Migrations Convergence
440
+ (imperative, ordered) (declarative, idempotent)
441
+ ───────────────────────────── ─────────────────────────────
442
+ Create / drop tables Indexes
443
+ Add / remove / rename columns Check constraints
444
+ Change column types Unique constraints
445
+ Data migrations (RunPython) Foreign key constraints
446
+ Custom SQL (RunSQL) NOT NULL enforcement
447
+ ```
448
+
449
+ 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.
450
+
451
+ ### Syncing
452
+
453
+ The primary command for all schema management is `postgres sync`. It runs migrations and convergence together:
436
454
 
437
455
  ```bash
438
- plain migrations create
456
+ plain postgres sync
439
457
  ```
440
458
 
441
- Key flags:
459
+ ```
460
+ plain postgres sync
461
+
462
+ ├─ 1. Create migrations (development only)
463
+ │ Detects model changes, generates migration files
464
+
465
+ ├─ 2. Apply migrations
466
+ │ Runs pending migrations in a single transaction
467
+
468
+ └─ 3. Converge
469
+ Compares indexes, constraints, FKs, and nullability
470
+ against model declarations — applies fixes independently
471
+ ```
442
472
 
443
- - `--dry-run` Show what migrations would be created (with operations and SQL) without writing files
444
- - `--check` — Exit non-zero if migrations are needed (for CI)
445
- - `--empty <package>` — Create an empty migration for custom data migrations
446
- - `--name <name>` — Set the migration filename
447
- - `-v 3` — Show full migration file contents
473
+ In development (`DEBUG=True`), sync auto-generates migrations before applying them. In production, it only applies existing migrations and converges.
448
474
 
449
- Only write migrations by hand if they are custom data migrations.
475
+ | Command | Purpose |
476
+ | --------------------------------------- | --------------------------------------------------------------------- |
477
+ | `plain postgres sync` | Create + apply migrations + converge (the one command for everything) |
478
+ | `plain postgres sync --check` | Exit non-zero if anything would change (for CI) |
479
+ | `plain postgres sync --drop-undeclared` | Also remove indexes/constraints not declared on any model |
480
+ | `plain postgres schema` | Show schema state with drift detection |
481
+ | `plain postgres schema --json` | Machine-readable schema output |
482
+ | `plain postgres converge` | Run convergence alone (advanced) |
450
483
 
451
- ### Running migrations
484
+ ### Migrations
485
+
486
+ Migrations track structural changes to your models. They are Python files stored in your app's `migrations/` directory.
452
487
 
453
488
  ```bash
454
- plain migrations apply
489
+ plain migrations create
455
490
  ```
456
491
 
457
492
  Key flags:
458
493
 
459
- - `--plan` — Show what migrations would run without applying them
460
- - `--check` — Exit non-zero if unapplied migrations exist (for CI)
461
- - `--fake`Mark migrations as applied without running them
462
-
463
- ### Viewing migration status
494
+ - `--dry-run` — Show what migrations would be created (with operations and SQL) without writing files
495
+ - `--check` — Exit non-zero if migrations are needed (for CI)
496
+ - `--empty <package>` Create an empty migration for custom data migrations
497
+ - `--name <name>` — Set the migration filename
464
498
 
465
- ```bash
466
- plain migrations list
467
- ```
499
+ Only write migrations by hand if they are custom data migrations.
468
500
 
469
- `migrations apply` has no `--list` or `--status` flag. Use `plain migrations list`.
501
+ Other migration commands:
470
502
 
471
- - `--format plan` — Show in dependency order instead of grouped by package
503
+ | Command | Purpose |
504
+ | ------------------------------------------- | ---------------------------------------------------- |
505
+ | `plain migrations apply` | Apply pending migrations |
506
+ | `plain migrations apply --plan` | Preview what would run |
507
+ | `plain migrations apply --check` | Exit non-zero if unapplied migrations exist (for CI) |
508
+ | `plain migrations apply --fake` | Mark as applied without running SQL |
509
+ | `plain migrations list` | View migration status by package |
510
+ | `plain migrations squash <pkg> <migration>` | Squash migrations into one |
511
+ | `plain migrations prune` | Remove stale migration records |
472
512
 
473
- ### Development workflow
513
+ #### Development workflow
474
514
 
475
515
  During development, iterating on models often produces multiple small migrations (0002, 0003, 0004...). Clean these up before committing.
476
516
 
@@ -497,7 +537,7 @@ Use this when migrations have already been committed or deployed to other enviro
497
537
  | Migrations are committed but not deployed | Delete-and-recreate (if all developers reset) or squash |
498
538
  | Migrations are deployed to production | Squash or full reset |
499
539
 
500
- ### Resetting migrations
540
+ #### Resetting migrations
501
541
 
502
542
  Over time a package can accumulate dozens of migrations. Once **every environment** (dev, staging, production) has applied all of them, you can replace the entire history with a single fresh `0001_initial`.
503
543
 
@@ -521,10 +561,61 @@ Over time a package can accumulate dozens of migrations. Once **every environmen
521
561
  - Data migrations (`RunPython`) in the deleted history are gone, which is fine since they've already run everywhere.
522
562
  - 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.
523
563
 
524
- ### Other migration commands
564
+ ### Convergence
565
+
566
+ 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`.
567
+
568
+ ```python
569
+ @postgres.register_model
570
+ class User(postgres.Model):
571
+ email: str = types.EmailField()
572
+ username: str = types.TextField(max_length=150)
573
+ age: int = types.IntegerField()
574
+
575
+ model_options = postgres.Options(
576
+ indexes=[
577
+ postgres.Index(fields=["email"], name="users_email_idx"),
578
+ ],
579
+ constraints=[
580
+ postgres.UniqueConstraint(fields=["email"], name="users_email_uniq"),
581
+ postgres.CheckConstraint(check=postgres.Q(age__gte=0), name="users_age_positive"),
582
+ ],
583
+ )
584
+ ```
585
+
586
+ When you run `postgres sync`, convergence detects that these indexes and constraints are missing and creates them — using non-blocking DDL operations where possible (e.g. `CREATE INDEX CONCURRENTLY`, `ADD CONSTRAINT ... NOT VALID` followed by `VALIDATE CONSTRAINT`).
587
+
588
+ **Key properties:**
589
+
590
+ - **Idempotent** — safe to run repeatedly. If the database already matches, nothing happens.
591
+ - **Non-blocking** — indexes are built with `CONCURRENTLY`, constraints use `NOT VALID` + `VALIDATE` to avoid locking writes.
592
+ - **Per-operation commits** — each fix is committed independently so a single failure doesn't roll back other fixes.
593
+ - **Self-healing** — detects and rebuilds `INVALID` indexes (e.g. from a previously failed `CREATE INDEX CONCURRENTLY`).
594
+ - **Rename-aware** — detects renamed indexes and constraints by matching their structure, avoiding unnecessary drop + recreate.
595
+
596
+ **Inspecting schema state:**
597
+
598
+ Use `postgres schema` to see what convergence would do. It shows every model's columns, indexes, and constraints compared against the database, with issues highlighted:
599
+
600
+ ```bash
601
+ plain postgres schema # all models
602
+ plain postgres schema User # single model
603
+ plain postgres schema --json # machine-readable output
604
+ ```
605
+
606
+ **Staged rollouts:**
607
+
608
+ Some changes can't be applied automatically. For example, if you add `NOT NULL` to a column that has existing `NULL` rows, convergence will report this as a blocked change and tell you to backfill the data first. Run `postgres sync` again after the backfill.
609
+
610
+ **Cleanup:**
611
+
612
+ When you remove an index or constraint from a model, the database object still exists. By default, `postgres sync` reports undeclared objects but doesn't drop them. Use `--drop-undeclared` to remove them:
613
+
614
+ ```bash
615
+ plain postgres sync --drop-undeclared
616
+ ```
525
617
 
526
- - `plain migrations squash <package> <migration>` Squash migrations into one
527
- - `plain migrations prune` — Remove stale migration records
618
+ Undeclared constraints block sync (they affect query behavior), while undeclared indexes are just reported as warnings.
528
619
 
529
620
  ## Fields
530
621
 
@@ -783,7 +874,7 @@ Field-level validation happens automatically based on field types and constraint
783
874
 
784
875
  ### Indexes and constraints
785
876
 
786
- You can optimize queries and ensure data integrity with indexes and constraints:
877
+ You can optimize queries and ensure data integrity with indexes and constraints. These are managed automatically by [convergence](#convergence) — just declare them on the model and run `postgres sync`.
787
878
 
788
879
  ```python
789
880
  class User(postgres.Model):
@@ -66,32 +66,32 @@ def converge(yes: bool, drop_undeclared: bool) -> None:
66
66
  click.secho(f" FAILED: {r.item.describe()} — {r.error}", fg="red")
67
67
 
68
68
  click.echo()
69
- click.secho(result.summary, fg="green" if result.ok else "yellow")
69
+ click.secho(f" {result.summary}", fg="green" if result.ok else "yellow")
70
70
  if not result.ok_for_sync:
71
71
  success = False
72
72
 
73
73
  if plan.blocked:
74
74
  click.echo()
75
- click.secho("Schema changes require a staged rollout:", fg="red", bold=True)
75
+ click.secho(" Schema changes require a staged rollout:", fg="red", bold=True)
76
76
  for item in plan.blocked:
77
- click.secho(f" {item.drift.describe()}", fg="red")
77
+ click.secho(f" {item.drift.describe()}", fg="red")
78
78
  if item.guidance:
79
- click.secho(f" {item.guidance}", fg="red", dim=True)
79
+ click.secho(f" {item.guidance}", fg="red", dim=True)
80
80
  success = False
81
81
 
82
82
  if not drop_undeclared and plan.blocking_cleanup:
83
83
  click.echo()
84
- click.secho("Undeclared constraints still in database:", fg="red", bold=True)
84
+ click.secho(" Undeclared constraints still in database:", fg="red", bold=True)
85
85
  for item in plan.blocking_cleanup:
86
- click.secho(f" {item.describe()}", fg="red")
87
- click.secho("Rerun with --drop-undeclared to remove them.", fg="red")
86
+ click.secho(f" {item.describe()}", fg="red")
87
+ click.secho(" Rerun with --drop-undeclared to remove them.", fg="red")
88
88
  success = False
89
89
 
90
90
  if not drop_undeclared and plan.optional_cleanup:
91
91
  click.echo()
92
92
  for item in plan.optional_cleanup:
93
- click.echo(f" {item.describe()}")
94
- click.echo("Run with --drop-undeclared to remove undeclared indexes.")
93
+ click.echo(f" {item.describe()}")
94
+ click.echo(" Run with --drop-undeclared to remove undeclared indexes.")
95
95
 
96
96
  if not success:
97
97
  sys.exit(1)
@@ -8,7 +8,7 @@ import click
8
8
  from ..convergence.analysis import ModelAnalysis, analyze_model
9
9
  from ..convergence.planning import can_auto_fix
10
10
  from ..db import get_connection
11
- from ..introspection import get_unknown_tables
11
+ from ..introspection import MANAGED_CONSTRAINT_TYPES, get_unknown_tables
12
12
  from ..registry import models_registry
13
13
 
14
14
 
@@ -24,6 +24,10 @@ def _fixable(msg: str) -> None:
24
24
  click.secho(f" ~ {msg} (auto-fix)", fg="yellow")
25
25
 
26
26
 
27
+ def _unmanaged(type_label: str) -> None:
28
+ click.secho(f" unmanaged ({type_label})", dim=True)
29
+
30
+
27
31
  def _render_model(analysis: ModelAnalysis) -> None:
28
32
  """Render a model analysis result as human-readable output."""
29
33
  click.secho(analysis.label, bold=True, nl=False)
@@ -52,7 +56,9 @@ def _render_model(analysis: ModelAnalysis) -> None:
52
56
 
53
57
  click.echo(f" {col_display:30s} {' '.join(type_parts)}", nl=False)
54
58
 
55
- if col.issue:
59
+ if col.issue and col.drift and can_auto_fix(col.drift):
60
+ _fixable(col.issue)
61
+ elif col.issue:
56
62
  _err(col.issue)
57
63
  else:
58
64
  _ok()
@@ -66,7 +72,9 @@ def _render_model(analysis: ModelAnalysis) -> None:
66
72
  fields_str = ", ".join(idx.fields) if idx.fields else "expressions"
67
73
  click.echo(f" {idx.name} ({fields_str})", nl=False)
68
74
 
69
- if idx.issue and idx.drift and can_auto_fix(idx.drift):
75
+ if idx.access_method:
76
+ _unmanaged(idx.access_method)
77
+ elif idx.issue and idx.drift and can_auto_fix(idx.drift):
70
78
  _fixable(idx.issue)
71
79
  elif idx.issue:
72
80
  _err(idx.issue)
@@ -79,15 +87,17 @@ def _render_model(analysis: ModelAnalysis) -> None:
79
87
  click.secho(" Constraints:", dim=True)
80
88
 
81
89
  for con in analysis.constraints:
82
- con_type = con.type.upper()
90
+ con_label = con.constraint_type.label.upper()
83
91
  if con.fields:
84
92
  click.echo(
85
- f" {con.name} {con_type} ({', '.join(con.fields)})", nl=False
93
+ f" {con.name} {con_label} ({', '.join(con.fields)})", nl=False
86
94
  )
87
95
  else:
88
- click.echo(f" {con.name} {con_type}", nl=False)
96
+ click.echo(f" {con.name} {con_label}", nl=False)
89
97
 
90
- if con.issue and con.drift and can_auto_fix(con.drift):
98
+ if con.constraint_type not in MANAGED_CONSTRAINT_TYPES:
99
+ _unmanaged(con.constraint_type.label)
100
+ elif con.issue and con.drift and can_auto_fix(con.drift):
91
101
  _fixable(con.issue)
92
102
  elif con.issue:
93
103
  _err(con.issue)