From 007940ee59e0064a0dccf1439de9c05838057865 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:17 +0000 Subject: [PATCH 01/25] refactor(python): fix signals logger name --- ryx-python/ryx/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ryx-python/ryx/signals.py b/ryx-python/ryx/signals.py index 2c89829..f82ea78 100644 --- a/ryx-python/ryx/signals.py +++ b/ryx-python/ryx/signals.py @@ -45,7 +45,7 @@ async def on_post_save(sender, instance, created, **kwargs): import weakref from typing import Any, Callable, Optional, Type -logger = logging.getLogger("Rxy.signals") +logger = logging.getLogger("ryx.signals") #### From 6372fef488819aa352db531d19433725c9f2b5aa Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:17 +0000 Subject: [PATCH 02/25] refactor(python): fix transaction logger name --- ryx-python/ryx/transaction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ryx-python/ryx/transaction.py b/ryx-python/ryx/transaction.py index 5c5f08a..edbc6a0 100644 --- a/ryx-python/ryx/transaction.py +++ b/ryx-python/ryx/transaction.py @@ -42,7 +42,7 @@ from ryx import ryx_core as _core -logger = logging.getLogger("Ryx.transaction") +logger = logging.getLogger("ryx.transaction") # ContextVar: holds the currently active transaction handle (if any) # for the current async task. This enables auto-enlistment in a future version. From 61c57779386b55e49df00643ce037471e81bc2cc Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:17 +0000 Subject: [PATCH 03/25] feat(python): implement logging from RYX_LOG_LEVEL env var --- ryx-python/ryx/__init__.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/ryx-python/ryx/__init__.py b/ryx-python/ryx/__init__.py index 2268ff2..b3486a6 100644 --- a/ryx-python/ryx/__init__.py +++ b/ryx-python/ryx/__init__.py @@ -2,8 +2,22 @@ # Import the compiled Rust extension directly to avoid circular import import ryx.ryx_core as _core +import logging as _logging import os +# Configure Ryx logging from RYX_LOG_LEVEL env var +_ryx_logger = _logging.getLogger("ryx") +_ryx_log_level = os.environ.get("RYX_LOG_LEVEL", "").strip().upper() +if _ryx_log_level: + _ryx_log_level_num = getattr(_logging, _ryx_log_level, _logging.INFO) + _ryx_log_handler = _logging.StreamHandler() + _ryx_log_handler.setFormatter( + _logging.Formatter("[ryx] %(levelname)s %(name)s: %(message)s") + ) + _ryx_logger.addHandler(_ryx_log_handler) + _ryx_logger.setLevel(_ryx_log_level_num) + _ryx_logger.propagate = False + # ORM core from ryx.models import Constraint, Index, Model @@ -123,8 +137,12 @@ async def setup( # For old versions wrap the url with a dict if isinstance(urls, str): - urls = {'default': urls} + urls = {'default': urls} + _ryx_logger.info( + "Initializing pools: %s (max_conn=%d, min_conn=%d)", + list(urls.keys()), max_connections, min_connections, + ) await _core.setup( urls, max_connections = max_connections, @@ -133,10 +151,12 @@ async def setup( idle_timeout = idle_timeout, max_lifetime = max_lifetime, ) + _ryx_logger.info("Pools initialized: %s", list(urls.keys())) def register_lookup(name: str, sql_template: str) -> None: """Register a custom lookup operator (process-global).""" + _ryx_logger.debug("Register custom lookup: %s = %s", name, sql_template) _core.register_lookup(name, sql_template) @@ -345,7 +365,10 @@ def _discover_config_file(): def _auto_setup(): global _AUTO_INIT_DONE - if _AUTO_INIT_DONE or not _should_auto_init(): + if _AUTO_INIT_DONE: + return + if not _should_auto_init(): + _ryx_logger.debug("Auto-init disabled via RYX_AUTO_INITIALIZE") return urls = _discover_urls_from_env() @@ -356,8 +379,11 @@ def _auto_setup(): pool_cfg = cfg.get("pool", {}) or {} if not urls: + _ryx_logger.debug("No URLs found — auto-init skipped") return + _ryx_logger.info("Auto-initializing with URLs: %s", list(urls.keys())) + try: import asyncio From 8bd95edb40cb37da849b64b63e2b91f84172610e Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:17 +0000 Subject: [PATCH 04/25] feat(python): add logging for bulk operations --- ryx-python/ryx/bulk.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ryx-python/ryx/bulk.py b/ryx-python/ryx/bulk.py index 38115ec..d5a1547 100644 --- a/ryx-python/ryx/bulk.py +++ b/ryx-python/ryx/bulk.py @@ -25,6 +25,7 @@ from __future__ import annotations +import logging from typing import List, Sequence, Type, TYPE_CHECKING, Optional if TYPE_CHECKING: @@ -33,6 +34,8 @@ from ryx import ryx_core as _core from ryx.router import get_router +logger = logging.getLogger("ryx.bulk") + def _resolve_alias(model: "Model") -> Optional[str]: """Resolve DB alias using Router → Meta.database → default(None).""" @@ -96,6 +99,11 @@ async def bulk_create( """ from ryx.models import _apply_auto_timestamps + logger.info( + "bulk_create %s: %d rows (batch_size=%d, validate=%s, ignore_conflicts=%s)", + model.__name__, len(instances), batch_size, validate, ignore_conflicts, + ) + if not instances: return list(instances) @@ -271,6 +279,11 @@ async def bulk_update( Signals: Does NOT fire pre_save / post_save signals (for performance). """ + logger.info( + "bulk_update %s: %d rows, fields=%s (batch_size=%d)", + model.__name__, len(instances), fields, batch_size, + ) + if not instances or not fields: return 0 @@ -352,6 +365,11 @@ async def bulk_delete( Signals: Does NOT fire pre_delete / post_delete signals. """ + logger.info( + "bulk_delete %s: %d rows (batch_size=%d)", + model.__name__, len(instances), batch_size, + ) + pk_field = model._meta.pk_field if not pk_field: raise ValueError(f"{model.__name__} has no primary key") From c4c3c18802aca1ee7512e041c1957a0e26738f66 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:17 +0000 Subject: [PATCH 05/25] feat(python): add logging for caching operations --- ryx-python/ryx/cache.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ryx-python/ryx/cache.py b/ryx-python/ryx/cache.py index 2cfa25f..c76b402 100644 --- a/ryx-python/ryx/cache.py +++ b/ryx-python/ryx/cache.py @@ -34,11 +34,14 @@ import asyncio import hashlib import json +import logging import time from abc import ABC, abstractmethod from collections import OrderedDict from typing import Any, Optional +logger = logging.getLogger("ryx.cache") + #### ## ABSTRACT CACHE BACKEND @@ -175,6 +178,10 @@ def configure_cache( global _cache_backend, _auto_invalidate _cache_backend = backend _auto_invalidate = auto_invalidate + logger.info( + "Cache configured: backend=%s, auto_invalidate=%s", + type(backend).__name__, auto_invalidate, + ) if auto_invalidate: _register_invalidation_signals() @@ -215,6 +222,7 @@ async def invalidate(key: str) -> None: """ if _cache_backend: await _cache_backend.delete(key) + logger.debug("Cache invalidate: %s", key) async def invalidate_model(model: type) -> None: @@ -231,12 +239,14 @@ async def invalidate_model(model: type) -> None: keys = await _cache_backend.keys(prefix) if keys: await _cache_backend.delete_many(keys) + logger.debug("Cache invalidate model %s: %d keys", model.__name__, len(keys)) async def invalidate_all() -> None: """Clear the entire cache.""" if _cache_backend: await _cache_backend.clear() + logger.debug("Cache invalidate all") #### @@ -269,9 +279,11 @@ async def _execute(self) -> list: # Try cache first cached = await backend.get(key) if cached is not None: + logger.debug("Cache HIT: %s", key) return cached # Cache miss → hit DB + logger.debug("Cache MISS: %s", key) result = await super()._execute() # type: ignore[misc] # Serialise model instances to plain dicts for caching From fb184e8c9b256183f4d81c0b14aa1cf9a81c3a81 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:17 +0000 Subject: [PATCH 06/25] feat(python): wire CLI debug/verbose flags to logging --- ryx-python/ryx/cli/config.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ryx-python/ryx/cli/config.py b/ryx-python/ryx/cli/config.py index 4e5d4fd..850681d 100644 --- a/ryx-python/ryx/cli/config.py +++ b/ryx-python/ryx/cli/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import os from dataclasses import dataclass, field from pathlib import Path @@ -7,6 +8,9 @@ from ryx.cli.config_loader import get_loader, load_config +_ryx_logger = logging.getLogger("ryx") +_logger = logging.getLogger("ryx.cli") + @dataclass class Config: @@ -54,6 +58,14 @@ def from_args(cls, args) -> "Config": config.debug = getattr(args, "debug", False) config.verbose = getattr(args, "verbose", False) + # Wire CLI flags to logging levels + if config.debug: + _ryx_logger.setLevel(logging.DEBUG) + _logger.debug("Debug mode enabled via CLI --debug") + elif config.verbose: + _ryx_logger.setLevel(logging.INFO) + _logger.info("Verbose mode enabled via CLI --verbose") + # Load config file if specified config_file = getattr(args, "config_file", None) if config_file: From 3017d15475b8b620c1d667708bdad5d06a074c72 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:17 +0000 Subject: [PATCH 07/25] feat(python): add debug logs for CRUD operations --- ryx-python/ryx/models.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ryx-python/ryx/models.py b/ryx-python/ryx/models.py index 5613d27..9f835f0 100644 --- a/ryx-python/ryx/models.py +++ b/ryx-python/ryx/models.py @@ -26,11 +26,14 @@ async def after_delete(self) → post-SQL hook from __future__ import annotations +import logging import re from datetime import datetime from typing import Any, Dict, List, Optional from ryx import ryx_core as _core + +logger = logging.getLogger("ryx.models") from ryx.exceptions import DoesNotExist, MultipleObjectsReturned from ryx.fields import AutoField, DateTimeField, DateField, TimeField, Field, ManyToManyField from ryx.signals import post_delete, post_save, pre_delete, pre_save @@ -233,6 +236,7 @@ async def count(self) -> int: async def create(self, **kw): """Create and save a new model instance.""" + logger.debug("Manager.create(%s): %s", self._model.__name__, kw) instance = self._model(**kw) # Use the manager's alias if specified @@ -613,6 +617,10 @@ async def save( if alias: builder = builder.set_using(alias) new_id = await builder.execute_insert(values, returning_id=True) + logger.debug( + "INSERT %s (pk=%s) on %s", + type(self).__name__, new_id, alias or "default", + ) if self._meta.pk_field: object.__setattr__(self, self._meta.pk_field.attname, new_id) @@ -642,6 +650,11 @@ async def save( pk_field.column, "exact", self.pk, negated=False ) await builder.execute_update(values) + logger.debug( + "UPDATE %s (pk=%s, fields=%s) on %s", + type(self).__name__, self.pk, + [f.column for f in fields_to_save], alias or "default", + ) # after_save hook await self.after_save(created) @@ -681,6 +694,10 @@ async def delete(self) -> None: builder = builder.set_using(alias) builder = builder.add_filter(pk_field.column, "exact", self.pk, negated=False) await builder.execute_delete() + logger.debug( + "DELETE %s (pk=%s) on %s", + type(self).__name__, self.pk, alias or "default", + ) # Clear pk to signal "no longer in DB" object.__setattr__(self, self._meta.pk_field.attname, None) From 15f7985cb22810745e752f5497e3d6ba0c41d93d Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:17 +0000 Subject: [PATCH 08/25] feat(python): log raw parameterized query executions --- ryx-python/ryx/pool_ext.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ryx-python/ryx/pool_ext.py b/ryx-python/ryx/pool_ext.py index 12acde6..4dee9e4 100644 --- a/ryx-python/ryx/pool_ext.py +++ b/ryx-python/ryx/pool_ext.py @@ -10,10 +10,13 @@ from __future__ import annotations +import logging from typing import Any, List from ryx import ryx_core as _core +logger = logging.getLogger("ryx.pool") + async def execute_with_params(sql: str, values: List[Any]) -> int: """Execute a parameterized SQL statement and return rows_affected. @@ -25,6 +28,7 @@ async def execute_with_params(sql: str, values: List[Any]) -> int: Returns: Number of rows affected. """ + logger.debug("EXEC: %s [%d params]", sql[:120], len(values)) return await _core.execute_with_params(sql, values) @@ -38,4 +42,5 @@ async def fetch_with_params(sql: str, values: List[Any]) -> list: Returns: List of row dicts. """ + logger.debug("FETCH: %s [%d params]", sql[:120], len(values)) return await _core.fetch_with_params(sql, values) From 8a24030e979f95553d67f7b48b94ef0a74ac0a2a Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:17 +0000 Subject: [PATCH 09/25] feat(python): log select_related execution --- ryx-python/ryx/relations.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ryx-python/ryx/relations.py b/ryx-python/ryx/relations.py index 2d158d0..4e38602 100644 --- a/ryx-python/ryx/relations.py +++ b/ryx-python/ryx/relations.py @@ -29,9 +29,11 @@ from __future__ import annotations -# import asyncio +import logging from typing import Any, Dict, List, TYPE_CHECKING +logger = logging.getLogger("ryx.relations") + if TYPE_CHECKING: from ryx.models import Model from ryx.queryset import QuerySet @@ -125,6 +127,10 @@ async def apply_select_related( result.append(instance) + logger.debug( + "select_related %s: %d rows via LEFT JOIN on %s", + model.__name__, len(result), list(joins.keys()), + ) return result From 8c600e911c68c366960f6416e8efa7cc662f1c7d Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:17 +0000 Subject: [PATCH 10/25] feat(python): log global router configuration --- ryx-python/ryx/router.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ryx-python/ryx/router.py b/ryx-python/ryx/router.py index 6fa6451..d27ba97 100644 --- a/ryx-python/ryx/router.py +++ b/ryx-python/ryx/router.py @@ -6,8 +6,11 @@ """ from __future__ import annotations +import logging from typing import Any, Optional, TYPE_CHECKING +logger = logging.getLogger("ryx.router") + if TYPE_CHECKING: from ryx.models import Model @@ -42,6 +45,7 @@ def set_router(router: BaseRouter) -> None: """Set the global router for the application.""" global _router _router = router + logger.info("Router set: %s", type(router).__name__) def get_router() -> Optional[BaseRouter]: From 56a1379bd34dfbde86fd559da3e22af17c2c6f42 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:18 +0000 Subject: [PATCH 11/25] feat(python): initialize Rust tracing from Python module --- ryx-python/src/lib.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ryx-python/src/lib.rs b/ryx-python/src/lib.rs index 8d994f2..708c7e1 100644 --- a/ryx-python/src/lib.rs +++ b/ryx-python/src/lib.rs @@ -1012,8 +1012,21 @@ fn bulk_update<'py>( // Module definition // ### +fn _init_tracing() { + let level = std::env::var("RYX_LOG_LEVEL").unwrap_or_default(); + if !level.is_empty() { + let filter = format!("ryx={}", level.to_lowercase()); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(true) + .try_init(); + } +} + #[pymodule] fn ryx_core(m: &Bound<'_, PyModule>) -> PyResult<()> { + _init_tracing(); + lookups::init_registry(); let mut builder = tokio::runtime::Builder::new_multi_thread(); From 7deb5213c976155aa80d9ca11cd130594ceb93b5 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:18 +0000 Subject: [PATCH 12/25] feat(query): add debug trace during query compilation --- ryx-query/src/compiler/compilr.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ryx-query/src/compiler/compilr.rs b/ryx-query/src/compiler/compilr.rs index 05cf399..25f079f 100644 --- a/ryx-query/src/compiler/compilr.rs +++ b/ryx-query/src/compiler/compilr.rs @@ -17,6 +17,7 @@ use crate::lookups::date_lookups as date; use crate::lookups::json_lookups as json; use crate::lookups::{self, LookupContext}; use crate::symbols::{GLOBAL_INTERNER, Symbol}; +use tracing::debug; use dashmap::DashMap; use once_cell::sync::Lazy; use smallvec::SmallVec; @@ -144,6 +145,7 @@ pub struct CompiledQuery { } pub fn compile(node: &QueryNode) -> QueryResult { + debug!(?node.operation, table = ?GLOBAL_INTERNER.resolve(node.table), "Compiling query"); let mut values: SmallVec<[SqlValue; 8]> = SmallVec::new(); let plan_hash = compute_plan_hash(node); let mut node_column_names: Option> = None; From 3a7db9122567606fd672d03c02848a06f1a1804a Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:18 +0000 Subject: [PATCH 13/25] feat(query): add debug trace during lookup registration --- ryx-query/src/lookups/lookups.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ryx-query/src/lookups/lookups.rs b/ryx-query/src/lookups/lookups.rs index c781ace..342c859 100644 --- a/ryx-query/src/lookups/lookups.rs +++ b/ryx-query/src/lookups/lookups.rs @@ -13,6 +13,7 @@ use std::sync::{OnceLock, RwLock}; // Removed unused SqlValue import use crate::backend::Backend; use crate::errors::{QueryError, QueryResult}; +use tracing::debug; // Re-export submodules pub use crate::lookups::common_lookups; @@ -122,10 +123,13 @@ pub fn register_custom( .write() .map_err(|e| QueryError::Internal(format!("Registry lock poisoned: {e}")))?; + let name = name.into(); + let sql_template = sql_template.into(); + debug!("Register custom lookup: {} = {}", name, sql_template); guard.custom.insert( - name.into(), + name, PythonLookup { - sql_template: sql_template.into(), + sql_template, }, ); From a55960b209a7f3e16e7e53d842a35713edaa3a58 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:18 +0000 Subject: [PATCH 14/25] feat(rs): add init_tracing and call it on init --- ryx-rs/src/lib.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ryx-rs/src/lib.rs b/ryx-rs/src/lib.rs index b4bbfbe..ee1cd32 100644 --- a/ryx-rs/src/lib.rs +++ b/ryx-rs/src/lib.rs @@ -11,6 +11,8 @@ pub mod row; pub mod stream; pub mod transaction; +use std::sync::OnceLock; + // Re-export key traits and types for convenience pub use config::{is_initialized, RyxConfig, PoolConfigSection, MigrationsConfig}; pub use model::{FieldMeta, Model, RelationMeta, Relationships}; @@ -20,6 +22,26 @@ pub use queryset::QuerySet; pub use row::FromRow; pub use transaction::transaction; +/// Initialize the global `tracing` subscriber from `RYX_LOG_LEVEL`. +/// +/// Called automatically by [`init()`]. Safe to call multiple times — +/// only the first call takes effect. +pub fn init_tracing() { + static INIT: OnceLock<()> = OnceLock::new(); + INIT.get_or_init(|| { + let level = + std::env::var("RYX_LOG_LEVEL").unwrap_or_default(); + if level.is_empty() { + return; + } + let filter = format!("ryx={}", level.to_lowercase()); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(true) + .try_init(); + }); +} + /// Auto-detect config and initialize the pool. /// /// Equivalent to the Python `import ryx` auto-setup: @@ -29,6 +51,8 @@ pub use transaction::transaction; /// /// No-op if already initialized or no config is found. pub async fn init() -> RyxResult<()> { + init_tracing(); + tracing::info!("Initializing Ryx ..."); config::init().await } From 6e35d395f5eca27af080a0bed3ad253971fa56bf Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 11:18:18 +0000 Subject: [PATCH 15/25] chore: update dependencies and Lockfile for tracing --- Cargo.lock | 3 +++ ryx-common/Cargo.toml | 1 + ryx-rs/Cargo.toml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 68c9c5e..e7ee9cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1805,6 +1805,7 @@ dependencies = [ "sqlx", "thiserror", "tokio", + "tracing", ] [[package]] @@ -1871,6 +1872,8 @@ dependencies = [ "sqlx", "tokio", "toml", + "tracing", + "tracing-subscriber", ] [[package]] diff --git a/ryx-common/Cargo.toml b/ryx-common/Cargo.toml index bb899f6..4015504 100644 --- a/ryx-common/Cargo.toml +++ b/ryx-common/Cargo.toml @@ -10,3 +10,4 @@ sqlx = { workspace = true } tokio = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } +tracing = { workspace = true } diff --git a/ryx-rs/Cargo.toml b/ryx-rs/Cargo.toml index 4e59f4e..4c806d9 100644 --- a/ryx-rs/Cargo.toml +++ b/ryx-rs/Cargo.toml @@ -17,6 +17,8 @@ chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } once_cell = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } toml = { workspace = true, optional = true } serde_yaml = { workspace = true, optional = true } redis = { version = "0.28", optional = true, features = ["tokio-comp"] } From eb9cddccc9bbd70af06a40047ef2e1f4625b1484 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 16:04:44 +0000 Subject: [PATCH 16/25] feat(logging): support silencing via NO_LOG/OFF/SILENT in RYX_LOG_LEVEL --- ryx-python/ryx/__init__.py | 6 +++++- ryx-python/src/lib.rs | 17 +++++++++++------ ryx-rs/src/lib.rs | 6 +++++- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/ryx-python/ryx/__init__.py b/ryx-python/ryx/__init__.py index b3486a6..f20e902 100644 --- a/ryx-python/ryx/__init__.py +++ b/ryx-python/ryx/__init__.py @@ -8,7 +8,11 @@ # Configure Ryx logging from RYX_LOG_LEVEL env var _ryx_logger = _logging.getLogger("ryx") _ryx_log_level = os.environ.get("RYX_LOG_LEVEL", "").strip().upper() -if _ryx_log_level: +if _ryx_log_level in ("NO_LOG", "OFF", "SILENT"): + _ryx_logger.disabled = True + _ryx_logger.propagate = False + _ryx_logger.addHandler(_logging.NullHandler()) +elif _ryx_log_level: _ryx_log_level_num = getattr(_logging, _ryx_log_level, _logging.INFO) _ryx_log_handler = _logging.StreamHandler() _ryx_log_handler.setFormatter( diff --git a/ryx-python/src/lib.rs b/ryx-python/src/lib.rs index 708c7e1..ec99239 100644 --- a/ryx-python/src/lib.rs +++ b/ryx-python/src/lib.rs @@ -1014,13 +1014,18 @@ fn bulk_update<'py>( fn _init_tracing() { let level = std::env::var("RYX_LOG_LEVEL").unwrap_or_default(); - if !level.is_empty() { - let filter = format!("ryx={}", level.to_lowercase()); - let _ = tracing_subscriber::fmt() - .with_env_filter(filter) - .with_target(true) - .try_init(); + if level.is_empty() { + return; } + let lower = level.to_lowercase(); + if lower == "no_log" || lower == "off" || lower == "silent" { + return; + } + let filter = format!("ryx={}", lower); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(true) + .try_init(); } #[pymodule] diff --git a/ryx-rs/src/lib.rs b/ryx-rs/src/lib.rs index ee1cd32..cd82d72 100644 --- a/ryx-rs/src/lib.rs +++ b/ryx-rs/src/lib.rs @@ -34,7 +34,11 @@ pub fn init_tracing() { if level.is_empty() { return; } - let filter = format!("ryx={}", level.to_lowercase()); + let lower = level.to_lowercase(); + if lower == "no_log" || lower == "off" || lower == "silent" { + return; + } + let filter = format!("ryx={}", lower); let _ = tracing_subscriber::fmt() .with_env_filter(filter) .with_target(true) From 73c862e454d9478335d9d31eed54acc5cd6044b3 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 16:05:56 +0000 Subject: [PATCH 17/25] feat(migrations): extract standalone helpers and add database alias support in Autodetector --- ryx-python/ryx/migrations/autodetect.py | 120 ++++++++++++++---------- 1 file changed, 71 insertions(+), 49 deletions(-) diff --git a/ryx-python/ryx/migrations/autodetect.py b/ryx-python/ryx/migrations/autodetect.py index 235d5e9..c8572f9 100644 --- a/ryx-python/ryx/migrations/autodetect.py +++ b/ryx-python/ryx/migrations/autodetect.py @@ -63,12 +63,7 @@ def describe(self) -> str: return f"Create table '{self.table}'" def to_python(self) -> str: - cols = ", ".join( - f'ColumnState(name={c.name!r}, db_type={c.db_type!r}, ' - f'nullable={c.nullable!r}, primary_key={c.primary_key!r}, ' - f'unique={c.unique!r})' - for c in self.columns - ) + cols = ", ".join(_column_state_repr(c) for c in self.columns) return f" CreateTable(table={self.table!r}, columns=[{cols}])," @@ -85,12 +80,9 @@ def describe(self) -> str: return f"Add field '{self.column.name}' to '{self.table}'" def to_python(self) -> str: - c = self.column return ( f" AddField(table={self.table!r}, " - f"column=ColumnState(name={c.name!r}, db_type={c.db_type!r}, " - f"nullable={c.nullable!r}, primary_key={c.primary_key!r}, " - f"unique={c.unique!r}))," + f"column={_column_state_repr(self.column)})," ) @@ -101,21 +93,22 @@ def to_python(self) -> str: class AlterField: """Change a column's type or constraints.""" table: str - old_col: ColumnState new_col: ColumnState + old_col: Optional[ColumnState] = None def describe(self) -> str: + old = self.old_col + old_info = f"{old.db_type} → " if old else "" return ( - f"Alter field '{self.old_col.name}' on '{self.table}': " - f"{self.old_col.db_type} → {self.new_col.db_type}" + f"Alter field '{self.new_col.name}' on '{self.table}': " + f"{old_info}{self.new_col.db_type}" ) def to_python(self) -> str: - nc = self.new_col + oc_repr = f", old_col={_column_state_repr(self.old_col)}" if self.old_col else "" return ( f" AlterField(table={self.table!r}, " - f"new_col=ColumnState(name={nc.name!r}, db_type={nc.db_type!r}, " - f"nullable={nc.nullable!r}))," + f"new_col={_column_state_repr(self.new_col)}{oc_repr})," ) @@ -171,6 +164,59 @@ class MigrationFile: operations: List[Any] # Operation instances +### +## COLUMN SERIALIZATION +#### +def _column_state_repr(c: ColumnState) -> str: + """Return a Python repr string for a ColumnState, including all fields.""" + parts = [f"name={c.name!r}", f"db_type={c.db_type!r}"] + if c.nullable is not True: + parts.append(f"nullable={c.nullable!r}") + if c.primary_key: + parts.append(f"primary_key={c.primary_key!r}") + if c.unique: + parts.append(f"unique={c.unique!r}") + if c.default is not None: + parts.append(f"default={c.default!r}") + return f"ColumnState({', '.join(parts)})" + + +### +## STANDALONE HELPERS (shared with runner) +#### +def load_migration_file(path: Path) -> MigrationFile: + """Import and return the Migration class from a migration file path.""" + spec = importlib.util.spec_from_file_location(path.stem, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + cls = mod.Migration + return MigrationFile( + name=path.stem, + dependencies=cls.dependencies, + operations=cls.operations, + ) + + +def apply_migration_to_state(mf: MigrationFile, state: SchemaState) -> None: + """Apply the operations in a MigrationFile to a SchemaState.""" + for op in mf.operations: + if isinstance(op, CreateTable): + table = TableState(name=op.table) + for col in op.columns: + table.add_column(col) + state.add_table(table) + + elif isinstance(op, AddField): + if state.has_table(op.table): + state.tables[op.table].add_column(op.column) + + elif isinstance(op, AlterField): + if state.has_table(op.table) and state.tables[op.table].has_column(op.new_col.name): + col = state.tables[op.table].columns[op.new_col.name] + col.db_type = op.new_col.db_type + col.nullable = op.new_col.nullable + + ### ## AUTODETECTOR #### @@ -182,6 +228,8 @@ class Autodetector: migrations_dir: Path to the migrations directory (relative or absolute). Created if it doesn't exist. app_label: Optional app namespace prefix for migration names. + alias: Optional database alias. If set, files go in + ``{migrations_dir}/{alias}/``. """ def __init__( @@ -189,9 +237,12 @@ def __init__( models: List[type], migrations_dir: str = "migrations", app_label: str = "", + alias: Optional[str] = None, ) -> None: self._models = models - self._migrations_dir = Path(migrations_dir) + self._alias = alias + base = Path(migrations_dir) + self._migrations_dir = base / alias if alias else base self._app_label = app_label # Public API @@ -279,8 +330,8 @@ def _load_applied_state(self) -> SchemaState: for mf in migration_files: try: - migration = self._load_migration_file(mf) - self._apply_migration_to_state(migration, state) + migration = load_migration_file(mf) + apply_migration_to_state(migration, state) except Exception as e: import warnings warnings.warn( @@ -290,35 +341,6 @@ def _load_applied_state(self) -> SchemaState: return state - def _load_migration_file(self, path: Path) -> MigrationFile: - """Import and return the Migration class from a migration file.""" - spec = importlib.util.spec_from_file_location(path.stem, path) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - cls = mod.Migration - return MigrationFile( - name = path.stem, - dependencies = cls.dependencies, - operations = cls.operations, - ) - - def _apply_migration_to_state(self, mf: MigrationFile, state: SchemaState) -> None: - """Apply the operations in a MigrationFile to a SchemaState.""" - for op in mf.operations: - if isinstance(op, CreateTable): - table = TableState(name=op.table) - for col in op.columns: - table.add_column(col) - state.add_table(table) - - elif isinstance(op, AddField): - if state.has_table(op.table): - state.tables[op.table].add_column(op.column) - - elif isinstance(op, AlterField): - if state.has_table(op.table) and state.tables[op.table].has_column(op.new_col.name): - state.tables[op.table].columns[op.new_col.name] = op.new_col - def _changes_to_operations( self, changes: List[SchemaChange], @@ -344,8 +366,8 @@ def _changes_to_operations( if change.old_state and change.new_state: ops.append(AlterField( table = change.table, - old_col = change.old_state, new_col = change.new_state, + old_col = change.old_state, )) # Also add index creation operations for all models From a9709da644dc3e7fa27c325f41fb4634fcdaaa21 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 16:05:59 +0000 Subject: [PATCH 18/25] feat(cli): add --alias option to makemigrations command --- ryx-python/ryx/cli/commands/makemigrations.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ryx-python/ryx/cli/commands/makemigrations.py b/ryx-python/ryx/cli/commands/makemigrations.py index 2ad54ec..8df9794 100644 --- a/ryx-python/ryx/cli/commands/makemigrations.py +++ b/ryx-python/ryx/cli/commands/makemigrations.py @@ -28,6 +28,11 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: metavar="DIR", help="Migrations directory (default: migrations)", ) + parser.add_argument( + "--alias", + metavar="ALIAS", + help="Database alias to generate migrations for (creates subdirectory)", + ) parser.add_argument( "--name", metavar="NAME", help="Override migration name slug" ) @@ -49,7 +54,11 @@ async def execute(self, args: argparse.Namespace) -> int: from ryx.migrations.autodetect import Autodetector - detector = Autodetector(models=models, migrations_dir=args.dir) + detector = Autodetector( + models=models, + migrations_dir=args.dir, + alias=getattr(args, "alias", None), + ) operations = detector.detect() if not operations: From 74e0e2e371095b7b1bacf39b867ac14beae0df6c Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 16:06:37 +0000 Subject: [PATCH 19/25] feat(cli): support --no-interactive and configure migration directory in migrate command --- ryx-python/ryx/cli/commands/migrate.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ryx-python/ryx/cli/commands/migrate.py b/ryx-python/ryx/cli/commands/migrate.py index 213b5cf..d6c4092 100644 --- a/ryx-python/ryx/cli/commands/migrate.py +++ b/ryx-python/ryx/cli/commands/migrate.py @@ -38,6 +38,11 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: metavar="ALIAS", help="Run migrations for a specific database alias", ) + parser.add_argument( + "--no-interactive", + action="store_true", + help="Fail if no migration files found (for CI)", + ) async def execute(self, args: argparse.Namespace) -> int: cfg = getattr(args, "resolved_config", None) @@ -66,6 +71,8 @@ async def execute(self, args: argparse.Namespace) -> int: models, dry_run=getattr(args, "dry_run", False), alias_filter=getattr(args, "database", None) or (cfg.db_alias if cfg else None), + migrations_dir=getattr(args, "dir", "migrations"), + no_interactive=getattr(args, "no_interactive", False), ) if getattr(args, "plan", False): From 615d71d2076e9f115a7a3f551943e5b65bb88ed7 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 16:06:40 +0000 Subject: [PATCH 20/25] feat(migrations): implement file-based migrations, database tracking, and interactive CLI fallback --- ryx-python/ryx/migrations/runner.py | 278 ++++++++++++++++++++++++---- 1 file changed, 241 insertions(+), 37 deletions(-) diff --git a/ryx-python/ryx/migrations/runner.py b/ryx-python/ryx/migrations/runner.py index ea0e3ea..88d39ab 100644 --- a/ryx-python/ryx/migrations/runner.py +++ b/ryx-python/ryx/migrations/runner.py @@ -17,10 +17,19 @@ from __future__ import annotations import logging -import os -from typing import List, Optional +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Set from ryx import ryx_core as _core +from ryx.migrations.autodetect import ( + AddField, + AlterField, + CreateIndex, + CreateTable, + RunSQL, + load_migration_file, +) from ryx.migrations.state import ( ChangeKind, ColumnState, @@ -64,10 +73,14 @@ def __init__( dry_run: bool = False, backend: Optional[str] = None, alias_filter: Optional[str] = None, + migrations_dir: str = "migrations", + no_interactive: bool = False, ) -> None: self._models = models self._dry_run = dry_run self._alias_filter = alias_filter + self._migrations_dir = Path(migrations_dir) + self._no_interactive = no_interactive # 'backend' is now a fallback if we can't detect it from the pool self._fallback_backend = backend.lower() if backend else "postgres" self._ddl = None # Will be initialized per-database during migration @@ -75,6 +88,11 @@ def __init__( async def migrate(self) -> List[SchemaChange]: """Detect and apply all pending schema changes across configured databases. + Migration strategy (in order of preference): + 1. File-based migrations — reads ``.py`` files from ``{migrations_dir}/{alias}/``, + applies pending ones, tracks in ``ryx_migrations`` table. + 2. No files found — interactive prompt offering live-DDL, auto-generate, or manual. + Returns: A list of all SchemaChange objects applied across databases. """ @@ -82,26 +100,23 @@ async def migrate(self) -> List[SchemaChange]: router = get_router() - all_applied_changes = [] + all_applied_changes: List[SchemaChange] = [] aliases = _core.list_aliases() for alias in aliases: - # Filter by alias if requested via CLI if self._alias_filter and alias != self._alias_filter: continue logger.info("Running migrations for database: %s", alias) - # 1. Setup backend and DDL generator for this specific alias + # Setup backend and DDL generator for this alias try: backend = _core.get_backend(alias) logger.info("Backend for alias '%s': %s", alias, backend) except Exception as e: logger.warning( "Could not detect backend for alias %s: %s. Falling back to %s", - alias, - e, - self._fallback_backend, + alias, e, self._fallback_backend, ) backend = self._fallback_backend @@ -109,47 +124,236 @@ async def migrate(self) -> List[SchemaChange]: self._ddl = DDLGenerator(backend) self._current_alias = alias - # 2. Determine which models belong to this database - models_for_db = [] - for model in self._models: - # Routing priority: Router -> Meta.database -> default - db = None - if router: - db = router.db_for_write(model) - if not db: - db = getattr(model._meta, "database", None) - - if db == alias or (db is None and alias == "default"): - models_for_db.append(model) - + # Determine models for this alias + models_for_db = self._filter_models_for_db(alias, router) if not models_for_db: logger.debug("No models mapped to database %s, skipping.", alias) continue - # 3. Process migrations for this database - await self._ensure_migrations_table(alias) - current_state = await self._introspect_schema(alias) - target_state = project_state_from_models(models_for_db) - changes = diff_states(current_state, target_state) - - if not changes: - logger.info("Database %s is up to date.", alias) + # Determine migration directory for this alias + # Priority: {migrations_dir}/{alias}/ (multi-DB) → {migrations_dir}/ (single-DB, backward compat) + alias_specific = self._migrations_dir / alias + if alias_specific.exists() and list(alias_specific.glob("[0-9]*.py")): + alias_dir = alias_specific + has_alias_subdir = True else: - logger.info("Detected %d change(s) for %s:", len(changes), alias) - for ch in changes: - logger.info(" - [%s] %s", alias, ch) + alias_dir = self._migrations_dir + has_alias_subdir = False - if self._dry_run: - self._print_dry_run(changes, target_state, alias) + migration_files = sorted(alias_dir.glob("[0-9]*.py")) if alias_dir.exists() else [] + + if migration_files: + changes = await self._apply_file_migrations(alias, migration_files) all_applied_changes.extend(changes) - else: - await self._apply_changes(changes, target_state, alias) + elif models_for_db: + await self._handle_no_migration_files( + alias, alias_dir, models_for_db, has_alias_subdir, + ) + + # Always apply indexes, constraints, M2M tables + if not self._dry_run: await self._apply_meta_extras(alias) - all_applied_changes.extend(changes) logger.info("Multi-DB migration complete.") return all_applied_changes + def _filter_models_for_db(self, alias: str, router) -> list: + """Return models whose route maps to the given database alias.""" + models = [] + for model in self._models: + db = None + if router: + db = router.db_for_write(model) + if not db: + db = getattr(model._meta, "database", None) + if db == alias or (db is None and alias == "default"): + models.append(model) + return models + + # ------------------------------------------------------------------ + # FILE-BASED MIGRATIONS + # ------------------------------------------------------------------ + async def _apply_file_migrations( + self, + alias: str, + migration_files: list, + ) -> list: + """Apply pending migration files to *alias*, tracking in ``ryx_migrations``. + + Returns list of SchemaChange objects for reporting. + """ + await self._ensure_migrations_table(alias) + applied: Set[str] = await self._get_applied_migrations(alias) + + pending = [f for f in migration_files if f.stem not in applied] + if not pending: + logger.info("Database %s is up to date.", alias) + return [] + + changes: List[SchemaChange] = [] + for mf_path in pending: + logger.info("Applying: %s to %s", mf_path.stem, alias) + print(f"[ryx] Applying {mf_path.stem} to {alias} ...") + + if self._dry_run: + print(f" Would apply: {mf_path.stem}") + changes.append(SchemaChange( + kind=ChangeKind.CREATE_TABLE, + table=mf_path.stem, + description=f"Migration {mf_path.stem}", + )) + continue + + migration = load_migration_file(mf_path) + for op in migration.operations: + sql = self._operation_to_ddl(op) + if sql: + logger.debug("SQL: %s", sql.strip()) + from ryx.executor_helpers import raw_execute + try: + await raw_execute(sql, alias=alias) + except Exception as e: + logger.error( + "DDL failed in %s: %s — %s", mf_path.stem, sql, e + ) + raise + + await self._record_migration(alias, mf_path.stem) + print(f"[ryx] ✓ {mf_path.stem} applied") + + return changes + + def _operation_to_ddl(self, op) -> Optional[str]: + """Convert a migration Operation to a DDL SQL string.""" + if isinstance(op, CreateTable): + table = TableState(name=op.table) + for col in op.columns: + table.add_column(col) + return self._ddl.create_table(table) + + if isinstance(op, AddField): + return self._ddl.add_column(op.table, op.column) + + if isinstance(op, AlterField): + return self._ddl.alter_column(op.table, op.new_col) + + if isinstance(op, CreateIndex): + return self._ddl.create_index_from_fields( + op.table, op.fields, op.name, unique=op.unique, + ) + + if isinstance(op, RunSQL): + return op.sql + + return None + + # ------------------------------------------------------------------ + # INTERACTIVE FALLBACK (no migration files found) + # ------------------------------------------------------------------ + async def _handle_no_migration_files( + self, alias: str, alias_dir: Path, models: list, has_alias_subdir: bool = False, + ) -> None: + """Called when no migration files exist for *alias*. + + Offers the user an interactive choice, or errors if ``--no-interactive``. + """ + if self._no_interactive: + print( + f"[ryx] No migration files for '{alias}' and --no-interactive is set.\n" + f" Run 'ryx makemigrations --models --alias {alias}' first." + ) + return + + print( + f"\n[ryx] No migration files found for database '{alias}'.\n" + f" {len(models)} model(s) are not yet tracked." + ) + print() + print(" [L] Live DDL — apply changes directly (development only)") + print(" [A] Auto-generate migration files, then migrate") + print(" [M] Manual — run 'ryx makemigrations --alias ' first") + print(" [S] Skip this database for now") + print() + + choice = input("[ryx] Choice (L/A/M/S) [S]: ").strip().upper() or "S" + + if choice == "L": + logger.info("Applying live DDL for %s ...", alias) + current_state = await self._introspect_schema(alias) + target_state = project_state_from_models(models) + changes = diff_states(current_state, target_state) + if changes: + print(f"[ryx] Applying {len(changes)} live change(s) to {alias}") + await self._apply_changes(changes, target_state, alias) + + elif choice == "A": + logger.info("Auto-generating migration files for %s ...", alias) + from ryx.migrations.autodetect import Autodetector + + alias_arg = alias if has_alias_subdir or alias != "default" else None + detector = Autodetector( + models=models, + migrations_dir=str(self._migrations_dir), + alias=alias_arg, + ) + operations = detector.detect() + if not operations: + print("[ryx] No changes detected.") + return + path = detector.write_migration(operations) + print(f"[ryx] Created migration: {path}") + + migration = load_migration_file(path) + await self._ensure_migrations_table(alias) + for op in migration.operations: + sql = self._operation_to_ddl(op) + if sql: + from ryx.executor_helpers import raw_execute + await raw_execute(sql, alias=alias) + await self._record_migration(alias, path.stem) + print(f"[ryx] ✓ {path.stem} applied") + + elif choice == "M": + alias_flag = f" --alias {alias}" if alias != "default" else "" + print(f"[ryx] Run: ryx makemigrations --models {alias_flag}") + print(f"[ryx] Then run 'ryx migrate' again.") + + else: + print(f"[ryx] Skipping database '{alias}'.") + + # ------------------------------------------------------------------ + # MIGRATION TRACKING TABLE + # ------------------------------------------------------------------ + async def _get_applied_migrations(self, alias: str) -> Set[str]: + """Return set of migration names already recorded in the tracking table.""" + applied: Set[str] = set() + try: + from ryx.executor_helpers import raw_fetch + + rows = await raw_fetch( + f"SELECT name FROM {MIGRATIONS_TABLE} ORDER BY id", + alias=alias, + ) + for row in rows: + applied.add(row.get("name", "")) + except Exception: + pass + return applied + + async def _record_migration(self, alias: str, name: str) -> None: + """Insert a row into the tracking table after a migration is applied.""" + from ryx.executor_helpers import raw_execute + + ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + sql = ( + f"INSERT INTO {MIGRATIONS_TABLE} (name, applied_at) " + f"VALUES ('{name}', '{ts}')" + ) + try: + await raw_execute(sql, alias=alias) + except Exception as e: + logger.warning("Could not record migration '%s': %s", name, e) + # Schema introspection async def _introspect_schema(self, alias: str) -> SchemaState: """Query the live database to build a current SchemaState.""" From 06f5d852388b28ce02f25fe175328e36977efaf9 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 18:46:07 +0000 Subject: [PATCH 21/25] feat(cli): add color and formatting utilities for CLI output --- ryx-python/ryx/cli/style.py | 81 +++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 ryx-python/ryx/cli/style.py diff --git a/ryx-python/ryx/cli/style.py b/ryx-python/ryx/cli/style.py new file mode 100644 index 0000000..a4f62c4 --- /dev/null +++ b/ryx-python/ryx/cli/style.py @@ -0,0 +1,81 @@ +""" +Ryx CLI — color and formatting utilities. + +Usage:: + + from ryx.cli.style import PREFIX, OK, FAIL, WARN, green, cyan + + print(f"{PREFIX} {OK} Migration applied") + print(f"{PREFIX} {FAIL} {red('Error:')} something broke") +""" + +from __future__ import annotations + +import os +import sys + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_RED = "\033[31m" +_GREEN = "\033[32m" +_YELLOW = "\033[33m" +_BLUE = "\033[34m" +_MAGENTA = "\033[35m" +_CYAN = "\033[36m" +_GREY = "\033[90m" + + +def _supports_color() -> bool: + if not sys.stdout.isatty(): + return False + if os.environ.get("NO_COLOR"): + return False + term = os.environ.get("TERM", "") + if term == "dumb": + return False + return True + + +_USE_COLOR = _supports_color() + + +def _c(text: str, code: str) -> str: + return f"{code}{text}{_RESET}" if _USE_COLOR else text + + +def bold(text: str) -> str: + return _c(text, _BOLD) + + +def dim(text: str) -> str: + return _c(text, _GREY) + + +def red(text: str) -> str: + return _c(text, _RED) + + +def green(text: str) -> str: + return _c(text, _GREEN) + + +def yellow(text: str) -> str: + return _c(text, _YELLOW) + + +def blue(text: str) -> str: + return _c(text, _BLUE) + + +def magenta(text: str) -> str: + return _c(text, _MAGENTA) + + +def cyan(text: str) -> str: + return _c(text, _CYAN) + + +PREFIX = _c("[ryx]", f"{_BLUE}{_BOLD}") if _USE_COLOR else "[ryx]" +OK = _c("✓", _GREEN) if _USE_COLOR else "✓" +FAIL = _c("✗", _RED) if _USE_COLOR else "✗" +WARN = _c("⚠", _YELLOW) if _USE_COLOR else "⚠" From 5f19f0509e6d6544365fe8c8ea5c0f51a37aa92d Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 18:46:12 +0000 Subject: [PATCH 22/25] feat(migrations): inject model references in operations and auto-import them --- ryx-python/ryx/migrations/autodetect.py | 105 +++++++++++++++++++----- 1 file changed, 85 insertions(+), 20 deletions(-) diff --git a/ryx-python/ryx/migrations/autodetect.py b/ryx-python/ryx/migrations/autodetect.py index c8572f9..050b779 100644 --- a/ryx-python/ryx/migrations/autodetect.py +++ b/ryx-python/ryx/migrations/autodetect.py @@ -41,7 +41,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, List, Optional +from typing import Any, Dict, List, Optional from ryx.migrations.state import ( ColumnState, SchemaState, TableState, @@ -58,13 +58,24 @@ class CreateTable: """Create a new database table.""" table: str columns: List[ColumnState] + model: Optional[type] = None def describe(self) -> str: return f"Create table '{self.table}'" def to_python(self) -> str: - cols = ", ".join(_column_state_repr(c) for c in self.columns) - return f" CreateTable(table={self.table!r}, columns=[{cols}])," + cols = ",\n ".join(_column_state_repr(c) for c in self.columns) + lines = [ + f" CreateTable(", + f" table={self.table!r},", + f" columns=[", + f" {cols},", + f" ],", + ] + if self.model: + lines.append(f" model={self.model.__qualname__},") + lines.append(f" ),") + return "\n".join(lines) ### @@ -75,15 +86,21 @@ class AddField: """Add a column to an existing table.""" table: str column: ColumnState + model: Optional[type] = None def describe(self) -> str: return f"Add field '{self.column.name}' to '{self.table}'" def to_python(self) -> str: - return ( - f" AddField(table={self.table!r}, " - f"column={_column_state_repr(self.column)})," - ) + lines = [ + f" AddField(", + f" table={self.table!r},", + f" column={_column_state_repr(self.column)},", + ] + if self.model: + lines.append(f" model={self.model.__qualname__},") + lines.append(f" ),") + return "\n".join(lines) ### @@ -95,6 +112,7 @@ class AlterField: table: str new_col: ColumnState old_col: Optional[ColumnState] = None + model: Optional[type] = None def describe(self) -> str: old = self.old_col @@ -105,11 +123,17 @@ def describe(self) -> str: ) def to_python(self) -> str: - oc_repr = f", old_col={_column_state_repr(self.old_col)}" if self.old_col else "" - return ( - f" AlterField(table={self.table!r}, " - f"new_col={_column_state_repr(self.new_col)}{oc_repr})," - ) + lines = [ + f" AlterField(", + f" table={self.table!r},", + f" new_col={_column_state_repr(self.new_col)},", + ] + if self.old_col: + lines.append(f" old_col={_column_state_repr(self.old_col)},") + if self.model: + lines.append(f" model={self.model.__qualname__},") + lines.append(f" ),") + return "\n".join(lines) ### @@ -122,15 +146,23 @@ class CreateIndex: name: str fields: List[str] unique: bool = False + model: Optional[type] = None def describe(self) -> str: return f"Create {'unique ' if self.unique else ''}index '{self.name}' on '{self.table}'" def to_python(self) -> str: - return ( - f" CreateIndex(table={self.table!r}, name={self.name!r}, " - f"fields={self.fields!r}, unique={self.unique!r})," - ) + lines = [ + f" CreateIndex(", + f" table={self.table!r},", + f" name={self.name!r},", + f" fields={self.fields!r},", + f" unique={self.unique!r},", + ] + if self.model: + lines.append(f" model={self.model.__qualname__},") + lines.append(f" ),") + return "\n".join(lines) ### @@ -287,6 +319,10 @@ def write_migration(self, operations: List[Any]) -> Path: ops_code = "\n".join(op.to_python() for op in operations) timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + # Collect unique model imports from operations + model_imports = self._collect_model_imports(operations) + model_imports_code = "\n".join(model_imports) + "\n" if model_imports else "" + content = f'''# Auto-generated by ryx ORM — {timestamp} # Do not edit manually unless you know what you are doing. @@ -294,7 +330,7 @@ def write_migration(self, operations: List[Any]) -> Path: CreateTable, AddField, AlterField, CreateIndex, RunSQL, ) from ryx.migrations.state import ColumnState - +{model_imports_code} class Migration: """Migration {file_name} @@ -312,6 +348,20 @@ class Migration: file_path.write_text(content) return file_path + @staticmethod + def _collect_model_imports(operations: list) -> list: + """Build ``from myapp.models import Author`` lines from operation models.""" + imports: Dict[str, list] = {} + for op in operations: + model = getattr(op, "model", None) + if not model: + continue + imports.setdefault(model.__module__, []).append(model.__qualname__) + return [ + f"from {mod} import {', '.join(sorted(set(cls)))}" + for mod, cls in sorted(imports.items()) + ] + # Internal helpers def _load_applied_state(self) -> SchemaState: """Build the current state by replaying all applied migrations in order. @@ -347,20 +397,33 @@ def _changes_to_operations( target: SchemaState, ) -> List[Any]: """Convert SchemaChange diffs to Operation objects.""" + # Build table → model lookup + table_to_model: Dict[str, type] = {} + for m in self._models: + if hasattr(m, "_meta"): + table_to_model[m._meta.table_name] = m + ops: List[Any] = [] for change in changes: + cls = table_to_model.get(change.table) + if change.kind == ChangeKind.CREATE_TABLE: table = target.tables.get(change.table) if table: ops.append(CreateTable( table = change.table, columns = list(table.columns.values()), + model = cls, )) elif change.kind == ChangeKind.ADD_COLUMN: if change.new_state: - ops.append(AddField(table=change.table, column=change.new_state)) + ops.append(AddField( + table=change.table, + column=change.new_state, + model=cls, + )) elif change.kind == ChangeKind.ALTER_COLUMN: if change.old_state and change.new_state: @@ -368,6 +431,7 @@ def _changes_to_operations( table = change.table, new_col = change.new_state, old_col = change.old_state, + model = cls, )) # Also add index creation operations for all models @@ -383,15 +447,16 @@ def _changes_to_operations( name = idx.name, fields = idx.fields, unique = idx.unique, + model = model, )) for i, fields in enumerate(meta.index_together): name = f"idx_{table}_{'_'.join(fields)}_{i}" - ops.append(CreateIndex(table=table, name=name, fields=list(fields))) + ops.append(CreateIndex(table=table, name=name, fields=list(fields), model=model)) for i, fields in enumerate(meta.unique_together): name = f"uq_{table}_{'_'.join(fields)}_{i}" - ops.append(CreateIndex(table=table, name=name, fields=list(fields), unique=True)) + ops.append(CreateIndex(table=table, name=name, fields=list(fields), unique=True, model=model)) return ops From 97944044be871eb0e46bf7b2062da307a27e63f1 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 18:46:16 +0000 Subject: [PATCH 23/25] refactor(cli): use new style module in makemigrations output --- ryx-python/ryx/cli/commands/makemigrations.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ryx-python/ryx/cli/commands/makemigrations.py b/ryx-python/ryx/cli/commands/makemigrations.py index 8df9794..b8597f3 100644 --- a/ryx-python/ryx/cli/commands/makemigrations.py +++ b/ryx-python/ryx/cli/commands/makemigrations.py @@ -4,6 +4,7 @@ import sys from ryx.cli.commands.base import Command +from ryx.cli.style import PREFIX, OK, WARN, green, red, yellow class MakeMigrationsCommand(Command): @@ -49,7 +50,7 @@ async def execute(self, args: argparse.Namespace) -> int: cfg = getattr(args, "resolved_config", None) or resolve_config(args) models = self._load_models(args.models or cfg.models) if not models: - print("[ryx] No models found. Pass --models myapp.models or set [models].files in ryx.toml") + print(f"{PREFIX} {WARN} No models found. Pass {yellow('--models myapp.models')} or set [models].files in ryx.toml") return 1 from ryx.migrations.autodetect import Autodetector @@ -62,21 +63,21 @@ async def execute(self, args: argparse.Namespace) -> int: operations = detector.detect() if not operations: - print("[ryx] No changes detected.") + print(f"{PREFIX} No changes detected.") if args.check: return 0 return 0 if args.check: - print(f"[ryx] {len(operations)} change(s) detected:") + print(f"{PREFIX} {red(str(len(operations)))} change(s) detected:") for op in operations: - print(f" - {op.describe()}") + print(f" {op.describe()}") return 1 path = detector.write_migration(operations) - print(f"[ryx] Created migration: {path}") + print(f"{PREFIX} {OK} Created {green(str(path.name))}") for op in operations: - print(f" - {op.describe()}") + print(f" {op.describe()}") return 0 From 9281c54cafcd5b1610ef930d06e6dd4ccdc58b89 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 18:46:22 +0000 Subject: [PATCH 24/25] refactor(cli): cleanup imports and apply styling in migrate command --- ryx-python/ryx/cli/commands/migrate.py | 29 ++++++++++---------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/ryx-python/ryx/cli/commands/migrate.py b/ryx-python/ryx/cli/commands/migrate.py index d6c4092..6f8834a 100644 --- a/ryx-python/ryx/cli/commands/migrate.py +++ b/ryx-python/ryx/cli/commands/migrate.py @@ -1,13 +1,12 @@ from __future__ import annotations import argparse -import asyncio import sys -from pathlib import Path -from typing import List, Optional +from typing import Optional from ryx.cli.commands.base import Command from ryx.cli.config import get_config, Config +from ryx.cli.style import PREFIX, OK, FAIL, cyan, green class MigrateCommand(Command): @@ -57,7 +56,7 @@ async def execute(self, args: argparse.Namespace) -> int: # Masking the first URL for the log first_url = list(urls.values())[0] if isinstance(urls, dict) else urls - print(f"[ryx] Connecting to {self._mask_url(first_url)} ...") + print(f"{PREFIX} Connecting to {cyan(self._mask_url(first_url))} ...") import ryx @@ -75,20 +74,13 @@ async def execute(self, args: argparse.Namespace) -> int: no_interactive=getattr(args, "no_interactive", False), ) - if getattr(args, "plan", False): - # For plan, we just want to see what would happen - # In a real implementation, this would be a separate runner method - print("[ryx] --plan is active. Running in dry-run mode...") - # We could force dry_run = True here - changes = await runner.migrate() if changes: - print( - f"[ryx] Applied {len(changes)} change(s) across configured databases." - ) + count = green(str(len(changes))) + print(f"{PREFIX} {OK} Applied {count} change(s)") else: - print("[ryx] No pending migrations.") + print(f"{PREFIX} No pending migrations") return 0 @@ -136,11 +128,12 @@ def _mask_url(self, url: str) -> str: return re.sub(r"(:)[^:@/]+(@)", r"\1***\2", url) def _print_missing_url(self) -> None: + from ryx.cli.style import FAIL, yellow, cyan print( - "[ryx] No database URL found.\n" - " Set RYX_DATABASE_URL environment variable, or\n" - " pass --url postgres://user:pass@host/db, or\n" - " create ryx_settings.py with DATABASE_URL = '...'" + f"{PREFIX} {FAIL} No database URL found.\n" + f" Set {cyan('RYX_DATABASE_URL')} environment variable, or\n" + f" pass {yellow('--url postgres://user:pass@host/db')}, or\n" + f" create {cyan('ryx_settings.py')} with {cyan('DATABASE_URL')} = '...'" ) From d1dd73e22090ad3d1ab11dd2d6c52634eea8e240 Mon Sep 17 00:00:00 2001 From: #Einswilli Date: Wed, 3 Jun 2026 18:46:27 +0000 Subject: [PATCH 25/25] feat(migrations): implement flat discovery, per-alias operation routing, and styled CLI output --- ryx-python/ryx/migrations/runner.py | 184 +++++++++++++++++++--------- 1 file changed, 123 insertions(+), 61 deletions(-) diff --git a/ryx-python/ryx/migrations/runner.py b/ryx-python/ryx/migrations/runner.py index 88d39ab..3ddfb18 100644 --- a/ryx-python/ryx/migrations/runner.py +++ b/ryx-python/ryx/migrations/runner.py @@ -41,6 +41,8 @@ ) from ryx.migrations.ddl import DDLGenerator, detect_backend +from ryx.cli.style import PREFIX, OK, FAIL, WARN, green, yellow, red, cyan, magenta + logger = logging.getLogger("ryx.migrations") MIGRATIONS_TABLE = "ryx_migrations" @@ -88,10 +90,14 @@ def __init__( async def migrate(self) -> List[SchemaChange]: """Detect and apply all pending schema changes across configured databases. - Migration strategy (in order of preference): - 1. File-based migrations — reads ``.py`` files from ``{migrations_dir}/{alias}/``, - applies pending ones, tracks in ``ryx_migrations`` table. - 2. No files found — interactive prompt offering live-DDL, auto-generate, or manual. + Strategy: + 1. Discover ALL migration files recursively under ``{migrations_dir}/``, + sorted globally by numeric prefix. + 2. For each DB alias, filter operations to only those whose table + routes to this alias (via ``Meta.database`` or ``Router``). + 3. Apply only relevant operations per alias; track each file as applied + per-alias in ``ryx_migrations``. + 4. If no migration files exist at all, offer interactive fallback. Returns: A list of all SchemaChange objects applied across databases. @@ -100,6 +106,9 @@ async def migrate(self) -> List[SchemaChange]: router = get_router() + # Discover all migration files (flat, recursive) + all_migration_files = self._discover_all_migration_files() + all_applied_changes: List[SchemaChange] = [] aliases = _core.list_aliases() @@ -130,25 +139,11 @@ async def migrate(self) -> List[SchemaChange]: logger.debug("No models mapped to database %s, skipping.", alias) continue - # Determine migration directory for this alias - # Priority: {migrations_dir}/{alias}/ (multi-DB) → {migrations_dir}/ (single-DB, backward compat) - alias_specific = self._migrations_dir / alias - if alias_specific.exists() and list(alias_specific.glob("[0-9]*.py")): - alias_dir = alias_specific - has_alias_subdir = True - else: - alias_dir = self._migrations_dir - has_alias_subdir = False - - migration_files = sorted(alias_dir.glob("[0-9]*.py")) if alias_dir.exists() else [] - - if migration_files: - changes = await self._apply_file_migrations(alias, migration_files) + if all_migration_files: + changes = await self._apply_file_migrations(alias, all_migration_files) all_applied_changes.extend(changes) elif models_for_db: - await self._handle_no_migration_files( - alias, alias_dir, models_for_db, has_alias_subdir, - ) + await self._handle_no_migration_files(alias, models_for_db) # Always apply indexes, constraints, M2M tables if not self._dry_run: @@ -157,6 +152,9 @@ async def migrate(self) -> List[SchemaChange]: logger.info("Multi-DB migration complete.") return all_applied_changes + # ------------------------------------------------------------------ + # MODEL → ALIAS ROUTING HELPERS + # ------------------------------------------------------------------ def _filter_models_for_db(self, alias: str, router) -> list: """Return models whose route maps to the given database alias.""" models = [] @@ -170,33 +168,90 @@ def _filter_models_for_db(self, alias: str, router) -> list: models.append(model) return models + def _operation_is_relevant(self, op, alias: str) -> bool: + """Return True if the operation should be executed for *alias*. + + ``RunSQL`` always executes. Table-level ops (CreateTable, AddField, …) + check ``op.model`` (the Model class itself, injected at migration-file + generation time): if ``model._meta.database`` matches *alias*, + the operation is relevant. + """ + if isinstance(op, RunSQL): + return True + model = getattr(op, "model", None) + if model is None: + return True + db = getattr(model._meta, "database", None) + return db == alias or (db is None and alias == "default") + + # ------------------------------------------------------------------ + # DISCOVER ALL MIGRATION FILES (flat, recursive) + # ------------------------------------------------------------------ + def _discover_all_migration_files(self) -> List[Path]: + """Recursively find all ``[0-9]*.py`` files under ``migrations/``. + + Returns a globally sorted list (by numeric prefix), ignoring ``__pycache__``. + """ + if not self._migrations_dir.exists(): + return [] + all_files: List[Path] = [] + for f in self._migrations_dir.rglob("[0-9]*.py"): + # Skip files in __pycache__ + if "__pycache__" in f.parts: + continue + all_files.append(f) + all_files.sort(key=lambda p: p.stem) + logger.debug("Discovered %d migration file(s)", len(all_files)) + return all_files + # ------------------------------------------------------------------ - # FILE-BASED MIGRATIONS + # FILE-BASED MIGRATIONS (flat + per-alias operation routing) # ------------------------------------------------------------------ async def _apply_file_migrations( self, alias: str, - migration_files: list, + all_migration_files: List[Path], ) -> list: - """Apply pending migration files to *alias*, tracking in ``ryx_migrations``. + """Apply pending migration files to *alias*. + + Each file's operations are filtered via ``model_path`` to only + include those relevant to *alias*. Tracks applied files per-alias + in ``ryx_migrations`` using the ``alias|file_stem`` key. Returns list of SchemaChange objects for reporting. """ await self._ensure_migrations_table(alias) applied: Set[str] = await self._get_applied_migrations(alias) - pending = [f for f in migration_files if f.stem not in applied] + pending = [f for f in all_migration_files if f.stem not in applied] if not pending: logger.info("Database %s is up to date.", alias) return [] changes: List[SchemaChange] = [] for mf_path in pending: - logger.info("Applying: %s to %s", mf_path.stem, alias) - print(f"[ryx] Applying {mf_path.stem} to {alias} ...") + migration = load_migration_file(mf_path) + + # Filter to only operations relevant to this alias + relevant_ops = [ + op for op in migration.operations + if self._operation_is_relevant(op, alias) + ] + + if not relevant_ops: + logger.debug( + "Skipping %s for %s — no relevant operations", + mf_path.stem, alias, + ) + await self._record_migration(alias, mf_path.stem) + continue + + logger.info("Applying: %s to %s (%d op(s))", mf_path.stem, alias, len(relevant_ops)) + label = f"{mf_path.stem}" + print(f"{PREFIX} {cyan(label)} → {magenta(alias)} ({len(relevant_ops)} op(s))") if self._dry_run: - print(f" Would apply: {mf_path.stem}") + print(f" {yellow('(dry-run)')} would apply: {cyan(label)}") changes.append(SchemaChange( kind=ChangeKind.CREATE_TABLE, table=mf_path.stem, @@ -204,8 +259,7 @@ async def _apply_file_migrations( )) continue - migration = load_migration_file(mf_path) - for op in migration.operations: + for op in relevant_ops: sql = self._operation_to_ddl(op) if sql: logger.debug("SQL: %s", sql.strip()) @@ -219,7 +273,7 @@ async def _apply_file_migrations( raise await self._record_migration(alias, mf_path.stem) - print(f"[ryx] ✓ {mf_path.stem} applied") + print(f"{PREFIX} {OK} {green(mf_path.stem)}") return changes @@ -248,34 +302,32 @@ def _operation_to_ddl(self, op) -> Optional[str]: return None # ------------------------------------------------------------------ - # INTERACTIVE FALLBACK (no migration files found) + # INTERACTIVE FALLBACK (no migration files exist at all) # ------------------------------------------------------------------ async def _handle_no_migration_files( - self, alias: str, alias_dir: Path, models: list, has_alias_subdir: bool = False, + self, alias: str, models: list, ) -> None: - """Called when no migration files exist for *alias*. + """Called when no migration files exist anywhere under ``migrations/``. Offers the user an interactive choice, or errors if ``--no-interactive``. """ if self._no_interactive: - print( - f"[ryx] No migration files for '{alias}' and --no-interactive is set.\n" - f" Run 'ryx makemigrations --models --alias {alias}' first." - ) + print(f"{PREFIX} {WARN} No migration files exist and {yellow('--no-interactive')} is set.") + print(f" Run {yellow('ryx makemigrations --models ')} first") return print( - f"\n[ryx] No migration files found for database '{alias}'.\n" - f" {len(models)} model(s) are not yet tracked." + f"\n{PREFIX} {yellow('No migration files exist')} for database {magenta(alias)}" ) + print(f" {len(models)} model(s) are not yet tracked.") print() - print(" [L] Live DDL — apply changes directly (development only)") - print(" [A] Auto-generate migration files, then migrate") - print(" [M] Manual — run 'ryx makemigrations --alias ' first") - print(" [S] Skip this database for now") + print(f" {green('L')}ive DDL — apply changes directly (development only)") + print(f" {green('A')}uto-generate migration files, then migrate") + print(f" {green('M')}anual — run {yellow('ryx makemigrations')} first") + print(f" {green('S')}kip this database for now") print() - choice = input("[ryx] Choice (L/A/M/S) [S]: ").strip().upper() or "S" + choice = input(f" {PREFIX} Choice [S]: ").strip().upper() or "S" if choice == "L": logger.info("Applying live DDL for %s ...", alias) @@ -283,25 +335,23 @@ async def _handle_no_migration_files( target_state = project_state_from_models(models) changes = diff_states(current_state, target_state) if changes: - print(f"[ryx] Applying {len(changes)} live change(s) to {alias}") + print(f"{PREFIX} {green(str(len(changes)))} live change(s) → {magenta(alias)}") await self._apply_changes(changes, target_state, alias) elif choice == "A": logger.info("Auto-generating migration files for %s ...", alias) from ryx.migrations.autodetect import Autodetector - alias_arg = alias if has_alias_subdir or alias != "default" else None detector = Autodetector( models=models, migrations_dir=str(self._migrations_dir), - alias=alias_arg, ) operations = detector.detect() if not operations: - print("[ryx] No changes detected.") + print(f"{PREFIX} No changes detected.") return path = detector.write_migration(operations) - print(f"[ryx] Created migration: {path}") + print(f"{PREFIX} {OK} Created {green(path.name)}") migration = load_migration_file(path) await self._ensure_migrations_table(alias) @@ -311,22 +361,26 @@ async def _handle_no_migration_files( from ryx.executor_helpers import raw_execute await raw_execute(sql, alias=alias) await self._record_migration(alias, path.stem) - print(f"[ryx] ✓ {path.stem} applied") + print(f"{PREFIX} {OK} {green(path.stem)} applied") elif choice == "M": - alias_flag = f" --alias {alias}" if alias != "default" else "" - print(f"[ryx] Run: ryx makemigrations --models {alias_flag}") - print(f"[ryx] Then run 'ryx migrate' again.") + print(f"{PREFIX} Run {yellow('ryx makemigrations --models ')}") + print(f" Then run {yellow('ryx migrate')} again") else: - print(f"[ryx] Skipping database '{alias}'.") + print(f"{PREFIX} {yellow('Skipped')} database {magenta(alias)}") # ------------------------------------------------------------------ # MIGRATION TRACKING TABLE # ------------------------------------------------------------------ async def _get_applied_migrations(self, alias: str) -> Set[str]: - """Return set of migration names already recorded in the tracking table.""" + """Return set of migration file stems already applied for this alias. + + Uses ``alias|stem`` as the tracking key. Falls back to bare stems + for backward compatibility with the old format. + """ applied: Set[str] = set() + prefix = f"{alias}|" try: from ryx.executor_helpers import raw_fetch @@ -335,24 +389,32 @@ async def _get_applied_migrations(self, alias: str) -> Set[str]: alias=alias, ) for row in rows: - applied.add(row.get("name", "")) + name: str = row.get("name", "") + if name.startswith(prefix): + applied.add(name[len(prefix):]) + elif "|" not in name: + applied.add(name) # old-style bare stem except Exception: pass return applied - async def _record_migration(self, alias: str, name: str) -> None: - """Insert a row into the tracking table after a migration is applied.""" + async def _record_migration(self, alias: str, stem: str) -> None: + """Insert a row into the tracking table for an applied migration. + + Stores ``alias|stem`` as the name to allow per-alias tracking. + """ from ryx.executor_helpers import raw_execute ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + qualified = f"{alias}|{stem}" sql = ( f"INSERT INTO {MIGRATIONS_TABLE} (name, applied_at) " - f"VALUES ('{name}', '{ts}')" + f"VALUES ('{qualified}', '{ts}')" ) try: await raw_execute(sql, alias=alias) except Exception as e: - logger.warning("Could not record migration '%s': %s", name, e) + logger.warning("Could not record migration '%s': %s", stem, e) # Schema introspection async def _introspect_schema(self, alias: str) -> SchemaState: