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:
Tom Flux
2026-08-14 22:41:01 +01:00
co-authored by Claude Opus 5
parent 2ae11e8d1e
commit 7e2f280acc
+424 -4
View File
@@ -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>&equiv;</i><u>&#x2714;</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) {