matrx-orm 3.1.51__tar.gz → 3.1.52__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 (326) hide show
  1. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/BASE_CLASS_METHODS.md +22 -3
  2. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/CLAUDE.md +1 -0
  3. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/PKG-INFO +1 -1
  4. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/base.py +69 -11
  5. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/extended.py +11 -4
  6. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/entity.py +80 -0
  7. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/exceptions.py +16 -3
  8. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/update.py +182 -10
  9. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/pyproject.toml +1 -1
  10. matrx_orm-3.1.52/tests/level1/test_optimistic_concurrency.py +375 -0
  11. matrx_orm-3.1.52/tests/level1/test_update_with_rebase.py +162 -0
  12. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/.gitignore +0 -0
  13. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/AGENTS.md +0 -0
  14. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/MODEL_API.md +0 -0
  15. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/README.md +0 -0
  16. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/database/orm/extended/managers/ai_model_base.py +0 -0
  17. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/docs/migrations.md +0 -0
  18. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/MODULE_README.md +0 -0
  19. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/__init__.py +0 -0
  20. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/MODULE_README.md +0 -0
  21. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/__init__.py +0 -0
  22. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/async_postgresql.py +0 -0
  23. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/base_adapter.py +0 -0
  24. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/postgresql.py +0 -0
  25. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/postgrest_client_adapter.py +0 -0
  26. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/adapters/supabase_adapter.py +0 -0
  27. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/admin/__init__.py +0 -0
  28. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/admin/router.py +0 -0
  29. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/__init__.py +0 -0
  30. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/auth.py +0 -0
  31. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/config.py +0 -0
  32. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/handlers.py +0 -0
  33. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/protocol.py +0 -0
  34. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/api/server.py +0 -0
  35. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/associations.py +0 -0
  36. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/cache_debug.py +0 -0
  37. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/catalog.py +0 -0
  38. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/MODULE_README.md +0 -0
  39. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/__init__.py +0 -0
  40. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/postgres_connection.py +0 -0
  41. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/postgrest.py +0 -0
  42. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/supabase_auth.py +0 -0
  43. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/supabase_config.py +0 -0
  44. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/client/supabase_manager.py +0 -0
  45. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/EXTENDED-TASK.md +0 -0
  46. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/FEATURE.md +0 -0
  47. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/MODULE_README.md +0 -0
  48. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/RELATIONS-TASKS.md +0 -0
  49. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/TASKS.md +0 -0
  50. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/__init__.py +0 -0
  51. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/async_db_manager.py +0 -0
  52. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/config.py +0 -0
  53. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/connections.py +0 -0
  54. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/db_function.py +0 -0
  55. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/diagnostics.py +0 -0
  56. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/expressions.py +0 -0
  57. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/fields.py +0 -0
  58. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/introspection.py +0 -0
  59. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/listen.py +0 -0
  60. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/loop_filters.py +0 -0
  61. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/model_dto.py +0 -0
  62. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/model_view.py +0 -0
  63. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/one_database.py +0 -0
  64. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/paginator.py +0 -0
  65. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/pool_maintenance.py +0 -0
  66. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/pydantic_bridge.py +0 -0
  67. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/registry.py +0 -0
  68. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/relations.py +0 -0
  69. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/resilience.py +0 -0
  70. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/rls_session.py +0 -0
  71. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/signals.py +0 -0
  72. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/tls.py +0 -0
  73. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/transaction.py +0 -0
  74. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/types.py +0 -0
  75. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/core/write_queue.py +0 -0
  76. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/error_handling.py +0 -0
  77. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/extended/__init__.py +0 -0
  78. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/extended/app_error_handler.py +0 -0
  79. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/identity_rule.py +0 -0
  80. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/middleware/__init__.py +0 -0
  81. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/middleware/base.py +0 -0
  82. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/MODULE_README.md +0 -0
  83. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/__init__.py +0 -0
  84. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/cli.py +0 -0
  85. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/ddl.py +0 -0
  86. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/diff.py +0 -0
  87. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/executor.py +0 -0
  88. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/integration.py +0 -0
  89. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/loader.py +0 -0
  90. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/operations.py +0 -0
  91. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/state.py +0 -0
  92. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/migrations/table_filter.py +0 -0
  93. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/MODULE_README.md +0 -0
  94. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/__init__.py +0 -0
  95. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/bulk_update_values.py +0 -0
  96. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/conflict.py +0 -0
  97. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/conflict_writes.py +0 -0
  98. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/create.py +0 -0
  99. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/db_functions.py +0 -0
  100. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/delete.py +0 -0
  101. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/dynamic_admin.py +0 -0
  102. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/dynamic_crud.py +0 -0
  103. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/graph_walk.py +0 -0
  104. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/latest_rows.py +0 -0
  105. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/queue_claim.py +0 -0
  106. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/read.py +0 -0
  107. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/relational_backfill.py +0 -0
  108. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/operations/staging_load.py +0 -0
  109. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/platform_db.py +0 -0
  110. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/py.typed +0 -0
  111. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/python_sql/MODULE_README.md +0 -0
  112. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/python_sql/__init__.py +0 -0
  113. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/python_sql/db_objects.py +0 -0
  114. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/python_sql/table_detailed_relationships.py +0 -0
  115. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/python_sql/table_typescript_relationship.py +0 -0
  116. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/query/FEATURE.md +0 -0
  117. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/query/MODULE_README.md +0 -0
  118. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/query/__init__.py +0 -0
  119. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/query/builder.py +0 -0
  120. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/query/executor.py +0 -0
  121. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/MODULE_README.md +0 -0
  122. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/__init__.py +0 -0
  123. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/code_handler.py +0 -0
  124. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/codegen_writer.py +0 -0
  125. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/columns.py +0 -0
  126. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/common.py +0 -0
  127. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/diff_preview.py +0 -0
  128. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/entity_capabilities.py +0 -0
  129. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/generator.py +0 -0
  130. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/helpers/__init__.py +0 -0
  131. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/helpers/base_generators.py +0 -0
  132. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/helpers/entity_generators.py +0 -0
  133. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/helpers/git_checker.py +0 -0
  134. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/package_wiring.py +0 -0
  135. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/relationships.py +0 -0
  136. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/runner.py +0 -0
  137. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/schema.py +0 -0
  138. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/schema_manager.py +0 -0
  139. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/tables.py +0 -0
  140. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/schema_builder/views.py +0 -0
  141. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/FEATURE.md +0 -0
  142. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/__init__.py +0 -0
  143. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/crypto.py +0 -0
  144. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/items.py +0 -0
  145. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/model.py +0 -0
  146. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/org_service.py +0 -0
  147. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/secrets_battery/service.py +0 -0
  148. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/README.md +0 -0
  149. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/__init__.py +0 -0
  150. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/coalesce.py +0 -0
  151. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/dag.py +0 -0
  152. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/errors.py +0 -0
  153. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/fallback.py +0 -0
  154. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/flush.py +0 -0
  155. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/lifecycle.py +0 -0
  156. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/managed.py +0 -0
  157. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/op.py +0 -0
  158. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/reads.py +0 -0
  159. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/session.py +0 -0
  160. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/session/telemetry.py +0 -0
  161. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/MODULE_README.md +0 -0
  162. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/__init__.py +0 -0
  163. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/executor.py +0 -0
  164. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/queries.py +0 -0
  165. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/registry.py +0 -0
  166. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/types.py +0 -0
  167. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/sql_executor/utils.py +0 -0
  168. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/state.py +0 -0
  169. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/utils/__init__.py +0 -0
  170. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/utils/sql_utils.py +0 -0
  171. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/matrx_orm/utils/type_converters.py +0 -0
  172. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/scripts/git-branches.sh +0 -0
  173. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/scripts/publish.sh +0 -0
  174. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/scripts/release.sh +0 -0
  175. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/MODULE_README.md +0 -0
  176. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/__init__.py +0 -0
  177. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/conftest.py +0 -0
  178. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/MODULE_README.md +0 -0
  179. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/__init__.py +0 -0
  180. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_admin_db_columns.py +0 -0
  181. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_admin_search_negation.py +0 -0
  182. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_admin_search_sentinels.py +0 -0
  183. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_advisory_lock.py +0 -0
  184. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_agent_message.py +0 -0
  185. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_api_auth.py +0 -0
  186. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_api_config.py +0 -0
  187. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_api_handlers.py +0 -0
  188. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_api_protocol.py +0 -0
  189. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_array_agg_order_by.py +0 -0
  190. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_associations.py +0 -0
  191. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_bulk_update_by_pk.py +0 -0
  192. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_bulk_upsert_increment_set_fields.py +0 -0
  193. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_cache_debug.py +0 -0
  194. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_call_function.py +0 -0
  195. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_case_when.py +0 -0
  196. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_catalog.py +0 -0
  197. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_composite_pk_filter.py +0 -0
  198. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_composite_pk_write_paths.py +0 -0
  199. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_config.py +0 -0
  200. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_conflict_target.py +0 -0
  201. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_conflict_writes.py +0 -0
  202. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_connection_codecs.py +0 -0
  203. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_connection_poison_guard.py +0 -0
  204. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_connection_primitives.py +0 -0
  205. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_count_composite_distinct.py +0 -0
  206. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_db_function_field.py +0 -0
  207. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_ddl_generator.py +0 -0
  208. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_derived_table.py +0 -0
  209. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_dirty_tracking.py +0 -0
  210. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_dynamic_crud.py +0 -0
  211. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_exceptions.py +0 -0
  212. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_execute_query_integrity_mapping.py +0 -0
  213. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_expression_primitives.py +0 -0
  214. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_fields.py +0 -0
  215. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_filter_jsonb_agg_array_index.py +0 -0
  216. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_from_alias.py +0 -0
  217. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_fts.py +0 -0
  218. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_func_expression_args.py +0 -0
  219. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_graph_walk.py +0 -0
  220. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_having_annotate_alias.py +0 -0
  221. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_insert_ignore_and_admin_primitives.py +0 -0
  222. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_introspect_rls_policies.py +0 -0
  223. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_join_and_aggregate.py +0 -0
  224. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_latest_rows.py +0 -0
  225. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_listen_notify.py +0 -0
  226. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_managed_write_guard.py +0 -0
  227. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_matrx_entity.py +0 -0
  228. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_migration_diff_types.py +0 -0
  229. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_migration_loader.py +0 -0
  230. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_model_instance.py +0 -0
  231. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_model_meta.py +0 -0
  232. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_order_by_expression.py +0 -0
  233. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_parameter_name_hydration.py +0 -0
  234. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_patch_jsonb_path.py +0 -0
  235. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_pool_loop_recreation.py +0 -0
  236. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_pool_maintenance.py +0 -0
  237. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_postgrest_filters.py +0 -0
  238. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_pydantic_bridge.py +0 -0
  239. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_query_builder.py +0 -0
  240. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_query_error_detail.py +0 -0
  241. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_query_executor_sql.py +0 -0
  242. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_query_timeout.py +0 -0
  243. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_querybuilder_clone_semantics.py +0 -0
  244. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_queue_claim.py +0 -0
  245. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_registry.py +0 -0
  246. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_registry_multi_database.py +0 -0
  247. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_registry_multi_schema.py +0 -0
  248. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_relational_backfill.py +0 -0
  249. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_relations.py +0 -0
  250. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_resilience.py +0 -0
  251. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_schema_exists.py +0 -0
  252. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_session_advisory_lock.py +0 -0
  253. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_state_cache.py +0 -0
  254. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_subquery_filter_raw.py +0 -0
  255. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_subquery_in_filter.py +0 -0
  256. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_supabase_auth.py +0 -0
  257. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_supabase_config.py +0 -0
  258. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_tls.py +0 -0
  259. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_typed_join_projection.py +0 -0
  260. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_typed_predicates.py +0 -0
  261. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_update_case_expression.py +0 -0
  262. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_update_subquery.py +0 -0
  263. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_upsert_default_leak.py +0 -0
  264. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_upsert_with_conflict.py +0 -0
  265. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_values_jsonb_decode.py +0 -0
  266. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_watched_lifecycle.py +0 -0
  267. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level1/test_write_retry_semantics.py +0 -0
  268. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/MODULE_README.md +0 -0
  269. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/__init__.py +0 -0
  270. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/conftest.py +0 -0
  271. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_bulk_ops.py +0 -0
  272. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_cache_integration.py +0 -0
  273. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_crud.py +0 -0
  274. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_foreign_keys.py +0 -0
  275. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_m2m.py +0 -0
  276. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_manager.py +0 -0
  277. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_migrations_live.py +0 -0
  278. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_query_execution.py +0 -0
  279. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/level2/test_schema_diff.py +0 -0
  280. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/.env.example +0 -0
  281. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/README.md +0 -0
  282. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/__init__.py +0 -0
  283. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/generate.py +0 -0
  284. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/generated/.gitkeep +0 -0
  285. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/matrx_orm.yaml +0 -0
  286. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project/test_schema_generation.py +0 -0
  287. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/.env.example +0 -0
  288. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/README.md +0 -0
  289. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/__init__.py +0 -0
  290. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/client_example.py +0 -0
  291. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/client_example.ts +0 -0
  292. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/client_supabase_example.py +0 -0
  293. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/sample_project_desktop/server.py +0 -0
  294. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/entity_tests.py +0 -0
  295. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_base_generation.py +0 -0
  296. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_composite_pk_fk_generation.py +0 -0
  297. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_composite_unique_generation.py +0 -0
  298. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_dto_identity_generation.py +0 -0
  299. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_entity_capability_generation.py +0 -0
  300. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_generate_schema.py +0 -0
  301. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_junction_analysis.py +0 -0
  302. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_multi_schema_output.py +0 -0
  303. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_sql_expression_defaults.py +0 -0
  304. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema/test_view_generation.py +0 -0
  305. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/schema_builder/test_focused_generation_plan.py +0 -0
  306. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/__init__.py +0 -0
  307. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/conftest.py +0 -0
  308. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_cache_no_downgrade.py +0 -0
  309. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_capture_poison_proof.py +0 -0
  310. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_coalesce.py +0 -0
  311. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_dag.py +0 -0
  312. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_disk_spill_fallback.py +0 -0
  313. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_flush_individual_fallback.py +0 -0
  314. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_governed_write.py +0 -0
  315. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_managed.py +0 -0
  316. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_op_enqueue_site.py +0 -0
  317. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_reads.py +0 -0
  318. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/session/test_session.py +0 -0
  319. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_database_name_alias.py +0 -0
  320. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_db_functions.py +0 -0
  321. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_model_cls_refactor.py +0 -0
  322. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_no_blocking_asyncpg_tls.py +0 -0
  323. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_one_database.py +0 -0
  324. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_secrets_battery.py +0 -0
  325. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_sql_param_casts.py +0 -0
  326. {matrx_orm-3.1.51 → matrx_orm-3.1.52}/tests/test_sync_twins_guard.py +0 -0
@@ -49,7 +49,7 @@ The following methods are available directly from all manager classes:
49
49
  ### CRUD & Bulk
50
50
  - `create_item(**data)`
51
51
  - `create_items(items_data, batch_size=1000, ignore_conflicts=False)`
52
- - `update_item(item_id, **updates)`
52
+ - `update_item(item_id, *, expected_version=None, **updates)`
53
53
  - `update_item_fields(item_id, **updates)`
54
54
  - `update_items(objects, fields, batch_size=1000)`
55
55
  - `delete_item(item_id)`
@@ -59,8 +59,27 @@ The following methods are available directly from all manager classes:
59
59
  - `upsert_item(data, conflict_fields, update_fields=None)`
60
60
  - `upsert_items(items_data, conflict_fields, update_fields=None)`
61
61
  - `count(**filters)`
62
- - `update_where(filters, **updates)`
63
- - `delete_where(**filters)`
62
+ - `update_where(filters, *, expected_version=None, **updates)`
63
+ - `delete_where(*, expected_version=None, **filters)`
64
+
65
+ ### Optimistic concurrency — `expected_version=`
66
+
67
+ **Opt-in compare-and-swap on the canonical `version` int column.** Pass
68
+ `expected_version=N` to `update_where` / `update_item` / instance `update()` /
69
+ `delete_where`: the statement gains `WHERE version = N` (updates also
70
+ `SET version = N + 1`). **Single-row paths raise on 0 rows** — updates:
71
+ `DoesNotExist` when the row is gone, `OptimisticLockError` (with
72
+ `current_version` + `current_row`) when it changed; guarded `delete_where`:
73
+ gone → returns 0 (idempotent), changed → `OptimisticLockError`. Guarded
74
+ `update_where`/`delete_where` require every PK column in the filters
75
+ (single-row addressing). **Bulk is different:** `bulk_update(...,
76
+ check_version=True)` guards each row on its loaded `version` but NEVER raises
77
+ to the caller — a conflicting row is logged loudly and excluded from the
78
+ returned count. Omit the parameter → exact legacy last-write-wins.
79
+ `MatrxEntity.update_with_rebase(id, expected_version=N, **fields)` additionally
80
+ auto-rebases past conflicts whose intervening writes were DISJOINT from the
81
+ written fields (proven from `history.row_versions` snapshots). Cross-repo
82
+ contract: `common-docs/systems/optimistic-concurrency/FEATURE.md`.
64
83
 
65
84
  ### Atomic queue claims
66
85
 
@@ -23,6 +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
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.
27
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`.
28
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).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrx-orm
3
- Version: 3.1.51
3
+ Version: 3.1.52
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
@@ -971,11 +971,24 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
971
971
  return run_sync(cls.exists(**kwargs))
972
972
 
973
973
  @classmethod
974
- async def update_where(cls, filters: dict[str, Any], **updates: Any) -> UpdateResult:
974
+ async def update_where(
975
+ cls,
976
+ filters: dict[str, Any],
977
+ *,
978
+ expected_version: int | None = None,
979
+ **updates: Any,
980
+ ) -> UpdateResult:
975
981
  """Bulk-update rows matching filters without fetching them first.
976
982
 
977
983
  Args:
978
984
  filters: Lookup kwargs (same syntax as filter()).
985
+ expected_version: opt-in optimistic concurrency — compare-and-swap
986
+ against the canonical ``version`` column. Requires the filters
987
+ to address ONE row (every primary-key column present as a plain
988
+ equality). 0 rows then RAISES: DoesNotExist when the row is
989
+ gone, OptimisticLockError (with ``current_version`` and
990
+ ``current_row``) when it changed since the declared revision.
991
+ Omitted → exact legacy behavior (last-write-wins, no raise).
979
992
  **updates: Field=value pairs to SET.
980
993
 
981
994
  Returns:
@@ -988,13 +1001,30 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
988
1001
  )
989
1002
  print(result.rows_affected)
990
1003
  """
1004
+ if expected_version is not None:
1005
+ missing = [pk for pk in cls._meta.primary_keys if pk not in filters]
1006
+ if missing:
1007
+ raise ValueError(
1008
+ f"{cls.__name__}.update_where(expected_version=...) is a "
1009
+ f"single-row compare-and-swap: the filters must contain every "
1010
+ f"primary-key column as a plain equality (missing: {missing})."
1011
+ )
1012
+ return await update.guarded_update(
1013
+ cls, dict(filters), dict(updates), expected_version=expected_version
1014
+ )
991
1015
  return await QueryBuilder(cls).filter(**filters).update(**updates)
992
1016
 
993
1017
  @classmethod
994
- async def delete_where(cls, **filters: Any) -> int:
1018
+ async def delete_where(cls, *, expected_version: int | None = None, **filters: Any) -> int:
995
1019
  """Delete rows matching filters without fetching them first.
996
1020
 
997
1021
  Args:
1022
+ expected_version: opt-in optimistic concurrency — only delete if the
1023
+ canonical ``version`` column still matches. Requires every
1024
+ primary-key column in the filters (single-row addressing). Row
1025
+ already gone → returns 0 (idempotent); row present at a
1026
+ different version → OptimisticLockError (someone rewrote what
1027
+ the caller decided to delete). Omitted → legacy behavior.
998
1028
  **filters: Lookup kwargs (same syntax as filter()).
999
1029
 
1000
1030
  Returns:
@@ -1003,6 +1033,17 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1003
1033
  Usage:
1004
1034
  deleted = await MyModel.delete_where(status="expired")
1005
1035
  """
1036
+ if expected_version is not None:
1037
+ missing = [pk for pk in cls._meta.primary_keys if pk not in filters]
1038
+ if missing:
1039
+ raise ValueError(
1040
+ f"{cls.__name__}.delete_where(expected_version=...) is a "
1041
+ f"single-row compare-and-swap: the filters must contain every "
1042
+ f"primary-key column as a plain equality (missing: {missing})."
1043
+ )
1044
+ return await update.guarded_delete(
1045
+ cls, dict(filters), expected_version=expected_version
1046
+ )
1006
1047
  return await QueryBuilder(cls).filter(**filters).delete()
1007
1048
 
1008
1049
  @classmethod
@@ -1191,15 +1232,18 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1191
1232
  return await cls.create(**data)
1192
1233
 
1193
1234
  @classmethod
1194
- async def update_item(cls, item_id: Any, **updates: Any) -> Self:
1235
+ async def update_item(
1236
+ cls, item_id: Any, *, expected_version: int | None = None, **updates: Any
1237
+ ) -> Self:
1195
1238
  """Update a row by primary key and return it. Raises DoesNotExist if absent.
1196
1239
 
1197
1240
  Mirrors ``BaseManager.update_item``: SELECT by pk, then UPDATE. When the
1198
1241
  returned instance isn't needed, prefer ``update_where({pk: id}, ...)``
1199
- (a single statement).
1242
+ (a single statement). ``expected_version`` opts into optimistic
1243
+ concurrency — see :meth:`update`.
1200
1244
  """
1201
1245
  instance = await cls.get(**cls._build_pk_filter(item_id))
1202
- return await instance.update(**updates)
1246
+ return await instance.update(expected_version=expected_version, **updates)
1203
1247
 
1204
1248
  @classmethod
1205
1249
  async def get_or_create(
@@ -1264,7 +1308,13 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1264
1308
  return run_sync(cls.upsert(data, conflict_fields, update_fields, on_conflict=on_conflict))
1265
1309
 
1266
1310
  @classmethod
1267
- def update_where_sync(cls, filters: dict[str, Any], **updates: Any) -> UpdateResult:
1311
+ def update_where_sync(
1312
+ cls,
1313
+ filters: dict[str, Any],
1314
+ *,
1315
+ expected_version: int | None = None,
1316
+ **updates: Any,
1317
+ ) -> UpdateResult:
1268
1318
  """Synchronous twin of update_where(). Sync contexts only."""
1269
1319
  try:
1270
1320
  asyncio.get_running_loop()
@@ -1275,10 +1325,10 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1275
1325
  if "no running event loop" not in str(e):
1276
1326
  raise
1277
1327
 
1278
- return run_sync(cls.update_where(filters, **updates))
1328
+ return run_sync(cls.update_where(filters, expected_version=expected_version, **updates))
1279
1329
 
1280
1330
  @classmethod
1281
- def delete_where_sync(cls, **filters: Any) -> int:
1331
+ def delete_where_sync(cls, *, expected_version: int | None = None, **filters: Any) -> int:
1282
1332
  """Synchronous twin of delete_where(). Sync contexts only."""
1283
1333
  try:
1284
1334
  asyncio.get_running_loop()
@@ -1289,7 +1339,7 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1289
1339
  if "no running event loop" not in str(e):
1290
1340
  raise
1291
1341
 
1292
- return run_sync(cls.delete_where(**filters))
1342
+ return run_sync(cls.delete_where(expected_version=expected_version, **filters))
1293
1343
 
1294
1344
  @classmethod
1295
1345
  async def all(cls, *, use_cache: bool = True) -> list[Self]:
@@ -1373,9 +1423,15 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1373
1423
  e.enrich(model=self.__class__, operation="save")
1374
1424
  raise
1375
1425
 
1376
- async def update(self, **kwargs: Any) -> Self:
1426
+ async def update(self, *, expected_version: int | None = None, **kwargs: Any) -> Self:
1377
1427
  """Update specific fields and save in one operation.
1378
1428
 
1429
+ ``expected_version`` opts into optimistic concurrency: the UPDATE becomes
1430
+ a compare-and-swap on the canonical ``version`` column against the
1431
+ declared base revision, raising OptimisticLockError (with the live row)
1432
+ on a mismatch and DoesNotExist when the row is gone. Omitted → exact
1433
+ legacy behavior.
1434
+
1379
1435
  Returns the same instance with updated fields.
1380
1436
  """
1381
1437
  invalid_fields = [k for k in kwargs if k not in self._fields]
@@ -1394,7 +1450,9 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1394
1450
  for field, value in kwargs.items():
1395
1451
  setattr(self, field, value)
1396
1452
 
1397
- await update.update_instance(self, fields=kwargs.keys())
1453
+ await update.update_instance(
1454
+ self, fields=kwargs.keys(), expected_version=expected_version
1455
+ )
1398
1456
  # Clear ONLY what was actually written. _mark_clean() here would wipe
1399
1457
  # unrelated pending edits: `m.purpose = "x"` (dirty, unsaved) followed by
1400
1458
  # `await m.update(status="y")` would drop `purpose` from _dirty, and the
@@ -375,7 +375,9 @@ class BaseManager(Generic[ModelT]):
375
375
  return initialized_items
376
376
 
377
377
  @handle_errors
378
- async def _update_item(self, item: ModelT, **updates: Any) -> ModelT:
378
+ async def _update_item(
379
+ self, item: ModelT, *, expected_version: int | None = None, **updates: Any
380
+ ) -> ModelT:
379
381
  """Update an existing item."""
380
382
  if not item:
381
383
  raise AppError(
@@ -384,7 +386,7 @@ class BaseManager(Generic[ModelT]):
384
386
  client_visible="The item to update could not be found.",
385
387
  )
386
388
  updates = self._validate_input_data(updates, partial=True)
387
- await item.update(**updates)
389
+ await item.update(expected_version=expected_version, **updates)
388
390
  return await self._initialize_item_runtime(item)
389
391
 
390
392
  @handle_errors
@@ -613,17 +615,22 @@ class BaseManager(Generic[ModelT]):
613
615
  items_data, batch_size=batch_size, ignore_conflicts=ignore_conflicts
614
616
  )
615
617
 
616
- async def update_item(self, item_id: Any, **updates: Any) -> ModelT:
618
+ async def update_item(
619
+ self, item_id: Any, *, expected_version: int | None = None, **updates: Any
620
+ ) -> ModelT:
617
621
  """Update an item by primary key.
618
622
 
619
623
  Uses ``self._pk_name`` so models whose primary key is NOT named ``id``
620
624
  (e.g. ``FileAnalysis`` whose PK is ``file_id``) work correctly.
621
625
  Sibling methods like ``update_item_fields`` and ``remove_active_by_id``
622
626
  already use this pattern — ``update_item`` was the historical outlier.
627
+
628
+ ``expected_version`` opts into optimistic concurrency (compare-and-swap
629
+ on the canonical ``version`` column) — see ``Model.update``.
623
630
  """
624
631
  async with self._governed_write():
625
632
  item = await self._get_item_or_raise(**self._pk_filter(item_id))
626
- return await self._update_item(item, **updates)
633
+ return await self._update_item(item, expected_version=expected_version, **updates)
627
634
 
628
635
  async def update_item_fields(self, item_id: Any, **updates: Any) -> int:
629
636
  """Update an item by ID via a single ``UPDATE … WHERE id=$1`` statement.
@@ -232,3 +232,83 @@ class MatrxEntity(Model):
232
232
  # Refresh through the ORM so the short-term cache holds the RESTORED row.
233
233
  await cls.get_or_none(id=str(self.id), use_cache=False)
234
234
  return int(new_version)
235
+
236
+ @classmethod
237
+ async def update_with_rebase(
238
+ cls,
239
+ item_id: Any,
240
+ *,
241
+ expected_version: int,
242
+ max_attempts: int = 3,
243
+ **updates: Any,
244
+ ) -> Self:
245
+ """Optimistically update, auto-rebasing past DISJOINT concurrent writes.
246
+
247
+ The platform conflict policy for field-scoped edits: a version conflict
248
+ where the intervening writes did not touch any of the fields being
249
+ written here is not a real conflict — rebase onto the head revision and
250
+ retry. Decided from the version SNAPSHOTS (``history.row_versions``):
251
+ for each field in ``updates``, compare its value at the caller's base
252
+ revision against its value at the head revision. Any difference means a
253
+ genuine overlapping edit → OptimisticLockError propagates (with
254
+ ``current_version`` + ``current_row`` for the caller's own merge UI).
255
+
256
+ Requires the entity to be versioned (snapshots are the evidence);
257
+ a jsonb column counts as ONE field — no intra-JSON merging.
258
+ """
259
+ from matrx_orm.exceptions import OptimisticLockError
260
+
261
+ cls._require(
262
+ "versioning",
263
+ cls._is_versioned,
264
+ "update_with_rebase needs version snapshots to prove the concurrent "
265
+ "writes were disjoint. Use update_item(expected_version=...) and "
266
+ "handle the conflict yourself.",
267
+ )
268
+ token = cls._require_token()
269
+ log = cls._version_log()
270
+
271
+ base_version = int(expected_version)
272
+ for _ in range(max(1, max_attempts)):
273
+ try:
274
+ return await cls.update_item(
275
+ item_id, expected_version=base_version, **updates
276
+ )
277
+ except OptimisticLockError as exc:
278
+ head_version = exc.current_version
279
+ if head_version is None:
280
+ raise
281
+ # Newest snapshot per version (a DELETE reuses the prior version
282
+ # number, so order by id — the log's own monotonic identity).
283
+ snapshots = (
284
+ await log.filter(
285
+ entity_type=token,
286
+ row_id=str(item_id),
287
+ version__in=[base_version, int(head_version)],
288
+ )
289
+ .order_by("-id")
290
+ .all()
291
+ )
292
+ base = next((s for s in snapshots if s.version == base_version), None)
293
+ head = next((s for s in snapshots if s.version == int(head_version)), None)
294
+ if base is None or head is None:
295
+ raise # no evidence → never guess; surface the conflict
296
+ overlapping = [
297
+ f
298
+ for f in updates
299
+ if (base.row_data or {}).get(f) != (head.row_data or {}).get(f)
300
+ ]
301
+ if overlapping:
302
+ raise # a field we are writing really changed underneath us
303
+ base_version = int(head_version)
304
+ # Retries exhausted under sustained contention — surface the live state.
305
+ raise OptimisticLockError(
306
+ model=cls,
307
+ pk=item_id,
308
+ expected_version=base_version,
309
+ message=(
310
+ f"update_with_rebase gave up after {max_attempts} attempts on "
311
+ f"{cls.__name__} {item_id} — sustained concurrent writes. "
312
+ f"Re-fetch and retry."
313
+ ),
314
+ )
@@ -1912,17 +1912,30 @@ class OptimisticLockError(ORMException):
1912
1912
  pk=None,
1913
1913
  expected_version: int | None = None,
1914
1914
  message: str | None = None,
1915
+ current_version: int | None = None,
1916
+ current_row=None,
1915
1917
  ) -> None:
1916
1918
  msg = message or (
1917
1919
  f"Optimistic lock conflict on {getattr(model, '__name__', model)} "
1918
- f"(pk={pk}, expected version={expected_version}). "
1919
- "The record was modified by another process — re-fetch and retry."
1920
+ f"(pk={pk}, expected version={expected_version}"
1921
+ + (f", current version={current_version}" if current_version is not None else "")
1922
+ + "). The record was modified by another process — re-fetch and retry."
1920
1923
  )
1921
1924
  super().__init__(
1922
1925
  message=msg,
1923
1926
  model=model,
1924
- details={"pk": pk, "expected_version": expected_version},
1927
+ details={
1928
+ "pk": pk,
1929
+ "expected_version": expected_version,
1930
+ "current_version": current_version,
1931
+ },
1925
1932
  )
1933
+ self.expected_version = expected_version
1934
+ self.current_version = current_version
1935
+ # The live row as re-read at conflict time (a Model instance), so the
1936
+ # caller can merge/rebase without a second fetch. May be None when the
1937
+ # raiser could not re-read.
1938
+ self.current_row = current_row
1926
1939
 
1927
1940
 
1928
1941
  class WriteQueueFullError(DatabaseError):
@@ -22,14 +22,124 @@ async def update(model_cls: type[Model], filters: dict[str, Any], **kwargs: Any)
22
22
  return await QueryBuilder(model_cls).filter(**filters).update(**kwargs)
23
23
 
24
24
 
25
- async def bulk_update(model_cls: type[Model], objects: Sequence[Model], fields: list[str]) -> int:
25
+ # The canonical revision column of the platform entity contract. `platform._touch_row`
26
+ # bumps it on every UPDATE; optimistic concurrency compares-and-swaps against it.
27
+ VERSION_COLUMN = "version"
28
+
29
+
30
+ def _require_version_column(model_cls: type[Model], version_column: str) -> None:
31
+ if version_column not in model_cls._fields:
32
+ raise ValueError(
33
+ f"{model_cls.__name__} has no '{version_column}' column — optimistic "
34
+ f"concurrency (expected_version=) requires the canonical integer "
35
+ f"version column maintained by platform._touch_row. Either the table "
36
+ f"is not a canonical entity, or the model is stale (rerun generation)."
37
+ )
38
+
39
+
40
+ async def _raise_stale_write(
41
+ model_cls: type[Model],
42
+ pk_filter: dict[str, Any],
43
+ version_column: str,
44
+ expected_version: int | None,
45
+ ) -> None:
46
+ """Classify a guarded UPDATE that hit 0 rows: gone vs. changed.
47
+
48
+ Re-reads by primary key alone (no version filter, no cache). Row absent →
49
+ DoesNotExist (deleted concurrently, or never existed). Row present → its
50
+ version no longer matches → OptimisticLockError carrying the live row.
51
+ """
52
+ from matrx_orm.exceptions import DoesNotExist, OptimisticLockError
53
+
54
+ # Note: inside an open Session the re-read merges PENDING (uncommitted)
55
+ # writes for this row — a CAS should run on the direct write paths, not
56
+ # Coordinator-adjacent code (see the contract's session-plane exclusion).
57
+ 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
+ if current is None or getattr(current, "deleted_at", None) is not None:
60
+ # Hard-gone or soft-deleted: for conflict classification a soft-deleted
61
+ # row IS gone — reporting it as "changed by someone else, refresh and
62
+ # re-apply" would send the user chasing a record that no longer exists.
63
+ 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,
70
+ )
71
+
72
+
73
+ async def guarded_update(
74
+ model_cls: type[Model],
75
+ filters: dict[str, Any],
76
+ update_data: dict[str, Any],
77
+ *,
78
+ expected_version: int,
79
+ version_column: str = VERSION_COLUMN,
80
+ ) -> UpdateResult:
81
+ """Compare-and-swap UPDATE: WHERE <filters> AND version = expected_version.
82
+
83
+ ``filters`` must address one row (every primary-key column as a plain
84
+ equality; extra narrowing filters are allowed). The version column is also
85
+ SET to expected_version + 1 so the CAS observes GUARDED writes on tables
86
+ without the `_touch_row` trigger; where the trigger exists it recomputes
87
+ the same value (OLD.version + 1) and wins — identical result. NOTE: on
88
+ trigger-less tables the CAS is sound only among writers that bump the
89
+ version column — an unguarded UPDATE that leaves `version` untouched is
90
+ invisible to it. Every content-write path on such a table must bump
91
+ (e.g. `version=F("version") + 1`), as matrx-graph's DefinitionStore does.
92
+
93
+ 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
96
+ narrowing filter that stopped matching still classifies as a conflict.
97
+ """
98
+ _require_version_column(model_cls, version_column)
99
+ data = dict(update_data)
100
+ data.setdefault(version_column, expected_version + 1)
101
+ result = await update(model_cls, {**filters, version_column: expected_version}, **data)
102
+ if result.rows_affected == 0:
103
+ 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)
105
+ return result
106
+
107
+
108
+ async def bulk_update(
109
+ model_cls: type[Model],
110
+ objects: Sequence[Model],
111
+ fields: list[str],
112
+ *,
113
+ check_version: bool = False,
114
+ ) -> int:
26
115
  """
27
116
  Enhanced bulk_update that works within the current ORM limitations.
28
117
  Uses controlled batch processing for individual per-row updates.
118
+
119
+ ``check_version=True`` turns each row's write into a compare-and-swap on the
120
+ canonical ``version`` column, using the version each instance was LOADED
121
+ with. Rows whose stored version moved are counted as failures (reported
122
+ loudly in the summary), never silently overwritten and never raised to the
123
+ caller — compare the returned count to ``len(objects)`` to detect losses.
124
+ Every instance must carry a loaded ``version`` value; a batch containing a
125
+ version-less stub raises ValueError BEFORE any row is written.
29
126
  """
30
127
  if not objects or not fields:
31
128
  return 0
32
129
 
130
+ if check_version:
131
+ _require_version_column(model_cls, VERSION_COLUMN)
132
+ stubs = [
133
+ obj for obj in objects if getattr(obj, VERSION_COLUMN, None) is None
134
+ ]
135
+ if stubs:
136
+ raise ValueError(
137
+ f"bulk_update(check_version=True) on {model_cls.__name__}: "
138
+ f"{len(stubs)} instance(s) have no loaded '{VERSION_COLUMN}' "
139
+ f"(partial stubs?). Load rows with their version, or drop the "
140
+ f"guard. No rows were written."
141
+ )
142
+
33
143
  invalid_fields = [k for k in fields if k not in model_cls._fields]
34
144
  if invalid_fields:
35
145
  vcprint(model_cls._fields, "Model Fields", color="yellow")
@@ -60,8 +170,21 @@ async def bulk_update(model_cls: type[Model], objects: Sequence[Model], fields:
60
170
  if value is not None:
61
171
  update_data[field_name] = field.get_db_prep_value(value)
62
172
 
173
+ row_filter = dict(pk_filter)
174
+ if check_version:
175
+ base_version = int(getattr(obj, VERSION_COLUMN))
176
+ row_filter[VERSION_COLUMN] = base_version
177
+ # Unconditional: even if 'version' is in `fields`, the guarded
178
+ # write owns the bump — keeping a caller-supplied base value
179
+ # would let a same-base CAS keep passing on trigger-less tables.
180
+ update_data[VERSION_COLUMN] = base_version + 1
181
+
63
182
  if update_data:
64
- result = await QueryBuilder(model_cls).filter(**pk_filter).update(**update_data)
183
+ result = await QueryBuilder(model_cls).filter(**row_filter).update(**update_data)
184
+ if check_version and result.rows_affected == 0:
185
+ await _raise_stale_write(
186
+ model_cls, pk_filter, VERSION_COLUMN, getattr(obj, VERSION_COLUMN, None)
187
+ )
65
188
  if result.rows_affected > 0:
66
189
  rows_affected += 1
67
190
  # Do NOT cache `obj` here. It is the CALLER's instance and is
@@ -133,10 +256,52 @@ async def decrement(model_cls: type[Model], filters: dict[str, Any], **kwargs: A
133
256
  return await update(model_cls, filters, **updates)
134
257
 
135
258
 
136
- async def update_instance(instance: Model, fields: Iterable[str] | None = None) -> Model:
137
- from matrx_orm.core.signals import post_save, pre_save
259
+ async def guarded_delete(
260
+ model_cls: type[Model],
261
+ filters: dict[str, Any],
262
+ *,
263
+ expected_version: int,
264
+ version_column: str = VERSION_COLUMN,
265
+ ) -> int:
266
+ """Compare-and-swap DELETE: WHERE <filters> AND version = expected_version.
267
+
268
+ ``filters`` must address one row (every primary-key column as a plain
269
+ equality). 0 rows deleted: row already gone → returns 0 (a delete is
270
+ idempotent — the desired end state holds); row present with a different
271
+ version → OptimisticLockError (someone rewrote what the caller decided to
272
+ delete; deleting it anyway would discard their work unseen).
273
+ """
138
274
  from matrx_orm.exceptions import OptimisticLockError
139
275
 
276
+ _require_version_column(model_cls, version_column)
277
+ deleted = (
278
+ await QueryBuilder(model_cls)
279
+ .filter(**{**filters, version_column: expected_version})
280
+ .delete()
281
+ )
282
+ if deleted == 0:
283
+ pk_filter = {pk: filters[pk] for pk in model_cls._meta.primary_keys}
284
+ current = await QueryBuilder(model_cls).filter(**pk_filter).get_or_none()
285
+ # Soft-deleted counts as already gone — idempotent 0, not a conflict.
286
+ 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,
293
+ )
294
+ return deleted
295
+
296
+
297
+ async def update_instance(
298
+ instance: Model,
299
+ fields: Iterable[str] | None = None,
300
+ *,
301
+ expected_version: int | None = None,
302
+ ) -> Model:
303
+ from matrx_orm.core.signals import post_save, pre_save
304
+
140
305
  model_cls = instance.__class__
141
306
  await pre_save.send(model_cls, instance=instance)
142
307
 
@@ -163,6 +328,16 @@ async def update_instance(instance: Model, fields: Iterable[str] | None = None)
163
328
  current_version = getattr(instance, fname, None)
164
329
  break
165
330
 
331
+ # Explicit caller-declared base revision (the platform optimistic-concurrency
332
+ # contract). Targets the canonical `version` column unless the model declares
333
+ # a VersionField under another name; the explicit value always wins over the
334
+ # instance's in-memory copy.
335
+ if expected_version is not None:
336
+ if version_field_name is None:
337
+ _require_version_column(model_cls, VERSION_COLUMN)
338
+ version_field_name = VERSION_COLUMN
339
+ current_version = expected_version
340
+
166
341
  update_data: dict[str, Any] = {}
167
342
  field_names: Iterable[str] = (
168
343
  fields if fields is not None else [f for f in model_cls._fields if f not in pk_filter]
@@ -201,12 +376,9 @@ async def update_instance(instance: Model, fields: Iterable[str] | None = None)
201
376
  result = await update(model_cls, filters, **update_data)
202
377
 
203
378
  if result.rows_affected == 0:
204
- if version_field_name is not None:
205
- raise OptimisticLockError(
206
- model=model_cls,
207
- pk=pk_filter if len(pk_filter) > 1 else next(iter(pk_filter.values())),
208
- expected_version=current_version,
209
- )
379
+ 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)
210
382
  raise ValueError(f"No rows were updated for {model_cls.__name__} with {pk_filter}")
211
383
 
212
384
  if result.updated_rows:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "matrx-orm"
3
- version = "3.1.51"
3
+ version = "3.1.52"
4
4
  description = "Async-first PostgreSQL ORM with bidirectional migrations, schema introspection, many-to-many relationships, and built-in state caching"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }