From 3208b3c92a8098e9d872bd4f3de5b2012cf588ff Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Tue, 28 Apr 2026 22:43:44 +0200 Subject: [PATCH] Add IndexedDB-backed offline notes cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/librenotes/web/public/notes-cache.js exposes window.notesCache with the offline-first read/write API the rest of the app uses: Schema (object store "notes", key path "id"): { id, title, content, updated_at, synced_at, dirty, deleted } plus a denormalised dirty_idx:0|1 column because IndexedDB cannot index booleans directly. Two indexes — by_updated_at for sorted listing, by_dirty for the sync controller's pending-queue scan. Per-tenant database name "librenotes-notes-{user_id}" so two users on the same browser have fully separate offline caches and clearAll() (called from authClient.clearSession on logout) drops only the leaving user's data. Public surface: - list/get/put/remove: straight CRUD. - markDirty(id, patch): stage an offline edit. Bumps updated_at to now() but preserves synced_at so the sync controller can detect server-side concurrent edits via the ?base= 409. - markDeleted(id): tombstone (deleted:true, dirty:true) so the sync controller can replay the delete on reconnect. - markSynced(id, serverUpdatedAt): clear dirty + record serverUpdatedAt as synced_at; if tombstone, drop entirely. - pending(): returns dirty rows for the sync queue. - estimateUsage(): wraps navigator.storage.estimate so the UI can warn before quota. Quota errors are remapped to a typed err.code === "QUOTA" so the UI can show "out of space" instead of a generic failure. The service worker precache list now includes notes-cache.js and sync.js so the offline shell has the cache layer too. Closes #22. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/librenotes/web/public/auth-client.js | 5 + cmd/librenotes/web/public/notes-cache.js | 208 +++++++++++++++++++++++ cmd/librenotes/web/public/sw.js | 2 + 3 files changed, 215 insertions(+) create mode 100644 cmd/librenotes/web/public/notes-cache.js diff --git a/cmd/librenotes/web/public/auth-client.js b/cmd/librenotes/web/public/auth-client.js index 28739fc..31afebc 100644 --- a/cmd/librenotes/web/public/auth-client.js +++ b/cmd/librenotes/web/public/auth-client.js @@ -47,6 +47,11 @@ sessionStorage.removeItem(SESSION_KEY); if (session && session.user_id) { clearTenantStore(session.user_id); + // Drop the offline notes cache for this tenant so a different + // user logging in on the same browser starts fresh. + if (window.notesCache && typeof window.notesCache.clearAll === "function") { + try { window.notesCache.clearAll(); } catch (_) {} + } } } diff --git a/cmd/librenotes/web/public/notes-cache.js b/cmd/librenotes/web/public/notes-cache.js new file mode 100644 index 0000000..5971b66 --- /dev/null +++ b/cmd/librenotes/web/public/notes-cache.js @@ -0,0 +1,208 @@ +// notes-cache.js — IndexedDB-backed offline cache for notes. +// +// Schema (object store "notes", key path "id"): +// { +// id: string, note ID slug +// title: string, +// content: string, +// updated_at: number (unix s), wall-clock of last local edit +// synced_at: number (unix s), server's updated_at as of last sync; +// null if never synced (offline-create) +// dirty: boolean, true when local edits await sync +// deleted: boolean, tombstone for offline deletes +// } +// +// Indexes: by_updated_at (notes.updated_at), by_dirty (notes.dirty). +// +// All reads/writes go through this module so the rest of the app +// has one obvious offline boundary. The sync controller (sync.js) +// is the only consumer of the dirty/deleted flags. + +(function () { + "use strict"; + + const DB_NAME_PREFIX = "librenotes-notes-"; + const DB_VERSION = 1; + const STORE = "notes"; + + function dbName() { + const session = window.authClient && window.authClient.loadSession(); + if (!session) throw new Error("notes-cache: not authenticated"); + return DB_NAME_PREFIX + session.user_id; + } + + function open() { + return new Promise(function (resolve, reject) { + let req; + try { + req = indexedDB.open(dbName(), DB_VERSION); + } catch (e) { + reject(e); + return; + } + req.onupgradeneeded = function (ev) { + const db = ev.target.result; + if (!db.objectStoreNames.contains(STORE)) { + const store = db.createObjectStore(STORE, { keyPath: "id" }); + store.createIndex("by_updated_at", "updated_at", { unique: false }); + // IndexedDB cannot index a boolean directly; we promote + // dirty to a number (0 / 1) on write so we can range-query. + store.createIndex("by_dirty", "dirty_idx", { unique: false }); + } + }; + req.onsuccess = function () { resolve(req.result); }; + req.onerror = function () { reject(req.error); }; + req.onblocked = function () { reject(new Error("notes-cache: db blocked")); }; + }); + } + + function tx(db, mode) { + return db.transaction(STORE, mode).objectStore(STORE); + } + + function promisify(req) { + return new Promise(function (resolve, reject) { + req.onsuccess = function () { resolve(req.result); }; + req.onerror = function () { reject(req.error); }; + }); + } + + function decorateForWrite(note) { + return Object.assign({}, note, { + dirty_idx: note.dirty ? 1 : 0, + }); + } + + async function list() { + const db = await open(); + const all = await promisify(tx(db, "readonly").getAll()); + db.close(); + return all + .filter(function (n) { return !n.deleted; }) + .sort(function (a, b) { return b.updated_at - a.updated_at; }); + } + + async function get(id) { + const db = await open(); + const n = await promisify(tx(db, "readonly").get(id)); + db.close(); + if (!n || n.deleted) return null; + return n; + } + + async function put(note) { + const db = await open(); + try { + await promisify(tx(db, "readwrite").put(decorateForWrite(note))); + } catch (e) { + db.close(); + // QuotaExceededError is the obvious one; surface as a typed + // error so the UI can show "out of space" instead of a generic + // failure. + if (e && (e.name === "QuotaExceededError" || (e.target && e.target.error && e.target.error.name === "QuotaExceededError"))) { + const err = new Error("storage quota exceeded"); + err.code = "QUOTA"; + throw err; + } + throw e; + } + db.close(); + } + + async function remove(id) { + const db = await open(); + await promisify(tx(db, "readwrite").delete(id)); + db.close(); + } + + // markDirty stages a local edit. updated_at is bumped to now; + // synced_at is left at the prior value so the sync controller can + // detect concurrent server edits via the conflict endpoint. + async function markDirty(id, patch) { + const existing = (await get(id)) || { id: id, synced_at: null }; + const merged = Object.assign({}, existing, patch, { + dirty: true, + deleted: false, + updated_at: Math.floor(Date.now() / 1000), + }); + await put(merged); + return merged; + } + + async function markDeleted(id) { + const existing = await get(id); + if (!existing) return; + const tombstone = Object.assign({}, existing, { + dirty: true, + deleted: true, + updated_at: Math.floor(Date.now() / 1000), + }); + const db = await open(); + await promisify(tx(db, "readwrite").put(decorateForWrite(tombstone))); + db.close(); + } + + // markSynced clears the dirty flag and records the server-side + // updated_at as synced_at. If the note was a tombstone, drop it. + async function markSynced(id, serverUpdatedAt) { + const existing = await get(id); + if (!existing) { + // Could be a tombstone we just confirmed — read raw. + const db = await open(); + const raw = await promisify(tx(db, "readonly").get(id)); + db.close(); + if (raw && raw.deleted) { + await remove(id); + } + return; + } + const updated = Object.assign({}, existing, { + dirty: false, + synced_at: serverUpdatedAt, + }); + await put(updated); + } + + // pending returns notes with unsynced changes (including tombstones). + async function pending() { + const db = await open(); + const idx = tx(db, "readonly").index("by_dirty"); + const range = IDBKeyRange.only(1); + const all = await promisify(idx.getAll(range)); + db.close(); + return all; + } + + async function clearAll() { + try { + const name = dbName(); + indexedDB.deleteDatabase(name); + } catch (_) { + // best effort + } + } + + // estimateUsage returns approximate storage usage if the browser + // exposes navigator.storage.estimate(). UI uses this to warn the + // user before they hit quota. + async function estimateUsage() { + if (navigator.storage && navigator.storage.estimate) { + try { return await navigator.storage.estimate(); } + catch (_) { return null; } + } + return null; + } + + window.notesCache = { + list, + get, + put, + remove, + markDirty, + markDeleted, + markSynced, + pending, + clearAll, + estimateUsage, + }; +})(); diff --git a/cmd/librenotes/web/public/sw.js b/cmd/librenotes/web/public/sw.js index 59848cc..4c633f7 100644 --- a/cmd/librenotes/web/public/sw.js +++ b/cmd/librenotes/web/public/sw.js @@ -20,6 +20,8 @@ const PRECACHE = [ "/app.html", "/style.css", "/auth-client.js", + "/notes-cache.js", + "/sync.js", "/login.js", "/verify.js", "/app.js",