From d71db09d4561b764edfc306f20b8c293083c99bb Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Tue, 28 Apr 2026 22:45:25 +0200 Subject: [PATCH] Add background sync controller with conflict detection cmd/librenotes/web/public/sync.js drives the offline-online reconciliation flow against the notes REST API: - start(): registers online/offline window listeners, runs an initial syncOnce() if currently online. - syncOnce(): push() then pull(); emits "librenotes:sync-state" events with state in {online, offline, syncing, synced, error}. - push(): walks notesCache.pending() (rows with dirty=1, including tombstones). PUTs use ?base= for optimistic locking and DELETEs use the same. The notes API returns 409 with the current server body on conflict; sync.js stashes the pair in conflictsById and dispatches "librenotes:sync-conflict" so the app shell can render a resolution dialog. - pull(): GETs the summary list, refetches any row whose server updated_at exceeds the local synced_at (or that is missing locally), and stamps it as cleanly synced. Skips locally-dirty rows so push's conflict path stays authoritative. - resolveConflict(id, "local"|"remote"|"merge", merged): replays the user's choice. "local" and "merge" PUT with the latest server base so the second attempt accepts; "remote" overwrites the local cache with the server copy. app.html now includes a sync-state badge in the header and a for conflict resolution wired to the events. app.js calls notesSync.start() on load and routes dialog clicks back to resolveConflict. The dialog uses native .showModal(), which all current target browsers support. style.css adds badge colour states (syncing/synced/offline/error) and a two-column conflict layout that collapses on narrow widths. Closes #23. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/librenotes/web/public/app.html | 28 +++- cmd/librenotes/web/public/app.js | 40 ++++++ cmd/librenotes/web/public/style.css | 61 ++++++++ cmd/librenotes/web/public/sync.js | 208 ++++++++++++++++++++++++++++ 4 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 cmd/librenotes/web/public/sync.js diff --git a/cmd/librenotes/web/public/app.html b/cmd/librenotes/web/public/app.html index 7c500d3..85ddb5d 100644 --- a/cmd/librenotes/web/public/app.html +++ b/cmd/librenotes/web/public/app.html @@ -13,6 +13,7 @@
librenotes + idle
@@ -20,11 +21,36 @@

Welcome

You're signed in. The full notes UI ships in a later phase; this - page exists to verify the auth + tenant-context wiring end to end.

+ page exercises auth, tenant context, offline cache, and sync.


   
+ +
+

Sync conflict

+

Note was modified both locally + and on the server since the last sync. Choose which version + to keep.

+
+
+

Local

+

+        
+
+

Server

+

+        
+
+ + + + +
+
+ + + diff --git a/cmd/librenotes/web/public/app.js b/cmd/librenotes/web/public/app.js index dccc4e8..3966e13 100644 --- a/cmd/librenotes/web/public/app.js +++ b/cmd/librenotes/web/public/app.js @@ -41,4 +41,44 @@ .catch(function (e) { document.getElementById("whoami-output").textContent = "error: " + e.message; }); + + // Sync state badge. + const $state = document.getElementById("sync-state"); + if ($state) { + window.addEventListener("librenotes:sync-state", function (ev) { + const s = ev.detail.state; + $state.textContent = s; + $state.dataset.state = s; + }); + } + + // Conflict dialog wiring. + const $dialog = document.getElementById("conflict-dialog"); + const $form = document.getElementById("conflict-form"); + let activeConflict = null; + if ($dialog) { + window.addEventListener("librenotes:sync-conflict", function (ev) { + activeConflict = ev.detail; + document.getElementById("conflict-id").textContent = activeConflict.id; + document.getElementById("conflict-local").textContent = + ((activeConflict.local && activeConflict.local.title) || "") + "\n\n" + + ((activeConflict.local && activeConflict.local.content) || ""); + document.getElementById("conflict-remote").textContent = + ((activeConflict.remote && activeConflict.remote.title) || "") + "\n\n" + + ((activeConflict.remote && activeConflict.remote.content) || ""); + if (typeof $dialog.showModal === "function") $dialog.showModal(); + }); + $form.addEventListener("submit", function (ev) { + const choice = ev.submitter && ev.submitter.value; + if (!activeConflict || !choice) return; + window.notesSync.resolveConflict(activeConflict.id, choice).catch(function (e) { + console.warn("resolve failed", e); + }); + activeConflict = null; + }); + } + + // Kick off the sync controller. It registers online/offline + // listeners and runs an initial reconciliation pass when online. + if (window.notesSync) window.notesSync.start(); })(); diff --git a/cmd/librenotes/web/public/style.css b/cmd/librenotes/web/public/style.css index 00bd2a7..0ddb877 100644 --- a/cmd/librenotes/web/public/style.css +++ b/cmd/librenotes/web/public/style.css @@ -258,3 +258,64 @@ a:hover { text-decoration: underline; } .auth-card { padding: 1.25rem; } .auth-card h1 { font-size: 1.25rem; } } + +/* Sync state badge in the app header. */ +.sync-state { + margin-left: auto; + font-size: 0.85rem; + padding: 0.2rem 0.6rem; + border-radius: 1rem; + background: var(--card); + color: var(--muted); + border: 1px solid var(--border); +} +.sync-state[data-state="syncing"] { color: var(--accent); } +.sync-state[data-state="synced"] { color: var(--success); } +.sync-state[data-state="offline"] { color: var(--muted); } +.sync-state[data-state="error"] { color: var(--error); } +.app-page .app-header .who { margin-left: 0; } + +/* Conflict dialog. */ +#conflict-dialog::backdrop { background: rgba(0,0,0,0.4); } +#conflict-dialog { + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--bg); + color: var(--fg); + padding: 1.5rem; + max-width: min(48rem, 90vw); + width: 100%; +} +.conflict-cols { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} +@media (max-width: 600px) { .conflict-cols { grid-template-columns: 1fr; } } +.conflict-cols pre { + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.4rem; + padding: 0.75rem; + max-height: 30vh; + overflow: auto; + white-space: pre-wrap; +} +#conflict-form menu { + display: flex; + gap: 0.5rem; + margin: 1rem 0 0; + padding: 0; +} +#conflict-form button { + padding: 0.5rem 1rem; + border-radius: 0.35rem; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-fg); + cursor: pointer; +} +#conflict-form button[value="remote"] { + background: transparent; + color: var(--accent); +} diff --git a/cmd/librenotes/web/public/sync.js b/cmd/librenotes/web/public/sync.js new file mode 100644 index 0000000..10c3119 --- /dev/null +++ b/cmd/librenotes/web/public/sync.js @@ -0,0 +1,208 @@ +// sync.js — background sync controller. +// +// Lifecycle: +// 1. start() registers online/offline listeners and kicks an +// initial sync if we are online. +// 2. push() walks notesCache.pending() and PUT/DELETEs each row +// against the server. ?base= drives optimistic +// locking; a 409 raises a conflict event. +// 3. pull() lists /api/notes and merges any remote rows whose +// server updated_at is newer than the local synced_at, or +// that are missing locally entirely. +// 4. After both pass, we publish a "synced" state. +// +// Conflicts are surfaced as DOM events on window: +// "librenotes:sync-state" detail = { state: "online" | "offline" | +// "syncing" | "synced" | +// "error" } +// "librenotes:sync-conflict" detail = { id, local, remote } +// +// The app shell listens for these and displays state badges + +// conflict modals. The conflict UI calls resolveConflict(...) to +// pick a side or merge. + +(function () { + "use strict"; + + let running = false; + let conflictsById = {}; + + function emit(name, detail) { + window.dispatchEvent(new CustomEvent("librenotes:" + name, { detail: detail })); + } + + function setState(s) { emit("sync-state", { state: s }); } + + function isOnline() { + return typeof navigator !== "undefined" ? navigator.onLine !== false : true; + } + + async function pushOne(note) { + const base = note.synced_at != null ? "?base=" + note.synced_at : ""; + if (note.deleted) { + const resp = await window.authClient.apiFetch( + "/api/notes/" + encodeURIComponent(note.id) + base, + { method: "DELETE" } + ); + if (resp.status === 409) { + // Server has a newer version. Pull will refetch it; meanwhile + // surface as a conflict so the user can decide. + conflictsById[note.id] = { id: note.id, local: note, remote: null }; + emit("sync-conflict", conflictsById[note.id]); + return; + } + if (resp.status === 204 || resp.status === 404) { + await window.notesCache.markSynced(note.id, null); + } + return; + } + const resp = await window.authClient.apiFetch( + "/api/notes/" + encodeURIComponent(note.id) + base, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: note.title || "", content: note.content || "" }), + } + ); + if (resp.status === 200) { + const body = await resp.json(); + await window.notesCache.markSynced(note.id, body.updated_at); + return; + } + if (resp.status === 409) { + const remote = await resp.json(); + conflictsById[note.id] = { id: note.id, local: note, remote: remote }; + emit("sync-conflict", conflictsById[note.id]); + return; + } + throw new Error("push " + note.id + " failed: " + resp.status); + } + + async function push() { + const queue = await window.notesCache.pending(); + for (const note of queue) { + try { await pushOne(note); } + catch (e) { console.warn("librenotes: push failed", note.id, e); throw e; } + } + } + + async function pull() { + const resp = await window.authClient.apiFetch("/api/notes"); + if (!resp.ok) throw new Error("pull list: " + resp.status); + const summaries = await resp.json(); + for (const s of summaries) { + const local = await window.notesCache.get(s.id); + // Skip rows the user has open dirty edits on; the conflict + // detector during push() will handle those. + if (local && local.dirty) continue; + if (local && local.synced_at === s.updated_at) continue; + const full = await window.authClient.apiFetch("/api/notes/" + encodeURIComponent(s.id)); + if (!full.ok) continue; + const note = await full.json(); + await window.notesCache.put({ + id: note.id, + title: note.title, + content: note.content, + updated_at: note.updated_at, + synced_at: note.updated_at, + dirty: false, + deleted: false, + }); + } + } + + async function syncOnce() { + if (!isOnline()) { + setState("offline"); + return; + } + if (running) return; + running = true; + setState("syncing"); + try { + await push(); + await pull(); + setState("synced"); + } catch (e) { + console.warn("librenotes: sync error", e); + setState("error"); + } finally { + running = false; + } + } + + function start() { + window.addEventListener("online", function () { + setState("online"); + syncOnce(); + }); + window.addEventListener("offline", function () { setState("offline"); }); + if (isOnline()) { + syncOnce(); + } else { + setState("offline"); + } + } + + // resolveConflict(id, choice, mergedFields) + // choice = "local" -> keep the local copy. Re-PUT with the + // latest server base so the server accepts. + // choice = "remote" -> keep the server copy. Overwrite local + // without dirty. + // choice = "merge" -> caller supplies mergedFields {title, content}. + // Re-PUT with the merged content using the + // latest server base. + async function resolveConflict(id, choice, mergedFields) { + const conflict = conflictsById[id]; + if (!conflict) throw new Error("no pending conflict for " + id); + const remote = conflict.remote; + const local = conflict.local; + + if (choice === "remote") { + await window.notesCache.put({ + id: id, + title: remote.title, + content: remote.content, + updated_at: remote.updated_at, + synced_at: remote.updated_at, + dirty: false, + deleted: false, + }); + } else { + const out = choice === "merge" && mergedFields + ? { title: mergedFields.title, content: mergedFields.content } + : { title: local.title, content: local.content }; + const base = remote ? remote.updated_at : ""; + const resp = await window.authClient.apiFetch( + "/api/notes/" + encodeURIComponent(id) + (base ? "?base=" + base : ""), + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(out), + } + ); + if (!resp.ok) throw new Error("resolve push: " + resp.status); + const body = await resp.json(); + await window.notesCache.put({ + id: id, + title: out.title, + content: out.content, + updated_at: body.updated_at, + synced_at: body.updated_at, + dirty: false, + deleted: false, + }); + } + delete conflictsById[id]; + setState("synced"); + } + + function pendingConflicts() { return Object.values(conflictsById); } + + window.notesSync = { + start, + syncOnce, + resolveConflict, + pendingConflicts, + }; +})();