fix(core): SQLite timeout=30, INSERT OR IGNORE migrations, parameterize tier unlockables

- get_connection(): add timeout=30 to both sqlite3 and pysqlcipher3 paths so
  concurrent writers retry instead of immediately raising OperationalError
- run_migrations(): INSERT OR IGNORE so two Store() calls racing on first boot
  don't hit a UNIQUE constraint on the migrations table
- can_use() / tier_label(): accept _byok_unlockable and _local_vision_unlockable
  overrides so products pass their own frozensets rather than sharing module-level
  constants (required for circuitforge-core to serve multiple products cleanly)
This commit is contained in:
pyr0ball 2026-03-31 10:37:51 -07:00
parent 22bad8590a
commit c027fe6137
3 changed files with 31 additions and 13 deletions

View file

@ -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") cloud_mode = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true", "yes")
if cloud_mode and key: if cloud_mode and key:
from pysqlcipher3 import dbapi2 as _sqlcipher # type: ignore 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}'") conn.execute(f"PRAGMA key='{key}'")
return conn 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)

View file

@ -23,5 +23,7 @@ def run_migrations(conn: sqlite3.Connection, migrations_dir: Path) -> None:
if sql_file.name in applied: if sql_file.name in applied:
continue continue
conn.executescript(sql_file.read_text()) 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() conn.commit()

View file

@ -30,6 +30,8 @@ def can_use(
has_byok: bool = False, has_byok: bool = False,
has_local_vision: bool = False, has_local_vision: bool = False,
_features: dict[str, str] | None = None, _features: dict[str, str] | None = None,
_byok_unlockable: frozenset[str] | None = None,
_local_vision_unlockable: frozenset[str] | None = None,
) -> bool: ) -> bool:
""" """
Return True if the given tier (and optional unlocks) can access feature. Return True if the given tier (and optional unlocks) can access feature.
@ -41,15 +43,22 @@ def can_use(
has_local_vision: True if user has a local vision model configured. has_local_vision: True if user has a local vision model configured.
_features: Featuremin_tier map. Products pass their own dict here. _features: Featuremin_tier map. Products pass their own dict here.
If None, all features are free. 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 {} 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: if feature not in features:
return True return True
if has_byok and feature in BYOK_UNLOCKABLE: if has_byok and feature in byok_unlockable:
return True return True
if has_local_vision and feature in LOCAL_VISION_UNLOCKABLE: if has_local_vision and feature in local_vision_unlockable:
return True return True
min_tier = features[feature] min_tier = features[feature]
@ -64,13 +73,18 @@ def tier_label(
has_byok: bool = False, has_byok: bool = False,
has_local_vision: bool = False, has_local_vision: bool = False,
_features: dict[str, str] | None = None, _features: dict[str, str] | None = None,
_byok_unlockable: frozenset[str] | None = None,
_local_vision_unlockable: frozenset[str] | None = None,
) -> str: ) -> str:
"""Return a human-readable label for the minimum tier needed for feature.""" """Return a human-readable label for the minimum tier needed for feature."""
features = _features or {} 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: if feature not in features:
return "free" return "free"
if has_byok and feature in BYOK_UNLOCKABLE: if has_byok and feature in byok_unlockable:
return "free (BYOK)" 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 "free (local vision)"
return features[feature] return features[feature]