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

+57
View File
@@ -0,0 +1,57 @@
/**
* The right-click menu on a table.
*
* The toolbar carries the same commands, but reaching them means leaving the
* cell you are working in, finding the button and reading a list — for shaping
* a table, where the target is the cell under the pointer, that is the long way
* round. This puts the commands where the cursor already is.
*
* Only the entries that apply are built: merge appears when several cells are
* selected, split when the cell was merged, and nothing at all outside a table
* so the browser's own menu stays available for ordinary text.
*/
/** True when the caret or selection sits inside a table cell. */
export const inTable = (editor) => !!editor?.isActive("table");
/**
* Menu items for `ContextMenu`, or null when there is no table under the click.
*
* Returning null rather than an empty list lets the caller tell "no menu" from
* "a menu with nothing in it" and fall through to the browser's own.
*/
export function tableMenuItems(editor) {
if (!inTable(editor)) return null;
const run = (fn) => () => fn(editor.chain().focus()).run();
const can = (name) => {
try {
return editor.can()[name]();
} catch {
return false;
}
};
const items = [
{ label: "Zeile darüber einfügen", action: run((c) => c.addRowBefore()) },
{ label: "Zeile darunter einfügen", action: run((c) => c.addRowAfter()) },
{ label: "Spalte links einfügen", action: run((c) => c.addColumnBefore()) },
{ label: "Spalte rechts einfügen", action: run((c) => c.addColumnAfter()) },
{ divider: true },
{ label: "Kopfzeile an/aus", action: run((c) => c.toggleHeaderRow()) },
];
// mergeCells and splitCell are mutually exclusive in practice: one needs a
// multi-cell selection, the other a cell that was merged earlier.
if (can("mergeCells")) items.push({ label: "Zellen verbinden", action: run((c) => c.mergeCells()) });
if (can("splitCell")) items.push({ label: "Zelle teilen", action: run((c) => c.splitCell()) });
items.push(
{ divider: true },
{ label: "Zeile löschen", danger: true, action: run((c) => c.deleteRow()) },
{ label: "Spalte löschen", danger: true, action: run((c) => c.deleteColumn()) },
{ label: "Tabelle löschen", danger: true, action: run((c) => c.deleteTable()) }
);
return items;
}
@@ -90,6 +90,7 @@ export function HistoryModal({ noteId, noteTitle, dark, onClose, onRestored, ren
});
};
const REASONS = {
conflict: "conflict — another device had saved",
"large-deletion": "large deletion",
"before-restore": "before restore",
"drawing-cleared": "drawing cleared",
+46 -1
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNoteEditor, EditorSurface } from "../../editor/useNoteEditor.jsx";
import { EditorToolbar } from "../../editor/EditorToolbar.jsx";
import { tableMenuItems } from "../../editor/tableMenu.js";
import { ContextMenu } from "../../ui/ContextMenu.jsx";
import { DrawingLayer } from "../drawing/DrawingLayer.jsx";
import {
CANON_W,
@@ -41,6 +43,7 @@ export function NoteEditor({
const [showColors, setShowColors] = useState(false);
const [showSizes, setShowSizes] = useState(false);
const [hideDrawing, setHideDrawing] = useState(false);
const [tableMenu, setTableMenu] = useState(null);
const [drawingBlend, setDrawingBlend] = useState({ mode: "normal", opacity: 1 });
const [isMobileViewport, setIsMobileViewport] = useState(window.innerWidth < 768);
const stageRef = useRef(null);
@@ -303,6 +306,35 @@ export function NoteEditor({
};
/* ── DRAWING: history ── */
/**
* Right-click inside a table opens the table menu; anywhere else the
* browser's own menu is left alone, because for ordinary text that one is
* more useful than anything offered here.
*/
const onTableContextMenu = (e) => {
if (mode !== "text" || !textEditor) return;
const at = textEditor.view.posAtCoords({ left: e.clientX, top: e.clientY });
// Put the caret in the cell that was clicked, so the commands act on it and
// not on wherever the cursor last sat. A selection spanning several cells is
// left intact — collapsing it would take "merge cells" away in the moment
// the user reached for it.
const sel = textEditor.state.selection;
const isCellSelection = !!sel.$anchorCell;
const clickedInside = at && at.pos >= sel.from && at.pos <= sel.to;
if (at && !(isCellSelection && clickedInside)) {
textEditor.commands.setTextSelection(at.pos);
}
const items = tableMenuItems(textEditor);
if (!items) return;
e.preventDefault();
// Without this the same event carries on to window, where the menu that is
// about to mount reads it as a click elsewhere and closes again instantly.
e.stopPropagation();
setTableMenu({ x: e.clientX, y: e.clientY, items });
};
const pushUndo = () => {
setUndoStack((p) => [...p.slice(-30), strokesRef.current]);
setRedoStack([]);
@@ -597,7 +629,11 @@ export function NoteEditor({
)}
{/* Content area */}
<div ref={stageRef} style={{ flex: 1, position: "relative", overflow: "hidden" }}>
<div
ref={stageRef}
onContextMenu={onTableContextMenu}
style={{ flex: 1, position: "relative", overflow: "hidden" }}
>
<div
style={{
position: "absolute",
@@ -686,6 +722,15 @@ export function NoteEditor({
</div>
</div>
{mode === "text" && <BacklinksPanel backlinks={backlinks} onOpenNote={onOpenNote} dark={dark} />}
{tableMenu && (
<ContextMenu
x={tableMenu.x}
y={tableMenu.y}
items={tableMenu.items}
onClose={() => setTableMenu(null)}
dark={dark}
/>
)}
</div>
);
}
+27 -1
View File
@@ -34,6 +34,9 @@ const succeed = () => {
const failWith = (status: number, body: unknown = { error: "nope" }) => {
handler = () => ({ status, body });
};
const respondWith = (body: unknown) => {
handler = () => ({ status: 200, body });
};
const failTransport = () => {
vi.stubGlobal(
"fetch",
@@ -320,7 +323,30 @@ describe("updateNoteContent", () => {
await vi.runAllTimersAsync();
});
const put = calls.find((c) => c.method === "PUT");
expect(put?.body).toEqual({ content: "body" });
expect(put?.body).toMatchObject({ content: "body" });
expect(put?.body).not.toHaveProperty("links");
});
it("sends the revision the edit was based on, so the server can spot a conflict", async () => {
await db.putNote(note("n1"));
await db.putNoteContent("n1", "old", null, null, "2026-01-01T10:00:00.000Z");
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body");
await vi.runAllTimersAsync();
});
const put = calls.find((c) => c.method === "PUT");
expect((put?.body as Record<string, unknown>).baseUpdatedAt).toBe("2026-01-01T10:00:00.000Z");
});
it("adopts the revision the server reports, so the next save is not taken for a conflict", async () => {
await db.putNote(note("n1"));
await db.putNoteContent("n1", "old", null, null, "2026-01-01T10:00:00.000Z");
respondWith({ success: true, updatedAt: "2026-01-01T11:00:00.000Z" });
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body");
await vi.runAllTimersAsync();
});
expect((await db.getNoteContent("n1"))?.updatedAt).toBe("2026-01-01T11:00:00.000Z");
});
it("queues the change when the request fails", async () => {
+52 -4
View File
@@ -101,8 +101,23 @@ export interface NoteUpdate {
content?: string;
notebookId?: string | null;
starred?: boolean;
tags?: string[];
links?: string[];
/**
* The server revision this edit was written on top of.
*
* Lets the server notice that another device saved in between, and keep the
* state it is about to bury as a version rather than losing it. Absent means
* "do not check" — used for writes that cannot conflict, like starring.
*/
baseUpdatedAt?: string | null;
}
/** What `PUT /notes/:id` answers with. */
interface UpdateResult {
success?: boolean;
updatedAt?: string;
/** True when another device had saved since this edit was started. */
conflict?: boolean;
}
/* ─── API helper ─── */
@@ -189,6 +204,34 @@ const api = {
},
};
/**
* Records the revision the server ended up at after our write.
*
* Without this every later edit would keep quoting the revision the note had
* when it was first loaded, and the second save in a row would look like a
* conflict with our own first one.
*/
async function adoptRevision(id: string, result: UpdateResult): Promise<void> {
if (result?.updatedAt) {
const cached = await db.getNoteContent(id);
if (cached) {
await db.putNoteContent(
id,
cached.content,
cached.drawingData ?? null,
cached.drawingStrokes ?? null,
result.updatedAt
);
}
}
if (result?.conflict) {
// The other device's state is kept server-side as a version marked
// "conflict"; the interface points at the history rather than trying to
// merge two documents nobody asked it to merge.
notifyData("conflict", { id });
}
}
/* ─── Online status ─── */
let _online = navigator.onLine;
window.addEventListener("online", () => {
@@ -502,7 +545,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
break;
}
case "updateNote":
await api.put(`/notes/${a.data.id}`, a.data.updates);
await adoptRevision(a.data.id, await api.put<UpdateResult>(`/notes/${a.data.id}`, a.data.updates));
break;
case "updateDrawing":
await api.put(`/notes/${a.data.id}/drawing`, { drawingStrokes: a.data.drawingStrokes ?? null });
@@ -792,11 +835,16 @@ export async function updateNoteContent(id: string, content: string, links?: str
notifyData("noteContent", { id });
// The server cannot read the encrypted body, so outgoing links travel with it.
const updates: NoteUpdate = Array.isArray(links) ? { content, links } : { content };
// baseUpdatedAt is the revision this copy came from — see NoteUpdate.
const updates: NoteUpdate = Array.isArray(links)
? { content, links, baseUpdatedAt: ex?.updatedAt ?? null }
: { content, baseUpdatedAt: ex?.updatedAt ?? null };
debounce(`content_${id}`, async () => {
if (_online && api.token && !note?._pendingCreate) {
try {
await trackWrite(id, () => api.put(`/notes/${id}`, updates));
const r = await trackWrite(id, () => api.put<UpdateResult>(`/notes/${id}`, updates));
await adoptRevision(id, r);
notifyStatus("synced");
return;
} catch {