matrx-orm 3.1.35__tar.gz → 3.1.37__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 (317) hide show
  1. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/CLAUDE.md +25 -0
  2. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/PKG-INFO +1 -1
  3. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/base.py +11 -5
  4. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/update.py +8 -2
  5. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/query/builder.py +46 -4
  6. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/README.md +18 -0
  7. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/flush.py +11 -2
  8. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/state.py +137 -1
  9. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/pyproject.toml +1 -1
  10. matrx_orm-3.1.37/tests/session/test_cache_no_downgrade.py +136 -0
  11. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/.gitignore +0 -0
  12. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/AGENTS.md +0 -0
  13. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/BASE_CLASS_METHODS.md +0 -0
  14. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/MODEL_API.md +0 -0
  15. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/README.md +0 -0
  16. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/database/orm/extended/managers/ai_model_base.py +0 -0
  17. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/docs/migrations.md +0 -0
  18. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/MODULE_README.md +0 -0
  19. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/__init__.py +0 -0
  20. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/MODULE_README.md +0 -0
  21. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/__init__.py +0 -0
  22. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/async_postgresql.py +0 -0
  23. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/base_adapter.py +0 -0
  24. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/postgresql.py +0 -0
  25. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/postgrest_client_adapter.py +0 -0
  26. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/supabase_adapter.py +0 -0
  27. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/admin/__init__.py +0 -0
  28. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/admin/router.py +0 -0
  29. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/__init__.py +0 -0
  30. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/auth.py +0 -0
  31. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/config.py +0 -0
  32. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/handlers.py +0 -0
  33. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/protocol.py +0 -0
  34. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/server.py +0 -0
  35. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/associations.py +0 -0
  36. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/cache_debug.py +0 -0
  37. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/catalog.py +0 -0
  38. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/MODULE_README.md +0 -0
  39. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/__init__.py +0 -0
  40. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/postgres_connection.py +0 -0
  41. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/postgrest.py +0 -0
  42. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/supabase_auth.py +0 -0
  43. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/supabase_config.py +0 -0
  44. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/supabase_manager.py +0 -0
  45. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/EXTENDED-TASK.md +0 -0
  46. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/MODULE_README.md +0 -0
  47. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/RELATIONS-TASKS.md +0 -0
  48. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/TASKS.md +0 -0
  49. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/__init__.py +0 -0
  50. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/async_db_manager.py +0 -0
  51. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/config.py +0 -0
  52. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/connections.py +0 -0
  53. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/db_function.py +0 -0
  54. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/diagnostics.py +0 -0
  55. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/expressions.py +0 -0
  56. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/extended.py +0 -0
  57. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/fields.py +0 -0
  58. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/introspection.py +0 -0
  59. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/listen.py +0 -0
  60. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/loop_filters.py +0 -0
  61. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/model_dto.py +0 -0
  62. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/model_view.py +0 -0
  63. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/paginator.py +0 -0
  64. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/pool_maintenance.py +0 -0
  65. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/pydantic_bridge.py +0 -0
  66. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/registry.py +0 -0
  67. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/relations.py +0 -0
  68. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/resilience.py +0 -0
  69. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/rls_session.py +0 -0
  70. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/signals.py +0 -0
  71. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/tls.py +0 -0
  72. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/transaction.py +0 -0
  73. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/types.py +0 -0
  74. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/write_queue.py +0 -0
  75. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/entity.py +0 -0
  76. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/error_handling.py +0 -0
  77. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/exceptions.py +0 -0
  78. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/extended/__init__.py +0 -0
  79. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/extended/app_error_handler.py +0 -0
  80. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/identity_rule.py +0 -0
  81. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/middleware/__init__.py +0 -0
  82. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/middleware/base.py +0 -0
  83. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/MODULE_README.md +0 -0
  84. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/__init__.py +0 -0
  85. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/cli.py +0 -0
  86. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/ddl.py +0 -0
  87. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/diff.py +0 -0
  88. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/executor.py +0 -0
  89. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/integration.py +0 -0
  90. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/loader.py +0 -0
  91. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/operations.py +0 -0
  92. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/state.py +0 -0
  93. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/table_filter.py +0 -0
  94. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/MODULE_README.md +0 -0
  95. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/__init__.py +0 -0
  96. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/bulk_update_values.py +0 -0
  97. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/conflict.py +0 -0
  98. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/conflict_writes.py +0 -0
  99. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/create.py +0 -0
  100. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/db_functions.py +0 -0
  101. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/delete.py +0 -0
  102. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/dynamic_admin.py +0 -0
  103. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/dynamic_crud.py +0 -0
  104. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/graph_walk.py +0 -0
  105. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/latest_rows.py +0 -0
  106. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/queue_claim.py +0 -0
  107. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/read.py +0 -0
  108. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/relational_backfill.py +0 -0
  109. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/staging_load.py +0 -0
  110. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/py.typed +0 -0
  111. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/python_sql/MODULE_README.md +0 -0
  112. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/python_sql/__init__.py +0 -0
  113. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/python_sql/db_objects.py +0 -0
  114. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/python_sql/table_detailed_relationships.py +0 -0
  115. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/python_sql/table_typescript_relationship.py +0 -0
  116. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/query/MODULE_README.md +0 -0
  117. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/query/__init__.py +0 -0
  118. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/query/executor.py +0 -0
  119. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/MODULE_README.md +0 -0
  120. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/__init__.py +0 -0
  121. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/code_handler.py +0 -0
  122. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/codegen_writer.py +0 -0
  123. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/columns.py +0 -0
  124. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/common.py +0 -0
  125. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/diff_preview.py +0 -0
  126. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/entity_capabilities.py +0 -0
  127. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/generator.py +0 -0
  128. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/helpers/__init__.py +0 -0
  129. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/helpers/base_generators.py +0 -0
  130. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/helpers/entity_generators.py +0 -0
  131. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/helpers/git_checker.py +0 -0
  132. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/package_wiring.py +0 -0
  133. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/relationships.py +0 -0
  134. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/runner.py +0 -0
  135. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/schema.py +0 -0
  136. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/schema_manager.py +0 -0
  137. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/tables.py +0 -0
  138. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/views.py +0 -0
  139. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/FEATURE.md +0 -0
  140. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/__init__.py +0 -0
  141. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/crypto.py +0 -0
  142. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/items.py +0 -0
  143. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/model.py +0 -0
  144. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/org_service.py +0 -0
  145. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/service.py +0 -0
  146. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/__init__.py +0 -0
  147. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/coalesce.py +0 -0
  148. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/dag.py +0 -0
  149. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/errors.py +0 -0
  150. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/fallback.py +0 -0
  151. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/lifecycle.py +0 -0
  152. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/managed.py +0 -0
  153. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/op.py +0 -0
  154. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/reads.py +0 -0
  155. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/session.py +0 -0
  156. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/telemetry.py +0 -0
  157. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/MODULE_README.md +0 -0
  158. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/__init__.py +0 -0
  159. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/executor.py +0 -0
  160. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/queries.py +0 -0
  161. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/registry.py +0 -0
  162. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/types.py +0 -0
  163. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/utils.py +0 -0
  164. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/utils/__init__.py +0 -0
  165. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/utils/sql_utils.py +0 -0
  166. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/utils/type_converters.py +0 -0
  167. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/scripts/git-branches.sh +0 -0
  168. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/scripts/publish.sh +0 -0
  169. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/scripts/release.sh +0 -0
  170. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/MODULE_README.md +0 -0
  171. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/__init__.py +0 -0
  172. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/conftest.py +0 -0
  173. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/MODULE_README.md +0 -0
  174. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/__init__.py +0 -0
  175. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_admin_db_columns.py +0 -0
  176. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_admin_search_negation.py +0 -0
  177. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_admin_search_sentinels.py +0 -0
  178. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_advisory_lock.py +0 -0
  179. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_agent_message.py +0 -0
  180. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_api_auth.py +0 -0
  181. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_api_config.py +0 -0
  182. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_api_handlers.py +0 -0
  183. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_api_protocol.py +0 -0
  184. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_array_agg_order_by.py +0 -0
  185. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_associations.py +0 -0
  186. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_bulk_update_by_pk.py +0 -0
  187. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_bulk_upsert_increment_set_fields.py +0 -0
  188. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_cache_debug.py +0 -0
  189. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_call_function.py +0 -0
  190. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_case_when.py +0 -0
  191. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_catalog.py +0 -0
  192. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_composite_pk_filter.py +0 -0
  193. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_composite_pk_write_paths.py +0 -0
  194. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_config.py +0 -0
  195. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_conflict_target.py +0 -0
  196. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_conflict_writes.py +0 -0
  197. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_connection_codecs.py +0 -0
  198. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_connection_poison_guard.py +0 -0
  199. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_connection_primitives.py +0 -0
  200. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_count_composite_distinct.py +0 -0
  201. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_db_function_field.py +0 -0
  202. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_ddl_generator.py +0 -0
  203. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_derived_table.py +0 -0
  204. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_dirty_tracking.py +0 -0
  205. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_dynamic_crud.py +0 -0
  206. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_exceptions.py +0 -0
  207. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_expression_primitives.py +0 -0
  208. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_fields.py +0 -0
  209. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_filter_jsonb_agg_array_index.py +0 -0
  210. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_from_alias.py +0 -0
  211. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_fts.py +0 -0
  212. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_func_expression_args.py +0 -0
  213. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_graph_walk.py +0 -0
  214. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_having_annotate_alias.py +0 -0
  215. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_insert_ignore_and_admin_primitives.py +0 -0
  216. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_introspect_rls_policies.py +0 -0
  217. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_join_and_aggregate.py +0 -0
  218. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_latest_rows.py +0 -0
  219. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_listen_notify.py +0 -0
  220. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_managed_write_guard.py +0 -0
  221. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_matrx_entity.py +0 -0
  222. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_migration_diff_types.py +0 -0
  223. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_migration_loader.py +0 -0
  224. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_model_instance.py +0 -0
  225. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_model_meta.py +0 -0
  226. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_order_by_expression.py +0 -0
  227. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_patch_jsonb_path.py +0 -0
  228. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_pool_loop_recreation.py +0 -0
  229. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_pool_maintenance.py +0 -0
  230. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_postgrest_filters.py +0 -0
  231. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_pydantic_bridge.py +0 -0
  232. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_query_builder.py +0 -0
  233. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_query_error_detail.py +0 -0
  234. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_query_executor_sql.py +0 -0
  235. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_query_timeout.py +0 -0
  236. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_querybuilder_clone_semantics.py +0 -0
  237. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_queue_claim.py +0 -0
  238. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_registry.py +0 -0
  239. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_registry_multi_database.py +0 -0
  240. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_registry_multi_schema.py +0 -0
  241. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_relational_backfill.py +0 -0
  242. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_relations.py +0 -0
  243. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_resilience.py +0 -0
  244. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_schema_exists.py +0 -0
  245. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_session_advisory_lock.py +0 -0
  246. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_state_cache.py +0 -0
  247. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_subquery_filter_raw.py +0 -0
  248. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_subquery_in_filter.py +0 -0
  249. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_supabase_auth.py +0 -0
  250. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_supabase_config.py +0 -0
  251. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_tls.py +0 -0
  252. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_typed_join_projection.py +0 -0
  253. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_typed_predicates.py +0 -0
  254. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_update_case_expression.py +0 -0
  255. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_update_subquery.py +0 -0
  256. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_upsert_default_leak.py +0 -0
  257. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_upsert_with_conflict.py +0 -0
  258. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_values_jsonb_decode.py +0 -0
  259. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_watched_lifecycle.py +0 -0
  260. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_write_retry_semantics.py +0 -0
  261. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/MODULE_README.md +0 -0
  262. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/__init__.py +0 -0
  263. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/conftest.py +0 -0
  264. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_bulk_ops.py +0 -0
  265. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_cache_integration.py +0 -0
  266. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_crud.py +0 -0
  267. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_foreign_keys.py +0 -0
  268. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_m2m.py +0 -0
  269. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_manager.py +0 -0
  270. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_migrations_live.py +0 -0
  271. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_query_execution.py +0 -0
  272. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_schema_diff.py +0 -0
  273. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/.env.example +0 -0
  274. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/README.md +0 -0
  275. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/__init__.py +0 -0
  276. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/generate.py +0 -0
  277. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/generated/.gitkeep +0 -0
  278. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/matrx_orm.yaml +0 -0
  279. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/test_schema_generation.py +0 -0
  280. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/.env.example +0 -0
  281. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/README.md +0 -0
  282. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/__init__.py +0 -0
  283. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/client_example.py +0 -0
  284. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/client_example.ts +0 -0
  285. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/client_supabase_example.py +0 -0
  286. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/server.py +0 -0
  287. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/entity_tests.py +0 -0
  288. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_base_generation.py +0 -0
  289. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_composite_pk_fk_generation.py +0 -0
  290. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_composite_unique_generation.py +0 -0
  291. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_dto_identity_generation.py +0 -0
  292. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_entity_capability_generation.py +0 -0
  293. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_generate_schema.py +0 -0
  294. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_junction_analysis.py +0 -0
  295. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_multi_schema_output.py +0 -0
  296. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_sql_expression_defaults.py +0 -0
  297. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_view_generation.py +0 -0
  298. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema_builder/test_focused_generation_plan.py +0 -0
  299. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/__init__.py +0 -0
  300. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/conftest.py +0 -0
  301. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_capture_poison_proof.py +0 -0
  302. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_coalesce.py +0 -0
  303. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_dag.py +0 -0
  304. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_disk_spill_fallback.py +0 -0
  305. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_flush_individual_fallback.py +0 -0
  306. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_governed_write.py +0 -0
  307. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_managed.py +0 -0
  308. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_op_enqueue_site.py +0 -0
  309. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_reads.py +0 -0
  310. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_session.py +0 -0
  311. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_database_name_alias.py +0 -0
  312. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_db_functions.py +0 -0
  313. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_model_cls_refactor.py +0 -0
  314. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_no_blocking_asyncpg_tls.py +0 -0
  315. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_secrets_battery.py +0 -0
  316. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_sql_param_casts.py +0 -0
  317. {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_sync_twins_guard.py +0 -0
@@ -116,6 +116,31 @@ the target and those explicitly named cross-schema FK dependencies.
116
116
 
117
117
  ---
118
118
 
119
+ ## 🚨 THE IDENTITY CACHE MUST NEVER LIE (read before touching any write path)
120
+
121
+ `StateManager` keeps a process-local per-model identity cache (default
122
+ SHORT_TERM = **10 minutes**). Cached reads are the default (`use_cache=True`), so
123
+ anything wrong in that cache is served as truth for ten minutes. Three rules, all
124
+ enforced in code:
125
+
126
+ 1. **No downgrades.** A cache entry may only be replaced by a record at least as
127
+ informed. Instances built from a column SUBSET must call
128
+ `matrx_orm.state.mark_partial(...)` — `ModelState.cache` evicts them instead
129
+ of storing them. An undeclared downgrade evicts AND screams.
130
+ 2. **Writes refresh or evict — never leave a pre-write copy.** `QueryBuilder.update`
131
+ refreshes from its own `RETURNING *` rows; `QueryBuilder.delete` evicts the
132
+ affected PKs (or clears the model when the filter isn't PK-shaped).
133
+ 3. **Identity reads are authoritative.** Any read that decides ownership, org
134
+ attribution, or whether a request may run at all passes `use_cache=False`
135
+ (`load_by_id(id, use_cache=False)`). Fast is not a reason to guess.
136
+
137
+ This is not theoretical hygiene. On 2026-07-25 rule 1 was missing: `session/flush.py`
138
+ builds a `{pk + payload}` stub per queued UPDATE and `bulk_update` cached it, so a
139
+ chat conversation read back with `organization_id = None` right after its own
140
+ turn-1 title/`message_count` UPDATE flushed — and aidream stopped the user's next
141
+ turn on a value Postgres had held correctly the entire time. Guards:
142
+ `tests/session/test_cache_no_downgrade.py`.
143
+
119
144
  ## Known issues
120
145
 
121
146
  - **No CLAUDE.md existed before this commit** — that was the main gap.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrx-orm
3
- Version: 3.1.35
3
+ Version: 3.1.37
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
@@ -1155,24 +1155,30 @@ class Model(RuntimeMixin, metaclass=ModelMeta):
1155
1155
  return pks[0]
1156
1156
 
1157
1157
  @classmethod
1158
- async def load_by_id(cls, item_id: Any) -> Self:
1158
+ async def load_by_id(cls, item_id: Any, use_cache: bool = True) -> Self:
1159
1159
  """Load a row by its primary key. Raises DoesNotExist if absent.
1160
1160
 
1161
1161
  Alias for ``get(**{pk: item_id})`` — mirrors ``BaseManager.load_by_id``.
1162
1162
  Composite-PK models accept a dict ``{pk: val}`` or a PK-ordered
1163
1163
  tuple/list (a bare scalar raises — see ``_build_pk_filter``).
1164
1164
  For a None-returning lookup use :meth:`load_by_id_or_none`.
1165
+
1166
+ ``use_cache=False`` is the AUTHORITATIVE read: pass it whenever the row
1167
+ decides identity, ownership, or whether the request may run at all. It
1168
+ also refreshes the cache with what it read, so a bypass never leaves a
1169
+ stale entry behind.
1165
1170
  """
1166
- return await cls.get(**cls._build_pk_filter(item_id))
1171
+ return await cls.get(use_cache=use_cache, **cls._build_pk_filter(item_id))
1167
1172
 
1168
1173
  @classmethod
1169
- async def load_by_id_or_none(cls, item_id: Any) -> Self | None:
1174
+ async def load_by_id_or_none(cls, item_id: Any, use_cache: bool = True) -> Self | None:
1170
1175
  """Load a row by its primary key, or None if absent.
1171
1176
 
1172
1177
  Alias for ``get_or_none(**{pk: item_id})``. Composite-PK models accept
1173
- a dict / PK-ordered tuple, same as :meth:`load_by_id`.
1178
+ a dict / PK-ordered tuple, same as :meth:`load_by_id`. See
1179
+ :meth:`load_by_id` for when to pass ``use_cache=False``.
1174
1180
  """
1175
- return await cls.get_or_none(**cls._build_pk_filter(item_id))
1181
+ return await cls.get_or_none(use_cache=use_cache, **cls._build_pk_filter(item_id))
1176
1182
 
1177
1183
  @classmethod
1178
1184
  async def filter_items(cls, *args: Any, **kwargs: Any) -> list[Self]:
@@ -64,8 +64,14 @@ async def bulk_update(model_cls: type[Model], objects: Sequence[Model], fields:
64
64
  result = await QueryBuilder(model_cls).filter(**pk_filter).update(**update_data)
65
65
  if result.rows_affected > 0:
66
66
  rows_affected += 1
67
-
68
- await StateManager.cache(model_cls, obj)
67
+ # Do NOT cache `obj` here. It is the CALLER's instance and is
68
+ # routinely a partial {pk + payload} stub (see
69
+ # session/flush.py), so writing it into the identity cache
70
+ # blanked every column the update didn't touch — the
71
+ # 2026-07-25 "conversation has no durable organization_id"
72
+ # bug. QueryBuilder.update already refreshed the cache from
73
+ # the statement's own RETURNING * rows, which is the
74
+ # authoritative post-write state.
69
75
 
70
76
  if debug and (i + 1) % 10 == 0:
71
77
  vcprint(f"Processed {i + 1}/{len(valid_objects)} updates...", color="cyan")
@@ -765,6 +765,30 @@ class QueryBuilder(Generic[ModelT]):
765
765
  combined[f"exclude__{k}"] = v
766
766
  return combined
767
767
 
768
+ def _invalidate_cache_for_filters(self) -> None:
769
+ """Evict the rows this statement affected from the identity cache.
770
+
771
+ Precise when the filter is a plain primary-key match (``pk=`` or
772
+ ``pk__in=``) — the common case by far. Anything else (multi-column
773
+ predicate, operator suffix, excludes) means the affected ids are unknown,
774
+ so the model's cache is cleared. Correctness over warmth.
775
+ """
776
+ pks = self.model._meta.primary_keys or ["id"]
777
+ combined = self._merge_filters_excludes()
778
+ if len(pks) == 1 and not self.excludes and set(combined) <= {pks[0], f"{pks[0]}__in"}:
779
+ values = combined.get(pks[0])
780
+ in_values = combined.get(f"{pks[0]}__in")
781
+ targets = list(in_values) if isinstance(in_values, (list, tuple, set)) else []
782
+ if values is not None:
783
+ targets.append(values)
784
+ if targets and StateManager.is_registered(self.model):
785
+ database = self.model.get_database_name()
786
+ state = StateManager._states[(database, self.model.__name__)]
787
+ for value in targets:
788
+ state.evict_by_id(value)
789
+ return
790
+ StateManager.clear_model(self.model)
791
+
768
792
  def _merge_having(self) -> dict[str, Any]:
769
793
  combined: dict[str, Any] = {}
770
794
  for h in self.having_filters:
@@ -862,11 +886,19 @@ class QueryBuilder(Generic[ModelT]):
862
886
  return None
863
887
 
864
888
  async def update(self, **kwargs: Any) -> UpdateResult:
865
- """Update matching records."""
889
+ """Update matching records.
890
+
891
+ The identity cache is refreshed from the statement's own
892
+ ``RETURNING *`` rows — an UPDATE here used to leave the PRE-write copy
893
+ cached and readable for the rest of the TTL (SHORT_TERM = 10 min), so a
894
+ row could be read back with values Postgres no longer held.
895
+ """
866
896
  try:
867
897
  if not kwargs:
868
898
  raise ValidationError(model=self.model, reason="No update data provided")
869
- return await self._get_executor().update(**kwargs)
899
+ result = await self._get_executor().update(**kwargs)
900
+ await StateManager.refresh_from_rows(self.model, result.updated_rows)
901
+ return result
870
902
  except ValidationError:
871
903
  raise
872
904
  except ORMException as e:
@@ -874,9 +906,19 @@ class QueryBuilder(Generic[ModelT]):
874
906
  raise
875
907
 
876
908
  async def delete(self) -> int:
877
- """Delete matching records."""
909
+ """Delete matching records.
910
+
911
+ Evicts the deleted rows from the identity cache — a filtered DELETE used
912
+ to leave them cached and readable for the rest of the TTL. When the
913
+ filter isn't a plain primary-key match the affected ids are unknown, so
914
+ the model's cache is cleared outright: losing warmth for one model is
915
+ free, serving a deleted row for 10 minutes is not.
916
+ """
878
917
  try:
879
- return await self._get_executor().delete()
918
+ deleted = await self._get_executor().delete()
919
+ if deleted:
920
+ self._invalidate_cache_for_filters()
921
+ return deleted
880
922
  except ORMException as e:
881
923
  e.enrich(model=self.model, operation="delete", filters=self._merge_filters_excludes())
882
924
  raise
@@ -134,6 +134,24 @@ Complex predicates (operator suffixes, JSON paths, joins) only match
134
134
  DB rows — the pending state is assumed safe (the DB still applies the
135
135
  predicate on its side).
136
136
 
137
+ ## 🚨 Flush stubs are PARTIAL — they never enter the identity cache
138
+
139
+ `flush._execute_group` turns each queued UPDATE into a lightweight
140
+ `model_cls(**{pk, **payload})` instance so `bulk_update` can build per-row SQL.
141
+ That instance is **not a row snapshot**: every column outside the payload holds a
142
+ model default (usually `None`). Each one is therefore stamped by
143
+ `matrx_orm.state.mark_partial(...)`, and `ModelState.cache` **evicts** instead of
144
+ storing any partial — the next read refetches the whole row.
145
+
146
+ This is load-bearing, not hygiene. `bulk_update` write-through used to install
147
+ those stubs as the row's cache entry, so for the whole cache TTL (SHORT_TERM = 10
148
+ min) a freshly-updated row read back with NULLs in every untouched column. On
149
+ 2026-07-25 that made a saved conversation read back with no `organization_id`
150
+ seconds after its own turn-1 title/`message_count` UPDATE flushed, and aidream's
151
+ identity guard hard-stopped the next turn. Any new code path that builds a model
152
+ instance from a column subset MUST call `mark_partial` — an undeclared downgrade
153
+ is evicted anyway and SCREAMS (see `state._scream_downgrade`).
154
+
137
155
  ## Nested Sessions
138
156
 
139
157
  Sub-agents, parallel-execute branches, workflow super-steps — every
@@ -29,6 +29,7 @@ from collections.abc import Sequence
29
29
  from contextvars import ContextVar
30
30
 
31
31
  from matrx_orm.session.op import SessionOp
32
+ from matrx_orm.state import mark_partial
32
33
 
33
34
  logger = logging.getLogger("matrx_orm.session.flush")
34
35
 
@@ -122,7 +123,13 @@ async def _execute_group(
122
123
  for op in ops:
123
124
  kwargs = {pk_field: op.pk_value}
124
125
  kwargs.update(op.payload)
125
- instances.append(model_cls(**kwargs))
126
+ # These are stubs, NOT row snapshots: every column outside `kwargs`
127
+ # is a model default (None), not the row's value. Declare that, or
128
+ # bulk_update's write-through would install one as the row's cache
129
+ # entry and blank every other column for the TTL (this is the bug
130
+ # that made a saved conversation read back with no organization_id
131
+ # on its next turn). See matrx_orm.state.mark_partial.
132
+ instances.append(mark_partial(model_cls(**kwargs), kwargs))
126
133
 
127
134
  await model_cls.bulk_update(instances, fields=all_fields)
128
135
  return len(instances)
@@ -132,7 +139,9 @@ async def _execute_group(
132
139
  pks = getattr(meta, "primary_keys", None) if meta else None
133
140
  pk_field = pks[0] if pks else ops[0].pk_field
134
141
 
135
- instances = [model_cls(**{pk_field: op.pk_value}) for op in ops]
142
+ instances = [
143
+ mark_partial(model_cls(**{pk_field: op.pk_value}), (pk_field,)) for op in ops
144
+ ]
136
145
  await model_cls.bulk_delete(instances)
137
146
  return len(instances)
138
147
 
@@ -15,6 +15,48 @@ from matrx_orm.exceptions import (
15
15
  )
16
16
 
17
17
 
18
+ # Attribute a write-through instance sets to declare "I am a {pk + payload}
19
+ # stub, not a row snapshot". Set by matrx_orm.session.flush; honored by
20
+ # ModelState.cache (evict, never store) and by mark_partial() below.
21
+ PARTIAL_FIELDS_ATTR = "_matrx_partial_fields"
22
+
23
+ # One scream per (model, blanked-field-set) — a downgrade is a code defect, so
24
+ # it must be visible, but it must not drown a hot loop in identical banners.
25
+ _SCREAMED_DOWNGRADES: set[tuple[str, tuple[str, ...]]] = set()
26
+
27
+
28
+ def mark_partial(instance, fields):
29
+ """Declare ``instance`` a partial write-through stub of its row.
30
+
31
+ Every caller that builds a model instance from a PK plus a subset of
32
+ columns (session flush, targeted bulk_update) MUST call this. Undeclared
33
+ partials trip the no-downgrade scream in :meth:`ModelState.cache`.
34
+ """
35
+ setattr(instance, PARTIAL_FIELDS_ATTR, frozenset(fields))
36
+ return instance
37
+
38
+
39
+ def _scream_downgrade(model_class, blanked: list[str]) -> None:
40
+ signature = (model_class.__name__, tuple(sorted(blanked)))
41
+ if signature in _SCREAMED_DOWNGRADES:
42
+ return
43
+ _SCREAMED_DOWNGRADES.add(signature)
44
+ vcprint(
45
+ "\n"
46
+ "══════════════════════════════════════════════════════════════════════\n"
47
+ " MATRX ORM — CACHE DOWNGRADE BLOCKED (no-downgrade law)\n"
48
+ "══════════════════════════════════════════════════════════════════════\n"
49
+ f" model: {model_class.__name__}\n"
50
+ f" would blank: {', '.join(sorted(blanked))}\n"
51
+ " action: cache entry EVICTED (next read refetches the full row).\n"
52
+ " Nothing was lost, but a PARTIAL model instance reached a\n"
53
+ " write-through cache path without calling\n"
54
+ " matrx_orm.state.mark_partial() — fix the writer.\n"
55
+ "══════════════════════════════════════════════════════════════════════",
56
+ color="red",
57
+ )
58
+
59
+
18
60
  class CachePolicy(Enum):
19
61
  PERMANENT = "permanent"
20
62
  LONG_TERM = "long_term"
@@ -192,7 +234,26 @@ class ModelState:
192
234
  return True
193
235
 
194
236
  async def cache(self, record):
195
- """Cache a record with proper error handling."""
237
+ """Cache a record with proper error handling.
238
+
239
+ THE NO-DOWNGRADE LAW: a cache entry may only ever be REPLACED by a
240
+ record that is at least as informed as the one already there. A
241
+ write-through instance built from ``{pk, **payload}`` is NOT a row
242
+ snapshot — every column outside the payload holds a model default
243
+ (usually ``None``), not the row's real value. Storing one blanks the
244
+ row for every cached reader until the TTL expires.
245
+
246
+ That is exactly how a conversation with a perfectly good
247
+ ``organization_id`` in Postgres read back as org-less for 10 minutes
248
+ after its own turn-1 title/message_count UPDATE flushed
249
+ (``session/flush.py`` builds partial instances; ``bulk_update`` cached
250
+ them), which then hard-failed the next turn's identity restore.
251
+
252
+ So: partial records EVICT instead of caching (the next read refetches
253
+ the whole row), and an unflagged record that would blank a populated
254
+ field evicts + SCREAMS — a downgrade means a partial instance reached
255
+ a write-through path without declaring itself.
256
+ """
196
257
  try:
197
258
  if not record:
198
259
  raise ValidationError(model=self.model_class, reason="Cannot cache None record")
@@ -204,6 +265,20 @@ class ModelState:
204
265
  reason="Could not generate cache key for record",
205
266
  )
206
267
 
268
+ if getattr(record, PARTIAL_FIELDS_ATTR, None) is not None:
269
+ # Declared partial (session flush write-through). Designed
270
+ # path — evict silently so the next read hits the DB.
271
+ self.evict_by_id(cache_key)
272
+ return
273
+
274
+ existing = self._cache.get(cache_key)
275
+ if existing is not None and existing is not record:
276
+ blanked = self._downgraded_fields(existing, record)
277
+ if blanked:
278
+ self.evict_by_id(cache_key)
279
+ _scream_downgrade(self.model_class, blanked)
280
+ return
281
+
207
282
  self._cache[cache_key] = record
208
283
  self._cache_times[cache_key] = datetime.now()
209
284
 
@@ -222,6 +297,14 @@ class ModelState:
222
297
  original_error=e,
223
298
  ) from e
224
299
 
300
+ def _downgraded_fields(self, existing, incoming) -> list[str]:
301
+ """Column names the incoming record would blank on the cached one."""
302
+ blanked: list[str] = []
303
+ for name in getattr(self.model_class, "_fields", {}):
304
+ if getattr(existing, name, None) is not None and getattr(incoming, name, None) is None:
305
+ blanked.append(name)
306
+ return blanked
307
+
225
308
  async def remove(self, record):
226
309
  cache_key = self._get_record_cache_key(record)
227
310
  self._cache.pop(cache_key, None)
@@ -472,6 +555,59 @@ class StateManager:
472
555
  original_error=e,
473
556
  ) from e
474
557
 
558
+ @classmethod
559
+ async def refresh_from_rows(cls, model_class, rows, *, max_rows: int = 200) -> None:
560
+ """Write authoritative post-write rows over whatever is cached.
561
+
562
+ For write paths that already have the true row in hand (``UPDATE …
563
+ RETURNING *``). Unlike :meth:`cache_bulk` this OVERWRITES an existing
564
+ entry — that is the point: a targeted UPDATE otherwise left the pre-write
565
+ copy cached and readable for the rest of the TTL.
566
+
567
+ Above ``max_rows`` the rows are evicted instead of cached, so a mass
568
+ UPDATE can't flood the cache with rows nobody asked for. Never raises —
569
+ a cache refresh must not fail the write that already committed.
570
+ """
571
+ if not rows or not cls.is_registered(model_class):
572
+ return
573
+ database = model_class.get_database_name()
574
+ state = cls._states[(database, model_class.__name__)]
575
+ pks = model_class._meta.primary_keys or ["id"]
576
+ evict_only = len(rows) > max_rows
577
+ for row in rows:
578
+ try:
579
+ if evict_only:
580
+ values = [row.get(pk) if isinstance(row, dict) else getattr(row, pk, None) for pk in pks]
581
+ if all(v is not None for v in values):
582
+ state.evict_by_id(*values)
583
+ continue
584
+ record = row if not isinstance(row, dict) else model_class.from_db_result(row)
585
+ await state.cache(record)
586
+ except Exception as exc: # noqa: BLE001 — never fail a committed write
587
+ vcprint(
588
+ f"[matrx_orm.state] refresh_from_rows skipped one "
589
+ f"{model_class.__name__} row ({type(exc).__name__}: {exc})",
590
+ color="yellow",
591
+ )
592
+
593
+ @classmethod
594
+ def clear_model(cls, model_class) -> int:
595
+ """Drop every cached record for a model. Returns the count dropped.
596
+
597
+ The correctness fallback for a write whose affected PKs are unknown
598
+ (a filtered DELETE). Losing cache warmth for one model is free; serving
599
+ a deleted row for 10 minutes is not.
600
+ """
601
+ if not cls.is_registered(model_class):
602
+ return 0
603
+ database = model_class.get_database_name()
604
+ state = cls._states[(database, model_class.__name__)]
605
+ dropped = len(state._cache)
606
+ state._cache.clear()
607
+ state._cache_times.clear()
608
+ state._subscriptions.clear()
609
+ return dropped
610
+
475
611
  @classmethod
476
612
  async def remove(cls, model_class, record):
477
613
  """Removes a specific record from the cache."""
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "matrx-orm"
3
- version = "3.1.35"
3
+ version = "3.1.37"
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" }
@@ -0,0 +1,136 @@
1
+ """THE NO-DOWNGRADE LAW — a cache entry may never lose information.
2
+
3
+ Regression guard for the 2026-07-25 incident: a conversation with a correct
4
+ ``organization_id`` in Postgres read back org-less for the whole 10-minute
5
+ cache TTL, because ``session/flush.py`` builds ``{pk + payload}`` stubs for
6
+ queued UPDATEs and ``operations/update.py::bulk_update`` wrote them straight
7
+ into the identity cache. The next turn's identity restore then STOPPED the
8
+ request on data that did not exist.
9
+
10
+ Two independent guarantees are locked in here:
11
+ 1. flush declares its stubs partial (``mark_partial``).
12
+ 2. ``ModelState.cache`` never stores a partial or downgrading record — it
13
+ evicts, so the next read refetches the whole row.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import pytest
19
+
20
+ from matrx_orm.state import PARTIAL_FIELDS_ATTR, CachePolicy, ModelState, mark_partial
21
+
22
+
23
+ class _Conversation:
24
+ """Minimal Model stand-in with the fields the incident turned on."""
25
+
26
+ _fields = {
27
+ "id": object(),
28
+ "organization_id": object(),
29
+ "title": object(),
30
+ "message_count": object(),
31
+ }
32
+
33
+ class _Meta:
34
+ primary_keys = ["id"]
35
+
36
+ _meta = _Meta()
37
+
38
+ def __init__(self, **kwargs):
39
+ for name in self._fields:
40
+ setattr(self, name, kwargs.get(name))
41
+
42
+ @classmethod
43
+ def get_database_name(cls):
44
+ return "test_db"
45
+
46
+
47
+ def _state() -> ModelState:
48
+ state = ModelState(_Conversation)
49
+ state.policy = CachePolicy.SHORT_TERM
50
+ return state
51
+
52
+
53
+ def _full_row() -> _Conversation:
54
+ return _Conversation(
55
+ id="c1", organization_id="org-1", title="Anthropic Model Sync", message_count=3
56
+ )
57
+
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_flush_update_stub_is_declared_partial():
61
+ stub = mark_partial(_Conversation(id="c1", message_count=5), {"id", "message_count"})
62
+ assert getattr(stub, PARTIAL_FIELDS_ATTR) == frozenset({"id", "message_count"})
63
+
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_partial_stub_evicts_instead_of_blanking_the_row():
67
+ state = _state()
68
+ await state.cache(_full_row())
69
+ assert (await state.get(id="c1")).organization_id == "org-1"
70
+
71
+ # Exactly what a queued conversation UPDATE flush produces on turn 1.
72
+ await state.cache(mark_partial(_Conversation(id="c1", message_count=5), {"id", "message_count"}))
73
+
74
+ # The org-less stub must NOT be servable. Cache miss → caller refetches.
75
+ assert await state.get(id="c1") is None
76
+
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_undeclared_downgrade_is_refused_too():
80
+ state = _state()
81
+ await state.cache(_full_row())
82
+ await state.cache(_Conversation(id="c1", message_count=7)) # no mark_partial
83
+ assert await state.get(id="c1") is None
84
+
85
+
86
+ @pytest.mark.asyncio
87
+ async def test_equally_informed_record_still_replaces():
88
+ state = _state()
89
+ await state.cache(_full_row())
90
+ fresh = _Conversation(
91
+ id="c1", organization_id="org-1", title="Renamed", message_count=9
92
+ )
93
+ await state.cache(fresh)
94
+ cached = await state.get(id="c1")
95
+ assert cached is fresh
96
+ assert cached.title == "Renamed"
97
+ assert cached.organization_id == "org-1"
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_refresh_from_rows_overwrites_a_stale_entry():
102
+ """A targeted UPDATE has the true post-write row in hand (RETURNING *), so the
103
+ cache is refreshed with it — cache_bulk's "only if absent" rule would have
104
+ left the PRE-write copy readable for the rest of the TTL."""
105
+ state = _state()
106
+ await state.cache(_full_row())
107
+
108
+ from matrx_orm.state import StateManager
109
+
110
+ StateManager._states[("test_db", "_Conversation")] = state
111
+ _Conversation.from_db_result = classmethod(lambda cls, row: cls(**row))
112
+ try:
113
+ await StateManager.refresh_from_rows(
114
+ _Conversation,
115
+ [{"id": "c1", "organization_id": "org-1", "title": "Renamed", "message_count": 4}],
116
+ )
117
+ cached = await state.get(id="c1")
118
+ assert cached.title == "Renamed"
119
+ assert cached.organization_id == "org-1"
120
+ finally:
121
+ StateManager._states.pop(("test_db", "_Conversation"), None)
122
+
123
+
124
+ @pytest.mark.asyncio
125
+ async def test_clear_model_drops_every_entry():
126
+ state = _state()
127
+ await state.cache(_full_row())
128
+
129
+ from matrx_orm.state import StateManager
130
+
131
+ StateManager._states[("test_db", "_Conversation")] = state
132
+ try:
133
+ assert StateManager.clear_model(_Conversation) == 1
134
+ assert await state.get(id="c1") is None
135
+ finally:
136
+ StateManager._states.pop(("test_db", "_Conversation"), None)
File without changes
File without changes
File without changes
File without changes