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:
1 parent
d97591b242
commit
92d525b3ab
71 files changed
+14354
-1826
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user