diff --git a/docs/DOMAIN_MODEL.md b/docs/DOMAIN_MODEL.md index efb43754a..cacf8a986 100644 --- a/docs/DOMAIN_MODEL.md +++ b/docs/DOMAIN_MODEL.md @@ -49,9 +49,8 @@ which graph and search projections attach. - `external_id` is the stable external/API identity and must survive ordinary updates and moves. - `file_path` is the current project-relative storage location. It is unique within a project and may change when the resource moves. -- `permalink` is the human/agent-facing semantic address for Markdown content. It is - project-scoped, may be absent when permalinks are disabled or inapplicable, and changes only - according to the configured move and permalink policies. +- `permalink` is the required human/agent-facing semantic address for Markdown content. It is + project-scoped and changes only according to the configured move and permalink policies. - Non-Markdown entities always have `permalink=None`. `external_id` is their stable API identity, and `file_path` locates the stored resource; a database-only permalink would not round-trip through the source file. diff --git a/plugins/codex/skills/bm-checkpoint/SKILL.md b/plugins/codex/skills/bm-checkpoint/SKILL.md index 07c51df4c..2a5d122c6 100644 --- a/plugins/codex/skills/bm-checkpoint/SKILL.md +++ b/plugins/codex/skills/bm-checkpoint/SKILL.md @@ -208,6 +208,5 @@ $bm-orient "" ``` Choose the first non-empty returned value in this order: `permalink`, -`file_path`, then `title`. This preserves a direct resume cursor when the Basic -Memory project has permalinks disabled. Use the returned value verbatim; never -construct or guess a permalink or file path. +`file_path`, then `title`. Use the returned value verbatim; never construct or +guess a permalink or file path. diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 35f1f230e..ca80087ee 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -90,6 +90,7 @@ def load_config(self) -> BasicMemoryConfig: "project_modes", "cloud_projects", "cloud_mode", + "disable_permalinks", } needs_resave = bool(stale_keys & file_data.keys()) diff --git a/src/basic_memory/config_models.py b/src/basic_memory/config_models.py index fb8b4991b..5d4be7763 100644 --- a/src/basic_memory/config_models.py +++ b/src/basic_memory/config_models.py @@ -600,11 +600,6 @@ def __init__(self, **data: Any) -> None: ... description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks", ) - disable_permalinks: bool = Field( - default=False, - description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.", - ) - write_note_overwrite_default: bool = Field( default=False, description=( @@ -637,7 +632,7 @@ def __init__(self, **data: Any) -> None: ... ensure_frontmatter_on_sync: bool = Field( default=True, - description="Ensure markdown files have frontmatter during sync by adding derived title/type/permalink when missing. When combined with disable_permalinks=True, this setting takes precedence for missing-frontmatter files and still writes permalinks.", + description="Ensure markdown files have complete frontmatter during sync by adding derived title and type when missing. Canonical permalinks are always added.", ) permalinks_include_project: bool = Field( diff --git a/src/basic_memory/deps/services.py b/src/basic_memory/deps/services.py index 6fb7ced94..69c44bfdd 100644 --- a/src/basic_memory/deps/services.py +++ b/src/basic_memory/deps/services.py @@ -344,7 +344,6 @@ async def get_note_content_mutation_service( ), write_repositories=accepted_note_repositories, move_policy=AcceptedNoteMutationMovePolicy( - disable_permalinks=app_config.disable_permalinks, update_permalinks_on_move=app_config.update_permalinks_on_move, ), # Local filesystem is the source of truth: reject a create when the diff --git a/src/basic_memory/index/local_moves.py b/src/basic_memory/index/local_moves.py index 39798c537..b868b213d 100644 --- a/src/basic_memory/index/local_moves.py +++ b/src/basic_memory/index/local_moves.py @@ -118,7 +118,7 @@ async def plan_moved_file_content( app_config = self.entity_service.app_config if app_config is None: raise RuntimeError("local move content updates require app_config") - if app_config.disable_permalinks or not app_config.update_permalinks_on_move: + if not app_config.update_permalinks_on_move: return None if not self.file_service.is_markdown(moved_file.new_path): return None diff --git a/src/basic_memory/indexing/accepted_note_mutation_runner.py b/src/basic_memory/indexing/accepted_note_mutation_runner.py index 6336467a7..38bcea9f5 100644 --- a/src/basic_memory/indexing/accepted_note_mutation_runner.py +++ b/src/basic_memory/indexing/accepted_note_mutation_runner.py @@ -198,13 +198,10 @@ class AcceptedNoteDeleteMutation: class AcceptedNoteMutationMovePolicy: """Permalink policy for DB-first accepted note moves.""" - disable_permalinks: bool update_permalinks_on_move: bool def should_update_permalink(self, entity: Entity) -> bool: - return not self.disable_permalinks and ( - self.update_permalinks_on_move or entity.permalink is None - ) + return self.update_permalinks_on_move or entity.permalink is None def accepted_note_mutation_utc_now() -> datetime: diff --git a/src/basic_memory/indexing/batch_indexer.py b/src/basic_memory/indexing/batch_indexer.py index b5618071b..36c392721 100644 --- a/src/basic_memory/indexing/batch_indexer.py +++ b/src/basic_memory/indexing/batch_indexer.py @@ -306,7 +306,11 @@ async def index_markdown_file( if path != file.path and permalink } with logfire.span("index.markdown_file.normalize", path=file.path): - prepared = await self._normalize_markdown_file(prepared, reserved_permalinks) + prepared = await self._normalize_markdown_file( + prepared, + reserved_permalinks, + existing_permalink=existing_permalink_by_path.get(file.path), + ) existing_permalink_by_path[file.path] = prepared.markdown.frontmatter.permalink with logfire.span("index.markdown_file.persist", path=file.path, is_new=new): @@ -416,6 +420,7 @@ async def _normalize_markdown_batch( normalized[path] = await self._normalize_markdown_file( prepared_markdown[path], reserved_permalinks, + existing_permalink=existing_permalink_by_path.get(path), ) existing_permalink_by_path[path] = normalized[path].markdown.frontmatter.permalink except Exception as exc: @@ -428,22 +433,29 @@ async def _normalize_markdown_file( self, prepared: _PreparedMarkdownFile, reserved_permalinks: set[str], + *, + existing_permalink: str | None = None, ) -> _PreparedMarkdownFile: final_checksum = prepared.final_checksum final_content = prepared.content - final_permalink = await self._resolve_batch_permalink(prepared, reserved_permalinks) + final_permalink = await self._resolve_batch_permalink( + prepared, + reserved_permalinks, + existing_permalink=existing_permalink, + ) # Trigger: markdown file has no frontmatter and sync enforcement is enabled. # Why: downstream indexing relies on normalized metadata and stable permalinks. # Outcome: write derived metadata back through the storage-agnostic writer. # A "malformed" file matches neither branch: its fenced block is not YAML, # so no rewrite can know which bytes were metadata; it is indexed as-is. - if prepared.frontmatter_state == "absent" and self.app_config.ensure_frontmatter_on_sync: - frontmatter_updates = { - "title": prepared.markdown.frontmatter.title, - "type": prepared.markdown.frontmatter.type, - "permalink": final_permalink, - } + if prepared.frontmatter_state == "absent": + frontmatter_updates = {"permalink": final_permalink} + if self.app_config.ensure_frontmatter_on_sync: + frontmatter_updates.update( + title=prepared.markdown.frontmatter.title, + type=prepared.markdown.frontmatter.type, + ) write_result = await self.file_writer.write_frontmatter( IndexFrontmatterUpdate(path=prepared.file.path, metadata=frontmatter_updates) ) @@ -456,7 +468,6 @@ async def _normalize_markdown_file( # Outcome: only the permalink field is updated when it actually differs. elif ( prepared.frontmatter_state == "present" - and not self.app_config.disable_permalinks and final_permalink != prepared.markdown.frontmatter.permalink ): prepared.markdown.frontmatter.metadata["permalink"] = final_permalink @@ -468,6 +479,10 @@ async def _normalize_markdown_file( ) final_checksum = write_result.checksum final_content = write_result.content + elif prepared.frontmatter_state == "malformed": + # A leading non-YAML fence is authored body content (#1451), so it is + # never rewritten. Its indexed Markdown identity is still mandatory. + prepared.markdown.frontmatter.metadata["permalink"] = final_permalink return _PreparedMarkdownFile( file=prepared.file, @@ -481,20 +496,19 @@ async def _resolve_batch_permalink( self, prepared: _PreparedMarkdownFile, reserved_permalinks: set[str], - ) -> str | None: - should_resolve_permalink = ( - prepared.frontmatter_state == "absent" and self.app_config.ensure_frontmatter_on_sync - ) or (prepared.frontmatter_state == "present" and not self.app_config.disable_permalinks) - if not should_resolve_permalink: - permalink = prepared.markdown.frontmatter.permalink - if permalink: - reserved_permalinks.add(permalink) - return permalink - - desired_permalink = await self.entity_service.resolve_permalink( - prepared.file.path, - markdown=prepared.markdown, - skip_conflict_check=True, + *, + existing_permalink: str | None, + ) -> str: + # Malformed frontmatter cannot carry canonical identity on disk. Preserve the + # indexed permalink across moves unless the source becomes safely rewriteable. + desired_permalink = ( + existing_permalink + if prepared.frontmatter_state == "malformed" and existing_permalink is not None + else await self.entity_service.resolve_permalink( + prepared.file.path, + markdown=prepared.markdown, + skip_conflict_check=True, + ) ) return self._reserve_batch_permalink(desired_permalink, reserved_permalinks) @@ -1052,18 +1066,11 @@ async def _reconcile_persisted_permalink( prepared: _PreparedMarkdownFile, entity: Entity, ) -> _PreparedMarkdownFile: - # Trigger: the source file started without frontmatter and sync is configured - # to leave frontmatterless files alone. - # Why: upsert may still assign a DB permalink even when disk content should stay untouched. - # Outcome: skip reconciliation writes that would silently inject frontmatter. - rewrite_allowed = ( - self.app_config.ensure_frontmatter_on_sync - if prepared.frontmatter_state == "absent" - else prepared.frontmatter_state == "present" - ) + # Malformed frontmatter cannot be rewritten safely because the leading fence may be + # authored body content. Valid and absent frontmatter have already accepted canonical + # permalink management, so conflict suffixes must be reconciled back to the file. if ( - self.app_config.disable_permalinks - or not rewrite_allowed + prepared.frontmatter_state == "malformed" or entity.permalink is None or entity.permalink == prepared.markdown.frontmatter.permalink ): diff --git a/src/basic_memory/repository/entity_repository.py b/src/basic_memory/repository/entity_repository.py index a35663141..5c42b752b 100644 --- a/src/basic_memory/repository/entity_repository.py +++ b/src/basic_memory/repository/entity_repository.py @@ -397,8 +397,17 @@ async def get_by_file_paths( Relation.from_id == Entity.id, Relation.generation == 0, ) + # A pre-v0.24 Markdown row may have a current checksum but no canonical + # permalink. Mask its checksum so the normal scan reads the unchanged file + # once and writes the required identity into both Markdown and indexed state. + legacy_markdown_identity = (Entity.content_type == "text/markdown") & Entity.permalink.is_( + None + ) indexed_checksum = case( - (or_(publication_pending, legacy_relation_pending), None), + ( + or_(publication_pending, legacy_relation_pending, legacy_markdown_identity), + None, + ), else_=Entity.checksum, ).label("checksum") query = select(Entity.file_path, indexed_checksum).where( # pragma: no cover diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index d142b0cd6..794a82a1a 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -157,10 +157,7 @@ def _sync_prepared_schema_state( source_schema.content_type = prepared.entity_fields.content_type source_schema.entity_metadata = prepared.entity_fields.entity_metadata - if self.app_config and self.app_config.disable_permalinks: - source_schema._permalink = "" - else: - source_schema._permalink = prepared.entity_fields.permalink + source_schema._permalink = prepared.entity_fields.permalink async def _read_persisted_write_snapshot( self, @@ -687,6 +684,8 @@ async def create_entity_from_markdown( Uses UPSERT approach to handle permalink/file_path conflicts cleanly. """ logger.debug(f"Creating entity: {markdown.frontmatter.title} file_path: {file_path}") + if markdown.frontmatter.permalink is None: + raise ValueError(f"Markdown note is missing a canonical permalink: {file_path}") model = entity_model_from_markdown( file_path, markdown, project_id=self.repository.project_id ) @@ -764,8 +763,9 @@ def _apply_markdown_entity_fields( entity.title = markdown.frontmatter.title entity.note_type = markdown.frontmatter.type - if markdown.frontmatter.permalink is not None: - entity.permalink = markdown.frontmatter.permalink + if markdown.frontmatter.permalink is None: + raise ValueError(f"Markdown note is missing a canonical permalink: {file_path}") + entity.permalink = markdown.frontmatter.permalink entity.file_path = file_path.as_posix() entity.content_type = "text/markdown" entity.created_at = markdown.created @@ -1020,10 +1020,8 @@ async def move_entity( # 6. Prepare database updates updates = {"file_path": destination_path} - # 7. Update permalink if configured or if entity has null permalink (unless disabled) - if not app_config.disable_permalinks and ( - app_config.update_permalinks_on_move or old_permalink is None - ): + # 7. Update permalink if configured or repair a legacy null permalink. + if app_config.update_permalinks_on_move or old_permalink is None: # Generate new permalink from destination path new_permalink = await self.resolve_permalink(destination_path) diff --git a/src/basic_memory/services/initialization.py b/src/basic_memory/services/initialization.py index a5524d7e7..01d9ba7fe 100644 --- a/src/basic_memory/services/initialization.py +++ b/src/basic_memory/services/initialization.py @@ -367,16 +367,6 @@ async def initialize_app( Args: app_config: The Basic Memory project configuration """ - # Trigger: frontmatter enforcement is enabled while permalink generation is disabled - # Why: missing-frontmatter indexing needs canonical permalinks for deterministic output - # Outcome: log startup precedence so behavior is explicit to operators - if app_config.ensure_frontmatter_on_sync and app_config.disable_permalinks: - logger.warning( - "Config precedence: ensure_frontmatter_on_sync=True overrides " - "disable_permalinks=True for markdown files missing frontmatter during indexing; " - "permalinks will be written." - ) - # Trigger: cloud/stateless deployment (skip_local_initialization — either # for_cloud_tenant's skip_initialization_sync or BASIC_MEMORY_CLOUD_MODE). # Why: cloud manages its own schema and per-tenant projects from the database. diff --git a/src/basic_memory/services/note_preparation.py b/src/basic_memory/services/note_preparation.py index e1e5ff4bb..7bf39bb75 100644 --- a/src/basic_memory/services/note_preparation.py +++ b/src/basic_memory/services/note_preparation.py @@ -72,7 +72,7 @@ class PreparedEntityFields: note_type: str entity_metadata: EntityMetadata content_type: str - permalink: str | None + permalink: str file_path: str created_at: datetime updated_at: datetime @@ -124,7 +124,7 @@ class PreparedEntityMove: file_path: Path markdown_content: str search_content: str - permalink: str | None + permalink: str observations: tuple[AcceptedObservationWrite, ...] = () sections: tuple[AcceptedSectionWrite, ...] = () relations: tuple[AcceptedRelationWrite, ...] = () @@ -307,10 +307,7 @@ async def _resolve_schema_permalink( content_markdown: EntityMarkdown | None = None, skip_conflict_check: bool = False, session: AsyncSession | None = None, -) -> str | None: - if dependencies.app_config and dependencies.app_config.disable_permalinks: - schema._permalink = current_permalink or "" - return current_permalink +) -> str: if current_permalink and not (content_markdown and content_markdown.frontmatter.permalink): schema._permalink = current_permalink return current_permalink @@ -329,7 +326,7 @@ def _build_entity_fields( *, file_path: Path, content_type: str, - permalink: str | None, + permalink: str, entity_markdown: EntityMarkdown, ) -> PreparedEntityFields: if entity_markdown.created is None or entity_markdown.modified is None: # pragma: no cover @@ -357,7 +354,7 @@ async def _build_prepared_write( file_path: Path, markdown_content: str, content_type: str, - permalink: str | None, + permalink: str, preserved_created_at: datetime | None = None, ) -> PreparedEntityWrite: entity_markdown = await dependencies.entity_parser.parse_markdown_content( @@ -806,22 +803,30 @@ async def prepare_edit_entity_content( title = _coerce_to_string(content_frontmatter["title"]) if "type" in content_frontmatter: note_type = _coerce_to_string(content_frontmatter["type"]) - if dependencies.app_config and dependencies.app_config.disable_permalinks: - permalink = entity.permalink - else: - content_permalink = _frontmatter_permalink(content_frontmatter.get("permalink")) - if content_permalink is not None: - permalink = await resolve_permalink( - dependencies, - file_path, - _build_frontmatter_markdown(title, note_type, content_permalink), - skip_conflict_check=skip_conflict_check, - session=session, - ) + content_permalink = _frontmatter_permalink(content_frontmatter.get("permalink")) + if content_permalink is not None: + permalink = await resolve_permalink( + dependencies, + file_path, + _build_frontmatter_markdown(title, note_type, content_permalink), + skip_conflict_check=skip_conflict_check, + session=session, + ) normalized_metadata = normalize_frontmatter_metadata(content_frontmatter or {}) metadata = { key: value for key, value in normalized_metadata.items() if value is not None } or None + if permalink is None: + permalink = await resolve_permalink( + dependencies, + file_path, + skip_conflict_check=skip_conflict_check, + session=session, + ) + post = frontmatter.loads(markdown_content) + if post.metadata.get("permalink") != permalink: + post.metadata["permalink"] = permalink + markdown_content = dump_frontmatter(post) reconciliation = reconcile_prepared_edit_title_from_h1( original_markdown=current_content, markdown_content=markdown_content, @@ -853,29 +858,36 @@ async def prepare_move_entity_content( file_path = Path(normalize_note_move_destination_path(destination_path)) markdown_content = current_content permalink = entity.permalink + entity_markdown = await dependencies.entity_parser.parse_markdown_content( + file_path=file_path, + content=markdown_content, + ctime=entity.created_at.timestamp() if entity.created_at is not None else None, + ) update_permalink = should_update_permalink if update_permalink is None: - disable_permalinks = bool( - dependencies.app_config and dependencies.app_config.disable_permalinks - ) update_permalinks_on_move = bool( dependencies.app_config and dependencies.app_config.update_permalinks_on_move ) - update_permalink = not disable_permalinks and ( - update_permalinks_on_move or entity.permalink is None - ) - if update_permalink: + update_permalink = update_permalinks_on_move or entity.permalink is None + # A malformed leading fence is canonical body content, not writable metadata. + # Preserve its established semantic identity across moves; legacy null rows still + # receive a database permalink so every indexed Markdown note has an identity. + if update_permalink and (entity_markdown.frontmatter_state != "malformed" or permalink is None): permalink = await resolve_permalink( dependencies, file_path, current_file_path=entity.file_path, session=session ) + if permalink is None: # pragma: no cover - update_permalink repairs legacy null rows + raise ValueError(f"Markdown note is missing a canonical permalink: {entity.file_path}") + if entity_markdown.frontmatter_state != "malformed": post = frontmatter.loads(markdown_content) - post.metadata["permalink"] = permalink - markdown_content = dump_frontmatter(post) - entity_markdown = await dependencies.entity_parser.parse_markdown_content( - file_path=file_path, - content=markdown_content, - ctime=entity.created_at.timestamp() if entity.created_at is not None else None, - ) + if post.metadata.get("permalink") != permalink: + post.metadata["permalink"] = permalink + markdown_content = dump_frontmatter(post) + entity_markdown = await dependencies.entity_parser.parse_markdown_content( + file_path=file_path, + content=markdown_content, + ctime=entity.created_at.timestamp() if entity.created_at is not None else None, + ) return PreparedEntityMove( file_path=file_path, markdown_content=markdown_content, diff --git a/test-int/test_mandatory_permalinks_integration.py b/test-int/test_mandatory_permalinks_integration.py new file mode 100644 index 000000000..fa18e82ac --- /dev/null +++ b/test-int/test_mandatory_permalinks_integration.py @@ -0,0 +1,285 @@ +"""Integration coverage for mandatory Markdown note permalinks.""" + +import json + +import pytest + +from basic_memory import db +from basic_memory.file_utils import compute_checksum +from basic_memory.index.local_project import ( + LocalProjectIndexRuntimeFactory, + run_local_project_index_for_project, +) +from basic_memory.models import Project +from basic_memory.repository.entity_repository import EntityRepository + + +@pytest.mark.asyncio +async def test_reindex_reserves_existing_malformed_identity_before_new_colliding_note( + test_project: Project, + project_config, + engine_factory, +) -> None: + malformed_path = project_config.home / "same-note.md" + original_bytes = b"---\ntitle: [unclosed\n---\n\n# Existing note\n" + malformed_path.write_bytes(original_bytes) + await run_local_project_index_for_project( + test_project, + runtime_factory=LocalProjectIndexRuntimeFactory(batch_size=10), + force_full=True, + ) + _, session_maker = engine_factory + repository = EntityRepository(project_id=test_project.id) + async with db.scoped_session(session_maker) as session: + original = await repository.get_by_file_path(session, "same-note.md") + assert original is not None + original_permalink = original.permalink + assert original_permalink is not None + + # The new path sorts first but must not take the established semantic address. + new_path = project_config.home / "same note.md" + new_path.write_text("# New note\n", encoding="utf-8") + original_bytes += b"\nAn edit to the existing note.\n" + malformed_path.write_bytes(original_bytes) + await run_local_project_index_for_project( + test_project, + runtime_factory=LocalProjectIndexRuntimeFactory(batch_size=10), + force_full=True, + ) + async with db.scoped_session(session_maker) as session: + existing = await repository.get_by_file_path(session, "same-note.md") + new = await repository.get_by_file_path(session, "same note.md") + assert existing is not None + assert new is not None + assert existing.permalink == original_permalink + assert new.permalink == f"{original_permalink}-1" + assert malformed_path.read_bytes() == original_bytes + assert f"permalink: {new.permalink}" in new_path.read_text(encoding="utf-8") + + +@pytest.mark.asyncio +async def test_project_index_adds_permalink_when_optional_frontmatter_is_disabled( + test_project: Project, + project_config, + engine_factory, + app_config, + config_manager, + monkeypatch, +) -> None: + """Legacy opt-outs cannot disable identity or block the real config/index flow.""" + app_config.ensure_frontmatter_on_sync = False + config_manager.save_config(app_config) + legacy_config = json.loads(config_manager.config_file.read_text(encoding="utf-8")) + legacy_config["disable_permalinks"] = True + config_manager.config_file.write_text(json.dumps(legacy_config), encoding="utf-8") + monkeypatch.setenv("BASIC_MEMORY_DISABLE_PERMALINKS", "true") + loaded_config = config_manager.load_config() + assert "disable_permalinks" not in loaded_config.model_dump() + + note_path = project_config.home / "legacy-note.md" + note_path.write_text("# Legacy Note\n\nExisting body.\n", encoding="utf-8") + + result = await run_local_project_index_for_project( + test_project, + runtime_factory=LocalProjectIndexRuntimeFactory(batch_size=10), + force_full=True, + ) + + expected_permalink = f"{test_project.permalink}/legacy-note" + assert result.enqueued_files == 1 + indexed_content = note_path.read_text(encoding="utf-8") + assert f"permalink: {expected_permalink}" in indexed_content + assert "title:" not in indexed_content + assert "type:" not in indexed_content + + _, session_maker = engine_factory + entity_repository = EntityRepository(project_id=test_project.id) + async with db.scoped_session(session_maker) as session: + entity = await entity_repository.get_by_file_path(session, "legacy-note.md") + + assert entity is not None + assert entity.permalink == expected_permalink + + +@pytest.mark.asyncio +async def test_project_index_reconciles_permalink_collisions_when_optional_frontmatter_is_disabled( + test_project: Project, + project_config, + engine_factory, + app_config, + config_manager, +) -> None: + """Conflict suffixes remain identical in canonical files and indexed entities.""" + app_config.ensure_frontmatter_on_sync = False + config_manager.save_config(app_config) + + spaced_path = project_config.home / "same note.md" + hyphen_path = project_config.home / "same-note.md" + spaced_path.write_text("# Spaced Note\n", encoding="utf-8") + hyphen_path.write_text("# Hyphen Note\n", encoding="utf-8") + + result = await run_local_project_index_for_project( + test_project, + runtime_factory=LocalProjectIndexRuntimeFactory(batch_size=10), + force_full=True, + ) + + assert result.enqueued_files == 2 + _, session_maker = engine_factory + entity_repository = EntityRepository(project_id=test_project.id) + async with db.scoped_session(session_maker) as session: + spaced_entity = await entity_repository.get_by_file_path(session, "same note.md") + hyphen_entity = await entity_repository.get_by_file_path(session, "same-note.md") + + assert spaced_entity is not None + assert hyphen_entity is not None + assert spaced_entity.permalink != hyphen_entity.permalink + assert { + spaced_entity.permalink, + hyphen_entity.permalink, + } == { + f"{test_project.permalink}/same-note", + f"{test_project.permalink}/same-note-1", + } + assert ( + f"permalink: {spaced_entity.permalink}" + in spaced_path.read_text(encoding="utf-8").splitlines() + ) + assert ( + f"permalink: {hyphen_entity.permalink}" + in hyphen_path.read_text(encoding="utf-8").splitlines() + ) + + +@pytest.mark.asyncio +async def test_normal_project_index_backfills_an_unchanged_legacy_note( + test_project: Project, + project_config, + engine_factory, + app_config, + config_manager, +) -> None: + """A regular startup scan repairs a checksum-current row with legacy null identity.""" + app_config.ensure_frontmatter_on_sync = False + config_manager.save_config(app_config) + note_path = project_config.home / "legacy-null.md" + note_path.write_text("# Legacy Null\n\nExisting body.\n", encoding="utf-8") + + await run_local_project_index_for_project( + test_project, + runtime_factory=LocalProjectIndexRuntimeFactory(batch_size=10), + force_full=True, + ) + + legacy_content = "# Legacy Null\n\nExisting body.\n" + note_path.write_text(legacy_content, encoding="utf-8") + _, session_maker = engine_factory + entity_repository = EntityRepository(project_id=test_project.id) + async with db.scoped_session(session_maker) as session: + entity = await entity_repository.get_by_file_path(session, "legacy-null.md") + assert entity is not None + entity.permalink = None + entity.checksum = await compute_checksum(legacy_content) + + result = await run_local_project_index_for_project( + test_project, + runtime_factory=LocalProjectIndexRuntimeFactory(batch_size=10), + ) + + assert result.enqueued_files == 1 + expected_permalink = f"{test_project.permalink}/legacy-null" + assert f"permalink: {expected_permalink}" in note_path.read_text(encoding="utf-8").splitlines() + async with db.scoped_session(session_maker) as session: + repaired = await entity_repository.get_by_file_path(session, "legacy-null.md") + assert repaired is not None + assert repaired.permalink == expected_permalink + + +@pytest.mark.asyncio +async def test_forced_index_preserves_moved_malformed_note_permalink( + test_project: Project, + project_config, + engine_factory, + app_config, + config_manager, +) -> None: + """An unrewritable note keeps its semantic address when move updates are disabled.""" + app_config.update_permalinks_on_move = False + config_manager.save_config(app_config) + original_path = project_config.home / "malformed.md" + moved_path = project_config.home / "archive" / "malformed.md" + original_content = "---\ntitle: [unclosed\n---\n\n# Malformed\n" + original_path.write_text(original_content, encoding="utf-8") + + await run_local_project_index_for_project( + test_project, + runtime_factory=LocalProjectIndexRuntimeFactory(batch_size=10), + force_full=True, + ) + _, session_maker = engine_factory + entity_repository = EntityRepository(project_id=test_project.id) + async with db.scoped_session(session_maker) as session: + original_entity = await entity_repository.get_by_file_path(session, "malformed.md") + assert original_entity is not None + original_permalink = original_entity.permalink + assert original_permalink is not None + + moved_path.parent.mkdir() + original_path.rename(moved_path) + move_result = await run_local_project_index_for_project( + test_project, + runtime_factory=LocalProjectIndexRuntimeFactory(batch_size=10), + ) + assert move_result.moved_files == 1 + + await run_local_project_index_for_project( + test_project, + runtime_factory=LocalProjectIndexRuntimeFactory(batch_size=10), + force_full=True, + ) + + assert moved_path.read_text(encoding="utf-8") == original_content + async with db.scoped_session(session_maker) as session: + moved_entity = await entity_repository.get_by_file_path(session, "archive/malformed.md") + assert moved_entity is not None + assert moved_entity.permalink == original_permalink + + +@pytest.mark.asyncio +async def test_db_first_move_preserves_malformed_note_bytes_and_permalink( + test_project: Project, + project_config, + engine_factory, + client, + app_config, + config_manager, +) -> None: + """The real accepted-write API move preserves an indexed malformed fence.""" + app_config.update_permalinks_on_move = False + config_manager.save_config(app_config) + malformed_content = "---\ntitle: [unclosed\n---\n\n# Malformed\n" + source_path = project_config.home / "malformed-db-move.md" + destination_path = project_config.home / "archive" / "malformed-db-move.md" + source_path.write_text(malformed_content, encoding="utf-8") + + await run_local_project_index_for_project( + test_project, + runtime_factory=LocalProjectIndexRuntimeFactory(batch_size=10), + force_full=True, + ) + _, session_maker = engine_factory + entity_repository = EntityRepository(project_id=test_project.id) + async with db.scoped_session(session_maker) as session: + created = await entity_repository.get_by_file_path(session, "malformed-db-move.md") + assert created is not None + assert created.permalink is not None + + response = await client.put( + f"/v2/projects/{test_project.external_id}/knowledge/entities/{created.external_id}/move", + json={"destination_path": "archive/malformed-db-move.md"}, + ) + + assert response.status_code == 202 + assert response.json()["permalink"] == created.permalink + assert not source_path.exists() + assert destination_path.read_text(encoding="utf-8") == malformed_content diff --git a/test-int/test_relation_generation_concurrency.py b/test-int/test_relation_generation_concurrency.py index 3623f94a9..9b2b325a1 100644 --- a/test-int/test_relation_generation_concurrency.py +++ b/test-int/test_relation_generation_concurrency.py @@ -391,7 +391,6 @@ async def test_mutual_relation_generations_complete_under_concurrent_persistence if engine.dialect.name != "postgresql": pytest.skip("row-lock deadlock regression requires PostgreSQL") - app_config.disable_permalinks = True entity_repository = EntityRepository(project_id=test_project.id) observation_repository = ObservationRepository(project_id=test_project.id) relation_repository = RelationRepository(project_id=test_project.id) diff --git a/tests/api/v2/test_entity_observation_lock_order.py b/tests/api/v2/test_entity_observation_lock_order.py index 7563e5a4d..e0681b7b6 100644 --- a/tests/api/v2/test_entity_observation_lock_order.py +++ b/tests/api/v2/test_entity_observation_lock_order.py @@ -24,10 +24,12 @@ from basic_memory.services.entity_service import EntityService -def _indexed_markdown(*, title: str, created: datetime) -> EntityMarkdown: +def _indexed_markdown(*, title: str, permalink: str, created: datetime) -> EntityMarkdown: """Build the file snapshot shared by the publication regressions.""" return EntityMarkdown( - frontmatter=EntityFrontmatter(metadata={"title": title, "type": "note"}), + frontmatter=EntityFrontmatter( + metadata={"title": title, "type": "note", "permalink": permalink} + ), observations=[ MarkdownObservation(content="Initial observation", category="fact"), MarkdownObservation(content="Accepted edit observation", category="fact"), @@ -89,9 +91,11 @@ async def test_markdown_update_defers_observations_to_generation_publication( content="# Deferred observation publication\n\n- [fact] Initial observation", ) ) + assert created.permalink is not None anchor = await entity_service._capture_note_content_anchor(created.id) markdown = _indexed_markdown( title="Deferred observation publication", + permalink=created.permalink, created=created.created_at, ) publication_calls: list[tuple[int, int, tuple[str, ...]]] = [] diff --git a/tests/index/test_local_move_content_updates.py b/tests/index/test_local_move_content_updates.py index 3961520ff..9e4ddce82 100644 --- a/tests/index/test_local_move_content_updates.py +++ b/tests/index/test_local_move_content_updates.py @@ -22,7 +22,6 @@ class MovePermalinkConfig: """Just the permalink policy flags the move content planner reads.""" - disable_permalinks: bool = False update_permalinks_on_move: bool = True @@ -105,13 +104,7 @@ async def test_plan_moved_file_content_requires_app_config(tmp_path: Path) -> No @pytest.mark.asyncio -async def test_plan_moved_file_content_respects_permalink_policy(tmp_path: Path) -> None: - disabled = _updater( - tmp_path, - StaticMoveEntityService(app_config=MovePermalinkConfig(disable_permalinks=True)), - ) - assert await disabled.plan_moved_file_content(_session(), _moved_file()) is None - +async def test_plan_moved_file_content_respects_move_permalink_policy(tmp_path: Path) -> None: no_move_updates = _updater( tmp_path, StaticMoveEntityService(app_config=MovePermalinkConfig(update_permalinks_on_move=False)), diff --git a/tests/index/test_local_project_index.py b/tests/index/test_local_project_index.py index 5d3acc2c0..a27376c28 100644 --- a/tests/index/test_local_project_index.py +++ b/tests/index/test_local_project_index.py @@ -2399,14 +2399,14 @@ async def test_local_project_index_assigns_unique_permalinks_for_path_conflicts( assert f"permalink: {hyphen_entity.permalink}" in hyphen_note_content.markdown_content -async def test_local_project_index_does_not_add_frontmatter_when_disabled( +async def test_local_project_index_adds_required_permalink_when_optional_fields_disabled( test_project: Project, project_config, app_config, config_manager, monkeypatch, ) -> None: - """Plain markdown files stay plain when missing-frontmatter rewrites are disabled.""" + """The opt-out suppresses optional fields, not canonical note identity.""" app_config.ensure_frontmatter_on_sync = False config_manager.save_config(app_config) @@ -2421,11 +2421,12 @@ async def test_local_project_index_does_not_add_frontmatter_when_disabled( assert result.enqueued_files == 1 indexed_content = plain_path.read_text(encoding="utf-8") - assert "permalink:" not in indexed_content + assert f"permalink: {test_project.permalink}/plain" in indexed_content + assert "title:" not in indexed_content assert "type:" not in indexed_content -async def test_local_project_index_indexes_thematic_break_content_without_frontmatter( +async def test_local_project_index_preserves_thematic_break_body_after_permalink_injection( test_project: Project, project_config, entity_repository, @@ -2435,7 +2436,7 @@ async def test_local_project_index_indexes_thematic_break_content_without_frontm config_manager, monkeypatch, ) -> None: - """Leading thematic-break markdown stays raw and searchable without frontmatter.""" + """A leading thematic break remains body content after required identity is added.""" app_config.ensure_frontmatter_on_sync = False config_manager.save_config(app_config) @@ -2451,8 +2452,10 @@ async def test_local_project_index_indexes_thematic_break_content_without_frontm ) assert result.enqueued_files == 1 - persisted_content = thematic_path.read_text(encoding="utf-8") - assert persisted_content == original_content + persisted_content = thematic_path.read_bytes().decode("utf-8") + normalized_content = persisted_content.replace("\r\n", "\n") + assert f"permalink: {test_project.permalink}/notes/thematic-break" in persisted_content + assert normalized_content.endswith(original_content.rstrip()) async with db.scoped_session(session_maker) as session: entity = await entity_repository.get_by_file_path(session, "notes/thematic-break.md") @@ -2470,7 +2473,7 @@ async def test_local_project_index_indexes_thematic_break_content_without_frontm assert results[0].file_path == "notes/thematic-break.md" -async def test_local_project_index_writes_frontmatter_when_enabled_even_if_permalinks_disabled( +async def test_local_project_index_writes_required_identity_frontmatter( test_project: Project, project_config, entity_repository, @@ -2481,7 +2484,6 @@ async def test_local_project_index_writes_frontmatter_when_enabled_even_if_perma ) -> None: """Missing-frontmatter project indexing writes identity metadata when configured.""" app_config.ensure_frontmatter_on_sync = True - app_config.disable_permalinks = True config_manager.save_config(app_config) note_path = project_config.home / "override.md" diff --git a/tests/indexing/test_accepted_note_mutation_runner.py b/tests/indexing/test_accepted_note_mutation_runner.py index 31e9a9fe9..6d404e665 100644 --- a/tests/indexing/test_accepted_note_mutation_runner.py +++ b/tests/indexing/test_accepted_note_mutation_runner.py @@ -791,7 +791,6 @@ def _dependencies( ), move_policy=move_policy or AcceptedNoteMutationMovePolicy( - disable_permalinks=False, update_permalinks_on_move=False, ), verify_storage_absent_on_create=verify_storage_absent_on_create, @@ -1794,7 +1793,6 @@ async def test_run_accepted_note_move_carries_previous_path_and_materialized_cle note_content_accept_repository=note_content_accept_repository, search_repository=search_repository, move_policy=AcceptedNoteMutationMovePolicy( - disable_permalinks=False, update_permalinks_on_move=True, ), ), diff --git a/tests/indexing/test_accepted_note_write_runner.py b/tests/indexing/test_accepted_note_write_runner.py index b4276e19c..23c67d4c3 100644 --- a/tests/indexing/test_accepted_note_write_runner.py +++ b/tests/indexing/test_accepted_note_write_runner.py @@ -1154,6 +1154,7 @@ async def test_persist_accepted_note_snapshot_emits_relation_generation() -> Non async def test_persist_accepted_note_move_emits_relation_generation() -> None: session = cast(AsyncSession, _FlushSession()) entity = _entity() + assert entity.permalink is not None entity.file_path = "notes/new.md" current_note_content = _note_content() current_note_content.file_path = "notes/old.md" diff --git a/tests/indexing/test_batch_indexer.py b/tests/indexing/test_batch_indexer.py index 56bb9ee27..4f983a019 100644 --- a/tests/indexing/test_batch_indexer.py +++ b/tests/indexing/test_batch_indexer.py @@ -405,8 +405,6 @@ async def test_batch_indexer_returns_original_markdown_content_when_no_frontmatt file_service, project_config, ): - app_config.disable_permalinks = True - path = "notes/original.md" original_content = dedent( """ @@ -741,7 +739,6 @@ async def test_batch_indexer_uses_parsed_markdown_body_for_malformed_frontmatter file_service, project_config, ): - app_config.disable_permalinks = True app_config.ensure_frontmatter_on_sync = False path = "notes/malformed.md" @@ -1598,7 +1595,7 @@ async def test_batch_indexer_strips_frontmatter_from_search_content_when_body_is @pytest.mark.asyncio -async def test_batch_indexer_does_not_inject_frontmatter_when_sync_enforcement_is_disabled( +async def test_batch_indexer_repairs_missing_permalink_when_optional_frontmatter_is_disabled( app_config, entity_service, entity_repository, @@ -1643,14 +1640,14 @@ async def test_batch_indexer_does_not_inject_frontmatter_when_sync_enforcement_i index_search=False, ) - # Trigger: Windows persists CRLF for text files even when the test literal uses LF. - # Why: this assertion cares about preserving a frontmatterless file, not about newline style. - # Outcome: compare against the exact content stored on disk after sync. persisted_content = (project_config.home / path).read_bytes().decode("utf-8") async with db.scoped_session(search_service.session_maker) as session: entity = await entity_repository.get_by_file_path(session, path) assert entity is not None assert entity.permalink == existing_permalink - assert frontmatter_writer.await_count == 0 + assert frontmatter_writer.await_count == 1 + assert f"permalink: {existing_permalink}" in persisted_content + assert "title:" not in persisted_content + assert "type:" not in persisted_content assert indexed.markdown_content == persisted_content assert (await file_service.read_file_bytes(path)).decode("utf-8") == persisted_content diff --git a/tests/services/test_entity_service.py b/tests/services/test_entity_service.py index 9410c838c..298ac5b2b 100644 --- a/tests/services/test_entity_service.py +++ b/tests/services/test_entity_service.py @@ -1,5 +1,6 @@ """Tests for EntityService.""" +import re import uuid from pathlib import Path from textwrap import dedent @@ -772,7 +773,7 @@ async def test_update_with_content( @pytest.mark.asyncio -async def test_create_with_no_frontmatter( +async def test_create_from_markdown_rejects_missing_canonical_permalink( project_config: ProjectConfig, entity_parser: EntityParser, entity_service: EntityService, @@ -786,15 +787,13 @@ async def test_create_with_no_frontmatter( await file_service.write_file(Path(full_path), content) entity_markdown = await entity_parser.parse_file(full_path) - created = await entity_service.create_entity_from_markdown(file_path, entity_markdown) - file_content, _ = await file_service.read_file(created.file_path) + with pytest.raises( + ValueError, + match=(f"Markdown note is missing a canonical permalink: {re.escape(str(file_path))}"), + ): + await entity_service.create_entity_from_markdown(file_path, entity_markdown) - assert file_path.as_posix() == created.file_path - assert created.title == "Git Workflow Guide" - assert created.note_type == "note" - assert created.permalink is None - - # assert file + file_content, _ = await file_service.read_file(file_path) expected = dedent(""" # Git Workflow Guide """).strip() @@ -1080,7 +1079,9 @@ async def test_create_entity_from_markdown_with_upsert( ) from datetime import datetime, timezone - frontmatter = EntityFrontmatter(metadata={"title": "UPSERT Test", "type": "test"}) + frontmatter = EntityFrontmatter( + metadata={"title": "UPSERT Test", "type": "test", "permalink": "test/upsert-test"} + ) markdown = RealEntityMarkdown( frontmatter=frontmatter, observations=[], @@ -1116,7 +1117,9 @@ async def test_create_entity_from_markdown_error_handling( ) from datetime import datetime, timezone - frontmatter = EntityFrontmatter(metadata={"title": "Error Test", "type": "test"}) + frontmatter = EntityFrontmatter( + metadata={"title": "Error Test", "type": "test", "permalink": "test/error-test"} + ) markdown = RealEntityMarkdown( frontmatter=frontmatter, observations=[], diff --git a/tests/services/test_entity_service_disable_permalinks.py b/tests/services/test_entity_service_disable_permalinks.py deleted file mode 100644 index 589c1e106..000000000 --- a/tests/services/test_entity_service_disable_permalinks.py +++ /dev/null @@ -1,278 +0,0 @@ -"""Tests for EntityService with disable_permalinks flag.""" - -from textwrap import dedent -import pytest -import yaml - -from basic_memory.config import BasicMemoryConfig -from basic_memory.schemas import Entity as EntitySchema -from basic_memory.services import FileService -from basic_memory.services.entity_service import EntityService - - -@pytest.mark.asyncio -async def test_create_entity_with_permalinks_disabled( - entity_repository, - observation_repository, - relation_repository, - entity_parser, - file_service: FileService, - link_resolver, - session_maker, -): - """Test that entities created with disable_permalinks=True don't have permalinks.""" - # Create entity service with permalinks disabled - app_config = BasicMemoryConfig(disable_permalinks=True) - entity_service = EntityService( - entity_parser=entity_parser, - entity_repository=entity_repository, - observation_repository=observation_repository, - relation_repository=relation_repository, - file_service=file_service, - link_resolver=link_resolver, - app_config=app_config, - session_maker=session_maker, - ) - - entity_data = EntitySchema( - title="Test Entity", - directory="test", - note_type="note", - content="Test content", - ) - - # Create entity - entity = await entity_service.create_entity(entity_data) - - # Assert entity has no permalink - assert entity.permalink is None - - # Verify file frontmatter doesn't contain permalink - file_path = file_service.get_entity_path(entity) - file_content, _ = await file_service.read_file(file_path) - _, frontmatter, doc_content = file_content.split("---", 2) - metadata = yaml.safe_load(frontmatter) - - assert "permalink" not in metadata - assert metadata["title"] == "Test Entity" - assert metadata["type"] == "note" - - -@pytest.mark.asyncio -async def test_create_or_update_keeps_aliasing_file_paths_distinct_without_permalinks( - entity_repository, - observation_repository, - relation_repository, - entity_parser, - file_service: FileService, - link_resolver, - session_maker, -): - """A forgiving read alias must never turn a distinct create into a canonical move.""" - entity_service = EntityService( - entity_parser=entity_parser, - entity_repository=entity_repository, - observation_repository=observation_repository, - relation_repository=relation_repository, - file_service=file_service, - link_resolver=link_resolver, - app_config=BasicMemoryConfig(disable_permalinks=True), - session_maker=session_maker, - ) - - underscored, underscored_created = await entity_service.create_or_update_entity( - EntitySchema( - title="alpha_note", - directory="", - note_type="note", - content="Underscored content", - ) - ) - hyphenated, hyphenated_created = await entity_service.create_or_update_entity( - EntitySchema( - title="alpha-note", - directory="", - note_type="note", - content="Hyphenated content", - ) - ) - - assert underscored_created is True - assert hyphenated_created is True - assert underscored.id != hyphenated.id - assert underscored.file_path == "alpha_note.md" - assert hyphenated.file_path == "alpha-note.md" - assert await file_service.exists(underscored.file_path) - assert await file_service.exists(hyphenated.file_path) - - -@pytest.mark.asyncio -async def test_update_entity_with_permalinks_disabled( - entity_repository, - observation_repository, - relation_repository, - entity_parser, - file_service: FileService, - link_resolver, - session_maker, -): - """Test that entities updated with disable_permalinks=True don't get permalinks added.""" - # First create with permalinks enabled - app_config_enabled = BasicMemoryConfig(disable_permalinks=False) - entity_service_enabled = EntityService( - entity_parser=entity_parser, - entity_repository=entity_repository, - observation_repository=observation_repository, - relation_repository=relation_repository, - file_service=file_service, - link_resolver=link_resolver, - app_config=app_config_enabled, - session_maker=session_maker, - ) - - entity_data = EntitySchema( - title="Test Entity", - directory="test", - note_type="note", - content="Original content", - ) - - # Create entity with permalinks enabled - entity = await entity_service_enabled.create_entity(entity_data) - assert entity.permalink is not None - original_permalink = entity.permalink - - # Now create service with permalinks disabled - app_config_disabled = BasicMemoryConfig(disable_permalinks=True) - entity_service_disabled = EntityService( - entity_parser=entity_parser, - entity_repository=entity_repository, - observation_repository=observation_repository, - relation_repository=relation_repository, - file_service=file_service, - link_resolver=link_resolver, - app_config=app_config_disabled, - session_maker=session_maker, - ) - - # Update entity with permalinks disabled - entity_data.content = "Updated content" - updated = await entity_service_disabled.update_entity(entity, entity_data) - - # Permalink should remain unchanged (not removed, just not updated) - assert updated.permalink == original_permalink - - # Verify file still has the original permalink - file_path = file_service.get_entity_path(updated) - file_content, _ = await file_service.read_file(file_path) - assert "Updated content" in file_content - assert f"permalink: {original_permalink}" in file_content - - -@pytest.mark.asyncio -async def test_create_entity_with_content_frontmatter_permalinks_disabled( - entity_repository, - observation_repository, - relation_repository, - entity_parser, - file_service: FileService, - link_resolver, - session_maker, -): - """Test that content frontmatter permalinks are ignored when disabled.""" - # Create entity service with permalinks disabled - app_config = BasicMemoryConfig(disable_permalinks=True) - entity_service = EntityService( - entity_parser=entity_parser, - entity_repository=entity_repository, - observation_repository=observation_repository, - relation_repository=relation_repository, - file_service=file_service, - link_resolver=link_resolver, - app_config=app_config, - session_maker=session_maker, - ) - - # Content with frontmatter containing permalink - content = dedent( - """ - --- - permalink: custom-permalink - --- - # Test Content - """ - ).strip() - - entity_data = EntitySchema( - title="Test Entity", - directory="test", - note_type="note", - content=content, - ) - - # Create entity - entity = await entity_service.create_entity(entity_data) - - # Entity should not have a permalink set - assert entity.permalink is None - - # Verify file doesn't have permalink in frontmatter - file_path = file_service.get_entity_path(entity) - file_content, _ = await file_service.read_file(file_path) - _, frontmatter, doc_content = file_content.split("---", 2) - metadata = yaml.safe_load(frontmatter) - - # The permalink from content frontmatter should not be present - assert "permalink" not in metadata - - -@pytest.mark.asyncio -async def test_move_entity_with_permalinks_disabled( - entity_repository, - observation_repository, - relation_repository, - entity_parser, - file_service: FileService, - link_resolver, - project_config, - session_maker, -): - """Test that moving an entity with disable_permalinks=True doesn't update permalinks.""" - # First create with permalinks enabled - app_config = BasicMemoryConfig(disable_permalinks=False, update_permalinks_on_move=True) - entity_service = EntityService( - entity_parser=entity_parser, - entity_repository=entity_repository, - observation_repository=observation_repository, - relation_repository=relation_repository, - file_service=file_service, - link_resolver=link_resolver, - app_config=app_config, - session_maker=session_maker, - ) - - entity_data = EntitySchema( - title="Test Entity", - directory="test", - note_type="note", - content="Test content", - ) - - # Create entity - entity = await entity_service.create_entity(entity_data) - original_permalink = entity.permalink - assert original_permalink is not None - - # Now disable permalinks - app_config_disabled = BasicMemoryConfig(disable_permalinks=True, update_permalinks_on_move=True) - - # Move entity - moved = await entity_service.move_entity( - identifier=original_permalink, - destination_path="new_folder/test_entity.md", - project_config=project_config, - app_config=app_config_disabled, - ) - - # Permalink should remain unchanged even though update_permalinks_on_move is True - assert moved.permalink == original_permalink diff --git a/tests/services/test_entity_service_prepare.py b/tests/services/test_entity_service_prepare.py index bd62d4506..6a3668557 100644 --- a/tests/services/test_entity_service_prepare.py +++ b/tests/services/test_entity_service_prepare.py @@ -569,7 +569,7 @@ async def test_prepare_edit_entity_content_prepend_fails_for_malformed_frontmatt @pytest.mark.asyncio -async def test_prepare_edit_entity_content_prepend_without_frontmatter_uses_simple_prepend( +async def test_prepare_edit_entity_content_prepend_without_frontmatter_restores_permalink( entity_service, ) -> None: created = await entity_service.create_entity( @@ -588,7 +588,8 @@ async def test_prepare_edit_entity_content_prepend_without_frontmatter_uses_simp content="Prepended line", ) - assert prepared.markdown_content == "Prepended line\nOriginal body" + assert parse_frontmatter(prepared.markdown_content)["permalink"] == created.permalink + assert remove_frontmatter(prepared.markdown_content) == "Prepended line\nOriginal body" @pytest.mark.asyncio diff --git a/tests/services/test_initialization.py b/tests/services/test_initialization.py index 9d4635da5..89f2f1d23 100644 --- a/tests/services/test_initialization.py +++ b/tests/services/test_initialization.py @@ -7,7 +7,6 @@ from __future__ import annotations import asyncio -from unittest.mock import AsyncMock import pytest @@ -18,7 +17,6 @@ from basic_memory.repository.project_repository import ProjectRepository from basic_memory.services.initialization import ( ensure_initialization, - initialize_app, initialize_database, initialize_file_indexing, reconcile_projects_with_config, @@ -140,39 +138,6 @@ def test_ensure_initialization_runs_and_cleans_up(app_config: BasicMemoryConfig, assert db._session_maker is None # pyright: ignore [reportPrivateUsage] -@pytest.mark.asyncio -async def test_initialize_app_warns_on_frontmatter_permalink_precedence( - app_config: BasicMemoryConfig, monkeypatch -): - app_config.database_backend = DatabaseBackend.SQLITE - app_config.ensure_frontmatter_on_sync = True - app_config.disable_permalinks = True - - init_db_mock = AsyncMock() - reconcile_mock = AsyncMock() - monkeypatch.setattr("basic_memory.services.initialization.initialize_database", init_db_mock) - monkeypatch.setattr( - "basic_memory.services.initialization.reconcile_projects_with_config", - reconcile_mock, - ) - - warnings: list[str] = [] - - def capture_warning(message: str) -> None: - warnings.append(message) - - monkeypatch.setattr("basic_memory.services.initialization.logger.warning", capture_warning) - - await initialize_app(app_config) - - assert init_db_mock.await_count == 1 - assert reconcile_mock.await_count == 1 - assert any( - "ensure_frontmatter_on_sync=True overrides disable_permalinks=True" in message - for message in warnings - ) - - class _FakeWatchService: """Captures init kwargs so tests can assert what the real service receives.""" @@ -409,37 +374,6 @@ async def test_initialize_file_indexing_skips_project_with_non_absolute_path( await db.shutdown_db() -@pytest.mark.asyncio -async def test_initialize_app_no_precedence_warning_when_not_conflicting( - app_config: BasicMemoryConfig, monkeypatch -): - app_config.ensure_frontmatter_on_sync = False - app_config.disable_permalinks = True - - monkeypatch.setattr( - "basic_memory.services.initialization.initialize_database", - AsyncMock(), - ) - monkeypatch.setattr( - "basic_memory.services.initialization.reconcile_projects_with_config", - AsyncMock(), - ) - - warnings: list[str] = [] - - def capture_warning(message: str) -> None: - warnings.append(message) - - monkeypatch.setattr("basic_memory.services.initialization.logger.warning", capture_warning) - - await initialize_app(app_config) - - assert not any( - "ensure_frontmatter_on_sync=True overrides disable_permalinks=True" in message - for message in warnings - ) - - @pytest.mark.asyncio async def test_recover_project_materializations_writes_stuck_file( session_maker, diff --git a/tests/test_config.py b/tests/test_config.py index c1e761287..19caff170 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -637,15 +637,23 @@ def test_save_config_uses_private_permissions(self, temp_config_manager): assert dir_mode == 0o700 assert file_mode == 0o600 - def test_disable_permalinks_flag_default(self): - """Test that disable_permalinks flag defaults to False.""" - config = BasicMemoryConfig() - assert config.disable_permalinks is False + def test_removed_disable_permalinks_false_is_ignored(self): + """Old configs commonly persisted the inert false default.""" + config = BasicMemoryConfig(disable_permalinks=False) + + assert "disable_permalinks" not in BasicMemoryConfig.model_fields + assert "disable_permalinks" not in config.model_dump(mode="json") - def test_disable_permalinks_flag_can_be_enabled(self): - """Test that disable_permalinks flag can be set to True.""" + def test_removed_disable_permalinks_true_is_ignored(self): + """The retired setting is an unknown key, regardless of its former value.""" config = BasicMemoryConfig(disable_permalinks=True) - assert config.disable_permalinks is True + assert "disable_permalinks" not in config.model_dump(mode="json") + + def test_removed_disable_permalinks_environment_is_ignored(self, monkeypatch): + """The retired environment variable cannot block startup.""" + monkeypatch.setenv("BASIC_MEMORY_DISABLE_PERMALINKS", "true") + config = BasicMemoryConfig() + assert "disable_permalinks" not in config.model_dump(mode="json") def test_ensure_frontmatter_on_sync_flag_default(self): """Test that ensure_frontmatter_on_sync defaults to True.""" @@ -936,6 +944,35 @@ def test_legacy_cloud_mode_key_is_stripped_on_normalization_save(self): raw = json.loads(config_manager.config_file.read_text(encoding="utf-8")) assert "cloud_mode" not in raw + def test_removed_disable_permalinks_true_is_stripped_on_normalization_save(self): + """Loading an old opt-out config removes the retired setting without disruption.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + config_manager = ConfigManager() + config_manager.config_dir = temp_path / "basic-memory" + config_manager.config_file = config_manager.config_dir / "config.json" + config_manager.config_dir.mkdir(parents=True, exist_ok=True) + + import json + + legacy_config = { + "projects": {"main": {"path": str(temp_path / "main"), "mode": "local"}}, + "default_project": "main", + "disable_permalinks": True, + } + config_manager.config_file.write_text(json.dumps(legacy_config, indent=2)) + + import basic_memory.config + + basic_memory.config._CONFIG_CACHE = None + basic_memory.config._CONFIG_MTIME = None + basic_memory.config._CONFIG_SIZE = None + + config_manager.load_config() + + raw = json.loads(config_manager.config_file.read_text(encoding="utf-8")) + assert "disable_permalinks" not in raw + def test_migration_creates_backup_of_old_config(self): """Config migration should create a .bak backup before overwriting.""" with tempfile.TemporaryDirectory() as temp_dir: