ferro-orm 0.5.0__tar.gz → 0.6.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 (161) hide show
  1. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/CHANGELOG.md +9 -0
  2. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/Cargo.lock +8 -3
  3. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/Cargo.toml +3 -3
  4. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/PKG-INFO +1 -1
  5. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/raw-sql.md +13 -6
  6. ferro_orm-0.6.0/docs/brainstorms/2026-04-29-named-connections-role-routing-requirements.md +218 -0
  7. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/coming-soon.md +18 -27
  8. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/faq.md +11 -1
  9. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/backend.md +8 -5
  10. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/database.md +63 -10
  11. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/queries.md +10 -2
  12. ferro_orm-0.6.0/docs/howto/multiple-databases.md +70 -0
  13. ferro_orm-0.6.0/docs/plans/2026-04-29-001-typed-null-binds-plan.md +984 -0
  14. ferro_orm-0.6.0/docs/plans/2026-04-29-002-feat-named-connections-plan.md +750 -0
  15. ferro_orm-0.6.0/docs/solutions/patterns/typed-null-binds.md +171 -0
  16. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/justfile +3 -0
  17. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/pyproject.toml +2 -1
  18. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/backend.rs +99 -2
  19. ferro_orm-0.6.0/src/connection.rs +375 -0
  20. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/__init__.py +40 -4
  21. ferro_orm-0.6.0/src/ferro/_core.pyi +121 -0
  22. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/models.py +155 -31
  23. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/query/builder.py +28 -19
  24. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/raw.py +23 -7
  25. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/relations/descriptors.py +21 -4
  26. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/state.py +4 -0
  27. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/lib.rs +2 -0
  28. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/operations.rs +839 -179
  29. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/query.rs +262 -30
  30. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/schema.rs +6 -14
  31. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/state.rs +100 -7
  32. ferro_orm-0.6.0/tests/test_connection.py +522 -0
  33. ferro_orm-0.6.0/tests/test_connection_redaction.py +38 -0
  34. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_cross_emitter_parity.py +8 -0
  35. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_documentation_features.py +12 -2
  36. ferro_orm-0.6.0/tests/test_named_connections_integration.py +78 -0
  37. ferro_orm-0.6.0/tests/test_transactions.py +384 -0
  38. ferro_orm-0.6.0/tests/test_typed_null_binds.py +343 -0
  39. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/uv.lock +25 -1
  40. ferro_orm-0.5.0/docs/howto/multiple-databases.md +0 -74
  41. ferro_orm-0.5.0/src/connection.rs +0 -176
  42. ferro_orm-0.5.0/src/ferro/_core.pyi +0 -66
  43. ferro_orm-0.5.0/tests/test_connection.py +0 -37
  44. ferro_orm-0.5.0/tests/test_transactions.py +0 -161
  45. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/agent-native.json +0 -0
  46. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/api-contract.json +0 -0
  47. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/correctness.json +0 -0
  48. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/data-migrations.json +0 -0
  49. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/kieran-python.json +0 -0
  50. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/maintainability.json +0 -0
  51. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/project-standards.json +0 -0
  52. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/testing.json +0 -0
  53. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  54. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  55. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/PERMISSIONS.md +0 -0
  56. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/PYPI_CHECKLIST.md +0 -0
  57. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/PYPI_SETUP.md +0 -0
  58. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/generated/wheels.generated.yml +0 -0
  59. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/pull_request_template.md +0 -0
  60. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/workflows/ci.yml +0 -0
  61. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/workflows/packaging-smoke.yml +0 -0
  62. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/workflows/publish-docs.yml +0 -0
  63. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/workflows/publish.yml +0 -0
  64. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.github/workflows/release.yml +0 -0
  65. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.gitignore +0 -0
  66. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.pre-commit-config.yaml +0 -0
  67. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/.python-version +0 -0
  68. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/AGENTS.md +0 -0
  69. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/CONTRIBUTING.md +0 -0
  70. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/LICENSE +0 -0
  71. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/README.md +0 -0
  72. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/TEST_RESULTS.md +0 -0
  73. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/fields.md +0 -0
  74. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/model.md +0 -0
  75. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/query.md +0 -0
  76. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/relationships.md +0 -0
  77. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/transactions.md +0 -0
  78. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/api/utilities.md +0 -0
  79. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/changelog.md +0 -0
  80. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/concepts/architecture.md +0 -0
  81. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/concepts/identity-map.md +0 -0
  82. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/concepts/performance.md +0 -0
  83. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/concepts/type-safety.md +0 -0
  84. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/contributing.md +0 -0
  85. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/getting-started/installation.md +0 -0
  86. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/getting-started/next-steps.md +0 -0
  87. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/getting-started/tutorial.md +0 -0
  88. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/migrations.md +0 -0
  89. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/models-and-fields.md +0 -0
  90. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/mutations.md +0 -0
  91. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/relationships.md +0 -0
  92. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/guide/transactions.md +0 -0
  93. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/howto/pagination.md +0 -0
  94. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/howto/soft-deletes.md +0 -0
  95. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/howto/testing.md +0 -0
  96. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/howto/timestamps.md +0 -0
  97. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/index.md +0 -0
  98. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/migration-sqlalchemy.md +0 -0
  99. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  100. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/README.md +0 -0
  101. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +0 -0
  102. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/issues/sa-pk-column-nullable-divergence.md +0 -0
  103. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +0 -0
  104. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/patterns/cross-emitter-ddl-parity.md +0 -0
  105. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/patterns/foreign-key-index.md +0 -0
  106. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/patterns/index-unique-redundancy.md +0 -0
  107. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/solutions/patterns/shadow-fk-columns.md +0 -0
  108. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/stylesheets/extra.css +0 -0
  109. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/docs/why-ferro.md +0 -0
  110. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/mkdocs.yml +0 -0
  111. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/scripts/demo_queries.py +0 -0
  112. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/_annotation_utils.py +0 -0
  113. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/_shadow_fk_types.py +0 -0
  114. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/base.py +0 -0
  115. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/composite_indexes.py +0 -0
  116. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/composite_uniques.py +0 -0
  117. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/fields.py +0 -0
  118. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/metaclass.py +0 -0
  119. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/migrations/__init__.py +0 -0
  120. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/migrations/alembic.py +0 -0
  121. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/py.typed +0 -0
  122. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/query/__init__.py +0 -0
  123. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/query/nodes.py +0 -0
  124. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/relations/__init__.py +0 -0
  125. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/src/ferro/schema_metadata.py +0 -0
  126. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/__init__.py +0 -0
  127. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/conftest.py +0 -0
  128. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/db_backends.py +0 -0
  129. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_aggregation.py +0 -0
  130. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_alembic_autogenerate.py +0 -0
  131. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_alembic_bridge.py +0 -0
  132. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_alembic_nullability.py +0 -0
  133. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_alembic_type_mapping.py +0 -0
  134. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_auto_migrate.py +0 -0
  135. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_bulk_update.py +0 -0
  136. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_composite_index.py +0 -0
  137. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_composite_unique.py +0 -0
  138. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_constraints.py +0 -0
  139. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_crud.py +0 -0
  140. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_db_backends.py +0 -0
  141. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_deletion.py +0 -0
  142. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_docs_examples.py +0 -0
  143. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_field_wrapper.py +0 -0
  144. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_helpers.py +0 -0
  145. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_hydration.py +0 -0
  146. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_metaclass_internals.py +0 -0
  147. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_metadata.py +0 -0
  148. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_models.py +0 -0
  149. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_one_to_one.py +0 -0
  150. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_query_builder.py +0 -0
  151. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_raw_sql.py +0 -0
  152. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_refresh.py +0 -0
  153. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_relationship_engine.py +0 -0
  154. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_schema.py +0 -0
  155. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_schema_constraints.py +0 -0
  156. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_schema_enum_annotations.py +0 -0
  157. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_shadow_fk_types.py +0 -0
  158. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_static_contracts.py +0 -0
  159. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_string_search.py +0 -0
  160. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_structural_types.py +0 -0
  161. {ferro_orm-0.5.0 → ferro_orm-0.6.0}/tests/test_temporal_types.py +0 -0
@@ -1,6 +1,15 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.6.0 (2026-04-30)
5
+
6
+ ### Features
7
+
8
+ - Support typed binds and named database routing
9
+ ([#45](https://github.com/syn54x/ferro-orm/pull/45),
10
+ [`e3fc930`](https://github.com/syn54x/ferro-orm/commit/e3fc9300178dce7ba763b744b92acf0385b9e90e))
11
+
12
+
4
13
  ## v0.5.0 (2026-04-28)
5
14
 
6
15
  ### Bug Fixes
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.5.0"
297
+ version = "0.6.0"
298
298
  dependencies = [
299
299
  "dashmap",
300
300
  "once_cell",
@@ -1179,9 +1179,9 @@ dependencies = [
1179
1179
 
1180
1180
  [[package]]
1181
1181
  name = "rustls"
1182
- version = "0.23.39"
1182
+ version = "0.23.40"
1183
1183
  source = "registry+https://github.com/rust-lang/crates.io-index"
1184
- checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
1184
+ checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
1185
1185
  dependencies = [
1186
1186
  "once_cell",
1187
1187
  "ring",
@@ -1237,6 +1237,7 @@ checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c"
1237
1237
  dependencies = [
1238
1238
  "inherent",
1239
1239
  "sea-query-derive",
1240
+ "uuid",
1240
1241
  ]
1241
1242
 
1242
1243
  [[package]]
@@ -1452,6 +1453,7 @@ dependencies = [
1452
1453
  "tokio-stream",
1453
1454
  "tracing",
1454
1455
  "url",
1456
+ "uuid",
1455
1457
  "webpki-roots 0.26.11",
1456
1458
  ]
1457
1459
 
@@ -1532,6 +1534,7 @@ dependencies = [
1532
1534
  "stringprep",
1533
1535
  "thiserror",
1534
1536
  "tracing",
1537
+ "uuid",
1535
1538
  "whoami",
1536
1539
  ]
1537
1540
 
@@ -1569,6 +1572,7 @@ dependencies = [
1569
1572
  "stringprep",
1570
1573
  "thiserror",
1571
1574
  "tracing",
1575
+ "uuid",
1572
1576
  "whoami",
1573
1577
  ]
1574
1578
 
@@ -1594,6 +1598,7 @@ dependencies = [
1594
1598
  "thiserror",
1595
1599
  "tracing",
1596
1600
  "url",
1601
+ "uuid",
1597
1602
  ]
1598
1603
 
1599
1604
  [[package]]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ferro"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -27,8 +27,8 @@ serde = { version = "1.0", features = ["derive"] }
27
27
  serde_json = "1.0"
28
28
  once_cell = "1.21"
29
29
  dashmap = "6.1"
30
- sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "any", "tls-rustls-ring-webpki"]}
31
- sea-query = "0.32"
30
+ sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "any", "tls-rustls-ring-webpki", "uuid"]}
31
+ sea-query = { version = "0.32", features = ["with-uuid"] }
32
32
  tokio = { version = "1.49", features = ["full"] }
33
33
  pyo3-async-runtimes = { version = "0.27", features = ["tokio-runtime"] }
34
34
  uuid = { version = "1.11", features = ["v4"] }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.5.0
3
+ Version: 0.6.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'
@@ -36,20 +36,26 @@ The `tx` handle owns the transaction's connection. You cannot misuse it —
36
36
  calling `tx.execute(...)` after the `async with` block exits raises
37
37
  `RuntimeError`.
38
38
 
39
- ### Top-level (auto-picks active tx)
39
+ ### Top-level (`using` or active transaction)
40
40
 
41
41
  ```python
42
42
  from ferro import execute, fetch_all, fetch_one
43
43
 
44
- # Outside any tx — runs on a one-off pool connection.
44
+ # Outside any tx — runs on the default connection.
45
45
  await execute("select pg_advisory_unlock_all()")
46
46
 
47
+ # Route explicitly to a named connection.
48
+ await execute("select run_pipeline_job($1)", job_id, using="service")
49
+
47
50
  # Inside a tx — auto-picked up via the same ContextVar that Model.create() uses.
48
- async with transaction():
51
+ async with transaction(using="service"):
49
52
  await execute("select set_config('request.jwt.claims', $1, true)", claims_json)
50
53
  rows = await fetch_all("select * from foo where org_id = $1", org_id)
51
54
  ```
52
55
 
56
+ Passing `using=...` inside an active transaction raises. A transaction is pinned to
57
+ one connection, and unqualified raw SQL inherits that connection.
58
+
53
59
  ## Placeholders are native to the backend
54
60
 
55
61
  | Backend | Placeholder syntax | Example |
@@ -96,9 +102,10 @@ the `sql` argument — use placeholders and pass values as positional args.
96
102
  ## Connection affinity
97
103
 
98
104
  Outside a `transaction()` block, each top-level `execute` / `fetch_all` /
99
- `fetch_one` call may use a different pool connection. Wrap in
100
- `transaction()` for connection-affinity-sensitive operations like
101
- `SET LOCAL`, advisory locks, or `LISTEN/NOTIFY`.
105
+ `fetch_one` call runs on the selected named pool (`using=...`) or the default
106
+ pool. Consecutive calls may use different physical connections from that pool.
107
+ Wrap in `transaction(using=...)` for connection-affinity-sensitive operations
108
+ like `SET LOCAL`, advisory locks, or `LISTEN/NOTIFY`.
102
109
 
103
110
  ## What raw SQL doesn't do
104
111
 
@@ -0,0 +1,218 @@
1
+ ---
2
+
3
+ ## date: 2026-04-29
4
+
5
+ topic: named-connections-role-routing
6
+
7
+ # Named Connections and Role-Safe Routing
8
+
9
+ ## Problem Frame
10
+
11
+ Ferro currently presents a single active database engine to Python callers. That keeps the API simple, but it blocks users who need to use the same database through different Postgres roles, such as a Supabase application role for user-facing data access and a service or pipeline role for trusted internal work.
12
+
13
+ The feature should let users register multiple named connections and choose the intended connection at the operation or transaction boundary. The default experience should stay ergonomic for single-database apps, while multi-role apps get explicit routing, role-safe transaction behavior, separate pool settings, and clear guardrails against accidental privilege mixing.
14
+
15
+ ---
16
+
17
+ ## Actors
18
+
19
+ - A1. Application developer: Configures Ferro connections and writes model/query code.
20
+ - A2. User-facing application runtime: Handles normal product requests through the least-privileged app connection.
21
+ - A3. Internal service or pipeline runtime: Runs trusted background work through a separate elevated connection.
22
+ - A4. Migration or setup process: Creates or updates schema using an explicitly chosen connection.
23
+ - A5. Downstream implementation agent: Plans and builds the feature without inventing public API semantics.
24
+
25
+ ---
26
+
27
+ ## Key Flows
28
+
29
+ - F1. Single-connection app startup
30
+ - **Trigger:** A developer uses Ferro as they do today with one database URL.
31
+ - **Actors:** A1, A2
32
+ - **Steps:** The app calls `ferro.connect(url)`. Ferro registers that connection as `"default"` and makes it the default connection. Existing unqualified model, query, transaction, and raw SQL calls continue to work.
33
+ - **Outcome:** Existing apps do not need to learn named routing unless they add more connections.
34
+ - **Covered by:** R1, R2, R5, R14
35
+ - F2. Multi-role Supabase startup
36
+ - **Trigger:** A developer needs separate app and service-role database access.
37
+ - **Actors:** A1, A2, A3
38
+ - **Steps:** The app registers an app connection with `default=True` and a service connection with its own name and pool settings. Normal model calls use the app connection. Pipeline code opts into the service connection explicitly.
39
+ - **Outcome:** Both roles can coexist in one process without reconnecting global state or sharing a pool.
40
+ - **Covered by:** R1, R2, R3, R4, R6, R12
41
+ - F3. Service transaction with inherited routing
42
+ - **Trigger:** A pipeline needs a unit of work to run through the service connection.
43
+ - **Actors:** A3
44
+ - **Steps:** Pipeline code enters `async with ferro.transaction(using="service")`. Unqualified model and raw SQL operations inside the block inherit the transaction connection. Any attempt to route part of the transaction to another connection fails clearly.
45
+ - **Outcome:** The transaction is ergonomic and cannot silently become a cross-connection pseudo-transaction.
46
+ - **Covered by:** R7, R8, R9, R10, R11
47
+ - F4. Schema setup on an explicit connection
48
+ - **Trigger:** A developer wants Ferro to create tables or run setup against a specific role.
49
+ - **Actors:** A1, A4
50
+ - **Steps:** The developer chooses the connection when enabling `auto_migrate` or calling schema creation APIs. Ferro does not assume that the default app connection should have migration privileges.
51
+ - **Outcome:** Schema writes are deliberate and can be restricted to a migration-capable connection.
52
+ - **Covered by:** R13, R15
53
+
54
+ ---
55
+
56
+ ## Requirements
57
+
58
+ **Connection registration and defaults**
59
+
60
+ - R1. Ferro must support registering more than one active connection in a process, each identified by a stable string name.
61
+ - R2. Calling `ferro.connect(url)` without a name must remain valid and must register the connection as `"default"`.
62
+ - R3. `ferro.connect(url, name="...", default=True)` must make that named connection the default for unqualified operations.
63
+ - R4. Ferro must provide a way to change the default connection after registration, such as `ferro.set_default_connection("app")`.
64
+ - R5. If more than one connection exists and no default has been selected, unqualified operations must fail with a clear error instead of guessing.
65
+
66
+ **Pool configuration**
67
+
68
+ - R6. Pool configuration must belong to the named connection, because app, service, pipeline, replica, and test connections can have different concurrency and lifetime needs.
69
+ - R7. Ferro should expose pool configuration as a Ferro API object or explicit keyword arguments rather than overloading database URL query parameters.
70
+ - R8. Native database URL settings, such as Postgres TLS parameters, must remain in the connection URL when they are part of the database driver's normal URL contract.
71
+ - R9. The initial pool configuration surface should cover the common operational needs: maximum connections, minimum connections if supported, acquire timeout, idle timeout, max lifetime, and connection health checking if supported by the backend.
72
+ - R10. Pool configuration must be optional; a connection with no explicit pool settings should use conservative Ferro defaults.
73
+
74
+ **Routing and operation ergonomics**
75
+
76
+ - R11. Ferro must support explicit per-operation routing through a named connection, using a concise API such as `Model.using("service")`, query-level `using`, or an equivalent fluent surface.
77
+ - R12. The connection resolution order must be: explicit operation routing first, active transaction connection second, default connection third, and clear error last.
78
+ - R13. Raw SQL APIs must participate in the same routing model as ORM APIs, including transaction inheritance.
79
+
80
+ **Transactions**
81
+
82
+ - R14. `ferro.transaction(using="name")` must bind the transaction to exactly one named connection for its lifetime.
83
+ - R15. Unqualified ORM and raw SQL calls inside a transaction must inherit the transaction's named connection.
84
+ - R16. Explicitly routing an operation to a different connection inside an active transaction must fail clearly unless Ferro later introduces an explicit cross-connection transaction feature.
85
+ - R17. Nested transactions must inherit the parent transaction connection unless the nested call specifies the same connection; specifying a different connection must fail clearly.
86
+
87
+ **Identity map and object safety**
88
+
89
+ - R18. Ferro's identity map must isolate instances by connection name as well as model and primary key, so an object loaded through an elevated role cannot be reused to satisfy an app-role query.
90
+ - R19. Model instances loaded from a named connection should carry enough internal state for later saves or relationship operations to prefer the same connection when no stronger routing context exists.
91
+
92
+ **Schema management and migrations**
93
+
94
+ - R20. `auto_migrate` and explicit schema creation APIs must run against a specific named connection.
95
+ - R21. Documentation must recommend using a migration-capable connection for schema changes rather than assuming the default app connection has DDL privileges.
96
+ - R22. Ferro must not silently run migrations across all registered connections.
97
+
98
+ **Security and Supabase guidance**
99
+
100
+ - R23. Documentation must warn that elevated service credentials must stay server-side and should not be exposed in public clients.
101
+ - R24. Documentation must recommend least-privileged custom Postgres roles where possible, with service-style privileges reserved for trusted internal processes.
102
+ - R25. Ferro must redact connection credentials in logs and user-facing errors.
103
+
104
+ ---
105
+
106
+ ## Acceptance Examples
107
+
108
+ - AE1. **Covers R1, R2, R14.** Given an existing app calls `await ferro.connect(url)`, when it calls `await User.create(...)`, the operation uses the implicitly registered `"default"` connection and does not require `using`.
109
+ - AE2. **Covers R3, R5, R11.** Given `app` and `service` connections are registered and `app` is marked default, when code calls `await User.all()`, it uses `app`; when code calls `await PipelineEvent.using("service").create(...)`, it uses `service`.
110
+ - AE3. **Covers R12, R14, R15.** Given code is inside `async with ferro.transaction(using="service")`, when it calls `await PipelineEvent.create(...)`, the model call uses the service transaction connection without repeating `using="service"`.
111
+ - AE4. **Covers R16, R17.** Given code is inside `async with ferro.transaction(using="service")`, when it attempts `await User.using("app").create(...)`, Ferro raises an error explaining that a transaction cannot switch from `service` to `app`.
112
+ - AE5. **Covers R18.** Given row `User(id=1)` is loaded through `service`, when the same row is later queried through `app`, Ferro must not return the service-loaded Python instance from the identity map.
113
+ - AE6. **Covers R6, R7, R10.** Given the app connection has `max_connections=20` and the service connection has `max_connections=5`, each named connection uses its own pool settings and neither setting affects the other.
114
+ - AE7. **Covers R20, R22.** Given `app` and `service` connections are registered, when `create_tables(using="service")` runs, Ferro creates schema only through `service` and does not run DDL on `app`.
115
+ - AE8. **Covers R23, R25.** Given a Supabase connection fails, the raised error and logs do not reveal passwords, service credentials, or full secret-bearing URLs.
116
+
117
+ ---
118
+
119
+ ## Success Criteria
120
+
121
+ - Existing single-connection Ferro apps continue to work without API changes.
122
+ - A user can run app-role and service-role Supabase/Postgres work in the same process without resetting global engine state.
123
+ - The happy path for a service transaction is concise enough that users do not repeat `using="service"` on every call inside the block.
124
+ - The API makes privilege boundaries visible at connection setup and transaction entry, not hidden in model definitions or global magic.
125
+ - Transaction behavior never implies atomicity across more than one named connection.
126
+ - A downstream planner can implement the feature without deciding the public API precedence rules, default behavior, pool ownership model, or Supabase safety posture from scratch.
127
+
128
+ ---
129
+
130
+ ## Scope Boundaries
131
+
132
+ - The first version does not need automatic read/write splitting or policy routers like Django's `DATABASE_ROUTERS`.
133
+ - The first version does not need distributed or two-phase transactions across named connections.
134
+ - The first version does not need cross-database relationships or joins.
135
+ - The first version does not need dynamic tenant connection creation beyond the same named registration primitives.
136
+ - The first version does not need per-model static binding, though the API should not preclude it later.
137
+ - Supabase is the motivating Postgres deployment target, but the feature should stay framed as named connection support rather than a Supabase-only capability.
138
+ - The feature should not add support for new database backends beyond Ferro's current supported backend contract.
139
+
140
+ ---
141
+
142
+ ## Key Decisions
143
+
144
+ - Named connections are the core abstraction: They cover same-database different-role access, multiple databases, replicas, and future routing without inventing a Supabase-only concept.
145
+ - Pool settings live on the connection: This matches operational reality and avoids global pool settings that are wrong for either app traffic or pipeline work.
146
+ - A default connection is allowed: It preserves Ferro's ergonomic model and avoids forcing `using` everywhere in normal application code.
147
+ - Transactions create an ambient routing context: This makes service-role units of work concise while keeping all operations on one connection.
148
+ - Explicit cross-connection operations inside a transaction are errors: This prevents accidental pseudo-atomic workflows.
149
+ - Identity-map keys include connection identity: This is necessary for role safety when different roles can see different rows or columns.
150
+ - Migrations are connection-specific: Schema writes should be deliberate and not fan out across registered connections.
151
+ - Credentials must be redacted: Multi-role support increases the chance that elevated credentials pass through Ferro configuration.
152
+
153
+ ---
154
+
155
+ ## Proposed UX Shape
156
+
157
+ This section is illustrative product UX, not an implementation prescription.
158
+
159
+ ```python
160
+ await ferro.connect(
161
+ app_url,
162
+ name="app",
163
+ default=True,
164
+ pool=ferro.PoolConfig(max_connections=20, min_connections=2),
165
+ )
166
+
167
+ await ferro.connect(
168
+ service_url,
169
+ name="service",
170
+ pool=ferro.PoolConfig(max_connections=5, acquire_timeout=30),
171
+ )
172
+
173
+ await User.create(email="user@example.com") # Uses app.
174
+
175
+ async with ferro.transaction(using="service"):
176
+ await PipelineEvent.create(kind="sync_started") # Uses service.
177
+ await ferro.execute("select set_config('app.pipeline', $1, true)", "sync")
178
+ ```
179
+
180
+ Connection resolution:
181
+
182
+ ```text
183
+ explicit operation using
184
+ -> active transaction connection
185
+ -> default named connection
186
+ -> clear "no connection selected" error
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Dependencies / Assumptions
192
+
193
+ - Ferro's typed backend work is the right foundation for this feature; named connections should build on explicit engine handles rather than revive URL-string or generic-pool dispatch.
194
+ - Direct Supabase/Postgres connections can represent the needed role boundary through distinct database URLs or credentials.
195
+ - Users who need multiple roles in one process value explicitness over fully automatic routing.
196
+ - Keeping routers out of v1 reduces API and testing complexity without blocking the app/service-role use case.
197
+
198
+ ---
199
+
200
+ ## Outstanding Questions
201
+
202
+ ### Resolve Before Planning
203
+
204
+ - None.
205
+
206
+ ### Deferred to Planning
207
+
208
+ - [Affects R7, R9][Technical] Which pool settings are supported uniformly across SQLite and Postgres, and which need backend-specific validation?
209
+ - [Affects R11, R12][Technical] Should the primary explicit routing API be `Model.using("name")`, query terminal `using="name"` arguments, or both for parity with the current query builder shape?
210
+ - [Affects R19][Technical] How should instance stickiness interact with an active transaction when they disagree?
211
+ - [Affects R20][Technical] Should `auto_migrate=True` remain on `connect()` only, or should schema setup move toward an explicit `create_tables(using="name")` first-class UX?
212
+ - [Affects R26][Needs research] What exact redaction behavior should be shared across Rust logs, Python exceptions, and test diagnostics?
213
+
214
+ ---
215
+
216
+ ## Next Steps
217
+
218
+ -> /ce-plan for structured implementation planning.
@@ -56,28 +56,27 @@ banned_users = await User.where(
56
56
 
57
57
  ### Raw SQL Queries
58
58
 
59
- **Status:** Not Implemented
59
+ **Status:** Implemented for `execute`, `fetch_all`, and `fetch_one`
60
60
 
61
61
  **Documentation References:**
62
62
  - `docs/guide/queries.md` (lines 252-266)
63
63
 
64
64
  **Description:**
65
- Direct raw SQL query execution with parameterization.
65
+ Direct raw SQL execution with parameterization is available. Raw rows are plain dictionaries of wire-close primitive values; typed model hydration still belongs to the ORM.
66
66
 
67
- **Example (Not Working):**
67
+ **Example:**
68
68
  ```python
69
- # This does not work yet
70
- from ferro import raw_query
69
+ from ferro import execute, fetch_all
71
70
 
72
- results = await raw_query(
71
+ results = await fetch_all(
73
72
  "SELECT * FROM users WHERE age > $1 AND status = $2",
74
73
  18,
75
- "active"
74
+ "active",
75
+ using="app",
76
76
  )
77
77
  ```
78
78
 
79
- **Workaround:**
80
- Use the query builder API for all queries.
79
+ Additional raw helpers beyond these functions remain out of scope.
81
80
 
82
81
  ---
83
82
 
@@ -264,48 +263,40 @@ Use `transaction()` context manager for scoped database operations.
264
263
  - `docs/guide/database.md` (lines 76-104)
265
264
 
266
265
  **Description:**
267
- Advanced connection pool parameters like `max_connections`, `min_connections`, and `connect_timeout`.
266
+ `PoolConfig(max_connections=..., min_connections=...)` is implemented per connection. Additional pool options like acquire timeout, idle timeout, max lifetime, and health-check toggles are still future work.
268
267
 
269
268
  **Example (Partially Working):**
270
269
  ```python
271
- # Support for these parameters is not confirmed
272
270
  await ferro.connect(
273
271
  "postgresql://user:password@localhost/dbname",
274
- max_connections=20, # May not work
275
- min_connections=5, # May not work
276
- connect_timeout=30 # May not work
272
+ pool=ferro.PoolConfig(max_connections=20, min_connections=5),
277
273
  )
278
274
  ```
279
275
 
280
- **Workaround:**
281
- Use basic connection string without advanced pool parameters.
276
+ For unsupported advanced pool options, use backend defaults.
282
277
 
283
278
  ---
284
279
 
285
280
  ### Multiple Database Support
286
281
 
287
- **Status:** Not Implemented
282
+ **Status:** Implemented for explicit named connections
288
283
 
289
284
  **Documentation References:**
290
285
  - `docs/guide/database.md` (lines 123-149)
291
286
  - `docs/howto/multiple-databases.md` (entire file)
292
287
 
293
288
  **Description:**
294
- Connecting to and querying multiple databases with named connections.
289
+ Connecting to and querying multiple databases or roles with explicit named connections is supported. Automatic router policies, read/write splitting, and distributed transactions remain out of scope.
295
290
 
296
- **Example (Not Working):**
291
+ **Example:**
297
292
  ```python
298
- # This does not work yet
299
- await ferro.connect("postgresql://localhost/main_db", name="primary")
293
+ await ferro.connect("postgresql://localhost/main_db", name="primary", default=True)
300
294
  await ferro.connect("postgresql://localhost/replica_db", name="replica")
301
295
 
302
296
  # Query specific database
303
297
  users = await User.using("replica").all()
304
298
  ```
305
299
 
306
- **Workaround:**
307
- Ferro currently supports only a single database connection per application.
308
-
309
300
  ---
310
301
 
311
302
  ## Transaction Features
@@ -489,7 +480,7 @@ profile = await user.profile
489
480
  ### Definitely Not Implemented
490
481
  1. `ilike()` - case-insensitive LIKE
491
482
  2. `not_in_()` - NOT IN operator
492
- 3. Raw SQL queries (`raw_query`)
483
+ 3. Additional raw SQL helper APIs beyond `execute` / `fetch_all` / `fetch_one`
493
484
  4. Eager loading (`prefetch_related`)
494
485
  5. Select specific fields (partial model loading)
495
486
  6. Aggregation functions (sum, avg, min, max)
@@ -497,8 +488,8 @@ profile = await user.profile
497
488
  8. `disconnect()` function
498
489
  9. `check_connection()` function
499
490
  10. `connection_context()` context manager
500
- 11. Connection pool advanced parameters
501
- 12. Multiple database support (`.using()`)
491
+ 11. Additional connection pool parameters
492
+ 12. Automatic routing policies for multiple databases
502
493
  13. Nested transactions / savepoints
503
494
 
504
495
  ### Needs Verification
@@ -137,7 +137,17 @@ Check your Ferro version's API for raw SQL support. Most versions provide an esc
137
137
 
138
138
  ### Does Ferro support multiple databases?
139
139
 
140
- Not yet. Ferro currently supports a single active database connection per application process.
140
+ Yes. Register each pool with a name and route explicitly with `using`:
141
+
142
+ ```python
143
+ await ferro.connect(APP_DATABASE_URL, name="app", default=True)
144
+ await ferro.connect(SERVICE_DATABASE_URL, name="service")
145
+
146
+ users = await User.all() # app/default
147
+ jobs = await Job.using("service").all()
148
+ ```
149
+
150
+ Ferro does not provide automatic routers, cross-database joins, or distributed transactions in v1.
141
151
 
142
152
  See [How-To: Multiple Databases](howto/multiple-databases.md).
143
153
 
@@ -15,7 +15,7 @@ The backend is the runtime database engine behind Ferro's Python API. It owns:
15
15
  - backend-specific SQL generation choices
16
16
  - value binding and hydration rules
17
17
 
18
- The backend does not introduce a new public routing API. Ferro still uses one active engine per process. Named databases, replicas, and `using("name")`-style routing are intentionally deferred.
18
+ The backend supports a registry of named connections. The common case still uses one default engine per process, while advanced applications can register multiple pools and route ORM, raw SQL, transaction, and schema operations with `using="name"`.
19
19
 
20
20
  ## Supported Backends
21
21
 
@@ -44,19 +44,22 @@ The important implementation detail is that URL detection happens once during `c
44
44
  1. Splits Ferro-only query parameters from the database URL.
45
45
  2. Classifies the backend from the URL scheme.
46
46
  3. Creates a typed SQLx pool for that backend.
47
- 4. Stores an `Arc<EngineHandle>` in global engine state.
47
+ 4. Registers an `Arc<EngineHandle>` under a connection name and optionally selects it as the default.
48
48
 
49
- SQLite uses `SqlitePoolOptions` and PostgreSQL uses `PgPoolOptions`. Both currently use a fixed pool size of 5 connections.
49
+ SQLite uses `SqlitePoolOptions` and PostgreSQL uses `PgPoolOptions`. `PoolConfig(max_connections=..., min_connections=...)` is applied per named connection, so app-role and service-role pools can have different sizes.
50
50
 
51
51
  ```text
52
- connect(url, auto_migrate)
52
+ connect(url, name, default, auto_migrate, pool)
53
53
  -> split ferro_search_path
54
54
  -> BackendKind::from_url(url)
55
55
  -> connect typed pool
56
56
  -> optionally create tables
57
- -> store EngineHandle globally
57
+ -> store EngineHandle in the named registry
58
+ -> optionally update the default connection
58
59
  ```
59
60
 
61
+ Connection resolution is centralized. Explicit `using` wins outside a transaction; active transactions pin all work to their selected connection; instance methods prefer the instance's origin connection; unqualified calls then fall back to the selected default connection.
62
+
60
63
  ### PostgreSQL Search Paths
61
64
 
62
65
  Ferro supports a private `ferro_search_path` URL parameter for test isolation:
@@ -85,6 +85,46 @@ If you assemble the URI yourself, percent-encode reserved characters in the pass
85
85
 
86
86
  ## Connection Options
87
87
 
88
+ ### Named Connections
89
+
90
+ Ferro can keep multiple active pools in one process. Unnamed `connect()` calls register and select the `"default"` connection. Named connections are explicit and only become the default when `default=True` is passed.
91
+
92
+ ```python
93
+ import os
94
+ import ferro
95
+
96
+ await ferro.connect(
97
+ os.environ["APP_DATABASE_URL"],
98
+ name="app",
99
+ default=True,
100
+ pool=ferro.PoolConfig(max_connections=10, min_connections=1),
101
+ )
102
+ await ferro.connect(
103
+ os.environ["SERVICE_DATABASE_URL"],
104
+ name="service",
105
+ pool=ferro.PoolConfig(max_connections=3),
106
+ )
107
+
108
+ # Default app role
109
+ users = await User.all()
110
+
111
+ # Explicit service role
112
+ job = await Job.using("service").create(kind="backfill")
113
+ await ferro.execute("select run_internal_job(?)", job.id, using="service")
114
+ ```
115
+
116
+ Use constants or trusted server-side code to choose `using` values. Do not bind connection names directly from request parameters, headers, GraphQL arguments, or other untrusted input.
117
+
118
+ ### Transaction Inheritance
119
+
120
+ Transactions are bound to one connection. Operations inside the block inherit that connection; trying to switch to another connection inside the transaction raises.
121
+
122
+ ```python
123
+ async with ferro.transaction(using="service"):
124
+ await Job.create(kind="backfill") # runs on service
125
+ await ferro.execute("select set_config('role_context', ?, true)", "pipeline")
126
+ ```
127
+
88
128
  ### Auto-Migration (Development)
89
129
 
90
130
  During development, automatically align the database schema with your models:
@@ -110,14 +150,31 @@ async def main():
110
150
  # Import models to register them
111
151
  from myapp.models import User, Post, Comment
112
152
 
113
- # Create all tables
153
+ # Create all tables on the default connection
114
154
  await ferro.create_tables()
115
155
  ```
116
156
 
117
157
  ## Multiple Databases
118
158
 
119
- !!! warning "Feature Not Implemented"
120
- Multi-database support is not yet available. Ferro currently supports only a single database connection per application. See [Coming Soon](../coming-soon.md#multiple-database-support) and [How-To: Multiple Databases](../howto/multiple-databases.md) for planned features.
159
+ Use named connections for multiple databases, roles, or pools:
160
+
161
+ ```python
162
+ await ferro.connect(os.environ["APP_DATABASE_URL"], name="app", default=True)
163
+ await ferro.connect(os.environ["SERVICE_DATABASE_URL"], name="service")
164
+
165
+ await ferro.create_tables(using="service")
166
+ service_users = await User.using("service").all()
167
+ ```
168
+
169
+ Ferro does not provide automatic router policies, read/write splitting, distributed transactions, or cross-connection joins in v1. Route each operation explicitly when it should not use the default connection.
170
+
171
+ ### Supabase Role Guidance
172
+
173
+ For Supabase/Postgres deployments, keep elevated service credentials server-side. Prefer least-privileged custom roles where possible, and avoid making a service-role connection the default in user-facing runtimes.
174
+
175
+ Named connections isolate pools and roles, not per-request RLS/JWT/session context inside one shared pool. If you set Postgres session state, prefer transaction-local settings and keep the work inside `transaction(using=...)`.
176
+
177
+ Service-origin objects can contain data unavailable to the app role. Treat them as elevated data and filter them deliberately before returning user-facing responses.
121
178
 
122
179
  ## Health Checks
123
180
 
@@ -196,16 +253,12 @@ async def on_shutdown():
196
253
  !!! note "disconnect() Not Available"
197
254
  The `disconnect()` function is not yet implemented. Connection cleanup happens automatically on process exit. See [Coming Soon](../coming-soon.md#disconnect) for more information.
198
255
 
199
- ### Use One Long-Lived Connection
200
-
201
- !!! note
202
- Advanced pool configuration such as `max_connections`, `min_connections`, and `connect_timeout` is not exposed by Ferro's current Python API. See [Coming Soon](../coming-soon.md#connection-pool-configuration).
256
+ ### Use Long-Lived Pools
203
257
 
204
- For web applications, connect once at startup and reuse that engine:
258
+ For web applications, connect once at startup and reuse those pools:
205
259
 
206
260
  ```python
207
- # Basic connection for production
208
- await ferro.connect("postgresql://localhost/proddb")
261
+ await ferro.connect("postgresql://localhost/proddb", name="app", default=True)
209
262
  ```
210
263
 
211
264
  ### Separate Dev/Prod Configs
@@ -251,8 +251,16 @@ smith_users = await User.where(User.name.like("%Smith%")).all()
251
251
 
252
252
  ## Raw SQL
253
253
 
254
- !!! warning "Feature Not Implemented"
255
- Raw SQL query execution is not yet available. Use the query builder API for all queries. See [Coming Soon](../coming-soon.md#raw-sql-queries) for more information.
254
+ Ferro exposes `execute`, `fetch_all`, and `fetch_one` for raw SQL escape hatches. Raw SQL uses backend-native placeholders and can route to named connections:
255
+
256
+ ```python
257
+ from ferro import execute, fetch_all
258
+
259
+ await execute("select run_pipeline_job($1)", job_id, using="service")
260
+ rows = await fetch_all("select id, name from users where org_id = $1", org_id)
261
+ ```
262
+
263
+ Inside `transaction(using="service")`, raw SQL inherits the transaction connection. See [Raw SQL](../api/raw-sql.md) for bind-type details and caveats.
256
264
 
257
265
  ## Performance Tips
258
266