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=<synced_at> 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
<dialog> 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 <dialog>.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) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:45:25 +02:00
co-authored by Claude Opus 4.7
parent 593e311a8a
commit f166485012
4 changed files with 336 additions and 1 deletions
+27 -1
View File
@@ -13,6 +13,7 @@
<header class="app-header"> <header class="app-header">
<a class="brand" href="/">librenotes</a> <a class="brand" href="/">librenotes</a>
<span class="who" id="who"></span> <span class="who" id="who"></span>
<span class="sync-state" id="sync-state" data-state="idle">idle</span>
<button id="theme-toggle" type="button">Toggle theme</button> <button id="theme-toggle" type="button">Toggle theme</button>
<button id="logout" type="button">Sign out</button> <button id="logout" type="button">Sign out</button>
</header> </header>
@@ -20,11 +21,36 @@
<main class="app-main"> <main class="app-main">
<h1>Welcome</h1> <h1>Welcome</h1>
<p>You're signed in. The full notes UI ships in a later phase; this <p>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.</p> page exercises auth, tenant context, offline cache, and sync.</p>
<pre id="whoami-output"></pre> <pre id="whoami-output"></pre>
</main> </main>
<dialog id="conflict-dialog">
<form method="dialog" id="conflict-form">
<h2>Sync conflict</h2>
<p>Note <code id="conflict-id"></code> was modified both locally
and on the server since the last sync. Choose which version
to keep.</p>
<section class="conflict-cols">
<div>
<h3>Local</h3>
<pre id="conflict-local"></pre>
</div>
<div>
<h3>Server</h3>
<pre id="conflict-remote"></pre>
</div>
</section>
<menu>
<button value="local">Keep local</button>
<button value="remote">Keep server</button>
</menu>
</form>
</dialog>
<script src="/auth-client.js"></script> <script src="/auth-client.js"></script>
<script src="/notes-cache.js"></script>
<script src="/sync.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
<script src="/pwa.js" defer></script> <script src="/pwa.js" defer></script>
</body> </body>
+40
View File
@@ -41,4 +41,44 @@
.catch(function (e) { .catch(function (e) {
document.getElementById("whoami-output").textContent = "error: " + e.message; 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();
})(); })();
+61
View File
@@ -258,3 +258,64 @@ a:hover { text-decoration: underline; }
.auth-card { padding: 1.25rem; } .auth-card { padding: 1.25rem; }
.auth-card h1 { font-size: 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);
}
+208
View File
@@ -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=<synced_at> 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,
};
})();