matrx-orm 3.1.73__tar.gz → 3.1.75__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.73 → matrx_orm-3.1.75}/.gitignore +2 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/CLAUDE.md +2 -2
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/PKG-INFO +2 -2
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/__init__.py +11 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/catalog.py +46 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/FEATURE.md +4 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/async_db_manager.py +44 -0
- matrx_orm-3.1.75/matrx_orm/core/pooler_role.py +290 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/exceptions.py +62 -2
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/update.py +128 -23
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/columns.py +106 -3
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/package_wiring.py +5 -2
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/README.md +6 -1
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/lifecycle.py +61 -22
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/pyproject.toml +2 -2
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_catalog.py +17 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_optimistic_concurrency.py +197 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_watched_lifecycle.py +43 -0
- matrx_orm-3.1.75/tests/live_pooler_role.py +104 -0
- matrx_orm-3.1.75/tests/schema_builder/test_array_default_parsing.py +56 -0
- matrx_orm-3.1.75/tests/test_pooler_role_guard.py +142 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/AGENTS.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/BASE_CLASS_METHODS.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/MODEL_API.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/database/orm/extended/managers/ai_model_base.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/docs/migrations.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/adapters/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/adapters/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/adapters/async_postgresql.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/adapters/base_adapter.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/adapters/postgresql.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/adapters/postgrest_client_adapter.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/adapters/supabase_adapter.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/admin/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/admin/router.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/api/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/api/auth.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/api/config.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/api/handlers.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/api/protocol.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/api/server.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/associations.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/cache_debug.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/catalog_sql.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/client/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/client/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/client/postgres_connection.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/client/postgrest.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/client/supabase_auth.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/client/supabase_config.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/client/supabase_manager.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/EXTENDED-TASK.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/RELATIONS-TASKS.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/TASKS.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/base.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/config.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/connections.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/db_function.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/diagnostics.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/expressions.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/extended.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/fields.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/introspection.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/listen.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/loop_filters.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/model_dto.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/model_view.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/one_database.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/paginator.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/pool_maintenance.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/pool_watch.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/pydantic_bridge.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/registry.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/relations.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/resilience.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/rls_session.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/signals.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/tls.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/transaction.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/types.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/core/write_queue.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/entity.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/error_handling.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/extended/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/extended/app_error_handler.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/identity_rule.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/middleware/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/middleware/base.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/migrations/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/migrations/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/migrations/cli.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/migrations/ddl.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/migrations/diff.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/migrations/executor.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/migrations/integration.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/migrations/loader.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/migrations/operations.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/migrations/state.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/migrations/table_filter.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/bulk_update_values.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/conflict.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/conflict_writes.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/create.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/db_functions.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/delete.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/dynamic_admin.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/dynamic_crud.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/graph_walk.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/latest_rows.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/queue_claim.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/read.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/relational_backfill.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/operations/staging_load.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/platform_db.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/py.typed +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/python_sql/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/python_sql/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/python_sql/db_objects.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/python_sql/table_detailed_relationships.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/python_sql/table_typescript_relationship.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/query/FEATURE.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/query/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/query/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/query/builder.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/query/executor.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/FEATURE.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/code_handler.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/codegen_writer.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/common.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/diff_preview.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/entity_capabilities.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/generator.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/helpers/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/helpers/base_generators.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/helpers/entity_generators.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/helpers/git_checker.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/relationships.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/runner.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/schema.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/schema_manager.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/tables.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/schema_builder/views.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/secrets_battery/FEATURE.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/secrets_battery/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/secrets_battery/attachments.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/secrets_battery/crypto.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/secrets_battery/items.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/secrets_battery/model.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/secrets_battery/org_service.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/secrets_battery/service.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/coalesce.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/dag.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/errors.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/fallback.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/flush.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/managed.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/op.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/reads.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/session.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/session/telemetry.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/sql_executor/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/sql_executor/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/sql_executor/executor.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/sql_executor/queries.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/sql_executor/registry.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/sql_executor/types.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/sql_executor/utils.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/state.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/utils/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/utils/sql_utils.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/matrx_orm/utils/type_converters.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/scripts/git-branches.sh +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/scripts/publish.sh +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/scripts/release.sh +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/conftest.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_admin_db_columns.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_admin_search_negation.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_admin_search_sentinels.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_advisory_lock.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_agent_message.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_api_auth.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_api_config.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_api_handlers.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_api_protocol.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_array_agg_order_by.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_associations.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_bulk_hydration_offload.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_bulk_update_by_pk.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_bulk_upsert_increment_set_fields.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_cache_debug.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_call_function.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_case_when.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_composite_pk_filter.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_composite_pk_write_paths.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_config.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_conflict_target.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_conflict_writes.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_connection_codecs.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_connection_poison_guard.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_connection_primitives.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_count_composite_distinct.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_db_function_field.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_ddl_generator.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_derived_table.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_dirty_tracking.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_dynamic_crud.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_exceptions.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_execute_query_integrity_mapping.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_expression_primitives.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_fields.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_filter_jsonb_agg_array_index.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_from_alias.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_fts.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_func_expression_args.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_graph_walk.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_having_annotate_alias.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_insert_ignore_and_admin_primitives.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_introspect_rls_policies.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_join_and_aggregate.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_latest_rows.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_listen_notify.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_managed_write_guard.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_matrx_entity.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_migration_diff_types.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_migration_loader.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_model_instance.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_model_meta.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_order_by_expression.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_parameter_name_hydration.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_patch_jsonb_path.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_pool_holder_census.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_pool_loop_recreation.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_pool_maintenance.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_postgrest_filters.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_pydantic_bridge.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_query_builder.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_query_error_detail.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_query_executor_sql.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_query_timeout.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_querybuilder_clone_semantics.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_queue_claim.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_registry.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_registry_multi_database.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_registry_multi_schema.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_relational_backfill.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_relations.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_resilience.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_rls_session_scope.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_schema_exists.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_session_advisory_lock.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_state_cache.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_subquery_filter_raw.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_subquery_in_filter.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_supabase_auth.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_supabase_config.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_tls.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_typed_join_projection.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_typed_predicates.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_update_case_expression.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_update_subquery.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_update_with_rebase.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_upsert_default_leak.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_upsert_with_conflict.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_values_jsonb_decode.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level1/test_write_retry_semantics.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/MODULE_README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/conftest.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/test_bulk_ops.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/test_cache_integration.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/test_crud.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/test_foreign_keys.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/test_m2m.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/test_manager.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/test_migrations_live.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/test_query_execution.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/level2/test_schema_diff.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project/.env.example +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project/README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project/generate.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project/generated/.gitkeep +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project/matrx_orm.yaml +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project/test_schema_generation.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project_desktop/.env.example +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project_desktop/README.md +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project_desktop/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project_desktop/client_example.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project_desktop/client_example.ts +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project_desktop/client_supabase_example.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/sample_project_desktop/server.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/entity_tests.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/test_base_generation.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/test_composite_pk_fk_generation.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/test_composite_unique_generation.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/test_dto_identity_generation.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/test_entity_capability_generation.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/test_generate_schema.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/test_junction_analysis.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/test_multi_schema_output.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/test_sql_expression_defaults.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/test_table_relationships_query.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema/test_view_generation.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema_builder/test_focused_generation_plan.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/schema_builder/test_host_models_aggregator.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/__init__.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/conftest.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/test_cache_no_downgrade.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/test_capture_poison_proof.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/test_coalesce.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/test_dag.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/test_disk_spill_fallback.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/test_flush_individual_fallback.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/test_governed_write.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/test_managed.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/test_op_enqueue_site.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/test_reads.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/session/test_session.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/test_database_name_alias.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/test_db_functions.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/test_model_cls_refactor.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/test_no_blocking_asyncpg_tls.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/test_one_database.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/test_secrets_battery.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/test_sql_param_casts.py +0 -0
- {matrx_orm-3.1.73 → matrx_orm-3.1.75}/tests/test_sync_twins_guard.py +0 -0
|
@@ -248,6 +248,7 @@ requirements_filtered.txt
|
|
|
248
248
|
|
|
249
249
|
# Claude Code local worktrees and per-user settings
|
|
250
250
|
.claude/worktrees/
|
|
251
|
+
.worktrees/
|
|
251
252
|
.claude/settings.local.json
|
|
252
253
|
|
|
253
254
|
# Append-only snapshots from matrx_utils.update_history (unbounded; do not commit)
|
|
@@ -283,3 +284,4 @@ db/evidence/
|
|
|
283
284
|
|
|
284
285
|
# Pooler contamination evidence — machine-local forensic capture, not source
|
|
285
286
|
db/evidence/
|
|
287
|
+
db/mirror/.env.mirror
|
|
@@ -23,7 +23,7 @@ Every table that becomes a Model needs row identity: a single-column primary key
|
|
|
23
23
|
- 🚨 **ONE CONNECTION, REQUIRED** (`matrx_orm.platform_db`): `register_platform_db(name, ...)` + `platform_connection_url()` — the ONLY sanctioned way any Matrx service or package turns env vars into a pool. One set of variables (`SUPABASE_MATRIX_*`), owned here; TLS is a deployment input (`SUPABASE_MATRIX_SSL`), never hardcoded; **RAISES** if it isn't given a database — no degraded mode. Every package's `bootstrap_db()` is one call to it. A package may absolutely run on its OWN Postgres — that is a change of VALUES, never a new variable name; what it may never have is a *second candidate* or a fallback chain. `register_database_from_env` is the low-level primitive underneath — **application and package code must not call it directly** (guard: `scripts/check_one_database.py`, blocking in release).
|
|
24
24
|
- **Core model layer**: `Model`, `BaseManager`, `BaseDTO`, `ModelView`, `model_registry`, plus 50+ field types (`CharField`, `IntegerField`, `ForeignKey`, `ManyToManyField`, `JSONField`, …).
|
|
25
25
|
- **`MatrxEntity`** (`matrx_orm/entity.py`) — **the platform entity contract as an ORM primitive.** A table registered in `platform.entity_types` generates as a `MatrxEntity` (everything else stays a plain `Model`) and gets the behavior instead of every consumer hand-wiring it: `await page.versions()` (not a query on `history.row_versions`), `await page.restore(3)` (not a `version_restore` RPC + a cache bust — `restore()` busts the cache itself), `Note.alive()` / `.dead()` / `.soft_delete()` / `.undelete()`, and `Page.capabilities()`. Capability flags (`_entity_token`, `_is_versioned`, `_has_soft_delete`, `_is_org_scoped`, `_rls_variant`) are **stamped by the generator from the LIVE DATABASE** (does the table really carry the `_version_capture` trigger? a `deleted_at` column?) — never from `entity_types`' own flags, which are wrong on 61 of its 242 rows. **The database is truth; a flag is a claim.** Using a capability an entity does not declare RAISES (`EntityCapabilityError`) and names the fix — it never returns silently-wrong data, and `is_deleted` refuses to answer from a column a partial fetch never loaded. Drift guard: `scripts/check_entity_drift.py` (loud, non-blocking, in `release.sh`).
|
|
26
|
-
- **Optimistic concurrency (`expected_version=`)** — opt-in compare-and-swap on the canonical `version` int column: `update_where` / `update_item` / instance `update()` take `expected_version=N` (`WHERE version = N`, `SET version = N + 1`; 0 rows → `DoesNotExist` if gone, `OptimisticLockError`
|
|
26
|
+
- **Optimistic concurrency (`expected_version=`)** — opt-in compare-and-swap on the canonical `version` int column: `update_where` / `update_item` / instance `update()` take `expected_version=N` (`WHERE version = N`, `SET version = N + 1`; 0 rows → `DoesNotExist` if gone, `OptimisticLockError` if changed — carrying the **decision package**: `current_version`, `current_row`, `contested_fields` (current values of ONLY the fields this write attempted), and `changed_by`/`changed_at` from `history.row_versions` where versioned); `bulk_update(..., check_version=True)` guards per row; `MatrxEntity.update_with_rebase` auto-rebases past DISJOINT concurrent writes using `history.row_versions` snapshots. **Never automatic** — an unguarded write stays last-write-wins (the `_touch_row` trigger bumps `version` on every update, so auto-guarding would raise spurious conflicts). Contract: `common-docs/systems/optimistic-concurrency/FEATURE.md`.
|
|
27
27
|
- **Change tracking** — every instance records which columns were **loaded** (a SELECTed row, or the caller's kwargs) and which were **changed**. `save()` writes only those, so `.only("id","email")` + `save()` can never NULL the columns it never fetched (it used to — silent data loss). Mutable containers (jsonb/arrays) are always rewritten when loaded, because `obj.metadata["k"]=1` cannot be seen by `__setattr__`; **reassigning** (`obj.metadata = {...}`) is tracked precisely and is the sanctioned way. `Model._hydrate()` is the ONE row→model entry point.
|
|
28
28
|
- **Upsert writes only what the caller supplied.** `upsert` / `bulk_upsert` / `upsert_with_conflict` materialize every field's Python default so the **INSERT** half is complete — but `ON CONFLICT DO UPDATE SET` covers **only the columns present in the caller's `data`** (an explicit `update_fields` still wins). Letting defaults reach the UPDATE half silently RESET every defaulted column on each re-upsert of an existing row: it once reset scraped-and-analyzed `research.rs_source` rows to `scrape_status='pending', server_attempts=0` (a watchdog then finalized them `'failed'`) and undid users' manual `is_included=false`. A caller-supplied `None` is excluded too — it is dropped from the INSERT, and `EXCLUDED.<col>` on an omitted column resolves to that column's **DEFAULT**. An upsert of only conflict keys means "ensure this row exists" and reconciles to a no-op self-assignment, never a raise (a bare `DO NOTHING` would break `RETURNING *`). Pinned by `tests/level1/test_upsert_default_leak.py`.
|
|
29
29
|
- **Read-only models** (`_read_only = True`): a model that can be queried but NEVER written through the ORM — every write path raises `ReadOnlyModelError` (enforced at `QueryExecutor.insert/bulk_insert/upsert/update/delete`, so it covers all of `create`/`save`/`update`/`delete`/`update_where`/`delete_where`/`upsert`/`bulk_*`). Used for DB-managed tables the app must not mutate. The canonical **`auth.users`** model is baked into the schema generator (`get_string_user_model`) as the full, curated Supabase auth schema (safe columns only — no password/token fields), `_read_only`, bound per-database. Read a user's profile/metadata there; mutate users only through Supabase Auth (GoTrue).
|
|
@@ -98,7 +98,7 @@ If the admin router ever needs auth beyond token-based, inject a `resolve_user`
|
|
|
98
98
|
|
|
99
99
|
- Full type hints — the ORM's contract with callers is very type-heavy and IDE discoverability matters.
|
|
100
100
|
- No docstrings except on public API (Model, QueryBuilder, field classes, migration entry points). One line.
|
|
101
|
-
- Hot paths (`QueryBuilder.execute`, adapter fetches, dict→dataclass transforms) must stay allocation-light. Favor
|
|
101
|
+
- Hot paths (`QueryBuilder.execute`, adapter fetches, dict→dataclass transforms) must stay allocation-light. Favor mandated dataclasses / `__slots__`.
|
|
102
102
|
- Explicit exception handling — never swallow `asyncpg` errors.
|
|
103
103
|
|
|
104
104
|
---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: matrx-orm
|
|
3
|
-
Version: 3.1.
|
|
3
|
+
Version: 3.1.75
|
|
4
4
|
Summary: Async-first PostgreSQL ORM with bidirectional migrations, schema introspection, many-to-many relationships, and built-in state caching
|
|
5
5
|
Project-URL: Homepage, https://github.com/AI-Matrix-Engine/aidream-current
|
|
6
6
|
Project-URL: Repository, https://github.com/AI-Matrix-Engine/aidream-current
|
|
@@ -25,7 +25,7 @@ Requires-Python: >=3.13
|
|
|
25
25
|
Requires-Dist: asyncpg>=0.31.0
|
|
26
26
|
Requires-Dist: cryptography>=43.0
|
|
27
27
|
Requires-Dist: gitpython>=3.1.40
|
|
28
|
-
Requires-Dist: matrx-utils>=2.0.
|
|
28
|
+
Requires-Dist: matrx-utils>=2.0.16
|
|
29
29
|
Requires-Dist: psycopg-pool>=3.2.5
|
|
30
30
|
Requires-Dist: psycopg[binary]>=3.2.5
|
|
31
31
|
Requires-Dist: python-dotenv>=1.0
|
|
@@ -203,6 +203,12 @@ from .core.pool_watch import (
|
|
|
203
203
|
connection_holder_census,
|
|
204
204
|
register_pool_saturation_sink,
|
|
205
205
|
)
|
|
206
|
+
from .core.pooler_role import (
|
|
207
|
+
EmptyReadContaminationEvent,
|
|
208
|
+
PoolerRoleContamination,
|
|
209
|
+
SessionRoleChangeRefused,
|
|
210
|
+
register_contamination_sink,
|
|
211
|
+
)
|
|
206
212
|
from .core.pydantic_bridge import (
|
|
207
213
|
validate_input as validate_model_input,
|
|
208
214
|
)
|
|
@@ -573,6 +579,11 @@ __all__ = [
|
|
|
573
579
|
"PoolSaturationEvent",
|
|
574
580
|
"connection_holder_census",
|
|
575
581
|
"register_pool_saturation_sink",
|
|
582
|
+
# ── Pooler role contamination ──────────────────────────────────────────────
|
|
583
|
+
"EmptyReadContaminationEvent",
|
|
584
|
+
"PoolerRoleContamination",
|
|
585
|
+
"SessionRoleChangeRefused",
|
|
586
|
+
"register_contamination_sink",
|
|
576
587
|
# ── Signals ────────────────────────────────────────────────────────────────
|
|
577
588
|
"Signal",
|
|
578
589
|
"pre_create",
|
|
@@ -26,6 +26,7 @@ __all__ = [
|
|
|
26
26
|
"softdelete_unique_conflicts",
|
|
27
27
|
"qualified_relation_names",
|
|
28
28
|
"trigger_function_usage",
|
|
29
|
+
"trigger_column_coverage",
|
|
29
30
|
"column_privileges",
|
|
30
31
|
"entity_capability_state",
|
|
31
32
|
]
|
|
@@ -487,6 +488,51 @@ async def trigger_function_usage(
|
|
|
487
488
|
)
|
|
488
489
|
|
|
489
490
|
|
|
491
|
+
async def trigger_column_coverage(
|
|
492
|
+
database: str,
|
|
493
|
+
*,
|
|
494
|
+
function_name: str,
|
|
495
|
+
column_names: list[str],
|
|
496
|
+
) -> list[dict[str, Any]]:
|
|
497
|
+
"""Return relations carrying any named column or the named trigger function."""
|
|
498
|
+
return await _query(
|
|
499
|
+
database,
|
|
500
|
+
"""
|
|
501
|
+
WITH column_relations AS (
|
|
502
|
+
SELECT DISTINCT c.oid,
|
|
503
|
+
n.nspname AS schema,
|
|
504
|
+
c.relname AS table_name
|
|
505
|
+
FROM pg_attribute a
|
|
506
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
507
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
508
|
+
WHERE a.attname = ANY($2::text[])
|
|
509
|
+
AND a.attnum > 0
|
|
510
|
+
AND NOT a.attisdropped
|
|
511
|
+
AND c.relkind = 'r'
|
|
512
|
+
), trigger_relations AS (
|
|
513
|
+
SELECT DISTINCT t.tgrelid AS oid,
|
|
514
|
+
n.nspname AS schema,
|
|
515
|
+
c.relname AS table_name
|
|
516
|
+
FROM pg_trigger t
|
|
517
|
+
JOIN pg_class c ON c.oid = t.tgrelid
|
|
518
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
519
|
+
JOIN pg_proc p ON p.oid = t.tgfoid
|
|
520
|
+
WHERE NOT t.tgisinternal
|
|
521
|
+
AND p.proname = $1
|
|
522
|
+
)
|
|
523
|
+
SELECT COALESCE(cols.schema, triggers.schema) AS schema,
|
|
524
|
+
COALESCE(cols.table_name, triggers.table_name) AS table_name,
|
|
525
|
+
(cols.oid IS NOT NULL) AS has_columns,
|
|
526
|
+
(triggers.oid IS NOT NULL) AS has_trigger
|
|
527
|
+
FROM column_relations cols
|
|
528
|
+
FULL OUTER JOIN trigger_relations triggers ON cols.oid = triggers.oid
|
|
529
|
+
ORDER BY 1, 2
|
|
530
|
+
""",
|
|
531
|
+
function_name,
|
|
532
|
+
column_names,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
490
536
|
async def foreign_keys_to_relation(
|
|
491
537
|
database: str,
|
|
492
538
|
*,
|
|
@@ -89,6 +89,10 @@ Verified against code: 2026-08-15.
|
|
|
89
89
|
|
|
90
90
|
## Change log
|
|
91
91
|
|
|
92
|
+
- 2026-08-16: Kept the destructive transaction-pool contamination proof out of
|
|
93
|
+
the installable runtime package and isolated it under package tests; the
|
|
94
|
+
operator wrapper is SQL-free and runtime prose no longer resembles an
|
|
95
|
+
executable non-local role mutation.
|
|
92
96
|
- 2026-08-15: Pinned transaction-local RLS role cleanup across success,
|
|
93
97
|
exception, and early-return paths.
|
|
94
98
|
- 2026-08-15: Added first-timeout pool holder census, host alarm seam, tracked raw-pool helper, and bounded cancellation-safe release.
|
|
@@ -18,6 +18,11 @@ from matrx_orm.core.config import (
|
|
|
18
18
|
resolve_pool_mode,
|
|
19
19
|
)
|
|
20
20
|
from matrx_orm.error_handling import handle_orm_operation
|
|
21
|
+
from matrx_orm.core.pooler_role import (
|
|
22
|
+
VERIFY_EMPTY_READS,
|
|
23
|
+
assert_no_session_role_change,
|
|
24
|
+
reprove_empty_read,
|
|
25
|
+
)
|
|
21
26
|
from matrx_orm.core.tls import resolve_asyncpg_ssl
|
|
22
27
|
from matrx_orm.exceptions import (
|
|
23
28
|
AdapterError,
|
|
@@ -327,6 +332,25 @@ class AsyncDatabaseManager:
|
|
|
327
332
|
_keepalive_tasks: dict[str, asyncio.Task] = {} # per-config pool warmer
|
|
328
333
|
_growth_locks: dict[str, asyncio.Lock] = {}
|
|
329
334
|
|
|
335
|
+
# Config names already resolved as "does an empty read need proving here?".
|
|
336
|
+
# Only a transaction-mode pool can be handed a server connection another
|
|
337
|
+
# client left wearing a role, so a direct Postgres pays nothing.
|
|
338
|
+
_empty_read_verification: dict[str, bool] = {}
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def _verifies_empty_reads(cls, config_name: str) -> bool:
|
|
342
|
+
if not VERIFY_EMPTY_READS:
|
|
343
|
+
return False
|
|
344
|
+
cached = cls._empty_read_verification.get(config_name)
|
|
345
|
+
if cached is not None:
|
|
346
|
+
return cached
|
|
347
|
+
try:
|
|
348
|
+
verifies = resolve_pool_mode(get_database_config(config_name)) == "transaction"
|
|
349
|
+
except Exception:
|
|
350
|
+
verifies = False
|
|
351
|
+
cls._empty_read_verification[config_name] = verifies
|
|
352
|
+
return verifies
|
|
353
|
+
|
|
330
354
|
@classmethod
|
|
331
355
|
async def _replace_zero_connection_pool(cls, config_name: str, failed_pool: Any) -> bool:
|
|
332
356
|
"""Atomically discard a pool that has lost every physical connection."""
|
|
@@ -1025,6 +1049,11 @@ class AsyncDatabaseManager:
|
|
|
1025
1049
|
``.timeout()`` seam for known-long reads. ``None`` keeps the pool
|
|
1026
1050
|
default.
|
|
1027
1051
|
"""
|
|
1052
|
+
# Layer 1 of the pooler-role defence: SQL that would leave a borrowed role
|
|
1053
|
+
# on a shared server connection never reaches Postgres. See
|
|
1054
|
+
# matrx_orm/core/pooler_role.py for what that contamination does to reads.
|
|
1055
|
+
assert_no_session_role_change(query)
|
|
1056
|
+
|
|
1028
1057
|
# Reuse the active transaction connection when available
|
|
1029
1058
|
try:
|
|
1030
1059
|
from matrx_orm.core.transaction import get_active_connection
|
|
@@ -1124,6 +1153,21 @@ class AsyncDatabaseManager:
|
|
|
1124
1153
|
read_attempt + 1,
|
|
1125
1154
|
query[:120],
|
|
1126
1155
|
)
|
|
1156
|
+
if not results and read_only and cls._verifies_empty_reads(config_name):
|
|
1157
|
+
# Layer 2: an empty result from a pooled connection is
|
|
1158
|
+
# unproven until a session that can state its own role
|
|
1159
|
+
# says so. A borrowed `authenticated` role returns ZERO
|
|
1160
|
+
# ROWS on an RLS table instead of raising.
|
|
1161
|
+
true_rows = await reprove_empty_read(
|
|
1162
|
+
conn,
|
|
1163
|
+
config_name=config_name,
|
|
1164
|
+
query=query,
|
|
1165
|
+
args=args,
|
|
1166
|
+
statement_timeout=statement_timeout,
|
|
1167
|
+
normalize=_normalize_record,
|
|
1168
|
+
)
|
|
1169
|
+
if true_rows is not None:
|
|
1170
|
+
return true_rows
|
|
1127
1171
|
return [_normalize_record(r) for r in results]
|
|
1128
1172
|
except asyncpg.exceptions.PostgresSyntaxError as e:
|
|
1129
1173
|
raise QueryError(
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Pooler role contamination — the ORM's two layers against a SILENT empty read.
|
|
2
|
+
|
|
3
|
+
WHY THIS EXISTS (reproduced 2026-08-15, FOUND_DEFECTS "An ORM read returned ZERO
|
|
4
|
+
ROWS against a table holding 4,060"):
|
|
5
|
+
|
|
6
|
+
On a transaction-mode pooler (Supavisor / PgBouncer) routing is *per transaction*,
|
|
7
|
+
so consecutive statements from ONE client connection execute on DIFFERENT server
|
|
8
|
+
connections. A server connection that any client on the tenant left with a
|
|
9
|
+
non-LOCAL ``SET ROLE`` comes back out of the shared pool still wearing that role.
|
|
10
|
+
Our statement then runs as ``authenticated`` instead of ``postgres``.
|
|
11
|
+
|
|
12
|
+
**On an RLS table that GRANTs SELECT to ``authenticated``, that does not raise —
|
|
13
|
+
it returns ZERO ROWS.** Measured live against the platform pooler: a bare
|
|
14
|
+
``SET ROLE authenticated`` issued on 8 connections made 22 of 40 subsequent ORM
|
|
15
|
+
reads of ``seo.backlink_observation`` (4,060 rows) come back empty, with no
|
|
16
|
+
exception, while an identity probe interleaved on the same pool reported
|
|
17
|
+
``current_user=authenticated, session_user=postgres`` on 21 of 40 samples.
|
|
18
|
+
|
|
19
|
+
Silent emptiness is indistinguishable from "there is no data" for every sweep,
|
|
20
|
+
reconciler, digest and monitor in the platform: a broken read reports a quiet week
|
|
21
|
+
forever and every scheduled run stays green. ``db/pooler_session.py`` already says
|
|
22
|
+
it for the migration tools — **a privilege error must never be recorded as
|
|
23
|
+
absence** — and this module extends that to every ORM read, because a per-caller
|
|
24
|
+
defence does not scale.
|
|
25
|
+
|
|
26
|
+
TWO INDEPENDENT LAYERS, each sufficient alone, each screaming when it fires:
|
|
27
|
+
|
|
28
|
+
1. **Prevention — the ORM refuses to emit a session-scoped role change.**
|
|
29
|
+
``assert_no_session_role_change`` rejects a bare ``SET ROLE`` /
|
|
30
|
+
``SET SESSION AUTHORIZATION`` / ``set_config('role', …, false)`` on every query
|
|
31
|
+
that goes through the ORM. This closes the one source class the 2026-08-14
|
|
32
|
+
investigation could never catch statically: SQL authored at RUNTIME — the
|
|
33
|
+
admin ``sql`` tool, ``db_admin``, registered ``SQLQuery`` objects, agent
|
|
34
|
+
scratch scripts. ``scripts/check_pooler_role_scope.py`` guards committed code;
|
|
35
|
+
this guards everything else. The sanctioned ways to change role remain
|
|
36
|
+
``matrx_orm.rls_session`` and ``db/pooler_session.py::impersonate``.
|
|
37
|
+
|
|
38
|
+
2. **Reconciliation — an empty read is re-proved on a session that can prove its
|
|
39
|
+
role.** A zero-row result from a read-only query on a transaction-pool config
|
|
40
|
+
is re-run inside an explicit transaction (pinned to ONE server connection)
|
|
41
|
+
that starts with ``RESET ROLE``. If the rows are really there, the caller gets
|
|
42
|
+
the TRUE rows and the lie is reported through every registered sink. If the
|
|
43
|
+
session cannot be made clean, it RAISES rather than handing back an absence.
|
|
44
|
+
|
|
45
|
+
Layer 2 follows the repo's guard doctrine: a guard that CAN reconcile MUST
|
|
46
|
+
reconcile — we hold the correct answer, so killing the caller's request would be
|
|
47
|
+
strictly worse than returning it loudly. It costs nothing on a non-empty read and
|
|
48
|
+
nothing at all on a direct (session-mode) Postgres connection, where cross-client
|
|
49
|
+
contamination cannot happen.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import logging
|
|
55
|
+
import re
|
|
56
|
+
from collections.abc import Callable
|
|
57
|
+
from datetime import UTC, datetime
|
|
58
|
+
from typing import Any
|
|
59
|
+
|
|
60
|
+
from pydantic import BaseModel, ConfigDict
|
|
61
|
+
|
|
62
|
+
from matrx_utils import vcprint
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger("matrx_orm.pooler_role")
|
|
65
|
+
|
|
66
|
+
# Turning this off re-arms a silent-data-loss class platform-wide. It exists so a
|
|
67
|
+
# deployment on a genuinely unshared Postgres can skip the work, and so the cost
|
|
68
|
+
# is a code change (reviewable) rather than an env var (invisible).
|
|
69
|
+
VERIFY_EMPTY_READS = True
|
|
70
|
+
|
|
71
|
+
# How many times a re-proof may try to obtain a session running as our own login
|
|
72
|
+
# role before it gives up and raises. A contaminated server connection is repaired
|
|
73
|
+
# in-transaction, so one retry is almost always enough.
|
|
74
|
+
REPROVE_ATTEMPTS = 3
|
|
75
|
+
|
|
76
|
+
# A role change that is NOT scoped to a transaction. ``SET LOCAL ROLE`` and
|
|
77
|
+
# ``set_config('role', …, true)`` are safe and deliberately not matched.
|
|
78
|
+
_NON_LOCAL_SET_ROLE = re.compile(r"(?im)(?:^|;)\s*SET\s+(?!LOCAL\b)ROLE\b")
|
|
79
|
+
_NON_LOCAL_SET_AUTHORIZATION = re.compile(
|
|
80
|
+
r"(?im)(?:^|;)\s*SET\s+(?!LOCAL\b)SESSION\s+AUTHORIZATION\b"
|
|
81
|
+
)
|
|
82
|
+
_NON_LOCAL_SET_CONFIG_ROLE = re.compile(
|
|
83
|
+
r"(?is)set_config\s*\(\s*['\"]role['\"]\s*,.*?,\s*(?:false|'false'|\"false\")\s*\)"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Cheap pre-filter so the overwhelming majority of queries pay a substring scan
|
|
87
|
+
# rather than three regexes. Deliberately loose — a false hint costs microseconds,
|
|
88
|
+
# a missed one costs the platform a silent empty read.
|
|
89
|
+
_ROLE_HINTS = ("role", "authorization", "set_config")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SessionRoleChangeRefused(RuntimeError):
|
|
93
|
+
"""A query tried to change the Postgres role for the whole session."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PoolerRoleContamination(RuntimeError):
|
|
97
|
+
"""A statement could not be pinned to a session running as our own login role."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class EmptyReadContaminationEvent(BaseModel):
|
|
101
|
+
"""A read returned zero rows because the session was wearing a borrowed role."""
|
|
102
|
+
|
|
103
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
104
|
+
|
|
105
|
+
config_name: str
|
|
106
|
+
query: str
|
|
107
|
+
contaminated_role: str
|
|
108
|
+
login_role: str
|
|
109
|
+
true_row_count: int
|
|
110
|
+
occurred_at: datetime
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
ContaminationSink = Callable[[EmptyReadContaminationEvent], None]
|
|
114
|
+
|
|
115
|
+
_sinks: list[ContaminationSink] = []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def register_contamination_sink(sink: ContaminationSink) -> None:
|
|
119
|
+
"""Register a durable observer for proven silent-empty reads."""
|
|
120
|
+
if sink not in _sinks:
|
|
121
|
+
_sinks.append(sink)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def reset_contamination_sinks() -> None:
|
|
125
|
+
"""Clear process state for isolated tests."""
|
|
126
|
+
_sinks.clear()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def assert_no_session_role_change(query: str) -> None:
|
|
130
|
+
"""Refuse SQL that would leave a borrowed role on a pooled server connection.
|
|
131
|
+
|
|
132
|
+
Layer 1. Raises ``SessionRoleChangeRefused`` and names the sanctioned path;
|
|
133
|
+
it never rewrites the caller's SQL, because guessing which role a caller meant
|
|
134
|
+
is exactly the ambiguity a guard must not resolve on its own.
|
|
135
|
+
"""
|
|
136
|
+
if not query:
|
|
137
|
+
return
|
|
138
|
+
lowered = query.lower()
|
|
139
|
+
if not any(hint in lowered for hint in _ROLE_HINTS):
|
|
140
|
+
return
|
|
141
|
+
if (
|
|
142
|
+
_NON_LOCAL_SET_ROLE.search(query)
|
|
143
|
+
or _NON_LOCAL_SET_AUTHORIZATION.search(query)
|
|
144
|
+
or _NON_LOCAL_SET_CONFIG_ROLE.search(query)
|
|
145
|
+
):
|
|
146
|
+
vcprint(
|
|
147
|
+
"REFUSED: a session-scoped role change reached the ORM.\n"
|
|
148
|
+
f" query: {query[:300]}\n"
|
|
149
|
+
" On a transaction pooler this role rides the SHARED server connection "
|
|
150
|
+
"back into the pool, and the next client — any service on this tenant — "
|
|
151
|
+
"silently runs as that role. On an RLS table it does not error: it "
|
|
152
|
+
"returns ZERO ROWS, and absence gets recorded as fact.\n"
|
|
153
|
+
" Use `async with rls_session(claims, role=...)` (ORM) or "
|
|
154
|
+
"`impersonate(conn, role)` (db/pooler_session.py). Both use SET LOCAL "
|
|
155
|
+
"ROLE inside a transaction and cannot leak.",
|
|
156
|
+
color="red",
|
|
157
|
+
style="bold",
|
|
158
|
+
)
|
|
159
|
+
raise SessionRoleChangeRefused(
|
|
160
|
+
"A session-scoped role change (SET ROLE / SET SESSION AUTHORIZATION / "
|
|
161
|
+
"a non-local set_config role change) may never be executed through the ORM — it "
|
|
162
|
+
"contaminates the shared pooler and turns other services' reads into "
|
|
163
|
+
"silent empty results. Use rls_session(...) / impersonate(...), which "
|
|
164
|
+
"issue SET LOCAL ROLE inside a transaction."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def emit_contamination(event: EmptyReadContaminationEvent) -> None:
|
|
169
|
+
"""Scream about a proven silent-empty read, durably record it, fan out to sinks."""
|
|
170
|
+
vcprint(
|
|
171
|
+
"POOLER ROLE CONTAMINATION — an ORM read returned ZERO ROWS that were "
|
|
172
|
+
"really there.\n"
|
|
173
|
+
f" config: {event.config_name}\n"
|
|
174
|
+
f" session ran as: {event.contaminated_role} (login role "
|
|
175
|
+
f"{event.login_role})\n"
|
|
176
|
+
f" true row count on a clean session: {event.true_row_count}\n"
|
|
177
|
+
f" query: {event.query[:300]}\n"
|
|
178
|
+
" The caller has been given the TRUE rows. Something on this Postgres "
|
|
179
|
+
"tenant issued a non-LOCAL SET ROLE; every other client is exposed until "
|
|
180
|
+
"those server connections cycle.",
|
|
181
|
+
color="red",
|
|
182
|
+
style="bold",
|
|
183
|
+
)
|
|
184
|
+
logger.error(
|
|
185
|
+
"[pooler-role] silent empty read repaired on %s (ran as %s, %d true rows): %s",
|
|
186
|
+
event.config_name,
|
|
187
|
+
event.contaminated_role,
|
|
188
|
+
event.true_row_count,
|
|
189
|
+
event.query[:200],
|
|
190
|
+
)
|
|
191
|
+
# Durable by default — a host should not have to wire anything for the
|
|
192
|
+
# platform's loudest data-integrity alarm to survive the process.
|
|
193
|
+
try:
|
|
194
|
+
from matrx_orm.session.fallback import record_error
|
|
195
|
+
|
|
196
|
+
await record_error(
|
|
197
|
+
PoolerRoleContamination(
|
|
198
|
+
f"read returned 0 rows as '{event.contaminated_role}'; "
|
|
199
|
+
f"{event.true_row_count} rows were really there"
|
|
200
|
+
),
|
|
201
|
+
kind="pooler_role_contamination",
|
|
202
|
+
source_app="matrx-orm",
|
|
203
|
+
route=f"orm:{event.config_name}",
|
|
204
|
+
payload={
|
|
205
|
+
"config_name": event.config_name,
|
|
206
|
+
"query": event.query[:2000],
|
|
207
|
+
"contaminated_role": event.contaminated_role,
|
|
208
|
+
"login_role": event.login_role,
|
|
209
|
+
"true_row_count": event.true_row_count,
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
except Exception:
|
|
213
|
+
logger.exception("[pooler-role] could not durably record the contamination event")
|
|
214
|
+
|
|
215
|
+
for sink in list(_sinks):
|
|
216
|
+
try:
|
|
217
|
+
sink(event)
|
|
218
|
+
except Exception:
|
|
219
|
+
logger.exception("[pooler-role] contamination sink raised")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def reprove_empty_read(
|
|
223
|
+
conn: Any,
|
|
224
|
+
*,
|
|
225
|
+
config_name: str,
|
|
226
|
+
query: str,
|
|
227
|
+
args: tuple[Any, ...],
|
|
228
|
+
statement_timeout: float | None,
|
|
229
|
+
normalize: Callable[[Any], dict[str, Any]],
|
|
230
|
+
) -> list[dict[str, Any]] | None:
|
|
231
|
+
"""Re-run an empty read on a session proven to be running as US.
|
|
232
|
+
|
|
233
|
+
Layer 2. Returns the TRUE rows when the empty result was a lie, ``None`` when
|
|
234
|
+
the read was genuinely empty. Raises ``PoolerRoleContamination`` when a clean
|
|
235
|
+
session cannot be obtained at all — an unprovable absence is never a result.
|
|
236
|
+
|
|
237
|
+
``conn`` must be a connection not currently inside a transaction; the explicit
|
|
238
|
+
transaction opened here is what pins every statement below to ONE server
|
|
239
|
+
connection, which is the only thing that makes ``RESET ROLE`` meaningful.
|
|
240
|
+
"""
|
|
241
|
+
last_role: tuple[str, str] | None = None
|
|
242
|
+
for _ in range(REPROVE_ATTEMPTS):
|
|
243
|
+
async with conn.transaction():
|
|
244
|
+
await conn.execute("RESET ROLE")
|
|
245
|
+
identity = await conn.fetchrow("SELECT current_user, session_user")
|
|
246
|
+
current = identity["current_user"]
|
|
247
|
+
login = identity["session_user"]
|
|
248
|
+
if current != login:
|
|
249
|
+
last_role = (current, login)
|
|
250
|
+
continue
|
|
251
|
+
rows = await conn.fetch(query, *args, timeout=statement_timeout)
|
|
252
|
+
if not rows:
|
|
253
|
+
return None
|
|
254
|
+
true_rows = [normalize(r) for r in rows]
|
|
255
|
+
|
|
256
|
+
await emit_contamination(
|
|
257
|
+
EmptyReadContaminationEvent(
|
|
258
|
+
config_name=config_name,
|
|
259
|
+
query=query,
|
|
260
|
+
# The first read ran on a session we could not observe directly;
|
|
261
|
+
# what we can state is the role it was NOT running as.
|
|
262
|
+
contaminated_role="<not the login role>" if last_role is None else last_role[0],
|
|
263
|
+
login_role=login,
|
|
264
|
+
true_row_count=len(true_rows),
|
|
265
|
+
occurred_at=datetime.now(UTC),
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
return true_rows
|
|
269
|
+
|
|
270
|
+
current, login = last_role if last_role is not None else ("<unknown>", "<unknown>")
|
|
271
|
+
raise PoolerRoleContamination(
|
|
272
|
+
f"an empty read could not be proved: every attempt to pin a clean session "
|
|
273
|
+
f"landed as '{current}' (login role '{login}'). Anything this session could "
|
|
274
|
+
f"not see is a PRIVILEGE failure, not absence. Query: {query[:200]}"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
__all__ = [
|
|
279
|
+
"ContaminationSink",
|
|
280
|
+
"EmptyReadContaminationEvent",
|
|
281
|
+
"PoolerRoleContamination",
|
|
282
|
+
"REPROVE_ATTEMPTS",
|
|
283
|
+
"SessionRoleChangeRefused",
|
|
284
|
+
"VERIFY_EMPTY_READS",
|
|
285
|
+
"assert_no_session_role_change",
|
|
286
|
+
"emit_contamination",
|
|
287
|
+
"register_contamination_sink",
|
|
288
|
+
"reprove_empty_read",
|
|
289
|
+
"reset_contamination_sinks",
|
|
290
|
+
]
|
|
@@ -5,7 +5,10 @@ import ssl
|
|
|
5
5
|
import traceback as _tb
|
|
6
6
|
from collections.abc import Mapping
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
+
from datetime import date, datetime, time
|
|
9
|
+
from decimal import Decimal
|
|
8
10
|
from typing import Any
|
|
11
|
+
from uuid import UUID
|
|
9
12
|
|
|
10
13
|
from matrx_utils import vcprint
|
|
11
14
|
|
|
@@ -1899,11 +1902,49 @@ class UnknownDatabaseError(ORMException):
|
|
|
1899
1902
|
return f"{_RED}{body}{_RESET}"
|
|
1900
1903
|
|
|
1901
1904
|
|
|
1905
|
+
def jsonable(value: object) -> object:
|
|
1906
|
+
"""Coerce a DB value into something JSON-serializable, losslessly where we can.
|
|
1907
|
+
|
|
1908
|
+
Error payloads cross the wire (the platform 409 `version_conflict` body is
|
|
1909
|
+
typed `JsonValue`), so a datetime / UUID / Decimal in a conflict's contested
|
|
1910
|
+
fields must not be what turns a conflict into a 500. Unknown types degrade
|
|
1911
|
+
to `str()` — a display value is always better than a crash inside an error
|
|
1912
|
+
handler.
|
|
1913
|
+
"""
|
|
1914
|
+
if value is None or isinstance(value, bool | int | float | str):
|
|
1915
|
+
return value
|
|
1916
|
+
if isinstance(value, datetime | date | time):
|
|
1917
|
+
return value.isoformat()
|
|
1918
|
+
if isinstance(value, Decimal):
|
|
1919
|
+
return float(value)
|
|
1920
|
+
if isinstance(value, UUID):
|
|
1921
|
+
return str(value)
|
|
1922
|
+
if isinstance(value, Mapping):
|
|
1923
|
+
return {str(k): jsonable(v) for k, v in value.items()}
|
|
1924
|
+
if isinstance(value, list | tuple | set | frozenset):
|
|
1925
|
+
return [jsonable(v) for v in value]
|
|
1926
|
+
return str(value)
|
|
1927
|
+
|
|
1928
|
+
|
|
1902
1929
|
class OptimisticLockError(ORMException):
|
|
1903
1930
|
"""Raised when an optimistic-lock version conflict is detected.
|
|
1904
1931
|
|
|
1905
|
-
|
|
1906
|
-
|
|
1932
|
+
THE LOSER GETS A DECISION, NOT AN ERROR (platform ruling 2026-08-10, item 3).
|
|
1933
|
+
A caller that lost a compare-and-swap already holds "proposed" (its own
|
|
1934
|
+
payload); this exception carries everything else it needs to RESOLVE rather
|
|
1935
|
+
than blindly retry:
|
|
1936
|
+
|
|
1937
|
+
``expected_version`` — the revision the caller wrote against
|
|
1938
|
+
``current_version`` — the revision that actually won
|
|
1939
|
+
``contested_fields`` — {field: current value} for ONLY the fields this
|
|
1940
|
+
write attempted (never the whole row)
|
|
1941
|
+
``changed_by`` / ``changed_at`` — who moved the row and when, from
|
|
1942
|
+
``history.row_versions``; None on an unversioned
|
|
1943
|
+
entity or when the lookup found nothing
|
|
1944
|
+
``current_row`` — the live instance, for a merge UI without a re-read
|
|
1945
|
+
|
|
1946
|
+
Resolution is just another write: keep-mine / keep-theirs / merged, resubmitted
|
|
1947
|
+
against ``current_version``. No queue, no resolution endpoint.
|
|
1907
1948
|
"""
|
|
1908
1949
|
|
|
1909
1950
|
def __init__(
|
|
@@ -1914,13 +1955,29 @@ class OptimisticLockError(ORMException):
|
|
|
1914
1955
|
message: str | None = None,
|
|
1915
1956
|
current_version: int | None = None,
|
|
1916
1957
|
current_row=None,
|
|
1958
|
+
contested_fields: Mapping[str, object] | None = None,
|
|
1959
|
+
changed_by: object = None,
|
|
1960
|
+
changed_at: object = None,
|
|
1917
1961
|
) -> None:
|
|
1918
1962
|
msg = message or (
|
|
1919
1963
|
f"Optimistic lock conflict on {getattr(model, '__name__', model)} "
|
|
1920
1964
|
f"(pk={pk}, expected version={expected_version}"
|
|
1921
1965
|
+ (f", current version={current_version}" if current_version is not None else "")
|
|
1966
|
+
+ (f", contested fields={sorted(contested_fields)}" if contested_fields else "")
|
|
1967
|
+
+ (f", changed by {changed_by}" if changed_by else "")
|
|
1922
1968
|
+ "). The record was modified by another process — re-fetch and retry."
|
|
1923
1969
|
)
|
|
1970
|
+
# Stored already-jsonable: this is a decision package for a human or a
|
|
1971
|
+
# wire payload, not a data-access path (that is `current_row`).
|
|
1972
|
+
self.contested_fields: dict[str, object] | None = (
|
|
1973
|
+
{str(k): jsonable(v) for k, v in contested_fields.items()}
|
|
1974
|
+
if contested_fields is not None
|
|
1975
|
+
else None
|
|
1976
|
+
)
|
|
1977
|
+
self.changed_by: str | None = str(changed_by) if changed_by is not None else None
|
|
1978
|
+
self.changed_at: str | None = (
|
|
1979
|
+
jsonable(changed_at) if changed_at is not None else None # type: ignore[assignment]
|
|
1980
|
+
)
|
|
1924
1981
|
super().__init__(
|
|
1925
1982
|
message=msg,
|
|
1926
1983
|
model=model,
|
|
@@ -1928,6 +1985,9 @@ class OptimisticLockError(ORMException):
|
|
|
1928
1985
|
"pk": pk,
|
|
1929
1986
|
"expected_version": expected_version,
|
|
1930
1987
|
"current_version": current_version,
|
|
1988
|
+
"contested_fields": self.contested_fields,
|
|
1989
|
+
"changed_by": self.changed_by,
|
|
1990
|
+
"changed_at": self.changed_at,
|
|
1931
1991
|
},
|
|
1932
1992
|
)
|
|
1933
1993
|
self.expected_version = expected_version
|