1+ """MigrationManager — extracted DDL and schema migration logic from SkillStore.
2+
3+ Epic 3.6: Separates database schema creation and versioning concerns from the
4+ monolithic SkillStore, following the same extraction pattern as SkillRepository
5+ (Epic 3.2), LineageTracker (Epic 3.3), AnalysisStore (Epic 3.4), and TagSearch (Epic 3.5).
6+
7+ Architecture:
8+ - Accepts ``db_path`` for standalone use, or ``conn`` + ``lock`` for
9+ embedding inside SkillStore (shared write connection, shared mutex).
10+ - Handles schema creation via DDL execution (CREATE TABLE, CREATE INDEX)
11+ - Manages PRAGMA settings for optimal SQLite configuration
12+ - Idempotent: safe to run multiple times via IF NOT EXISTS
13+ - Future: schema versioning and upgrade paths
14+ """
15+
16+ from __future__ import annotations
17+
18+ import sqlite3
19+ import threading
20+ import time
21+ from functools import wraps
22+ from pathlib import Path
23+ from typing import Optional
24+
25+ from openspace .utils .logging import Logger
26+
27+ logger = Logger .get_logger (__name__ )
28+
29+
30+ def _db_retry (
31+ max_retries : int = 5 ,
32+ initial_delay : float = 0.1 ,
33+ backoff : float = 2.0 ,
34+ ):
35+ """Retry on transient SQLite errors with exponential backoff."""
36+
37+ def decorator (func ):
38+ @wraps (func )
39+ def wrapper (* args , ** kwargs ):
40+ delay = initial_delay
41+ for attempt in range (max_retries ):
42+ try :
43+ return func (* args , ** kwargs )
44+ except (sqlite3 .OperationalError , sqlite3 .DatabaseError ) as exc :
45+ if attempt == max_retries - 1 :
46+ logger .error (f"DB { func .__name__ } failed after { max_retries } retries: { exc } " )
47+ raise
48+ logger .warning (f"DB { func .__name__ } retry { attempt + 1 } /{ max_retries } : { exc } " )
49+ time .sleep (delay )
50+ delay *= backoff
51+
52+ return wrapper
53+
54+ return decorator
55+
56+
57+ _DDL = """
58+ CREATE TABLE IF NOT EXISTS skill_records (
59+ skill_id TEXT PRIMARY KEY,
60+ name TEXT NOT NULL,
61+ description TEXT NOT NULL DEFAULT '',
62+ path TEXT NOT NULL DEFAULT '',
63+ is_active INTEGER NOT NULL DEFAULT 1,
64+ category TEXT NOT NULL DEFAULT 'workflow',
65+ visibility TEXT NOT NULL DEFAULT 'private',
66+ creator_id TEXT NOT NULL DEFAULT '',
67+ lineage_origin TEXT NOT NULL DEFAULT 'imported',
68+ lineage_generation INTEGER NOT NULL DEFAULT 0,
69+ lineage_source_task_id TEXT,
70+ lineage_change_summary TEXT NOT NULL DEFAULT '',
71+ lineage_content_diff TEXT NOT NULL DEFAULT '',
72+ lineage_content_snapshot TEXT NOT NULL DEFAULT '{}',
73+ lineage_created_at TEXT NOT NULL,
74+ lineage_created_by TEXT NOT NULL DEFAULT '',
75+ total_selections INTEGER NOT NULL DEFAULT 0,
76+ total_applied INTEGER NOT NULL DEFAULT 0,
77+ total_completions INTEGER NOT NULL DEFAULT 0,
78+ total_fallbacks INTEGER NOT NULL DEFAULT 0,
79+ first_seen TEXT NOT NULL,
80+ last_updated TEXT NOT NULL
81+ );
82+ CREATE INDEX IF NOT EXISTS idx_sr_category ON skill_records(category);
83+ CREATE INDEX IF NOT EXISTS idx_sr_updated ON skill_records(last_updated);
84+ CREATE INDEX IF NOT EXISTS idx_sr_active ON skill_records(is_active);
85+ CREATE INDEX IF NOT EXISTS idx_sr_name ON skill_records(name);
86+
87+ CREATE TABLE IF NOT EXISTS skill_lineage_parents (
88+ skill_id TEXT NOT NULL
89+ REFERENCES skill_records(skill_id) ON DELETE CASCADE,
90+ parent_skill_id TEXT NOT NULL,
91+ PRIMARY KEY (skill_id, parent_skill_id)
92+ );
93+ CREATE INDEX IF NOT EXISTS idx_lp_parent
94+ ON skill_lineage_parents(parent_skill_id);
95+
96+ -- One row per task. task_id is UNIQUE (at most one analysis per task).
97+ CREATE TABLE IF NOT EXISTS execution_analyses (
98+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99+ task_id TEXT NOT NULL UNIQUE,
100+ timestamp TEXT NOT NULL,
101+ task_completed INTEGER NOT NULL DEFAULT 0,
102+ execution_note TEXT NOT NULL DEFAULT '',
103+ tool_issues TEXT NOT NULL DEFAULT '[]',
104+ candidate_for_evolution INTEGER NOT NULL DEFAULT 0,
105+ evolution_suggestions TEXT NOT NULL DEFAULT '[]',
106+ analyzed_by TEXT NOT NULL DEFAULT '',
107+ analyzed_at TEXT NOT NULL
108+ );
109+ CREATE INDEX IF NOT EXISTS idx_ea_task ON execution_analyses(task_id);
110+ CREATE INDEX IF NOT EXISTS idx_ea_ts ON execution_analyses(timestamp);
111+
112+ -- Per-skill judgments within an analysis.
113+ -- FK to execution_analyses.id (CASCADE delete).
114+ -- skill_id is a plain TEXT — no FK to skill_records so that
115+ -- historical judgments survive skill deletion.
116+ CREATE TABLE IF NOT EXISTS skill_judgments (
117+ id INTEGER PRIMARY KEY AUTOINCREMENT,
118+ analysis_id INTEGER NOT NULL
119+ REFERENCES execution_analyses(id) ON DELETE CASCADE,
120+ skill_id TEXT NOT NULL,
121+ skill_applied INTEGER NOT NULL DEFAULT 0,
122+ note TEXT NOT NULL DEFAULT '',
123+ UNIQUE(analysis_id, skill_id)
124+ );
125+ CREATE INDEX IF NOT EXISTS idx_sj_skill ON skill_judgments(skill_id);
126+ CREATE INDEX IF NOT EXISTS idx_sj_analysis ON skill_judgments(analysis_id);
127+
128+ CREATE TABLE IF NOT EXISTS skill_tool_deps (
129+ skill_id TEXT NOT NULL
130+ REFERENCES skill_records(skill_id) ON DELETE CASCADE,
131+ tool_key TEXT NOT NULL,
132+ critical INTEGER NOT NULL DEFAULT 0,
133+ PRIMARY KEY (skill_id, tool_key)
134+ );
135+ CREATE INDEX IF NOT EXISTS idx_td_tool ON skill_tool_deps(tool_key);
136+
137+ CREATE TABLE IF NOT EXISTS skill_tags (
138+ skill_id TEXT NOT NULL
139+ REFERENCES skill_records(skill_id) ON DELETE CASCADE,
140+ tag TEXT NOT NULL,
141+ PRIMARY KEY (skill_id, tag)
142+ );
143+ """
144+
145+
146+ class MigrationManager :
147+ """Database schema creation and migration management.
148+
149+ Extracted from ``SkillStore`` (Epic 3.6) to isolate DDL and schema
150+ versioning logic.
151+
152+ Usage (standalone)::
153+
154+ manager = MigrationManager(db_path=Path("skills.db"))
155+ manager.initialize_schema()
156+ manager.close()
157+
158+ Usage (embedded in SkillStore)::
159+
160+ # SkillStore creates us internally:
161+ # store._migrations = MigrationManager(conn=store._conn, lock=store._mu)
162+ # SkillStore calls manager.initialize_schema() during __init__
163+ """
164+
165+ def __init__ (
166+ self ,
167+ db_path : Optional [Path ] = None ,
168+ conn : Optional [sqlite3 .Connection ] = None ,
169+ lock : Optional [threading .Lock ] = None ,
170+ ) -> None :
171+ self ._owns_conn = conn is None
172+ self ._closed = False
173+ self ._mu = lock if lock is not None else threading .Lock ()
174+
175+ if conn is not None :
176+ self ._conn = conn
177+ self ._db_path = Path (":shared:" )
178+ else :
179+ if db_path is None :
180+ raise ValueError ("Either db_path or conn must be provided" )
181+ self ._db_path = Path (db_path )
182+ self ._conn = self ._make_connection (read_only = False )
183+
184+ logger .debug (f"MigrationManager ready at { self ._db_path } " )
185+
186+ # ── Connection Management ──────────────────────────────────────────
187+
188+ def _make_connection (self , * , read_only : bool ) -> sqlite3 .Connection :
189+ """Create a tuned SQLite connection with optimal PRAGMA settings."""
190+ conn = sqlite3 .connect (
191+ str (self ._db_path ),
192+ timeout = 30.0 ,
193+ check_same_thread = False ,
194+ )
195+
196+ # Performance and reliability PRAGMA settings
197+ conn .execute ("PRAGMA journal_mode=WAL" )
198+ conn .execute ("PRAGMA busy_timeout=30000" )
199+ conn .execute ("PRAGMA synchronous=NORMAL" )
200+ conn .execute ("PRAGMA cache_size=-16000" ) # 16 MB
201+ conn .execute ("PRAGMA temp_store=MEMORY" )
202+ conn .execute ("PRAGMA foreign_keys=ON" )
203+
204+ if read_only :
205+ conn .execute ("PRAGMA query_only=ON" )
206+
207+ conn .row_factory = sqlite3 .Row
208+ return conn
209+
210+ def _ensure_open (self ) -> None :
211+ if self ._closed :
212+ raise RuntimeError ("MigrationManager is closed" )
213+
214+ def close (self ) -> None :
215+ """Close the connection if we own it."""
216+ if self ._closed :
217+ return
218+
219+ if self ._owns_conn :
220+ self ._closed = True
221+ try :
222+ self ._conn .close ()
223+ except Exception :
224+ pass
225+
226+ logger .debug ("MigrationManager closed" )
227+
228+ # ── Schema Management ───────────────────────────────────────────────
229+
230+ @_db_retry ()
231+ def initialize_schema (self ) -> None :
232+ """Create all tables and indexes if they don't exist (idempotent).
233+
234+ This method executes the complete DDL script to set up the OpenSpace
235+ skill engine database schema. Safe to call multiple times due to
236+ IF NOT EXISTS clauses.
237+ """
238+ self ._ensure_open ()
239+
240+ with self ._mu :
241+ self ._conn .executescript (_DDL )
242+ self ._conn .commit ()
243+
244+ logger .debug ("Schema initialized successfully" )
245+
246+ def get_schema_version (self ) -> int :
247+ """Get the current schema version from PRAGMA user_version.
248+
249+ Returns:
250+ Current schema version (0 if not set).
251+ """
252+ self ._ensure_open ()
253+
254+ with self ._mu :
255+ cursor = self ._conn .execute ("PRAGMA user_version" )
256+ return cursor .fetchone ()[0 ]
257+
258+ def set_schema_version (self , version : int ) -> None :
259+ """Set the schema version using PRAGMA user_version.
260+
261+ Args:
262+ version: Schema version number to set.
263+ """
264+ self ._ensure_open ()
265+
266+ if version < 0 :
267+ raise ValueError (f"Schema version must be non-negative, got { version } " )
268+
269+ with self ._mu :
270+ # Note: PRAGMA user_version does not support parameterized queries
271+ # This is safe because version is validated as an integer above
272+ self ._conn .execute (f"PRAGMA user_version = { version } " )
273+ self ._conn .commit ()
274+
275+ logger .debug (f"Schema version set to { version } " )
276+
277+ def migrate_to_version (self , target_version : int ) -> None :
278+ """Migrate schema from current version to target version.
279+
280+ Args:
281+ target_version: Target schema version.
282+
283+ Raises:
284+ ValueError: If target version is lower than current version.
285+ RuntimeError: If migration path is not supported.
286+ """
287+ self ._ensure_open ()
288+
289+ current_version = self .get_schema_version ()
290+
291+ if target_version < current_version :
292+ raise ValueError (
293+ f"Cannot downgrade from version { current_version } to { target_version } "
294+ )
295+
296+ if target_version == current_version :
297+ logger .debug (f"Schema already at version { target_version } " )
298+ return
299+
300+ # For now, we only support migration from 0 to 1
301+ # Future versions will add migration paths between versions
302+ if current_version == 0 and target_version == 1 :
303+ self .initialize_schema ()
304+ self .set_schema_version (1 )
305+ logger .info (f"Migrated schema from { current_version } to { target_version } " )
306+ else :
307+ raise RuntimeError (
308+ f"Migration from version { current_version } to { target_version } not supported"
309+ )
310+
311+ def ensure_current_schema (self , expected_version : int = 1 ) -> None :
312+ """Ensure the database schema is at the expected version.
313+
314+ This is the recommended method for application initialization.
315+ It will automatically migrate if needed.
316+
317+ Args:
318+ expected_version: The version the schema should be at.
319+ """
320+ self ._ensure_open ()
321+
322+ current_version = self .get_schema_version ()
323+
324+ if current_version < expected_version :
325+ logger .info (f"Schema upgrade needed: { current_version } → { expected_version } " )
326+ self .migrate_to_version (expected_version )
327+ elif current_version > expected_version :
328+ logger .warning (
329+ f"Database schema version { current_version } is newer than expected { expected_version } "
330+ )
0 commit comments