ferro-orm 0.10.4__tar.gz → 0.11.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 (180) hide show
  1. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/AGENTS.md +44 -1
  2. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/CHANGELOG.md +29 -0
  3. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/Cargo.lock +42 -43
  4. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/Cargo.toml +1 -1
  5. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/PKG-INFO +1 -1
  6. ferro_orm-0.11.0/docs/brainstorms/2026-05-25-annotated-strenum-cold-hydration-requirements.md +112 -0
  7. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/coming-soon.md +5 -30
  8. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/database.md +46 -1
  9. ferro_orm-0.11.0/docs/plans/2026-05-25-001-fix-annotated-strenum-cold-hydration-plan.md +228 -0
  10. ferro_orm-0.11.0/docs/solutions/issues/sqlite-integer-decimal-hydrates-as-none.md +87 -0
  11. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/sqlite-null-hydrates-as-int-zero.md +1 -0
  12. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/configurable-column-storage-types.md +8 -6
  13. ferro_orm-0.11.0/docs/solutions/patterns/ddl-on-live-engine.md +68 -0
  14. ferro_orm-0.11.0/docs/solutions/patterns/sqlite-alembic-reconnect-hydration-tests.md +58 -0
  15. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/typed-null-binds.md +13 -0
  16. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/pyproject.toml +1 -1
  17. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/backend.rs +349 -11
  18. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/connection.rs +26 -37
  19. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/__init__.py +35 -0
  20. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/_core.pyi +34 -0
  21. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/metaclass.py +20 -1
  22. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/models.py +19 -41
  23. ferro_orm-0.11.0/src/introspect.rs +334 -0
  24. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/lib.rs +35 -2
  25. ferro_orm-0.11.0/src/migrate.rs +1267 -0
  26. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/operations.rs +64 -68
  27. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/query.rs +11 -15
  28. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/schema.rs +338 -210
  29. ferro_orm-0.11.0/tests/test_auto_migrate.py +582 -0
  30. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_connection.py +7 -4
  31. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_cross_emitter_parity.py +99 -3
  32. ferro_orm-0.11.0/tests/test_enum_cold_hydration.py +64 -0
  33. ferro_orm-0.11.0/tests/test_migrate_plan.py +225 -0
  34. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/uv.lock +1 -1
  35. ferro_orm-0.10.4/tests/test_auto_migrate.py +0 -200
  36. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  37. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  38. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/PERMISSIONS.md +0 -0
  39. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/PYPI_CHECKLIST.md +0 -0
  40. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/PYPI_SETUP.md +0 -0
  41. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/generated/wheels.generated.yml +0 -0
  42. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/pull_request_template.md +0 -0
  43. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/workflows/ci.yml +0 -0
  44. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/workflows/packaging-smoke.yml +0 -0
  45. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/workflows/publish-docs.yml +0 -0
  46. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/workflows/publish.yml +0 -0
  47. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.github/workflows/release.yml +0 -0
  48. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.gitignore +0 -0
  49. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.pre-commit-config.yaml +0 -0
  50. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/.python-version +0 -0
  51. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/CONTRIBUTING.md +0 -0
  52. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/LICENSE +0 -0
  53. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/README.md +0 -0
  54. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/TEST_RESULTS.md +0 -0
  55. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/exceptions.md +0 -0
  56. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/fields.md +0 -0
  57. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/model.md +0 -0
  58. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/query.md +0 -0
  59. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/raw-sql.md +0 -0
  60. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/relationships.md +0 -0
  61. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/transactions.md +0 -0
  62. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/api/utilities.md +0 -0
  63. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +0 -0
  64. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/brainstorms/2026-05-08-typed-query-predicates-requirements.md +0 -0
  65. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/brainstorms/2026-05-13-configurable-column-storage-types-requirements.md +0 -0
  66. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/brainstorms/2026-05-14-autogenerate-support-for-db-type-requirements.md +0 -0
  67. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/changelog.md +0 -0
  68. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/concepts/architecture.md +0 -0
  69. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/concepts/identity-map.md +0 -0
  70. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/concepts/performance.md +0 -0
  71. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/concepts/query-typing.md +0 -0
  72. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/concepts/type-safety.md +0 -0
  73. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/contributing.md +0 -0
  74. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/faq.md +0 -0
  75. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/getting-started/installation.md +0 -0
  76. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/getting-started/next-steps.md +0 -0
  77. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/getting-started/tutorial.md +0 -0
  78. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/backend.md +0 -0
  79. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/migrations.md +0 -0
  80. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/models-and-fields.md +0 -0
  81. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/mutations.md +0 -0
  82. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/queries.md +0 -0
  83. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/relationships.md +0 -0
  84. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/guide/transactions.md +0 -0
  85. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/howto/multiple-databases.md +0 -0
  86. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/howto/pagination.md +0 -0
  87. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/howto/soft-deletes.md +0 -0
  88. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/howto/testing.md +0 -0
  89. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/howto/timestamps.md +0 -0
  90. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/index.md +0 -0
  91. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/migration-sqlalchemy.md +0 -0
  92. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  93. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-04-29-001-typed-null-binds-plan.md +0 -0
  94. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-04-29-002-feat-named-connections-plan.md +0 -0
  95. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-05-07-001-refactor-generic-model-connection-plan.md +0 -0
  96. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-05-08-001-feat-typed-query-predicates-plan.md +0 -0
  97. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/plans/2026-05-13-001-feat-configurable-column-storage-types-plan.md +0 -0
  98. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/README.md +0 -0
  99. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/pydantic-slots-missing-after-ferro-hydration.md +0 -0
  100. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  101. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  102. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  103. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/issues/typed-where-null-panics-is-null.md +0 -0
  104. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  105. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
  106. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  107. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  108. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/stylesheets/extra.css +0 -0
  109. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/docs/why-ferro.md +0 -0
  110. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/justfile +0 -0
  111. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/mkdocs.yml +0 -0
  112. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/scripts/demo_queries.py +0 -0
  113. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/_annotation_utils.py +0 -0
  114. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/_shadow_fk_types.py +0 -0
  115. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/base.py +0 -0
  116. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/composite_indexes.py +0 -0
  117. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/composite_uniques.py +0 -0
  118. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/exceptions.py +0 -0
  119. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/fields.py +0 -0
  120. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/migrations/__init__.py +0 -0
  121. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/migrations/alembic.py +0 -0
  122. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/py.typed +0 -0
  123. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/query/__init__.py +0 -0
  124. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/query/builder.py +0 -0
  125. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/query/nodes.py +0 -0
  126. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/raw.py +0 -0
  127. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/relations/__init__.py +0 -0
  128. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/relations/descriptors.py +0 -0
  129. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/schema_metadata.py +0 -0
  130. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/ferro/state.py +0 -0
  131. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/schema_bind.rs +0 -0
  132. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/src/state.rs +0 -0
  133. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/__init__.py +0 -0
  134. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/conftest.py +0 -0
  135. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/db_backends.py +0 -0
  136. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_aggregation.py +0 -0
  137. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_alembic_autogenerate.py +0 -0
  138. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_alembic_bridge.py +0 -0
  139. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_alembic_db_type.py +0 -0
  140. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_alembic_nullability.py +0 -0
  141. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_alembic_type_mapping.py +0 -0
  142. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_bulk_update.py +0 -0
  143. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_composite_index.py +0 -0
  144. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_composite_unique.py +0 -0
  145. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_connection_redaction.py +0 -0
  146. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_constraints.py +0 -0
  147. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_crud.py +0 -0
  148. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_db_backends.py +0 -0
  149. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_db_type_cross_emitter_parity.py +0 -0
  150. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_db_type_integration.py +0 -0
  151. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_db_type_typing.py +0 -0
  152. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_db_type_validation.py +0 -0
  153. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_deletion.py +0 -0
  154. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_docs_examples.py +0 -0
  155. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_documentation_features.py +0 -0
  156. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_field_wrapper.py +0 -0
  157. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_helpers.py +0 -0
  158. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_hydration.py +0 -0
  159. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_metaclass_internals.py +0 -0
  160. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_metadata.py +0 -0
  161. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_models.py +0 -0
  162. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_named_connections_integration.py +0 -0
  163. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_one_to_one.py +0 -0
  164. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_query_builder.py +0 -0
  165. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_query_typing.py +0 -0
  166. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_raw_sql.py +0 -0
  167. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_refresh.py +0 -0
  168. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_relationship_engine.py +0 -0
  169. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_schema.py +0 -0
  170. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_schema_constraints.py +0 -0
  171. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_schema_db_type_metadata.py +0 -0
  172. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_schema_enum_annotations.py +0 -0
  173. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_shadow_fk_types.py +0 -0
  174. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_sqlite_alembic_reconnect_hydration.py +0 -0
  175. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_static_contracts.py +0 -0
  176. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_string_search.py +0 -0
  177. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_structural_types.py +0 -0
  178. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_temporal_types.py +0 -0
  179. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_transactions.py +0 -0
  180. {ferro_orm-0.10.4 → ferro_orm-0.11.0}/tests/test_typed_null_binds.py +0 -0
@@ -32,7 +32,7 @@ For a single model, every emitter must agree on:
32
32
  includes the canonical `db_type` vocabulary (`text`, `varchar(N)`,
33
33
  `smallint`, `int`, `bigint`, `uuid`, `timestamp`, `timestamptz`, `date`,
34
34
  `time`) — duplicated in `_db_type_to_sa_type` (Python) and
35
- `apply_db_type_to_column_def` (Rust), pinned by
35
+ `db_type_token_to_canonical` (Rust), pinned by
36
36
  `tests/test_db_type_cross_emitter_parity.py`.
37
37
  4. **Index names** — `idx_<table>_<col>` for single-column indexes,
38
38
  `idx_<table>_<col1>_<col2>...` for composite indexes.
@@ -147,3 +147,46 @@ search this directory before starting work.
147
147
  `docs/solutions/issues/` — debugging stories and known footguns.
148
148
 
149
149
  See `docs/solutions/README.md` for the frontmatter conventions.
150
+
151
+ ---
152
+
153
+ ## I-6: No AI attribution in commits or PRs
154
+
155
+ Never sign commits or pull requests with AI/agent attribution. No
156
+ `Co-Authored-By: Claude ...` trailers, no "Generated with Claude Code"
157
+ footers, no robot emoji bylines — in commit messages, PR titles, or PR
158
+ bodies. This applies even when an agent's default behavior is to add them:
159
+ this rule overrides those defaults for this repository.
160
+
161
+ ---
162
+
163
+ ## I-7: No stop-gap solutions
164
+
165
+ Every feature, bug fix, and improvement must be designed as the best,
166
+ well-thought-out solution for the project with the library's future in
167
+ mind — as if time and money were no object. No stop-gaps, hacks,
168
+ quick-fixes, or otherwise lesser solves.
169
+
170
+ What this means in practice:
171
+
172
+ - **Prefer first-class, reusable primitives over local patches.** If a fix
173
+ only works for the immediate symptom while leaving the underlying
174
+ capability gap in place, build the capability instead. (Precedent:
175
+ `EngineHandle::refresh_pool()` was built as an engine-level schema-epoch
176
+ primitive rather than a migration-local statement-cache flush.)
177
+ - **Fail loudly over degrading silently.** "Skip with a warning and
178
+ continue", "best effort", and "documented residual risk" are not
179
+ acceptable resolutions for correctness gaps. Either the operation
180
+ succeeds completely or it aborts with a clear, actionable error.
181
+ - **Treat certain phrases as redesign triggers.** If a plan, comment, or PR
182
+ description contains "best-effort", "partial mitigation", "documented
183
+ residual risk", "good enough for now", "temporary workaround", or
184
+ "fallback if X turns out to be hard" — that part of the design is not
185
+ finished. Redesign it before presenting or implementing it.
186
+ - **Scoped-down is fine; hollowed-out is not.** Deliberately excluding
187
+ something from scope (with the boundary stated and a real path for the
188
+ excluded case, e.g. "renames are Alembic territory") is good design.
189
+ Shipping a half-working version of something that is *in* scope is not.
190
+
191
+ This rule binds human contributors and AI agents equally, and overrides any
192
+ agent default that biases toward minimal or expedient changes.
@@ -1,6 +1,35 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.11.0 (2026-06-11)
5
+
6
+ ### Chores
7
+
8
+ - Solution quality requirements
9
+ ([`c62770b`](https://github.com/syn54x/ferro-orm/commit/c62770b6d3073e8d596c30f29895bccddc2a4d7f))
10
+
11
+ ### Documentation
12
+
13
+ - Compound SQLite hydration learnings (#56, #58)
14
+ ([#60](https://github.com/syn54x/ferro-orm/pull/60),
15
+ [`b609e72`](https://github.com/syn54x/ferro-orm/commit/b609e7274e1886cc5d8564c7adb52fc3c79ffc8d))
16
+
17
+ ### Features
18
+
19
+ - Extend auto_migrate with column updates and destructive drops
20
+ ([#69](https://github.com/syn54x/ferro-orm/pull/69),
21
+ [`a76cb0f`](https://github.com/syn54x/ferro-orm/commit/a76cb0f2451dab79303353f2c77d74ca3e1ad4a5))
22
+
23
+
24
+ ## v0.10.5 (2026-05-25)
25
+
26
+ ### Bug Fixes
27
+
28
+ - Coerce Annotated StrEnum fields on cold hydration
29
+ ([#66](https://github.com/syn54x/ferro-orm/pull/66),
30
+ [`c17c13b`](https://github.com/syn54x/ferro-orm/commit/c17c13b56e100028a42fa431ca59c9729a22daeb))
31
+
32
+
4
33
  ## v0.10.4 (2026-05-24)
5
34
 
6
35
  ### Bug Fixes
@@ -43,9 +43,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
43
43
 
44
44
  [[package]]
45
45
  name = "bitflags"
46
- version = "2.11.1"
46
+ version = "2.13.0"
47
47
  source = "registry+https://github.com/rust-lang/crates.io-index"
48
- checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
48
+ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
49
49
  dependencies = [
50
50
  "serde_core",
51
51
  ]
@@ -79,9 +79,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
79
79
 
80
80
  [[package]]
81
81
  name = "cc"
82
- version = "1.2.62"
82
+ version = "1.2.63"
83
83
  source = "registry+https://github.com/rust-lang/crates.io-index"
84
- checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
84
+ checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
85
85
  dependencies = [
86
86
  "find-msvc-tools",
87
87
  "shlex",
@@ -230,9 +230,9 @@ dependencies = [
230
230
 
231
231
  [[package]]
232
232
  name = "displaydoc"
233
- version = "0.2.5"
233
+ version = "0.2.6"
234
234
  source = "registry+https://github.com/rust-lang/crates.io-index"
235
- checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
235
+ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
236
236
  dependencies = [
237
237
  "proc-macro2",
238
238
  "quote",
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.10.4"
297
+ version = "0.11.0"
298
298
  dependencies = [
299
299
  "dashmap",
300
300
  "once_cell",
@@ -711,13 +711,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
711
711
 
712
712
  [[package]]
713
713
  name = "js-sys"
714
- version = "0.3.99"
714
+ version = "0.3.100"
715
715
  source = "registry+https://github.com/rust-lang/crates.io-index"
716
- checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
716
+ checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
717
717
  dependencies = [
718
718
  "cfg-if",
719
719
  "futures-util",
720
- "once_cell",
721
720
  "wasm-bindgen",
722
721
  ]
723
722
 
@@ -750,14 +749,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
750
749
 
751
750
  [[package]]
752
751
  name = "libredox"
753
- version = "0.1.16"
752
+ version = "0.1.17"
754
753
  source = "registry+https://github.com/rust-lang/crates.io-index"
755
- checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
754
+ checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
756
755
  dependencies = [
757
756
  "bitflags",
758
757
  "libc",
759
758
  "plain",
760
- "redox_syscall 0.7.5",
759
+ "redox_syscall 0.8.1",
761
760
  ]
762
761
 
763
762
  [[package]]
@@ -788,9 +787,9 @@ dependencies = [
788
787
 
789
788
  [[package]]
790
789
  name = "log"
791
- version = "0.4.29"
790
+ version = "0.4.32"
792
791
  source = "registry+https://github.com/rust-lang/crates.io-index"
793
- checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
792
+ checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
794
793
 
795
794
  [[package]]
796
795
  name = "md-5"
@@ -804,9 +803,9 @@ dependencies = [
804
803
 
805
804
  [[package]]
806
805
  name = "memchr"
807
- version = "2.8.0"
806
+ version = "2.8.1"
808
807
  source = "registry+https://github.com/rust-lang/crates.io-index"
809
- checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
808
+ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
810
809
 
811
810
  [[package]]
812
811
  name = "memoffset"
@@ -819,9 +818,9 @@ dependencies = [
819
818
 
820
819
  [[package]]
821
820
  name = "mio"
822
- version = "1.2.0"
821
+ version = "1.2.1"
823
822
  source = "registry+https://github.com/rust-lang/crates.io-index"
824
- checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
823
+ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
825
824
  dependencies = [
826
825
  "libc",
827
826
  "wasi",
@@ -1136,9 +1135,9 @@ dependencies = [
1136
1135
 
1137
1136
  [[package]]
1138
1137
  name = "redox_syscall"
1139
- version = "0.7.5"
1138
+ version = "0.8.1"
1140
1139
  source = "registry+https://github.com/rust-lang/crates.io-index"
1141
- checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
1140
+ checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
1142
1141
  dependencies = [
1143
1142
  "bitflags",
1144
1143
  ]
@@ -1339,9 +1338,9 @@ dependencies = [
1339
1338
 
1340
1339
  [[package]]
1341
1340
  name = "shlex"
1342
- version = "1.3.0"
1341
+ version = "2.0.1"
1343
1342
  source = "registry+https://github.com/rust-lang/crates.io-index"
1344
- checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
1343
+ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1345
1344
 
1346
1345
  [[package]]
1347
1346
  name = "signal-hook-registry"
@@ -1380,9 +1379,9 @@ dependencies = [
1380
1379
 
1381
1380
  [[package]]
1382
1381
  name = "socket2"
1383
- version = "0.6.3"
1382
+ version = "0.6.4"
1384
1383
  source = "registry+https://github.com/rust-lang/crates.io-index"
1385
- checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
1384
+ checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
1386
1385
  dependencies = [
1387
1386
  "libc",
1388
1387
  "windows-sys 0.61.2",
@@ -1770,9 +1769,9 @@ dependencies = [
1770
1769
 
1771
1770
  [[package]]
1772
1771
  name = "typenum"
1773
- version = "1.20.0"
1772
+ version = "1.20.1"
1774
1773
  source = "registry+https://github.com/rust-lang/crates.io-index"
1775
- checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
1774
+ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
1776
1775
 
1777
1776
  [[package]]
1778
1777
  name = "unicode-bidi"
@@ -1839,9 +1838,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1839
1838
 
1840
1839
  [[package]]
1841
1840
  name = "uuid"
1842
- version = "1.23.1"
1841
+ version = "1.23.3"
1843
1842
  source = "registry+https://github.com/rust-lang/crates.io-index"
1844
- checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
1843
+ checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
1845
1844
  dependencies = [
1846
1845
  "getrandom 0.4.2",
1847
1846
  "js-sys",
@@ -1892,9 +1891,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
1892
1891
 
1893
1892
  [[package]]
1894
1893
  name = "wasm-bindgen"
1895
- version = "0.2.122"
1894
+ version = "0.2.123"
1896
1895
  source = "registry+https://github.com/rust-lang/crates.io-index"
1897
- checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
1896
+ checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
1898
1897
  dependencies = [
1899
1898
  "cfg-if",
1900
1899
  "once_cell",
@@ -1905,9 +1904,9 @@ dependencies = [
1905
1904
 
1906
1905
  [[package]]
1907
1906
  name = "wasm-bindgen-macro"
1908
- version = "0.2.122"
1907
+ version = "0.2.123"
1909
1908
  source = "registry+https://github.com/rust-lang/crates.io-index"
1910
- checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
1909
+ checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
1911
1910
  dependencies = [
1912
1911
  "quote",
1913
1912
  "wasm-bindgen-macro-support",
@@ -1915,9 +1914,9 @@ dependencies = [
1915
1914
 
1916
1915
  [[package]]
1917
1916
  name = "wasm-bindgen-macro-support"
1918
- version = "0.2.122"
1917
+ version = "0.2.123"
1919
1918
  source = "registry+https://github.com/rust-lang/crates.io-index"
1920
- checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
1919
+ checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
1921
1920
  dependencies = [
1922
1921
  "bumpalo",
1923
1922
  "proc-macro2",
@@ -1928,9 +1927,9 @@ dependencies = [
1928
1927
 
1929
1928
  [[package]]
1930
1929
  name = "wasm-bindgen-shared"
1931
- version = "0.2.122"
1930
+ version = "0.2.123"
1932
1931
  source = "registry+https://github.com/rust-lang/crates.io-index"
1933
- checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
1932
+ checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
1934
1933
  dependencies = [
1935
1934
  "unicode-ident",
1936
1935
  ]
@@ -2253,9 +2252,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
2253
2252
 
2254
2253
  [[package]]
2255
2254
  name = "yoke"
2256
- version = "0.8.2"
2255
+ version = "0.8.3"
2257
2256
  source = "registry+https://github.com/rust-lang/crates.io-index"
2258
- checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
2257
+ checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
2259
2258
  dependencies = [
2260
2259
  "stable_deref_trait",
2261
2260
  "yoke-derive",
@@ -2276,18 +2275,18 @@ dependencies = [
2276
2275
 
2277
2276
  [[package]]
2278
2277
  name = "zerocopy"
2279
- version = "0.8.48"
2278
+ version = "0.8.52"
2280
2279
  source = "registry+https://github.com/rust-lang/crates.io-index"
2281
- checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
2280
+ checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
2282
2281
  dependencies = [
2283
2282
  "zerocopy-derive",
2284
2283
  ]
2285
2284
 
2286
2285
  [[package]]
2287
2286
  name = "zerocopy-derive"
2288
- version = "0.8.48"
2287
+ version = "0.8.52"
2289
2288
  source = "registry+https://github.com/rust-lang/crates.io-index"
2290
- checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
2289
+ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
2291
2290
  dependencies = [
2292
2291
  "proc-macro2",
2293
2292
  "quote",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ferro"
3
- version = "0.10.4"
3
+ version = "0.11.0"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.10.4
3
+ Version: 0.11.0
4
4
  Requires-Dist: pydantic>=2.0
5
5
  Requires-Dist: alembic>=1.18.1 ; extra == 'alembic'
6
6
  Requires-Dist: sqlalchemy>=2.0.46 ; extra == 'alembic'
@@ -0,0 +1,112 @@
1
+ ---
2
+ date: 2026-05-25
3
+ topic: annotated-strenum-cold-hydration
4
+ issue: https://github.com/syn54x/ferro-orm/issues/65
5
+ ---
6
+
7
+ # Annotated StrEnum Cold Hydration
8
+
9
+ ## Summary
10
+
11
+ Register enum field types once at model class definition (using the same annotation-unwrapping logic as schema registration), then coerce string values from the database into enum members on every hydration path. This fixes cold fetches for `Annotated[StrEnum, FerroField(db_type="text")]` under PEP 563/649 deferred annotations without duplicating discovery logic in `_fix_types`.
12
+
13
+ ---
14
+
15
+ ## Problem Frame
16
+
17
+ Ferro hydrates rows through a zero-copy Rust path that populates instance `__dict__` with raw column values. String-valued columns therefore arrive as `str`, not as the declared Python enum. A post-hydration pass (`Model._fix_types`) is responsible for coercing those strings into enum members.
18
+
19
+ That pass discovers enum fields lazily on first use. Discovery calls `get_type_hints` with the **ferro.models** module namespace, not the user model’s defining module. With `from __future__ import annotations` (common on Python 3.14+), deferred annotation strings never resolve in that namespace; the fallback reads `__annotations__`, which are still strings and do not match `isinstance(hint, type)`. Result: `_enum_fields` stays empty, coercion is skipped, and cold reads return plain `str` even though `__ferro_schema__` already records `enum_type_name` correctly.
20
+
21
+ `create()` in the same process often still yields enum instances because Pydantic validates on construction. The bug surfaces on **cold** `all()` / `get()` / query results after `reset_engine()` or a fresh connection — exactly when apps rely on `.value`, `match`/`case`, or strict `isinstance` checks.
22
+
23
+ Equality with the raw string (`instance.mode == "hourly"`) still works; the failure mode is **type fidelity** and enum APIs, not storage or SQL.
24
+
25
+ ---
26
+
27
+ ## Requirements
28
+
29
+ **Enum registration (canonical, class-definition time)**
30
+
31
+ - R1. Each concrete `Model` subclass exposes a stable `_enum_fields: dict[str, type[Enum]]` populated during metaclass setup (Phase 3), not on first `_fix_types` call.
32
+ - R2. Registration uses the shared `_enum_subclass_from_annotation` helper already used when building `__ferro_schema__`, so `Annotated[...]`, optional unions, and plain enum annotations are handled identically for schema and hydration.
33
+ - R3. The source of truth for which fields are enums is Pydantic’s resolved `model_fields[<name>].annotation` (with `get_type_hints(cls, include_extras=True)` as fallback only when a field annotation is missing), never `get_type_hints` with ferro-internal `globals()` / `locals()`.
34
+ - R4. `_fix_types` performs **coercion only** against the pre-built `_enum_fields` map; it must not re-scan annotations or mutate discovery state on fetch.
35
+
36
+ **Hydration behavior**
37
+
38
+ - R5. After any Rust-backed fetch (`all`, `get`, `fetch_filtered`, query builder `first`/`all`, `ModelConnection.get_or_none`, etc.), every non-null enum column whose stored value is not already an instance of the registered enum class is coerced via `enum_cls(raw_value)` (preserving current tolerant failure behavior for invalid DB values).
39
+ - R6. Cold fetch after `reset_engine()` + reconnect must return enum members, not `str`, for models using `Annotated[StrEnum, FerroField(db_type="text")]` with PEP 563/649 deferred annotations.
40
+ - R7. Behavior for plain `StrEnum` fields (no `Annotated`), native Postgres enum columns, and `IntEnum` with integer storage remains unchanged — no regression in existing round-trip tests.
41
+
42
+ **Testing**
43
+
44
+ - R8. Add an integration regression test that defines a model with `from __future__ import annotations`, `Annotated[StrEnum, FerroField(db_type="text")]`, inserts via `create()`, calls `reset_engine()`, reconnects, fetches via `all()` (or `get`), and asserts `isinstance(field, EnumSubclass)` and `.value` access works.
45
+ - R9. Optionally assert `_enum_fields` is non-empty and includes the field name after class creation (unit-level guard against rediscovering the #65 failure mode).
46
+
47
+ ---
48
+
49
+ ## Acceptance Examples
50
+
51
+ - **AE1 — Cold fetch with deferred annotations**
52
+ Covers: R5, R6, R8
53
+ Given `from __future__ import annotations` and `billing_mode: Annotated[Mode, FerroField(db_type="text")]`, when a row is created with `Mode.HOURLY`, then `reset_engine()` and reconnect, then `Row.all()[0]`, then `type(row.billing_mode)` is `Mode` and `row.billing_mode.value == "hourly"`.
54
+
55
+ - **AE2 — Schema parity unchanged**
56
+ Covers: R2, R7
57
+ Given the same model class, `__ferro_schema__["properties"]["billing_mode"]["enum_type_name"]` remains `"mode"` (lowercased class name) before and after the fix.
58
+
59
+ - **AE3 — Invalid DB value tolerance**
60
+ Covers: R5
61
+ Given a text column containing a string that is not a valid enum member, when fetched, the instance field remains a non-enum value (or coercion fails silently as today) without raising during `_fix_types` — no change to defensive behavior unless separately specified.
62
+
63
+ ---
64
+
65
+ ## Success Criteria
66
+
67
+ - Issue #65 reproduction script exits 0 on main after the fix.
68
+ - New regression test fails on current `main` without the fix and passes with it.
69
+ - No new phantom DDL or cross-emitter drift (hydration-only change).
70
+ - `_enum_fields` discovery logic exists in exactly one conceptual place (metaclass + shared helper), not duplicated in `_fix_types`.
71
+
72
+ ---
73
+
74
+ ## Scope Boundaries
75
+
76
+ **In scope**
77
+
78
+ - Python-side enum registration and post-hydration coercion for all existing fetch entry points.
79
+ - Regression test under PEP 563/649 + `Annotated` + `db_type="text"`.
80
+ - Closes GitHub issue #65.
81
+
82
+ **Out of scope**
83
+
84
+ - Rust-side enum coercion during dict population (duplicate logic across FFI; defer unless profiling proves Python coercion is a bottleneck).
85
+ - Changing default storage away from native Postgres enums in Alembic (separate product decision; `db_type="text"` on StrEnum remains valid).
86
+ - Pydantic `model_validate` on hydration (violates direct-to-dict / zero-copy invariant I-2).
87
+ - Broader refactors of `_fix_types` for non-enum types (UUID, Decimal, etc.) unless already planned elsewhere.
88
+
89
+ ---
90
+
91
+ ## Key Decisions
92
+
93
+ | Decision | Choice | Rationale |
94
+ |----------|--------|-----------|
95
+ | Where to register enums | Metaclass Phase 3 | Same lifecycle as `ferro_fields` and schema registration; immune to wrong `get_type_hints` namespaces. |
96
+ | Shared helper | Reuse `_enum_subclass_from_annotation` | Schema and hydration must agree on what counts as an enum field (AGENTS.md parity spirit). |
97
+ | `_fix_types` role | Coercion only | Eliminates lazy discovery bug class; cheaper on hot path. |
98
+ | Minimal patch alternative | Repair `_fix_types` discovery only | **Rejected as lesser solve** — fixes #65 but leaves two discovery paths and repeats failure modes for the next annotation shape. |
99
+
100
+ ---
101
+
102
+ ## Dependencies / Assumptions
103
+
104
+ - Pydantic v2 continues to resolve `model_fields[].annotation` to real enum types even when `__annotations__` on the class remain strings under PEP 563.
105
+ - `reset_engine()` remains a valid way to simulate cold identity-map clears in tests.
106
+ - Issue reporter environment (ferro 0.10.3, Python 3.14+, SQLite) matches current test matrix support.
107
+
108
+ ---
109
+
110
+ ## Outstanding Questions
111
+
112
+ - None blocking implementation. If PEP 649-only models without `__annotate_func__` resolution in metaclass appear in the wild, confirm `_resolve_deferred_annotations` still leaves `model_fields` authoritative (spot-check in plan phase).
@@ -410,37 +410,12 @@ Document the exception hierarchy and import paths:
410
410
 
411
411
  ### Many-to-Many Join Table Creation
412
412
 
413
- **Status:** Partially Implemented
414
-
415
- **Documentation References:**
416
- - `docs/guide/relationships.md` (lines 176-289)
417
-
418
- **Description:**
419
- Many-to-many relationships are defined with `ManyToMany(...)`, but the join tables are not automatically created during `auto_migrate=True`.
420
-
421
- **Example (Partially Working):**
422
- ```python
423
- from typing import Annotated
424
-
425
- from ferro import BackRef, Field, ManyToMany, Model, Relation
426
-
427
- class Post(Model):
428
- id: int | None = Field(default=None, primary_key=True)
429
- tags: Relation[list["Tag"]] = ManyToMany(related_name="posts")
430
-
431
- class Tag(Model):
432
- id: int | None = Field(default=None, primary_key=True)
433
- posts: Relation[list["Post"]] = BackRef()
434
-
435
- # Models created, but join table 'post_tags' is NOT auto-created
436
- # This causes errors when trying to use M2M methods:
437
- await post.tags.add(tag) # RuntimeError: no such table: post_tags
438
- ```
439
-
440
- **Workaround:**
441
- Manual join table creation may be required, or use Alembic migrations. Further investigation needed.
413
+ **Status:** Implemented
442
414
 
443
- **Test Status:** 4 tests skipped in `tests/test_documentation_features.py`
415
+ Many-to-many join tables are registered alongside their models and created by
416
+ `auto_migrate=True` / `create_tables()` like any other table (they also
417
+ participate in `migrate_updates` diffing). Covered by
418
+ `tests/test_auto_migrate.py::test_m2m_join_table_created_during_auto_migrate`.
444
419
 
445
420
  ---
446
421
 
@@ -133,8 +133,53 @@ During development, automatically align the database schema with your models:
133
133
  await ferro.connect("sqlite:dev.db?mode=rwc", auto_migrate=True)
134
134
  ```
135
135
 
136
+ `auto_migrate=True` creates missing tables (including many-to-many join tables) and never touches existing ones. Two opt-in flags extend it:
137
+
138
+ ```python
139
+ await ferro.connect(
140
+ "sqlite:dev.db?mode=rwc",
141
+ auto_migrate=True,
142
+ migrate_updates=True, # update existing tables to match the models
143
+ migrate_destructive=True, # also drop columns removed from the models
144
+ )
145
+ ```
146
+
147
+ The flags form a ladder — `migrate_destructive` implies `migrate_updates`, which implies `auto_migrate` — so passing just the strongest flag you want is enough.
148
+
149
+ #### Schema updates with `migrate_updates`
150
+
151
+ When a model gains fields between releases, `migrate_updates=True` reconciles the live table on connect. What it can do is capability-relative per backend:
152
+
153
+ | Change | SQLite | Postgres |
154
+ |---|---|---|
155
+ | Add missing column | ✅ `ADD COLUMN` | ✅ `ADD COLUMN` |
156
+ | Add missing column's index (`index=True`) | ✅ `CREATE INDEX` | ✅ `CREATE INDEX` |
157
+ | Add unique column (`unique=True`) | ✅ via explicit `uq_` unique index + warning | ✅ inline `UNIQUE` |
158
+ | Add foreign-key column | ✅ column only, no FK constraint + warning | ✅ column + FK constraint |
159
+ | Change column type | ⚠️ warning, no DDL (type affinity makes drift mostly cosmetic) | ✅ `ALTER COLUMN ... TYPE ... USING` cast |
160
+ | Change nullability | ⚠️ warning, no DDL | ✅ `SET NOT NULL` / `DROP NOT NULL` |
161
+ | Drop removed column (`migrate_destructive`) | ✅ dependency-aware | ✅ |
162
+ | Rename column/table, change primary key, drop table | ❌ never — [Alembic](migrations.md) territory | ❌ never |
163
+
164
+ Rules to know:
165
+
166
+ - **NOT NULL additions need a literal default.** Existing rows must be backfilled, so a new required field without a literal default fails the connect with a clear error. Make the field nullable, give it a default, or use Alembic. On Postgres the backfill default is dropped immediately after the add (a fresh `CREATE TABLE` carries no server default); SQLite cannot drop a column default, so it remains — harmless, and invisible to Alembic autogenerate's defaults.
167
+ - **Added columns reuse the exact `CREATE TABLE` DDL.** A database brought forward by `migrate_updates` is byte-identical to one created fresh, so `alembic revision --autogenerate` stays clean (this is pinned by the cross-emitter parity tests).
168
+ - **Destructive drops are dependency-aware and fail loudly.** Explicit indexes covering a dropped column are dropped first (they are orphaned anyway). Columns that are primary keys, enforced by UNIQUE/CHECK constraints, or referenced by other tables' foreign keys abort the migration with an error pointing at Alembic — nothing is skipped silently. Tables are never dropped.
169
+ - **Postgres native enum columns are left alone.** They only exist via Alembic, which remains their owner.
170
+ - **Postgres type changes take an exclusive lock** and fail the connect if existing data does not cast cleanly — acceptable for a development flag, but worth knowing.
171
+ - **The pool refreshes after any schema change.** No connection can serve a statement prepared against the pre-migration schema (on SQLite stale statements panic the sqlx worker and silently return zero rows; on Postgres they raise `cached plan must not change result type`), and identity-mapped instances hydrated before the migration are evicted so loads return fresh, complete objects.
172
+
173
+ The same pass can be run explicitly on a live connection instead of at connect time:
174
+
175
+ ```python
176
+ await ferro.migrate() # create + update (default)
177
+ await ferro.migrate(destructive=True) # also drop removed columns
178
+ await ferro.migrate(using="service") # against a named connection
179
+ ```
180
+
136
181
  !!! danger "Production Warning"
137
- `auto_migrate=True` is intended for development only. For production, use [Alembic migrations](migrations.md).
182
+ `auto_migrate=True` and its extension flags are intended for development and local-first apps whose schema is still moving. For production, use [Alembic migrations](migrations.md) — renames, primary-key changes, and data transforms are deliberately out of auto-migrate's scope.
138
183
 
139
184
  ## Manual Table Creation
140
185