diff --git a/nullboard.html b/nullboard.html index 0e1aca1..49cda88 100644 --- a/nullboard.html +++ b/nullboard.html @@ -751,6 +751,31 @@ color: #d20; } + /*** ValidBoard: save state, next to the (updated) alert */ + .logo .sync { + display: none; + font-style: normal; + margin-left: 5px; + } + + .logo.sync-busy .sync, + .logo.sync-err .sync { + display: inline-block; + } + + .logo.sync-busy .sync { + color: #999; + } + + /* An unsaved board is worth breaking the logo's usual fade-out for. */ + .logo.sync-err { + opacity: 1; + } + + .logo.sync-err .sync { + color: #d20; + } + /***/ .config { position: absolute; @@ -1547,17 +1572,28 @@ + + + + +
@@ -2444,6 +2480,368 @@ } } + /* + * ValidBoard: boards live in SQLite on the server. + * + * The Storage contract above is synchronous — setItem() has to say yes or + * no there and then — and a round trip can't answer that fast. So this + * keeps the whole keyspace in memory and treats the server as somewhere to + * push it: reads are memory reads, writes are optimistic and batched. + * + * The initial copy is embedded in the page by the server (see #nb-bootstrap + * in the body), so it is already here by the time openInner() runs and no + * blocking request is needed at load. + * + * Keys are the same names Storage_Local uses, minus its 'nullboard.' + * prefix — the server's table is ours alone and needs no namespacing. + */ + class Storage_Server extends Storage + { + constructor() + { + super(); + + this.type = 'Server'; + this.items = new Map(); // key -> value; the copy the app reads + + this.pending = new Map(); // key -> op, queued for the next batch + this.inflight = null; // the batch currently being POSTed + this.timer = null; + this.failures = 0; + this.status = 'ok'; // 'ok' | 'busy' | 'error' + this.warned = false; // "session expired" alert is once-only + } + + /* + * Storage interface + */ + getItem(name) + { + var val = this.items.get(name); + return (val === undefined) ? null : val; + } + + setItem(name, val) + { + this.items.set(name, val); + this.enqueue(name, { op: 'set', key: name, value: val }); + return true; + } + + delItem(name) + { + this.items.delete(name); + this.enqueue(name, { op: 'del', key: name }); + return true; + } + + openInner() + { + if (! this.hydrate()) + return false; + + // Decide this before saving a default config, or the write below + // would make every new install look like an existing one. + var newInstall = (this.items.size == 0); + var conf = this.getJson('config'); + + if (conf) + { + this.conf = Object.assign(new AppConfig(), conf); + } + else if (! this.setJson('config', this.conf)) + { + this.conf = null; + return false; + } + + this.boardIndex = new Map(); + + // Array.from() because rebuildMeta() may write while we iterate. + for (var k of Array.from(this.items.keys())) + { + var m = k.match(/^board\.(\d+)\.meta$/); + + if (! m) + continue; + + var board_id = parseInt(m[1]); + var meta = this.getJson('board.' + board_id + '.meta'); + + if (! meta || ! meta.hasOwnProperty('history')) + { + console.log( `Invalid meta for board ${board_id}` ); + continue; + } + + for (var rev of meta.history) + if (! this.getJson('board.' + board_id + '.' + rev)) + { + console.log( `Invalid revision ${rev} in history of ${board_id}` ); + meta = this.rebuildMeta(board_id); + break; + } + + if (! meta) + continue; + + delete meta.backingUp; // run-time var + delete meta.needsBackup; // ditto + + this.boardIndex.set(board_id, Object.assign(new BoardMeta(), meta)); + } + + this.fixupConfig(newInstall); + + this.type = 'Server'; + + return true; + } + + wipeInner() + { + this.items.clear(); + this.pending.clear(); + this.inflight = null; + + var self = this; + + fetch('/api/items', { method: 'DELETE', credentials: 'same-origin' }) + .then(function(rsp){ if (! rsp.ok) throw new Error('server said ' + rsp.status); }) + .catch(function(err){ self.setStatus('error', 'wipe failed: ' + err.message); }); + + this.conf = new AppConfig(); + this.boardIndex = new Map(); + } + + /* + * private + */ + hydrate() + { + var el = document.getElementById('nb-bootstrap'); + + if (! el) + { + alert("ValidBoard: this page is missing its board data.\n\nReload to try again."); + return false; + } + + try + { + var data = JSON.parse(el.textContent); + + for (var k in data) + this.items.set(k, data[k]); + } + catch (x) + { + alert("ValidBoard: couldn't read the boards sent by the server.\n\n" + x); + return false; + } + + console.log( `Loaded ${this.items.size} key(s) from the server` ); + + // Nothing else needs it, and leaving a copy of every board sitting + // in the DOM only invites confusion when debugging. + el.textContent = '{}'; + + return true; + } + + // Same recovery as Storage_Local: a board whose meta has gone stale is + // worth rebuilding from whatever revisions did survive. + rebuildMeta(board_id) + { + var meta = new BoardMeta(); + + console.log( `Rebuilding meta for ${board_id} ...` ); + + meta.current = this.getItem('board.' + board_id); // may be null + + var re = new RegExp('^board\\.' + board_id + '\\.(\\d+)$'); + var revs = new Array(); + + for (var k of this.items.keys()) + { + var m = k.match(re); + if (m) revs.push( parseInt(m[1]) ); + } + + if (! revs.length) + { + console.log('* No revisions found'); + this.delItem('board.' + board_id); + return false; + } + + revs.sort(function(a,b){ return b-a; }); + meta.history = revs; + + if (! meta.history.includes(meta.current)) + meta.current = meta.history[meta.history.length-1]; + + var board = this.getJson('board.' + board_id + '.' + meta.current) + meta.title = (board.title || '(untitled board)'); + + this.setJson('board.' + board_id + '.meta', meta); + + return meta; + } + + /* + * write-behind + * + * Changes are collected by key — a note edited five times in a second + * is one write — and flushed as a single transaction shortly after. + */ + enqueue(key, op) + { + this.pending.set(key, op); + this.scheduleFlush(); + } + + // No argument: batch up whatever lands in the next FLUSH_DELAY ms, but + // don't let a steady stream of edits push the flush back forever. + // With one: a retry, which overrides any sooner flush already queued. + scheduleFlush(delay) + { + if (delay === undefined) + { + if (this.timer !== null) + return; + + delay = Storage_Server.FLUSH_DELAY; + } + else if (this.timer !== null) + { + clearTimeout(this.timer); + } + + var self = this; + + this.timer = setTimeout(function(){ + self.timer = null; + self.flush(); + }, delay); + } + + flush() + { + if (this.inflight || ! this.pending.size) + return; + + this.inflight = this.pending; + this.pending = new Map(); + + var self = this; + var ops = Array.from(this.inflight.values()); + + this.setStatus('busy'); + + fetch('/api/items', { + method : 'POST', + headers : { 'Content-Type': 'application/json' }, + credentials : 'same-origin', + body : JSON.stringify({ ops: ops }) + }) + .then(function(rsp){ + if (rsp.status == 401) + { + self.onSessionLost(); + throw new Error('not signed in'); + } + + if (! rsp.ok) + throw new Error('server said ' + rsp.status); + + self.inflight = null; + self.failures = 0; + + if (self.pending.size) + { + self.setStatus('busy'); + self.scheduleFlush(); + } + else + { + self.setStatus('ok'); + } + }) + .catch(function(err){ + // The batch goes back on the queue, so nothing is lost while + // the server is unreachable — it just keeps trying. + self.requeue(); + self.failures++; + self.setStatus('error', err.message); + + var delay = Math.min(30000, 1000 * Math.pow(2, self.failures - 1)); + + console.log( `Save failed (${err.message}), retrying in ${delay}ms` ); + self.scheduleFlush(delay); + }); + } + + requeue() + { + if (! this.inflight) + return; + + var merged = this.inflight; + + // Anything queued while the batch was in flight is newer, so it + // wins over the copy going back on the queue. + this.pending.forEach(function(op, key){ merged.set(key, op); }); + + this.pending = merged; + this.inflight = null; + } + + // The cookie expired, or you signed out in another tab. Keep retrying + // rather than dropping the edits — signing back in anywhere makes the + // next attempt succeed. + onSessionLost() + { + if (this.warned) + return; + + this.warned = true; + + alert("ValidBoard: your session has expired, so recent changes aren't saved yet.\n\n" + + "Sign in again in another tab and they'll be saved automatically."); + } + + hasUnsavedChanges() + { + return (this.pending.size > 0) || (this.inflight !== null); + } + + setStatus(status, detail) + { + this.status = status; + + var $logo = $('.logo').removeClass('sync-busy sync-err'); + var $sync = $('.logo .sync').attr('title', detail || ''); + + if (status == 'busy') + { + $logo.addClass('sync-busy'); + $sync.text('saving...'); + } + else if (status == 'error') + { + $logo.addClass('sync-err'); + $sync.text('not saved'); + } + else + { + $sync.text(''); + } + } + } + + Storage_Server.FLUSH_DELAY = 300; // ms + /* * */ @@ -5112,7 +5510,7 @@ } }; - NB.storage = new Storage_Local(); + NB.storage = new Storage_Server(); if (! NB.storage.open()) { @@ -5120,6 +5518,28 @@ throw new Error(); } + /* + * ValidBoard: writes are asynchronous now, so a tab can be closed while a + * batch is still queued. Say so rather than losing it. + */ + window.addEventListener('beforeunload', function(e){ + if (! NB.storage.hasUnsavedChanges()) + return; + + e.preventDefault(); + e.returnValue = ''; + return ''; + }); + + $('.logo .sign-out').click(function(){ + if (NB.storage.hasUnsavedChanges() && + ! confirm("Some changes haven't reached the server yet.\n\nSign out anyway?")) + return false; + + $('.sign-out-form').submit(); + return false; + }); + var boards = NB.storage.getBoardIndex(); boards.forEach( function(meta, board_id) {