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

+245
View File
@@ -0,0 +1,245 @@
/**
* InkNote Offline Store — IndexedDB local-first storage.
*
* All reads and writes go here first, so the app works offline; the sync engine
* pushes queued changes to the server when a connection is available.
*/
import type { NoteMeta, NoteContent, Notebook, StrokeDoc, QueueEntry } from "./types";
const DB_NAME = "inknote";
const DB_VERSION = 3;
type StoreName = "notebooks" | "notes" | "noteContents" | "syncQueue" | "meta" | "attachments";
let db: IDBDatabase | null = null;
function open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
if (db) return resolve(db);
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const d = (e.target as IDBOpenDBRequest).result;
if (!d.objectStoreNames.contains("notebooks")) {
const nb = d.createObjectStore("notebooks", { keyPath: "id" });
nb.createIndex("sortOrder", "sortOrder");
}
if (!d.objectStoreNames.contains("notes")) {
const n = d.createObjectStore("notes", { keyPath: "id" });
n.createIndex("folderId", "folderId");
n.createIndex("trashed", "trashed");
n.createIndex("updatedAt", "updatedAt");
}
// Bodies live apart from metadata so the note list stays cheap to load.
if (!d.objectStoreNames.contains("noteContents")) {
d.createObjectStore("noteContents", { keyPath: "id" });
}
if (!d.objectStoreNames.contains("syncQueue")) {
const sq = d.createObjectStore("syncQueue", { keyPath: "queueId", autoIncrement: true });
sq.createIndex("entityId", "entityId");
sq.createIndex("timestamp", "timestamp");
}
if (!d.objectStoreNames.contains("meta")) {
d.createObjectStore("meta", { keyPath: "key" });
}
// Attachment bytes, so images render offline and download only once.
if (!d.objectStoreNames.contains("attachments")) {
d.createObjectStore("attachments", { keyPath: "id" });
}
};
req.onsuccess = (e) => {
db = (e.target as IDBOpenDBRequest).result;
resolve(db);
};
req.onerror = (e) => reject((e.target as IDBOpenDBRequest).error);
});
}
function reqToPromise<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((res, rej) => {
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
});
}
/** Resolves when the whole transaction commits, not just one request. */
function txDone(t: IDBTransaction): Promise<void> {
return new Promise((res, rej) => {
t.oncomplete = () => res();
t.onerror = () => rej(t.error);
t.onabort = () => rej(t.error ?? new Error("Transaction aborted"));
});
}
async function store(name: StoreName, mode: IDBTransactionMode): Promise<IDBObjectStore> {
const d = await open();
return d.transaction(name, mode).objectStore(name);
}
/* ─── Meta (key-value) ─── */
export async function getMeta<T = unknown>(key: string): Promise<T | null> {
const s = await store("meta", "readonly");
const r = await reqToPromise<{ key: string; value: T } | undefined>(s.get(key));
return r?.value ?? null;
}
export async function setMeta(key: string, value: unknown): Promise<void> {
const s = await store("meta", "readwrite");
await reqToPromise(s.put({ key, value }));
}
export async function deleteMeta(key: string): Promise<void> {
const s = await store("meta", "readwrite");
await reqToPromise(s.delete(key));
}
/* ─── Notebooks ─── */
export async function getAllNotebooks(): Promise<Notebook[]> {
const s = await store("notebooks", "readonly");
const all = await reqToPromise<Notebook[]>(s.getAll());
return all.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}
export async function putNotebook(nb: Notebook): Promise<void> {
const s = await store("notebooks", "readwrite");
await reqToPromise(s.put(nb));
}
export async function putNotebooks(list: Notebook[]): Promise<void> {
const d = await open();
const t = d.transaction("notebooks", "readwrite");
const s = t.objectStore("notebooks");
for (const nb of list) s.put(nb);
return txDone(t);
}
export async function deleteNotebook(id: string): Promise<void> {
const s = await store("notebooks", "readwrite");
await reqToPromise(s.delete(id));
}
export async function clearNotebooks(): Promise<void> {
const s = await store("notebooks", "readwrite");
await reqToPromise(s.clear());
}
/* ─── Notes (metadata) ─── */
export async function getAllNotes(): Promise<NoteMeta[]> {
const s = await store("notes", "readonly");
return reqToPromise<NoteMeta[]>(s.getAll());
}
export async function getNote(id: string): Promise<NoteMeta | undefined> {
const s = await store("notes", "readonly");
return reqToPromise<NoteMeta | undefined>(s.get(id));
}
export async function putNote(note: NoteMeta): Promise<void> {
const s = await store("notes", "readwrite");
await reqToPromise(s.put(note));
}
export async function putNotes(list: NoteMeta[]): Promise<void> {
const d = await open();
const t = d.transaction("notes", "readwrite");
const s = t.objectStore("notes");
for (const n of list) s.put(n);
return txDone(t);
}
export async function deleteNote(id: string): Promise<void> {
const d = await open();
const t = d.transaction(["notes", "noteContents"], "readwrite");
t.objectStore("notes").delete(id);
t.objectStore("noteContents").delete(id);
return txDone(t);
}
export async function clearNotes(): Promise<void> {
const s = await store("notes", "readwrite");
await reqToPromise(s.clear());
}
/* ─── Note content (lazily loaded) ─── */
export async function getNoteContent(id: string): Promise<(NoteContent & { id: string }) | undefined> {
const s = await store("noteContents", "readonly");
return reqToPromise<(NoteContent & { id: string }) | undefined>(s.get(id));
}
/**
* `updatedAt` records which server revision this copy came from, so a background
* prefetch can tell a stale cached body from an up-to-date one.
*/
export async function putNoteContent(
id: string,
content: string,
drawingData: string | null,
drawingStrokes: StrokeDoc | null = null,
updatedAt: string | null = null
): Promise<void> {
const s = await store("noteContents", "readwrite");
await reqToPromise(s.put({ id, content, drawingData, drawingStrokes, updatedAt }));
}
/* ─── Attachments (binary cache) ─── */
export async function getAttachmentBlob(id: string): Promise<Blob | null> {
const s = await store("attachments", "readonly");
const r = await reqToPromise<{ id: string; blob: Blob } | undefined>(s.get(id));
return r?.blob ?? null;
}
export async function putAttachmentBlob(id: string, blob: Blob): Promise<void> {
const s = await store("attachments", "readwrite");
await reqToPromise(s.put({ id, blob, cachedAt: Date.now() }));
}
/* ─── Sync queue ─── */
export async function enqueue(action: { action: string; data: Record<string, unknown> }): Promise<void> {
const s = await store("syncQueue", "readwrite");
await reqToPromise(s.add({ ...action, timestamp: Date.now() }));
}
export async function peekQueue(limit = 50): Promise<QueueEntry[]> {
const s = await store("syncQueue", "readonly");
return reqToPromise<QueueEntry[]>(s.getAll(null, limit));
}
export async function dequeue(queueId: number): Promise<void> {
const s = await store("syncQueue", "readwrite");
await reqToPromise(s.delete(queueId));
}
/** Records a failed attempt so a retried entry can eventually be given up on. */
export async function updateQueueEntry(queueId: number, patch: Partial<QueueEntry>): Promise<void> {
const s = await store("syncQueue", "readwrite");
const entry = await reqToPromise<QueueEntry | undefined>(s.get(queueId));
if (!entry) return;
await reqToPromise(s.put({ ...entry, ...patch }));
}
export async function clearQueue(): Promise<void> {
const s = await store("syncQueue", "readwrite");
await reqToPromise(s.clear());
}
export async function getQueueSize(): Promise<number> {
const s = await store("syncQueue", "readonly");
return reqToPromise<number>(s.count());
}
/* ─── Full wipe (on logout) ─── */
export async function clearAll(): Promise<void> {
const d = await open();
const stores: StoreName[] = ["notebooks", "notes", "noteContents", "syncQueue", "meta", "attachments"];
const t = d.transaction(stores, "readwrite");
for (const s of stores) t.objectStore(s).clear();
return txDone(t);
}