diff --git a/src/fabledassistant/services/note_versions.py b/src/fabledassistant/services/note_versions.py index 425c4dc..bef59fb 100644 --- a/src/fabledassistant/services/note_versions.py +++ b/src/fabledassistant/services/note_versions.py @@ -47,13 +47,18 @@ async def create_version( await session.commit() await session.refresh(version) - # Prune versions beyond MAX_VERSIONS + # Prune rolling versions beyond MAX_VERSIONS. Pinned rows + # (pin_kind IS NOT NULL) are excluded from both the counted + # bucket and the deletion candidate set, so they survive + # indefinitely regardless of rolling autosave volume. await session.execute( text(""" DELETE FROM note_versions WHERE id IN ( SELECT id FROM note_versions - WHERE note_id = :note_id AND user_id = :user_id + WHERE note_id = :note_id + AND user_id = :user_id + AND pin_kind IS NULL ORDER BY created_at DESC OFFSET :max_versions ) diff --git a/tests/test_version_pinning_prune.py b/tests/test_version_pinning_prune.py new file mode 100644 index 0000000..d84b123 --- /dev/null +++ b/tests/test_version_pinning_prune.py @@ -0,0 +1,45 @@ +"""Tests for the prune logic — both rolling (pin_kind IS NULL) and the +auto-pin bucket (pin_kind='auto'). + +Design: docs/superpowers/specs/2026-05-13-note-version-pinning-design.md +""" +from unittest.mock import AsyncMock, MagicMock, patch + + +async def test_create_version_prune_sql_filters_to_unpinned(): + """The DELETE statement issued by create_version's prune step must + include `pin_kind IS NULL` in the inner SELECT so pinned versions + aren't counted toward MAX_VERSIONS and can't be pruned by it.""" + mock_session = AsyncMock() + select_result = MagicMock() + # No prior version → skips the throttle/dedupe early-return paths and + # proceeds straight to insert + prune. + select_result.scalars.return_value.first.return_value = None + mock_session.add = MagicMock() + mock_session.commit = AsyncMock() + mock_session.refresh = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + captured_sql: list[str] = [] + + async def execute_capture(stmt, *args, **kwargs): + captured_sql.append(str(stmt)) + if len(captured_sql) == 1: + return select_result + return MagicMock() + + mock_session.execute = AsyncMock(side_effect=execute_capture) + + with patch( + "fabledassistant.services.note_versions.async_session", + return_value=mock_session, + ): + from fabledassistant.services.note_versions import create_version + await create_version( + user_id=1, note_id=42, body="content", title="t", tags=["a"], + ) + + assert any("pin_kind IS NULL" in s for s in captured_sql), ( + f"Expected prune SQL to filter pin_kind IS NULL; got: {captured_sql!r}" + )