matrx-orm 3.1.74__tar.gz → 3.1.76__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 (339) hide show
  1. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/CLAUDE.md +2 -2
  2. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/PKG-INFO +2 -2
  3. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/FEATURE.md +25 -2
  4. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/pooler_role.py +44 -27
  5. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/exceptions.py +62 -2
  6. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/update.py +128 -23
  7. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/package_wiring.py +5 -2
  8. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/README.md +6 -1
  9. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/lifecycle.py +61 -22
  10. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/pyproject.toml +2 -2
  11. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_optimistic_concurrency.py +197 -0
  12. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_watched_lifecycle.py +43 -0
  13. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/live_pooler_role.py +28 -1
  14. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/test_pooler_role_guard.py +86 -4
  15. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/.gitignore +0 -0
  16. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/AGENTS.md +0 -0
  17. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/BASE_CLASS_METHODS.md +0 -0
  18. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/MODEL_API.md +0 -0
  19. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/README.md +0 -0
  20. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/database/orm/extended/managers/ai_model_base.py +0 -0
  21. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/docs/migrations.md +0 -0
  22. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/MODULE_README.md +0 -0
  23. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/__init__.py +0 -0
  24. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/adapters/MODULE_README.md +0 -0
  25. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/adapters/__init__.py +0 -0
  26. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/adapters/async_postgresql.py +0 -0
  27. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/adapters/base_adapter.py +0 -0
  28. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/adapters/postgresql.py +0 -0
  29. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/adapters/postgrest_client_adapter.py +0 -0
  30. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/adapters/supabase_adapter.py +0 -0
  31. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/admin/__init__.py +0 -0
  32. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/admin/router.py +0 -0
  33. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/api/__init__.py +0 -0
  34. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/api/auth.py +0 -0
  35. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/api/config.py +0 -0
  36. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/api/handlers.py +0 -0
  37. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/api/protocol.py +0 -0
  38. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/api/server.py +0 -0
  39. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/associations.py +0 -0
  40. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/cache_debug.py +0 -0
  41. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/catalog.py +0 -0
  42. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/catalog_sql.py +0 -0
  43. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/client/MODULE_README.md +0 -0
  44. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/client/__init__.py +0 -0
  45. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/client/postgres_connection.py +0 -0
  46. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/client/postgrest.py +0 -0
  47. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/client/supabase_auth.py +0 -0
  48. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/client/supabase_config.py +0 -0
  49. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/client/supabase_manager.py +0 -0
  50. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/EXTENDED-TASK.md +0 -0
  51. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/MODULE_README.md +0 -0
  52. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/RELATIONS-TASKS.md +0 -0
  53. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/TASKS.md +0 -0
  54. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/__init__.py +0 -0
  55. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/async_db_manager.py +0 -0
  56. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/base.py +0 -0
  57. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/config.py +0 -0
  58. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/connections.py +0 -0
  59. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/db_function.py +0 -0
  60. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/diagnostics.py +0 -0
  61. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/expressions.py +0 -0
  62. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/extended.py +0 -0
  63. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/fields.py +0 -0
  64. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/introspection.py +0 -0
  65. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/listen.py +0 -0
  66. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/loop_filters.py +0 -0
  67. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/model_dto.py +0 -0
  68. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/model_view.py +0 -0
  69. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/one_database.py +0 -0
  70. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/paginator.py +0 -0
  71. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/pool_maintenance.py +0 -0
  72. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/pool_watch.py +0 -0
  73. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/pydantic_bridge.py +0 -0
  74. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/registry.py +0 -0
  75. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/relations.py +0 -0
  76. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/resilience.py +0 -0
  77. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/rls_session.py +0 -0
  78. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/signals.py +0 -0
  79. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/tls.py +0 -0
  80. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/transaction.py +0 -0
  81. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/types.py +0 -0
  82. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/core/write_queue.py +0 -0
  83. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/entity.py +0 -0
  84. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/error_handling.py +0 -0
  85. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/extended/__init__.py +0 -0
  86. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/extended/app_error_handler.py +0 -0
  87. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/identity_rule.py +0 -0
  88. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/middleware/__init__.py +0 -0
  89. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/middleware/base.py +0 -0
  90. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/migrations/MODULE_README.md +0 -0
  91. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/migrations/__init__.py +0 -0
  92. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/migrations/cli.py +0 -0
  93. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/migrations/ddl.py +0 -0
  94. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/migrations/diff.py +0 -0
  95. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/migrations/executor.py +0 -0
  96. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/migrations/integration.py +0 -0
  97. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/migrations/loader.py +0 -0
  98. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/migrations/operations.py +0 -0
  99. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/migrations/state.py +0 -0
  100. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/migrations/table_filter.py +0 -0
  101. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/MODULE_README.md +0 -0
  102. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/__init__.py +0 -0
  103. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/bulk_update_values.py +0 -0
  104. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/conflict.py +0 -0
  105. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/conflict_writes.py +0 -0
  106. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/create.py +0 -0
  107. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/db_functions.py +0 -0
  108. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/delete.py +0 -0
  109. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/dynamic_admin.py +0 -0
  110. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/dynamic_crud.py +0 -0
  111. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/graph_walk.py +0 -0
  112. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/latest_rows.py +0 -0
  113. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/queue_claim.py +0 -0
  114. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/read.py +0 -0
  115. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/relational_backfill.py +0 -0
  116. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/operations/staging_load.py +0 -0
  117. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/platform_db.py +0 -0
  118. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/py.typed +0 -0
  119. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/python_sql/MODULE_README.md +0 -0
  120. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/python_sql/__init__.py +0 -0
  121. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/python_sql/db_objects.py +0 -0
  122. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/python_sql/table_detailed_relationships.py +0 -0
  123. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/python_sql/table_typescript_relationship.py +0 -0
  124. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/query/FEATURE.md +0 -0
  125. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/query/MODULE_README.md +0 -0
  126. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/query/__init__.py +0 -0
  127. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/query/builder.py +0 -0
  128. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/query/executor.py +0 -0
  129. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/FEATURE.md +0 -0
  130. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/MODULE_README.md +0 -0
  131. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/__init__.py +0 -0
  132. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/code_handler.py +0 -0
  133. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/codegen_writer.py +0 -0
  134. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/columns.py +0 -0
  135. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/common.py +0 -0
  136. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/diff_preview.py +0 -0
  137. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/entity_capabilities.py +0 -0
  138. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/generator.py +0 -0
  139. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/helpers/__init__.py +0 -0
  140. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/helpers/base_generators.py +0 -0
  141. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/helpers/entity_generators.py +0 -0
  142. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/helpers/git_checker.py +0 -0
  143. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/relationships.py +0 -0
  144. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/runner.py +0 -0
  145. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/schema.py +0 -0
  146. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/schema_manager.py +0 -0
  147. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/tables.py +0 -0
  148. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/schema_builder/views.py +0 -0
  149. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/secrets_battery/FEATURE.md +0 -0
  150. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/secrets_battery/__init__.py +0 -0
  151. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/secrets_battery/attachments.py +0 -0
  152. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/secrets_battery/crypto.py +0 -0
  153. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/secrets_battery/items.py +0 -0
  154. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/secrets_battery/model.py +0 -0
  155. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/secrets_battery/org_service.py +0 -0
  156. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/secrets_battery/service.py +0 -0
  157. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/__init__.py +0 -0
  158. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/coalesce.py +0 -0
  159. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/dag.py +0 -0
  160. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/errors.py +0 -0
  161. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/fallback.py +0 -0
  162. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/flush.py +0 -0
  163. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/managed.py +0 -0
  164. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/op.py +0 -0
  165. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/reads.py +0 -0
  166. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/session.py +0 -0
  167. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/session/telemetry.py +0 -0
  168. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/sql_executor/MODULE_README.md +0 -0
  169. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/sql_executor/__init__.py +0 -0
  170. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/sql_executor/executor.py +0 -0
  171. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/sql_executor/queries.py +0 -0
  172. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/sql_executor/registry.py +0 -0
  173. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/sql_executor/types.py +0 -0
  174. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/sql_executor/utils.py +0 -0
  175. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/state.py +0 -0
  176. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/utils/__init__.py +0 -0
  177. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/utils/sql_utils.py +0 -0
  178. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/matrx_orm/utils/type_converters.py +0 -0
  179. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/scripts/git-branches.sh +0 -0
  180. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/scripts/publish.sh +0 -0
  181. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/scripts/release.sh +0 -0
  182. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/MODULE_README.md +0 -0
  183. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/__init__.py +0 -0
  184. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/conftest.py +0 -0
  185. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/MODULE_README.md +0 -0
  186. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/__init__.py +0 -0
  187. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_admin_db_columns.py +0 -0
  188. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_admin_search_negation.py +0 -0
  189. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_admin_search_sentinels.py +0 -0
  190. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_advisory_lock.py +0 -0
  191. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_agent_message.py +0 -0
  192. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_api_auth.py +0 -0
  193. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_api_config.py +0 -0
  194. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_api_handlers.py +0 -0
  195. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_api_protocol.py +0 -0
  196. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_array_agg_order_by.py +0 -0
  197. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_associations.py +0 -0
  198. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_bulk_hydration_offload.py +0 -0
  199. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_bulk_update_by_pk.py +0 -0
  200. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_bulk_upsert_increment_set_fields.py +0 -0
  201. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_cache_debug.py +0 -0
  202. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_call_function.py +0 -0
  203. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_case_when.py +0 -0
  204. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_catalog.py +0 -0
  205. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_composite_pk_filter.py +0 -0
  206. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_composite_pk_write_paths.py +0 -0
  207. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_config.py +0 -0
  208. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_conflict_target.py +0 -0
  209. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_conflict_writes.py +0 -0
  210. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_connection_codecs.py +0 -0
  211. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_connection_poison_guard.py +0 -0
  212. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_connection_primitives.py +0 -0
  213. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_count_composite_distinct.py +0 -0
  214. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_db_function_field.py +0 -0
  215. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_ddl_generator.py +0 -0
  216. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_derived_table.py +0 -0
  217. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_dirty_tracking.py +0 -0
  218. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_dynamic_crud.py +0 -0
  219. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_exceptions.py +0 -0
  220. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_execute_query_integrity_mapping.py +0 -0
  221. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_expression_primitives.py +0 -0
  222. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_fields.py +0 -0
  223. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_filter_jsonb_agg_array_index.py +0 -0
  224. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_from_alias.py +0 -0
  225. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_fts.py +0 -0
  226. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_func_expression_args.py +0 -0
  227. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_graph_walk.py +0 -0
  228. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_having_annotate_alias.py +0 -0
  229. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_insert_ignore_and_admin_primitives.py +0 -0
  230. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_introspect_rls_policies.py +0 -0
  231. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_join_and_aggregate.py +0 -0
  232. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_latest_rows.py +0 -0
  233. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_listen_notify.py +0 -0
  234. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_managed_write_guard.py +0 -0
  235. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_matrx_entity.py +0 -0
  236. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_migration_diff_types.py +0 -0
  237. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_migration_loader.py +0 -0
  238. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_model_instance.py +0 -0
  239. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_model_meta.py +0 -0
  240. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_order_by_expression.py +0 -0
  241. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_parameter_name_hydration.py +0 -0
  242. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_patch_jsonb_path.py +0 -0
  243. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_pool_holder_census.py +0 -0
  244. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_pool_loop_recreation.py +0 -0
  245. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_pool_maintenance.py +0 -0
  246. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_postgrest_filters.py +0 -0
  247. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_pydantic_bridge.py +0 -0
  248. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_query_builder.py +0 -0
  249. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_query_error_detail.py +0 -0
  250. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_query_executor_sql.py +0 -0
  251. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_query_timeout.py +0 -0
  252. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_querybuilder_clone_semantics.py +0 -0
  253. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_queue_claim.py +0 -0
  254. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_registry.py +0 -0
  255. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_registry_multi_database.py +0 -0
  256. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_registry_multi_schema.py +0 -0
  257. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_relational_backfill.py +0 -0
  258. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_relations.py +0 -0
  259. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_resilience.py +0 -0
  260. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_rls_session_scope.py +0 -0
  261. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_schema_exists.py +0 -0
  262. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_session_advisory_lock.py +0 -0
  263. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_state_cache.py +0 -0
  264. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_subquery_filter_raw.py +0 -0
  265. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_subquery_in_filter.py +0 -0
  266. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_supabase_auth.py +0 -0
  267. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_supabase_config.py +0 -0
  268. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_tls.py +0 -0
  269. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_typed_join_projection.py +0 -0
  270. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_typed_predicates.py +0 -0
  271. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_update_case_expression.py +0 -0
  272. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_update_subquery.py +0 -0
  273. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_update_with_rebase.py +0 -0
  274. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_upsert_default_leak.py +0 -0
  275. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_upsert_with_conflict.py +0 -0
  276. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_values_jsonb_decode.py +0 -0
  277. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level1/test_write_retry_semantics.py +0 -0
  278. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/MODULE_README.md +0 -0
  279. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/__init__.py +0 -0
  280. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/conftest.py +0 -0
  281. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/test_bulk_ops.py +0 -0
  282. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/test_cache_integration.py +0 -0
  283. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/test_crud.py +0 -0
  284. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/test_foreign_keys.py +0 -0
  285. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/test_m2m.py +0 -0
  286. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/test_manager.py +0 -0
  287. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/test_migrations_live.py +0 -0
  288. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/test_query_execution.py +0 -0
  289. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/level2/test_schema_diff.py +0 -0
  290. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project/.env.example +0 -0
  291. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project/README.md +0 -0
  292. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project/__init__.py +0 -0
  293. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project/generate.py +0 -0
  294. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project/generated/.gitkeep +0 -0
  295. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project/matrx_orm.yaml +0 -0
  296. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project/test_schema_generation.py +0 -0
  297. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project_desktop/.env.example +0 -0
  298. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project_desktop/README.md +0 -0
  299. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project_desktop/__init__.py +0 -0
  300. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project_desktop/client_example.py +0 -0
  301. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project_desktop/client_example.ts +0 -0
  302. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project_desktop/client_supabase_example.py +0 -0
  303. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/sample_project_desktop/server.py +0 -0
  304. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/entity_tests.py +0 -0
  305. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/test_base_generation.py +0 -0
  306. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/test_composite_pk_fk_generation.py +0 -0
  307. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/test_composite_unique_generation.py +0 -0
  308. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/test_dto_identity_generation.py +0 -0
  309. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/test_entity_capability_generation.py +0 -0
  310. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/test_generate_schema.py +0 -0
  311. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/test_junction_analysis.py +0 -0
  312. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/test_multi_schema_output.py +0 -0
  313. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/test_sql_expression_defaults.py +0 -0
  314. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/test_table_relationships_query.py +0 -0
  315. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema/test_view_generation.py +0 -0
  316. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema_builder/test_array_default_parsing.py +0 -0
  317. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema_builder/test_focused_generation_plan.py +0 -0
  318. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/schema_builder/test_host_models_aggregator.py +0 -0
  319. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/__init__.py +0 -0
  320. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/conftest.py +0 -0
  321. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/test_cache_no_downgrade.py +0 -0
  322. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/test_capture_poison_proof.py +0 -0
  323. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/test_coalesce.py +0 -0
  324. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/test_dag.py +0 -0
  325. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/test_disk_spill_fallback.py +0 -0
  326. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/test_flush_individual_fallback.py +0 -0
  327. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/test_governed_write.py +0 -0
  328. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/test_managed.py +0 -0
  329. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/test_op_enqueue_site.py +0 -0
  330. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/test_reads.py +0 -0
  331. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/session/test_session.py +0 -0
  332. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/test_database_name_alias.py +0 -0
  333. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/test_db_functions.py +0 -0
  334. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/test_model_cls_refactor.py +0 -0
  335. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/test_no_blocking_asyncpg_tls.py +0 -0
  336. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/test_one_database.py +0 -0
  337. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/test_secrets_battery.py +0 -0
  338. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/test_sql_param_casts.py +0 -0
  339. {matrx_orm-3.1.74 → matrx_orm-3.1.76}/tests/test_sync_twins_guard.py +0 -0
@@ -23,7 +23,7 @@ Every table that becomes a Model needs row identity: a single-column primary key
23
23
  - 🚨 **ONE CONNECTION, REQUIRED** (`matrx_orm.platform_db`): `register_platform_db(name, ...)` + `platform_connection_url()` — the ONLY sanctioned way any Matrx service or package turns env vars into a pool. One set of variables (`SUPABASE_MATRIX_*`), owned here; TLS is a deployment input (`SUPABASE_MATRIX_SSL`), never hardcoded; **RAISES** if it isn't given a database — no degraded mode. Every package's `bootstrap_db()` is one call to it. A package may absolutely run on its OWN Postgres — that is a change of VALUES, never a new variable name; what it may never have is a *second candidate* or a fallback chain. `register_database_from_env` is the low-level primitive underneath — **application and package code must not call it directly** (guard: `scripts/check_one_database.py`, blocking in release).
24
24
  - **Core model layer**: `Model`, `BaseManager`, `BaseDTO`, `ModelView`, `model_registry`, plus 50+ field types (`CharField`, `IntegerField`, `ForeignKey`, `ManyToManyField`, `JSONField`, …).
25
25
  - **`MatrxEntity`** (`matrx_orm/entity.py`) — **the platform entity contract as an ORM primitive.** A table registered in `platform.entity_types` generates as a `MatrxEntity` (everything else stays a plain `Model`) and gets the behavior instead of every consumer hand-wiring it: `await page.versions()` (not a query on `history.row_versions`), `await page.restore(3)` (not a `version_restore` RPC + a cache bust — `restore()` busts the cache itself), `Note.alive()` / `.dead()` / `.soft_delete()` / `.undelete()`, and `Page.capabilities()`. Capability flags (`_entity_token`, `_is_versioned`, `_has_soft_delete`, `_is_org_scoped`, `_rls_variant`) are **stamped by the generator from the LIVE DATABASE** (does the table really carry the `_version_capture` trigger? a `deleted_at` column?) — never from `entity_types`' own flags, which are wrong on 61 of its 242 rows. **The database is truth; a flag is a claim.** Using a capability an entity does not declare RAISES (`EntityCapabilityError`) and names the fix — it never returns silently-wrong data, and `is_deleted` refuses to answer from a column a partial fetch never loaded. Drift guard: `scripts/check_entity_drift.py` (loud, non-blocking, in `release.sh`).
26
- - **Optimistic concurrency (`expected_version=`)** — opt-in compare-and-swap on the canonical `version` int column: `update_where` / `update_item` / instance `update()` take `expected_version=N` (`WHERE version = N`, `SET version = N + 1`; 0 rows → `DoesNotExist` if gone, `OptimisticLockError` with `current_version` + `current_row` if changed); `bulk_update(..., check_version=True)` guards per row; `MatrxEntity.update_with_rebase` auto-rebases past DISJOINT concurrent writes using `history.row_versions` snapshots. **Never automatic** — an unguarded write stays last-write-wins (the `_touch_row` trigger bumps `version` on every update, so auto-guarding would raise spurious conflicts). Contract: `common-docs/systems/optimistic-concurrency/FEATURE.md`.
26
+ - **Optimistic concurrency (`expected_version=`)** — opt-in compare-and-swap on the canonical `version` int column: `update_where` / `update_item` / instance `update()` take `expected_version=N` (`WHERE version = N`, `SET version = N + 1`; 0 rows → `DoesNotExist` if gone, `OptimisticLockError` if changed — carrying the **decision package**: `current_version`, `current_row`, `contested_fields` (current values of ONLY the fields this write attempted), and `changed_by`/`changed_at` from `history.row_versions` where versioned); `bulk_update(..., check_version=True)` guards per row; `MatrxEntity.update_with_rebase` auto-rebases past DISJOINT concurrent writes using `history.row_versions` snapshots. **Never automatic** — an unguarded write stays last-write-wins (the `_touch_row` trigger bumps `version` on every update, so auto-guarding would raise spurious conflicts). Contract: `common-docs/systems/optimistic-concurrency/FEATURE.md`.
27
27
  - **Change tracking** — every instance records which columns were **loaded** (a SELECTed row, or the caller's kwargs) and which were **changed**. `save()` writes only those, so `.only("id","email")` + `save()` can never NULL the columns it never fetched (it used to — silent data loss). Mutable containers (jsonb/arrays) are always rewritten when loaded, because `obj.metadata["k"]=1` cannot be seen by `__setattr__`; **reassigning** (`obj.metadata = {...}`) is tracked precisely and is the sanctioned way. `Model._hydrate()` is the ONE row→model entry point.
28
28
  - **Upsert writes only what the caller supplied.** `upsert` / `bulk_upsert` / `upsert_with_conflict` materialize every field's Python default so the **INSERT** half is complete — but `ON CONFLICT DO UPDATE SET` covers **only the columns present in the caller's `data`** (an explicit `update_fields` still wins). Letting defaults reach the UPDATE half silently RESET every defaulted column on each re-upsert of an existing row: it once reset scraped-and-analyzed `research.rs_source` rows to `scrape_status='pending', server_attempts=0` (a watchdog then finalized them `'failed'`) and undid users' manual `is_included=false`. A caller-supplied `None` is excluded too — it is dropped from the INSERT, and `EXCLUDED.<col>` on an omitted column resolves to that column's **DEFAULT**. An upsert of only conflict keys means "ensure this row exists" and reconciles to a no-op self-assignment, never a raise (a bare `DO NOTHING` would break `RETURNING *`). Pinned by `tests/level1/test_upsert_default_leak.py`.
29
29
  - **Read-only models** (`_read_only = True`): a model that can be queried but NEVER written through the ORM — every write path raises `ReadOnlyModelError` (enforced at `QueryExecutor.insert/bulk_insert/upsert/update/delete`, so it covers all of `create`/`save`/`update`/`delete`/`update_where`/`delete_where`/`upsert`/`bulk_*`). Used for DB-managed tables the app must not mutate. The canonical **`auth.users`** model is baked into the schema generator (`get_string_user_model`) as the full, curated Supabase auth schema (safe columns only — no password/token fields), `_read_only`, bound per-database. Read a user's profile/metadata there; mutate users only through Supabase Auth (GoTrue).
@@ -98,7 +98,7 @@ If the admin router ever needs auth beyond token-based, inject a `resolve_user`
98
98
 
99
99
  - Full type hints — the ORM's contract with callers is very type-heavy and IDE discoverability matters.
100
100
  - No docstrings except on public API (Model, QueryBuilder, field classes, migration entry points). One line.
101
- - Hot paths (`QueryBuilder.execute`, adapter fetches, dict→dataclass transforms) must stay allocation-light. Favor slotted dataclasses / `__slots__`.
101
+ - Hot paths (`QueryBuilder.execute`, adapter fetches, dict→dataclass transforms) must stay allocation-light. Favor mandated dataclasses / `__slots__`.
102
102
  - Explicit exception handling — never swallow `asyncpg` errors.
103
103
 
104
104
  ---
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: matrx-orm
3
- Version: 3.1.74
3
+ Version: 3.1.76
4
4
  Summary: Async-first PostgreSQL ORM with bidirectional migrations, schema introspection, many-to-many relationships, and built-in state caching
5
5
  Project-URL: Homepage, https://github.com/AI-Matrix-Engine/aidream-current
6
6
  Project-URL: Repository, https://github.com/AI-Matrix-Engine/aidream-current
@@ -25,7 +25,7 @@ Requires-Python: >=3.13
25
25
  Requires-Dist: asyncpg>=0.31.0
26
26
  Requires-Dist: cryptography>=43.0
27
27
  Requires-Dist: gitpython>=3.1.40
28
- Requires-Dist: matrx-utils>=2.0.15
28
+ Requires-Dist: matrx-utils>=2.0.17
29
29
  Requires-Dist: psycopg-pool>=3.2.5
30
30
  Requires-Dist: psycopg[binary]>=3.2.5
31
31
  Requires-Dist: python-dotenv>=1.0
@@ -82,13 +82,36 @@ commit; exceptions roll back; cancellation-shielded teardown finishes before
82
82
  the connection returns to the pool. No path can leave `authenticated` on a
83
83
  reused server connection.
84
84
 
85
+ **Empty-to-nonempty is not contamination proof.** A READ COMMITTED row can be
86
+ created between the original empty query and its reproof (`guest_executions`
87
+ does this under concurrent first requests). `reprove_empty_read` captures the
88
+ pinned server session's identity before and after `RESET SESSION AUTHORIZATION`
89
+ + `RESET ROLE`, returns any newly visible rows, and emits
90
+ `pooler_role_contamination` only when that identity change directly proves a
91
+ borrowed role. This also repairs both `SET ROLE` and `SET SESSION AUTHORIZATION`;
92
+ `RESET ROLE` alone does not repair the latter.
93
+
94
+ **The destructive live proof is loopback-only.**
95
+ `tests/live_pooler_role.py::assert_isolated_pooler_target` runs before DSN
96
+ construction or connection and refuses every shared or remote database. The proof
97
+ deliberately contaminates server connections; repeated `RESET ROLE` statements are
98
+ probabilistic cleanup on a transaction pooler, never isolation. Run it only by
99
+ pointing the existing `SUPABASE_MATRIX_*` values at a loopback Postgres +
100
+ transaction-pooler test stack.
101
+
85
102
  Pinned by `tests/level1/test_rls_session_scope.py` and
86
- `tests/level1/test_connection_poison_guard.py`.
103
+ `tests/level1/test_connection_poison_guard.py`, plus
104
+ `tests/test_pooler_role_guard.py` for the live-proof boundary.
87
105
 
88
- Verified against code: 2026-08-15.
106
+ Verified against code: 2026-08-17.
89
107
 
90
108
  ## Change log
91
109
 
110
+ - 2026-08-17: Required direct before/after role evidence before recording
111
+ contamination; concurrent empty-to-nonempty reads reconcile without a false
112
+ repair-queue row, and session-authorization contamination is reset too.
113
+ - 2026-08-17: Refused destructive pooler-role verification against shared or
114
+ remote databases before any connection is opened.
92
115
  - 2026-08-16: Kept the destructive transaction-pool contamination proof out of
93
116
  the installable runtime package and isolated it under package tests; the
94
117
  operator wrapper is SQL-free and runtime prose no longer resembles an
@@ -37,10 +37,12 @@ TWO INDEPENDENT LAYERS, each sufficient alone, each screaming when it fires:
37
37
 
38
38
  2. **Reconciliation — an empty read is re-proved on a session that can prove its
39
39
  role.** A zero-row result from a read-only query on a transaction-pool config
40
- is re-run inside an explicit transaction (pinned to ONE server connection)
41
- that starts with ``RESET ROLE``. If the rows are really there, the caller gets
42
- the TRUE rows and the lie is reported through every registered sink. If the
43
- session cannot be made clean, it RAISES rather than handing back an absence.
40
+ is re-run inside an explicit transaction (pinned to ONE server connection).
41
+ The pinned session's identity is captured before and after resetting session
42
+ authorization and role. The caller gets any rows that appeared, but a
43
+ contamination alarm is emitted only when the role change was directly
44
+ observed. If the session cannot be made clean, it RAISES rather than handing
45
+ back an absence.
44
46
 
45
47
  Layer 2 follows the repo's guard doctrine: a guard that CAN reconcile MUST
46
48
  reconcile — we hold the correct answer, so killing the caller's request would be
@@ -98,7 +100,7 @@ class PoolerRoleContamination(RuntimeError):
98
100
 
99
101
 
100
102
  class EmptyReadContaminationEvent(BaseModel):
101
- """A read returned zero rows because the session was wearing a borrowed role."""
103
+ """An empty-read reproof directly observed and repaired a borrowed role."""
102
104
 
103
105
  model_config = ConfigDict(extra="forbid", frozen=True)
104
106
 
@@ -166,14 +168,14 @@ def assert_no_session_role_change(query: str) -> None:
166
168
 
167
169
 
168
170
  async def emit_contamination(event: EmptyReadContaminationEvent) -> None:
169
- """Scream about a proven silent-empty read, durably record it, fan out to sinks."""
171
+ """Scream about a directly observed borrowed role and fan out to sinks."""
170
172
  vcprint(
171
- "POOLER ROLE CONTAMINATION — an ORM read returned ZERO ROWS that were "
172
- "really there.\n"
173
+ "POOLER ROLE CONTAMINATION — an empty-read reproof directly observed "
174
+ "and repaired a borrowed server role.\n"
173
175
  f" config: {event.config_name}\n"
174
- f" session ran as: {event.contaminated_role} (login role "
176
+ f" pooled session was: {event.contaminated_role} (clean login role "
175
177
  f"{event.login_role})\n"
176
- f" true row count on a clean session: {event.true_row_count}\n"
178
+ f" cleaned query row count: {event.true_row_count}\n"
177
179
  f" query: {event.query[:300]}\n"
178
180
  " The caller has been given the TRUE rows. Something on this Postgres "
179
181
  "tenant issued a non-LOCAL SET ROLE; every other client is exposed until "
@@ -182,7 +184,7 @@ async def emit_contamination(event: EmptyReadContaminationEvent) -> None:
182
184
  style="bold",
183
185
  )
184
186
  logger.error(
185
- "[pooler-role] silent empty read repaired on %s (ran as %s, %d true rows): %s",
187
+ "[pooler-role] borrowed pooled role repaired on %s (observed %s, %d clean rows): %s",
186
188
  event.config_name,
187
189
  event.contaminated_role,
188
190
  event.true_row_count,
@@ -195,8 +197,9 @@ async def emit_contamination(event: EmptyReadContaminationEvent) -> None:
195
197
 
196
198
  await record_error(
197
199
  PoolerRoleContamination(
198
- f"read returned 0 rows as '{event.contaminated_role}'; "
199
- f"{event.true_row_count} rows were really there"
200
+ f"reproof observed pooled role '{event.contaminated_role}' "
201
+ f"(login role '{event.login_role}'); cleaned read returned "
202
+ f"{event.true_row_count} rows"
200
203
  ),
201
204
  kind="pooler_role_contamination",
202
205
  source_app="matrx-orm",
@@ -241,10 +244,18 @@ async def reprove_empty_read(
241
244
  last_role: tuple[str, str] | None = None
242
245
  for _ in range(REPROVE_ATTEMPTS):
243
246
  async with conn.transaction():
247
+ before = await conn.fetchrow("SELECT current_user, session_user")
248
+ before_current = before["current_user"]
249
+ before_session = before["session_user"]
250
+
251
+ # RESET ROLE alone cannot repair SET SESSION AUTHORIZATION: in that
252
+ # state current_user and session_user can agree on the WRONG identity.
253
+ # Reset both while this transaction pins the exact server connection.
254
+ await conn.execute("RESET SESSION AUTHORIZATION")
244
255
  await conn.execute("RESET ROLE")
245
- identity = await conn.fetchrow("SELECT current_user, session_user")
246
- current = identity["current_user"]
247
- login = identity["session_user"]
256
+ after = await conn.fetchrow("SELECT current_user, session_user")
257
+ current = after["current_user"]
258
+ login = after["session_user"]
248
259
  if current != login:
249
260
  last_role = (current, login)
250
261
  continue
@@ -253,18 +264,24 @@ async def reprove_empty_read(
253
264
  return None
254
265
  true_rows = [normalize(r) for r in rows]
255
266
 
256
- await emit_contamination(
257
- EmptyReadContaminationEvent(
258
- config_name=config_name,
259
- query=query,
260
- # The first read ran on a session we could not observe directly;
261
- # what we can state is the role it was NOT running as.
262
- contaminated_role="<not the login role>" if last_role is None else last_role[0],
263
- login_role=login,
264
- true_row_count=len(true_rows),
265
- occurred_at=datetime.now(UTC),
267
+ observed_contamination = (before_current, before_session) != (current, login)
268
+
269
+ # Rows may legitimately appear between two READ COMMITTED statements.
270
+ # guest_executions is the canonical example: concurrent first requests
271
+ # for one fingerprint race a lookup against creation. Empty -> non-empty
272
+ # is reconciliation, not proof of pool contamination. Only the captured
273
+ # before/after identity change can put this class in the repair queue.
274
+ if observed_contamination:
275
+ await emit_contamination(
276
+ EmptyReadContaminationEvent(
277
+ config_name=config_name,
278
+ query=query,
279
+ contaminated_role=before_current,
280
+ login_role=login,
281
+ true_row_count=len(true_rows),
282
+ occurred_at=datetime.now(UTC),
283
+ )
266
284
  )
267
- )
268
285
  return true_rows
269
286
 
270
287
  current, login = last_role if last_role is not None else ("<unknown>", "<unknown>")
@@ -5,7 +5,10 @@ import ssl
5
5
  import traceback as _tb
6
6
  from collections.abc import Mapping
7
7
  from dataclasses import dataclass
8
+ from datetime import date, datetime, time
9
+ from decimal import Decimal
8
10
  from typing import Any
11
+ from uuid import UUID
9
12
 
10
13
  from matrx_utils import vcprint
11
14
 
@@ -1899,11 +1902,49 @@ class UnknownDatabaseError(ORMException):
1899
1902
  return f"{_RED}{body}{_RESET}"
1900
1903
 
1901
1904
 
1905
+ def jsonable(value: object) -> object:
1906
+ """Coerce a DB value into something JSON-serializable, losslessly where we can.
1907
+
1908
+ Error payloads cross the wire (the platform 409 `version_conflict` body is
1909
+ typed `JsonValue`), so a datetime / UUID / Decimal in a conflict's contested
1910
+ fields must not be what turns a conflict into a 500. Unknown types degrade
1911
+ to `str()` — a display value is always better than a crash inside an error
1912
+ handler.
1913
+ """
1914
+ if value is None or isinstance(value, bool | int | float | str):
1915
+ return value
1916
+ if isinstance(value, datetime | date | time):
1917
+ return value.isoformat()
1918
+ if isinstance(value, Decimal):
1919
+ return float(value)
1920
+ if isinstance(value, UUID):
1921
+ return str(value)
1922
+ if isinstance(value, Mapping):
1923
+ return {str(k): jsonable(v) for k, v in value.items()}
1924
+ if isinstance(value, list | tuple | set | frozenset):
1925
+ return [jsonable(v) for v in value]
1926
+ return str(value)
1927
+
1928
+
1902
1929
  class OptimisticLockError(ORMException):
1903
1930
  """Raised when an optimistic-lock version conflict is detected.
1904
1931
 
1905
- This means another process has updated the row since it was last fetched.
1906
- Re-fetch the record and retry the operation.
1932
+ THE LOSER GETS A DECISION, NOT AN ERROR (platform ruling 2026-08-10, item 3).
1933
+ A caller that lost a compare-and-swap already holds "proposed" (its own
1934
+ payload); this exception carries everything else it needs to RESOLVE rather
1935
+ than blindly retry:
1936
+
1937
+ ``expected_version`` — the revision the caller wrote against
1938
+ ``current_version`` — the revision that actually won
1939
+ ``contested_fields`` — {field: current value} for ONLY the fields this
1940
+ write attempted (never the whole row)
1941
+ ``changed_by`` / ``changed_at`` — who moved the row and when, from
1942
+ ``history.row_versions``; None on an unversioned
1943
+ entity or when the lookup found nothing
1944
+ ``current_row`` — the live instance, for a merge UI without a re-read
1945
+
1946
+ Resolution is just another write: keep-mine / keep-theirs / merged, resubmitted
1947
+ against ``current_version``. No queue, no resolution endpoint.
1907
1948
  """
1908
1949
 
1909
1950
  def __init__(
@@ -1914,13 +1955,29 @@ class OptimisticLockError(ORMException):
1914
1955
  message: str | None = None,
1915
1956
  current_version: int | None = None,
1916
1957
  current_row=None,
1958
+ contested_fields: Mapping[str, object] | None = None,
1959
+ changed_by: object = None,
1960
+ changed_at: object = None,
1917
1961
  ) -> None:
1918
1962
  msg = message or (
1919
1963
  f"Optimistic lock conflict on {getattr(model, '__name__', model)} "
1920
1964
  f"(pk={pk}, expected version={expected_version}"
1921
1965
  + (f", current version={current_version}" if current_version is not None else "")
1966
+ + (f", contested fields={sorted(contested_fields)}" if contested_fields else "")
1967
+ + (f", changed by {changed_by}" if changed_by else "")
1922
1968
  + "). The record was modified by another process — re-fetch and retry."
1923
1969
  )
1970
+ # Stored already-jsonable: this is a decision package for a human or a
1971
+ # wire payload, not a data-access path (that is `current_row`).
1972
+ self.contested_fields: dict[str, object] | None = (
1973
+ {str(k): jsonable(v) for k, v in contested_fields.items()}
1974
+ if contested_fields is not None
1975
+ else None
1976
+ )
1977
+ self.changed_by: str | None = str(changed_by) if changed_by is not None else None
1978
+ self.changed_at: str | None = (
1979
+ jsonable(changed_at) if changed_at is not None else None # type: ignore[assignment]
1980
+ )
1924
1981
  super().__init__(
1925
1982
  message=msg,
1926
1983
  model=model,
@@ -1928,6 +1985,9 @@ class OptimisticLockError(ORMException):
1928
1985
  "pk": pk,
1929
1986
  "expected_version": expected_version,
1930
1987
  "current_version": current_version,
1988
+ "contested_fields": self.contested_fields,
1989
+ "changed_by": self.changed_by,
1990
+ "changed_at": self.changed_at,
1931
1991
  },
1932
1992
  )
1933
1993
  self.expected_version = expected_version
@@ -37,36 +37,129 @@ def _require_version_column(model_cls: type[Model], version_column: str) -> None
37
37
  )
38
38
 
39
39
 
40
+ def _contested_values(
41
+ model_cls: type[Model],
42
+ current: Model,
43
+ attempted_fields: Iterable[str] | None,
44
+ version_column: str,
45
+ ) -> dict[str, Any] | None:
46
+ """The CURRENT values of just the fields the losing write attempted.
47
+
48
+ Only the contested fields — never a dump of the whole row (a conflict body
49
+ is not a read endpoint, and the row may carry columns the caller is not
50
+ entitled to see). The version column and any primary key are excluded: the
51
+ revisions travel as their own fields and the pk was never in contest.
52
+ """
53
+ if not attempted_fields:
54
+ return None
55
+ pks = set(model_cls._meta.primary_keys or [])
56
+ return {
57
+ name: getattr(current, name, None)
58
+ for name in attempted_fields
59
+ if name != version_column and name not in pks and name in model_cls._fields
60
+ }
61
+
62
+
63
+ async def _conflict_actor(
64
+ model_cls: type[Model],
65
+ pk_filter: dict[str, Any],
66
+ current_version: int | None,
67
+ ) -> tuple[Any, Any]:
68
+ """(changed_by, changed_at) for the winning revision, from history.row_versions.
69
+
70
+ Best-effort by construction: absent (None, None) on an unversioned entity, a
71
+ composite-PK table (the version log keys on a single ``row_id``), a missing
72
+ snapshot, or any failure reading the log. A conflict must NEVER be turned
73
+ into a different error because the courtesy lookup did not work out.
74
+ """
75
+ from matrx_orm.entity import MatrxEntity
76
+
77
+ if not isinstance(model_cls, type) or not issubclass(model_cls, MatrxEntity):
78
+ return (None, None)
79
+ if not (model_cls._is_versioned and model_cls._entity_token) or current_version is None:
80
+ return (None, None)
81
+ if len(pk_filter) != 1:
82
+ return (None, None)
83
+ row_id = next(iter(pk_filter.values()))
84
+ try:
85
+ log = model_cls._version_log()
86
+ snapshots = (
87
+ await log.filter(
88
+ entity_type=model_cls._entity_token,
89
+ row_id=str(row_id),
90
+ version=int(current_version),
91
+ )
92
+ .order_by("-id")
93
+ .limit(1)
94
+ .all()
95
+ )
96
+ except Exception as exc:
97
+ vcprint(
98
+ f"[matrx-orm] conflict actor lookup failed for {model_cls.__name__} "
99
+ f"{row_id} v{current_version} ({type(exc).__name__}: {exc}) — the "
100
+ f"conflict still carries its versions and contested fields.",
101
+ color="yellow",
102
+ log_level="WARNING",
103
+ )
104
+ return (None, None)
105
+ if not snapshots:
106
+ return (None, None)
107
+ snapshot = snapshots[0]
108
+ return (getattr(snapshot, "actor_id", None), getattr(snapshot, "occurred_at", None))
109
+
110
+
111
+ async def _build_conflict(
112
+ model_cls: type[Model],
113
+ pk_filter: dict[str, Any],
114
+ version_column: str,
115
+ expected_version: int | None,
116
+ current: Model,
117
+ attempted_fields: Iterable[str] | None,
118
+ ) -> Any:
119
+ """Assemble the full decision package for a lost compare-and-swap."""
120
+ from matrx_orm.exceptions import OptimisticLockError
121
+
122
+ current_version = getattr(current, version_column, None)
123
+ changed_by, changed_at = await _conflict_actor(model_cls, pk_filter, current_version)
124
+ return OptimisticLockError(
125
+ model=model_cls,
126
+ pk=pk_filter if len(pk_filter) > 1 else next(iter(pk_filter.values())),
127
+ expected_version=expected_version,
128
+ current_version=current_version,
129
+ current_row=current,
130
+ contested_fields=_contested_values(model_cls, current, attempted_fields, version_column),
131
+ changed_by=changed_by,
132
+ changed_at=changed_at,
133
+ )
134
+
135
+
40
136
  async def _raise_stale_write(
41
137
  model_cls: type[Model],
42
138
  pk_filter: dict[str, Any],
43
139
  version_column: str,
44
140
  expected_version: int | None,
141
+ attempted_fields: Iterable[str] | None = None,
45
142
  ) -> None:
46
143
  """Classify a guarded UPDATE that hit 0 rows: gone vs. changed.
47
144
 
48
145
  Re-reads by primary key alone (no version filter, no cache). Row absent →
49
146
  DoesNotExist (deleted concurrently, or never existed). Row present → its
50
- version no longer matches → OptimisticLockError carrying the live row.
147
+ version no longer matches → OptimisticLockError carrying the live row and
148
+ the decision package (contested field values + who changed it, when).
51
149
  """
52
- from matrx_orm.exceptions import DoesNotExist, OptimisticLockError
150
+ from matrx_orm.exceptions import DoesNotExist
53
151
 
54
152
  # Note: inside an open Session the re-read merges PENDING (uncommitted)
55
153
  # writes for this row — a CAS should run on the direct write paths, not
56
154
  # Coordinator-adjacent code (see the contract's session-plane exclusion).
57
155
  current = await QueryBuilder(model_cls).filter(**pk_filter).get_or_none()
58
- pk_value = pk_filter if len(pk_filter) > 1 else next(iter(pk_filter.values()))
59
156
  if current is None or getattr(current, "deleted_at", None) is not None:
60
157
  # Hard-gone or soft-deleted: for conflict classification a soft-deleted
61
158
  # row IS gone — reporting it as "changed by someone else, refresh and
62
159
  # re-apply" would send the user chasing a record that no longer exists.
63
160
  raise DoesNotExist(model=model_cls, filters=pk_filter)
64
- raise OptimisticLockError(
65
- model=model_cls,
66
- pk=pk_value,
67
- expected_version=expected_version,
68
- current_version=getattr(current, version_column, None),
69
- current_row=current,
161
+ raise await _build_conflict(
162
+ model_cls, pk_filter, version_column, expected_version, current, attempted_fields
70
163
  )
71
164
 
72
165
 
@@ -91,17 +184,21 @@ async def guarded_update(
91
184
  (e.g. `version=F("version") + 1`), as matrx-graph's DefinitionStore does.
92
185
 
93
186
  0 rows affected raises: DoesNotExist when the row is gone,
94
- OptimisticLockError (with current_version + current_row) when it changed.
95
- The gone-vs-changed re-read uses the PRIMARY-KEY columns only, so an extra
187
+ OptimisticLockError (with current_version, current_row, the contested
188
+ fields' current values and who changed them) when it changed. The
189
+ gone-vs-changed re-read uses the PRIMARY-KEY columns only, so an extra
96
190
  narrowing filter that stopped matching still classifies as a conflict.
97
191
  """
98
192
  _require_version_column(model_cls, version_column)
99
193
  data = dict(update_data)
194
+ attempted_fields = list(data)
100
195
  data.setdefault(version_column, expected_version + 1)
101
196
  result = await update(model_cls, {**filters, version_column: expected_version}, **data)
102
197
  if result.rows_affected == 0:
103
198
  pk_filter = {pk: filters[pk] for pk in model_cls._meta.primary_keys}
104
- await _raise_stale_write(model_cls, pk_filter, version_column, expected_version)
199
+ await _raise_stale_write(
200
+ model_cls, pk_filter, version_column, expected_version, attempted_fields
201
+ )
105
202
  return result
106
203
 
107
204
 
@@ -183,7 +280,11 @@ async def bulk_update(
183
280
  result = await QueryBuilder(model_cls).filter(**row_filter).update(**update_data)
184
281
  if check_version and result.rows_affected == 0:
185
282
  await _raise_stale_write(
186
- model_cls, pk_filter, VERSION_COLUMN, getattr(obj, VERSION_COLUMN, None)
283
+ model_cls,
284
+ pk_filter,
285
+ VERSION_COLUMN,
286
+ getattr(obj, VERSION_COLUMN, None),
287
+ fields,
187
288
  )
188
289
  if result.rows_affected > 0:
189
290
  rows_affected += 1
@@ -271,8 +372,6 @@ async def guarded_delete(
271
372
  version → OptimisticLockError (someone rewrote what the caller decided to
272
373
  delete; deleting it anyway would discard their work unseen).
273
374
  """
274
- from matrx_orm.exceptions import OptimisticLockError
275
-
276
375
  _require_version_column(model_cls, version_column)
277
376
  deleted = (
278
377
  await QueryBuilder(model_cls)
@@ -284,12 +383,10 @@ async def guarded_delete(
284
383
  current = await QueryBuilder(model_cls).filter(**pk_filter).get_or_none()
285
384
  # Soft-deleted counts as already gone — idempotent 0, not a conflict.
286
385
  if current is not None and getattr(current, "deleted_at", None) is None:
287
- raise OptimisticLockError(
288
- model=model_cls,
289
- pk=pk_filter if len(pk_filter) > 1 else next(iter(pk_filter.values())),
290
- expected_version=expected_version,
291
- current_version=getattr(current, version_column, None),
292
- current_row=current,
386
+ # A delete contests no individual field (it contests the whole row),
387
+ # so contested_fields stays absent; the actor half still applies.
388
+ raise await _build_conflict(
389
+ model_cls, pk_filter, version_column, expected_version, current, None
293
390
  )
294
391
  return deleted
295
392
 
@@ -377,8 +474,16 @@ async def update_instance(
377
474
 
378
475
  if result.rows_affected == 0:
379
476
  if version_field_name is not None and current_version is not None:
380
- # Guarded write: classify gone-vs-changed and raise accordingly.
381
- await _raise_stale_write(model_cls, pk_filter, version_field_name, current_version)
477
+ # Guarded write: classify gone-vs-changed and raise accordingly. The
478
+ # contested fields are exactly what this write attempted, minus the
479
+ # version bump the guard itself added.
480
+ await _raise_stale_write(
481
+ model_cls,
482
+ pk_filter,
483
+ version_field_name,
484
+ current_version,
485
+ [f for f in update_data if f != version_field_name],
486
+ )
382
487
  raise ValueError(f"No rows were updated for {model_cls.__name__} with {pk_filter}")
383
488
 
384
489
  if result.updated_rows:
@@ -329,8 +329,7 @@ _HEADER = '''\
329
329
  # because it imports db.models.* / db.managers.* — forbidden inside packages/.
330
330
  """AUTO-GENERATED host->package DB wiring. Do not edit."""
331
331
 
332
- from __future__ import annotations
333
- '''
332
+ from __future__ import annotations'''
334
333
 
335
334
 
336
335
  def _bucket_order(bucket: str) -> int:
@@ -360,6 +359,10 @@ def render_wiring_module(output_module: str, packages: list[ResolvedPackage]) ->
360
359
  for (module, symbol), local in sorted(local_for.items(), key=lambda kv: int(kv[1][2:])):
361
360
  lines.append(f" from {module} import {symbol} as {local}")
362
361
  lines.append(f" import {pkg.configure_import} as _target")
362
+ # Blank line after the import block: keeps the emitted file byte-identical
363
+ # to `ruff format` output, so the wiring drift gate and the formatter can
364
+ # never fight over this file.
365
+ lines.append("")
363
366
 
364
367
  # Build the kwargs.
365
368
  buckets: dict[str, list[Assignment]] = {}
@@ -110,7 +110,12 @@ and:
110
110
  2. Registers a `WatchedLifecycleConfig` with `WatchedLifecycleRegistry`.
111
111
 
112
112
  The sweeper (started by the host app at startup) iterates the registry
113
- on its periodic tick.
113
+ on its periodic tick. A watchdog that automatically transitions rows reports
114
+ every sweep in which it finds work. An alert-only watchdog reports immediately,
115
+ again whenever its stuck-row count changes, after recovery if the condition
116
+ returns, and once per hour while an unchanged condition persists. This keeps a
117
+ durable operational problem visible without emitting the same ERROR every
118
+ minute forever.
114
119
 
115
120
  ## Pending-aware reads
116
121