aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 16:07:18 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 16:07:18 +0200
commit2916aa376009283305a7acec4aafa3c96544499e (patch)
treebc19d010e8bebf39c175271564b3be01d1c06aa0 /packages/meshbay-hub/src/meshbay_hub/static/playlists.js
parent25f62e169e049f0757ef611d5b409e7523958dee (diff)
downloadmeshbay-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.js211
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,