The DELETE inside create_version now filters pin_kind IS NULL so pinned rows (auto or manual) aren't counted toward MAX_VERSIONS=50 and aren't candidates for deletion. Pinned versions live indefinitely regardless of how heavy rolling autosave traffic gets on the same note.
91 lines
3.5 KiB
Python
91 lines
3.5 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import select, text
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.note_version import NoteVersion
|
|
|
|
# Maximum snapshots retained per note.
|
|
MAX_VERSIONS = 50
|
|
|
|
# Minimum seconds between snapshots. Prevents autosave from consuming all
|
|
# slots — with autosave at 60 s intervals, a 5-minute gate means at most
|
|
# one snapshot per editing session segment rather than one per save tick.
|
|
MIN_VERSION_INTERVAL_SECONDS = 300
|
|
|
|
|
|
async def create_version(
|
|
user_id: int, note_id: int, body: str, title: str, tags: list[str] | None = None
|
|
) -> NoteVersion | None:
|
|
async with async_session() as session:
|
|
# Fetch the most recent snapshot once for both checks.
|
|
recent = await session.execute(
|
|
select(NoteVersion)
|
|
.where(NoteVersion.note_id == note_id, NoteVersion.user_id == user_id)
|
|
.order_by(NoteVersion.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
last = recent.scalars().first()
|
|
if last is not None:
|
|
# Skip if content is identical — no change, no snapshot (git semantics).
|
|
if (
|
|
last.body == body
|
|
and last.title == title
|
|
and sorted(last.tags or []) == sorted(tags or [])
|
|
):
|
|
return None
|
|
# Skip if within the minimum interval to prevent rapid-fire autosave snapshots.
|
|
last_at = last.created_at
|
|
if last_at.tzinfo is None:
|
|
last_at = last_at.replace(tzinfo=timezone.utc)
|
|
age = (datetime.now(timezone.utc) - last_at).total_seconds()
|
|
if age < MIN_VERSION_INTERVAL_SECONDS:
|
|
return None
|
|
|
|
version = NoteVersion(note_id=note_id, user_id=user_id, body=body, title=title, tags=tags or [])
|
|
session.add(version)
|
|
await session.commit()
|
|
await session.refresh(version)
|
|
|
|
# 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
|
|
AND pin_kind IS NULL
|
|
ORDER BY created_at DESC
|
|
OFFSET :max_versions
|
|
)
|
|
""").bindparams(note_id=note_id, user_id=user_id, max_versions=MAX_VERSIONS)
|
|
)
|
|
await session.commit()
|
|
return version
|
|
|
|
|
|
async def list_versions(user_id: int, note_id: int) -> list[NoteVersion]:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(NoteVersion)
|
|
.where(NoteVersion.note_id == note_id, NoteVersion.user_id == user_id)
|
|
.order_by(NoteVersion.created_at.desc())
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def get_version(user_id: int, note_id: int, version_id: int) -> NoteVersion | None:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(NoteVersion).where(
|
|
NoteVersion.id == version_id,
|
|
NoteVersion.note_id == note_id,
|
|
NoteVersion.user_id == user_id,
|
|
)
|
|
)
|
|
return result.scalars().first()
|