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:
1 parent
8c36c6121b
commit
df279a5d36
10 files changed
+367
-152
No files matched your search
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user