", {
@@ -197,3 +199,30 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
restoreTauri();
}
});
+
+runWholeBlobP2aSuite({
+ label: "sections",
+ Manager: ChannelSectionSyncManager,
+ publishEdit: (m, store) => m.publishSections(store),
+ subscribe: (m, cb) => m.subscribeToSections(cb),
+ makeNonEmptyStore: () => ({
+ version: 1,
+ sections: [{ id: "s1", name: "Work", order: 0 }],
+ assignments: {},
+ }),
+ makeEditStore: () => ({
+ version: 1,
+ sections: [{ id: "click", name: "Click", order: 0 }],
+ assignments: {},
+ }),
+ makeMountStore: () => ({
+ version: 1,
+ sections: [{ id: "mount", name: "Mount", order: 0 }],
+ assignments: {},
+ }),
+ makeRemoteStore: () => ({
+ version: 1,
+ sections: [{ id: "remote", name: "Remote", order: 0 }],
+ assignments: {},
+ }),
+});
diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs b/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs
index 8cfd82cb9e..b3e40374d1 100644
--- a/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs
+++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.test.mjs
@@ -4,12 +4,14 @@ import { after, before, test } from "node:test";
import { JSDOM } from "jsdom";
import { makeHookStubs } from "./sidebarSyncTestHelpers.mjs";
import { runWholeBlobHookSuite } from "./wholeBlobHook.shared.test.mjs";
+import { runWholeBlobP2aSuite } from "./wholeBlobSyncP2a.shared.test.mjs";
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
const { useChannelSortPreference } = await import(
"./useChannelSortPreference.ts"
);
+const { ChannelSortSyncManager } = await import("./channelSortSync.ts");
const { readChannelSortOutbox, storageKey } = await import(
"./channelSortPreference.ts"
);
@@ -128,3 +130,14 @@ test("legacy replay whose v2 transfer fails (quota) does not write the consumed
restoreTauri();
}
});
+
+runWholeBlobP2aSuite({
+ label: "sort",
+ Manager: ChannelSortSyncManager,
+ publishEdit: (m, store) => m.publishSortPrefs(store),
+ subscribe: (m, cb) => m.subscribeToSortPrefs(cb),
+ makeNonEmptyStore: () => ({ version: 1, groups: { s1: "recent" } }),
+ makeEditStore: () => ({ version: 1, groups: { click: "alpha" } }),
+ makeMountStore: () => ({ version: 1, groups: { mount: "recent" } }),
+ makeRemoteStore: () => ({ version: 1, groups: { remote: "recent" } }),
+});
diff --git a/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs b/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs
index 99338227e2..f6efbc33dd 100644
--- a/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs
+++ b/desktop/src/features/sidebar/lib/wholeBlobSync.shared.test.mjs
@@ -839,242 +839,3 @@ test("P2b decrypt gap: watermark advances synchronously; lastRemoteHead advances
mock.reset();
}
});
-
-// ── P2a queue-until-bootstrap regressions ────────────────────────────────────
-//
-// Three causal regressions covering the queue-until-bootstrap contract.
-//
-// Shared mutation target A (tests 1 and 2): removing the `bootstrapStarted`
-// guard from `publish()` lets the debounce timer fire while bootstrap is still
-// in-flight. `publishBaseline` is `{0,""}` at that point, so
-// `remoteAdvancedSince(head, {0,""})` = true and `fetchOwnBlobBeforePublish`
-// adopts the edit away — exactly the Kalvin P2a click-loss.
-//
-// Shared mutation target B (test 3): removing the `bootstrapFailed` exception
-// from `fetchOwnBlobBeforePublish` causes a hard-adopt against `{0,""}` when
-// bootstrap failed, dropping the queued edit even though no baseline was ever
-// established.
-
-// Test 1 (load-bearing): fresh device (watermark=0), relay HAS a pre-existing
-// head, user clicks during unresolved bootstrap → bootstrap succeeds → edit
-// publishes above the relay head, never adopted away.
-// Failing mutation: remove `this.bootstrapStarted &&` from the publish() guard
-// (i.e. revert to unconditional timer scheduling) → debounce fires against
-// {0,""} baseline → remoteAdvancedSince returns true → adopt fires instead.
-test("P2a queue-until-bootstrap: click during unresolved bootstrap on fresh device publishes above the relay head, never adopted", async () => {
- let releaseBootstrap = null;
- let publishCalls = 0;
- const { fireDelay, restore } = makeTimerBed();
- const remoteStore = makeStore([{ id: "remote", name: "Remote", order: 0 }]);
- const tauri = installEchoTauri("pk-p2a-1");
-
- // storedHead tracks what the relay retains. Initially the pre-existing head;
- // updated to our published event once we publish successfully.
- let storedHead = [tauri.mintHead(remoteStore, 200, "evt-relay-head")];
-
- // Fetch call 1 (bootstrap): blocks until released, returns the pre-existing
- // relay head. Subsequent calls (pre-publish fetch, confirmation) return
- // storedHead — which after our publish will be our own event.
- let fetchCalls = 0;
- mock.method(relayClient, "fetchEvents", () => {
- fetchCalls++;
- if (fetchCalls === 1)
- return new Promise((res) => {
- releaseBootstrap = () => res(storedHead);
- });
- return Promise.resolve(storedHead);
- });
- mock.method(relayClient, "publishEvent", (e) => {
- publishCalls++;
- storedHead = [e]; // relay retains our event
- return Promise.resolve();
- });
-
- try {
- const manager = new SyntheticWholeBlobManager("pk-p2a-1", RELAY);
- const adopted = [];
- manager.setOnRemoteAdopted((r) => adopted.push(r));
-
- // Start bootstrap (blocked on fetch call 1).
- const bootstrapPromise = manager.bootstrap(nonEmptyStore());
-
- // User clicks during unresolved bootstrap. With the fix: no timer fires yet.
- // Without the fix (mutation): timer fires immediately against {0,""} baseline.
- manager.publishSections(
- makeStore([{ id: "click", name: "Click", order: 0 }]),
- );
-
- // Release bootstrap → head is recorded (200,"evt-relay-head"), bootstrapResolved=true,
- // releaseDeferred() re-freezes publishBaseline to {200,"evt-relay-head"} and
- // schedules the 2s debounce.
- while (releaseBootstrap === null) await Promise.resolve();
- releaseBootstrap();
- await bootstrapPromise;
- for (let i = 0; i < 50; i++) await Promise.resolve();
-
- // Fire the debounce released by bootstrap.
- await fireDelay(2000);
- for (let i = 0; i < 100; i++) await Promise.resolve();
-
- // The edit must have published. publishBaseline was {200,"evt-relay-head"};
- // pre-publish fetch returns the same head → remoteAdvancedSince = false → publish.
- // After our publish, storedHead = our event; confirmation fetch confirms us.
- // Without the fix: baseline was {0,""} → remoteAdvancedSince = true → adopt.
- assert.equal(adopted.length, 0, "click must not be adopted away");
- assert.ok(publishCalls >= 1, "click must publish above the relay head");
- assert.equal(
- manager.getPendingStore(),
- null,
- "pending cleared after publish",
- );
- manager.destroy();
- } finally {
- tauri.restore();
- restore();
- mock.reset();
- }
-});
-
-// Test 2: click during unresolved bootstrap, bootstrap succeeds, then a peer
-// posts a genuinely newer head BEFORE the debounce fires → edit adopts (normal LWW).
-// Failing mutation: same as test 1 — without the guard, the debounce fires during
-// bootstrap with baseline {0,""}. The pre-publish fetch returns a {0,""}-relative
-// advance but for the wrong reason (before the bootstrap head was established).
-// With the fix, publishBaseline = {200,"evt-boot"} and the peer head at ts=400
-// is a genuine advance → adopt fires correctly.
-test("P2a queue-until-bootstrap: genuinely newer peer head that arrives after bootstrap adopt via normal LWW", async () => {
- let releaseBootstrap = null;
- let publishCalls = 0;
- const { fireDelay, restore } = makeTimerBed();
- const bootstrapStore = makeStore([{ id: "boot", name: "Boot", order: 0 }]);
- const peerStore = makeStore([{ id: "peer", name: "Peer", order: 0 }]);
- const tauri = installEchoTauri("pk-p2a-2");
-
- const bootHead = tauri.mintHead(bootstrapStore, 200, "evt-boot");
- const peerHead = tauri.mintHead(peerStore, 400, "evt-peer");
-
- // Bootstrap returns the boot head. Pre-publish fetch returns the peer-newer
- // head (simulating a peer publishing between bootstrap and doPublish).
- let fetchCalls = 0;
- mock.method(relayClient, "fetchEvents", () => {
- fetchCalls++;
- if (fetchCalls === 1)
- return new Promise((res) => {
- releaseBootstrap = () => res([bootHead]);
- });
- return Promise.resolve([peerHead]);
- });
- mock.method(relayClient, "publishEvent", () => {
- publishCalls++;
- return Promise.resolve();
- });
-
- try {
- const manager = new SyntheticWholeBlobManager("pk-p2a-2", RELAY);
- const adopted = [];
- manager.setOnRemoteAdopted((r) => adopted.push(r));
-
- const bootstrapPromise = manager.bootstrap(nonEmptyStore());
-
- // Click during unresolved bootstrap.
- manager.publishSections(
- makeStore([{ id: "click-2", name: "Click", order: 0 }]),
- );
-
- while (releaseBootstrap === null) await Promise.resolve();
- releaseBootstrap();
- await bootstrapPromise;
- // After bootstrap: publishBaseline = {200,"evt-boot"} (re-frozen by releaseDeferred).
- for (let i = 0; i < 50; i++) await Promise.resolve();
-
- await fireDelay(2000);
- for (let i = 0; i < 100; i++) await Promise.resolve();
-
- // Pre-publish fetch returns {400,"evt-peer"}: remoteAdvancedSince({400,...},{200,...})=true
- // → adopt fires correctly. Normal LWW: peer truly posted after bootstrap baseline.
- assert.equal(
- publishCalls,
- 0,
- "edit must not publish over the newer peer head",
- );
- assert.equal(
- adopted.length,
- 1,
- "newer peer head must be adopted (normal LWW)",
- );
- manager.destroy();
- } finally {
- tauri.restore();
- restore();
- mock.reset();
- }
-});
-
-// Test 3 (Kalvin's original P2a sequence): bootstrap FAILS, user clicks,
-// pre-publish fetch finds a pre-existing head → edit publishes above it.
-// Failing mutation: remove the `this.bootstrapFailed &&` check from
-// fetchOwnBlobBeforePublish — the hard-adopt against {0,""} baseline fires.
-test("P2a failed bootstrap: click after failed bootstrap publishes above the relay head, never adopts against empty baseline", async () => {
- let publishCalls = 0;
- const { fireDelay, restore } = makeTimerBed();
- const relayStore = makeStore([{ id: "relay", name: "Relay", order: 0 }]);
- const tauri = installEchoTauri("pk-p2a-3");
-
- // storedHead starts as the pre-existing relay head; updated to our event on publish.
- let storedHead = [tauri.mintHead(relayStore, 100, "evt-existing")];
-
- // Bootstrap fetch: call 1 rejects. Subsequent calls: return storedHead.
- let fetchCalls = 0;
- mock.method(relayClient, "fetchEvents", () => {
- fetchCalls++;
- if (fetchCalls === 1) return Promise.reject(new Error("relay timeout"));
- return Promise.resolve(storedHead);
- });
- mock.method(relayClient, "publishEvent", (e) => {
- publishCalls++;
- storedHead = [e]; // relay retains our event
- return Promise.resolve();
- });
-
- try {
- const manager = new SyntheticWholeBlobManager("pk-p2a-3", RELAY);
- const adopted = [];
- manager.setOnRemoteAdopted((r) => adopted.push(r));
-
- // Bootstrap fails: bootstrapFailed=true, bootstrapResolved=true.
- // No releaseDeferred edit to schedule (nothing was queued during bootstrap).
- await manager.bootstrap(nonEmptyStore());
-
- // User clicks after failed bootstrap — publish() sees bootstrapResolved=true
- // so it schedules immediately with publishBaseline={0,""} (lastRemoteHead
- // was never advanced because bootstrap failed).
- manager.publishSections(
- makeStore([{ id: "click-3", name: "Click", order: 0 }]),
- );
-
- await fireDelay(2000);
- for (let i = 0; i < 100; i++) await Promise.resolve();
-
- // Pre-publish fetch returns {100,"evt-existing"}: remoteAdvancedSince(true).
- // bootstrapFailed+empty-baseline exception: fold the head in and publish
- // above it instead of adopting. After publish, storedHead = our event;
- // confirmation confirms us.
- // Without the fix: hard-adopt fires, publishCalls=0, adopted.length=1.
- assert.equal(
- adopted.length,
- 0,
- "click must not be adopted against empty baseline",
- );
- assert.ok(publishCalls >= 1, "click must publish above the relay head");
- assert.equal(
- manager.getPendingStore(),
- null,
- "pending cleared after publish",
- );
- manager.destroy();
- } finally {
- tauri.restore();
- restore();
- mock.reset();
- }
-});
diff --git a/desktop/src/features/sidebar/lib/wholeBlobSyncManager.ts b/desktop/src/features/sidebar/lib/wholeBlobSyncManager.ts
index 9a912f1b76..8d989fb1d6 100644
--- a/desktop/src/features/sidebar/lib/wholeBlobSyncManager.ts
+++ b/desktop/src/features/sidebar/lib/wholeBlobSyncManager.ts
@@ -214,9 +214,16 @@ export class WholeBlobSyncManager {
// fetchOwnBlobBeforePublish so an edit whose baseline is {0,""} because
// bootstrap failed still publishes ABOVE the first head it discovers rather
// than adopting it away. (For a successful bootstrap, releaseDeferred re-freezes
- // publishBaseline to lastRemoteHead, so the baseline is not {0,""} at doPublish
- // and this flag is never consulted.)
+ // publishBaseline to the bootstrap-result head, so the baseline is not {0,""}
+ // at doPublish and this flag is never consulted.)
private bootstrapFailed = false;
+ // Disarmed once any head is independently observed after a failed bootstrap
+ // (live relay subscription or a reconnect/periodic fetch). When disarmed, the
+ // failed-bootstrap exception in fetchOwnBlobBeforePublish is suppressed: the
+ // independently-observed head is already a genuine remote advance that the
+ // normal remoteAdvancedSince check correctly adopts — the exception must not
+ // override that by treating the head as a "first unknown base".
+ private bootstrapFailedExternalHeadObserved = false;
// Event ids we signed and sent to the relay but whose ACK never arrived (the
// publish promise rejected as a timeout/socket error after the frame left).
// The relay MAY have accepted such a write, so if a later cycle's pre-publish
@@ -267,6 +274,13 @@ export class WholeBlobSyncManager {
// decrypt it, so seed-publish is blocked even when the payload is
// unreadable (e.g. wrong key).
this.recordRemoteHead(event.created_at, event.id);
+ // A head was independently observed via a non-bootstrap fetch (reconnect,
+ // periodic refresh, etc.). Disarm the failed-bootstrap exception so this
+ // head is treated as a genuine remote advance rather than a "first unknown
+ // base" — the normal remoteAdvancedSince check handles it from here.
+ if (this.bootstrapFailed) {
+ this.bootstrapFailedExternalHeadObserved = true;
+ }
const result = await this.decryptAndParse(event);
if (!result) {
return { status: "failed", createdAt: event.created_at };
@@ -354,21 +368,32 @@ export class WholeBlobSyncManager {
/**
* Release any edit that was deferred until bootstrap resolved. Called by
- * bootstrap() after it sets bootstrapResolved=true. If a pending edit exists
- * with no timer or in-flight cycle, re-freeze publishBaseline from the
- * now-established lastRemoteHead and schedule the normal 2s debounce.
+ * bootstrap() after it sets bootstrapResolved=true, passing the head that
+ * bootstrap itself observed (the canonical head at the moment bootstrap
+ * completed — NOT the mutable lastRemoteHead, which subscribeLive may advance
+ * at any time). If a pending edit exists with no timer or in-flight cycle,
+ * re-freeze publishBaseline to canonicalMax(queueTimeBaseline, bootstrapResultHead)
+ * and schedule the normal 2s debounce.
*
- * Re-freezing the baseline here (rather than leaving it at the {0,""} it had
- * when publish() was called during unresolved bootstrap) is the P2a fix for
- * the success path: the edit's comparison point becomes the head that
- * bootstrap just established, so the pre-publish fetch for the same head
- * returns "not advanced" and the edit publishes above it correctly. A peer
- * head that arrives BETWEEN bootstrap resolution and doPublish is correctly
- * seen as a genuine advance (its timestamp exceeds the just-frozen baseline)
- * and is adopted per normal LWW. For a failed bootstrap, lastRemoteHead stays
- * {0,""} and the bootstrapFailed flag enables the doPublish-time exception.
+ * Re-freezing the baseline here is the P2a fix for the success path: the
+ * edit's comparison point becomes the head bootstrap established, so the
+ * pre-publish fetch for the same head returns "not advanced" and the edit
+ * publishes above it correctly.
+ *
+ * Using the bootstrap-result snapshot (not mutable lastRemoteHead) is critical:
+ * subscribeLive can advance lastRemoteHead while bootstrap is unresolved. A
+ * live peer head arriving AFTER the click but BEFORE bootstrap resolves is
+ * never incorporated into the editable store while a local edit is pending;
+ * using mutable lastRemoteHead here would fold that live head into the baseline,
+ * making the pre-publish check see equality and publish the pre-head blob OVER
+ * the peer's write. With the bootstrap-result snapshot, that live peer head
+ * remains a genuine advance (its timestamp exceeds the just-frozen baseline)
+ * and is adopted per normal LWW when the pre-publish fetch returns it.
+ *
+ * For a failed bootstrap, bootstrapResultHead is {0,""} and the bootstrapFailed
+ * flag enables the doPublish-time exception.
*/
- private releaseDeferred(): void {
+ private releaseDeferred(bootstrapResultHead: PublishBaseline): void {
if (
!this.destroyed &&
this.pendingStore !== null &&
@@ -376,11 +401,12 @@ export class WholeBlobSyncManager {
this.retryTimer === null &&
!this.publishInFlight
) {
- // Re-freeze the baseline to the head now known from bootstrap. For a
- // successful bootstrap this is the fetched head; for a failed bootstrap
- // lastRemoteHead is still {0,""} (the bootstrapFailed exception handles
- // that case separately in fetchOwnBlobBeforePublish).
- this.publishBaseline = { ...this.lastRemoteHead };
+ // Re-freeze the baseline to the head established by bootstrap. For a
+ // successful bootstrap this is the exact head returned by the fetch
+ // (snapshotted before runBootstrap ran, so subscribeLive cannot race it);
+ // for a failed bootstrap bootstrapResultHead is {0,""} (the bootstrapFailed
+ // exception handles that case in fetchOwnBlobBeforePublish).
+ this.publishBaseline = { ...bootstrapResultHead };
this.debounceTimer = window.setTimeout(() => {
this.debounceTimer = null;
this.startCycle();
@@ -606,15 +632,24 @@ export class WholeBlobSyncManager {
// Failed-bootstrap exception (P2a): the edit was queued while bootstrap
// was in-flight and bootstrap subsequently FAILED — no head was ever
// fetched, so publishBaseline and lastRemoteHead were never advanced.
- // releaseDeferred() re-freezes publishBaseline from lastRemoteHead, but
- // when bootstrap failed that is still {0,""}. Any head the relay returns
- // here is the TRUE first established baseline; publish above it rather
- // than adopting it away. (When bootstrap SUCCEEDS, releaseDeferred sets
- // publishBaseline to the fetched head before scheduling the timer, so
- // this branch is not reached for successful bootstraps whose head was
- // already known when the debounce fired.)
+ // releaseDeferred() re-freezes publishBaseline from the bootstrap-result
+ // head, but when bootstrap failed that is still {0,""}. The FIRST head
+ // the relay returns at pre-publish time is the TRUE first established
+ // baseline; publish above it rather than adopting it away.
+ //
+ // Guard: this exception fires only when no external head was independently
+ // observed after the edit was queued (subscribeLive or a reconnect/periodic
+ // fetch set bootstrapFailedExternalHeadObserved). An independently-observed
+ // head is a genuine post-click remote advance — the normal
+ // remoteAdvancedSince check above correctly adopts it.
+ //
+ // (For a successful bootstrap, releaseDeferred sets publishBaseline to the
+ // fetched head before scheduling the timer, so this branch is not reached
+ // for successful bootstraps whose head was already known when the debounce
+ // fired.)
if (
this.bootstrapFailed &&
+ !this.bootstrapFailedExternalHeadObserved &&
this.publishBaseline.createdAt === 0 &&
this.publishBaseline.eventId === ""
) {
@@ -872,6 +907,16 @@ export class WholeBlobSyncManager {
void this.decryptAndParse(event).then((result) => {
if (result) {
this.recordRemoteHead(result.createdAt, result.eventId);
+ // A live head was independently observed (not via a pre-publish
+ // fetch). If bootstrap previously failed and a pending edit's
+ // failed-bootstrap exception could still fire, disarm it: this live
+ // head is a genuine post-click remote advance — the normal
+ // remoteAdvancedSince check in fetchOwnBlobBeforePublish handles it
+ // correctly (adopts the advancing head rather than treating it as
+ // a "first unknown base" to publish above).
+ if (this.bootstrapFailed) {
+ this.bootstrapFailedExternalHeadObserved = true;
+ }
onUpdate(result);
}
});
@@ -899,19 +944,41 @@ export class WholeBlobSyncManager {
if (fetchResult.status === "failed") {
this.bootstrapFailed = true;
}
+ // Snapshot the bootstrap result head as the baseline for releaseDeferred.
+ // We derive this from fetchResult directly — NOT from lastRemoteHead —
+ // because subscribeLive may have updated lastRemoteHead with a live peer
+ // head that arrived during the bootstrap fetch. That live head must remain
+ // a genuine remote advance after bootstrap resolves; folding it into the
+ // baseline would make the pre-publish check see equality and publish over
+ // it. fetchResult carries exactly the head bootstrap itself fetched (or
+ // nothing, if the relay was absent or the fetch failed).
+ const bootstrapResultHead: PublishBaseline =
+ fetchResult.status === "found"
+ ? { createdAt: fetchResult.createdAt, eventId: fetchResult.eventId }
+ : { createdAt: 0, eventId: "" };
const result = runBootstrap({
fetchResult,
lastHead: this.lastRemoteCreatedAt,
localStore,
isLocalNonEmpty: this.config.isLocalNonEmpty,
- publishFn: (s) => this.publish(s),
+ // Absent-bootstrap seed-publish guard (Defect 1 / Thufir pass-4 finding 1):
+ // when the relay confirms absent and local state is non-empty, runBootstrap
+ // calls publishFn(localStore) to seed the user's local blob to the relay.
+ // But if a pending edit already exists (the user clicked during the async
+ // fetch), that edit IS the seed intent — do NOT call publish(localStore),
+ // which would bump the generation and overwrite the pending edit's outbox
+ // entry with the stale mount snapshot.
+ publishFn: (s) => {
+ if (this.pendingStore === null) this.publish(s);
+ },
});
// Mark bootstrap resolved BEFORE returning. The hook's .then() runs
// synchronously on the resolved promise, so any publish() it calls will
// already see bootstrapResolved=true and schedule the debounce normally.
- // releaseDeferred() covers edits queued via other paths during the fetch.
+ // releaseDeferred() covers edits queued via other paths during the fetch,
+ // using the bootstrap-result snapshot so no post-click live head races in.
this.bootstrapResolved = true;
- this.releaseDeferred();
+ this.releaseDeferred(bootstrapResultHead);
return result;
}
diff --git a/desktop/src/features/sidebar/lib/wholeBlobSyncP2a.shared.test.mjs b/desktop/src/features/sidebar/lib/wholeBlobSyncP2a.shared.test.mjs
new file mode 100644
index 0000000000..224f181b2e
--- /dev/null
+++ b/desktop/src/features/sidebar/lib/wholeBlobSyncP2a.shared.test.mjs
@@ -0,0 +1,449 @@
+// Shared P2a queue-until-bootstrap regression suite for WholeBlobSyncManager.
+//
+// Exports runWholeBlobP2aSuite({ label, Manager, publishEdit, makeNonEmptyStore,
+// makeEditStore, makeRemoteStore }) and is exercised by both lane test files:
+// useChannelSections.test.mjs — sections lane (ChannelSectionSyncManager)
+// useChannelSortPreference.test.mjs — sort lane (ChannelSortSyncManager)
+//
+// Five causal regressions:
+//
+// T1 (load-bearing): fresh device, relay HAS a head, click during unresolved
+// bootstrap → edit publishes above relay head.
+// Mutation: drop `bootstrapStarted &&` guard in publish() → debounce fires
+// against {0,""} baseline → remoteAdvancedSince returns true → adopt.
+//
+// T2: blocked bootstrap → click → live peer head B arrives → bootstrap
+// resolves → B is adopted (normal LWW, not published over).
+// Mutation: releaseDeferred uses mutable lastRemoteHead instead of snapshot
+// → publishBaseline folds B in → pre-publish sees equality → publish over B.
+//
+// T3: non-empty mount, click during blocked bootstrap, relay confirms absent
+// → the edit (not the mount snapshot) is the published payload.
+// Mutation: drop `if (this.pendingStore === null)` guard in bootstrap's
+// publishFn → seed calls publish(localStore), bumps generation, overwrites
+// outbox with stale mount snapshot → edit lost.
+//
+// T4: failed bootstrap → click → first pre-publish fetch throws → live peer
+// head arrives via subscribeToX → retry must ADOPT, never publish over it.
+// Mutation: drop `!this.bootstrapFailedExternalHeadObserved` from the
+// failed-bootstrap exception → exception fires, publishes over the peer head.
+//
+// T5 (Kalvin's original sequence): failed bootstrap → click → no live head →
+// pre-publish fetch finds a relay head → edit publishes above it.
+// Mutation: remove the entire bootstrapFailed exception block from
+// fetchOwnBlobBeforePublish → hard-adopt against {0,""} fires, click lost.
+
+import assert from "node:assert/strict";
+import test, { mock } from "node:test";
+
+import { relayClient } from "@/shared/api/relayClient";
+import {
+ makeHookTimerBed,
+ installEchoTauri,
+} from "./sidebarSyncTestHelpers.mjs";
+
+const RELAY = "wss://r.p2a";
+
+/**
+ * Register P2a queue-until-bootstrap causal regressions for a single lane.
+ *
+ * @param {object} opts
+ * @param {string} opts.label — Lane label used in test names ("sections"|"sort")
+ * @param {Function} opts.Manager — Concrete manager class (ChannelSectionSyncManager|ChannelSortSyncManager)
+ * @param {Function} opts.publishEdit — (manager, store) => void — calls the lane publish method
+ * @param {Function} opts.subscribe — (manager, cb) => Promise — calls subscribeToX
+ * @param {Function} opts.makeNonEmptyStore — () => non-empty store (for mount / localStore arg)
+ * @param {Function} opts.makeEditStore — () => store representing the user's click
+ * @param {Function} opts.makeMountStore — () => stale mount snapshot (distinct from edit)
+ * @param {Function} opts.makeRemoteStore — () => remote head store (for relay mock)
+ */
+export function runWholeBlobP2aSuite({
+ label,
+ Manager,
+ publishEdit,
+ subscribe,
+ makeNonEmptyStore,
+ makeEditStore,
+ makeMountStore,
+ makeRemoteStore,
+}) {
+ // T1: load-bearing fresh-device case.
+ // Mutation: revert publish() guard to unconditional timer scheduling.
+ // Expected: edit publishes above relay head (publishCalls >= 1, adopted = 0).
+ test(`P2a ${label}: click during unresolved bootstrap on fresh device publishes above the relay head`, async () => {
+ let releaseBootstrap = null;
+ let publishCalls = 0;
+ const { fireDelay, restore } = makeHookTimerBed();
+ const tauri = installEchoTauri(`pk-p2a-t1-${label}`);
+
+ let storedHead = [
+ tauri.mintHead(makeRemoteStore(), 200, `evt-relay-${label}`),
+ ];
+ let fetchCalls = 0;
+ mock.method(relayClient, "fetchEvents", () => {
+ fetchCalls++;
+ if (fetchCalls === 1)
+ return new Promise((res) => {
+ releaseBootstrap = () => res(storedHead);
+ });
+ return Promise.resolve(storedHead);
+ });
+ mock.method(relayClient, "publishEvent", (e) => {
+ publishCalls++;
+ storedHead = [e];
+ return Promise.resolve();
+ });
+
+ try {
+ const manager = new Manager(`pk-p2a-t1-${label}`, RELAY);
+ const adopted = [];
+ manager.setOnRemoteAdopted((r) => adopted.push(r));
+
+ // Bootstrap blocked; click during unresolved bootstrap.
+ const bootstrapPromise = manager.bootstrap(makeNonEmptyStore());
+ publishEdit(manager, makeEditStore());
+
+ // Release bootstrap → bootstrapResolved=true, releaseDeferred snaps
+ // publishBaseline to {200,"evt-relay-