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