PageSourceSearch

https://goblin.tools/js/todo-db.js?v=T01i_B2aL-m4Cq-umQ-xBRlNdBkcr4HorIO8ifcMvq4

js goblin.tools collected 2026-10-01 11:37:00 UTC 18,155 bytes, 439 lines download raw bytes

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.