diff --git a/.gitignore b/.gitignore index 1102c98..a8c5e11 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ node_modules/ # IDE /.idea + +# Claude Code (Arbeitsverzeichnis, lokale Einstellungen und Worktrees) +/.claude diff --git a/docs/research/historie-nachruesten.md b/docs/research/historie-nachruesten.md new file mode 100644 index 0000000..dc975cb --- /dev/null +++ b/docs/research/historie-nachruesten.md @@ -0,0 +1,941 @@ +# Was es kostet, Historie später möglich zu halten + +> Research zu Gitea-Issue [#22](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/22). Stand: 2026-08-28. +> Recherchiert gegen Primärquellen (Hersteller-Doku, Quellcode auf GitHub, Microsoft Learn, docs.rs, crates.io). +> **Nichts davon wurde gebaut oder kompiliert.** Es wurde kein Anwendungscode geschrieben und keine Dependency +> hinzugefügt. Alles unter „Belegt" stammt aus der jeweils genannten Quelle; alles Abgeleitete ist als solches +> markiert. +> +> Historie ist **nicht geplant**. Dieses Dokument beschafft nur die Fakten für die Schreib-Entscheidung in +> [#19](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/19). + +--- + +## Kurzantwort + +**Nein — ein Schreibverfahren „temporäre Datei schreiben, dann umbenennen" schließt eine spätere Historie +nicht aus. Es ist im Gegenteil das *einzige* der beiden zur Wahl stehenden Verfahren, das Historie +überhaupt billig macht.** + +Die Begründung in einem Satz: Historie braucht die **Vorfassung**, und beim Umbenennen liegt die Vorfassung +zum Zeitpunkt des Schreibens noch unversehrt auf der Platte — ein direktes `fs::write` hat sie da schon +vernichtet. + +Der Einwand aus der Beratungsrunde trifft eine reale Gefahr, aber am falschen Ort. Teuer wird Historie nicht +durch das **Wie** des Schreibens, sondern durch das **Wo**: Wenn nach einem halben Jahr fünf Stellen im Code +eine Notiz auf die Platte schreiben, muss man fünf Stellen anfassen. Wenn es genau eine gibt, ist Historie +eine Änderung *innerhalb dieser einen Funktion* — und alles Weitere (Format, Ablageort, Aufbewahrung) eine +Entscheidung, die man dann trifft, nicht heute. + +Der Preis dafür, Historie möglich zu halten, beträgt also: **eine Funktion.** Keine Datei, kein Format, kein +Byte auf der Platte, keine Dependency. + +Und die Asymmetrie läuft genau andersherum als behauptet: Nicht das schlichte Schreiben ist die schwer +umkehrbare Entscheidung, sondern **der Append-Log**. Von „Datei ist die Wahrheit" zu „Datei plus Sicherungen" +ist eine Ergänzung. Von „Log ist die Wahrheit" zurück ist eine Migration. Wer die Option offenhalten will, +hält sie gerade dadurch offen, dass er den Log **nicht** baut. + +Dass der Log die Einbahnstraße ist, ist dabei nicht nur ein Architektur-Argument: Die Automerge-Maintainer +schreiben selbst, dass sich Historie **nicht verwerfen lässt, ohne das Dokument zu ersetzen**, und Yjs +begründet in seiner README, warum Tombstones konstruktionsbedingt liegenbleiben müssen (3.2). + +--- + +## 1. Was Historie technisch überhaupt braucht + +Bevor man Kosten schätzt, lohnt es, die Anforderung auf ihre Bestandteile zu zerlegen. Eine Historie — +gleich welcher Bauart — braucht genau vier Dinge: + +1. **Einen Auslöser.** Ein Zeitpunkt, an dem entschieden wird: jetzt entsteht eine neue Fassung. +2. **Die Vorfassung.** Der Inhalt, der gleich ersetzt wird. +3. **Einen Ablageort.** Irgendwo müssen die alten Fassungen hin. +4. **Eine Aufräumregel.** Ohne sie wächst der Ablageort unbegrenzt. + +Nur **Punkt 1 und 2 hängen am Schreibverfahren.** Punkt 3 und 4 sind reine Folgeentscheidungen und komplett +unabhängig davon, wie die `.md` auf die Platte kommt. + +Und beide, Auslöser und Vorfassung, fallen beim temp+rename-Verfahren **ohnehin an**: + +- Der **Auslöser** ist der Aufruf der Schreibfunktion. Präziser geht es nicht. +- Die **Vorfassung** liegt in dem Moment, in dem die temporäre Datei fertig geschrieben ist und das Umbenennen + ansteht, noch vollständig und unangetastet unter ihrem Pfad. Man muss sie nur lesen, bevor man umbenennt. + +Bei einem direkten `fs::write` auf die Zieldatei ist die Vorfassung dagegen nach dem ersten Byte weg. Wer dort +später Historie nachrüsten will, muss die Datei **zusätzlich vorher lesen** — was möglich, aber ein +Extra-Schritt ist, den das rename-Verfahren geschenkt bekommt. + +> **Nebenbefund, der #19 unabhängig von Historie betrifft:** [#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20) +> (Zeitstempel-Konflikt) verlangt ohnehin, dass die Schreibfunktion vor dem Schreiben den Zustand der +> Zieldatei prüft. Wer dort statt eines `stat` gleich den Inhalt liest, hat die Vorfassung ohne +> zusätzlichen Syscall in der Hand. Das ist kein Argument, es heute zu tun — nur der Beleg, dass es +> später auch nichts kostet. + +--- + +## 2. Wie vergleichbare lokale Markdown-Editoren es lösen + +Kurzüberblick, Details und Belege in den Unterabschnitten: + +| Werkzeug | Wo liegt die Historie? | Format | Intervall | Aufbewahrung | Neben den `.md`? | +|---|---|---|---|---|---| +| **Obsidian** (File recovery) | App-Systemordner, `%APPDATA%\Obsidian\` — **außerhalb** des Vault | Volltext-Snapshots | 5 Min (Default) | 7 Tage (Default) | **Nein** | +| **Obsidian Sync** (Version history) | Auf Obsidians Servern | serverseitig | — | 1 Monat (Standard) / 12 Monate (Plus) | **Nein**, kostenpflichtig | +| **Joplin** (Note History) | Tabelle `revisions` in `database.sqlite` | Diff-Kette (`diff-match-patch`, JSON) | 10 Min | 90 Tage (Default) | **Nein** — es gibt gar keine `.md` | +| **Logseq** (0.10.9) | `logseq/bak//` **im** Graph-Ordner | Volltext-Kopien, Zeitstempel im Namen | **nur bei Konflikt oder Schreibfehler**, nicht bei jedem Speichern | 6 Fassungen pro Datei | **Ja** | +| **Zettlr** | — es gibt keine | — | — | — | — | +| **Syncthing** (kein Editor, aber das beste Vorbild) | `.stversions/` **im** Sync-Ordner | Volltext-Kopien mit Zeitstempel im Namen | pro Änderung | konfigurierbar, logarithmisch ausgedünnt | **Ja** | + +**Das auffälligste Muster:** Von den untersuchten Editoren legt **keiner** seine Historie neben die +Notizdateien. Sie liegt entweder im App-Ordner (Obsidian), in einer Datenbank (Joplin) oder auf einem Server +(Obsidian Sync). Wer neben den `.md` versioniert, ist ein Sync-Werkzeug, kein Editor. + +### 2.1 Obsidian — Historie als aufgesetztes Core-Plugin, komplett außerhalb des Vault + +Obsidian ist der direkteste Vergleichspunkt: lose `.md`-Dateien in einem Ordner, dasselbe Versprechen an den +Nutzer, dieselbe Ausgangslage. + +**Belegt** (Zitate aus der offiziellen Help-Seite ): + +- „File recovery is a core plugin that protects your work from accidental deletions, file corruption, or + unwanted changes by automatically saving **complete snapshots** of your notes at regular intervals." +- „**Snapshots capture the full content of your files, not just changes**, allowing you to restore any + previous version." — also **Volltext, keine Diffs.** +- „**By default, snapshots are saved a minimum of 5 minutes from each other, and kept for 7 days.**" +- „**Snapshots are kept in the Global settings, outside of the vault**, to account for vault-related data + loss. This means that **snapshots are stored with the absolute path to the note**. If you've moved your + vault recently, you may need to move it back to the location where it was when the snapshot was taken." +- „If you are using Obsidian Sync or other syncing services, **File recovery snapshots will not sync between + devices. Snapshots are device-specific and remain local to each device.**" +- „File recovery is **not a complete backup solution**." +- Nur `.md` und `.canvas` sind wiederherstellbar. +- Aufräumen automatisch nach Ablauf der Tagesgrenze, manuell über *Clear history*. + +Der Ablageort ist damit belegt als der Electron-userData-Ordner der App — +**Windows: `%APPDATA%\Obsidian\`** (), **nicht** `.obsidian/` im Vault +und **nicht** neben den Notizen. + +**Vermutung (starkes Indiz, nicht wörtlich belegt):** Die Snapshots liegen in Obsidians IndexedDB/LevelDB. +Die Doku nennt bei IndexedDB nur Sync-State und Metadata-Cache, führt aber für File recovery **dieselbe** +Apple-Lockdown-Mode-Einschränkung auf wie für IndexedDB. Ein Serialisierungsformat ist nirgends dokumentiert; +Obsidian ist Closed Source. + +**Widerspruch in der Doku selbst, den man kennen sollte:** Der Abschnitt „Storage and performance" derselben +Seite behauptet, Snapshots „only store changed files", während der Haupttext „full content […] not just +changes" sagt. Plausible Auflösung (**Vermutung**): Nur *geänderte Dateien* werden überhaupt gesnapshottet, +jede davon aber als Volltext. + +**Zum Schreibverfahren:** Es gibt **keine offizielle Aussage**, ob Obsidian atomar (temp+rename) oder +in-place schreibt. Belegt ist nur die Plugin-API-Semantik: `Vault.process` ist dokumentiert als „**Atomically +read, modify, and save the contents of a note**" und garantiert, „that the file doesn't change between +reading the current content and writing the updated content" +(). Das ist eine **Sperre gegen konkurrierende Plugins innerhalb des +Obsidian-Prozesses**, keine dokumentierte Aussage über `rename()`-Atomarität gegenüber dem Dateisystem — +diese Unterscheidung macht die Doku nirgends explizit (**Lesart, nicht wörtlich belegt**). Der Changelog zu +1.13.4 (30.07.2026) enthält den Eintrag „Fixed a rare race condition where a file could get stuck and fail to +save until the app was restarted" () — ein Beleg +dafür, dass der Save-Pfad auch nach über zehn Jahren nicht trivial ist. + +**Warum das für #22 der wichtigste Befund ist:** Obsidians Historie ist ein **nachträglich aufgesetztes +Plugin, das mit dem Schreibverfahren des Kerns nichts zu tun hat**. Es beobachtet Dateien und legt Kopien +woanders ab. Genau das ist das „Historie ist eine Schicht *über* dem Schreiben"-Modell — von dem am längsten +etablierten Werkzeug dieser Kategorie in Produktion vorgeführt. + +**Der Preis, den Obsidian für diesen Ort zahlt** (alles wörtlich aus der Doku oben): absolute Pfade, die nach +einem Vault-Umzug brechen; keine Mitnahme beim Backup; kein Sync zwischen Geräten. Ein Mittelweg — +Historie in einem versteckten Ordner *im* Vault — hat von den untersuchten Werkzeugen keiner gewählt; für +Obsidian existiert dazu ein offener Feature-Request +(, +**Sekundärquelle, nur als Hinweis**). + +### 2.2 Joplin — der Gegenentwurf: keine `.md`, dafür Diff-Ketten in SQLite + +Joplin ist als Vergleich nur begrenzt tauglich, weil es das Versprechen, das DioNotes gibt, gar nicht erst +macht — aber es zeigt sehr genau, was man mit einer Datenbank *dahinter* gewinnt. + +**Belegt** (): `database.sqlite` ist „The central SQLite +database file where **all notes, notebooks, tags, and metadata are stored**". Es gibt keine `.md`-Dateien. +Unter Windows: `%UserProfile%\.config\joplin-desktop`. + +**Belegt** (): + +- „All the apps save a version of the modified notes **every 10 minutes**." +- Aufbewahrung „by default, for **90 days**". +- „These revisions are then **synced across all the devices**." — anders als bei Obsidian. +- Wiederherstellen kopiert in einen Ordner „Restored Notes", ohne die aktuelle Version zu verändern. +- Bei unterschiedlichen Einstellungen pro Gerät gewinnt die **kürzeste** Aufbewahrungsdauer. + +**Belegt aus dem Quellcode** (, +, +): + +- Tabelle `revisions`, Felder u. a. `title_diff`, `body_diff`, `metadata_diff`, `item_id`, `parent_id`. +- Diffs über **Google `diff-match-patch`**, serialisiert als JSON (`createTextPatch` → + `JSON.stringify(dmp.patch_make(old, new))`). +- Defaults: `revisionService.ttlDays` = **90**, `revisionService.intervalBetweenRevisions` = **10 Min** + (nicht in der UI sichtbar), `revisionService.oldNoteInterval` = **7 Tage**. +- Die erste Revision einer Notiz ist ein Volltext-Patch gegen den Leerstring; Folgerevisionen sind Diffs mit + `parent_id` auf die Vorgängerin. `mergeDiffs()` läuft die Kette bis zur Wurzel zurück. +- **Aufräumen mit Rebase am Kopf** — der interessanteste Teil. Kommentar wörtlich aus `Revision.ts`: + + > „When deleting old revisions, we need to make sure that the oldest surviving revision is a 'merged' one + > (as opposed to a diff from a now deleted revision). So every time we deleted a revision, we need to find + > if there's a corresponding surviving revision and modify that revision into a 'merged' one." + + Konkret: Die älteste überlebende Revision wird rekonstruiert und als Volltext-Patch gegen den Leerstring + neu geschrieben, `parent_id` auf `''`. Damit bleibt die Kettenlänge auf das TTL-Fenster begrenzt. +- Änderungen, die aus **Sync oder Entschlüsselung** stammen, werden ausgefiltert — nur echte lokale Edits + erzeugen Revisionen. + +**Die Lehre für DioNotes:** Diff-Ketten sind sparsam, aber nicht umsonst. Sie kosten eine Rekonstruktion in +`O(Kettenlänge)` bei **jedem Anlegen und jedem Anzeigen** einer Revision, plus eine nicht triviale +Rebase-Logik beim Aufräumen, plus einen dokumentierten Fehlerpfad, wenn eine zu löschende Revision noch +verschlüsselt ist. Obsidian hat für dasselbe Feature Volltext gewählt und dafür die Aufbewahrung auf 7 statt +90 Tage gesetzt. **Retention-Dauer und Speicherstrategie hängen direkt zusammen** — das ist die eigentliche +Stellschraube, und sie liegt vollständig in der Zukunft. + +### 2.3 Logseq — Sicherungskopien im Workspace, aber nur wenn es brenzlig wird + +Logseq ist für DioNotes der **lehrreichste** Fall, weil es als einziges untersuchtes Werkzeug seine +Sicherungskopien **neben die Notizen legt** — genau der Mittelweg aus der Ticket-Frage — und weil dabei +sichtbar wird, wo dieser Weg scheuert. + +> **Versionsvorbehalt:** Der `master`-Branch von Logseq ist inzwischen die DB-basierte Neuentwicklung ohne +> `handler/file.cljs`. Alle Datei-relevanten Belege stammen deshalb aus **Tag `0.10.9`**, der letzten +> dateibasierten Release-Linie. + +**Belegt** — drei generierte Verzeichnisse im Graph-Ordner: + +| Pfad | Zweck | Beleg | +|---|---|---| +| `logseq/bak` | Sicherung überschriebener Dateien | [`backup_file.cljs#L7`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L7) | +| `logseq/version-files/local` | explizit angeforderte Versionen | [`backup_file.cljs#L8`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L8) | +| `logseq/.recycle` | Papierkorb — Löschen ist ein `renameSync` dorthin | [`handler.cljs#L55-L67`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L55-L67) | + +**Namenskonvention** — pro Datei ein eigener Unterordner, Zeitstempel im Dateinamen +([`backup_file.cljs#L10-L51`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L10-L51)): + +``` +/logseq/bak/pages/Foo/2024-01-15T10_23_45.678Z.Desktop.md + └ relativer Pfad ┘└ ISO-8601, ":" → "_" ┘└ Plattform ┘ +``` + +**Der wichtigste Befund: Es wird NICHT bei jedem Speichern gesichert.** Es gibt genau drei Auslöser +(**belegt**): + +1. **Erkannte Abweichung zwischen Platte und DB beim Schreiben** + ([`fs/node.cljs#L54-L57`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/fs/node.cljs#L54-L57)) — + und selbst dann filtert der Electron-Handler ein zweites Mal und sichert nur, wenn der Diff eine + *Löschung* zeigt + ([`handler.cljs#L75-L80`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L75-L80)): + ```clojure + (when (and (string? db-content) (string? new-content) + (string-some-deleted? db-content new-content)) + (backup-file/backup-file repo :backup-dir path ...)) + ``` +2. **Schreibfehler** (EBUSY, Lock, fehlende Rechte) — mit Notification „A backup file was saved to …" + ([`handler.cljs#L117-L140`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L117-L140)). +3. **Externe Dateiänderung über den Watcher**, bevor sie in die DB übernommen wird — Quellcode-Kommentar + wörtlich: *„save the previous content in a versioned bak file to avoid data overwritten."* + ([`watcher_handler.cljs#L44-L56`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/fs/watcher_handler.cljs#L44-L56)). + +Maintainer `cnrpman` fasst es zusammen: *„Basically it writes to `bak` when there's a dangerous file +operation… **It's a safe net.**"* ([Issue #3370](https://github.com/logseq/logseq/issues/3370#issuecomment-1556840114)). + +**Das ist keine Historie, sondern ein Fangnetz** — und genau das macht es für DioNotes interessant: Der +billigste denkbare Nutzen (Datenverlust bei Konflikten verhindern) wird mit dem billigsten denkbaren +Mechanismus erkauft, ohne dass irgendetwas am Schreibverfahren hängt. + +**Aufräumen — es gibt eine Grenze, sie greift nur zu lokal** (**belegt**, +[`backup_file.cljs#L27-L34`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/backup_file.cljs#L27-L34)): + +```clojure +(defn- truncate-old-versioned-files! + "reserve the latest 6 version files" + [dir] + ... (drop 6 (reverse (sort files))) ... removeSync ...) +``` + +Warum der Ordner trotzdem wächst (**Ableitung aus dem Code**, deckt sich mit den Nutzerberichten): + +- Die Grenze gilt **pro Datei-Unterordner**; die **Zahl der Unterordner ist unbegrenzt**. +- Aufgeräumt wird **nur beim Schreiben eines neuen Backups in genau diesen Ordner**. Kein Startup-Sweep, + kein Hintergrundjob. Ordner von Dateien, die nie wieder konfligieren, bleiben ewig stehen. +- Sortiert wird rein lexikografisch — funktioniert nur, weil ISO-8601 lexikografisch = chronologisch ist. + +Die Retention-Zahl wanderte über die Jahre: nie getrunct (≤ 0.6.1) → 10 +([PR #4421](https://github.com/logseq/logseq/pull/4421), Commit-Text: *„Files in the logseq/bak folder are +never truncated"*) → 3 → 6. Gründer `tiensonqin` empfiehlt in +[Issue #4819](https://github.com/logseq/logseq/issues/4819): *„**I suggest to cleanup backups using some +cron scripts.**"* Es gibt **keine offizielle Doku** zum bak-Ordner und **keinen Off-Schalter** +([Forum, Aug 2025](https://discuss.logseq.com/t/add-option-to-disable-backups-logseq-bak-folder/33107), +unbeantwortet). + +**Korrektur einer verbreiteten Annahme:** Beschwerden über *Wachstum* gibt es reichlich +([#4819](https://github.com/logseq/logseq/issues/4819), [#3588](https://github.com/logseq/logseq/issues/3588) +„600 or more backup files", [#3370](https://github.com/logseq/logseq/issues/3370) offen seit 2021, +[Discussion #8624](https://github.com/logseq/logseq/discussions/8624) seit drei Jahren unbeantwortet). Aber +**keine Primärquelle nennt eine konkrete GB-Zahl.** Belegt ist unbegrenztes Ordner-Wachstum, nicht +unbegrenztes Größenwachstum. + +**Weitere belegte Details:** + +- **Nicht atomar geschrieben:** `fs/writeFileSync path content`, mit vorherigem `chmodSync` auf `644`, falls + die Datei nicht schreibbar ist + ([`handler.cljs#L117-L126`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/handler.cljs#L117-L126)). +- **Watcher: chokidar**, `awaitWriteFinish true` + ([`fs_watcher.cljs`](https://github.com/logseq/logseq/blob/0.10.9/src/electron/electron/fs_watcher.cljs)). +- **Konflikt:** Vergleich **vor** dem Schreiben; bei Abweichung wird **nicht geschrieben**, sondern ein Modal + gezeigt mit zwei **editierbaren** Textareas („On disk:" / „In Logseq:"), je einem Button „Select this" — + kein Merge, kein Abbrechen + ([`components/diff.cljs#L27-L90`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/components/diff.cljs#L27-L90)). +- **Undo überlebt den Neustart nicht.** Der Stack ist ein reines In-Memory-Atom über + DataScript-Datoms — `(def ^:private undo-redo-states (atom {}))` + ([`undo_redo.cljs#L16-L28`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/modules/editor/undo_redo.cljs#L16-L28)). + Die offizielle Doku formuliert es vorsichtig: *„during their editing sessions"* + ([docs: Undo and Redo](https://github.com/logseq/docs/blob/master/pages/Undo%20and%20Redo.md)). +- **Es gibt keine UI, die bak-Versionen anzeigt oder wiederherstellt** — nur „Open page backups directory", + das den Explorer öffnet. Der Menüpunkt „View page history" ist dagegen **Git**-basiert + (`shell/get-file-latest-git-log`) und setzt ein eigenes Git-Setup des Nutzers voraus + ([`page_menu.cljs#L93-L159`](https://github.com/logseq/logseq/blob/0.10.9/src/main/frontend/components/page_menu.cljs#L93-L159)). + +**Die Lehre:** Selbst das Werkzeug, das im Workspace sichert, hat sich gegen „bei jedem Speichern" und für +„nur wenn es brenzlig wird" entschieden — und hat trotzdem ein Aufräumproblem, weil die Grenze pro Datei +statt global gilt. Beides sind Entwurfsfehler, die man **beim Nachrüsten** vermeiden kann, weil sie mit dem +Schreibverfahren nichts zu tun haben. + +### 2.4 Zettlr — gar keine Historie, und das ist die offizielle Position + +**Belegt: Zettlr hat kein Snapshot-, History- oder Versions-Feature.** Die Doku delegiert das Thema +ausdrücklich an den Nutzer +(): + +> „We strongly recommend that you regularly back up your workspaces. The simplest method is to use a cloud +> storage provider (such as Dropbox, Google Drive, OneDrive, or iCloud)… you should also back up the files +> onto an external storage device from time to time." + +Vorhanden ist nur der System-Papierkorb via `shell.trashItem` +([`safe-delete.ts`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/util/safe-delete.ts)) +und ein **überschreibendes** Autosave. + +**Schreibverfahren — nicht atomar** (**belegt**, +[`fsal/index.ts#L529-L533`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/index.ts#L529-L533)): + +```ts +public async writeTextFile (filePath: string, contents: string): Promise { + await this._cache.del(filePath) + await fs.writeFile(filePath, contents, 'utf-8') +} +``` + +Drei Details des Save-Pfads, die #18 bis #20 direkt betreffen (**belegt**, +[`documents/index.ts#L1527-L1603`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/documents/index.ts#L1527-L1603)): + +- Der Descriptor wird **vor** dem Schreiben aktualisiert, mit explizitem Kommentar zur Race Condition + „User ändert → Save startet → User ändert mehr → Save beendet und macht die Änderungen rückgängig". +- **Self-Echo-Unterdrückung:** `this._ignoreChanges.push(filePath)` — der eigene Write wird beim + Watcher-Event wieder herausgefiltert. +- **BOM und Zeilenende werden rekonstruiert:** intern arbeitet Zettlr immer mit `\n`, beim Schreiben wird der + ursprüngliche Zustand wiederhergestellt (`doc.descriptor.bom + content.split('\n').join(doc.descriptor.linefeed)`). + +**Konfliktbehandlung, dreistufig** (**belegt**, +[`documents/index.ts#L1070-L1130`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/documents/index.ts#L1070-L1130)) — +für [#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20) unmittelbar verwertbar: + +1. **mtime-Filter**, bewusst `modtime <= ourModtime → return` statt Gleichheitsprüfung. Kommentar wörtlich: + *„In response to issue #1621: We will not check for equal modtime but only for newer modtime to prevent + sluggish cloud synchronization services (e.g. OneDrive and Box) from having text appear to 'jump'."* +2. **Inhaltsvergleich:** `if (diskContents === doc.lastSavedContent) return` — fängt Cloud-Dienste ab, die + nur Attribute anfassen. +3. **Dialog** „File changed on disk" mit den Buttons **„Keep editor contents"** / **„Load changes from disk"** + (kein Merge, kein Diff) und einer Checkbox „Always load changes from disk if there are no unsaved changes + in the editor", die direkt die Einstellung `alwaysReloadFiles` schreibt. + +**Zur Git-Frage: Es gibt kein offizielles Zettlr-Git-Plugin** (**belegt**). `Zettlr/Zettlr-Git-Plugin` +liefert 404; die Organisation hat keine Plugin-Repos +(). Zettlr hat überhaupt **kein Plugin-System** — Maintainer +`nathanlesage`: *„Writing a plugin system is incredibly difficult, and thus we should do that extremely +limited."* ([Discussion #3108](https://github.com/Zettlr/Zettlr/discussions/3108)). Das einzige Git-Feature +ist reine Erkennung: `dir.isGitRepository = (await fs.lstat(path.join(dir.path, '.git'))).isDirectory()` +([`fsal-directory.ts#L103-L105`](https://github.com/Zettlr/Zettlr/blob/86c6c56df1a94dfde4b1f05b13b365878ac1199a/source/app/service-providers/fsal/fsal-directory.ts#L103-L105)). +[Issue #1050 „Integrate basic git functionality"](https://github.com/Zettlr/Zettlr/issues/1050) ist seit Juli +2020 offen, Label „Priority: Low". + +**Die Lehre:** Ein etablierter, ernstgemeinter Markdown-Editor kommt seit Jahren **ohne jede eingebaute +Historie** aus und verweist stattdessen auf externe Werkzeuge. Das ist der empirische Beleg dafür, dass +„Historie nicht bauen" eine tragfähige Produktentscheidung ist — nicht bloß ein Aufschub. + +### 2.5 Syncthing — kein Editor, aber das beste Vorbild für Ablage und Aufräumen + +Syncthing ist der einzige untersuchte Vertreter, der Versionen **neben** den Nutzdateien ablegt, und hat dafür +die durchdachteste Aufräumstrategie. Alles hier **belegt** aus +: + +- **Ablageort:** Standardmäßig der Ordner **`.stversions`** innerhalb des freigegebenen Verzeichnisses. Ein + eigener Pfad ist konfigurierbar, aber die Doku warnt ausdrücklich: „please ensure that it's on the same + partition or filesystem as the regular folder path." +- **Namenskonvention:** zeitgestempelte Dateinamen. +- **Vier Modi:** *Trash Can* (letzte Fassung, `cleanoutDays`), *Simple* (N Fassungen pro Datei mit + Zeitstempel), *Staggered* (altersbasiertes Ausdünnen), *External* (delegiert an ein Kommando). +- **Der Staggered-Versioner dünnt logarithmisch aus:** + + | Alter | behalten wird | + |---|---| + | 1 Stunde | die älteste Version pro 30-Sekunden-Intervall | + | 1 Tag | die älteste Version pro Stunde | + | 30 Tage | die älteste Version pro Tag | + | bis „Maximum Age" | die älteste Version pro Woche | + + „Maximum Age" = 0 bedeutet: für immer behalten. + +Das ist die Antwort auf die Aufräumfrage, falls DioNotes je Historie bekommt: **konstanter Overhead statt +unbegrenztem Wachstum**, ohne Diff-Rekonstruktion, ohne Datenbank. Vier Zeilen Regel. + +--- + +## 3. Was ein Append-Log konkret kosten würde + +Der Vorschlag aus der Beratungsrunde: ein Append-Log neben jeder Notiz, aus dem die `.md` als lesbare Sicht +materialisiert wird. Undo über Sitzungen, Historie und Konfliktauflösung fielen als Nebenprodukt an. + +Der Vorschlag ist technisch seriös — das ist Event Sourcing, und es liefert tatsächlich alles drei. Die Frage +ist nicht, ob er funktioniert, sondern was er kostet. + +### 3.1 Größenwachstum — belegte Zahlen + +Die maßgebliche Zahl stammt aus dem **offiziellen Benchmark-Repo von Kevin Jahns**, dem Yjs-Autor, das die +Yjs-README selbst verlinkt (). Gemessen wird der bekannte +B4-Editier-Trace: 182.315 Einfügungen, 77.463 Löschungen, **104.852 Zeichen im Endtext**. + +| | Yjs 13.6.11 | Automerge 2.1.10 | +|---|---|---| +| **Dokumentgröße gemerged** (`docSize`) | 159.929 B ≈ **1,53× Rohtext** | 129.116 B ≈ **1,23× Rohtext** | +| **Summe aller Einzel-Updates** (`updateSize`) | **7.306.126 B ≈ 70× Rohtext** | — | +| Durchschnittliche Update-Größe | 29 B | — | +| Parse-Zeit | 39 ms | 1.805 ms | + +**Das ist die eigentliche Kostenaussage, und sie ist zweigeteilt** (**belegt**): + +- **Ein *zusammengefasstes* CRDT-Dokument ist erstaunlich billig** — 1,2 bis 1,5× des Rohtextes, mit voller + Historie. Das ist deutlich günstiger, als man intuitiv erwartet. +- **Ein *ungekürzter Append-Log* — also genau das, was der Vorschlag beschreibt — kostet rund 70× den + Rohtext.** Der Unterschied zwischen 1,5× und 70× ist die Compaction. Ein Log, der nur angehängt und nie + zusammengefasst wird, liegt beim oberen Wert. + +Bei der Skalierung B4×100 (10,5 Mio. Zeichen) bleibt die Dokumentgröße bei 1,53×, der **Arbeitsspeicher** +steigt aber auf 327,1 MB — etwa 31× des Textes (**belegt**, dieselbe Quelle). + +Die offizielle Yjs-FAQ ordnet das ein (): „**Size is very rarely a practical +problem** as long as you deal with human-entered text input." Das ist ein fairer Einwand gegen eine reine +Größen-Argumentation — und der Grund, warum der Ausschlussgrund in 3.4 steht und nicht hier. + +**Was daraus für DioNotes folgt** (**Ableitung**): + +- **Eine Sidecar-Datei pro Notiz.** Ein Workspace mit 500 Notizen enthält 1.000 Dateien statt 500. Der + Notiz-Baum müsste die Hälfte davon ausblenden; jedes Backup-, Sync- und Suchwerkzeug des Nutzers sieht sie. +- **Autosave koppelt an den Speicherplatz.** Die Auslöser-Entscheidung aus + [#18](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/18) würde zur + Speicherplatz-Entscheidung: „Autosave nach Tipppause" erzeugt Größenordnungen mehr Log-Einträge als + „manuell mit Strg+S". Diese Kopplung existiert bei keiner anderen Option aus Abschnitt 4. + +### 3.2 Aufräumen: bei CRDT-Logs nicht vorgesehen + +Der wichtigste Befund dieses Abschnitts, und er ist aus Maintainer-Mund belegt. + +**Automerge** — Peter van Hardenberg (Maintainer) in +[Issue #799](https://github.com/automerge/automerge/issues/799), 24.12.2023, wörtlich: + +> „'Compact' could mean a few different things here — one is whether you can reduce the on-disk size of an +> Automerge document. **This happens automatically when you call `save()`.** It quite literally compacts the +> history into a very efficient columnar representation. +> +> **Discarding the history is not currently possible without replacing the document: if you don't have the +> same history, you can't merge the same changes.** That's because we use a git-like commit hash for each +> change to ensure integrity." + +„Shallow clone" und „cherry pick" nennt er als nützlich, verwandt und „**not yet implemented**"; nachgefasst +am 23.09.2024: „I don't know *when* it will happen." Der Automerge-2.0-Blogpost kündigt dasselbe schon seit +Januar 2023 an (, Abschnitt „History Management"): + +> „Today the best way to remove something from an Automerge document's history is to **recreate the document +> from scratch** or to reset to a time before that change went in." + +Orion Henry (Automerge-Core) in [Issue #348](https://github.com/automerge/automerge/issues/348), 25.04.2022: +„the solutions really are to either have the nodes coordinate **a total document reset every so often — +throwing out the old document and starting fresh**". Issue #348 wurde am 21.08.2026 geschlossen mit dem +Hinweis auf `hexane`, den neuen Storage-Engine — der laut offiziellem Blogpost +() „around 2–9× faster" ist, also **Kompression und Performance +liefert, keine History-Truncation**. Die Schließung bedeutet nicht, dass das Feature existiert. + +**Yjs** — die README nennt den Grund formal +(, Abschnitt „Yjs CRDT Algorithm"): + +> „CRDTs that are suitable for shared text editing suffer from the fact that they **only grow in size**. […] +> **We can't garbage collect deleted structs (tombstones) while ensuring a unique order of the structs.**" + +Im Quellcode bestätigt: `Item.gc()` ersetzt beim normalen Löschen nur den `content` durch +`ContentDeleted(len)` — **das komplette Item-Struct mit `id{client,clock}`, `origin`, `rightOrigin`, `parent` +und der Reihenfolge-Verlinkung bleibt stehen** +(). Und das Zusammenfassen von Updates hilft nicht: +„**Note that this feature only merges document updates and doesn't garbage-collect deleted content**" +(). Wer Snapshots will, muss GC ganz abschalten — der Code wirft +sonst: `if (originDoc.gc) { throw new Error('Garbage-collection must be disabled in \`originDoc\`!') }` +(). + +**Das Gegenargument, das fairerweise dazugehört.** Das offizielle Automerge-Cookbook +() schreibt: + +> „Martin Kleppmann did some experiments with a benchmark document to see how much space would be saved by +> discarding history […] **The savings are not all that great, which is why we haven't prioritised history +> truncation so far.**" + +Das ist ernst zu nehmen — es gilt für Kleppmanns Messfall: **ein großes, weitgehend linear getipptes +Dokument mit wenigen Actor-IDs.** Eine Notiz-App hat das umgekehrte Profil: **viele kleine Dokumente, die +über Jahre in vielen kurzen Sitzungen angefasst werden**, also viele Actor-IDs pro Dokument. Ob die Ökonomie +dort kippt, ist für dieses Dokument **nicht belegt** — die 70×-Zahl aus 3.1 legt es nahe, ist aber am +B4-Trace gemessen, nicht am Notiz-Profil. + +**Fazit dieses Abschnitts:** Aufräumen heißt bei einem CRDT-Log entweder gar nichts (Yjs behält Tombstones +konstruktionsbedingt) oder „Dokument wegwerfen und neu anfangen" (Automerge). Genau denselben Kompromiss +fährt Joplin mit dem Rebase der ältesten überlebenden Revision (2.2) — nur innerhalb einer Datenbank, wo er +billig ist. Neben jeder Notiz im Dateisystem ist er es nicht. + +### 3.3 Sidecar-Dateien in Sync-Ordnern — belegte Risiken + +Ob die bloße Verdopplung der Dateizahl schadet, ist **nicht belegt**; die harten Limits sprechen sogar +dagegen (Dropbox: „Users shouldn't sync more than **300.000 files**" und „**1.000.000 write operations per +namespace in a 24 hour period**", ; OneDrive: „no more than +a total of **300.000 items**", +). +Bei typischen Workspace-Größen ist das unkritisch. + +**Belegt sind dagegen zwei andere Fallstricke**, und beide treffen ein Sidecar-Design härter als eine +einzelne Datei: + +- **Platzhalter-Dateien werden als gelöscht interpretiert.** Obsidians offizielle Sync-FAQ + (): „Cloud storage services like OneDrive and Dropbox offer 'Files + on-demand' or 'Online-only files' features […] However, since these files are not always available locally, + **Obsidian Sync will interpret them as deleted, leading to their removal from your remote vault.**" + Ergänzend (): „iCloud Drive on Windows may lead to **file duplication + or corruption**." Und die architektonische Begründung: „**Obsidian requires access to the entire vault for + its features** […] This makes it difficult for some services to function reliably with Obsidian." +- **Das bloße Nachsehen, ob eine Datei existiert, kann die App einfrieren.** Apples Technote TN3150 + (): + „Materializing a dataless file can take time […] **the app may become unresponsive.** If an app's main + queue remains unresponsive for a long time, **the system may terminate the app and trigger a watchdog + crash.**" Als auslösende Aktionen nennt Apple ausdrücklich „**enumerating a directory's contents and + checking whether a file exists**", und: „`stat` and `getattrlist` both trigger the materialization of any + intermediate folders." Für jede Notiz die zugehörige Log-Datei zu suchen oder zu statten ist genau dieses + Muster. (Die Technote gilt für Apple-Plattformen; DioNotes ist Windows-only, aber der Mechanismus — + Platzhalter-Dateien, die beim Zugriff nachgeladen werden — ist bei OneDrive Files On-Demand derselbe. + **Übertragung: Vermutung.**) + +Apples eigene Empfehlung für mehrteilige Dokumente ist bezeichnend: **File Packages statt nebeneinander\- +liegender Dateien** („If your document data format consists of multiple distinct pieces, use a file +package", ). +Für DioNotes untauglich — dann wäre die `.md` keine eigenständige Datei mehr, und genau das ist das +Versprechen. + +> **Nicht als Beleg verwendbar** (gegengeprüft und verworfen): Zotereos Warnung „extremely likely to corrupt +> your database" betrifft SQLite und File Locking, nicht Sidecar-Strukturen. git-annex' Warnung gegen +> Dropbox-Ordner beruht auf Case-Insensitivity und Symlink-Handling. Für DVC gibt es keine offizielle +> Warnung, nur Community-Threads. + +### 3.4 Das Zwei-Wahrheiten-Problem — der eigentliche Einwand + +Dieser Punkt braucht keine externe Quelle, er folgt aus den Rahmenbedingungen des Projekts. + +DioNotes hat **keinen Filewatcher** ([#16](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/16), +Rahmenentscheidung) und rechnet **ausdrücklich damit, dass Dropbox, Syncthing oder Git die Dateien ändern, +während die App läuft** ([#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20)). + +Damit gilt für einen Append-Log: + +- Ändert jemand die `.md` von außen, weiß der Log nichts davon. Der Log sagt „Inhalt X", die Datei sagt + „Inhalt Y". **Zwei Wahrheiten.** +- Beim nächsten Materialisieren gewinnt der Log — und **überschreibt die externe Änderung stillschweigend.** + Das ist kein Randfall, sondern der Normalfall bei jedem Sync-Konflikt. +- Der Ausweg wäre, die externe Änderung als neuen Eintrag in den Log einzuspeisen (Diff gegen den zuletzt + materialisierten Stand). Das ist machbar — aber es setzt genau die Konflikterkennung voraus, die #20 erst + noch entscheiden muss, und macht sie von einer Kann- zu einer **Muss**-Anforderung. + +Die entscheidende Beobachtung: **Beim „Datei ist die Wahrheit"-Modell gibt es dieses Problem nicht.** Eine +Sicherungskopie, die veraltet ist, ist eine weniger nützliche Sicherungskopie — kein inkonsistenter Zustand. +Die Datei ist immer richtig, weil sie per Definition die Wahrheit ist. Der Append-Log führt eine +Korrektheitslücke ein, die das schlichte Verfahren gar nicht erst hat. + +### 3.5 Bewertung am Versprechen + +Der Bewertungsmaßstab aus dem Projektkontext: „Der Nutzer kann das Werkzeug jederzeit verlassen, seine +Dateien sind mit jedem Editor lesbar." + +Ein Append-Log, aus dem die `.md` materialisiert wird, degradiert die `.md` zur **Ausgabe**. Formal bleibt sie +lesbar — praktisch ist sie es nicht mehr im Sinne des Versprechens, denn: + +- Eine Änderung an ihr ist **wirkungslos oder wird verworfen**, sobald DioNotes das nächste Mal + materialisiert. Der Nutzer *darf* seine Dateien dann nicht mehr mit einem anderen Editor bearbeiten, + sondern nur noch lesen. +- Das Werkzeug zu verlassen, hieße die Historie zurückzulassen — sie steckt im Log, nicht in der Datei. + +Der Unterschied zu einer Sicherungskopie ist damit kein gradueller: Eine Sicherungskopie ist **zusätzliche** +Information neben der Wahrheit. Ein Log ist **die** Wahrheit, und die Datei wird zur Kopie. Das ist genau die +Umkehrung, die das Versprechen bricht. + +--- + +## 4. Die billigeren Mittelwege + +Zur Einordnung: Alle drei sind **Aufsätze auf ein unverändertes Schreibverfahren**. Keiner verlangt, dass +#19 heute irgendetwas anders entscheidet. Sie sind hier aufgeführt, damit die Größenordnung des „später" +bekannt ist — nicht als Empfehlung, heute etwas davon zu bauen. + +### 4.1 Eine einzelne Sicherungskopie der Vorfassung + +Der billigste Weg: Vor dem Umbenennen die Zieldatei nach `.md.bak` (oder in `.dionotes/bak/`) kopieren. +Eine Fassung, überschrieben bei jedem Speichern. + +- **Kosten:** eine Datei pro je gespeicherter Notiz, Größe = Größe der Notiz. Bei 500 Notizen à 4 KB also + ~2 MB. Kein Wachstum über die Zeit, weil jede Sicherung die vorige ersetzt. **Aufräumen entfällt.** +- **Nutzen:** deckt genau einen Fall ab — „ich habe gerade etwas kaputtgespeichert". Kein Undo über + Sitzungen, keine Historie. +- **Präzedenzfall:** Logseq macht im Kern nichts anderes, nur mit sechs statt einer Fassung und nur bei + Konflikt (2.3). +- **Fallstrick:** Wenn die Sicherung **neben** der Notiz liegt, sieht der Nutzer sie im Ordner und im Sync — + und der Notiz-Baum müsste sie ausblenden, ohne die Punkt-Regel aufzuweichen. In `.dionotes/` liegt sie + außer Sichtweite, wird aber beim Löschen einer Notiz zur Waise. Beides lösbar, beides eine Entscheidung + für später. + +### 4.2 Zeitstempel-Ordner mit logarithmischer Ausdünnung (das Syncthing-Modell) + +Ein Ordner `.dionotes/versions//.md`, ausgedünnt nach dem Muster aus 2.5. + +- **Kosten:** Volltext-Kopien, aber durch die Ausdünnung mit **konstantem Overhead statt unbegrenztem + Wachstum**. Bei den Syncthing-Buckets landet man pro Notiz nach einem Jahr bei grob 50–100 Fassungen + (**Abschätzung, nicht belegt**) — für eine 4-KB-Notiz also ein paar hundert Kilobyte. +- **Nutzen:** echte Historie mit Zeitpunkten, Undo über Sitzungen, kein Diff-Algorithmus nötig. +- **Belegtes Vorbild in Produktion:** Syncthings `.stversions` mit vier Modi und den Ausdünn-Buckets + (2.5, ). +- **Warum das die beste Nachrüst-Option wäre:** Es braucht keine Datenbank, kein Diff-Format, keine + Kettenrekonstruktion wie bei Joplin (2.2) — und es vermeidet Logseqs Aufräumfehler, weil die Ausdünnung + altersbasiert statt anzahlbasiert-pro-Datei ist. + +### 4.3 Git-Repository im Workspace + +Die Variante, die manche Nutzer heute schon von Hand fahren — und die Logseqs „View page history" bereits +voraussetzt (2.3). + +**Belegte Kosten:** + +- Pro Commit entstehen **mindestens drei lose Objekte** (Blob + Root-Tree + Commit), pro Verzeichnisebene ein + weiteres Tree-Objekt. Format: zlib-komprimierter Header + Inhalt unter + `.git/objects/<2>/<38>` (). +- **Lose Objekte enthalten den vollen Blob, keine Deltas** — Delta-Kompression gibt es erst beim Packen. Eine + Ein-Zeichen-Änderung an einer 5-KB-Notiz erzeugt einen kompletten neuen komprimierten Blob (**Ableitung** + aus derselben Quelle). +- `gc.auto` = **6700** lose Objekte, `gc.autoPackLimit` = **50** Packs, `gc.pruneExpire` = **2.weeks.ago**, + `gc.autoDetach` = **true** (, ). +- Sobald Housekeeping ausgelöst wird, laufen laut Doku **alle** Housekeeping-Aufgaben mit (rerere, worktrees, + reflog). + +**Ableitung für DioNotes:** 6700 ÷ ~3 Objekte je Commit ≈ **~2.200 Commits bis zum ersten automatischen +Repack**. Bei „ein Commit pro Speichern" mit Autosave ist das eine Frage von Wochen, nicht von Jahren. Der +Repack läuft dann im Hintergrund, schreibt große `.pack`-Dateien und löscht tausende lose Dateien auf einen +Schlag — mitten in einem Ordner, den womöglich Dropbox oder Syncthing beobachtet. Für den Sync-Client sieht +das aus wie ein Änderungssturm (**Ableitung, mittlere Konfidenz**). `.git/` gehörte damit zwingend aus dem +Sync ausgenommen. + +**Die Rust-Seite** (**belegt**, crates.io/docs.rs): + +| Crate | Version | Downloads gesamt | C-Toolchain nötig? | +|---|---|---|---| +| `git2` (libgit2-Bindings) | 0.21.0 | ~110 Mio. | **Ja** — `libgit2-sys` kompiliert ~180k Zeilen C via `cc` (kein CMake) | +| `gix` / gitoxide | 0.87.1 | ~44 Mio. | **Nein** — Feature-Doku wörtlich: „No C toolchain is involved." | + +`gix` beschreibt sich als „pure Rust implementation of Git", testet Windows in CI („failures do prevent +releases") und deckt commit, log, status, diff, reset ab. Der Haken ist der Reifegrad: Der Haupt-Einstiegs\- +punkt `gix` steht laut eigenem Stability-Tier-Schema noch auf **„Initial Development"**, entsprechend +API-Churn bei jedem Minor. Unter Windows nutzt libgit2 WinHTTP, OpenSSL ist für lokale Repo-Operationen nicht +nötig (**Ableitung** aus der Windows-Verlinkung in `build.rs`). + +**Bewertung:** Git ist der einzige Weg, der Historie *und* Diff *und* Branching liefert — und der einzige, +der eine schwergewichtige Dependency plus unbegrenztes Wachstum plus periodisch teures Repacking mitbringt. +Für „Undo über Sitzungen" ist es mit Abstand die teuerste Antwort. Bemerkenswert ist, was die untersuchten +Editoren daraus machen: Zettlr empfiehlt Git **nicht** (2.4), Logseq benutzt es nur lesend und setzt voraus, +dass der Nutzer es selbst eingerichtet hat (2.3). **Niemand richtet dem Nutzer ein Repo ein.** + +### 4.4 Kostenvergleich + +| Option | Zusätzliche Dateien | Wachstum | Aufräumen | Dependency | Bricht das Versprechen? | +|---|---|---|---|---|---| +| Nichts tun (heute) | 0 | — | — | keine | nein | +| Eine Sicherungskopie (4.1) | 1 pro gespeicherter Notiz | keins | entfällt | keine | nein | +| Zeitstempel-Ordner (4.2) | N pro Notiz, ausgedünnt | beschränkt | Ausdünn-Regel, ~1 Funktion | keine | nein | +| Git im Workspace (4.3) | `.git/`, ≥3 Objekte pro Commit | unbeschränkt | `git gc`, teuer | `gix` oder `git2` | nein | +| Append-Log (Abschnitt 3) | 1 Log pro Notiz | **≈ 70× Rohtext** ungekürzt, ≈ 1,2–1,5× gemerged | **von den Bibliotheken nicht vorgesehen** (Automerge: Dokument neu anlegen; Yjs: Tombstones bleiben) | CRDT-/Log-Bibliothek | **ja** — die `.md` wird zur Kopie | + +--- + +## 5. Die eigentliche Frage: Schließt temp+rename eine spätere Historie aus? + +**Nein.** Die Begründung in vier Schritten. + +### 5.1 Das Schreibverfahren ist für Historie günstig, nicht hinderlich + +Wie in Abschnitt 1 hergeleitet: Historie braucht Auslöser und Vorfassung. Beim temp+rename-Verfahren ist die +Vorfassung im Moment vor dem Umbenennen noch vollständig auf der Platte. Der Einbau einer Historie wäre eine +Einfügung von wenigen Zeilen **an einer Stelle**, an der man ohnehin schon steht: + +``` +schreibe_notiz(pfad, inhalt): + prüfe Konflikt (aus #20) + ── hier ließe sich später die Vorfassung wegsichern ── + schreibe inhalt nach pfad.tmp + sync + benenne pfad.tmp nach pfad um +``` + +Das Gegenteil gilt für den naiven Weg: `fs::write(pfad, inhalt)` zerstört die Vorfassung, bevor irgendjemand +sie sichern könnte. Wer dort Historie nachrüstet, muss die Datei zusätzlich vorher lesen. Das ist immer noch +billig — aber es ist der **teurere** der beiden Wege, und er ist zugleich der, den #19 aus Gründen der +Absturzsicherheit ohnehin ausschließen will. + +**Damit ist der Einwand in seiner konkreten Form widerlegt**: Die Entscheidung, die #19 zu treffen hat, macht +Historie nicht teurer. Sie macht sie billiger. + +### 5.2 Was Historie *tatsächlich* teuer machen würde + +Damit das Ergebnis belastbar ist, hier die Gegenprobe — die Entscheidungen, die den Einwand berechtigt machen +würden. Keine davon ist die Wahl zwischen `rename` und `write`: + +| Falle | Warum sie teuer wird | Kosten sie zu vermeiden | +|---|---|---| +| **Mehrere Schreibstellen.** Autosave schreibt hier, „Notiz verlassen" dort, „Fenster schließen" an dritter Stelle. | Historie muss an jeder Stelle nachgezogen werden, und jede vergessene Stelle ist ein Loch in der Historie, das man erst durch einen Bug bemerkt. | **Eine** Funktion, die schreibt. Alle Auslöser aus [#18](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/18) rufen dieselbe. Kostet heute nichts. | +| **Kein Notiz-Begriff, nur Pfade.** | Historie, die am Pfad hängt, reißt beim Umbenennen ab ([#23](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/23)). Obsidian hat genau dieses Problem, dokumentiert („snapshots are stored with the absolute path to the note"). | Entweder akzeptieren (Obsidian tut es) oder später eine stabile ID einführen. Beides nachrüstbar; die Kette bräche dann nur für Umbenennungen *vor* der Einführung. | +| **Umbenennen als Löschen+Neuanlegen implementiert.** | Zerreißt jede Historie und erzeugt Waisen im Ablageort. | Umbenennen als Umbenennen implementieren. Ist in #23 ohnehin die naheliegende Wahl. | +| **Der Log-Ansatz aus dem Vorschlag.** | Siehe Abschnitt 3 — er ist die einzige der hier betrachteten Optionen, die schwer umkehrbar ist. | Ihn nicht bauen. | + +**Nur die erste Zeile ist eine echte Auflage an #19** — und sie kostet heute exakt nichts, weil es aktuell +noch **überhaupt keine Schreibstelle im Code gibt** (verifiziert: keine Fundstelle für `fs::`/`write`/`File::` +unter `src/`; der Editor nimmt Eingaben entgegen und verwirft sie, so beschrieben in +[#16](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/16)). + +### 5.3 Die Asymmetrie läuft andersherum + +Der Vorschlag aus der Beratungsrunde begründet sich damit, dass die Schreib-Entscheidung schwer umkehrbar sei. +Prüft man beide Richtungen, ergibt sich das Gegenteil: + +| Richtung | Aufwand | +|---|---| +| „Datei ist die Wahrheit" → „Datei plus Sicherungen" | **Ergänzung.** Eine Funktion erweitern, ein Ordner, eine Aufräumregel. Bestehende Notizen funktionieren unverändert weiter, die Historie beginnt eben ab Einführung. Kein Datenmigrations-Schritt, kein Bruch für den Nutzer. | +| „Log ist die Wahrheit" → „Datei ist die Wahrheit" | **Migration.** Jede Notiz aus dem Log materialisieren, die Logs entfernen oder verwaisen lassen, die gesamte gesammelte Historie wegwerfen oder in ein neues Format überführen — und den Nutzern erklären, warum ihre Historie weg ist. | + +Wer sich die Option offenhalten will, hält sie also gerade dadurch offen, dass er den Log **nicht** baut. Der +Log ist die Einbahnstraße, nicht das schlichte Schreiben. + +Obsidian ist der empirische Beleg: Das Werkzeug wurde 2020 veröffentlicht, hat von Anfang an lose `.md`-Dateien +geschrieben, und File recovery kam später als Core-Plugin obendrauf — vollständig außerhalb des Vault, ohne +das Dateiformat oder das Schreibverfahren anzufassen (**belegt** durch den Aufbau des Plugins, s. 2.1; +das genaue Erscheinungsdatum des Plugins ist **nicht belegt**). + +### 5.4 Belastbarkeit dieser Antwort + +Die Antwort steht auf zwei Beinen unterschiedlicher Härte: + +- **Hart (belegt):** Obsidian betreibt eine produktive Historie für lose `.md`-Dateien als aufgesetzte + Schicht, die vom Schreibverfahren des Kerns entkoppelt ist und außerhalb des Datenordners lebt. Ein + Werkzeug mit derselben Ausgangslage wie DioNotes hat das Problem also nachweislich nachträglich gelöst. +- **Weich (Argumentation, nicht durch Quelle belegt):** Dass genau *eine* Schreibstelle die einzige + load-bearing Auflage ist, ist eine Schlussfolgerung aus der Zerlegung in Abschnitt 1, kein Zitat. Sie ist + aber schwer zu bestreiten: Eine Schicht über N Aufrufstellen einzuziehen kostet N Änderungen, über einer + Aufrufstelle eine. + +**Fazit für #19: Der Einwand ist entkräftet. Die Schreib-Entscheidung darf allein nach den Kriterien aus #19 +fallen — Absturzsicherheit, Sichtbarkeit der temporären Datei, Dateirechte, Aufräumen. Historie legt ihr +genau eine Auflage auf, und die ist heute gratis: eine einzige Funktion, durch die alles Schreiben läuft.** + +--- + +## 6. Randbefunde für die Nachbartickets + +Bei der Recherche fielen Belege an, die #22 nicht beantworten, aber #19 und #20 direkt betreffen. Sie stehen +hier, damit sie nicht verlorengehen — **nicht** als Entscheidung. + +**Für [#19](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/19) (Schreibverfahren):** + +- **Weder Rust noch Microsoft dokumentieren `rename` als atomar.** Das Wort „atomic" kommt weder in + noch in der `MoveFileExW`-Doku vor. Rust garantiert nur + „replacing the original file if `to` already exists" und nennt `MoveFileExW` mit Fallback auf + `SetFileInformationByHandle`. Man verlässt sich auf undokumentiertes NTFS-Verhalten (**belegt durch + Abwesenheit**). +- **Microsoft empfiehlt für genau diesen Anwendungsfall `ReplaceFile`, nicht `MoveFileEx`.** Aus + , Abschnitt „Applications + updating a single file with 'document-like' data": *„A common approach is to write the document to a new + file, then replace the original file with the new one. **One method to do this is with the `ReplaceFile` + API.**"* +- **Temp+rename verliert Metadaten.** Die `ReplaceFileW`-Remarks führen als Vorteil auf, dass die Funktion + *creation time, short file name, object identifier, DACLs, security resource attributes, encryption, + compression* und *named streams* der Originaldatei erhält — was per Kontrast belegt, dass ein nacktes + temp+rename genau diese verliert. Die **File ID ändert sich sogar bei `ReplaceFile`**. Kein untersuchtes + Rust-Crate (`tempfile`, `atomicwrites`, `atomic-write-file`) nutzt `ReplaceFile`; `atomic-write-file` + dokumentiert die Verluste immerhin explizit („No support for preserving timestamps, ACLs, extended + attributes…"). + → **Praktische Folge für #19:** Ein „erstellt am" gehört ins Frontmatter der `.md`, nicht ins Dateisystem. +- **Das realistischste Fehlerbild ist `ERROR_SHARING_VIOLATION`**, wenn Defender oder ein Sync-Client die + Zieldatei ohne `FILE_SHARE_DELETE` offen hält (). + `tempfile::NamedTempFile::persist` gibt die temporäre Datei im `PersistError` zurück — genau für den + Retry-Fall gebaut. Ebenfalls belegt: `persist` synchronisiert weder Inhalt noch Verzeichnis. +- **Die temporäre Datei muss im selben Verzeichnis liegen** — `fs::rename` scheitert über Volume-Grenzen + („This will not work if the new name is on a different mount point"), und Syncthing warnt bei seinem + eigenen Versions-Ordner mit derselben Begründung. +- **`fs-err` löst das Problem nicht** (es ist nur ein Drop-in-Ersatz für `std::fs` mit besseren + Fehlermeldungen), ist aber die naheliegende Ergänzung, um überhaupt zu sehen, *welche* Datei die + Sharing-Violation geworfen hat. + +**Für [#20](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/20) (Zeitstempel-Konflikt):** + +- **Zettlrs mtime-Regel ist bewusst `modtime <= ourModtime → ignorieren`, nicht Gleichheitsprüfung**, mit + wörtlicher Begründung im Code: *„to prevent sluggish cloud synchronization services (e.g. OneDrive and Box) + from having text appear to 'jump'"*. Danach folgt ein **Inhaltsvergleich**, weil Cloud-Dienste Dateien + anfassen, ohne den Inhalt zu ändern. Ein reiner mtime-Vergleich fängt das nicht ab. +- **Logseq vergleicht ebenfalls den Inhalt vor dem Schreiben** und schreibt bei Abweichung gar nicht. + Beide untersuchten Open-Source-Editoren lösen den Konflikt über Inhalt, nicht über Zeitstempel allein. +- **Beide brauchen einen Self-Echo-Filter**, damit der eigene Schreibvorgang nicht als externe Änderung + zurückkommt (Zettlr: `_ignoreChanges`-Liste; Logseq: Inhaltsvergleich). Das ist erst relevant, falls je ein + Filewatcher dazukommt — aber es erklärt, warum ein Watcher nicht „nur ein Watcher" ist. +- **Dropbox nennt als dokumentierte Ursache Nr. 3 für Conflicted Copies wörtlich „applications with an + auto-save feature"** (). Das ist ein Argument für + [#18](https://gitlab.hive-brain.de/RDietermann/dionotes-client/issues/18), Autosave zu entprellen statt pro + Tastendruck zu schreiben. +- **Syncthings Konfliktnamen** sind `.sync-conflict--