TipTap editor, version history, search, attachments — and a cleanup

The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.

Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
  missing; it refuses to start. The old fallback silently made every stored
  note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
  not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
  already eaten drawing strokes and note links; sync.hasPendingWrites() now
  guards every path that takes a body from the server.

Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
  to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
  into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
  encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
  fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
  the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.

Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
  covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
  unreadable.

Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
  search, layout, history and drag each live behind a hook, and the four
  panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
  the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
  restoring a history entry pushed a duplicate one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-07 17:00:14 +02:00
1 parent d97591b242
commit 92d525b3ab
71 files changed
+14354 -1826

No files matched your search

+393
View File
@@ -0,0 +1,393 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as db from "./offlineStore";
import * as sync from "./syncEngine";
import type { NoteMeta, Notebook } from "./types";
/* A stub transport. `req()` only ever touches status, ok and json(), so a plain
* object is enough and keeps the tests free of undici/jsdom differences. */
type Reply = { status: number; body?: unknown };
const reply = ({ status, body = {} }: Reply) => ({
status,
ok: status >= 200 && status < 300,
json: async () => body,
});
let calls: Array<{ method: string; url: string; body: unknown }> = [];
let handler: (method: string, url: string, body: unknown) => Reply | Promise<Reply>;
function installFetch() {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, opts: RequestInit = {}) => {
const method = opts.method || "GET";
const body = opts.body ? JSON.parse(opts.body as string) : undefined;
calls.push({ method, url, body });
return reply(await handler(method, url, body));
})
);
}
const succeed = () => {
handler = () => ({ status: 200, body: { id: "server-id" } });
};
const failWith = (status: number, body: unknown = { error: "nope" }) => {
handler = () => ({ status, body });
};
const failTransport = () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new TypeError("Failed to fetch");
})
);
};
const note = (id: string, over: Partial<NoteMeta> = {}): NoteMeta => ({
id,
title: `Note ${id}`,
folderId: "nb1",
starred: false,
trashed: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...over,
});
const notebook = (id: string, over: Partial<Notebook> = {}): Notebook => ({
id,
name: `NB ${id}`,
cover: 0,
sortOrder: 0,
...over,
});
beforeEach(async () => {
await db.clearAll();
calls = [];
succeed();
installFetch();
sync.__testing.setToken("test-token");
sync.__testing.setOnline(true);
sync.__testing.resetPendingWrites();
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
});
afterEach(() => {
// Restored here as well as in each test: a timed-out test never runs its own
// cleanup, and leaked fake timers would hang every hook after it.
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Fakes only setTimeout, so IndexedDB keeps working while the debounce does not. */
const withFakeDebounce = async (fn: () => Promise<void>) => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
await fn();
} finally {
vi.useRealTimers();
}
};
describe("flushQueue", () => {
it("removes an entry once the server accepted it", async () => {
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await sync.__testing.flushQueue();
expect(await db.getQueueSize()).toBe(0);
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual(["DELETE /api/notes/n1"]);
});
it("keeps a failed entry queued and counts the attempt — losing it silently ate offline edits", async () => {
failWith(500);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await sync.__testing.flushQueue();
const [entry] = await db.peekQueue();
expect(entry).toBeDefined();
expect(entry!.attempts).toBe(1);
expect(entry!.action).toBe("trashNote");
});
it("retries across flushes and gives up only after repeated failures", async () => {
failWith(500);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
for (let i = 1; i <= 4; i++) {
await sync.__testing.flushQueue();
const [entry] = await db.peekQueue();
expect(entry!.attempts).toBe(i);
}
// Fifth failure hits the cap; the entry is dropped so it cannot wedge the queue.
await sync.__testing.flushQueue();
expect(await db.getQueueSize()).toBe(0);
});
it("stops at the first transport failure and keeps everything for later", async () => {
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await db.enqueue({ action: "trashNote", data: { id: "n2" } });
failTransport();
await sync.__testing.flushQueue();
expect(await db.getQueueSize()).toBe(2);
const entries = await db.peekQueue();
// Nothing is counted as a failed attempt: being offline is not the entry's fault.
expect(entries.every((e) => e.attempts === undefined)).toBe(true);
});
it("propagates an auth failure and leaves the queue untouched", async () => {
failWith(401);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await expect(sync.__testing.flushQueue()).rejects.toThrow("AUTH");
expect(await db.getQueueSize()).toBe(1);
});
it("processes entries in the order they were queued", async () => {
await db.enqueue({ action: "trashNote", data: { id: "first" } });
await db.enqueue({ action: "trashNote", data: { id: "second" } });
await sync.__testing.flushQueue();
expect(calls.map((c) => c.url)).toEqual(["/api/notes/first", "/api/notes/second"]);
});
it("does nothing while signed out", async () => {
sync.__testing.setToken(null);
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
await sync.__testing.flushQueue();
expect(calls.length).toBe(0);
expect(await db.getQueueSize()).toBe(1);
});
it("repoints notes at the server id when a queued notebook is finally created", async () => {
await db.putNotebook(notebook("nb_local", { _pendingCreate: true }));
await db.putNote(note("n1", { folderId: "nb_local" }));
await db.enqueue({
action: "createNotebook",
data: { localId: "nb_local", name: "Later", cover: 0, sortOrder: 0 },
});
await sync.__testing.flushQueue();
expect((await db.getNote("n1"))?.folderId).toBe("server-id");
expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["server-id"]);
});
});
describe("fullSync", () => {
const serverState = (notes: unknown[], notebooks: unknown[]) => {
handler = (_method, url) => {
if (url.endsWith("/notebooks")) return { status: 200, body: notebooks };
if (url.endsWith("/notes/trash")) return { status: 200, body: [] };
if (url.endsWith("/notes")) return { status: 200, body: notes };
return { status: 200, body: {} };
};
};
it("mirrors the server's notes and notebooks", async () => {
serverState(
[{ id: "s1", title: "Server note", notebook_id: "snb", created_at: "t", updated_at: "t" }],
[{ id: "snb", name: "Server book", cover_index: 2, sort_order: 1 }]
);
await sync.fullSync();
expect((await db.getAllNotes()).map((n) => n.id)).toEqual(["s1"]);
const [nb] = await db.getAllNotebooks();
expect(nb).toMatchObject({ id: "snb", name: "Server book", cover: 2, sortOrder: 1 });
});
it("keeps notes created offline that the server has never seen", async () => {
await db.putNote(note("local_1", { _pendingCreate: true }));
await db.putNote(note("stale_1")); // synced earlier, since deleted elsewhere
serverState([], []);
await sync.fullSync();
const ids = (await db.getAllNotes()).map((n) => n.id);
expect(ids).toContain("local_1");
expect(ids).not.toContain("stale_1");
});
it("keeps notebooks created offline", async () => {
await db.putNotebook(notebook("nb_local", { _pendingCreate: true }));
serverState([], []);
await sync.fullSync();
expect((await db.getAllNotebooks()).map((n) => n.id)).toContain("nb_local");
});
it("drops the local-only flag once the server reports the same id", async () => {
await db.putNote(note("s1", { _pendingCreate: true }));
serverState(
[{ id: "s1", title: "Now on server", notebook_id: null, created_at: "t", updated_at: "t" }],
[]
);
await sync.fullSync();
const [n] = await db.getAllNotes();
expect(n!._pendingCreate).toBeUndefined();
expect(n!.title).toBe("Now on server");
});
it("leaves the local mirror intact when a server read fails midway", async () => {
await db.putNote(note("keep"));
await db.putNotebook(notebook("keep_nb"));
handler = (_method, url) => {
if (url.endsWith("/notebooks")) return { status: 200, body: [] };
return { status: 500, body: { error: "boom" } }; // the notes read fails
};
await sync.fullSync();
expect((await db.getAllNotes()).map((n) => n.id)).toEqual(["keep"]);
expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["keep_nb"]);
});
it("does nothing while signed out", async () => {
sync.__testing.setToken(null);
await sync.fullSync();
expect(calls.length).toBe(0);
});
});
describe("hasPendingWrites", () => {
it("is false without an id", () => {
expect(sync.hasPendingWrites(null)).toBe(false);
expect(sync.hasPendingWrites(undefined)).toBe(false);
expect(sync.hasPendingWrites("")).toBe(false);
});
it("is false for a note nobody is writing", () => {
expect(sync.hasPendingWrites("n1")).toBe(false);
});
it("reports a write that is in flight — the guard that stops stale reads winning", () => {
sync.__testing.beginWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(true);
expect(sync.hasPendingWrites("n2")).toBe(false);
sync.__testing.endWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(false);
});
it("stays true until the last of several overlapping writes finishes", () => {
sync.__testing.beginWrite("n1");
sync.__testing.beginWrite("n1");
sync.__testing.endWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(true);
sync.__testing.endWrite("n1");
expect(sync.hasPendingWrites("n1")).toBe(false);
});
it("covers the debounce window before a save even starts", async () => {
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "typing…");
// The request has not been sent yet, but the note must already count as busy.
expect(sync.hasPendingWrites("n1")).toBe(true);
await vi.runAllTimersAsync();
});
expect(sync.hasPendingWrites("n1")).toBe(false);
});
});
describe("updateNoteContent", () => {
it("writes locally before anything reaches the network", async () => {
await db.putNote(note("n1"));
await sync.updateNoteContent("n1", "body text");
expect((await db.getNoteContent("n1"))?.content).toBe("body text");
expect(calls.length).toBe(0); // still inside the debounce window
});
it("sends links along with the body, since the server cannot read it", async () => {
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body", ["target-1"]);
await vi.runAllTimersAsync();
});
const put = calls.find((c) => c.method === "PUT");
expect(put?.body).toMatchObject({ content: "body", links: ["target-1"] });
});
it("omits the links field when the caller passes none", async () => {
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body");
await vi.runAllTimersAsync();
});
const put = calls.find((c) => c.method === "PUT");
expect(put?.body).toEqual({ content: "body" });
});
it("queues the change when the request fails", async () => {
failWith(500);
await db.putNote(note("n1"));
await withFakeDebounce(async () => {
await sync.updateNoteContent("n1", "body");
await vi.runAllTimersAsync();
});
const [entry] = await db.peekQueue();
expect(entry?.action).toBe("updateNote");
});
});
describe("loadNoteContent", () => {
it("returns the cached body without waiting for the server", async () => {
await db.putNoteContent("n1", "cached", null);
const c = await sync.loadNoteContent("n1");
expect(c.content).toBe("cached");
});
it("does not refresh from the server while a write is in flight", async () => {
await db.putNoteContent("n1", "cached", null);
sync.__testing.beginWrite("n1");
await sync.loadNoteContent("n1");
expect(calls.length).toBe(0);
sync.__testing.endWrite("n1");
});
it("falls back to the cache when the server read fails", async () => {
await db.putNoteContent("n1", "cached", null);
failWith(500);
const c = await sync.loadNoteContent("n1", { forceRemote: true });
expect(c.content).toBe("cached");
});
it("returns an empty shape for a note with nothing cached and no connection", async () => {
sync.__testing.setOnline(false);
const c = await sync.loadNoteContent("missing");
expect(c).toEqual({ content: "", drawingData: null, drawingStrokes: null, updatedAt: null });
sync.__testing.setOnline(true);
});
});
describe("offline behaviour", () => {
beforeEach(() => sync.__testing.setOnline(false));
afterEach(() => sync.__testing.setOnline(true));
it("creates a note locally and queues it", async () => {
const n = await sync.createNote("nb1");
expect(n._pendingCreate).toBe(true);
expect(calls.length).toBe(0);
const [entry] = await db.peekQueue();
expect(entry?.action).toBe("createNote");
});
it("creates a notebook locally, flagged so fullSync will not drop it", async () => {
const nb = await sync.createNotebook({ name: "Later", cover: 1 });
expect(nb._pendingCreate).toBe(true);
expect((await db.getAllNotebooks())[0]?._pendingCreate).toBe(true);
});
it("queues a star toggle instead of losing it", async () => {
await db.putNote(note("n1"));
expect(await sync.toggleNoteStar("n1")).toBe(true);
const [entry] = await db.peekQueue();
expect(entry?.action).toBe("updateNote");
expect((await db.getNote("n1"))?.starred).toBe(true);
});
});