codexu/note-gen is an open-source, local-first Markdown note-taking app. Notes live as regular files in the user's workspace. GitHub, Gitee, GitLab, Gitea, S3, and WebDAV are optional sync targets rather than the primary store.
That ownership model creates a less obvious engineering problem. An editor save, a filesystem write, and a remote upload do not happen at the same time. Save events may fire repeatedly while someone is typing, and another device may change the same file before the next upload starts.
A direct save -> upload path would be easy to build, but it would also generate noisy requests and make stale writes more likely. NoteGen splits the job into three parts: an in-memory push queue, remote-version checks, and explicit conflict handling.
Save events enqueue paths, not content
The main entry point is src/lib/sync/sync-push-queue.ts. A singleton SyncPushQueue is initialized from src/app/layout.tsx and listens for four events:
-
article-savedadds the file path to the queue; -
editor-inputrefreshes the last-input timestamp; -
sync-pulledprevents a fresh pull from immediately bouncing back as a push; -
article-openedresets the idle calculation when the active file changes.
The queue reads the user's auto-sync interval and checks for idleness every 100 milliseconds. It only calls flush() after the configured quiet period has passed.
The delay is not only a request-saving debounce. Before each upload, the queue waits another 100 milliseconds for the filesystem write to settle and then reads the file from disk again. The event answers “which path changed?”; the workspace answers “what is the latest content?”
That distinction matters in a local-first editor. React state and editor events are transient. The Markdown file on disk is the durable fact the user owns.
It deduplicates by path, but it is not a durable job queue
During flush(), pending tasks are grouped in a Map keyed by path. Repeated saves for one file collapse into the newest task. Different paths are processed from newest to oldest. If another save happens while an upload is running, the new task is appended and scheduled after the current batch.
This is intentionally smaller than a general-purpose message queue. It lives in memory and does not recover pending work after an application restart. Before processing begins, a new addTask() call also replaces the current queue with the latest task. The design optimizes for the final state of an interactive editing session, not delivery of every intermediate snapshot.
An application that must deliver every file mutation would need persisted jobs, task states, restart recovery, and probably idempotency keys. A notes editor usually cares more about the final file than the version produced after each keystroke, so NoteGen accepts the narrower guarantee.
Read remote content before writing it
When a push begins, the queue first calls pullRemoteFile(). If the remote content is byte-for-byte equal to the local file, it skips the upload and only refreshes the locally recorded remote SHA. This avoids creating another commit when a save event fires without a content change.
For GitHub, Gitee, GitLab, and Gitea, the queue then fetches the current remote file metadata again and passes its SHA to the provider's upload function. That SHA acts as an optimistic concurrency token: update the file only if the remote version still matches the version we just observed.
After a successful upload, the returned remote SHA is stored in Tauri Store under syncedFileShas. On a later open or sync, compareFileVersions() in src/lib/sync/auto-sync.ts compares the recorded SHA with the current remote SHA. A mismatch means the remote file changed since this device last synchronized it.
The local content hash and the remote identifier are not interchangeable. Local content uses SHA-256, while Git providers may expose blob or commit identifiers. NoteGen therefore treats the remote SHA as a remote-version cursor, then uses local modification time, remote commit time, and the last sync time for additional decisions.
There is also a ten-second grace period after a pull or restore. If writing remote content makes the local file appear newer for a moment, the grace period stops that filesystem timestamp from triggering an immediate push back to the server.
A conflict is not a transient network error
The push queue classifies HTTP 409 and 422 responses, along with messages containing terms such as sha, blob, out of date, or conflict, as version mismatches.
On the first mismatch, it does not keep retrying the same stale write. It emits a sync-sha-mismatch event containing the path and the known local and remote identifiers. src/components/sync-confirm-dialog.tsx consumes that event and asks the user whether to keep the local version. Only an explicit confirmation enters the forcePush() path; cancelling leaves the remote file unchanged.
Pulls have a separate content-level conflict path in src/lib/sync/conflict-resolution.ts:
- identical content needs no action;
- a pure tail addition can be merged;
- content that only differs in whitespace can be treated as a formatting change;
- larger differences require a local-or-remote decision.
The merge is deliberately conservative. It is not a Markdown AST merge or a three-way merge, and it cannot understand two edits to the same paragraph. Asking the user is safer than manufacturing a document that looks valid while silently dropping meaning.
One workflow does not mean one consistency model
All six providers pass through the same queue, but they do not provide identical conflict guarantees.
Git-based providers expose SHAs or commit identifiers that fit optimistic concurrency checks. S3 and WebDAV store ETags in local sync state, but their current upload paths are primarily direct writes; they are not yet normalized into conditional requests with the same semantics as a Git SHA update.
Provider adapters also differ in how they return or throw errors. The queue can only recognize a structured 409 or 422 if the adapter preserves that information. A shared upload() shape is the easy part of multi-provider sync. The hard parts are version tokens, conditional writes, error classification, and a recovery action the user can understand.
NoteGen currently shares scheduling and confirmation flows across providers while keeping those backend differences visible in the implementation.
Four rules worth carrying into another local-first app
The implementation suggests four practical rules:
- Enqueue a path, then read the latest file from disk immediately before upload.
- Combine idle detection with per-path deduplication for high-frequency editor saves.
- Persist the remote version identifier as the baseline for the next sync.
- Treat network failure and version conflict as different states; the latter may require a user decision.
The useful measure of a sync system is not whether every request succeeds. It is whether latency, multi-device edits, and different storage protocols can be handled without making an irreversible overwrite on the user's behalf.
The original Chinese version is available on Juejin.
Top comments (0)