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>
176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
|
|
import * as db from "./offlineStore";
|
|
import type { NoteMeta, Notebook } from "./types";
|
|
|
|
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, sortOrder = 0): Notebook => ({ id, name: `NB ${id}`, cover: 0, sortOrder });
|
|
|
|
beforeEach(async () => {
|
|
await db.clearAll();
|
|
});
|
|
|
|
describe("notes", () => {
|
|
it("stores and reads back a note", async () => {
|
|
await db.putNote(note("a"));
|
|
expect(await db.getNote("a")).toMatchObject({ id: "a", title: "Note a" });
|
|
});
|
|
|
|
it("returns undefined for a note that does not exist", async () => {
|
|
expect(await db.getNote("nope")).toBeUndefined();
|
|
});
|
|
|
|
it("writes a batch in one transaction", async () => {
|
|
await db.putNotes([note("a"), note("b"), note("c")]);
|
|
expect((await db.getAllNotes()).length).toBe(3);
|
|
});
|
|
|
|
it("deleting a note also drops its body, leaving nothing orphaned", async () => {
|
|
await db.putNote(note("a"));
|
|
await db.putNoteContent("a", "content", null);
|
|
await db.deleteNote("a");
|
|
expect(await db.getNote("a")).toBeUndefined();
|
|
expect(await db.getNoteContent("a")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("note content", () => {
|
|
it("keeps content, drawing and revision together", async () => {
|
|
const strokes = {
|
|
v: 1,
|
|
w: 1200,
|
|
h: 1600,
|
|
strokes: [{ t: "pen" as const, c: "#fff", s: 5, p: [0, 0, 1, 1] }],
|
|
};
|
|
await db.putNoteContent("a", "body", null, strokes, "2026-02-02T00:00:00.000Z");
|
|
const got = await db.getNoteContent("a");
|
|
expect(got?.content).toBe("body");
|
|
expect(got?.drawingStrokes?.strokes.length).toBe(1);
|
|
expect(got?.updatedAt).toBe("2026-02-02T00:00:00.000Z");
|
|
});
|
|
|
|
it("defaults the drawing fields rather than leaving them undefined", async () => {
|
|
await db.putNoteContent("a", "body", null);
|
|
const got = await db.getNoteContent("a");
|
|
expect(got?.drawingStrokes).toBeNull();
|
|
expect(got?.updatedAt).toBeNull();
|
|
});
|
|
|
|
it("overwrites an existing body", async () => {
|
|
await db.putNoteContent("a", "first", null);
|
|
await db.putNoteContent("a", "second", null);
|
|
expect((await db.getNoteContent("a"))?.content).toBe("second");
|
|
});
|
|
});
|
|
|
|
describe("notebooks", () => {
|
|
it("returns notebooks in sort order, not insertion order", async () => {
|
|
await db.putNotebooks([notebook("c", 2), notebook("a", 0), notebook("b", 1)]);
|
|
expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["a", "b", "c"]);
|
|
});
|
|
});
|
|
|
|
describe("sync queue", () => {
|
|
it("preserves insertion order", async () => {
|
|
await db.enqueue({ action: "first", data: {} });
|
|
await db.enqueue({ action: "second", data: {} });
|
|
expect((await db.peekQueue()).map((e) => e.action)).toEqual(["first", "second"]);
|
|
});
|
|
|
|
it("counts what is waiting", async () => {
|
|
await db.enqueue({ action: "a", data: {} });
|
|
await db.enqueue({ action: "b", data: {} });
|
|
expect(await db.getQueueSize()).toBe(2);
|
|
});
|
|
|
|
it("removes only the dequeued entry", async () => {
|
|
await db.enqueue({ action: "keep", data: {} });
|
|
await db.enqueue({ action: "drop", data: {} });
|
|
const [, second] = await db.peekQueue();
|
|
await db.dequeue(second!.queueId);
|
|
expect((await db.peekQueue()).map((e) => e.action)).toEqual(["keep"]);
|
|
});
|
|
|
|
it("records a failed attempt without losing the entry — the bug that silently ate offline edits", async () => {
|
|
await db.enqueue({ action: "updateNote", data: { id: "a" } });
|
|
const [entry] = await db.peekQueue();
|
|
await db.updateQueueEntry(entry!.queueId, { attempts: 1, lastError: "boom" });
|
|
|
|
const [updated] = await db.peekQueue();
|
|
expect(updated!.attempts).toBe(1);
|
|
expect(updated!.lastError).toBe("boom");
|
|
expect(updated!.action).toBe("updateNote");
|
|
expect(updated!.data).toEqual({ id: "a" });
|
|
});
|
|
|
|
it("ignores an update for an entry that is already gone", async () => {
|
|
await expect(db.updateQueueEntry(9999, { attempts: 1 })).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("attachments", () => {
|
|
/* jsdom's Blob is a stub without `arrayBuffer`, and fake-indexeddb's
|
|
* structured clone flattens it to `{}` — so real Blob fidelity cannot be
|
|
* asserted here. A byte array clones faithfully and still exercises the
|
|
* store's own logic; that images survive a round trip through a real browser
|
|
* is covered by the end-to-end suite instead. */
|
|
it("round-trips binary payloads", async () => {
|
|
const bytes = new Uint8Array([1, 2, 3, 250]);
|
|
await db.putAttachmentBlob("att1", bytes as unknown as Blob);
|
|
const got = (await db.getAttachmentBlob("att1")) as unknown as Uint8Array;
|
|
expect(got).not.toBeNull();
|
|
expect(Array.from(got)).toEqual([1, 2, 3, 250]);
|
|
});
|
|
|
|
it("returns null for an attachment that was never cached", async () => {
|
|
expect(await db.getAttachmentBlob("missing")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("meta", () => {
|
|
it("stores arbitrary values", async () => {
|
|
await db.setMeta("user", { id: "u1", username: "marc" });
|
|
expect(await db.getMeta("user")).toEqual({ id: "u1", username: "marc" });
|
|
});
|
|
|
|
it("returns null for an unset key", async () => {
|
|
expect(await db.getMeta("nothing")).toBeNull();
|
|
});
|
|
|
|
it("deletes a key", async () => {
|
|
await db.setMeta("token", "abc");
|
|
await db.deleteMeta("token");
|
|
expect(await db.getMeta("token")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("clearAll", () => {
|
|
it("wipes every store, as logout must", async () => {
|
|
await db.putNote(note("a"));
|
|
await db.putNoteContent("a", "body", null);
|
|
await db.putNotebook(notebook("nb1"));
|
|
await db.enqueue({ action: "x", data: {} });
|
|
await db.setMeta("token", "abc");
|
|
await db.putAttachmentBlob("att", new Blob(["x"]));
|
|
|
|
await db.clearAll();
|
|
|
|
expect(await db.getAllNotes()).toEqual([]);
|
|
expect(await db.getNoteContent("a")).toBeUndefined();
|
|
expect(await db.getAllNotebooks()).toEqual([]);
|
|
expect(await db.getQueueSize()).toBe(0);
|
|
expect(await db.getMeta("token")).toBeNull();
|
|
expect(await db.getAttachmentBlob("att")).toBeNull();
|
|
});
|
|
});
|