"""Move data between local and cloud backends, both directions. Two components have backends today (more later — R2, DeepInfra): - inventory_db : SQLite file <-> Turso libsql - vector_db : local Qdrant <-> Qdrant Cloud Each migration is full-replace: target tables / collection are wiped first, then every row / point copied over. Idempotent. No partial sync mode (kept simple on purpose; we're a single-user side project). Public API: migrate_inventory(direction) # 'local_to_cloud' | 'cloud_to_local' migrate_vectors(direction, collection=None) InventoryReport, VectorReport # what got moved CLI: python -m src.lib.system.migrate inventory --to cloud python -m src.lib.system.migrate vectors --to local --collection library__bge-m3 """ from __future__ import annotations import time from dataclasses import dataclass, field from src.config import load_config from src.stage1_inventory.db import open_local, open_turso # --- Tables to migrate, in FK-safe insert order (parents before children) --- INVENTORY_TABLES = ( "books", "labels", "book_labels", "book_indexes", "processing_log", "llm_calls", "query_history", "printed_pages", ) # Reference tables: deterministic primary keys, safe to merge with INSERT OR IGNORE. # In merge mode we add new rows but leave existing target rows untouched. INVENTORY_REFERENCE_TABLES = frozenset(( "books", # PK book_id "labels", # PK label_id "book_labels", # composite PK (book_id, label_id) "book_indexes", # composite PK (book_id, collection_name) "printed_pages", # composite PK (book_id, pdf_page) )) # Log tables: AUTOINCREMENT integer PK; "merging" them would either overwrite # unrelated rows that share an id, or duplicate them. Merge mode skips these # tables on purpose — logs are local to where the work happened. INVENTORY_LOG_TABLES = frozenset(( "processing_log", "llm_calls", "query_history", )) BATCH_INSERT = 200 # ===================================================================== # Models # ===================================================================== @dataclass class InventoryReport: direction: str mode: str = "replace" # "merge" | "replace" rows_per_table: dict[str, int] = field(default_factory=dict) skipped_tables: list[str] = field(default_factory=list) elapsed_ms: int = 0 @property def total_rows(self) -> int: return sum(self.rows_per_table.values()) @dataclass class VectorReport: direction: str collection: str mode: str = "replace" # "merge" | "replace" points: int = 0 elapsed_ms: int = 0 @dataclass class SideSummary: """Cheap snapshot of one side's data, used for the migration preview UI.""" available: bool detail: str error: str = "" rows_per_table: dict[str, int] = field(default_factory=dict) points: int = 0 # vectors only @property def total_rows(self) -> int: return sum(self.rows_per_table.values()) # ===================================================================== # Inventory DB migration # ===================================================================== def _columns_of(conn, table: str) -> list[str]: rows = conn.execute(f"PRAGMA table_info({table})").fetchall() # PRAGMA table_info returns rows shaped (cid, name, type, notnull, dflt_value, pk). # Both backends expose name at index 1. (Our DictRow wrapper supports indexing.) return [r[1] for r in rows] def _copy_table(src_conn, dst_conn, table: str, mode: str) -> int: """Copy `table` from src to dst. - mode='replace': DELETE all target rows first, then bulk INSERT from source. - mode='merge': INSERT OR IGNORE — adds new rows, keeps existing target rows untouched. Safe to re-run. """ cols = _columns_of(src_conn, table) if not cols: return 0 col_list = ", ".join(cols) placeholders = ", ".join("?" * len(cols)) if mode == "replace": dst_conn.execute(f"DELETE FROM {table}") dst_conn.commit() insert_verb = "INSERT" else: # merge insert_verb = "INSERT OR IGNORE" src_rows = src_conn.execute(f"SELECT {col_list} FROM {table}").fetchall() if not src_rows: return 0 sql = f"{insert_verb} INTO {table} ({col_list}) VALUES ({placeholders})" payload = [tuple(r[i] for i in range(len(cols))) for r in src_rows] for i in range(0, len(payload), BATCH_INSERT): dst_conn.executemany(sql, payload[i : i + BATCH_INSERT]) dst_conn.commit() return len(payload) def migrate_inventory(direction: str, mode: str = "merge") -> InventoryReport: """direction = 'local_to_cloud' | 'cloud_to_local'. mode = 'merge' (safer default): INSERT OR IGNORE rows on every reference table; skip the AUTOINCREMENT log tables (processing_log, llm_calls, query_history) because re-using their integer ids across machines is meaningless. Idempotent. mode = 'replace': DELETE every table on the target side, then INSERT a full copy from the source. Destructive on target. Both connections are opened directly (bypassing the config dispatch) so the active backend setting doesn't matter. Schema is auto-created on the target side via init_schema(). """ if direction not in ("local_to_cloud", "cloud_to_local"): raise ValueError(f"unknown direction {direction!r}") if mode not in ("merge", "replace"): raise ValueError(f"unknown mode {mode!r}") started = time.monotonic() if direction == "local_to_cloud": src = open_local() dst = open_turso() else: src = open_turso() dst = open_local() report = InventoryReport(direction=direction, mode=mode) try: # Foreign keys are ON on local; defer them while we wipe + bulk insert. try: dst.execute("PRAGMA foreign_keys = OFF") except Exception: # noqa: BLE001 pass # turso may not support PRAGMA the same way; best effort for table in INVENTORY_TABLES: # Merge skips log tables on purpose if mode == "merge" and table in INVENTORY_LOG_TABLES: report.skipped_tables.append(table) continue try: report.rows_per_table[table] = _copy_table(src, dst, table, mode) except Exception as e: # noqa: BLE001 report.rows_per_table[table] = -1 print(f"[migrate_inventory] {table}: FAILED {type(e).__name__}: {e}") try: dst.execute("PRAGMA foreign_keys = ON") except Exception: # noqa: BLE001 pass finally: try: src.close() except Exception: pass # noqa: BLE001 try: dst.close() except Exception: pass # noqa: BLE001 report.elapsed_ms = int((time.monotonic() - started) * 1000) return report # ===================================================================== # Vector DB migration # ===================================================================== def _local_qdrant_client(): from qdrant_client import QdrantClient cfg = load_config() vc = cfg.section("vector_db") return QdrantClient(host=vc.get("host", "localhost"), port=int(vc.get("port", 6333))) def _copy_payload_indexes(src, dst, coll: str, src_info=None) -> None: """Replicate the source collection's payload indexes onto the target. Without this, filter queries on cloud (e.g. WHERE language='ar') fail with 'Index required but not found'. We read `payload_schema` from source and re-create each entry on target. Idempotent — if a target index already exists, Qdrant treats it as a no-op. """ if src_info is None: src_info = src.get_collection(coll) schema = src_info.payload_schema or {} for field_name, field_info in schema.items(): try: dst.create_payload_index( collection_name=coll, field_name=field_name, field_schema=field_info.data_type, ) except Exception as e: # noqa: BLE001 print(f"[migrate_vectors] payload index for {field_name!r} failed: {type(e).__name__}: {e}") def _cloud_qdrant_client(): import os from qdrant_client import QdrantClient from src.stage6_indexing.qdrant_io import CLOUD_TIMEOUT_S url = os.environ.get("QDRANT_URL") if not url: raise RuntimeError("QDRANT_URL not set in .env (cannot migrate vectors)") return QdrantClient(url=url, api_key=os.environ.get("QDRANT_API_KEY"), timeout=CLOUD_TIMEOUT_S) VECTOR_BATCH_LOCAL = 256 # local-target uploads can be big VECTOR_BATCH_CLOUD = 64 # cloud-target uploads need to fit in one HTTP timeout def migrate_vectors( direction: str, collection: str | None = None, batch: int | None = None, mode: str = "merge", ) -> VectorReport: """Copy every point in `collection` from source backend to target backend. mode = 'merge' (default): if the target collection doesn't exist, create it with the source's vectors_config; then upsert every source point. Existing target points with the same id get updated (Qdrant point ids are deterministic uuid5 of chunk_id, so re-running is idempotent). Existing points not present in source are kept. mode = 'replace': delete target collection if present, recreate from source's config, upsert every source point. Destructive. """ if direction not in ("local_to_cloud", "cloud_to_local"): raise ValueError(f"unknown direction {direction!r}") if mode not in ("merge", "replace"): raise ValueError(f"unknown mode {mode!r}") from src.stage6_indexing.qdrant_io import default_collection_name coll = collection or default_collection_name() started = time.monotonic() if direction == "local_to_cloud": src = _local_qdrant_client() dst = _cloud_qdrant_client() target_is_cloud = True else: src = _cloud_qdrant_client() dst = _local_qdrant_client() target_is_cloud = False # Default batch depends on which side is the upload target if batch is None: batch = VECTOR_BATCH_CLOUD if target_is_cloud else VECTOR_BATCH_LOCAL # Read source collection config (always needed if target needs creation) info = src.get_collection(coll) vectors_config = info.config.params.vectors sparse_config = info.config.params.sparse_vectors if mode == "replace": if dst.collection_exists(coll): dst.delete_collection(coll) dst.create_collection( collection_name=coll, vectors_config=vectors_config, sparse_vectors_config=sparse_config, ) _copy_payload_indexes(src, dst, coll, info) else: # merge if not dst.collection_exists(coll): dst.create_collection( collection_name=coll, vectors_config=vectors_config, sparse_vectors_config=sparse_config, ) _copy_payload_indexes(src, dst, coll, info) else: # Existing target — make sure every source index also exists on # target (idempotent; create_payload_index is a no-op if present). _copy_payload_indexes(src, dst, coll, info) # Scroll source points and upsert (upsert is idempotent on point id) moved = 0 next_offset = None while True: points, next_offset = src.scroll( collection_name=coll, limit=batch, offset=next_offset, with_payload=True, with_vectors=True, ) if not points: break from qdrant_client.http import models as qm upsert_payload = [ qm.PointStruct(id=p.id, payload=p.payload, vector=p.vector) for p in points ] # wait=False on cloud so the server queues the batch and returns # immediately — the HTTP round-trip stays well under the timeout. dst.upsert(collection_name=coll, points=upsert_payload, wait=not target_is_cloud) moved += len(points) if next_offset is None: break # On cloud target with wait=False, give the server a moment to finish # indexing before reporting. Cheap: one count() round-trip. if target_is_cloud: try: final = dst.count(collection_name=coll, exact=True).count print(f"[migrate_vectors] cloud collection now has {final} points") except Exception: # noqa: BLE001 pass elapsed_ms = int((time.monotonic() - started) * 1000) return VectorReport(direction=direction, collection=coll, mode=mode, points=moved, elapsed_ms=elapsed_ms) # ===================================================================== # Side summaries (no data is moved — just counts for the preview UI) # ===================================================================== def inventory_summary() -> dict[str, SideSummary]: """Return {'local': SideSummary, 'cloud': SideSummary} for the inventory DB. Each summary lists row counts per table (so the user can see what's at stake before migrating). Errors are caught and surfaced in the `error` field so the UI never crashes if e.g. Turso is unreachable. """ out: dict[str, SideSummary] = {} # --- Local SQLite --- try: src = open_local() rows: dict[str, int] = {} for t in INVENTORY_TABLES: try: row = src.execute(f"SELECT COUNT(*) FROM {t}").fetchone() rows[t] = int(row[0]) except Exception: # noqa: BLE001 rows[t] = 0 src.close() out["local"] = SideSummary( available=True, detail=f"{rows.get('books', 0)} books · {sum(rows.values())} total rows", rows_per_table=rows, ) except Exception as e: # noqa: BLE001 out["local"] = SideSummary(available=False, detail="local SQLite unreachable", error=f"{type(e).__name__}: {e}") # --- Cloud Turso --- try: dst = open_turso() rows = {} for t in INVENTORY_TABLES: try: row = dst.execute(f"SELECT COUNT(*) FROM {t}").fetchone() rows[t] = int(row[0]) except Exception: # noqa: BLE001 rows[t] = 0 dst.close() out["cloud"] = SideSummary( available=True, detail=f"{rows.get('books', 0)} books · {sum(rows.values())} total rows", rows_per_table=rows, ) except Exception as e: # noqa: BLE001 out["cloud"] = SideSummary(available=False, detail="Turso unreachable / not configured", error=f"{type(e).__name__}: {e}") return out def vector_summary(collection: str | None = None) -> dict[str, SideSummary]: """Return {'local': SideSummary, 'cloud': SideSummary} for a Qdrant collection.""" from src.stage6_indexing.qdrant_io import default_collection_name coll = collection or default_collection_name() out: dict[str, SideSummary] = {} # --- Local Docker Qdrant --- try: client = _local_qdrant_client() if client.collection_exists(coll): n = client.get_collection(coll).points_count out["local"] = SideSummary(available=True, detail=f"{n} points · {coll}", points=int(n or 0)) else: out["local"] = SideSummary(available=True, detail=f"collection {coll!r} not present", points=0) except Exception as e: # noqa: BLE001 out["local"] = SideSummary(available=False, detail="local Qdrant unreachable", error=f"{type(e).__name__}: {e}") # --- Cloud Qdrant Cloud --- try: client = _cloud_qdrant_client() if client.collection_exists(coll): n = client.get_collection(coll).points_count out["cloud"] = SideSummary(available=True, detail=f"{n} points · {coll}", points=int(n or 0)) else: out["cloud"] = SideSummary(available=True, detail=f"collection {coll!r} not present", points=0) except Exception as e: # noqa: BLE001 out["cloud"] = SideSummary(available=False, detail="Qdrant Cloud unreachable / not configured", error=f"{type(e).__name__}: {e}") return out