diff --git a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs index 31d8a6e087..15cf06da3e 100644 --- a/desktop/src/features/sidebar/lib/useChannelSections.test.mjs +++ b/desktop/src/features/sidebar/lib/useChannelSections.test.mjs @@ -4,6 +4,7 @@ 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"); @@ -13,6 +14,7 @@ const { readChannelSectionsOutbox, } = await import("./channelSectionsStorage.ts"); const { useChannelSections } = await import("./useChannelSections.ts"); +const { ChannelSectionSyncManager } = await import("./channelSectionsSync.ts"); const { normalizeRelayUrl } = await import("@/shared/lib/normalizeRelayUrl"); const dom = new JSDOM("", { @@ -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-