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:
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -20,6 +20,8 @@ const PRECACHE = [
|
||||
"/app.html",
|
||||
"/style.css",
|
||||
"/auth-client.js",
|
||||
"/notes-cache.js",
|
||||
"/sync.js",
|
||||
"/login.js",
|
||||
"/verify.js",
|
||||
"/app.js",
|
||||
|
||||
Reference in New Issue
Block a user