Temporary chats

A clock in the top-right starts one. It is never listed in the sidebar and
is swept a day after the last thing said in it.

A real row rather than something held in the browser, because a reload, a
crash or a background tab all look identical from here -- "delete when you
navigate away" would lose conversations people meant to keep. The flag rides
in the URL (/chat?temporary=1) rather than in JavaScript, so it survives a
reload and can be bookmarked, and the composer carries it as a hidden field
beside model_id.

Keep clears the flag. Without a way out, a conversation that turns out to
matter is destroyed a day later with no recourse, and people would find that
out exactly once.

archived was filtered in three places and temporary mirrors all three, plus
Folder.visible_chats. It also skips the unread flag in _persist: there is no
sidebar row for the dot to land on, and the toast would name a chat nobody
can navigate to.

The sweep measures age from the newest message, not from the chat row.
created_at would destroy a conversation still in use at hour 23, and
updated_at does not move when a message is inserted -- onupdate fires on an
UPDATE of the chat, and adding a message is not one. It runs at startup
beside the existing upload sweep.

Deleting a chat cascades its rows but leaves the files on disk; only the
orphan sweep unlinks anything, and it looks only at uploads that were never
attached. files.remove_files_for_chats() closes that for the new sweep. The
same hole in delete_chat is pre-existing and left for its own change, which
can now call the same helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 00:45:03 +02:00
parent e185edc9e1
commit 09eecbdd9a
12 changed files with 387 additions and 10 deletions
+40 -3
View File
@@ -34,6 +34,7 @@ router = APIRouter(prefix="/api/chats", tags=["chats"])
# Well under nginx's 60s default; see services/sse.py:KEEPALIVE. # Well under nginx's 60s default; see services/sse.py:KEEPALIVE.
KEEPALIVE_AFTER = 15.0 KEEPALIVE_AFTER = 15.0
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat: def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
chat = db.get(Chat, chat_id) chat = db.get(Chat, chat_id)
# 404 rather than 403 for someone else's chat: whether a given id exists is # 404 rather than 403 for someone else's chat: whether a given id exists is
@@ -43,7 +44,14 @@ def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
return chat return chat
def _new_chat(db: DBSession, user: User, *, folder_id: str = "", model_id: str = "") -> Chat: def _new_chat(
db: DBSession,
user: User,
*,
folder_id: str = "",
model_id: str = "",
temporary: bool = False,
) -> Chat:
"""Create a chat row, resolving which model it should use.""" """Create a chat row, resolving which model it should use."""
chosen = None chosen = None
if model_id: if model_id:
@@ -60,6 +68,7 @@ def _new_chat(db: DBSession, user: User, *, folder_id: str = "", model_id: str =
folder_id=folder_id or None, folder_id=folder_id or None,
model_id=chosen[0] if chosen else "", model_id=chosen[0] if chosen else "",
connection_id=chosen[1] if chosen else None, connection_id=chosen[1] if chosen else None,
temporary=temporary,
) )
db.add(chat) db.add(chat)
db.commit() db.commit()
@@ -74,6 +83,7 @@ async def start_chat(
file_ids: list[str] = Form(default=[]), file_ids: list[str] = Form(default=[]),
folder_id: str = Form(""), folder_id: str = Form(""),
model_id: str = Form(""), model_id: str = Form(""),
temporary: bool = Form(False),
) -> Response: ) -> Response:
"""Create a chat from its first message. """Create a chat from its first message.
@@ -86,7 +96,9 @@ async def start_chat(
if not content and not file_ids: if not content and not file_ids:
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
chat = _new_chat(db, user, folder_id=folder_id, model_id=model_id) chat = _new_chat(
db, user, folder_id=folder_id, model_id=model_id, temporary=temporary
)
user_message = chat_service.create_message(db, chat, ROLE_USER, content) user_message = chat_service.create_message(db, chat, ROLE_USER, content)
if file_ids: if file_ids:
@@ -106,6 +118,25 @@ async def start_chat(
# /start when the first message is actually sent. # /start when the first message is actually sent.
@router.post("/{chat_id}/keep")
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Stop a temporary chat being temporary.
A conversation that turns out to matter has to have a way out; without one,
the sweep destroys it a day later with no recourse, and people discover that
exactly once.
"""
chat = _owned_chat(db, chat_id, user.id)
chat.temporary = False
db.commit()
response = Response(status_code=status.HTTP_204_NO_CONTENT)
# The sidebar has to gain a row and the topbar has to lose a badge; a full
# refresh is one line against a handful of out-of-band fragments.
response.headers["HX-Refresh"] = "true"
return response
@router.get("/unread") @router.get("/unread")
async def unread_poll(db: Db, user: RequiredUser) -> Response: async def unread_poll(db: Db, user: RequiredUser) -> Response:
"""Dots for the sidebar, and a toast for anything newly arrived. """Dots for the sidebar, and a toast for anything newly arrived.
@@ -119,7 +150,13 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
""" """
chats = list( chats = list(
db.scalars( db.scalars(
select(Chat).where(Chat.user_id == user.id, Chat.archived.is_(False)) select(Chat).where(
Chat.user_id == user.id,
Chat.archived.is_(False),
# A temporary chat has no sidebar row, so a dot has nowhere to
# land and the toast would name a chat nobody can navigate to.
Chat.temporary.is_(False),
)
) )
) )
+8 -2
View File
@@ -83,6 +83,7 @@ def sidebar_context(db: DBSession, user: User) -> dict:
Chat.user_id == user.id, Chat.user_id == user.id,
Chat.folder_id.is_(None), Chat.folder_id.is_(None),
Chat.archived.is_(False), Chat.archived.is_(False),
Chat.temporary.is_(False),
) )
.order_by(Chat.pinned.desc(), Chat.updated_at.desc()) .order_by(Chat.pinned.desc(), Chat.updated_at.desc())
) )
@@ -164,11 +165,15 @@ async def offline(request: Request) -> Response:
@router.get("/chat") @router.get("/chat")
async def chat_index(request: Request, db: Db, user: RequiredUser, model: str = ""): async def chat_index(
request: Request, db: Db, user: RequiredUser, model: str = "", temporary: bool = False
):
"""A composer with no chat behind it yet. """A composer with no chat behind it yet.
`?model=` preselects one, which is how the pinned shortcuts work without `?model=` preselects one, which is how the pinned shortcuts work without
creating a row for a chat that may never be sent. creating a row for a chat that may never be sent. `?temporary=1` is the
same idea for the temporary flag: it lives in the URL rather than in
JavaScript, so it survives a reload and can be bookmarked.
""" """
context = _chat_context(db, user, None) context = _chat_context(db, user, None)
@@ -195,6 +200,7 @@ async def chat_index(request: Request, db: Db, user: RequiredUser, model: str =
"bodies": {}, "bodies": {},
**context, **context,
"current_model": preselected, "current_model": preselected,
"starting_temporary": temporary,
**sidebar_context(db, user), **sidebar_context(db, user),
}, },
) )
+8 -1
View File
@@ -58,7 +58,7 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
Ordered like the unfiled list: pinned first, then most recently touched. Ordered like the unfiled list: pinned first, then most recently touched.
""" """
kept = [chat for chat in self.chats if not chat.archived] kept = [chat for chat in self.chats if not chat.archived and not chat.temporary]
kept.sort(key=lambda chat: chat.updated_at, reverse=True) kept.sort(key=lambda chat: chat.updated_at, reverse=True)
kept.sort(key=lambda chat: not chat.pinned) kept.sort(key=lambda chat: not chat.pinned)
return kept return kept
@@ -95,6 +95,13 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Never listed in the sidebar, and swept a day after the last thing said in
# it. A real row rather than something held in the browser, so a reload or a
# dropped connection does not lose the conversation -- and `Keep` clears the
# flag, because a temporary chat that turns out to matter must have a way
# out. See services/chat.py:sweep_temporary.
temporary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# A reply landed while nobody was watching this chat. Cleared when the chat # A reply landed while nobody was watching this chat. Cleared when the chat
# is next opened. `unread_notified` stops the same arrival being announced # is next opened. `unread_notified` stops the same arrival being announced
# on every poll. # on every poll.
+4
View File
@@ -62,6 +62,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# disk forever. Cheap, and startup is the natural moment for it. # disk forever. Cheap, and startup is the natural moment for it.
try: try:
from lembas.db.session import session_scope from lembas.db.session import session_scope
from lembas.services.chat import sweep_temporary
from lembas.services.files import sweep_orphans from lembas.services.files import sweep_orphans
from lembas.services.library.documents import sweep_unfiled from lembas.services.library.documents import sweep_unfiled
@@ -70,6 +71,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Documents that predate knowledge bases have nowhere to live until # Documents that predate knowledge bases have nowhere to live until
# this runs; see services/library/documents.py. # this runs; see services/library/documents.py.
sweep_unfiled(db) sweep_unfiled(db)
# Temporary chats older than a day. Startup only, like the sweeps
# above it -- see services/chat.py:sweep_temporary.
sweep_temporary(db)
except Exception: # noqa: BLE001 - housekeeping must never block startup except Exception: # noqa: BLE001 - housekeeping must never block startup
log.exception("orphaned upload sweep failed") log.exception("orphaned upload sweep failed")
+47 -2
View File
@@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from lembas.db.models import ( from lembas.db.models import (
@@ -32,6 +33,9 @@ FORWARDED_PARAMS = frozenset(
MAX_TITLE_LENGTH = 60 MAX_TITLE_LENGTH = 60
# How long a temporary chat survives after the last thing said in it.
TEMPORARY_LIFETIME = timedelta(hours=24)
def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]: def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]:
"""Find the connection and model a chat should use. """Find the connection and model a chat should use.
@@ -387,8 +391,49 @@ def create_message(
return message return message
def sweep_temporary(db: DBSession, older_than: timedelta = TEMPORARY_LIFETIME) -> int:
"""Delete temporary chats nobody has touched for a day.
Age is measured from the newest message rather than from the chat row's own
timestamps. `created_at` would destroy a conversation still in use at hour
23, and `updated_at` does not move when a message is inserted -- `onupdate`
fires on an UPDATE of the chat, and adding a message is not one.
Startup only, like files.sweep_orphans beside it. A server that runs for a
month sweeps once; that is the trade the existing sweep already makes, and a
scheduler is a whole new concern for a single-worker application.
"""
cutoff = datetime.now(UTC) - older_than
newest = (
select(Message.chat_id, func.max(Message.created_at).label("last"))
.group_by(Message.chat_id)
.subquery()
)
stale = list(
db.scalars(
select(Chat)
.outerjoin(newest, newest.c.chat_id == Chat.id)
.where(
Chat.temporary.is_(True),
func.coalesce(newest.c.last, Chat.created_at) < cutoff,
)
)
)
if not stale:
return 0
files_service.remove_files_for_chats(db, [chat.id for chat in stale])
for chat in stale:
db.delete(chat)
db.commit()
log.info("swept %d temporary chat(s)", len(stale))
return len(stale)
def user_chats(db: DBSession, user_id: str, *, folder_id: str | None = None) -> list[Chat]: def user_chats(db: DBSession, user_id: str, *, folder_id: str | None = None) -> list[Chat]:
query = select(Chat).where(Chat.user_id == user_id, Chat.archived.is_(False)) query = select(Chat).where(
Chat.user_id == user_id, Chat.archived.is_(False), Chat.temporary.is_(False)
)
if folder_id is not None: if folder_id is not None:
query = query.where(Chat.folder_id == folder_id) query = query.where(Chat.folder_id == folder_id)
return list(db.scalars(query.order_by(Chat.pinned.desc(), Chat.updated_at.desc()))) return list(db.scalars(query.order_by(Chat.pinned.desc(), Chat.updated_at.desc())))
+20
View File
@@ -435,6 +435,26 @@ def claim(db: DBSession, *, ids: list[str], user_id: str, message_id: str) -> li
return pending return pending
def remove_files_for_chats(db: DBSession, chat_ids: list[str]) -> int:
"""Unlink the files belonging to these chats' attachments.
Deleting a Chat cascades to its Message and Attachment *rows* but leaves the
files on disk -- only `sweep_orphans` unlinks anything, and it only looks at
uploads that were never attached. Anything that deletes chats has to call
this first, while the rows still say which files to remove.
"""
if not chat_ids:
return 0
removed = 0
for attachment in db.scalars(select(Attachment).where(Attachment.chat_id.in_(chat_ids))):
path = stored_path(attachment.stored_name)
if path is not None and path.exists():
path.unlink(missing_ok=True)
removed += 1
return removed
def sweep_orphans(db: DBSession, older_than: timedelta = ORPHAN_AGE) -> int: def sweep_orphans(db: DBSession, older_than: timedelta = ORPHAN_AGE) -> int:
"""Delete uploads that were never attached to a message. """Delete uploads that were never attached to a message.
+4 -2
View File
@@ -443,8 +443,10 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
chat.title_generated = True chat.title_generated = True
# Nobody watching when it landed, so it is news. The chat page # Nobody watching when it landed, so it is news. The chat page
# clears this when it is next opened. # clears this when it is next opened. Not for a temporary chat:
if generation.followers == 0: # there is no sidebar row for the dot, and the toast would name a
# chat nobody can navigate to.
if generation.followers == 0 and not chat.temporary:
chat.unread = True chat.unread = True
chat.unread_notified = False chat.unread_notified = False
+1
View File
@@ -345,6 +345,7 @@ button, input, textarea, select {
.badge--leaf { background: var(--leaf-soft); color: var(--leaf); } .badge--leaf { background: var(--leaf-soft); color: var(--leaf); }
.badge--success { background: var(--success-soft); color: var(--success); } .badge--success { background: var(--success-soft); color: var(--success); }
.badge--danger { background: var(--danger-soft); color: var(--danger); } .badge--danger { background: var(--danger-soft); color: var(--danger); }
.badge--warning { background: var(--warning-soft); color: var(--warning); }
/* --- Application shell ----------------------------------------------------- */ /* --- Application shell ----------------------------------------------------- */
.shell { display: flex; height: 100dvh; overflow: hidden; } .shell { display: flex; height: 100dvh; overflow: hidden; }
@@ -50,6 +50,9 @@
{% if not chat and current_model %} {% if not chat and current_model %}
<input type="hidden" name="model_id" value="{{ current_model.model_id }}"> <input type="hidden" name="model_id" value="{{ current_model.model_id }}">
{% endif %} {% endif %}
{% if not chat and starting_temporary %}
<input type="hidden" name="temporary" value="true">
{% endif %}
<div class="composer__row"> <div class="composer__row">
{% if can.get("files.upload") %} {% if can.get("files.upload") %}
+26
View File
@@ -25,6 +25,32 @@
</h1> </h1>
<div class="topbar__actions"> <div class="topbar__actions">
{#
A link, not a script: the flag lives in the URL, so it survives a
reload and can be bookmarked. On an existing temporary chat the same
corner explains what temporary means and offers the way out -- without
one, a conversation that turns out to matter is destroyed a day later
with no recourse.
#}
{% if chat and chat.temporary %}
<span class="badge badge--warning"
title="Not listed in the sidebar, and removed 24 hours after the last message.">
Temporary
</span>
<button class="btn btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/keep" hx-swap="none"
title="Keep this chat and list it in the sidebar">
{{ icon("pin", "icon--sm") }} Keep
</button>
{% elif not chat and can.get("chat.create") %}
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
href="{{ '/chat' if starting_temporary else '/chat?temporary=1' }}"
aria-label="Temporary chat"
title="{% if starting_temporary %}Starting a temporary chat. Click to go back to a normal one.{% else %}Start a temporary chat: not listed in the sidebar, and removed after a day.{% endif %}">
{{ icon("clock") }}
</a>
{% endif %}
{% if models %} {% if models %}
{% if can.get("chat.model_select") or not chat %} {% if can.get("chat.model_select") or not chat %}
{% include "chat/_model_picker.html" %} {% include "chat/_model_picker.html" %}
@@ -114,6 +114,10 @@
<rect x="3.5" y="4.5" width="17" height="4" rx="1.2"/> <rect x="3.5" y="4.5" width="17" height="4" rx="1.2"/>
<path d="M5 8.5v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9M10 12.5h4"/> <path d="M5 8.5v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9M10 12.5h4"/>
</symbol> </symbol>
<symbol id="i-clock" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8.5"/>
<path d="M12 7.2V12l3.2 2"/>
</symbol>
<symbol id="i-warning" viewBox="0 0 24 24"> <symbol id="i-warning" viewBox="0 0 24 24">
<path d="M12 4.2 21 19.5H3Z"/> <path d="M12 4.2 21 19.5H3Z"/>
<path d="M12 10v4M12 16.8h.01"/> <path d="M12 10v4M12 16.8h.01"/>
+222
View File
@@ -0,0 +1,222 @@
"""Temporary chats: hidden from the sidebar, and swept a day later."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Chat, Connection, Folder, Message, Model
from lembas.services import chat as chat_service
from lembas.services.crypto import encrypt
def _connection(db) -> Connection:
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="test-model"))
db.commit()
return connection
def _temporary(db, chat_id: str, *, title: str = "Passing thought") -> Chat:
chat = db.get(Chat, chat_id)
chat.temporary = True
chat.title = title
db.commit()
return chat
# --- Hidden ------------------------------------------------------------------
def test_a_temporary_chat_is_not_in_the_sidebar(client: TestClient, db, registered, make_chat):
_connection(db)
_temporary(db, make_chat())
assert "Passing thought" not in client.get("/chat").text
def test_a_temporary_chat_inside_a_folder_is_not_listed(
client: TestClient, db, registered, make_chat
):
"""The folder branch renders through the relationship, which is why this
needs asserting separately from the unfiled list."""
_connection(db)
client.post("/api/folders", data={"name": "Quests"})
folder = db.scalar(select(Folder))
chat_id = make_chat()
client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id})
_temporary(db, chat_id)
page = client.get("/chat").text
assert "Passing thought" not in page
assert "Empty" in page
def test_a_temporary_chat_gets_no_unread_dot(client: TestClient, db, registered, make_chat):
"""There is no sidebar row for the dot, and the toast would name a chat
nobody can navigate to."""
_connection(db)
chat = _temporary(db, make_chat())
chat.unread = True
db.commit()
response = client.get("/api/chats/unread")
assert chat.id not in response.text
assert "HX-Trigger" not in response.headers
def test_user_chats_excludes_temporary_ones(db, registered, make_chat, user_id):
_connection(db)
_temporary(db, make_chat())
assert chat_service.user_chats(db, user_id) == []
def test_a_finished_temporary_reply_is_not_unread(db, registered, make_chat):
from lembas.services import generation as generation_service
_connection(db)
chat_id = make_chat()
_temporary(db, chat_id)
reply = Message(chat_id=chat_id, role="assistant", content="", complete=False)
db.add(reply)
db.commit()
generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
generation.content.append("Waybread.")
generation_service._persist(generation, "", 0.0)
db.expire_all()
assert db.get(Chat, chat_id).unread is False
# --- Starting one -------------------------------------------------------------
def test_the_new_chat_screen_carries_the_flag(client: TestClient, db, registered):
_connection(db)
assert 'name="temporary"' not in client.get("/chat").text
assert 'name="temporary"' in client.get("/chat?temporary=1").text
def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered):
_connection(db)
client.post("/api/chats/start", data={"content": "hello", "temporary": "true"})
assert db.scalar(select(Chat)).temporary is True
def test_starting_an_ordinary_chat_does_not(client: TestClient, db, registered):
_connection(db)
client.post("/api/chats/start", data={"content": "hello"})
assert db.scalar(select(Chat)).temporary is False
# --- Keeping one --------------------------------------------------------------
def test_keeping_a_chat_clears_the_flag(client: TestClient, db, registered, make_chat):
_connection(db)
chat = _temporary(db, make_chat())
response = client.post(f"/api/chats/{chat.id}/keep")
assert response.status_code == 204
assert response.headers["HX-Refresh"] == "true"
db.refresh(chat)
assert chat.temporary is False
assert "Passing thought" in client.get("/chat").text
def test_keeping_someone_elses_chat_is_not_found(client: TestClient, db, registered, make_chat):
from lembas.db.models import User
from lembas.security.passwords import hash_password
_connection(db)
chat = _temporary(db, make_chat())
other = User(name="Sam", email="s@shire.test", password_hash=hash_password("x"))
db.add(other)
db.commit()
chat.user_id = other.id
db.commit()
assert client.post(f"/api/chats/{chat.id}/keep").status_code == 404
# --- The sweep ----------------------------------------------------------------
def _aged(db, chat_id: str, hours: float) -> None:
"""Backdate the chat's newest message, which is what the sweep measures."""
when = datetime.now(UTC) - timedelta(hours=hours)
message = Message(chat_id=chat_id, role="user", content="hello", created_at=when)
db.add(message)
db.commit()
def test_the_sweep_removes_a_stale_temporary_chat(db, registered, make_chat):
_connection(db)
chat_id = make_chat()
_temporary(db, chat_id)
_aged(db, chat_id, 25)
assert chat_service.sweep_temporary(db) == 1
assert db.get(Chat, chat_id) is None
def test_the_sweep_keeps_one_still_in_use(db, registered, make_chat):
"""Age is measured from the newest message. A conversation still going at
hour 23 must not vanish mid-sentence."""
_connection(db)
chat_id = make_chat()
_temporary(db, chat_id)
_aged(db, chat_id, 40)
_aged(db, chat_id, 0.5)
assert chat_service.sweep_temporary(db) == 0
assert db.get(Chat, chat_id) is not None
def test_the_sweep_leaves_ordinary_chats_alone(db, registered, make_chat):
_connection(db)
chat_id = make_chat()
_aged(db, chat_id, 500)
assert chat_service.sweep_temporary(db) == 0
assert db.get(Chat, chat_id) is not None
def test_a_temporary_chat_with_no_messages_ages_from_its_own_row(db, registered, make_chat):
_connection(db)
chat_id = make_chat()
chat = _temporary(db, chat_id)
chat.created_at = datetime.now(UTC) - timedelta(hours=30)
db.commit()
assert chat_service.sweep_temporary(db) == 1
def test_the_sweep_unlinks_the_files_too(client: TestClient, db, registered, make_chat):
"""Deleting a chat cascades the rows but leaves the files on disk. Anything
that deletes chats has to remove them while the rows still say which."""
from lembas.db.models import Attachment
from lembas.services.files import stored_path
_connection(db)
chat_id = make_chat()
client.post("/api/files", files={"file": ("notes.txt", b"some words", "text/plain")})
attachment = db.scalar(select(Attachment))
attachment.chat_id = chat_id
message = Message(chat_id=chat_id, role="user", content="look")
db.add(message)
db.commit()
attachment.message_id = message.id
db.commit()
path = stored_path(attachment.stored_name)
assert path is not None and path.exists()
_temporary(db, chat_id)
db.query(Message).filter(Message.id == message.id).update(
{"created_at": datetime.now(UTC) - timedelta(hours=30)}
)
db.commit()
chat_service.sweep_temporary(db)
assert not path.exists()