From bb6249e00ef764e6d1ccaf086dddc31d40711164 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 13:57:09 -0400 Subject: [PATCH] feat(versions): prune_auto_pins FIFO-trims auto-pinned bucket Auto-pinned versions live in their own bucket with MAX_AUTO_PINS=25 cap. The scan job calls this after each note's promotions complete; the oldest auto-pinned rows are dropped past the cap. Manual pins and rolling rows are untouched. --- .../services/version_pinning.py | 29 ++++++++++++++ tests/test_version_pinning_prune.py | 38 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/fabledassistant/services/version_pinning.py b/src/fabledassistant/services/version_pinning.py index 6b32803..780b795 100644 --- a/src/fabledassistant/services/version_pinning.py +++ b/src/fabledassistant/services/version_pinning.py @@ -88,3 +88,32 @@ async def unpin_version( await session.commit() await session.refresh(version) return version + + +async def prune_auto_pins(user_id: int, note_id: int) -> None: + """FIFO-prune the auto-pinned bucket for one note past MAX_AUTO_PINS. + + Manual pins and rolling rows are untouched. Called by the scan job + after each note's auto-pin promotions finish. + """ + async with async_session() as session: + 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 + AND pin_kind = 'auto' + ORDER BY created_at DESC + OFFSET :max_auto_pins + ) + """ + ).bindparams( + note_id=note_id, + user_id=user_id, + max_auto_pins=MAX_AUTO_PINS, + ) + ) + await session.commit() diff --git a/tests/test_version_pinning_prune.py b/tests/test_version_pinning_prune.py index d84b123..c6e1ca5 100644 --- a/tests/test_version_pinning_prune.py +++ b/tests/test_version_pinning_prune.py @@ -43,3 +43,41 @@ async def test_create_version_prune_sql_filters_to_unpinned(): 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}" ) + + +async def test_prune_auto_pins_filters_to_auto_kind(): + """prune_auto_pins must filter to pin_kind='auto' so manual pins and + rolling rows aren't touched, and must bind MAX_AUTO_PINS as the OFFSET.""" + mock_session = AsyncMock() + mock_session.commit = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + captured_sql: list[str] = [] + captured_params: list[dict] = [] + + async def execute_capture(stmt, *args, **kwargs): + captured_sql.append(str(stmt)) + try: + captured_params.append(dict(stmt.compile().params)) + except Exception: + captured_params.append({}) + return MagicMock() + + mock_session.execute = AsyncMock(side_effect=execute_capture) + + with patch( + "fabledassistant.services.version_pinning.async_session", + return_value=mock_session, + ): + from fabledassistant.services.version_pinning import ( + prune_auto_pins, MAX_AUTO_PINS, + ) + await prune_auto_pins(user_id=1, note_id=42) + + assert any("pin_kind = 'auto'" in s for s in captured_sql), ( + f"expected auto-bucket filter in SQL; got: {captured_sql!r}" + ) + assert any( + p.get("max_auto_pins") == MAX_AUTO_PINS for p in captured_params + ), f"bound params: {captured_params!r}"