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:
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user