Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/pause.js

18.4 KiB, 1 run

created by r2519314175:1411, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* ============================================================
2 Daimond — the pause tree (DaimondPause)
3 ------------------------------------------------------------
4 One control, six placements, and a rule that makes the top of
5 the rail honest.
6
7 Notes2 asks for a pause/play/traffic-light on the rail, on each
8 mailbox, on each mail folder, on each Diamond, on each
9 triggered action, and globally. Six placements of one control.
10 What had to be settled was not how it looks but what AMBER
11 means, because a colour that can be set by hand means nothing:
12
13 A leaf is binary: playing or paused. A branch shows green
14 when every leaf under it plays, red when none does, and
15 amber otherwise. Amber is DERIVED and can never be set.
16 Clicking a branch pauses all its leaves, or resumes them.
17
18 That is why the global control is not a seventh setting — it is
19 the root of the same tree. It also means only leaves hold
20 state, so this module stores a set of paused leaf ids and
21 nothing else. Everything a branch shows is computed.
22
23 Two consequences worth stating, because both are deliberate:
24
25 - A leaf that appears later PLAYS. Pause every Diamond, make
26 a new one, and the branch goes amber rather than the new
27 Diamond arriving paused. A branch has no state to inherit,
28 and inventing one would be the settable amber this rule
29 exists to forbid. Something that must start paused is
30 seeded paused when it is created.
31
32 - Pause is about SPENDING, not access. A paused Diamond still
33 opens, its crystal still renders, its files still list. The
34 control that greyed out the whole object would be a
35 different feature wearing the same icon.
36
37 Enforcement is not here and is not in the widget: it is at the
38 points where money is committed — the key mint, the governor's
39 dispatch gate, and the gateway calls that spend. A pause the UI
40 respects and the network does not is decoration.
41
42 The decision logic here is pure and separately testable; the
43 widget and its DOM live in daimond.js, which owns them, exactly
44 as the governor is split. This module holds state and answers
45 questions.
46
47 Attaches a single global, `window.DaimondPause`. Also exported
48 for Node, so the pure core can be unit-tested without a
49 browser.
50 ============================================================ */
51(function () {
52 'use strict';
53
54 // Per-account; accounts.js namespaces every `daimond-*` key.
55 var STORE_KEY = 'daimond-pause';
56
57 // ── Node ids ───────────────────────────────────────────────
58 // Slash-delimited paths, so an ancestor is a string prefix and
59 // the tree can be walked without the tree being present. The
60 // shapes in use, all built by `DaimondPause.id`:
61 //
62 // root
63 // root/diamonds
64 // root/diamonds/<diamondId> branch, when it has triggers
65 // root/diamonds/<diamondId>/self leaf: the daimon's own turns
66 // root/diamonds/<diamondId>/triggers/<n> leaf: one triggered action
67 // root/chats/<chatId> leaf
68 // root/mail/<accountId> branch
69 // root/mail/<accountId>/<folder> leaf
70 // root/workers leaf: the global worker pump
71 // root/web leaf: fetching a page through the gateway
72 //
73 // `root/web` is not one of notes2's six placements. It is here because a web
74 // fetch spends and had nowhere to be charged: without it the enforcement had
75 // to fall back to the global control, which means a page fetch was held only
76 // when EVERYTHING was — and on a new account, whose tree has no leaves at all,
77 // the global control read green, so it was never held. A spend with no node
78 // is a spend with no pause. (That account's control reads RED now, since the
79 // light counts armed leaves; the argument for the node is unchanged, because
80 // it was never about the colour.)
81 //
82 // A node that both spends and has children is modelled as a
83 // branch with a `self` leaf, so the "only leaves hold state"
84 // rule never needs an exception.
85
86 var ROOT = 'root';
87
88 /// Build a node id from parts, escaping any slash a name carries.
89 /// A Diamond id or a mail folder is user- or server-named, and one
90 /// containing a slash would otherwise invent a level in the tree.
91 function id() {
92 var parts = [];
93 for (var i = 0; i < arguments.length; i++) {
94 var s = arguments[i];
95 if (s == null || s === '') continue;
96 parts.push(String(s).replace(/%/g, '%25').replace(/\//g, '%2F'));
97 }
98 return parts.join('/');
99 }
100
101 // ── Pure core ──────────────────────────────────────────────
102 // No DOM, no storage, no clock. A tree is `{ id, kind, label,
103 // children }`; a node with no `children` array is a leaf.
104
105 /// Every leaf id at or under `node`. A leaf returns itself.
106 ///
107 /// A leaf is a node with **no `children` array at all**, not one whose array
108 /// is empty. An empty branch — a mailbox whose folders have not loaded, a
109 /// Diamonds section on a new account — is still a branch: treating it as a
110 /// leaf gave it a pause flag of its own, so pausing the root wrote a phantom
111 /// id that nothing would ever resume, and the empty-branch rule in `stateOf`
112 /// could never fire.
113 function leavesUnder(node) {
114 if (!node) return [];
115 if (!node.children) return [node.id];
116 var out = [];
117 for (var i = 0; i < node.children.length; i++) {
118 out = out.concat(leavesUnder(node.children[i]));
119 }
120 return out;
121 }
122
123 /// Find a node by id within a tree, or null.
124 function findNode(tree, wanted) {
125 if (!tree) return null;
126 if (tree.id === wanted) return tree;
127 for (var i = 0; tree.children && i < tree.children.length; i++) {
128 var hit = findNode(tree.children[i], wanted);
129 if (hit) return hit;
130 }
131 return null;
132 }
133
134 /// Every leaf id at or under `node` that is ARMED -- that is, that has
135 /// something set up to spend WITHOUT ANYBODY ASKING.
136 ///
137 /// ── WHAT THE LIGHT IS ABOUT, WHICH CHANGED ─────────────────
138 ///
139 /// It used to be about whether anything had been PAUSED, so a node nobody had
140 /// touched read green and green was taken to mean "running". The owner read
141 /// the Email panel exactly that way and said so: it "shows green when all
142 /// mailboxes are updated manually", which is to say green while no automation
143 /// existed at all, and "in the default case, the light should show red, since
144 /// there is no automation running".
145 ///
146 /// He is right, and the old reading has a second fault the first hides. A
147 /// triggered action turned OFF is not paused, so its leaf read green and its
148 /// light said running -- while `DaimondTriggers.ready` refused to fire it. The
149 /// light reported a surface flag; the thing that decides is `allowed()`, which
150 /// is `ready(t) && !paused(leaf)`. Two of the three colours were being drawn
151 /// from half of that expression.
152 ///
153 /// So a leaf now counts towards the light only when it is armed, and the light
154 /// says the whole of `allowed()`: red where nothing under here can go off on
155 /// its own, green where everything that can, will, amber in between.
156 ///
157 /// `armed` is a field on the tree node and its ABSENCE MEANS ARMED. A leaf
158 /// added later without thinking about this behaves exactly as it did before
159 /// rather than silently dropping out of every light above it.
160 function armedUnder(node) {
161 if (!node) return [];
162 if (!node.children) return (node.armed === false) ? [] : [node.id];
163 var out = [];
164 for (var i = 0; i < node.children.length; i++) {
165 out = out.concat(armedUnder(node.children[i]));
166 }
167 return out;
168 }
169
170 /// The four states, derived. `paused` is a set-like object whose
171 /// own keys are the paused leaf ids.
172 ///
173 /// idle nothing under here runs on its own. RED.
174 /// pause everything that could is held. RED.
175 /// mixed some are held. AMBER, and only ever arrived at.
176 /// play everything that could, will. GREEN.
177 ///
178 /// `idle` and `pause` are both red and are not the same fact, which is why
179 /// they are not one value: "there is no automation here" and "the automation
180 /// here is stopped" are different things to say to somebody, and the widget
181 /// says them differently. They offer the same two buttons.
182 ///
183 /// A branch with no ARMED leaf under it is `idle`, and that replaces the old
184 /// rule that made it green. The argument for green was that calling an empty
185 /// mailbox red "would make the global control red for a new account that has
186 /// done nothing wrong" -- but red here is not an accusation and never was. It
187 /// is the answer to "is anything running by itself?", and on a new account the
188 /// honest answer is no.
189 function stateOf(node, paused) {
190 var leaves = armedUnder(node);
191 if (!leaves.length) return 'idle';
192 var n = 0;
193 for (var i = 0; i < leaves.length; i++) {
194 if (paused && paused[leaves[i]]) n++;
195 }
196 if (n === 0) return 'play';
197 if (n === leaves.length) return 'pause';
198 return 'mixed'; // amber, and only ever arrived at
199 }
200
201 /// The set that results from setting `node` to playing or paused.
202 /// Returns a NEW object; the caller decides whether it changed.
203 function applySet(node, paused, playing) {
204 var next = {};
205 for (var k in paused) if (paused[k]) next[k] = true;
206 var leaves = leavesUnder(node);
207 for (var i = 0; i < leaves.length; i++) {
208 if (playing) delete next[leaves[i]];
209 else next[leaves[i]] = true;
210 }
211 return next;
212 }
213
214 /// What clicking a node does. A branch showing amber resumes —
215 /// the alternative is a click that pauses the leaves already
216 /// playing, which reads as the control fighting the user.
217 ///
218 /// `idle` resumes too, and that is deliberate rather than incidental: a node
219 /// with nothing armed may still hold leaves somebody paused before they turned
220 /// the automation off, and play is the way to let those go. It is the only
221 /// press on this control that can look like it did nothing, which is why the
222 /// state word says "nothing set up" rather than "paused".
223 function clickWould(node, paused) {
224 return stateOf(node, paused) === 'play' ? 'pause' : 'play';
225 }
226
227 /// The stored form: a SORTED array and a stamp that moves only
228 /// when the set does.
229 ///
230 /// Sorted because the sync parcel has to be a fixed point — a set
231 /// serialised in hash order differs between two collects, the
232 /// device then always has news, and two devices push at each other
233 /// for ever. That has happened here twice; see
234 /// `dev/verify_parcelstable.mjs`.
235 function toRecord(paused, stamp) {
236 var out = [];
237 for (var k in paused) if (paused[k]) out.push(k);
238 out.sort();
239 return { paused: out, stamp: stamp || 0 };
240 }
241
242 /// The set from a stored record, tolerating anything.
243 function fromRecord(rec) {
244 var set = {};
245 var list = (rec && rec.paused) || [];
246 for (var i = 0; i < list.length; i++) {
247 if (typeof list[i] === 'string' && list[i]) set[list[i]] = true;
248 }
249 return set;
250 }
251
252 /// Merge two records for the sync. The later stamp wins whole;
253 /// EQUAL stamps take the union, which errs towards paused.
254 ///
255 /// Union at an equal stamp is the lesson of the tag-loss incident:
256 /// two devices that changed within the same millisecond otherwise
257 /// silently discard one side's change. Erring towards paused is
258 /// the safe direction — the cost of a wrong pause is a click, the
259 /// cost of a wrong resume is money.
260 function mergeRecords(a, b) {
261 var sa = (a && a.stamp) || 0;
262 var sb = (b && b.stamp) || 0;
263 if (sa > sb) return toRecord(fromRecord(a), sa);
264 if (sb > sa) return toRecord(fromRecord(b), sb);
265 var set = fromRecord(a);
266 var other = fromRecord(b);
267 for (var k in other) set[k] = true;
268 return toRecord(set, sa);
269 }
270
271 // ── Stateful shell ─────────────────────────────────────────
272 // Storage, the clock, the live tree and the subscribers. None of
273 // this runs under Node; the export at the foot hands out the pure
274 // core only.
275
276 var _paused = null; // lazily loaded set
277 var _stamp = 0;
278 var _tree = null; // a function returning the live tree
279 var _subs = [];
280
281 function now() {
282 return (typeof Date !== 'undefined') ? Date.now() : 0;
283 }
284
285 function load() {
286 if (_paused) return;
287 _paused = {};
288 _stamp = 0;
289 try {
290 var raw = localStorage.getItem(STORE_KEY);
291 if (raw) {
292 var rec = JSON.parse(raw);
293 _paused = fromRecord(rec);
294 _stamp = (rec && rec.stamp) || 0;
295 }
296 } catch (e) { /* storage blocked or corrupt: everything plays */ }
297 }
298
299 function save() {
300 try {
301 localStorage.setItem(STORE_KEY, JSON.stringify(toRecord(_paused, _stamp)));
302 } catch (e) { /* quota */ }
303 }
304
305 function announce() {
306 for (var i = 0; i < _subs.length; i++) {
307 try { _subs[i](); } catch (e) { /* a listener must not stop the others */ }
308 }
309 try {
310 if (typeof window !== 'undefined' && window.dispatchEvent) {
311 window.dispatchEvent(new CustomEvent('daimond:pause'));
312 }
313 } catch (e) { /* no CustomEvent in this context */ }
314 }
315
316 /// Register the function that returns the live tree. daimond.js
317 /// builds it from the Diamonds, chats, mailboxes and triggers that
318 /// exist at the moment it is asked.
319 function setTree(fn) { _tree = fn; }
320
321 function tree() {
322 try { return (typeof _tree === 'function') ? _tree() : null; } catch (e) { return null; }
323 }
324
325 /// Is this leaf paused? The whole answer for a leaf is its own
326 /// flag: branches hold no state, so there is no ancestor to
327 /// consult and no tree to walk. Enforcement calls this, and it
328 /// must stay cheap enough to sit in front of every spend.
329 function isPaused(nodeId) {
330 if (!nodeId) return false;
331 load();
332 return !!_paused[nodeId];
333 }
334
335 /// The state of any node, leaf or branch: 'play', 'pause' or
336 /// 'mixed'. Needs the tree, so an unknown id answers from its own
337 /// flag alone rather than pretending.
338 function state(nodeId) {
339 load();
340 var t = tree();
341 var node = t ? findNode(t, nodeId) : null;
342 if (!node) return _paused[nodeId] ? 'pause' : 'play';
343 return stateOf(node, _paused);
344 }
345
346 /// Set a node playing or paused, writing every leaf under it.
347 /// Returns true when something actually changed — the stamp moves
348 /// only then, which is what keeps the sync parcel a fixed point.
349 function set(nodeId, playing) {
350 load();
351 var t = tree();
352 var node = (t ? findNode(t, nodeId) : null) || { id: nodeId };
353 var next = applySet(node, _paused, playing);
354 var before = JSON.stringify(toRecord(_paused, 0));
355 var after = JSON.stringify(toRecord(next, 0));
356 if (before === after) return false;
357 _paused = next;
358 _stamp = now();
359 save();
360 announce();
361 return true;
362 }
363
364 /// Click a node: a branch that is wholly playing pauses, anything
365 /// else resumes.
366 function toggle(nodeId) {
367 load();
368 var t = tree();
369 var node = (t ? findNode(t, nodeId) : null) || { id: nodeId };
370 return set(nodeId, clickWould(node, _paused) === 'play');
371 }
372
373 /// Seed a leaf as paused at the moment it is created, without
374 /// touching anything else. Phase H's two default Diamonds start
375 /// paused this way, rather than by a branch that remembers.
376 function seedPaused(nodeId) {
377 if (!nodeId) return false;
378 load();
379 if (_paused[nodeId]) return false;
380 _paused[nodeId] = true;
381 _stamp = now();
382 save();
383 announce();
384 return true;
385 }
386
387 /// Forget a leaf entirely, for an object being deleted. A stale id
388 /// is harmless to `isPaused` but would keep a branch amber for
389 /// ever and would travel in the parcel for the life of the
390 /// account.
391 function forget(prefix) {
392 load();
393 var hit = false;
394 for (var k in _paused) {
395 if (k === prefix || k.indexOf(prefix + '/') === 0) { delete _paused[k]; hit = true; }
396 }
397 if (hit) { _stamp = now(); save(); announce(); }
398 return hit;
399 }
400
401 /// What travels in the sync parcel. Stable bytes for stable state.
402 function snapshot() {
403 load();
404 return toRecord(_paused, _stamp);
405 }
406
407 /// Take a record from the sync, merged against what is held here.
408 /// Returns true when the local state moved.
409 function adopt(rec) {
410 load();
411 var merged = mergeRecords(toRecord(_paused, _stamp), rec);
412 var before = JSON.stringify(toRecord(_paused, _stamp));
413 var after = JSON.stringify(merged);
414 if (before === after) return false;
415 _paused = fromRecord(merged);
416 _stamp = merged.stamp || 0;
417 save();
418 announce();
419 return true;
420 }
421
422 /// Called when a listener wants to know the tree has moved.
423 function subscribe(fn) {
424 if (typeof fn === 'function') _subs.push(fn);
425 }
426
427 /// Drop everything held, for an account switch: one account's
428 /// pauses must never colour another's.
429 function reset() { _paused = null; _stamp = 0; }
430
431 /// Every paused leaf, for a verifier or a diagnostic.
432 function pausedIds() { load(); return toRecord(_paused, _stamp).paused; }
433
434 /// A human name for a node, for a refusal that has to be readable.
435 ///
436 /// The tree carries a `label` on each node, but only the widget could see it,
437 /// so every refusal at the spend boundary said `root/diamonds/a1b2/self` —
438 /// which names the node exactly and tells the person nothing. Falls back to
439 /// the last meaningful segment of the id, unescaped, so a node the tree has
440 /// not heard of still reads as something rather than as a path.
441 function label(nodeId) {
442 if (!nodeId) return '';
443 var t = tree();
444 var node = t ? findNode(t, nodeId) : null;
445 if (node && node.label) return node.label;
446 var parts = String(nodeId).split('/');
447 // `…/self` is the object's own spending, so the name wanted is its parent's.
448 if (parts.length > 1 && parts[parts.length - 1] === 'self') parts.pop();
449 var last = parts[parts.length - 1] || nodeId;
450 return decodeURIComponent(last.replace(/%2F/g, '/'));
451 }
452
453 var api = {
454 // Live API.
455 id: id,
456 ROOT: ROOT,
457 setTree: setTree,
458 isPaused: isPaused,
459 state: state,
460 set: set,
461 toggle: toggle,
462 seedPaused: seedPaused,
463 forget: forget,
464 snapshot: snapshot,
465 adopt: adopt,
466 subscribe: subscribe,
467 reset: reset,
468 pausedIds: pausedIds,
469 label: label,
470 // Pure core, exposed for tests and for reuse.
471 _core: {
472 leavesUnder: leavesUnder,
473 armedUnder: armedUnder,
474 findNode: findNode,
475 stateOf: stateOf,
476 applySet: applySet,
477 clickWould: clickWould,
478 toRecord: toRecord,
479 fromRecord: fromRecord,
480 mergeRecords: mergeRecords,
481 consts: { STORE_KEY: STORE_KEY, ROOT: ROOT },
482 },
483 };
484
485 if (typeof window !== 'undefined') window.DaimondPause = api;
486 if (typeof module !== 'undefined' && module.exports) module.exports = api;
487})();