1010
1111from pathlib import Path as FilePath
1212
13+ import frontmatter
1314from fastapi import APIRouter , Path , Query
15+ from loguru import logger
1416
15- from basic_memory .deps import EntityRepositoryV2ExternalDep , LinkResolverV2ExternalDep
17+ from basic_memory .deps import (
18+ EntityRepositoryV2ExternalDep ,
19+ FileServiceV2ExternalDep ,
20+ LinkResolverV2ExternalDep ,
21+ )
1622from basic_memory .models .knowledge import Entity
1723from basic_memory .schemas .schema import (
1824 ValidationReport ,
@@ -67,11 +73,54 @@ def _entity_to_note_data(entity: Entity) -> NoteData:
6773
6874
6975def _entity_frontmatter (entity : Entity ) -> dict :
70- """Build a frontmatter dict from an entity for schema resolution."""
71- frontmatter = dict (entity .entity_metadata ) if entity .entity_metadata else {}
76+ """Build a frontmatter dict from an entity's database metadata.
77+
78+ Used for the notes being validated — their type and schema ref are
79+ unlikely to change between syncs.
80+ """
81+ fm = dict (entity .entity_metadata ) if entity .entity_metadata else {}
7282 if entity .note_type :
73- frontmatter .setdefault ("type" , entity .note_type )
74- return frontmatter
83+ fm .setdefault ("type" , entity .note_type )
84+ return fm
85+
86+
87+ async def _schema_frontmatter_from_file (
88+ file_service : FileServiceV2ExternalDep ,
89+ entity : Entity ,
90+ ) -> dict :
91+ """Read a schema entity's frontmatter directly from its file.
92+
93+ Schema definitions (field declarations, validation mode) are the source
94+ of truth for validation. Reading from the file ensures schema-validate
95+ always uses the latest settings, even when the file watcher hasn't
96+ synced changes to entity_metadata in the database.
97+ """
98+ try :
99+ content = await file_service .read_file_content (entity .file_path )
100+ post = frontmatter .loads (content )
101+ metadata = dict (post .metadata )
102+
103+ # Trigger: file is mid-edit and missing required schema fields
104+ # Why: parse_schema_note() raises ValueError for missing entity/schema,
105+ # which would turn validation into a 500 response
106+ # Outcome: fall back to last-known-good database metadata
107+ if not metadata .get ("entity" ) or not isinstance (metadata .get ("schema" ), dict ):
108+ logger .warning (
109+ "Schema file has incomplete frontmatter, falling back to database metadata" ,
110+ file_path = entity .file_path ,
111+ )
112+ return _entity_frontmatter (entity )
113+
114+ return metadata
115+ except Exception :
116+ # Trigger: file is missing, unreadable, or has malformed frontmatter
117+ # Why: fall back to database metadata rather than failing validation entirely
118+ # Outcome: behaves like before this change — uses potentially stale data
119+ logger .warning (
120+ "Failed to read schema file, falling back to database metadata" ,
121+ file_path = entity .file_path ,
122+ )
123+ return _entity_frontmatter (entity )
75124
76125
77126# --- Validation ---
@@ -80,6 +129,7 @@ def _entity_frontmatter(entity: Entity) -> dict:
80129@router .post ("/schema/validate" , response_model = ValidationReport )
81130async def validate_schema (
82131 entity_repository : EntityRepositoryV2ExternalDep ,
132+ file_service : FileServiceV2ExternalDep ,
83133 link_resolver : LinkResolverV2ExternalDep ,
84134 project_id : str = Path (..., description = "Project external UUID" ),
85135 note_type : str | None = Query (None , description = "Note type to validate" ),
@@ -89,6 +139,10 @@ async def validate_schema(
89139
90140 Validates a specific note (by identifier) or all notes of a given type.
91141 Returns warnings/errors based on the schema's validation mode.
142+
143+ Schema definitions are read directly from their files to ensure the
144+ latest settings (validation mode, field declarations) are always used,
145+ even when file changes haven't been synced to the database yet.
92146 """
93147 results : list [NoteValidationResponse ] = []
94148
@@ -109,7 +163,7 @@ async def search_fn(query: str) -> list[dict]:
109163 query ,
110164 allow_reference_match = isinstance (schema_ref , str ) and query == schema_ref ,
111165 )
112- return [_entity_frontmatter ( e ) for e in entities ]
166+ return [await _schema_frontmatter_from_file ( file_service , e ) for e in entities ]
113167
114168 schema_def = await resolve_schema (frontmatter , search_fn )
115169 if schema_def :
@@ -145,7 +199,7 @@ async def search_fn(query: str) -> list[dict]:
145199 query ,
146200 allow_reference_match = isinstance (schema_ref , str ) and query == schema_ref ,
147201 )
148- return [_entity_frontmatter ( e ) for e in entities ]
202+ return [await _schema_frontmatter_from_file ( file_service , e ) for e in entities ]
149203
150204 schema_def = await resolve_schema (frontmatter , search_fn )
151205 if schema_def :
@@ -219,6 +273,7 @@ async def infer_schema_endpoint(
219273@router .get ("/schema/diff/{note_type}" , response_model = DriftReport )
220274async def diff_schema_endpoint (
221275 entity_repository : EntityRepositoryV2ExternalDep ,
276+ file_service : FileServiceV2ExternalDep ,
222277 note_type : str = Path (..., description = "Note type to check for drift" ),
223278 project_id : str = Path (..., description = "Project external UUID" ),
224279):
@@ -231,7 +286,7 @@ async def diff_schema_endpoint(
231286
232287 async def search_fn (query : str ) -> list [dict ]:
233288 entities = await _find_schema_entities (entity_repository , query )
234- return [_entity_frontmatter ( e ) for e in entities ]
289+ return [await _schema_frontmatter_from_file ( file_service , e ) for e in entities ]
235290
236291 # Resolve schema by note type
237292 schema_frontmatter = {"type" : note_type }
0 commit comments