A write should not cost a revision
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m42s

Every body change snapshotted into history — core/src/local/store.rs and
notes/__init__.py both — so a write was expensive, and the clients
compensated by writing as rarely as they could. BoardViewModel says it
outright: "Saved on close rather than per keystroke, so a session of typing
costs one write and one revision snapshot."

That is durability paying for version history. An app kill mid-session lost
everything typed, so that the revision list would stay tidy. The safety
property is worth more than the feature it was subsidising, and no
comparable product makes this trade: Keep and Apple Notes write
continuously with no history, Docs and Notion write continuously and
coalesce history behind the scenes, Obsidian debounces and snapshots on an
interval. Save-on-close is the outlier, and this coupling is why we had it.

A body change now earns a snapshot only if it is the first of an editing
session — the body actually differs, and the note carries no revision from
the last ten minutes.

Session granularity falls out of the window rather than being declared. A
snapshot stores the body as it was BEFORE the edit, so the first write of a
sitting captures the note as you found it and every write after it inside
the window adds nothing. One revision per sitting, with no commit flag for
a client to send and no wire surface to carry it.

That is why it is a time rule and not a protocol one. sync.py applies pushed
bodies through the same check, so a client autosaving every second cannot
make the server snapshot every second either — which a client-declared
commit point could not have guaranteed without a protocol bump.

Restoring a revision still snapshots unconditionally: a considered act, not
a keystroke, and it stays undoable.

Unblocks idle-debounced autosave, an honest updated_at, and the "Edited just
now" line the editor is getting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 21:47:01 -04:00
co-authored by Claude Opus 5
parent 24685556b7
commit 2707054563
5 changed files with 181 additions and 7 deletions
+70
View File
@@ -16,6 +16,7 @@ deployment has ever seen.
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
import pytest
import pytest_asyncio
@@ -30,6 +31,8 @@ from thoughtsync.models.user import User
from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings
from thoughtsync.notes.helpers import derive_display_title
from thoughtsync.models.note_link_preview import NoteLinkPreview
from thoughtsync.models.note_revision import NoteRevision
from thoughtsync.revisions import REVISION_WINDOW_MINUTES, should_snapshot
from thoughtsync.sync import _apply_note_items
from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls
@@ -414,3 +417,70 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db):
assert row["type"] == "int"
assert row["minimum"] is not None and row["maximum"] is not None
assert row["description"], f"{row['key']} has no description to explain itself"
async def _revision_count(db, note_id) -> int:
rows = (await db.scalars(select(NoteRevision.id).where(NoteRevision.note_id == note_id))).all()
return len(rows)
async def test_a_session_of_edits_costs_one_revision(db, owner):
"""The change that makes autosave affordable.
Version history used to snapshot on EVERY body write, so the clients saved as
rarely as they could — only when an editor closed — and a crash mid-session lost
everything typed. Durability was paying for history. Now a sitting earns one
revision no matter how many times it is written, so a client can write whenever
it likes.
"""
note = Note(owner_id=owner.id, body="one", display_title="one")
db.add(note)
await db.commit()
# A session's worth of autosaves.
for text_ in ("one two", "one two three", "one two three four"):
if await should_snapshot(db, note.id, note.body, text_):
db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = text_
await db.commit()
assert await _revision_count(db, note.id) == 1
# And it is the body as it was BEFORE the sitting, not some midpoint — which is
# what makes one-per-session the useful granularity rather than an arbitrary one.
kept = (await db.scalars(select(NoteRevision.body).where(NoteRevision.note_id == note.id))).all()
assert kept == ["one"]
async def test_rewriting_the_same_text_is_not_a_version(db, owner):
note = Note(owner_id=owner.id, body="unchanged", display_title="unchanged")
db.add(note)
await db.commit()
assert await should_snapshot(db, note.id, note.body, "unchanged") is False
assert await _revision_count(db, note.id) == 0
async def test_a_later_sitting_earns_its_own_revision(db, owner):
"""The window has to REOPEN, or a note edited daily would keep only its first
version forever — which would be a worse history than the one we replaced."""
note = Note(owner_id=owner.id, body="today", display_title="today")
db.add(note)
await db.flush()
# A revision from longer ago than one session: the clock is not mocked, the row
# is simply written with an older timestamp, which is what the query reads.
stale = datetime.now(timezone.utc) - timedelta(minutes=REVISION_WINDOW_MINUTES + 1)
db.add(NoteRevision(note_id=note.id, body="yesterday", created_at=stale))
await db.commit()
assert await should_snapshot(db, note.id, note.body, "tomorrow") is True
async def test_a_revision_inside_the_window_blocks_another(db, owner):
note = Note(owner_id=owner.id, body="draft", display_title="draft")
db.add(note)
await db.flush()
db.add(NoteRevision(note_id=note.id, body="earlier", created_at=datetime.now(timezone.utc)))
await db.commit()
assert await should_snapshot(db, note.id, note.body, "draft revised") is False