Files
Oleg Isonen d78e770d82 perf: Keep article indexing out of publish API requests (#6427)
Ref #6416

The publish workflow was doing too much work in one short-lived API
request. This moves content indexing into the build runner and keeps
article bodies out of hosted/SSR sync.

## What changed

- The build-bundle API can return the lightweight project bundle without
preparing a full content index.
- The CLI downloads required assets directly from storage and builds the
index locally for SSG. Asset downloads remain bounded to 10 concurrent
requests.
- Hosted/SSR sync skips Markdown and MDX article downloads and does not
compile an article index. It still downloads regular assets needed by
the build.
- Markdown body queries use stored frontmatter and fetch only the
requested article at runtime. MDX still needs source downloads and
compilation for SSG.
- Public MCP and content-engine docs explain the Markdown/MDX tradeoff.

This removes article parsing and article-body transfer from the tRPC
request. SSG still downloads article sources from storage and compiles
MDX in the build runner. Hosted runtime article reads remain on demand.

## Checklist

- [x] Keep the tRPC bundle request limited to project metadata and
necessary records.
- [x] Prepare SSG content indexes in the build runner; keep hosted
article bodies out of sync.
- [x] Cover no-body query preparation, direct asset reads, and
SSG-versus-hosted sync behavior with regression tests.
- [x] Update public CLI, MCP, and content-engine guidance.
- [x] Regenerate and verify fixtures.
- [x] Run workspace tests, typechecks, lint, formatting,
package-boundary, generated API/docs, and fixture-size checks.
- [ ] Verify the deployed publish flow has comfortable margin below the
hosted API timeout.

## Verification

- `pnpm -r --workspace-concurrency=1 test`: passed, including all 3,082
Builder tests and 1,150 CLI tests.
- `pnpm -r typecheck`, `pnpm lint`, `pnpm check:package-boundaries`,
`pnpm check:generated-api`, `pnpm check:generated-docs`, `pnpm
check:fixture-sizes`, and `pnpm exec oxfmt --check`: passed.
- The new hosted article-download regression test was confirmed to fail
with the old behavior and pass after the fix.
- A deployed end-to-end publish timing check is still needed, so this PR
remains draft.
2026-09-26 11:56:37 +02:00

240 lines
6.8 KiB
TypeScript

import { describe, expect, test } from "vitest";
import {
compileDocumentSourceGraph,
createDocumentSourceUrl,
DocumentSourceCompilationError,
} from "./source-compiler";
const documents = [
{
id: "post",
documentUrl: "https://content.webstudio.test/posts/hello.json",
revision: "post-r1",
contentRef: "content:post",
format: "json" as const,
source:
'{"author":{"$ref":"../authors/ada.md#frontmatter"},"body":{"$ref":"../authors/ada.md#body"}}',
},
{
id: "author",
documentUrl: "https://content.webstudio.test/authors/ada.md",
revision: "author-r1",
contentRef: "content:author",
format: "markdown" as const,
source:
"---\nname: Ada\navatar:\n $ref: ../media/ada.json#/url\n---\nWriter.\n",
},
{
id: "avatar",
documentUrl: "https://content.webstudio.test/media/ada.json",
revision: "avatar-r1",
contentRef: "content:avatar",
format: "json" as const,
source: '{"url":"/ada.png"}',
},
];
describe("document source graph compiler", () => {
test("rejects invalid reference markers in supplied frontmatter", async () => {
await expect(
compileDocumentSourceGraph({
documents: [
{
...documents[1],
source: "---\nname: Ada\n---\nBody",
frontmatter: { avatar: { $ref: 42 } },
},
],
})
).rejects.toMatchObject({
code: "DOCUMENT_ANALYSIS_FAILED",
documentId: "author",
cause: {
code: "INVALID_REFERENCE_MARKER",
referenceId: "#frontmatter/avatar",
},
});
});
test.each(["markdown", "mdx"] as const)(
"discovers %s references from supplied frontmatter without reading its body",
async (format) => {
const expected = await compileDocumentSourceGraph({ documents });
const graph = await compileDocumentSourceGraph({
documents: documents.map((document) =>
document.id === "author"
? {
...document,
format,
frontmatter: {
name: "Ada",
avatar: { $ref: "../media/ada.json#/url" },
},
source: {
async *[Symbol.asyncIterator]() {
yield await Promise.reject<Uint8Array>(
new Error("Markdown body must not be downloaded")
);
},
},
}
: document
),
});
expect(graph.edges).toEqual(expected.edges);
expect(graph.nodes).toEqual(
expected.nodes.map((node) =>
node.id === "author" ? { ...node, format } : node
)
);
}
);
test("encodes every Asset path segment as document URL data", () => {
expect(createDocumentSourceUrl("authors/Ada #1/プロフィール.md")).toBe(
"https://content.webstudio.local/authors/Ada%20%231/%E3%83%97%E3%83%AD%E3%83%95%E3%82%A3%E3%83%BC%E3%83%AB.md"
);
});
test("discovers mixed-format references and compiles a deterministic graph", async () => {
const graph = await compileDocumentSourceGraph({
documents: [...documents].reverse(),
concurrency: 2,
});
expect(graph.nodes.map(({ id }) => id)).toEqual([
"author",
"avatar",
"post",
]);
expect(graph.nodes.map(({ id, format }) => [id, format])).toEqual([
["author", "markdown"],
["avatar", "json"],
["post", "json"],
]);
expect(graph.edges).toEqual([
{
sourceId: "author",
referenceId: "#frontmatter/avatar",
reference: {
documentId: "avatar",
revision: "avatar-r1",
representation: { type: "json", path: ["url"] },
},
},
{
sourceId: "post",
referenceId: "#/author",
reference: {
documentId: "author",
revision: "author-r1",
representation: { type: "markdown-frontmatter" },
},
},
{
sourceId: "post",
referenceId: "#/body",
reference: {
documentId: "author",
revision: "author-r1",
representation: { type: "markdown-body" },
},
},
]);
});
test("reports analysis failures with their source document", async () => {
await expect(
compileDocumentSourceGraph({
documents: [
{
...documents[0],
source: '{"author":{"$ref":42}}',
},
],
concurrency: 1,
})
).rejects.toMatchObject({
code: "DOCUMENT_ANALYSIS_FAILED",
documentId: "post",
cause: expect.objectContaining({
code: "INVALID_REFERENCE_MARKER",
referenceId: "#/author",
}),
} satisfies Partial<DocumentSourceCompilationError>);
});
test("ignores invalid catalog entries outside the selected root closure", async () => {
const graph = await compileDocumentSourceGraph({
documents: [
{ ...documents[0], source: '{"title":"Selected"}' },
{
...documents[1],
id: "unrelated-one",
documentUrl: "https://content.webstudio.test/unrelated.md",
},
{
...documents[1],
id: "unrelated-two",
documentUrl: "https://content.webstudio.test/unrelated.md",
},
],
rootIds: ["post"],
});
expect(graph.nodes.map(({ id }) => id)).toEqual(["post"]);
});
test("reports every reachable reference failure", async () => {
await expect(
compileDocumentSourceGraph({
documents: [
{
...documents[0],
id: "first",
documentUrl: "https://content.webstudio.test/posts/first.json",
source: '{"author":{"$ref":"../missing/first.md#frontmatter"}}',
},
{
...documents[0],
id: "second",
documentUrl: "https://content.webstudio.test/posts/second.json",
source: '{"author":{"$ref":"../missing/second.md#frontmatter"}}',
},
],
rootIds: ["first", "second"],
})
).rejects.toMatchObject({
errors: [
{ code: "TARGET_NOT_FOUND", sourceDocumentId: "first" },
{ code: "TARGET_NOT_FOUND", sourceDocumentId: "second" },
],
});
});
test("rejects an already-cancelled compilation before reading sources", async () => {
const controller = new AbortController();
controller.abort("cancelled");
let read = false;
await expect(
compileDocumentSourceGraph({
documents: [
{
...documents[0],
source: {
async *[Symbol.asyncIterator]() {
read = true;
yield new Uint8Array();
},
},
},
],
concurrency: 1,
signal: controller.signal,
})
).rejects.toMatchObject({ code: "REQUEST_CANCELLED" });
expect(read).toBe(false);
});
});