Store boards on the server rather than in localStorage
Adds Storage_Server next to upstream's Storage_Local and swaps which one
is instantiated; the rest of nullboard.html is untouched so upstream
changes still merge.
The Storage contract is synchronous — setItem has to return true or false
there and then — which no round trip can satisfy. So the whole keyspace
is held in memory and the server is somewhere to push it to:
- The initial copy is embedded in the page by the server, already
parsed and in hand before the app boots. In the page rather than a
.js file so another site can't <script src> it and read the boards.
- Writes are collected by key, so a note edited repeatedly is one
write, and flushed shortly after as a single transaction. A failed
batch goes back on the queue and retries with a backoff rather than
being dropped.
- The logo shows saving/not saved, and closing the tab with writes
still queued asks first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UU1vyTHj3uE9PJYSxRxwkU
This commit is contained in:
co-authored by
Claude Opus 5
parent
2ae11e8d1e
commit
7e2f280acc
+424
-4
@@ -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 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="logo">
|
||||
<a class=site href=https://nullboard.io>Nullboard</a>
|
||||
<a class=site href=/>ValidBoard</a>
|
||||
<i class=alert></i>
|
||||
<i class=sync></i>
|
||||
<div class=bulk>
|
||||
<a href=# class=view-about>About</a>
|
||||
<a href=# class=view-license>License</a>
|
||||
<a href=https://nullboard.io/changes target=_blank class=view-changes>Changes</a>
|
||||
<a href=https://nullboard.io/github target=_blank>Github</a>
|
||||
<a href=https://nullboard.io/twitter target=_blank>Twitter</a>
|
||||
<a href=https://nullboard.io target=_blank>Nullboard</a>
|
||||
<a href=# class=sign-out>Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
ValidBoard: the server drops the whole keyspace in here before the app
|
||||
script runs, so Storage_Server can answer nullboard's synchronous
|
||||
getItem() from memory without a blocking request. Embedded in the page
|
||||
rather than served as a .js file so it can't be read cross-site.
|
||||
-->
|
||||
<script type="application/json" id="nb-bootstrap">{}</script>
|
||||
|
||||
<form class=sign-out-form method=post action=/logout hidden></form>
|
||||
|
||||
<div class='config no-user-select'>
|
||||
<a href=# class=teaser><i>≡</i><u>✔</u></a>
|
||||
<div class=bulk>
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user