1/** 2 * TodoDB - IndexedDB persistence layer for Magic Todo 3 * 4 * Provides async storage with no practical size limit, complementing 5 * localStorage which is used as a fast synchronous boot cache. 6 * 7 * Schema (v1): 8 * Object Store "todos" - keyPath: "id", indexes: parentId, timestamp 9 * Object Store "meta" - keyPath: "key" (stores timestamps, migration state, etc.) 10 */ 11 12const TodoDB = (function () { 13 const DB_NAME = 'goblin-todos'; 14 const DB_VERSION = 1; 15 const STORE_TODOS = 'todos'; 16 const STORE_META = 'meta'; 17 18 let _db = null; 19 let _ready = null; // Promise that resolves when DB is open 20 let _writesInFlight = 0; // Track active saveAll calls so an unexpected 21 // `onclose` mid-write can flag the next caller 22 // to retry rather than silently dropping data. 23 let _closeDuringWrite = false; 24 let _writeFailed = false; // Set when a write transaction aborts/errors 25 // (e.g. quota exceeded). saveAll resolves either 26 // way, so the caller consumes this flag to tell a 27 // committed write from a silently-dropped one. 28 29 /** 30 * Serialize writes across tabs via Web Locks. Falls through to 31 * `fn()` when the helper isn't loaded (tests, very old browsers). 32 */ 33 function _withLock(fn) { 34 if (typeof window !== 'undefined' && typeof window.withTodoLock === 'function') { 35 return window.withTodoLock(fn); 36 } 37 return fn(); 38 } 39 40 /** 41 * Open (or create) the IndexedDB database. 42 * Returns the db instance, or null if IndexedDB is unavailable. 43 */ 44 function open() { 45 if (_ready) return _ready; 46 47 _ready = new Promise(function (resolve) { 48 if (typeof indexedDB === 'undefined') { 49 console.warn('TodoDB: IndexedDB not available, falling back to localStorage only'); 50 resolve(null); 51 return; 52 } 53 54 try { 55 var request = indexedDB.open(DB_NAME, DB_VERSION); 56 57 request.onupgradeneeded = function (event) { 58 var db = event.target.result; 59 60 if (!db.objectStoreNames.contains(STORE_TODOS)) { 61 var store = db.createObjectStore(STORE_TODOS, { keyPath: 'id' }); 62 store.createIndex('parentId', 'parentId', { unique: false }); 63 store.createIndex('timestamp', 'timestamp', { unique: false }); 64 } 65 66 if (!db.objectStoreNames.contains(STORE_META)) { 67 db.createObjectStore(STORE_META, { keyPath: 'key' }); 68 } 69 }; 70 71 request.onsuccess = function (event) { 72 _db = event.target.result; 73 74 // Handle unexpected close (e.g. version change from another tab) 75 _db.onclose = function () { 76 // If a write was mid-flight when the connection 77 // dropped, the transaction will have aborted but 78 // the promise still resolves cleanly. Flag it so 79 // the caller can warn/retry. Next call will 80 // re-open since `_db` and `_ready` are nulled. 81 if (_writesInFlight > 0) { 82 _closeDuringWrite = true; 83 } 84 _db = null; 85 _ready = null; 86 }; 87 88 resolve(_db); 89 }; 90 91 request.onerror = function (event) { 92 console.error('TodoDB: open error:', event.target.error); 93 resolve(null); 94 }; 95 } catch (e) { 96 console.error('TodoDB: failed to open:', e); 97 resolve(null); 98 } 99 }); 100 101 return _ready; 102 } 103 104 /** 105 * Load all todos from IndexedDB. 106 * Returns an array of plain objects, or null if unavailable. 107 */ 108 async function loadAll() { 109 var db = await open(); 110 if (!db) return null; 111 112 return new Promise(function (resolve) { 113 try { 114 var tx = db.transaction(STORE_TODOS, 'readonly'); 115 var store = tx.objectStore(STORE_TODOS); 116 var request = store.getAll(); 117 118 request.onsuccess = function () { 119 resolve(request.result || []); 120 }; 121 122 request.onerror = function () { 123 console.error('TodoDB: loadAll error:', request.error); 124 resolve(null); 125 }; 126 } catch (e) { 127 console.error('TodoDB: loadAll exception:', e); 128 resolve(null); 129 } 130 }); 131 } 132 133 /** 134 * Read a value from the meta store. 135 */ 136 async function getMeta(key) { 137 var db = await open(); 138 if (!db) return null; 139 140 return new Promise(function (resolve) { 141 try { 142 var tx = db.transaction(STORE_META, 'readonly'); 143 var request = tx.objectStore(STORE_META).get(key); 144 145 request.onsuccess = function () { 146 resolve(request.result ? request.result.value : null); 147 }; 148 149 request.onerror = function () { 150 resolve(null); 151 }; 152 } catch (e) { 153 resolve(null); 154 } 155 }); 156 } 157 158 /** 159 * Write a value to the meta store. 160 */ 161 async function setMeta(key, value) { 162 var db = await open(); 163 if (!db) return; 164 165 return new Promise(function (resolve) { 166 try { 167 var tx = db.transaction(STORE_META, 'readwrite'); 168 tx.objectStore(STORE_META).put({ key: key, value: value });
169 tx.oncomplete = function () { resolve(); }; 170 tx.onerror = function () { resolve(); }; 171 } catch (e) { 172 resolve(); 173 } 174 }); 175 } 176 177 /** 178 * Save all todos to IndexedDB. 179 * 180 * Diffed write: upserts every todo by id and explicitly deletes any 181 * existing id NOT in the incoming set. All inside one readwrite 182 * transaction, so the operation remains atomic. Replaces the older 183 * `clear() + put()` pattern, which created a tiny window of "store 184 * is empty" inside the transaction and meant a single accidental 185 * empty-array call could wipe everything. 186 * 187 * Optionally persists tabs via the meta store under key 'tabs'. 188 * Optionally persists history (undo stack) under key 'history'. 189 * Optionally persists the active tab id under key 'activeTabId' so the 190 * user's tab selection survives reload without needing localStorage. 191 * 192 * `opts.allowEmpty` (default false): when true, an empty `todos` 193 * array is accepted as an explicit wipe. Without it, an empty array 194 * against a non-empty IDB is refused â the in-memory state went to 195 * zero unexpectedly and we'd rather log a loud error than silently 196 * delete everyone's todos. 197 */ 198 async function saveAll(todos, timestamp, tabs, history, activeTabId, opts) { 199 // Serialize writes across this tab AND every peer tab via Web 200 // Locks. Without this, two tabs racing on `saveAll` can publish 201 // a stale `todoOrder` / `activeTabId` snapshot. The lock body is 202 // a single await chain so it releases promptly. 203 return _withLock(async function () { 204 return _saveAllLocked(todos, timestamp, tabs, history, activeTabId, opts); 205 }); 206 } 207 208 async function _saveAllLocked(todos, timestamp, tabs, history, activeTabId, opts) { 209 var db = await open(); 210 if (!db) return; 211 212 opts = opts || {}; 213 214 // Reset per-attempt so this flag reflects only THIS write. Without the 215 // reset a successful close-during-write retry would still see the first 216 // attempt's failure and raise a false "storage full" alarm. 217 _writeFailed = false; 218 219 // Guard against accidental full wipes. If the caller passes an 220 // empty array but IDB currently holds rows, refuse â only an 221 // explicit allowEmpty:true should ever trigger the destructive 222 // path (used by deliberate "delete everything" flows). 223 if ((!Array.isArray(todos) || todos.length === 0) && !opts.allowEmpty) { 224 try { 225 var existing = await loadAll(); 226 if (existing && existing.length > 0) { 227 console.error('TodoDB: refusing to saveAll with empty array â IDB has ' 228 + existing.length + ' row(s). Pass {allowEmpty:true} for explicit wipes.'); 229 return; 230 } 231 } catch (e) { 232 // If the pre-check itself errors out, bail rather than 233 // proceeding with a destructive write we can't justify. 234 console.error('TodoDB: saveAll empty-guard pre-check failed:', e); 235 return; 236 } 237 } 238 239 _writesInFlight++; 240 return new Promise(function (resolve) { 241 var done = function () { 242 _writesInFlight = Math.max(0, _writesInFlight - 1); 243 resolve(); 244 }; 245 try { 246 var tx = db.transaction([STORE_TODOS, STORE_META], 'readwrite'); 247 var store = tx.objectStore(STORE_TODOS); 248 249 // Read existing keys so we can compute deletes inside the 250 // same transaction. getAllKeys() is cheap (keys only). 251 var existingKeysReq = store.getAllKeys(); 252 253 existingKeysReq.onsuccess = function () { 254 var existingKeys = existingKeysReq.result || []; 255 var newIdSet = new Set(); 256 257 for (var i = 0; i < todos.length; i++) { 258 var todo = todos[i]; 259 if (!todo || todo.id === undefined || todo.id === null) continue; 260 var obj = { 261 id: todo.id, 262 parentId: todo.parentId, 263 text: todo.text 264 }; 265 if (todo.isCompleted === true) obj.isCompleted = true; 266 if (todo.category) obj.category = todo.category; 267 if (typeof todo.estimate === 'number') obj.estimate = todo.estimate; 268 if (todo.timestamp) obj.timestamp = todo.timestamp; 269 if (todo.tabId) obj.tabId = todo.tabId; 270 if (todo.deadline) obj.deadline = todo.deadline;
271 if (typeof todo.elapsed === 'number' && todo.elapsed > 0) obj.elapsed = todo.elapsed; 272 store.put(obj); 273 // Track id for the delete-diff below. IDB compares 274 // keys structurally; coerce to string so a 275 // numeric LS id and string IDB key match cleanly. 276 newIdSet.add(String(todo.id)); 277 } 278 279 // Delete every existing key NOT in the new set. 280 for (var k = 0; k < existingKeys.length; k++) { 281 if (!newIdSet.has(String(existingKeys[k]))) { 282 store.delete(existingKeys[k]); 283 } 284 } 285 }; 286 287 existingKeysReq.onerror = function () { 288 // If we can't read keys we can't safely diff. Don't 289 // fall back to clear+put â abort the transaction so 290 // nothing is mutated. 291 console.error('TodoDB: saveAll could not read existing keys; aborting'); 292 try { tx.abort(); } catch (e) {} 293 }; 294 295 // Also save the timestamp in meta 296 if (timestamp) { 297 tx.objectStore(STORE_META).put({ key: 'timestamp', value: timestamp }); 298 } 299 300 if (Array.isArray(tabs)) { 301 tx.objectStore(STORE_META).put({ key: 'tabs', value: tabs }); 302 } 303 304 if (Array.isArray(history)) { 305 tx.objectStore(STORE_META).put({ key: 'history', value: history }); 306 } 307 308 // activeTabId: persist the currently-selected tab atomically 309 // alongside the rest of the snapshot. Passing `null` is an 310 // explicit "no active tab"; `undefined` means "caller didn't 311 // care, leave whatever's there alone". 312 if (activeTabId !== undefined) { 313 tx.objectStore(STORE_META).put({ key: 'activeTabId', value: activeTabId }); 314 } 315 316 // Canonical display order. `store.getAll()` returns records 317 // keyed by `id` (UUID â alphabetical, effectively random), 318 // which would scramble todo order on every load. Capture 319 // the caller's array order here so the load path can 320 // restore it deterministically. 321 if (Array.isArray(todos)) { 322 var orderIds = []; 323 for (var oi = 0; oi < todos.length; oi++) { 324 var ot = todos[oi]; 325 if (ot && ot.id !== undefined && ot.id !== null) { 326 orderIds.push(String(ot.id)); 327 } 328 } 329 tx.objectStore(STORE_META).put({ key: 'todoOrder', value: orderIds }); 330 } 331 332 // Sync v2: the server revision these todos correspond to, written 333 // ATOMICALLY with them (same transaction). This is what lets the v2 334 // boot pairing check (§6.5) detect a torn write â a syncRev tracked 335 // separately from the todos could never catch a lost todos write. 336 // v1 callers never pass syncRev, so this is a no-op for them. 337 if (opts && typeof opts.syncRev === 'number') { 338 tx.objectStore(STORE_META).put({ key: 'syncRev', value: opts.syncRev }); 339 } 340 341 tx.oncomplete = function () { done(); }; 342 tx.onerror = function (event) { 343 console.error('TodoDB: saveAll error:', event.target.error); 344 _writeFailed = true; // atomic rollback -> nothing persisted 345 done(); 346 }; 347 tx.onabort = function () { 348 // A put that fails (most often quota exceeded) aborts the 349 // whole transaction and rolls back every change, so the 350 // just-made edit did NOT persist. Flag it so the caller can 351 // warn instead of the change silently vanishing on reload. 352 _writeFailed = true; 353 done(); 354 }; 355 } catch (e) { 356 console.error('TodoDB: saveAll exception:', e); 357 _writeFailed = true; 358 done(); 359 } 360 }); 361 } 362 363 /** 364 * Return true exactly once if a write was in flight when the DB 365 * connection unexpectedly closed (e.g. another tab triggered a 366 * version change). Callers can use this to warn the user that 367 * their last change may not have persisted. 368 */ 369 function consumeCloseDuringWrite() { 370 if (_closeDuringWrite) { 371 _closeDuringWrite = false; 372 return true; 373 } 374 return false; 375 } 376 377 /** 378 * Return true exactly once if the most recent write transaction aborted or 379 * errored (e.g. quota exceeded) so nothing was persisted. saveAll always 380 * resolves; callers consume this to surface an error instead of letting the 381 * change vanish silently on the next reload. 382 */ 383 function consumeWriteFailed() { 384 if (_writeFailed) { 385 _writeFailed = false; 386 return true; 387 } 388 return false; 389 } 390 391 /** 392 * Clear every todo and every meta entry from IndexedDB. Was only 393 * deleting the `timestamp` meta key, which left stale `tabs` /
394 * `history` / `migratedFromLocalStorage` entries behind after a 395 * factory-reset-style wipe. `clear()` on the meta store drops them 396 * all; it's also future-proof if we add more meta keys later. 397 */ 398 async function clear() { 399 var db = await open(); 400 if (!db) return; 401 402 return new Promise(function (resolve) { 403 try { 404 var tx = db.transaction([STORE_TODOS, STORE_META], 'readwrite'); 405 tx.objectStore(STORE_TODOS).clear(); 406 tx.objectStore(STORE_META).clear(); 407 tx.oncomplete = function () { resolve(); }; 408 tx.onerror = function () { resolve(); }; 409 } catch (e) { 410 resolve(); 411 } 412 }); 413 } 414 415 /** 416 * Check if IndexedDB is available and open. 417 */ 418 function isAvailable() { 419 return _db !== null; 420 } 421 422 // Public API 423 return { 424 open: open, 425 loadAll: loadAll, 426 saveAll: saveAll, 427 getMeta: getMeta, 428 setMeta: setMeta, 429 clear: clear, 430 isAvailable: isAvailable, 431 consumeCloseDuringWrite: consumeCloseDuringWrite, 432 consumeWriteFailed: consumeWriteFailed 433 }; 434})(); 435 436// Export for browser 437if (typeof window !== 'undefined') { 438 window.TodoDB = TodoDB; 439}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.