diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:07:18 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:07:18 +0200 |
| commit | 2916aa376009283305a7acec4aafa3c96544499e (patch) | |
| tree | bc19d010e8bebf39c175271564b3be01d1c06aa0 /packages/meshbay-hub/src/meshbay_hub/static/playlists.js | |
| parent | 25f62e169e049f0757ef611d5b409e7523958dee (diff) | |
| download | meshbay-2916aa376009283305a7acec4aafa3c96544499e.tar.gz | |
playlists: make the writes actually leave the browser
Reported from a phone: signing in with the same account showed no
playlists. syncWith was called from exactly one place in the interface,
so creating a playlist, deleting one, removing a track and saving the
queue all wrote to IndexedDB and stopped there. The store pushes itself
now, coalesced, so a new mutation cannot forget to.
Silence was the real defect. The node audited only successes, so a
refusal left no trace and user_blob_list none at all; the background
push swallowed its reason; the interface said nothing. All three report
now, and "Sync now" says what happened either way.
An unreadable blob on a node was treated as a fetch failure and returned
before the push — permanent, once the node held anything. It is an
absence: the client is the authority, and it gets overwritten.
A sign-in reconciles whatever this browser already holds, a pending push
is flushed when the page goes away, and a push that did not land is
retried once.
no_key is spelled out: a client that signs in with its remembered device
key only ever has a bundle key persisted before the playlist subkey
existed, and an AES handle is non-extractable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/playlists.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/playlists.js | 211 |
1 files changed, 206 insertions, 5 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js index 11525eb..5aa501a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js @@ -94,6 +94,19 @@ function forgetPlaylistKey() { _state = null; } +/** + * Drop this account's local copy — what a browser that has never seen it looks + * like. Exported for the tests, which have no other way to be a fresh device. + */ +async function forgetLocal(userId) { + const st = await ensureLoaded(userId); + for (const id of Object.keys(st.manifest.playlists || {})) { + await _idbPut(userId, bodyKind(id), null); + } + await _idbPut(userId, MANIFEST_KIND, null); + _state = null; +} + // ── local storage ──────────────────────────────────────────────────────────── // // Plaintext in IndexedDB, exactly as the cached group indexes already are @@ -135,6 +148,166 @@ async function _idbPut(userId, kind, value) { } catch { /* a private window, or storage refused — the session still works */ } } +// ── pushing, which is this module's job and not the caller's ───────────────── +// +// The first version left it to the call sites: `music-app.js` synced after +// adding tracks, and nothing else did. Creating a playlist, deleting one, +// removing a track and saving the queue all wrote to IndexedDB and stopped +// there — so a second device saw nothing, which is the one thing this feature +// exists for. Found by reading the node's own audit log: two events, ever. +// +// Scattering `syncNow()` across six call sites would be the same mistake with +// five more places to forget it. The store knows when it changed; it pushes. + +let _transport = null; // () => Promise<transport|null>, registered by the shell +let _pushTimer = null; +let _retryTimer = null; +let _pushFor = null; + +// Coalescing window, and the single retry after a push that did not land. +// `let` rather than `const` so the tests can shorten them: a retry nobody can +// wait for is a retry nobody has checked, and this feature has already shipped +// one path that no test ever called. +let PUSH_DEBOUNCE_MS = 1500; +let PUSH_RETRY_MS = 20000; + +function setPushTimings({ debounceMs, retryMs }) { + if (debounceMs != null) PUSH_DEBOUNCE_MS = debounceMs; + if (retryMs != null) PUSH_RETRY_MS = retryMs; +} +// What the last push actually did. A background push that fails reports to +// nobody by construction — which is how playlists stopped leaving a browser for +// two hours while the interface showed them perfectly. `lastSync()` is what +// "Sync now" and a support question can both read. +let _lastSync = { ok: null, reason: 'never run', at: null }; + +function lastSync() { + return { ..._lastSync }; +} + +/** + * How the store reaches a node. The shell registers this because the shell owns + * connections; `null` unregisters it at sign-out. + */ +function setPlaylistTransport(fn) { + _transport = fn; +} + +/** + * Push after a write, coalesced. + * + * Adding an album is one write and should be one push, and so should a burst of + * stars. A second and a half is long enough to swallow a burst and short enough + * that closing the laptop afterwards is not how the edit is lost. + * + * Failure is silent by design: the local copy is the authority (§6.4), and the + * next sync — the next group opened, or "Sync now" — carries it. What must not + * happen is the write failing because the push did. + */ +function _schedulePush(userId, isRetry) { + if (!_transport) return; + _pushFor = userId; + clearTimeout(_pushTimer); + _pushTimer = setTimeout(async () => { + _pushTimer = null; + let result; + try { + const tr = await _transport(); + result = tr ? await syncWith(tr, _pushFor) + : { ok: false, reason: 'no reachable node' }; + } catch (err) { + // The write itself already succeeded locally; this is only the push. + result = { ok: false, reason: err.message || String(err) }; + } + _note(result); + + // One retry, and exactly one. A node that is off tends to stay off, and a + // push that keeps looping spends a phone's battery on a node that is not + // coming back — while the local copy is already correct and the next + // mutation, or the next Music tab opened, carries it anyway. `no_key` is + // never retried: it cannot fix itself without the reader doing something. + if (!result.ok && !isRetry && result.reason !== 'no_key') { + clearTimeout(_retryTimer); + _retryTimer = setTimeout(() => _schedulePush(_pushFor, true), PUSH_RETRY_MS); + } + }, isRetry ? 0 : PUSH_DEBOUNCE_MS); +} + +/** + * A device that has nothing of its own fetches, once. + * + * §7 said "on sign-in, nothing", on the grounds that the local copy is + * authoritative and complete. That is true of a device that has been used + * before and **false of a new one** — which is the case this whole feature + * exists for. Signing in on a phone and finding the playlists absent is exactly + * what the design promised would not happen, and exactly what it did. + * + * It runs **whatever this browser already holds**, which is the correction to + * the correction: bounding it to an empty device left the ordinary case out. + * A phone that already has nine playlists and is missing the tenth would not + * have gone looking either, and would have waited for a group's Music tab to + * be opened — which is not where anybody looks for a playlist. + * + * Once per sign-in, and it is one `list` plus one `fetch`: the manifest is a + * few kilobytes, and bodies are still pulled only when they are behind. + */ +async function pullOnce(userId) { + if (!_transport) return { ok: false, reason: 'no_transport' }; + try { + const tr = await _transport(); + if (!tr) return { ok: false, reason: 'offline' }; + return await syncWith(tr, userId); + } catch (err) { + return { ok: false, reason: err.message || 'failed' }; + } +} + +/** + * Record what a push did, and say so once in the console when it did not work. + * + * Silence was the actual defect here, not any single bug: every path that could + * fail swallowed its reason, so the interface, the console and the node's log + * all agreed that nothing was wrong. + */ +function _note(result) { + _lastSync = { + ok: !!(result && result.ok), + reason: (result && result.reason) || null, + unreadable: !!(result && result.unreadable), + pushed: (result && result.pushed) || 0, + at: new Date().toISOString(), + }; + if (!_lastSync.ok) { + console.warn('[MeshBay] playlists not synced:', _lastSync.reason); + } + return result; +} + +/** + * Send a pending push now, because the page is about to go away. + * + * The shell calls this on `pagehide` and when the tab goes hidden — closing a + * laptop within the coalescing window is not a rare thing to do, and it is the + * one moment when waiting another second and a half means waiting until the + * next time Music is opened. + * + * Best-effort by nature: a browser tearing a page down owes an `await` + * nothing. It costs a message when it works and nothing when it does not. + */ +async function flushPush() { + if (!_pushTimer && !_retryTimer) return; + clearTimeout(_pushTimer); + clearTimeout(_retryTimer); + _pushTimer = null; + _retryTimer = null; + try { + const tr = await _transport(); + if (tr) _note(await syncWith(tr, _pushFor)); + } catch (err) { + _note({ ok: false, reason: err.message || String(err) }); + } +} + // ── in-memory state ────────────────────────────────────────────────────────── let _state = null; // { userId, manifest, bodies: Map(id -> body) } @@ -246,6 +419,7 @@ async function createPlaylist(userId, name) { _entry(st, id, wanted); await _saveManifest(st); await _saveBody(st, emptyBody(id)); + _schedulePush(userId); return id; } @@ -257,6 +431,7 @@ async function renamePlaylist(userId, id, name) { e.device = deviceId(); e.updated_at = Math.floor(Date.now() / 1000); await _saveManifest(st); + _schedulePush(userId); } /** @@ -278,6 +453,7 @@ async function deletePlaylist(userId, id) { await _saveManifest(st); st.bodies.delete(id); await _idbPut(st.userId, bodyKind(id), null); + _schedulePush(userId); } /** @@ -308,6 +484,7 @@ async function addTracks(userId, id, entries, groupId, name) { e.count = next.tracks.length; e.updated_at = Math.floor(Date.now() / 1000); await _saveManifest(st); + _schedulePush(userId); return incoming.length; } @@ -327,6 +504,7 @@ async function removeTrackAt(userId, id, at) { e.count = next.tracks.length; e.updated_at = Math.floor(Date.now() / 1000); await _saveManifest(st); + _schedulePush(userId); return true; } @@ -388,15 +566,31 @@ async function syncWith(transport, userId) { let theirs = null; if (have.has(MANIFEST_KIND)) { + let row = null; try { - const row = await transport.fetchUserBlob(MANIFEST_KIND); - if (row && row.blob_enc) { - theirs = await open(row.blob_enc, MANIFEST_KIND, userId, key); - } + row = await transport.fetchUserBlob(MANIFEST_KIND); } catch (err) { + // The transport failed. Nothing to reconcile against and nothing to + // overwrite, so leave this node alone. result.reason = err.message || 'fetch failed'; return result; } + if (row && row.blob_enc) { + try { + theirs = await open(row.blob_enc, MANIFEST_KIND, userId, key); + } catch (err) { + // A blob this account cannot open with this passphrase is not an older + // copy of anything — it is unreadable, and treating it as a *fetch* + // failure wedged the sync permanently: the first write landed because + // the node was empty, and every sync after it returned here without + // ever pushing again. The client is the authority (§6.4), so an + // unreadable remote copy is an absence, and gets overwritten. + console.warn('[MeshBay] playlist manifest on this node will not open:', + err.message, '— overwriting it with the local copy'); + result.unreadable = true; + theirs = null; + } + } } const merged = mergeManifests(st.manifest, theirs || emptyManifest()); @@ -433,7 +627,12 @@ async function syncWith(transport, userId) { result.pulled += 1; } } - } catch { /* one body failing must not stop the rest */ } + } catch { + // Same reasoning as the manifest above, and already the right shape: + // one body that will not open must not stop the rest, and the local + // copy is pushed over it on the next write to that playlist. + result.unreadable = true; + } } else if (localRev > nodeRev && localRev > 0) { try { await transport.storeUserBlob( @@ -476,6 +675,8 @@ async function syncWith(transport, userId) { export { FAVORITES_ID, + setPlaylistTransport, flushPush, pullOnce, forgetLocal, lastSync, + setPushTimings, deviceId, playlistKey, forgetPlaylistKey, ensureLoaded, listPlaylists, getPlaylistTracks, createPlaylist, renamePlaylist, deletePlaylist, |