/** * 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 { 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(req: IDBRequest): Promise { 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 { 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 { const d = await open(); return d.transaction(name, mode).objectStore(name); } /* ─── Meta (key-value) ─── */ export async function getMeta(key: string): Promise { 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 { const s = await store("meta", "readwrite"); await reqToPromise(s.put({ key, value })); } export async function deleteMeta(key: string): Promise { const s = await store("meta", "readwrite"); await reqToPromise(s.delete(key)); } /* ─── Chapters ─── */ export async function getAllChapters(): Promise { const s = await store("chapters", "readonly"); return reqToPromise(s.getAll()); } export async function putChapter(c: Chapter): Promise { const s = await store("chapters", "readwrite"); await reqToPromise(s.put(c)); } export async function putChapters(list: Chapter[]): Promise { 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 { const s = await store("chapters", "readwrite"); await reqToPromise(s.delete(id)); } /* ─── Notebooks ─── */ export async function getAllNotebooks(): Promise { const s = await store("notebooks", "readonly"); const all = await reqToPromise(s.getAll()); return all.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); } export async function putNotebook(nb: Notebook): Promise { const s = await store("notebooks", "readwrite"); await reqToPromise(s.put(nb)); } export async function putNotebooks(list: Notebook[]): Promise { 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 { const s = await store("notebooks", "readwrite"); await reqToPromise(s.delete(id)); } export async function clearNotebooks(): Promise { const s = await store("notebooks", "readwrite"); await reqToPromise(s.clear()); } /* ─── Notes (metadata) ─── */ export async function getAllNotes(): Promise { const s = await store("notes", "readonly"); return reqToPromise(s.getAll()); } export async function getNote(id: string): Promise { const s = await store("notes", "readonly"); return reqToPromise(s.get(id)); } export async function putNote(note: NoteMeta): Promise { const s = await store("notes", "readwrite"); await reqToPromise(s.put(note)); } export async function putNotes(list: NoteMeta[]): Promise { 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 { 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 { 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 { 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 { 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 { 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 }): Promise { const s = await store("syncQueue", "readwrite"); await reqToPromise(s.add({ ...action, timestamp: Date.now() })); } export async function peekQueue(limit = 50): Promise { const s = await store("syncQueue", "readonly"); return reqToPromise(s.getAll(null, limit)); } export async function dequeue(queueId: number): Promise { 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): Promise { const s = await store("syncQueue", "readwrite"); const entry = await reqToPromise(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 { if (!oldId || oldId === newId) return; const s = await store("syncQueue", "readwrite"); const entries = await reqToPromise(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 { const s = await store("syncQueue", "readwrite"); await reqToPromise(s.clear()); } export async function getQueueSize(): Promise { const s = await store("syncQueue", "readonly"); return reqToPromise(s.count()); } /* ─── Full wipe (on logout) ─── */ export async function clearAll(): Promise { 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); }