diff --git a/circuitforge_core/db/base.py b/circuitforge_core/db/base.py index baed4d0..5a0293c 100644 --- a/circuitforge_core/db/base.py +++ b/circuitforge_core/db/base.py @@ -22,7 +22,9 @@ def get_connection(db_path: Path, key: str = "") -> sqlite3.Connection: cloud_mode = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes") if cloud_mode and key: from pysqlcipher3 import dbapi2 as _sqlcipher # type: ignore - conn = _sqlcipher.connect(str(db_path)) + conn = _sqlcipher.connect(str(db_path), timeout=30) conn.execute(f"PRAGMA key='{key}'") return conn - return sqlite3.connect(str(db_path)) + # timeout=30: retry for up to 30s when another writer holds the lock (WAL mode + # allows concurrent readers but only one writer at a time). + return sqlite3.connect(str(db_path), timeout=30) diff --git a/circuitforge_core/db/migrations.py b/circuitforge_core/db/migrations.py index ddcf331..f3b3cac 100644 --- a/circuitforge_core/db/migrations.py +++ b/circuitforge_core/db/migrations.py @@ -23,5 +23,7 @@ def run_migrations(conn: sqlite3.Connection, migrations_dir: Path) -> None: if sql_file.name in applied: continue conn.executescript(sql_file.read_text()) - conn.execute("INSERT INTO _migrations (name) VALUES (?)", (sql_file.name,)) + # OR IGNORE: safe if two Store() calls race on the same DB — second writer + # just skips the insert rather than raising UNIQUE constraint failed. + conn.execute("INSERT OR IGNORE INTO _migrations (name) VALUES (?)", (sql_file.name,)) conn.commit() diff --git a/circuitforge_core/tiers/tiers.py b/circuitforge_core/tiers/tiers.py index d243ad6..3b5d2d8 100644 --- a/circuitforge_core/tiers/tiers.py +++ b/circuitforge_core/tiers/tiers.py @@ -30,26 +30,35 @@ def can_use( has_byok: bool = False, has_local_vision: bool = False, _features: dict[str, str] | None = None, + _byok_unlockable: frozenset[str] | None = None, + _local_vision_unlockable: frozenset[str] | None = None, ) -> bool: """ Return True if the given tier (and optional unlocks) can access feature. Args: - feature: Feature key string. - tier: User's current tier ("free", "paid", "premium", "ultra"). - has_byok: True if user has a configured LLM backend. - has_local_vision: True if user has a local vision model configured. - _features: Feature→min_tier map. Products pass their own dict here. - If None, all features are free. + feature: Feature key string. + tier: User's current tier ("free", "paid", "premium", "ultra"). + has_byok: True if user has a configured LLM backend. + has_local_vision: True if user has a local vision model configured. + _features: Feature→min_tier map. Products pass their own dict here. + If None, all features are free. + _byok_unlockable: Product-specific BYOK-unlockable features. + If None, uses module-level BYOK_UNLOCKABLE. + _local_vision_unlockable: Product-specific local vision unlockable features. + If None, uses module-level LOCAL_VISION_UNLOCKABLE. """ features = _features or {} + byok_unlockable = _byok_unlockable if _byok_unlockable is not None else BYOK_UNLOCKABLE + local_vision_unlockable = _local_vision_unlockable if _local_vision_unlockable is not None else LOCAL_VISION_UNLOCKABLE + if feature not in features: return True - if has_byok and feature in BYOK_UNLOCKABLE: + if has_byok and feature in byok_unlockable: return True - if has_local_vision and feature in LOCAL_VISION_UNLOCKABLE: + if has_local_vision and feature in local_vision_unlockable: return True min_tier = features[feature] @@ -64,13 +73,18 @@ def tier_label( has_byok: bool = False, has_local_vision: bool = False, _features: dict[str, str] | None = None, + _byok_unlockable: frozenset[str] | None = None, + _local_vision_unlockable: frozenset[str] | None = None, ) -> str: """Return a human-readable label for the minimum tier needed for feature.""" features = _features or {} + byok_unlockable = _byok_unlockable if _byok_unlockable is not None else BYOK_UNLOCKABLE + local_vision_unlockable = _local_vision_unlockable if _local_vision_unlockable is not None else LOCAL_VISION_UNLOCKABLE + if feature not in features: return "free" - if has_byok and feature in BYOK_UNLOCKABLE: + if has_byok and feature in byok_unlockable: return "free (BYOK)" - if has_local_vision and feature in LOCAL_VISION_UNLOCKABLE: + if has_local_vision and feature in local_vision_unlockable: return "free (local vision)" return features[feature]