Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit c3f0e4bdc2b5068589d898ed2c0a51526fcccae2
parent 79bef42cb078cf903a38285d855607e37d7e0e07
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 03:32:56 -0400

hub: the subs manifest — a 404 is an empty manifest, a real error is retried once and fails its archive (review fix 4)

Replaces 79ca2b0f's retry: false. A member with no live chat has no subs
manifest; its 404 now resolves to an empty one at once, the way posts
does, so the archive is ready without a second request. Any other error
(network, 5xx) keeps one retry (explicit on this query) and then fails the
archive like a failed manifest or page: the chip says failed, offers
Retry, and the "N of M archives answered" line names it. Before, a real
error read "ready" with that member's live chat silently missing.
A member's /subs/* is CORS-headed by its _headers, 404s included (checked
live on jeralyzer.pages.dev), so a missing file reads as a 404, not a
network error. e2e: a 500 is asked twice, fails with Retry, recovers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/components/SearchDataContext.tsx | 48++++++++++++++++++++++++++++++++++--------------
Mexport/e2e-hub/federated-search.spec.ts | 43+++++++++++++++++++++++++++++++++++++------
2 files changed, 71 insertions(+), 20 deletions(-)

diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx @@ -27,6 +27,7 @@ import type { DisplaySummary } from "../lib/transcripts"; import type { Manifest, SubsManifest } from "../lib/manifest"; import type { PostsManifest } from "../lib/posts"; import { readerFor } from "../lib/archive/readers"; +import { ArchiveHttpError } from "../lib/archive/reader"; import { DEFAULT_GROUP_FALLBACK_ID, FALLBACK_GROUP, @@ -344,16 +345,33 @@ export function MultiSiteDataProvider({ // contributions land in the search in one re-run. A posts 404 (no posts // corpus) resolves to an empty manifest, not a failure. // - // The subs manifest is asked for ONCE (`retry: false`). A member with no live - // chat has none, and its 404 is its answer: the client default (one retry, - // after ~1 s) only held that archive at "loading" for another second, since - // readiness waits for this query to settle and an error settles it. + // A subs 404 is the same answer — a member with no live chat has no subs + // manifest — so it resolves to an empty one at once. It used to throw, and + // the client's retry held that archive at "loading" for another second. Any + // OTHER error (network, 5xx) is retried once, then fails the archive like a + // failed manifest or page: its chip says so and offers Retry. (A member's + // `/subs/*` is CORS-headed by its _headers, 404s included, so a missing file + // reads as a 404 here, not as a network error.) const subsQueries = useQueries({ queries: sites.map((s) => ({ queryKey: ["subs-manifest", s.origin], - queryFn: () => readerFor(s.origin).readSubsSiteManifest(), + queryFn: () => + readerFor(s.origin) + .readSubsSiteManifest() + .catch((err: unknown): SubsManifest => { + if (err instanceof ArchiveHttpError && err.status === 404) { + return { + version: 0, + channels: [], + totalCount: 0, + liveChatTotalCount: 0, + generatedAt: "", + }; + } + throw err; + }), enabled: inScope(s), - retry: false, + retry: 1, })), }); const postsQueries = useQueries({ @@ -393,21 +411,23 @@ export function MultiSiteDataProvider({ const pages = pagesByOrigin.get(s.origin) ?? []; const count = m?.data?.totalCount; const withCount = count === undefined ? base : { ...base, count }; - // A subs manifest that errored still counts as settled (the archive just - // has no live chat in the search); posts never error (404 → empty). - const settled = (q: { isSuccess: boolean; isError: boolean; isFetching: boolean } | undefined) => - !!q && (q.isSuccess || (q.isError && !q.isFetching)); + // Subs and posts manifests resolve a 404 to an empty manifest; posts never + // errors, and a subs error (after its retry) fails the archive below. + const subs = subsQueries[i]; if ( m?.isSuccess && pages.length === m.data.pageCount && pages.every((p) => p.isSuccess) && - settled(subsQueries[i]) && - settled(postsQueries[i]) + subs?.isSuccess && + postsQueries[i]?.isSuccess ) { return { ...withCount, status: "ready" }; } - const fetching = !!m?.isFetching || pages.some((p) => p.isFetching); - const failed = m?.isError ? m : pages.find((p) => p.isError); + const fetching = + !!m?.isFetching || pages.some((p) => p.isFetching) || !!subs?.isFetching; + const failed = m?.isError + ? m + : (pages.find((p) => p.isError) ?? (subs?.isError ? subs : undefined)); if (failed && !fetching) { return { ...withCount, diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts @@ -8,7 +8,8 @@ import { expect, test, type Page, type Route } from "@playwright/test"; // still render), Retry, progressive readiness (a slow member does not hold the // fast one's results back), and a card naming its source archive in text. // Release 10 (L1): a member with no live chat is ready on its first subs -// answer (a 404 is not retried); the official archives follow the homepage's +// answer (a 404 is an empty manifest), and a subs manifest that cannot be read +// fails its archive with Retry; the official archives follow the homepage's // order (hub-summary.json's) on the cards and the chips, and an archive with no // accent wears ONE colour — its homepage card's — on its card, its chip and its // results; /ask searches only the archives the chips leave in. @@ -67,12 +68,13 @@ function pageOf(m: Member) { } // Every request to a member origin is counted; `pages` decides how its -// summaries page is answered, `subs` whether it has a subs manifest at all. +// summaries page is answered, `subs` whether it has a subs manifest at all +// ("missing": a 404) or cannot serve it ("error": a 500). type PageMode = "ok" | "abort" | "hold"; type MemberMock = { requests: string[]; pages: PageMode; - subs: "ok" | "missing"; + subs: "ok" | "missing" | "error"; held: Route[]; }; @@ -92,6 +94,9 @@ async function mockMember(page: Page, m: Member): Promise<MemberMock> { } return fulfillJson(route, pageOf(m)); } + if (url.pathname === "/subs/manifest.json" && mock.subs === "error") { + return route.fulfill({ status: 500, headers: CORS, body: "" }); + } if (url.pathname === "/subs/manifest.json" && mock.subs === "ok") { return fulfillJson(route, { version: 4, @@ -304,9 +309,9 @@ test.describe("hub federated search — scope, per-archive state, attribution", mocks.b.subs = "missing"; await page.goto("/"); - // Ready means every feed of the archive has settled, subs included. With a - // retry, the 404 settled only on the second answer (~1 s later), so the - // chip turned ready with two requests on record; now there is one. + // Ready means every feed of the archive is in, subs included. A 404 is an + // empty subs manifest, at once. It used to be an error, retried after ~1 s, + // so the chip turned ready with two requests on record; now there is one. await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); await expect(resultFrom(page, B)).toHaveCount(1); const subsAsks = (m: MemberMock) => @@ -398,4 +403,30 @@ test.describe("hub federated search — scope, per-archive state, attribution", await expect(ready).toBeVisible(); expect(mocks.b.requests).toContain("/summaries/page-0000.json"); }); + + test("a subs manifest that cannot be read is retried once, then fails its archive with Retry", async ({ + page, + }) => { + const mocks = await setup(page); + mocks.b.subs = "error"; + await page.goto("/"); + + await expect(chip(page, B)).toHaveAttribute("data-status", "failed"); + await expect( + chip(page, B).getByRole("button", { name: "Retry Origin B" }), + ).toBeVisible(); + const subsAsks = () => + mocks.b.requests.filter((p) => p === "/subs/manifest.json").length; + expect(subsAsks()).toBe(2); + await expect(status(page)).toContainText("1 of 2 archives answered."); + await expect(resultFrom(page, A)).toHaveCount(1); + await expect(resultFrom(page, B)).toHaveCount(0); + + // The member recovers; Retry reads its subs manifest again and it is in. + mocks.b.subs = "ok"; + await chip(page, B).getByRole("button", { name: "Retry Origin B" }).click(); + await expect(chip(page, B)).toHaveAttribute("data-status", "ready"); + await expect(resultFrom(page, B)).toHaveCount(1); + await expect(status(page)).toHaveCount(0); + }); });