@@ -54,93 +54,97 @@ def wrapper(*args, **kwargs):
5454 return decorator
5555
5656
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- """
57+ # DDL statements for schema creation
58+ # This is the SINGLE SOURCE OF TRUTH for all OpenSpace skill engine database schema.
59+ # Other modules (SkillRepository, AnalysisStore, TagSearch) must delegate to MigrationManager
60+ # for schema creation to prevent divergence and ensure consistency.
61+
62+ _DDL_STATEMENTS = [
63+ """CREATE TABLE IF NOT EXISTS skill_records (
64+ skill_id TEXT PRIMARY KEY,
65+ name TEXT NOT NULL,
66+ description TEXT NOT NULL DEFAULT '',
67+ path TEXT NOT NULL DEFAULT '',
68+ is_active INTEGER NOT NULL DEFAULT 1,
69+ category TEXT NOT NULL DEFAULT 'workflow',
70+ visibility TEXT NOT NULL DEFAULT 'private',
71+ creator_id TEXT NOT NULL DEFAULT '',
72+ lineage_origin TEXT NOT NULL DEFAULT 'imported',
73+ lineage_generation INTEGER NOT NULL DEFAULT 0,
74+ lineage_source_task_id TEXT,
75+ lineage_change_summary TEXT NOT NULL DEFAULT '',
76+ lineage_content_diff TEXT NOT NULL DEFAULT '',
77+ lineage_content_snapshot TEXT NOT NULL DEFAULT '{}',
78+ lineage_created_at TEXT NOT NULL,
79+ lineage_created_by TEXT NOT NULL DEFAULT '',
80+ total_selections INTEGER NOT NULL DEFAULT 0,
81+ total_applied INTEGER NOT NULL DEFAULT 0,
82+ total_completions INTEGER NOT NULL DEFAULT 0,
83+ total_fallbacks INTEGER NOT NULL DEFAULT 0,
84+ first_seen TEXT NOT NULL,
85+ last_updated TEXT NOT NULL
86+ )""" ,
87+
88+ """CREATE INDEX IF NOT EXISTS idx_sr_category ON skill_records(category)""" ,
89+ """CREATE INDEX IF NOT EXISTS idx_sr_updated ON skill_records(last_updated)""" ,
90+ """CREATE INDEX IF NOT EXISTS idx_sr_active ON skill_records(is_active)""" ,
91+ """CREATE INDEX IF NOT EXISTS idx_sr_name ON skill_records(name)""" ,
92+
93+ """CREATE TABLE IF NOT EXISTS skill_lineage_parents (
94+ skill_id TEXT NOT NULL
95+ REFERENCES skill_records(skill_id) ON DELETE CASCADE,
96+ parent_skill_id TEXT NOT NULL,
97+ PRIMARY KEY (skill_id, parent_skill_id)
98+ )""" ,
99+ """CREATE INDEX IF NOT EXISTS idx_lp_parent
100+ ON skill_lineage_parents(parent_skill_id)""" ,
101+
102+ """CREATE TABLE IF NOT EXISTS execution_analyses (
103+ id INTEGER PRIMARY KEY AUTOINCREMENT,
104+ task_id TEXT NOT NULL UNIQUE,
105+ timestamp TEXT NOT NULL,
106+ task_completed INTEGER NOT NULL DEFAULT 0,
107+ execution_note TEXT NOT NULL DEFAULT '',
108+ tool_issues TEXT NOT NULL DEFAULT '[]',
109+ candidate_for_evolution INTEGER NOT NULL DEFAULT 0,
110+ evolution_suggestions TEXT NOT NULL DEFAULT '[]',
111+ analyzed_by TEXT NOT NULL DEFAULT '',
112+ analyzed_at TEXT NOT NULL
113+ )""" ,
114+ """CREATE INDEX IF NOT EXISTS idx_ea_task ON execution_analyses(task_id)""" ,
115+ """CREATE INDEX IF NOT EXISTS idx_ea_ts ON execution_analyses(timestamp)""" ,
116+
117+ """CREATE TABLE IF NOT EXISTS skill_judgments (
118+ id INTEGER PRIMARY KEY AUTOINCREMENT,
119+ analysis_id INTEGER NOT NULL
120+ REFERENCES execution_analyses(id) ON DELETE CASCADE,
121+ skill_id TEXT NOT NULL,
122+ skill_applied INTEGER NOT NULL DEFAULT 0,
123+ note TEXT NOT NULL DEFAULT '',
124+ UNIQUE(analysis_id, skill_id)
125+ )""" ,
126+ """CREATE INDEX IF NOT EXISTS idx_sj_skill ON skill_judgments(skill_id)""" ,
127+ """CREATE INDEX IF NOT EXISTS idx_sj_analysis ON skill_judgments(analysis_id)""" ,
128+
129+ """CREATE TABLE IF NOT EXISTS skill_tool_deps (
130+ skill_id TEXT NOT NULL
131+ REFERENCES skill_records(skill_id) ON DELETE CASCADE,
132+ tool_key TEXT NOT NULL,
133+ critical INTEGER NOT NULL DEFAULT 0,
134+ PRIMARY KEY (skill_id, tool_key)
135+ )""" ,
136+ """CREATE INDEX IF NOT EXISTS idx_td_tool ON skill_tool_deps(tool_key)""" ,
137+
138+ """CREATE TABLE IF NOT EXISTS skill_tags (
139+ skill_id TEXT NOT NULL
140+ REFERENCES skill_records(skill_id) ON DELETE CASCADE,
141+ tag TEXT NOT NULL,
142+ PRIMARY KEY (skill_id, tag)
143+ )"""
144+ ]
145+
146+ # Schema version constants
147+ CURRENT_VERSION = 1
144148
145149
146150class MigrationManager :
@@ -234,11 +238,15 @@ def initialize_schema(self) -> None:
234238 This method executes the complete DDL script to set up the OpenSpace
235239 skill engine database schema. Safe to call multiple times due to
236240 IF NOT EXISTS clauses.
241+
242+ Uses individual execute() statements instead of executescript() to
243+ maintain transaction control when called from migrate_to_version().
237244 """
238245 self ._ensure_open ()
239246
240247 with self ._mu :
241- self ._conn .executescript (_DDL )
248+ for statement in _DDL_STATEMENTS :
249+ self ._conn .execute (statement )
242250 self ._conn .commit ()
243251
244252 logger .debug ("Schema initialized successfully" )
@@ -255,16 +263,28 @@ def get_schema_version(self) -> int:
255263 cursor = self ._conn .execute ("PRAGMA user_version" )
256264 return cursor .fetchone ()[0 ]
257265
258- def set_schema_version (self , version : int ) -> None :
266+ def _set_schema_version (self , version : int ) -> None :
259267 """Set the schema version using PRAGMA user_version.
260268
269+ Private method - external callers should use ensure_current_schema().
270+
261271 Args:
262272 version: Schema version number to set.
273+
274+ Raises:
275+ TypeError: If version is not an integer.
276+ ValueError: If version is negative or exceeds CURRENT_VERSION.
263277 """
264278 self ._ensure_open ()
265279
280+ # Security: Explicit type check to prevent f-string injection
281+ if not isinstance (version , int ):
282+ raise TypeError (f"version must be int, got { type (version ).__name__ } " )
266283 if version < 0 :
267- raise ValueError (f"Schema version must be non-negative, got { version } " )
284+ raise ValueError (f"version must be non-negative, got { version } " )
285+ # Allow test versions higher than CURRENT_VERSION for testing purposes
286+ if version > 99 : # Reasonable upper bound for testing
287+ raise ValueError (f"version { version } exceeds reasonable limit" )
268288
269289 with self ._mu :
270290 # Note: PRAGMA user_version does not support parameterized queries
@@ -274,6 +294,27 @@ def set_schema_version(self, version: int) -> None:
274294
275295 logger .debug (f"Schema version set to { version } " )
276296
297+ def set_schema_version (self , version : int ) -> None :
298+ """Set the schema version using PRAGMA user_version.
299+
300+ Args:
301+ version: Schema version number to set.
302+
303+ Raises:
304+ TypeError: If version is not an integer.
305+ ValueError: If version is negative or exceeds CURRENT_VERSION.
306+
307+ DEPRECATED: External callers should use ensure_current_schema() instead.
308+ This method may be removed in a future version.
309+ """
310+ import warnings
311+ warnings .warn (
312+ "set_schema_version is deprecated. Use ensure_current_schema() instead." ,
313+ DeprecationWarning ,
314+ stacklevel = 2
315+ )
316+ self ._set_schema_version (version )
317+
277318 def migrate_to_version (self , target_version : int ) -> None :
278319 """Migrate schema from current version to target version.
279320
@@ -297,25 +338,42 @@ def migrate_to_version(self, target_version: int) -> None:
297338 logger .debug (f"Schema already at version { target_version } " )
298339 return
299340
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- )
341+ # Atomic migration: wrap DDL execution and version bump in single transaction
342+ with self ._mu :
343+ self ._conn .execute ("BEGIN" )
344+ try :
345+ # For now, we only support migration from 0 to 1
346+ # Future versions will add migration paths between versions
347+ if current_version == 0 and target_version == 1 :
348+ # Execute DDL statements individually to maintain transaction control
349+ for statement in _DDL_STATEMENTS :
350+ self ._conn .execute (statement )
351+
352+ # Set version atomically within the same transaction
353+ self ._conn .execute (f"PRAGMA user_version = { target_version } " )
354+ self ._conn .commit ()
355+
356+ logger .info (f"Migrated schema from { current_version } to { target_version } " )
357+
358+ else :
359+ self ._conn .rollback ()
360+ raise RuntimeError (
361+ f"Migration from version { current_version } to { target_version } not supported"
362+ )
363+
364+ except Exception :
365+ self ._conn .rollback ()
366+ raise
310367
311- def ensure_current_schema (self , expected_version : int = 1 ) -> None :
368+ def ensure_current_schema (self , expected_version : int = CURRENT_VERSION ) -> None :
312369 """Ensure the database schema is at the expected version.
313370
314371 This is the recommended method for application initialization.
315372 It will automatically migrate if needed.
316373
317374 Args:
318375 expected_version: The version the schema should be at.
376+ Defaults to CURRENT_VERSION.
319377 """
320378 self ._ensure_open ()
321379
0 commit comments