A note is in exactly one chapter, or in none — which means the notebook itself. Chapters sit above the loose notes; tapping one opens its own list and the menu button becomes the way back out, which on a phone is the only way, since the list fills the screen. A chapter is not a wastebasket. Removing one never removes what it held: the notes come back to the notebook, where they can be seen and filed again. That is ON DELETE SET NULL, and it is also written out in the delete route, because it is the promise the whole thing rests on and should be visible where it happens rather than inferred from a schema. The dialog says so too, with the number of notes affected, and the suite removes a chapter holding three notes and asks the server for all three. Three rules keep the two levels honest. A note filed into a chapter leaves the notebook's own list, or filing it would appear to do nothing. A chapter belongs to its notebook, so the server refuses to file a note into a chapter of a book it is not in, and moving a note to another notebook clears its chapter. And separators belong to the list they were drawn in: a separator's order counts the notes before it, which means nothing across lists. Two bugs found while building it, both a day old and both affecting the desktop rather than the phone they were written for: - A submenu was clipped away. The context menu had been given `overflow-y: auto`, which cuts off anything flying out sideways — so the notebooks under "Move to..." were invisible and every click on them landed on the note list behind. The side was inverted too: `right: 100%` places a panel on the left. - A separator was a one-pixel hairline in the border colour with faint grey text on near-black. Present, and on a phone effectively invisible. And one test that never measured what it claimed. It asked whether a dragged note had become "the most recently changed" by reading the order on screen — but three notes made seconds apart share an `updated_at` second, so that order is a tie broken arbitrarily. It passed on luck for weeks and fell over on a run a fraction faster. It now compares the timestamps themselves, before and after the drag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
/**
|
|
* 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 { Chapter, NoteMeta, NoteContent, Notebook, StrokeDoc, QueueEntry } from "./types";
|
|
|
|
const DB_NAME = "inknote";
|
|
const DB_VERSION = 4;
|
|
|
|
type StoreName = "notebooks" | "chapters" | "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" });
|
|
}
|
|
|
|
// Chapters — folders inside a notebook.
|
|
if (!d.objectStoreNames.contains("chapters")) {
|
|
const ch = d.createObjectStore("chapters", { keyPath: "id" });
|
|
ch.createIndex("notebookId", "notebookId");
|
|
}
|
|
};
|
|
|
|
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));
|
|
}
|
|
|
|
/* ─── Chapters ─── */
|
|
|
|
export async function getAllChapters(): Promise<Chapter[]> {
|
|
const s = await store("chapters", "readonly");
|
|
return reqToPromise<Chapter[]>(s.getAll());
|
|
}
|
|
|
|
export async function putChapter(c: Chapter): Promise<void> {
|
|
const s = await store("chapters", "readwrite");
|
|
await reqToPromise(s.put(c));
|
|
}
|
|
|
|
export async function putChapters(list: Chapter[]): Promise<void> {
|
|
const s = await store("chapters", "readwrite");
|
|
for (const c of list) s.put(c);
|
|
await txDone(s.transaction);
|
|
}
|
|
|
|
export async function deleteChapter(id: string): Promise<void> {
|
|
const s = await store("chapters", "readwrite");
|
|
await reqToPromise(s.delete(id));
|
|
}
|
|
|
|
/* ─── 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 }));
|
|
}
|
|
|
|
/**
|
|
* Rewrites every queued reference to a temporary id.
|
|
*
|
|
* Something made offline carries an id this device invented. When its create
|
|
* finally reaches the server, the server names it something else — and every
|
|
* entry still waiting in the queue is addressed to the name nobody else knows.
|
|
*
|
|
* They used to be left as they were. The database was repointed and the queue
|
|
* was not, so the create arrived and then every edit behind it asked the server
|
|
* for `local_xxxx`, got a 404, retried five times and was thrown away: a note
|
|
* written and drawn on offline reached the server empty and untitled, with the
|
|
* work silently dropped.
|
|
*
|
|
* The replacement is a plain string match over the whole payload rather than a
|
|
* list of fields to look in. Ids are long random strings, so a false match is
|
|
* not a practical worry — and a list of fields is a list to forget to add to.
|
|
*/
|
|
export async function repointQueue(oldId: string, newId: string): Promise<void> {
|
|
if (!oldId || oldId === newId) return;
|
|
const s = await store("syncQueue", "readwrite");
|
|
const entries = await reqToPromise<QueueEntry[]>(s.getAll());
|
|
for (const entry of entries) {
|
|
const before = JSON.stringify(entry.data ?? {});
|
|
if (!before.includes(oldId)) continue;
|
|
await reqToPromise(s.put({ ...entry, data: JSON.parse(before.split(oldId).join(newId)) }));
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|