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
+43 -3
View File
@@ -375,6 +375,43 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
load_note(conn, &id)
}
/// How long one editing session is assumed to last.
///
/// Inside this window a note's body may be written any number of times and only the
/// FIRST write snapshots. That is what makes an idle-debounced autosave affordable:
/// a write costs a write, not a write plus a revision.
const REVISION_WINDOW_MINUTES: i64 = 10;
/// Whether a body change earns a snapshot of the pre-edit body.
///
/// Two conditions. The body must actually differ — re-saving identical text is not a
/// version of anything. And the note must not already carry a revision from this
/// editing session.
///
/// The session rule is what keeps version history worth reading. Because
/// [`snapshot_revision`] stores the body as it was BEFORE the edit, the first write
/// of a session captures the note as you found it, and every write after it inside
/// the window adds nothing. One revision per sitting falls out of the window on its
/// own — no "commit" the client has to declare, and no protocol surface to carry it,
/// which matters because sync-apply takes this same path.
fn should_snapshot(conn: &Connection, id: &str, new_body: &str) -> rusqlite::Result<bool> {
let current: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
if current == new_body {
return Ok(false);
}
// String comparison, not date maths: timestamps are RFC3339 UTC with a fixed
// millisecond field (see the module header), so lexical order IS chronological.
let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES))
.to_rfc3339_opts(SecondsFormat::Millis, true);
let recent: i64 = conn.query_row(
"SELECT COUNT(*) FROM note_revisions WHERE note_id = ?1 AND created_at >= ?2",
params![id, cutoff],
|r| r.get(0),
)?;
Ok(recent == 0)
}
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let body: String =
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))?;
@@ -391,9 +428,12 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
.as_object()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("changes must be an object".into()))?;
// Snapshot the pre-edit body before changing it (version history).
if obj.contains_key("body") {
snapshot_revision(conn, id)?;
// Snapshot the pre-edit body before changing it (version history) — but only
// once per editing session, and only if it actually changed. See should_snapshot.
if let Some(body) = obj.get("body").and_then(|v| v.as_str()) {
if should_snapshot(conn, id, body)? {
snapshot_revision(conn, id)?;
}
}
for (k, v) in obj {