side-notes/shared/storage.js

190 lines
5.7 KiB
JavaScript

// IndexedDB storage layer for Side Notes.
//
// Schema:
// Object store: "notes"
// Key path: id (auto-generated)
// Indexes: "byKey" (non-unique, on derived URL key)
// "byUpdated" (on updatedAt)
//
// Retrieval always filters `deletedAt == null`, so at most one live note
// per URL key is visible even if soft-deleted records coexist.
const DB_NAME = 'side-notes';
const DB_VERSION = 1;
const STORE_NAME = 'notes';
/** @returns {Promise<IDBDatabase>} */
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: 'id',
autoIncrement: true,
});
store.createIndex('byKey', 'key', { unique: false });
store.createIndex('byUpdated', 'updatedAt');
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
/**
* Generate a unique id. crypto.randomUUID is available in service workers
* and extension pages in Chromium-based browsers.
*/
function generateId() {
return crypto.randomUUID();
}
/**
* Retrieve the live (non-deleted) note for a given URL key.
* @param {string} key
* @returns {Promise<object|null>}
*/
export async function getNoteByKey(key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const index = tx.objectStore(STORE_NAME).index('byKey');
const req = index.openCursor(IDBKeyRange.only(key), 'prev');
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return resolve(null);
// Skip soft-deleted records; return the first live one (most recent
// because we iterate in reverse insertion order via 'prev').
if (cursor.value.deletedAt == null) {
resolve(cursor.value);
} else {
cursor.continue();
}
};
req.onerror = () => reject(req.error);
});
}
/**
* Persist (insert or update) a note for a given key.
*
* The save strategy: look up the existing live note for this key; if one
* exists, overwrite it in-place. Otherwise insert a new record.
*
* @param {string} key
* @param {string} content — markdown body
* @param {{ pageTitle?: string, favIconUrl?: string }} pageMeta
* @param {{ caretStart?: number, caretEnd?: number, scrollTop?: number }} [caret]
* Optional editor caret/scroll state captured at save time. When
* omitted, any previously stored caret fields are preserved.
* @returns {Promise<object>} the saved record
*/
export async function saveNote(key, content, pageMeta = {}, caret) {
const db = await openDB();
const now = new Date().toISOString();
// Find the existing live record for this key.
const existing = await getNoteByKey(key);
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
let req;
if (existing) {
// Update in-place.
const updated = {
...existing,
key,
content,
pageTitle: pageMeta.pageTitle ?? existing.pageTitle ?? '',
favIconUrl: pageMeta.favIconUrl ?? existing.favIconUrl ?? '',
updatedAt: now,
};
if (caret) {
updated.caretStart = caret.caretStart ?? 0;
updated.caretEnd = caret.caretEnd ?? 0;
updated.scrollTop = caret.scrollTop ?? 0;
}
req = store.put(updated);
} else {
// Insert new.
const record = {
id: generateId(),
key,
content,
pageTitle: pageMeta.pageTitle ?? '',
favIconUrl: pageMeta.favIconUrl ?? '',
createdAt: now,
updatedAt: now,
deletedAt: null,
};
if (caret) {
record.caretStart = caret.caretStart ?? 0;
record.caretEnd = caret.caretEnd ?? 0;
record.scrollTop = caret.scrollTop ?? 0;
}
req = store.add(record);
}
req.onsuccess = () => {
// Return the saved record.
const getReq = store.get(req.result);
getReq.onsuccess = () => resolve(getReq.result);
getReq.onerror = () => reject(getReq.error);
};
req.onerror = () => reject(req.error);
});
}
/**
* List all live notes ordered by updatedAt descending.
* @returns {Promise<object[]>}
*/
export async function listNotes() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const index = tx.objectStore(STORE_NAME).index('byUpdated');
const req = index.openCursor(null, 'prev');
const results = [];
req.onsuccess = () => {
const cursor = req.result;
if (cursor) {
if (cursor.value.deletedAt == null) {
results.push(cursor.value);
}
cursor.continue();
} else {
resolve(results);
}
};
req.onerror = () => reject(req.error);
});
}
/**
* Soft-delete a note by id (sets deletedAt). No-op if already deleted.
* @param {string|number} id
* @returns {Promise<void>}
*/
export async function markDeleted(id) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const getReq = store.get(id);
getReq.onsuccess = () => {
const record = getReq.result;
if (!record || record.deletedAt != null) {
resolve();
return;
}
record.deletedAt = new Date().toISOString();
store.put(record).onsuccess = () => resolve();
};
getReq.onerror = () => reject(getReq.error);
});
}