Replace unused vector clocks with a conflict check that works, drop the rest

The vector clocks in the server were never reached: no client sent one,
so the branch was dead and every write was a blind overwrite. Not "the
newer edit wins" — "the request that arrives last wins", which is worse:
a phone that was offline all day buries the laptop's newer work simply by
reconnecting late.

Each write now carries baseUpdatedAt, the revision it was written on top
of. If the note has moved on since, the incoming edit still wins — a
rejection would throw away what was just typed — but the state it
replaces is kept as a version marked "conflict", so both exist and the
history is where you choose. Clocks could have told a real conflict from
a device catching up, but that precision only pays off with a merge
interface, and this reuses the version history already built.

adoptRevision() stores the revision the server reports back, or the
second save in a row would be flagged as conflicting with the first.

Also removed, as agreed:
- POST /api/notes/pdf — accepted PDFs that nothing could ever read back,
  with no retrieval route and no viewer. The readme advertised "PDF
  Import & Annotation"; that claim is gone too.
- POST /api/sync — a batch endpoint the client never called.
- the tags column — notebooks, starring and search already cover it.

Columns on existing databases are left in place. Dropping them buys
nothing and is not worth touching a live database for.

Tables now have a right-click menu (editor/tableMenu.js) for rows,
columns, header and merging. It needs stopPropagation: without it the
same event reaches window, where the menu that is mounting reads it as a
click elsewhere and closes instantly — the reason the other context menus
in App.jsx already do this.

92 browser checks across seven suites, 125 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 18:59:08 +02:00
1 parent 8c36c6121b
commit df279a5d36
10 files changed
+367 -152

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
/* Two devices editing the same note without knowing about each other.
*
* The write that arrives last wins — refusing it would throw away what was just
* typed — but the state it buries has to survive as a version, or the other
* device's work is simply gone. That is the whole point of the check, so this
* verifies it end to end rather than trusting the route in isolation. */
import { api, check, createNote, createNotebook, launch, report, setTitle, typeInEditor, withFreshUser } from "./lib.mjs";
const browser = await launch();
let session;
try {
session = await withFreshUser(browser, "conflict");
const p = session.page;
await createNotebook(p, "Konflikt Buch");
await createNote(p);
await setTitle(p, "Umkämpft");
await typeInEditor(p, "Ursprünglicher Text von Gerät A.");
await p.waitForTimeout(2000);
const id = (await api(p, "GET", "/notes")).body[0].id;
const base = (await api(p, "GET", `/notes/${id}`)).body.updated_at;
check(!!base, "the note reports a revision to base an edit on", String(base));
/* Device B saves — it has seen the same revision, so no conflict. */
const first = await api(p, "PUT", `/notes/${id}`, {
content: JSON.stringify({ v: 2, doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Gerät B war hier." }] }] } }),
baseUpdatedAt: base,
});
check(first.status === 200, "a save on the current revision is accepted", String(first.status));
check(first.body.conflict === false, "and is not reported as a conflict", JSON.stringify(first.body.conflict));
check(!!first.body.updatedAt, "the server answers with the new revision");
/* Device A saves, still believing the note is at the original revision. */
const stale = await api(p, "PUT", `/notes/${id}`, {
content: JSON.stringify({ v: 2, doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Gerät A schreibt spät." }] }] } }),
baseUpdatedAt: base,
});
check(stale.status === 200, "the late save is still accepted, not rejected", String(stale.status));
check(stale.body.conflict === true, "but it is reported as a conflict");
/* The late write won... */
const now = (await api(p, "GET", `/notes/${id}`)).body;
check(now.content.includes("Gerät A schreibt spät"), "the later write is what the note now holds");
/* ...and the buried version is recoverable. */
const versions = (await api(p, "GET", `/notes/${id}/versions`)).body;
check(
versions.some((v) => v.reason === "conflict"),
"a version is kept and labelled as a conflict",
JSON.stringify(versions.map((v) => v.reason))
);
const conflictVersion = versions.find((v) => v.reason === "conflict");
const buried = (await api(p, "GET", `/notes/${id}/versions/${conflictVersion.id}`)).body;
check(
buried.content.includes("Gerät B war hier"),
"and it holds exactly the work the late write buried"
);
/* A write with no base at all must not be treated as a conflict — that is how
* writes that cannot conflict, like starring, reach the server. */
const noBase = await api(p, "PUT", `/notes/${id}`, { starred: true });
check(noBase.body.conflict === false, "a write without a base revision is never a conflict");
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}