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,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);
|
||||
}
|
||||
Reference in new issue
Block a user