Add IndexedDB-backed offline notes cache

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=<unix> 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:43:44 +02:00
co-authored by Claude Opus 4.7
parent f9c941f723
commit 3208b3c92a
3 changed files with 215 additions and 0 deletions
+5
View File
@@ -47,6 +47,11 @@
sessionStorage.removeItem(SESSION_KEY); sessionStorage.removeItem(SESSION_KEY);
if (session && session.user_id) { if (session && session.user_id) {
clearTenantStore(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 (_) {}
}
} }
} }
+208
View File
@@ -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,
};
})();
+2
View File
@@ -20,6 +20,8 @@ const PRECACHE = [
"/app.html", "/app.html",
"/style.css", "/style.css",
"/auth-client.js", "/auth-client.js",
"/notes-cache.js",
"/sync.js",
"/login.js", "/login.js",
"/verify.js", "/verify.js",
"/app.js", "/app.js",