Files
inkwell/src/thoughtsync/retention.py
T
bvandeusen 95aa10c2c3
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Remove the title field — a note is named by its first line
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME
stays — search results, export filenames and the command palette all need one —
but nothing is typed into it any more. `display_title` is now the first non-empty
line of the body, falling back to the first checklist item.

That fallback is what step 2 bought, and the reason this could not go first: a
checklist had no body to be named from, so the title was its only name. Now every
note has a body, and a note that is only a checklist is named by its first item.

Gone everywhere: the column and note_revisions.title (0026), the field on the
core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7),
`normalize_title`, the wire field, the FFI record and `NoteEdit::Title` /
`ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and
the Android title field in both the compose sheet and the editor.

**The search vector had to be rebuilt, not just left alone.** `notes.search_vector`
is a STORED GENERATED column whose expression names `title` — Postgres refuses to
drop a column another generated column depends on. It is dropped and recreated over
`display_title` at weight A, which keeps the original intent: a note's NAME ranks
above the rest of its body.

**An imported title becomes the note's first body line.** Keep notes carry one, and
so does any ThoughtSync export taken before this. Dropping it would silently lose
text someone wrote; folding it in puts it exactly where a name now lives, so the
note arrives named as it was. Skipped when the body already opens with that line,
so re-importing an export this code produced doesn't stack duplicates.

Two smaller things fell out. The Android editor loses its bold first field — one
weight throughout, because the first line is the note's name but not a different
KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s
justification comment moved to `ClearRemindAt`, which is now the surviving example
of why NoteEdit is a list rather than a struct of options.

Protocol note corrected to say what actually shipped: v2 is "no kind, no title",
one bump for the pair.

Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests
all green before pushing. It caught four things — orphaned serde attributes where
fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview
still has one), nine retention fixtures inserting a dropped column, and four
rustfmt diffs.
2026-08-22 19:33:57 -04:00

168 lines
7.4 KiB
Python

"""Trash retention — what "permanently deleted" means, and when it happens by itself.
Two things live here, deliberately together:
**`purge_note`** — the single definition of destroying a note. Three callers reach
permanent deletion by different routes (the user's Delete forever in the web UI,
a client's `op=delete` over sync, and the sweeper below), and if each had its own
idea of what to tear down they would drift — one would forget the files, another
the revision history, and "permanently deleted" would quietly mean three different
things depending on how you got there.
**The sweeper** — trash that nobody empties is not free: a trashed note keeps its
attachment BYTES on disk for as long as it sits there. So trash expires. The window
is the `trash_retention_days` setting (default 30, `0` = keep forever), re-read on
every pass so a change in admin Settings takes effect without a restart.
A purged note is not a deleted ROW — it's a content-less tombstone. That's what lets
an offline client that reappears next month learn the note is gone instead of
faithfully resurrecting it on the next push.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete as sa_delete
from sqlalchemy import select
from .config import Config
from .db import session_scope
from .models.label import NoteLabel
from .models.note import Note
from .models.note_attachment import NoteAttachment
from .models.note_item import NoteItem
from .models.note_link_preview import NoteLinkPreview
from .models.note_revision import NoteRevision
from .settings import get_setting
logger = logging.getLogger(__name__)
# How often the sweeper wakes. Retention is measured in days, so anything under
# "a few times a day" buys nothing but load — a note trashed at 09:00 expiring at
# 14:00 rather than 09:00 thirty days later is not a difference anyone can feel.
SWEEP_INTERVAL_SECONDS = 6 * 60 * 60
# Let the app finish booting (migrations, first requests) before the first sweep.
SWEEP_STARTUP_DELAY_SECONDS = 60
# Rows purged per transaction. A long-neglected install could have thousands of
# expired notes on the first sweep; committing in batches keeps that from becoming
# one enormous transaction holding locks while it deletes files.
SWEEP_BATCH = 200
def expired_before(now: datetime, retention_days: int) -> datetime | None:
"""The cutoff: trash older than this has expired. `None` = retention is off.
Kept separate from the query so the window arithmetic — including the two ways
to say "never" (0 and negative, the latter reachable by typing a stray minus in
Settings) — is testable without a database.
"""
if retention_days <= 0:
return None
return now - timedelta(days=retention_days)
async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
"""Turn a note into a content-less tombstone: delete its children (and the
attachment files on disk), clear its content, stamp `purged_at`.
The row survives on purpose — offline clients read it off the delta feed and
learn the note is gone. Everything that carries the note's CONTENT goes, and
that includes history: a revision row holds the full body, so leaving revisions
behind would mean the text of a "permanently deleted" note is still on the
server, recoverable by anyone who can read the table.
"""
atts = (await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id == note.id))).all()
for a in atts:
try:
(Config.media_root() / a.path).unlink(missing_ok=True)
except OSError:
# A missing or unreadable file must not strand the row: the DB record is
# what the user asked us to destroy, and a failed unlink leaving it in
# place would make the note reappear whole on the next sweep.
logger.warning("couldn't remove attachment file %s during purge", a.path, exc_info=True)
await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id))
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
note.body = ""
note.display_title = ""
# `deleted_at` deliberately SURVIVES. It's still true — that is when the note was
# deleted — and keeping it means every ordinary query, present and future, that
# says "not trashed" (`deleted_at IS NULL`) excludes tombstones for free. Clearing
# it would leave a content-less row looking like a perfectly normal active note,
# and it would surface on the board as a blank card. Only the Trash view, which
# asks for `deleted_at IS NOT NULL`, has to name `purged_at` explicitly.
note.remind_at = None
note.purged_at = datetime.now(timezone.utc)
if edited_at is not None:
note.updated_at = edited_at
async def sweep_expired_trash(db, retention_days: int, *, now: datetime | None = None) -> int:
"""Purge every note whose trash has expired. Returns how many were purged.
Runs across ALL owners — it's a server-wide policy, not a per-user action, and
the sweeper has no session to scope it by (rule 47 is about honoring the ACL on
user-initiated reads, not about exempting rows from server maintenance).
"""
cutoff = expired_before(now or datetime.now(timezone.utc), retention_days)
if cutoff is None:
return 0
total = 0
while True:
expired = (
await db.scalars(
select(Note)
.where(
Note.deleted_at.is_not(None),
Note.deleted_at < cutoff,
# Already a tombstone. Without this the purge would re-run on
# every sweep forever, bumping sync_revision each time and
# handing clients an endless stream of "news" about one note.
Note.purged_at.is_(None),
)
.order_by(Note.deleted_at)
.limit(SWEEP_BATCH)
)
).all()
if not expired:
return total
for note in expired:
await purge_note(db, note)
await db.commit()
total += len(expired)
async def sweep_once() -> int:
"""One sweep against the live retention setting, in its own session."""
async with session_scope() as db:
try:
days = int(await get_setting(db, "trash_retention_days"))
except (KeyError, TypeError, ValueError):
return 0
return await sweep_expired_trash(db, days)
async def run_sweeper() -> None:
"""The background loop. Started in `before_serving`, cancelled on shutdown.
A sweep failure (DB blip, unreadable media directory) must never take the loop
down with it — the next pass simply finds the same expired rows and tries again.
"""
await asyncio.sleep(SWEEP_STARTUP_DELAY_SECONDS)
while True:
try:
purged = await sweep_once()
if purged:
logger.info("trash retention: purged %d expired note(s)", purged)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("trash retention sweep failed; will retry next pass")
await asyncio.sleep(SWEEP_INTERVAL_SECONDS)