// Local view ordering and intent binding. Contexts are not credentials. function sameContext(left, right) { return ['instance', 'connection', 'configuration', 'draft'].every(key => left?.[key] === right?.[key]); } class ViewSession { constructor({read, send, render, error, permissions, edited = () => {}}) { Object.assign(this, {read, send, render, error, permissions, edited}); this.view = null; this.queue = []; this.ticket = 0; this.acceptedTicket = 0; this.retired = new Set(); this.message = ''; } get busy() { return this.queue.length !== 0; } get onlyEdits() { return this.queue.every(entry => entry.path === '/api/config/edit'); } editing(id) { return this.queue.some(entry => entry.body.field_id === id && entry.context.instance === this.view?.state.context.instance && entry.context.connection === this.view?.state.context.connection && entry.context.configuration === this.view?.state.context.configuration); } accept(view, ticket) { const current = this.view?.state; const next = view.state; if (current) { if (current.context.instance === next.context.instance) { if (BigInt(next.config_revision) < BigInt(current.config_revision)) return false; } else { if (ticket < this.acceptedTicket || this.retired.has(next.context.instance)) return false; this.retired.add(current.context.instance); } } this.acceptedTicket = Math.max(ticket, this.acceptedTicket); this.view = view; this.render(view); if (this.message) this.error(this.message); return true; } async refresh() { const ticket = ++this.ticket; const view = await this.read(); return this.accept(view, ticket); } mutate(path, body = {}, context = this.view?.state.context) { if (!this.view) return Promise.resolve(null); return new Promise(resolve => { this.queue.push({path, body, context, resolve}); this.permissions(); if (this.queue.length === 1) this.drain(); }); } async drain() { while (this.queue.length) { const entry = this.queue[0]; const ticket = ++this.ticket; let result = null; try { if (!sameContext(entry.context, this.view.state.context)) { const error = Error('View changed; queued action discarded. Review the current configuration.'); error.staleIntent = true; throw error; } const view = await this.send(entry.path, {...entry.body, context: entry.context}); if (this.accept(view, ticket)) { this.message = ''; result = view; // Only our own accepted edits can advance already queued edits. // A poll, another editor, a reset, or a new connection cannot rebase intent. if (entry.path === '/api/config/edit' && view.state.context.instance === entry.context.instance && view.state.context.connection === entry.context.connection && view.state.context.configuration === entry.context.configuration) { for (const queued of this.queue.slice(1)) { if (sameContext(queued.context, entry.context)) queued.context = view.state.context; } this.edited(entry.context, view.state.context); } } } catch (error) { const relevant = error.view ? this.accept(error.view, ticket) : error.staleIntent || sameContext(entry.context, this.view.state.context); if (relevant) this.message = error.message; } finally { this.queue.shift(); this.render(this.view); if (this.message) this.error(this.message); this.permissions(); entry.resolve(result); } } } } if (typeof module !== 'undefined') module.exports = {ViewSession, sameContext};