Files
radicle-explorer/explorer/http-client/lib/compat.ts
T
Daniel Norman 0dc1e557a5 Move repo facts outside the project payload
Repos need not carry a `xyz.radicle.project` payload; the default
branch can come from `xyz.radicle.crefs` alone. Such repos got no
counts and no default branch, and failed schema validation in
explorer, which broke the parse of a whole listing.

httpd:
- Add top-level `cobs` with issue, patch and bucketed release counts
- Add top-level `defaultBranch`, qualified like the crefs payload
- Omit a count the node cannot determine instead of reporting zero
- Drop `head`: the tip is `refs[defaultBranch]`
- Keep counts in the project payload for older clients until a
  major release
- Keep the project payload when a COB read fails
- Keep `meta.releases` a single count: delegate + other
- Log and skip canonical ref rules and refs that fail to read,
  so one bad ref doesn't drop the default branch tip

explorer:
- Model `cobs` and `defaultBranch`, adapting older nodes to one shape
- Resolve the default branch from the crefs HEAD on older nodes
- Collect the shims for older httpd in http-client/lib/compat.ts,
  each naming the httpd release that the client can drop it with
- Add `defaultBranchTip`, replacing `head`, with a fallback to the
  legacy `meta.head`
- Make the project payload and its legacy meta optional
- Fall back to the RID for the repo name, and show a placeholder
  for a missing name in repo cards and the header
- Hide a counter instead of reading a missing count as zero
- Fall back to a full-page check for "More" without counts
- Add a `repo.sourceUnavailable` route for repos without a
  default branch or tip
- Browse explicit revisions and offer the branch selector without
  a default branch
- List these repos in repo grids and on the landing page
- Say the node is older or built without releases
- Scope release pages in the client, so views never call the shims
- Read OG worker repos through the same compat shims
- Share the repo name and default branch helpers with the OG worker

CI:
- Add check:open-graph, a wrangler deploy dry run
- Run it in GitHub CI, Buildkite and check:ci
- Install explorer's dependencies first: the worker
  resolves compat.ts imports, such as zod, from there

Tests:
- Test that a build without releases omits the count
- Test that one failing rule keeps the others
- Test the repo shape across httpd versions and the release shims
- Test `hasMorePages` and `repoName`
- Test repos without a project payload end to end
2026-10-29 18:21:09 +01:00

161 lines
5.5 KiB
TypeScript

// Shims that let this client read older httpd releases, so the app only ever
// sees one shape. See docs/adr/0001 and docs/api-changes.md.
//
// Each shim has a "Compat:" comment that says what older httpd does and the
// last httpd release that needs it: "Client: remove when explorer drops httpd
// 0.30". Fallbacks to an older endpoint stay in the client method that calls
// the endpoint, with the same comment.
import type { Release } from "./repo/release.js";
import type { ReleaseScope } from "./repo.js";
import type { Author } from "./shared.js";
import type { z } from "zod";
import { number, object, record, string } from "zod";
// Compat: older httpd reports the issue and patch counts and the default
// branch tip inside the project payload, and the release count as a single
// number. Client: remove when explorer drops httpd 0.30.
export const legacyMetaSchema = object({
head: string(),
patches: object({
open: number(),
draft: number(),
archived: number(),
merged: number(),
}),
issues: object({
open: number(),
closed: number(),
}),
releases: number().optional(),
});
// Compat: older httpd names the default branch only in the project payload,
// so a repo without one has none, even when the symbolic `HEAD` of its
// `xyz.radicle.crefs` payload names it. It sends that payload, so the client
// resolves `HEAD` itself. A malformed payload counts as missing, so that the
// repo still parses. Client: remove when explorer drops httpd 0.30.
export const crefsPayloadSchema = object({
data: object({ symbolic: record(string(), string()).optional() }),
})
.optional()
.catch(undefined);
type LegacyProject =
| {
data: { defaultBranch: string };
meta?: z.infer<typeof legacyMetaSchema>;
}
| undefined;
type LegacyRefs<T> =
{ tags: Record<string, T>; refs: Record<string, string> } | undefined;
// Compat: older httpd, see `legacyMetaSchema` and `crefsPayloadSchema`. It
// reports the counts and the default branch only in the project payload.
// Client: remove when explorer drops httpd 0.30.
export function legacyRepoFields<T>(
project: LegacyProject,
crefs: z.infer<typeof crefsPayloadSchema>,
refs: LegacyRefs<T>,
) {
const meta = project?.meta;
const defaultBranch = legacyDefaultBranch(project, crefs);
return {
cobs: meta
? { patches: meta.patches, issues: meta.issues, releases: undefined }
: undefined,
releaseCount: meta?.releases,
defaultBranch,
refs: legacyRefs(project, defaultBranch, refs),
};
}
// httpd before 0.25.0 sends no canonical refs, and 0.25.0 leaves the default
// branch out of them unless the repo has a crefs payload. Both report the tip
// as `meta.head`, so add it to the canonical refs.
function legacyRefs<T>(
project: LegacyProject,
defaultBranch: string | undefined,
refs: LegacyRefs<T>,
) {
const head = project?.meta?.head;
if (
head === undefined ||
defaultBranch === undefined ||
refs?.refs[defaultBranch] !== undefined ||
// `meta.head` is the tip of the project's default branch, which a crefs
// HEAD may name differently.
defaultBranch !== `refs/heads/${project?.data.defaultBranch}`
) {
return refs;
}
return {
tags: refs?.tags ?? {},
refs: { ...refs?.refs, [defaultBranch]: head },
};
}
// Like heartwood's `Doc::default_branch`: the crefs `HEAD`, which may point
// at another symbolic ref, else the project payload. Only a branch counts.
function legacyDefaultBranch(
project: { data: { defaultBranch: string } } | undefined,
crefs: z.infer<typeof crefsPayloadSchema>,
): string | undefined {
const symbolic = crefs?.data.symbolic;
if (symbolic?.HEAD !== undefined) {
const seen = new Set<string>();
let target: string | undefined = symbolic.HEAD;
while (target !== undefined && !seen.has(target)) {
if (target.startsWith("refs/")) {
return target.startsWith("refs/heads/") ? target : undefined;
}
seen.add(target);
target = symbolic[target];
}
return undefined;
}
return project ? `refs/heads/${project.data.defaultBranch}` : undefined;
}
// Compat: older httpd ignores `scope` and only knows `allAuthors`, which
// asks for every author. Client: remove when explorer drops httpd 0.30.
export function legacyReleasesQuery(
scope: ReleaseScope | undefined,
): Record<string, boolean> {
return scope === "untrusted" ? { allAuthors: true } : {};
}
// Compat: older httpd answers the untrusted scope with every author, so
// keep only the releases that no delegate created. A no-op on newer nodes.
// Client: remove when explorer drops httpd 0.30.
export function legacyScopeReleases(
releases: Release[],
delegates: Author[],
scope: ReleaseScope,
): Release[] {
if (scope === "trusted") {
return releases;
}
const delegateIds = new Set(delegates.map(d => d.id));
return releases.filter(r => !delegateIds.has(r.creator.id));
}
// Compat: older httpd reports no release buckets, so look for others'
// releases in the first page of every author. A full page means others may
// come later, and only the filter depends on this, so a full page or a failed
// fetch counts as having others. Client: remove when explorer drops httpd
// 0.30.
export async function legacyHasOtherReleases(
getPage: () => Promise<Release[]>,
delegates: Author[],
perPage: number,
): Promise<boolean> {
const everyAuthor = await getPage().catch(() => undefined);
if (everyAuthor === undefined || everyAuthor.length === perPage) {
return true;
}
return legacyScopeReleases(everyAuthor, delegates, "untrusted").length > 0;
}