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.
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/CLAUDE.md +25 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/PKG-INFO +1 -1
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/base.py +11 -5
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/update.py +8 -2
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/query/builder.py +46 -4
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/README.md +18 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/flush.py +11 -2
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/state.py +137 -1
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/pyproject.toml +1 -1
- matrx_orm-3.1.37/tests/session/test_cache_no_downgrade.py +136 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/.gitignore +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/AGENTS.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/BASE_CLASS_METHODS.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/MODEL_API.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/database/orm/extended/managers/ai_model_base.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/docs/migrations.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/async_postgresql.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/base_adapter.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/postgresql.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/postgrest_client_adapter.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/adapters/supabase_adapter.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/admin/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/admin/router.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/auth.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/config.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/handlers.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/protocol.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/api/server.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/associations.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/cache_debug.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/catalog.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/postgres_connection.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/postgrest.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/supabase_auth.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/supabase_config.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/client/supabase_manager.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/EXTENDED-TASK.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/RELATIONS-TASKS.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/TASKS.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/async_db_manager.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/config.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/connections.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/db_function.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/diagnostics.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/expressions.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/extended.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/fields.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/introspection.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/listen.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/loop_filters.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/model_dto.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/model_view.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/paginator.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/pool_maintenance.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/pydantic_bridge.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/registry.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/relations.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/resilience.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/rls_session.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/signals.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/tls.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/transaction.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/types.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/core/write_queue.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/entity.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/error_handling.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/exceptions.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/extended/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/extended/app_error_handler.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/identity_rule.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/middleware/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/middleware/base.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/cli.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/ddl.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/diff.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/executor.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/integration.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/loader.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/operations.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/state.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/migrations/table_filter.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/bulk_update_values.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/conflict.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/conflict_writes.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/create.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/db_functions.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/delete.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/dynamic_admin.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/dynamic_crud.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/graph_walk.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/latest_rows.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/queue_claim.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/read.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/relational_backfill.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/operations/staging_load.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/py.typed +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/python_sql/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/python_sql/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/python_sql/db_objects.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/python_sql/table_detailed_relationships.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/python_sql/table_typescript_relationship.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/query/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/query/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/query/executor.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/code_handler.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/codegen_writer.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/columns.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/common.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/diff_preview.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/entity_capabilities.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/generator.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/helpers/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/helpers/base_generators.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/helpers/entity_generators.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/helpers/git_checker.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/package_wiring.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/relationships.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/runner.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/schema.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/schema_manager.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/tables.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/schema_builder/views.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/FEATURE.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/crypto.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/items.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/model.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/org_service.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/secrets_battery/service.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/coalesce.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/dag.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/errors.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/fallback.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/lifecycle.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/managed.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/op.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/reads.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/session.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/session/telemetry.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/executor.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/queries.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/registry.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/types.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/sql_executor/utils.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/utils/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/utils/sql_utils.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/matrx_orm/utils/type_converters.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/scripts/git-branches.sh +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/scripts/publish.sh +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/scripts/release.sh +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/conftest.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_admin_db_columns.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_admin_search_negation.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_admin_search_sentinels.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_advisory_lock.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_agent_message.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_api_auth.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_api_config.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_api_handlers.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_api_protocol.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_array_agg_order_by.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_associations.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_bulk_update_by_pk.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_bulk_upsert_increment_set_fields.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_cache_debug.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_call_function.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_case_when.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_catalog.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_composite_pk_filter.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_composite_pk_write_paths.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_config.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_conflict_target.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_conflict_writes.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_connection_codecs.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_connection_poison_guard.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_connection_primitives.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_count_composite_distinct.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_db_function_field.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_ddl_generator.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_derived_table.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_dirty_tracking.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_dynamic_crud.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_exceptions.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_expression_primitives.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_fields.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_filter_jsonb_agg_array_index.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_from_alias.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_fts.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_func_expression_args.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_graph_walk.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_having_annotate_alias.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_insert_ignore_and_admin_primitives.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_introspect_rls_policies.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_join_and_aggregate.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_latest_rows.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_listen_notify.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_managed_write_guard.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_matrx_entity.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_migration_diff_types.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_migration_loader.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_model_instance.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_model_meta.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_order_by_expression.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_patch_jsonb_path.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_pool_loop_recreation.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_pool_maintenance.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_postgrest_filters.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_pydantic_bridge.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_query_builder.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_query_error_detail.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_query_executor_sql.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_query_timeout.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_querybuilder_clone_semantics.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_queue_claim.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_registry.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_registry_multi_database.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_registry_multi_schema.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_relational_backfill.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_relations.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_resilience.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_schema_exists.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_session_advisory_lock.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_state_cache.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_subquery_filter_raw.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_subquery_in_filter.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_supabase_auth.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_supabase_config.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_tls.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_typed_join_projection.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_typed_predicates.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_update_case_expression.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_update_subquery.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_upsert_default_leak.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_upsert_with_conflict.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_values_jsonb_decode.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_watched_lifecycle.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level1/test_write_retry_semantics.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/MODULE_README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/conftest.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_bulk_ops.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_cache_integration.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_crud.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_foreign_keys.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_m2m.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_manager.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_migrations_live.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_query_execution.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/level2/test_schema_diff.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/.env.example +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/generate.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/generated/.gitkeep +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/matrx_orm.yaml +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project/test_schema_generation.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/.env.example +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/README.md +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/client_example.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/client_example.ts +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/client_supabase_example.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/sample_project_desktop/server.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/entity_tests.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_base_generation.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_composite_pk_fk_generation.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_composite_unique_generation.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_dto_identity_generation.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_entity_capability_generation.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_generate_schema.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_junction_analysis.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_multi_schema_output.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_sql_expression_defaults.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema/test_view_generation.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/schema_builder/test_focused_generation_plan.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/__init__.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/conftest.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_capture_poison_proof.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_coalesce.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_dag.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_disk_spill_fallback.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_flush_individual_fallback.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_governed_write.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_managed.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_op_enqueue_site.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_reads.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/session/test_session.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_database_name_alias.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_db_functions.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_model_cls_refactor.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_no_blocking_asyncpg_tls.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_secrets_battery.py +0 -0
- {matrx_orm-3.1.35 → matrx_orm-3.1.37}/tests/test_sql_param_casts.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|