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 | })(); |