Renames src/fabledassistant -> src/scribe and all imports, plus the default DB name and DB user/password (fabled -> scribe) in config + compose. 952 refs / 154 files. Reverses the old 'internal name stays fabledassistant' convention. Code-only: live databases are still physically named 'fabledassistant'. Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git host (fabledsword), MCP (fabled-git) and the image name (fabledscribe) are intentionally unchanged. ruff check src/ clean locally; CI (typecheck + pytest) is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
91 lines
3.5 KiB
Python
91 lines
3.5 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import select, text
|
|
|
|
from scribe.models import async_session
|
|
from scribe.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()
|