fix: web UI client review findings (step 2a)

- One socket at a time: a socket being replaced is let go without a word,
  and connect() or a new token while a reconnect waits opens no second one.
  A new token reconnects at once, without a refused.
- Events that came before the schema go as soon as it is whole, ahead of
  the sync frames formatted after them, not at syncEnd over newer state.
- A schema's hash counts only once it has come whole, so a sync cut short
  gets it again.
- Feeds are subscribed again after a reconnect; an unknown feed is refused.
- A long op refused as busy without its id goes again.
- A sync without repeaters clears them; order changes name their list; a
  refused resync is called off.
- refused: a listener can close() to stop the retries, which the UI does
  once /loginContext says a login is needed. The client itself can't tell a
  refused token from a device that is down.
- localStorage keeps the schema by default. minify.py removes its work
  folder even when a bundle fails. ADR 0005 records the two bundles.
- Smaller: ProtoSchema.ROWS/withoutRows, ProtoModel.ITEMS, named type
  numbers, unused feeds map and FakeSocket.all gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-25 18:04:07 +01:00
parent 99c6019431
commit 4415beb4f7
8 changed files with 382 additions and 147 deletions

View file

@ -17,11 +17,11 @@ function recordedSchema() {
}
// The recorded sync's device sections, expanded, frame by frame.
function deviceSections(schema) {
return sync.filter((f) => f.t === 4).map((f) => {
const d = {};
for (const k of Object.keys(f.d)) if (!['T', 'F', 'E', 'Q'].includes(k)) d[k] = f.d[k];
return schema.expand(d);
});
return sync.filter((f) => f.t === 4).map((f) => schema.expand(ProtoSchema.withoutRows(f.d)));
}
// The recorded sync as the device sends it to a client that has the schema.
function syncWithoutSchema() {
return sync.map((f) => Object.assign({}, f, { d: ProtoSchema.withoutRows(f.d || {}) }));
}
// The recorded sync's items under their short keys.
function recordedItems(key) {
@ -36,7 +36,7 @@ class FakeSocket {
this.sent = [];
this.closed = false;
FakeSocket.last = this;
FakeSocket.all.push(this);
FakeSocket.count++;
}
send(text) { this.sent.push(JSON.parse(text)); }
close() {
@ -49,8 +49,9 @@ class FakeSocket {
// The device closing it, or refusing it before it opened.
drop() { this.close(); }
}
FakeSocket.all = [];
FakeSocket.reset = () => { FakeSocket.all = []; FakeSocket.last = null; };
// How many the client has opened.
FakeSocket.count = 0;
FakeSocket.reset = () => { FakeSocket.count = 0; FakeSocket.last = null; };
// Timers the test runs by hand.
class FakeTimers {
@ -64,9 +65,9 @@ class FakeTimers {
};
this.clearTimeout = (id) => { this.timers.delete(id); };
}
// Runs every timer waiting, and returns their delays.
// Runs every timer waiting, the shortest first, and returns their delays.
runAll() {
const due = [...this.timers.entries()];
const due = [...this.timers.entries()].sort((a, b) => a[1].ms - b[1].ms);
this.timers.clear();
for (const [, t] of due) t.fn();
return due.map(([, t]) => t.ms);
@ -79,4 +80,5 @@ class FakeStorage {
setItem(key, value) { this.items[key] = String(value); }
}
module.exports = { hello, sync, schemaJson, recordedSchema, deviceSections, recordedItems, FakeSocket, FakeTimers, FakeStorage };
module.exports = { hello, sync, schemaJson, recordedSchema, deviceSections, syncWithoutSchema, recordedItems, FakeSocket,
FakeTimers, FakeStorage };