From ef90d631727e7a5d66be3aebcb8192d74b72d567 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 31 Aug 2026 22:05:02 -0400 Subject: [PATCH] fix(projects): scope revision reads and advance heads Signed-off-by: John Tennant --- .../projects/projectEnumeration.test.mjs | 33 ++++++++++-- .../features/projects/projectEnumeration.ts | 50 +++++++++++++++---- .../features/projects/projectModels.test.mjs | 2 + .../src/features/projects/projectModels.ts | 2 + .../projects/useAddProjectRepository.ts | 2 + .../projects/useAttachProjectRepository.ts | 2 + 6 files changed, 78 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/projects/projectEnumeration.test.mjs b/desktop/src/features/projects/projectEnumeration.test.mjs index 1b65d9dd2e..e277ee24ea 100644 --- a/desktop/src/features/projects/projectEnumeration.test.mjs +++ b/desktop/src/features/projects/projectEnumeration.test.mjs @@ -82,13 +82,13 @@ test("buildProjectHomeFromFetcher scopes startup lookup to the active channel", extraFilter: { "#buzz-channel": [channelId] }, }); assert.deepEqual(calls[1], { - kinds: [47001], - extraFilter: undefined, - }); - assert.deepEqual(calls[2], { kinds: [30617], extraFilter: { "#buzz-channel": [channelId] }, }); + assert.deepEqual(calls[2], { + kinds: [47001], + extraFilter: { "#a": [`30621:${owner}:relay`] }, + }); assert.deepEqual(calls[3], { kinds: [5], extraFilter: { "#a": [`30621:${owner}:relay`, repositoryAddress] }, @@ -255,6 +255,31 @@ test("buildProjectsFromFetcher scopes the tombstone fetch to announcement coordi }); }); +test("buildProjectsFromFetcher scopes revisions to announced Project coordinates", async () => { + const OWNER = "a".repeat(64); + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 200, + content: "", + tags: [["d", "proj"]], + }; + const revisionCalls = []; + const fetchExhaustively = async (kinds, extraFilter) => { + if (kinds.includes(30621)) return [projectEvent]; + if (kinds.includes(47001)) { + revisionCalls.push(extraFilter); + return []; + } + return []; + }; + + await buildProjectsFromFetcher(fetchExhaustively); + + assert.deepEqual(revisionCalls, [{ "#a": [`30621:${OWNER}:proj`] }]); +}); + test("buildProjectsFromFetcher skips the tombstone fetch when there are no announcements", async () => { let deletionQueried = false; const fetchExhaustively = async (kinds) => { diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts index 96235d0436..9ec7c9aa16 100644 --- a/desktop/src/features/projects/projectEnumeration.ts +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -14,7 +14,7 @@ const PROJECT_ENUMERATION_PAGE_SIZE = 500; // Relays commonly cap filter tag-value lists; chunk `#a` scoping well below // any such cap. -const TOMBSTONE_COORDINATE_CHUNK_SIZE = 100; +const PROJECT_COORDINATE_CHUNK_SIZE = 100; /** Additional server-side scoping merged into every enumeration page. */ export type ProjectEventExtraFilter = { @@ -149,10 +149,10 @@ async function fetchScopedDeletionEvents( for ( let index = 0; index < coordinates.length; - index += TOMBSTONE_COORDINATE_CHUNK_SIZE + index += PROJECT_COORDINATE_CHUNK_SIZE ) { chunks.push( - coordinates.slice(index, index + TOMBSTONE_COORDINATE_CHUNK_SIZE), + coordinates.slice(index, index + PROJECT_COORDINATE_CHUNK_SIZE), ); } @@ -162,6 +162,36 @@ async function fetchScopedDeletionEvents( return pages.flat(); } +async function fetchScopedProjectRevisionEvents( + fetchExhaustively: FetchProjectEventsExhaustively, + projectEvents: RelayEvent[], +): Promise { + const coordinates = [ + ...new Set( + projectEvents.flatMap((event) => { + const coordinate = eventCoordinate(event); + return coordinate ? [coordinate] : []; + }), + ), + ]; + const chunks: string[][] = []; + for ( + let index = 0; + index < coordinates.length; + index += PROJECT_COORDINATE_CHUNK_SIZE + ) { + chunks.push( + coordinates.slice(index, index + PROJECT_COORDINATE_CHUNK_SIZE), + ); + } + const pages = await Promise.all( + chunks.map((chunk) => + fetchExhaustively([KIND_PROJECT_REVISION], { "#a": chunk }), + ), + ); + return pages.flat(); +} + /** * Core fetch-and-build logic for `fetchProjects`, extracted for testability. * @@ -180,12 +210,14 @@ export async function buildProjectsFromFetcher( viewerPubkey?: string | null; } = {}, ): Promise { - const [projectEvents, projectRevisionEvents, repositoryEvents] = - await Promise.all([ - fetchExhaustively([KIND_PROJECT_ANNOUNCEMENT]), - fetchExhaustively([KIND_PROJECT_REVISION]), - fetchExhaustively([KIND_REPO_ANNOUNCEMENT]), - ]); + const [projectEvents, repositoryEvents] = await Promise.all([ + fetchExhaustively([KIND_PROJECT_ANNOUNCEMENT]), + fetchExhaustively([KIND_REPO_ANNOUNCEMENT]), + ]); + const projectRevisionEvents = await fetchScopedProjectRevisionEvents( + fetchExhaustively, + projectEvents, + ); // Tombstones are fetched second (not in parallel) because the `#a` scoping // needs the announcement coordinates; both announcement kinds are small, diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs index 200b4fe608..ff2fda0ea6 100644 --- a/desktop/src/features/projects/projectModels.test.mjs +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -311,11 +311,13 @@ test("addRepositoryToProject promotes a legacy repository route to a project coo legacyProject, attachedRepository, 300, + "f".repeat(64), ); assert.equal(updated.id, `30621:${PROJECT_OWNER}:sprout`); assert.equal(updated.legacy, false); assert.equal(updated.repositories.length, 2); + assert.equal(updated.effectiveRevisionId, "f".repeat(64)); }); test("selectProjectRepository honors a request and falls back to primary", () => { diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts index 3acfe42f52..b533f7e26c 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -629,6 +629,7 @@ export function addRepositoryToProject( project: Project, repository: Repository, createdAt: number, + effectiveRevisionId = project.effectiveRevisionId, ): Project { const projectAddress = `${KIND_PROJECT_ANNOUNCEMENT}:${project.owner}:${project.dtag}`; const repositoryAddresses = [ @@ -645,6 +646,7 @@ export function addRepositoryToProject( ...project, id: projectAddress, createdAt, + effectiveRevisionId, legacy: false, projectAddress, primaryRepositoryAddress: diff --git a/desktop/src/features/projects/useAddProjectRepository.ts b/desktop/src/features/projects/useAddProjectRepository.ts index 4b2f7ac8e2..9fe18a1b12 100644 --- a/desktop/src/features/projects/useAddProjectRepository.ts +++ b/desktop/src/features/projects/useAddProjectRepository.ts @@ -186,6 +186,7 @@ export async function addProjectRepository( project, repository, projectEvent.created_at, + projectEvent.id.toLowerCase(), ), repository, }; @@ -306,6 +307,7 @@ export async function addProjectRepository( project, repository, projectEvent.created_at, + projectEvent.id.toLowerCase(), ), repository, }; diff --git a/desktop/src/features/projects/useAttachProjectRepository.ts b/desktop/src/features/projects/useAttachProjectRepository.ts index a96e7181c0..500e0ea2c6 100644 --- a/desktop/src/features/projects/useAttachProjectRepository.ts +++ b/desktop/src/features/projects/useAttachProjectRepository.ts @@ -80,6 +80,7 @@ async function attachProjectRepository({ project, repository, projectEvent.created_at, + projectEvent.id.toLowerCase(), ), repository, }; @@ -101,6 +102,7 @@ async function attachProjectRepository({ project, repository, projectEvent.created_at, + projectEvent.id.toLowerCase(), ), repository, };