diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 12:21:04 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 12:21:04 +0200 |
| commit | 8889e90b8f1629f983c7ed4e44a2badbda525095 (patch) | |
| tree | 9e5b32d9eed7338188be3ddf7dffb16e9e15b75e /docs/playlists.md | |
| parent | 9cbff21274604e37c0986d57937deef85819c396 (diff) | |
| download | meshbay-8889e90b8f1629f983c7ed4e44a2badbda525095.tar.gz | |
docs: playlists, corrected against what building it needed
Fourteen places the proposal was wrong, listed rather than edited away:
the MNP bump was 2.1 and is 3.1; the kind pattern refused the one
playlist every account has; confirm() is the house style and prompt()
is what throws; submenus expand in place; deflate is 4.5x and the
first fixture's 23x was a property of the fixture.
Adds the hazard the proposal missed entirely: two wrappers that take
two arguments and forward two, so every "add to queue" arrived as a
plain play.
The acceptance section now says what is checked and what is not —
no real daemon round trip, no two-browser convergence, no Firefox.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs/playlists.md')
| -rw-r--r-- | docs/playlists.md | 404 |
1 files changed, 289 insertions, 115 deletions
diff --git a/docs/playlists.md b/docs/playlists.md index ecbbac4..49b1585 100644 --- a/docs/playlists.md +++ b/docs/playlists.md @@ -1,7 +1,13 @@ # MeshBay — Playlists (design) -> Status: **proposal**, not implemented. This was deferred out of the Music -> application's design, which deferred it for the right reason: *"a genuinely +> Status: **built**, 2026-09-16, in five commits. This document was written as +> a proposal and has been corrected against what the code turned out to need — +> §0.2 lists every place the proposal was wrong, because a design document that +> quietly agrees with the implementation afterwards is a document nobody can +> use to check the implementation. +> +> It was deferred out of the Music application's design, which deferred it for +> the right reason: *"a genuinely > new category of per-account node state, not covered by anything E9 already > enumerates — needs its own design pass (ownership, sync across devices, > whether it's node-local or something else)"*. This document is that pass. @@ -53,6 +59,30 @@ fields that actually have to be there. --- +## 0.2 What building it corrected + +Fourteen things. They are listed rather than edited away because the ones that +were *wrong* are more useful than the ones that were right. + +| | The proposal said | Building it found | +|---|---|---| +| 1 | MNP MINOR bump to **2.1** | The wire version was already **3.0**; the bump is **3.1**, and `MNP_MIN_SUPPORTED` does not move because nothing here is required | +| 2 | `kind` is `playlist:<uuid>` | That refuses `playlist:favorites` — the one playlist every account has. The pattern is `[A-Za-z0-9_-]{1,64}` | +| 3 | The blob is **msgpack**, "same as everything else on MNP" | The node never parses it, so the encoding is a private choice; MNP's codec is private to `transport.js`, a classic script. **JSON**, which is what keeps the merge runnable by `node` with nothing around it | +| 4 | Delete confirms inline, "not `window.confirm()`, which blocks the SPA" | Backwards. `prompt()` **throws** in Electron and is banned by a test; `confirm()` is measured to work and is used in twenty places. A *name* needs a field; a *confirmation* uses `confirm` | +| 5 | Submenus fly out, and are a sheet on a coarse pointer | They **expand in place**, which the account menu's language list already does. No flipping, no hover intent, no separate mobile design. The tracklist level loads when it is expanded | +| 6 | Add all four new files to `STATIC_FILES` / `SPLIT_FILES` | Those lists check hook ordering. The four hookless modules do not belong in them, exactly as `source-merge.js` does not; only `menu.js` and `playlist-menu.js` do | +| 7 | Four modules | **Five**: `playlist-crypto.js` is split out so seal/open can be executed standalone. A crypto layer that cannot be executed is one nobody has checked | +| 8 | "Save the current queue" in the queue panel **and** the toolbar menu | The queue is the player's own state. Mirroring it into a menu in another component would mean lifting that state out of the player for a menu item, so it lives in the queue panel alone | +| 9 | — | **The `op` hazard**, missed entirely. `group-page.js` and `search-page.js` both wrap `onPlayQueue` with a two-argument signature and forwarded two, so every "add to queue" arrived at the player as a plain play. §9.6 | +| 10 | — | **`user_blob_list` is what makes sync quiet.** Comparing against the merged watermark says nothing about what *that node* holds, so the first version pushed every body on every sync. §7.1 | +| 11 | — | **A tombstoned playlist's body must be deleted from each node**, or the account's quota fills with graves. §7.2 | +| 12 | The IndexedDB version is bumped "in the existing `onupgradeneeded`" | And must stay there, in one file. Two modules opening one database at versions of their own is a `VersionError` thrown at whichever runs second. §14.2 | +| 13 | deflate is worth about three | **4.5×** on realistic data — and the first fixture said 23× because it repeated one content hash on every track, which is a property of the fixture. §4.2 | +| 14 | `body_rev` alongside `rev` | Right, and worth saying why: a rename and a track added elsewhere both write n+1 under one counter, and one of two non-overlapping edits is lost. §5.1 | + +--- + ## 1. What this design does not reopen - **Views over the index, never a catalogue** (`MESHBAY_DESIGN.md` §6.5, §9.1). @@ -75,16 +105,17 @@ fields that actually have to be there. --- -## 2. Four problems, and only two of them are hard +## 2. Five problems, and only two of them are hard | | Problem | Where it is solved | |---|---|---| | A | Somewhere durable to put a few hundred KB per account | §3 — several acceptable answers, one clearly best | | B | How to **name** a track that lives in one group on one node, inside a list that spans several | §5.3 — where the silent failures live | | C | Reconciling N copies when nodes are ON and OFF | §6 — the stated fear, well founded *only* for the wrong granularity | -| D | A queue that can be **appended to**, not only replaced | §8 — the player's queue is three `useState`s reset wholesale; that is the one real code change | +| D | A queue that can be **appended to**, not only replaced | §9 — the player's queue is three `useState`s reset wholesale; that is the one real code change | +| E | Getting `op` from the menu to the player **through two wrappers** | §9.2a — not foreseen at all, and the only defect here that shipped in a form nothing on screen would have shown | -The request reads as a storage problem. Storage is the easy quarter of it. +The request reads as a storage problem. Storage is the easy fifth of it. --- @@ -288,11 +319,16 @@ which is the failure this whole document exists to prevent. | limit | value | enforced by | |---|---|---| | manifest (`kind = "playlists"`) | 64 KB sealed | node | -| one body (`kind = "playlist:<id>"`) | 1 MB sealed ≈ 10 000 tracks | node | +| one body (`kind = "playlist:<id>"`) | 1 MB sealed ≈ 17 000 tracks | node | | all rows for one account on one node | 8 MB | node | | tracks per playlist | 10 000 | client, before sealing | | playlists per account | 200 | client, before sealing | +At 4.5× and ~270 bytes a track, a sealed body runs about **60 bytes a track**: +the 1 MB cap is ~17 000 tracks in one playlist, and 8 MB per account is ~140 000 +track references across all of them. Both hold with room to spare, which is the +point of writing the measurement down rather than the guess. + The client limits exist so the ordinary case produces a sentence in the UI rather than an MNP error; the node limits exist because a client is not trusted to hold to them, and this is an unbounded write primitive pointed at @@ -313,6 +349,14 @@ named and then dismissed. ### 5.1 The manifest — `kind = "playlists"` +**JSON, not msgpack**, inside the blob. The node stores bytes and never parses +them, so the encoding is this client's private choice rather than a wire +format — and MNP's own codec is private to `transport.js`, which is a classic +script and cannot be imported. Keeping the payload in reach of `JSON.parse` is +what lets `playlist-merge.js` be executed by `node` with nothing around it, +which is the only real evidence the merge has. After deflate the difference +from msgpack is a few per cent. + Small, always resident, and the only thing every menu needs: ``` @@ -350,9 +394,16 @@ how §6.3's defect ships. ``` Array order *is* playlist order. `rev` here is the body's own, and the -manifest's `body_rev` is a copy of it — a client that sees a manifest naming -`body_rev: 41` while holding body `rev: 39` knows to fetch without fetching to -find out. A body whose manifest entry is `deleted` is dropped locally and +manifest's `body_rev` is a **watermark**: the highest body revision anybody has seen, +merged by taking the maximum and never decreasing, with `count` following +whichever side carries it. A client that sees a manifest naming `body_rev: 41` +while holding body `rev: 39` knows to fetch without fetching to find out. + +The two counters are not redundancy. Rename a playlist on one device while +adding a track to it on another and both write revision n+1; under a single +counter those collide and one of two edits that do not overlap at all is lost. +`rev` carries the name and the tombstone, `body_rev` carries the tracks, and +they move independently. A body whose manifest entry is `deleted` is dropped locally and `user_blob_delete`d on each node as it is reached; the tombstone in the manifest is what survives, not the body. @@ -500,13 +551,30 @@ well as at menu time: the manifest says which bodies are stale (`body_rev`), and a body is pulled when it is opened, loaded, or edited — not on every connection. A user with 40 playlists who only ever plays two never transfers the other 38. -Two additions on top: +As built, that is: **one reconciliation when Music mounts on a connected +group**, and another whenever a track is added to a playlist — both over the +transport that group already has open. And an explicit **"Sync now"** in the +playlist menu. **On sign-in, nothing**: the local copy is authoritative and +complete (§6.4), and the first group opened reconciles. -- **An explicit "Sync now"** in the playlist menu, which does the Search-style - sweep and reports which nodes it could not reach — the same honest reporting - `fetchAllIndexes` already does. -- **On sign-in, nothing.** The local copy is authoritative and complete (§6.4); - the first group opened will reconcile. +### 7.1 Ask the node what it holds, never guess + +`user_blob_list` returns kinds and revisions, no payloads, in one message. Sync +compares against **that**, not against the merged watermark. + +The difference is not an optimisation. `body_rev` in the manifest is the highest +revision *anybody* has seen; it says nothing about whether this particular node +has it. The first version of `syncWith` compared against it and so could not +tell "this node is behind" from "this node is up to date" — and pushed every +body on every sync, over a connection it had borrowed from something else. + +### 7.2 A tombstone has a body to reclaim + +Deleting a playlist tombstones its manifest entry, and the entry is what has to +survive. The **body** does not: it is `user_blob_delete`d from each node as that +node is reached. Without that step the body of every playlist ever deleted stays +on every node for ever, and the account's 8 MB quota fills up with graves — +which the node is entitled to refuse the next real write for. A device that only ever opens one group therefore only ever converges with one node. That is correct and not a defect: convergence is eventual, and the copy @@ -518,7 +586,7 @@ was reachable, which is usually A's, and spreads from there. ## 8. Protocol and node-side implementation -### 8.1 MNP — additive, MINOR bump (2.1) +### 8.1 MNP — additive, MINOR bump (3.1) ``` user_blob_store { kind, rev, blob_enc } client → node @@ -537,8 +605,22 @@ tells the node nothing it does not already know from its own table. Modelled on `KEYPAIR_BUNDLE_*` in every respect, including that the node stores and returns an opaque byte string. `kind` is validated against a **pattern** — -`playlists` or `playlist:<uuid>` — so the table does not become an arbitrary -key/value store for whatever a client feels like writing. +`playlists`, or `playlist:` and one to sixty-four of `[A-Za-z0-9_-]` — so the +table does not become an arbitrary key/value store for whatever a client feels +like writing. + +The character class is deliberately wider than a UUID, and that is a correction: +the reserved id is the word `favorites` (§5.1), so a hex-only pattern refuses +the one playlist every account has. It stays narrow enough to carry no structure +of its own — no `/`, no `.`, no second `:` — so a kind can never be read as a +path or as anything but one name in one namespace. + +**The bump is 3.1, not 2.1.** The proposal read the version off an older +document; the wire version was already 3.0. `MNP_MIN_SUPPORTED` stays at 3.0, +because nothing here is *required*: a 3.0 node answers "unknown message type" +and the client stores its playlists on the next node it reaches, keeping its own +copy meanwhile (§6.4). That is the difference from the 3.0 bump, where the +requirement rather than the messages is what made it MAJOR. Every reply carries `req_id` through the ordinary `_send` path. This is not optional and does not need re-arguing: `CLAUDE.md` records at length what @@ -622,6 +704,33 @@ onPlayQueue(tracks, startIndex, source, op) // op: 'replace' | 'next' | 'appen move, the `<audio>` element is not touched, nothing reloads. - `'append'` — push them at the end of `order`. +### 9.2a The wrapper that eats the argument + +One more component sits between the menu and the shell, and it is where this +broke. + +`group-page.js` and `search-page.js` each wrap `onPlayQueue` before handing it +down — the first to attach the group and its transport, the second to close a +video modal. Both wrappers were written as `(tracks, startIndex)` and forwarded +two arguments, so **every "add to queue" in a group arrived at the player as a +plain play and silently replaced the queue.** + +Nothing about that reads as wrong at either end. The call site passes four +arguments and is correct; the wrapper takes two and is correct for what it was +written for; the player receives `op: undefined` and defaults, correctly, to +`replace`. It was found by a browser probe pressing the button, and by nothing +else. + +The rule this leaves behind, and the reason the contract is asymmetric: + +- **view → wrapper** is `(tracks, startIndex, op)`. A view has no `source` to + give and must not invent an argument slot for one. +- **wrapper → shell** is `(tracks, startIndex, source, op)`. `source` is the + wrapper's own contribution. + +`test_music_queue.py` reads both wrappers for the named `op` parameter, which is +weak evidence — but it is the evidence that names the failure. + ### 9.3 Collapse `tracks` / `order` / `pos` into one reducer Three `useState`s updated from one event is where this ships a defect. `setOrder` @@ -671,10 +780,15 @@ do for a long playlist and is wrong, because for an album those 5 tracks share one connection, and for a cross-group playlist they may need **five different node dials**, each up to 10 s, against a pool of 12. -Rule: **prefetch only entries whose group already has a live connection in the -pool**; let the rest warm when the queue reaches them and the dial happens -anyway. An album is unaffected (one group, always live). A shuffled cross-group -playlist stops trying to open half the pool to warm tracks it may never reach. +Rule, as built: **prefetch only entries in the same group as the track +playing**, and never one whose group is already marked down (§11.3). An album is +unaffected — one group, always live — and a shuffled cross-group playlist stops +trying to open half the pool to warm tracks it may never reach. The rest warm +when the queue gets to them and the dial has to happen anyway. + +Deliberately the *playing* group rather than "anything the pool already holds": +the pool's contents change under it, and a prefetch rule whose answer depends on +what some other page happened to open is a rule nobody can reason about. --- @@ -700,10 +814,16 @@ The codebase has no context menu and no dropdown outside the account menu in album than the one it was opened on. - flips its anchor when it would open past the viewport edge, which on a phone is most of the time. -- submenus: a flyout on a fine pointer; **on a coarse pointer, a sheet**. A - two-level flyout (§10.3's "remove a track") has nowhere to go at 400 px, and - building it as a nested flyout and then discovering that on a phone is the - predictable version of this. +- submenus **expand in place**, downward, at every width. This is the + correction: the proposal wanted a flyout with a sheet on a coarse pointer, and + the account menu's language list (`app.js`) already shows the simpler answer. + Expanding needs no flipping, no hover intent and no separate mobile design, + and the two-level case (§10.3's "remove a track") has nowhere to fly out *to* + at 400 px anyway. +- a submenu may declare **`loadItems`** instead of `items`, resolved when it is + expanded. Exactly one needs it: a playlist's tracklist, read from IndexedDB. + Building every playlist's tracklist eagerly would mean reading a ten-thousand + track list to draw a menu nobody may open. Videos, Photos and Files all want this component eventually. It is deliberately generic and lives outside `music-*.js`. @@ -750,47 +870,59 @@ A new `playlist` glyph in `icon.js` next to `music`. One button, one icon, no label — the toolbar already wraps to three rows on a phone (`style.css:2557`) and has no room for a word. -The menu, in the requested order: +The menu, as built: ``` Load a playlist ▸ <list> → replace the queue, play -Save the current queue as… <name prompt> ← §10.4 -New playlist… <name prompt> -Delete a playlist ▸ <list> → inline confirm +New playlist… <a field> +Delete a playlist ▸ <list> → confirm() Remove a track from a playlist ▸ <list> ▸ <tracks> ──────────────────────────────── Sync now ← §7 ``` -Two additions to what was asked, both stated so they can be refused: **"Save the -current queue as…"** is placed at 2 because it is a creation verb and belongs -next to the other one; **"Sync now"** is §7's explicit sweep, which has to live -somewhere and this is the only playlist surface there is. +The four verbs asked for, in the order asked for, plus **"Sync now"** — §7's +explicit reconciliation, which has to live somewhere and this is the only +playlist surface there is. + +**"Save the current queue as…" is not here**, against the proposal. The queue is +the *player's* own state; putting the action in a menu owned by a different +component would mean lifting that state out of the player to reach it. It lives +in the queue panel alone (§10.4), which is where the current queue is a thing +the reader can see. Rules the list items carry: - **Load** replaces (§9.1) and starts playing. It fetches the body first if `body_rev` says the local copy is stale, which is the one place a playlist action can block — show the spinner the page already has. -- **New** takes a name only, as asked; tracks are attached afterwards. The name - must be unique *for this user*, checked against the manifest, case-folded with - the same `foldKey` the Music grouping already uses (`music-app.js`, exported) - rather than a second normaliser — a copy keeps agreeing until one of them - changes. -- **Delete** never lists Favourites (§5.1). It confirms inline; not - `window.confirm()`, which blocks the SPA's event loop and looks like a browser - alert in an Electron shell. -- **Remove a track** is the two-level one, and is a **sheet on mobile** - (§10.1): pick the playlist, then its tracklist with a trash button per row. - That sheet is also where reordering lands if O4 is ever taken up, which is - another reason not to build it as a flyout. +- **New** takes a name only, as asked, typed into **a field**: `window.prompt` + does not exist in the desktop client — it throws, which is how the Files + toolbar's New folder button came to do nothing at all, and + `test_no_prompt_in_the_spa.py` refuses a build that reintroduces it. The name + must be unique *for this user*, checked against the manifest and folded for + case and accents, so "Soirée" and "soiree" are not two playlists. +- **Delete** never lists Favourites (§5.1) — the store refuses it anyway, and + offering an action that always fails is worse than not offering it. It asks + with **`window.confirm`**, which is the correction: `prompt()` throws in + Electron and is banned by a test, while `confirm()` is measured to work and is + used in twenty places in this SPA. A deletion is a tombstone and nothing in + the interface undoes it, so it is worth one question. +- **Remove a track** is the two-level one, expanded in place: pick the playlist, + then its tracklist, one click per track. The second level is read from + IndexedDB when it is opened (§10.1's `loadItems`). ### 10.4 Saving the current queue -Reachable from two places, deliberately: the **queue panel** (`QueuePanel`, -`music-player.js:136`) gets a button in its top bar, because that panel is where -"the current queue" is a thing the user can see; and the toolbar menu mirrors it -for discoverability. +One place, not two: the **queue panel** (`QueuePanel`) gets a button in its top +bar, because that panel is where "the current queue" is a thing the reader can +see. The proposal wanted the toolbar menu to mirror it; the queue is the +player's own state, and a menu owned by a different component would have to have +it lifted out of the player to reach — which is a real change to the player for +a second way to press the same button. + +The modal itself is the shell's (`app.js`), because the player bar outlives +every page and the account it belongs to is there. It saves `order.map(i => tracks[i])` — **play order, not insertion order**. If shuffle is on, that freezes the shuffle, which is what "save what I am listening @@ -855,14 +987,23 @@ things: `groupId` down for the session, advance past **every** queued track from that group in one step, and reset the counter. -If that empties the queue, stop **once**, with a stated count — "3 groups -unreachable, 42 tracks skipped" — which is the same honest reporting -`fetchAllIndexes` does, and is what the 5-bound was protecting in the first -place. A group marked down is retried only on an explicit user action (pressing -play or next), never automatically, so a 300-track playlist does not re-dial a -dead node 300 times. `transport.waitForReconnect()` already covers the transient +If that empties the queue, playback stops **once**, with the error already on +screen — which is what the 5-bound was protecting in the first place. A group +marked down stays down for the session, so a 300-track playlist does not re-dial +a dead node 300 times; `transport.waitForReconnect()` still covers the transient within-group case and is untouched. +How the two are told apart: `fetchTrackBlob` tags the error it raises when no +node answered for that group (`err.isGroupDown`), and the load path passes the +group id to `advancePastFailure` only for those. A decode failure — including +the one that surfaces later as an `error` event on the `<audio>` element rather +than a rejected promise — reaches the same function with nothing and takes the +bounded path, unchanged. + +`test_music_queue.py` queues **six** tracks from a dead group behind one live +one, which is one more than the bound: without the split, playback lands on the +last of the six instead of past them. + --- ## 12. Security — per adversary @@ -923,7 +1064,7 @@ later. | O4 | Reordering a playlist, and conflicts better than LWW | Losing a reorder is survivable; losing an added track is not, and per-playlist `rev` already prevents that. The drag surface is §10.3's sheet when it comes | | O5 | Export / import a playlist as a file | Trivial once §14.1 exists (it is `JSON.stringify` of one body) and worth doing, but it is not what makes playlists work across devices | | O6 | Tombstone collection driven by an explicit per-node acknowledged `rev` | §6.3's rule is adequate at this scale; a real collector matters at thousands of deletions, which is not a real state | -| O7 | A `/playlists` page and sidebar entry | **Dropped from this design, not deferred lightly.** The first draft proposed it; the requirement is a menu in Music's toolbar, and Music is already mounted in both places that matter (§10). A separate page would be a third surface showing the same list, and the first one to go stale | +| O7 | A `/playlists` page and sidebar entry | **Decided against, and now built that way.** The requirement is a menu in Music's toolbar, and Music is already mounted in both places that matter (§10) — Search needed nothing beyond being passed `userId`. A separate page would be a third surface showing the same list, and the first one to go stale | | O8 | Per-queue persistence — restoring the current queue after a reload | Different object with a different lifetime: the queue is ephemeral and per-device, and `localStorage` is its right home, not §3's blob. Worth doing; not this | --- @@ -932,19 +1073,30 @@ later. ### 14.1 Modules -| file | contents | why separate | -|---|---|---| -| `playlists.js` | manifest + body load / merge / save, tombstones, id-path repair, compress, seal, open, the §5.3 whitelist | pure data layer, **no UI** — the merge is the one part that can be properly tested and must not be entangled with a component | -| `queue-ops.js` | the `{tracks, order, pos}` reducer (§9.3) | same reason, and it is the part that can regress the player | -| `menu.js` | `<Menu>`, `useMenu()` — anchored menu, submenus, coarse-pointer sheets | generic; Videos, Photos and Files want it too | -| `playlist-menu.js` | the toolbar button, its menu, and the create / delete / remove-track / save-queue modals | ~300 lines of modal that do not belong in `music-app.js` | +**Five files, not four.** Three of them have **no imports at all** and are +executed whole by their tests — which is the only real evidence this feature +has, and is why the split is where it is. + +| file | imports | contents | executed by | +|---|---|---|---| +| `queue-ops.js` | none | the `{tracks, order, pos}` reducer (§9.3) | `test_queue_ops.py` | +| `playlist-merge.js` | none | the shapes, the merge rules, tombstones, id/path repair, the §5.3 whitelist | `test_playlist_merge.py` | +| `playlist-crypto.js` | none | compress → pad → seal, and the HKDF subkey | `test_playlist_crypto.py`, against node's own WebCrypto | +| `playlists.js` | the three above, plus `hub-client.js` | IndexedDB, the transport, the mutations | Chrome, against a stubbed node (`test_playlist_store.py`) | +| `menu.js`, `playlist-menu.js` | components | the shared pop-up menu, and the toolbar button with its modals | Chrome (`test_playlist_ui.py`) | + +`playlist-crypto.js` is the one the proposal did not separate. Folding seal and +open into `playlists.js` would have put them behind IndexedDB and a transport, +where node cannot reach them — and a crypto layer that cannot be executed is a +crypto layer nobody has checked. Edited: `music-app.js` (toolbar button, per-item menus, the `<button>` → `<div>` row fix), `music-player.js` (the reducer, the `op` branch, the split failure -counters, the queue panel's save button), `app.js` (`op` through -`handlePlayQueue`, the `groupTransportRef` guard), `hub-client.js` (IDB v2), -`keyderive.js` (the HKDF handle), `icon.js` (a `playlist` glyph), `style.css`. -`search-page.js` needs **nothing** — it already mounts `MusicApp`. +counters, the queue panel's save button, the prefetch gate), `app.js` (`op` +through `handlePlayQueue`, the `groupTransportRef` guard, the save-queue modal), +`group-page.js` and `search-page.js` (forwarding `op` — §9.2a — and passing +`userId`), `hub-client.js` (IDB v2 and `openDB`), `keyderive.js` (the HKDF +handle), `icon.js` (`playlist` and `playnext`), `style.css`, ten locales. ### 14.2 The IndexedDB detail that will otherwise be missed @@ -954,9 +1106,21 @@ handling it in the existing `onupgradeneeded`, which currently creates one store and would otherwise never run again. A store that is never created throws on first access, at a point far from the version constant. -One store holding the manifest and the bodies, keyed by `kind`, so §8.1's -namespace is the IndexedDB key as well and nothing has to translate between two -schemes. +One store holding the manifest and the bodies, keyed by **`userId|kind`**. Two +accounts on one browser is ordinary, and so is a sign-out that never runs, so +the separation belongs in the key rather than in a cleanup path that has to +happen. + +**The version and every store stay in `hub-client.js`, and `openDB` is +exported.** The first version of `playlists.js` opened the database itself at +version 2 while `hub-client.js` still opened it at 1 — two modules disagreeing +about a version is a `VersionError` thrown at whichever of them happens to run +second, and neither file would look wrong on its own. + +Records are stored **in clear** locally, exactly as the cached group indexes +already are. What is sealed is the copy that leaves this browser; a second +encryption layer over the local cache would protect against nothing the index +cache is not already exposed to, and would need its key held in the same place. ### 14.3 Checklist, per `MESHBAY_DESIGN.md` §9.4 @@ -964,7 +1128,10 @@ schemes. 2. Nothing for caching: every file under `static/` feeds the `/a/<hash>/` fingerprint. 3. `test_hook_ordering.py` (`STATIC_FILES`) and `test_transport_contracts.py` - (`SPLIT_FILES`) — all four new files. + (`SPLIT_FILES`) — **`menu.js` and `playlist-menu.js` only.** Those lists walk + components for hook ordering and dependency arrays; the four hookless modules + do not belong in them, exactly as `source-merge.js` does not. The proposal + said "all four new files", which would have been noise. 4. i18n keys in all ten `static/locales/*.js`; `test_locales.py` holds them to `en.js`'s key set. 5. `npm run sync-ui` in `meshbay-client`, confirmed reported — CI fails if the @@ -974,51 +1141,58 @@ schemes. --- -## 15. Acceptance before shipping +## 15. What is verified, and what is not + +Baseline before any of this: **2558 passed, 1 failed, 12 errors** — the failure +and the errors are the pre-existing browser ones in `test_sticky_header.py` +(a `.admin-table` overflow at 360px in Group settings, and Firefox producing no +measurement at all here). Every commit was checked against that baseline, at the +level of the named failure rather than the count. + +### 15.1 Checked, by tests that execute the real code + +| | What | Where | +|---|---|---| +| 1 | The queue reducer: append to an empty queue, insert-next at the last position, insert-next while shuffled, **two appends in one tick**, removing what is playing, shuffle after an append | `test_queue_ops.py` — 20 cases against `queue-ops.js` itself | +| 2 | Album playback is unchanged: auto-advance, shuffle on mid-album keeping the playing track, play-all replacing | `test_music_queue.py`, in Chrome, on the shipped `GroupPage` and player | +| 3 | The merge, through the five divergences — two devices, a node offline for each in turn, delete against edit, a resurrect attempt, a `rev` tie — and **it fails with the tombstone rule removed** (checked: one test fails, alone) and **fails on a clock-based merge** (checked: seven fail) | `test_playlist_merge.py` — 22 cases against `playlist-merge.js` itself | +| 4 | Sealing: compression worth 4.5× on realistic data, padding that makes 1, 2 and 10 tracks the same length, a random nonce, the AAD refusing one playlist's body served as another's or as another account's, a flipped byte, a future format byte | `test_playlist_crypto.py` — against node's own WebCrypto | +| 5 | **Argon2id runs exactly once** per sign-in, and the AES handle is genuinely not derivable — which is why the HKDF handle must be a second import | `test_playlist_key.py` | +| 6 | The node: a blob round-trips as bytes and **survives the process**; the plaintext is not in `bundles.db`; one account never sees another; every cap refuses with a stated reason and writes nothing; the quota subtracts what a write replaces | `test_user_blob_store.py`, `test_user_blob_mnp.py` — 49 cases | +| 7 | The store: a stale node cannot lower the merged state; a deletion is not resurrected; an edit made elsewhere arrives with its body; a sync with nothing to do writes nothing; a deleted body is reclaimed; what leaves the browser is sealed | `test_playlist_store.py`, in Chrome against a stubbed node | +| 8 | The menus: the toolbar's five verbs, naming a playlist in a field, Favourites offered first before it exists, adding an album from its cover, loading into the queue, the two-level tracklist, deleting after a confirm | `test_playlist_ui.py`, in Chrome | +| 9 | An unreachable group is skipped **whole** — six dead tracks, one more than the bound | `test_music_queue.py` | + +### 15.2 Not checked, and what that costs -**No-regression first**, because §9.2 changes the player's queue state and -nothing else in this document can be judged until that is known to be clean: +These need things a test suite here does not have. They are listed so nobody +reads §15.1 as more than it is. -1. Baseline counts **before any edit**, per `CLAUDE.md`: the hub suite has - pre-existing browser failures, and a count taken afterwards proves nothing. -2. Play an album, let it auto-advance across at least three tracks, with - prefetch observed warming ahead. Then the same with shuffle on, toggled - mid-album, and confirm the playing track does not restart. -3. `tests/harness/queue_ops.mjs` runs the real reducer through §9.3's six - scenarios, including **two appends in one tick**, and fails if the reducer is - replaced by the three-`useState` version. +1. **A round trip through a real node daemon.** The MNP handlers are driven + directly against a real `BundleStore`, and the store is checked across a + close and reopen — but not across a running daemon, a real DataChannel and a + real handshake. The likeliest thing to be wrong there is msgpack `bin` + framing for a payload of this size, which nothing here exercises. +2. **Two browsers, one account, one node, converging** — and the same with the + node stopped between the two edits. The merge is proved; the *plumbing* + around it is proved against a stub that always behaves. +3. **A real cross-group playlist playing**, which is where §11.3 and §9.5 meet + real latency rather than a stub that rejects instantly. +4. **The 650 ms sign-in budget**, measured. Argon2 is proved to run once; the + wall-clock cost of the second `importKey` is assumed to be nil rather than + measured. +5. **Firefox**, for any of the browser-driven work: its probe produces no + measurement in this environment, which is a pre-existing gap and not one this + feature closes. -**Then the feature:** +### 15.3 Still open -4. `tests/harness/playlist_merge.mjs` runs the real `merge()` through five - divergences — two devices, one node offline for each in turn, a delete on one - side and an edit on the other, a resurrect attempt, a `rev` tie — and **fails - with the tombstone rule removed**. Check that, or the test is decoration. -5. A round trip through a real node: seal, `user_blob_store`, restart the daemon, - `user_blob_fetch`, open. Confirms the blob survives the process, not just the - test. Repeat for a body and for the manifest. -6. Read `bundles.db` back and confirm no playlist plaintext is in it — the same - check `test_chat_key_storage.py` already makes for epoch keys, for the same - reason. A plaintext table beside it is the obvious thing to write and would - collapse the whole claim silently. -7. Two browsers, one account, one node: edit in each, converge, confirm both - agree. Repeat with the node stopped between the two edits, and confirm - neither edit is lost when it comes back. -8. Open Music with **every** node offline and confirm every menu in §10 draws in - full — playlist names, track titles, artists, durations, entries greyed — - because that is the requirement §5.3 exists for and it cannot be unit-tested - meaningfully. -9. Build a playlist of ~2 000 tracks, confirm the sealed body is under the 1 MB - cap, and **measure the bytes written when one track is starred** — it must be - the Favourites body plus the manifest, not the collection. That number is - what §4.3 is for. -10. Confirm each cap refuses rather than truncates, and that the refusal reaches - the client as a stated reason rather than a bare `error`. -11. Confirm sign-in still runs Argon2id exactly **once** after §3.4's change - (measure it; the budget is the 650 ms already recorded in draft-v5 §7.1). -12. A playlist spanning three groups, one of them stopped: confirm it loads - whole, plays, skips the dead group's tracks **in one step** rather than - erroring on the sixth, and reports the count. -13. Create a playlist from inside a group, then open Search → Music and confirm - the same playlist is in the menu there (requirement 6) — on the same device - immediately, on a second device after it has opened any group. +- **O7 is now decided, not deferred**: there is no `/playlists` page, and the + Search page needed no change beyond passing `userId` — §11's requirement is + met by `MusicApp` being mounted in both places. +- `deleteUserBlob` reclaims a tombstoned body, but **tombstones themselves are + never collected** (O6). At forty bytes each this is not a real problem yet, + and a collector needs the per-node acknowledged `rev` that O6 describes. +- **Nothing calls `syncWith` on the Search page.** It has a transport per group + and no single one to ride; the group page's mount is what reconciles today. A + reader who only ever uses Search converges only when they open a group. |