Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/bounded-concurrency-and-loading-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@vayo-hq/schema-engine": patch
"@vayo-hq/cli": patch
"@vayo-hq/server": patch
"@vayo-hq/ui": patch
---

Fixed two real issues found while running `vayo scan`/`vayo export`/the docs
UI against a real, large production API (600+ endpoints):

- `vayo scan`'s route-merge loop and `vayo export`/`vayo diff`'s per-endpoint
override/example/test-script lookups (and the identical logic in
`@vayo-hq/server`'s `GET /api/spec`/`GET /api/diff`) ran one DB round-trip
at a time in a sequential `for...of` loop — safe, but measured taking
minutes against a real remote MongoDB cluster at this scale, easily
mistaken for a hang. Added `mapWithConcurrency` to `@vayo-hq/schema-engine`
(bounded-concurrency `Promise.all`, 20 at a time — fast without firing
hundreds of simultaneous connections at the database) and switched every
one of these call sites to it.
- The docs UI's sidebar and main pane rendered "No endpoints yet"/"No
endpoints captured yet" immediately on load, before the first spec/folders
fetch had actually resolved — indistinguishable from a project with
nothing captured. A large real API can take several real seconds to
answer (see above), so this was a visible false-empty flash every time.
`DocsApp` now tracks whether the initial fetch is still pending and shows
"Loading endpoints…" instead, in the sidebar (`FolderTree`), the main pane,
and Full Docs mode (`FullDocView`).
5 changes: 4 additions & 1 deletion packages/cli/src/commands/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ const diffSpecs = vi.fn();
vi.mock("@vayo-hq/db-mongo", () => ({
createAdapter: () => ({ listApiVersions, listEndpoints, listOverrides }),
}));
vi.mock("@vayo-hq/schema-engine", () => ({ resolveEndpoint: (...args: unknown[]) => resolveEndpoint(...args) }));
vi.mock("@vayo-hq/schema-engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vayo-hq/schema-engine")>();
return { ...actual, resolveEndpoint: (...args: unknown[]) => resolveEndpoint(...args) };
});
vi.mock("@vayo-hq/openapi-compiler", () => ({
compile: (...args: unknown[]) => compile(...args),
diffSpecs: (...args: unknown[]) => diffSpecs(...args),
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
// (docs/07-api-versioning.md).

import type { ResolvedEndpoint } from "@vayo-hq/types";
import { resolveEndpoint } from "@vayo-hq/schema-engine";
import { resolveEndpoint, mapWithConcurrency } from "@vayo-hq/schema-engine";
import { compile, diffSpecs } from "@vayo-hq/openapi-compiler";
import { createAdapter } from "@vayo-hq/db-mongo";
import { requireMongoUri } from "../config.js";

/** See export.ts's identical constant — bounded concurrency instead of a
* plain `Promise.all` so a real, large API's override lookups don't
* overwhelm the database's own connection pool. */
const FETCH_CONCURRENCY = 20;

export interface DiffOptions {
failOnBreaking?: boolean;
}
Expand All @@ -23,8 +28,8 @@ export async function diffCommand(from: string, to: string, options: DiffOptions

async function compileVersion(version: string) {
const endpoints = await db.listEndpoints(version);
const resolved: ResolvedEndpoint[] = await Promise.all(
endpoints.map(async (endpoint) => resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId))),
const resolved: ResolvedEndpoint[] = await mapWithConcurrency(endpoints, FETCH_CONCURRENCY, async (endpoint) =>
resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId)),
);
return compile(resolved, version);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ vi.mock("@vayo-hq/db-mongo", () => ({
listEnvironments,
}),
}));
vi.mock("@vayo-hq/schema-engine", () => ({ resolveEndpoint: (...args: unknown[]) => resolveEndpoint(...args) }));
vi.mock("@vayo-hq/schema-engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vayo-hq/schema-engine")>();
return { ...actual, resolveEndpoint: (...args: unknown[]) => resolveEndpoint(...args) };
});
vi.mock("@vayo-hq/openapi-compiler", () => ({ compile: (...args: unknown[]) => compile(...args) }));
vi.mock("@vayo-hq/server", () => ({ compilePostmanCollection: (...args: unknown[]) => compilePostmanCollection(...args) }));
vi.mock("../config.js", () => ({ requireMongoUri: () => "mongodb://localhost:27017/vayo" }));
Expand Down
21 changes: 14 additions & 7 deletions packages/cli/src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@
import { writeFileSync } from "node:fs";
import path from "node:path";
import type { ExampleDoc, ResolvedEndpoint, TestScriptDoc } from "@vayo-hq/types";
import { resolveEndpoint } from "@vayo-hq/schema-engine";
import { resolveEndpoint, mapWithConcurrency } from "@vayo-hq/schema-engine";
import { compile } from "@vayo-hq/openapi-compiler";
import { compilePostmanCollection } from "@vayo-hq/server";
import { createAdapter } from "@vayo-hq/db-mongo";
import { requireMongoUri } from "../config.js";

/** How many per-endpoint reads (examples, test scripts) run at once. A real
* API can have hundreds of endpoints; sequential (one-at-a-time) awaiting
* measured taking minutes against a real remote MongoDB cluster on a 600+
* endpoint production API — bounded concurrency instead of a plain
* `Promise.all` to avoid overwhelming the database's own connection pool. */
const FETCH_CONCURRENCY = 20;

export interface ExportOptions {
version: string;
format: "openapi" | "postman";
Expand All @@ -23,8 +30,8 @@ export async function exportCommand(options: ExportOptions): Promise<void> {
const db = createAdapter(mongoUri);

const endpoints = await db.listEndpoints(options.version);
const resolved: ResolvedEndpoint[] = await Promise.all(
endpoints.map(async (endpoint) => resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId))),
const resolved: ResolvedEndpoint[] = await mapWithConcurrency(endpoints, FETCH_CONCURRENCY, async (endpoint) =>
resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId)),
);

// The equivalent of swagger-jsdoc's static options.definition.info/servers
Expand All @@ -39,22 +46,22 @@ export async function exportCommand(options: ExportOptions): Promise<void> {
// — shared by both export formats so a team's saved Try It Now responses
// show up in the OpenAPI export exactly as they already did in Postman's.
const pinnedExamples = new Map<string, ExampleDoc[]>();
for (const endpoint of resolved) {
await mapWithConcurrency(resolved, FETCH_CONCURRENCY, async (endpoint) => {
const pinned = (await db.listExamples(endpoint.vayoId)).filter((e) => e.pinned);
if (pinned.length > 0) pinnedExamples.set(endpoint.vayoId, pinned);
}
});

if (options.format === "postman") {
const folders = await db.listFolders(options.version);
const placements = new Map<string, string | null>();
const testScripts = new Map<string, TestScriptDoc>();
for (const endpoint of resolved) {
await mapWithConcurrency(resolved, FETCH_CONCURRENCY, async (endpoint) => {
const folderId = (endpoint as unknown as { folderId?: string | null }).folderId ?? null;
placements.set(endpoint.vayoId, folderId);

const script = await db.getTestScript(endpoint.vayoId);
if (script) testScripts.set(endpoint.vayoId, script);
}
});
const collection = compilePostmanCollection(
`${settings.title} (${options.version})`,
resolved,
Expand Down
13 changes: 10 additions & 3 deletions packages/cli/src/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@

import { scanProject } from "@vayo-hq/ast";
import { createAdapter } from "@vayo-hq/db-mongo";
import { resolveVersion } from "@vayo-hq/schema-engine";
import { resolveVersion, mapWithConcurrency } from "@vayo-hq/schema-engine";
import { loadConfig, requireMongoUri } from "../config.js";

/** How many `upsertStaticResult` writes run at once. A real API can have
* hundreds of routes; sequential (one-at-a-time) awaiting measured taking
* minutes against a real remote MongoDB cluster on a 600+ route production
* API — bounded concurrency instead of a plain `Promise.all` to avoid
* overwhelming the database's own connection pool. */
const UPSERT_CONCURRENCY = 20;

export interface ScanOptions {
config?: string;
}
Expand All @@ -25,7 +32,7 @@ export async function scanCommand(options: ScanOptions): Promise<void> {
const groups = new Set<string>();
const versionsTouched = new Set<string>();
const confirmedVayoIdsByVersion = new Map<string, string[]>();
for (const route of result.routes) {
await mapWithConcurrency(result.routes, UPSERT_CONCURRENCY, async (route) => {
const version = resolveVersion(route.pathTemplate, configuredVersions);
const saved = await db.upsertStaticResult(route, version);
groups.add(route.group);
Expand All @@ -36,7 +43,7 @@ export async function scanCommand(options: ScanOptions): Promise<void> {
console.log(
`merged ${route.method} ${route.pathTemplate} (${version}) — scopes=${JSON.stringify(route.scopes)} middlewareChain=${JSON.stringify(route.middlewareChain)}`,
);
}
});

console.log(`\nvayo: scanned ${result.routes.length} route(s) across ${groups.size} group(s).`);

Expand Down
37 changes: 37 additions & 0 deletions packages/schema-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { CapturedSample, EndpointDoc, OverrideDoc } from "@vayo-hq/types";
import {
detectSchemaChange,
mapWithConcurrency,
mergeCapturedSample,
mergeStaticResult,
resolveAuthRequired,
Expand Down Expand Up @@ -757,3 +758,39 @@ describe("resolveEndpoint", () => {
expect((resolved.responseSchemas["200"] as any).properties.newField).toBeDefined();
});
});

describe("mapWithConcurrency", () => {
it("maps every item and preserves result order regardless of completion order", async () => {
const delays = [30, 10, 20, 0, 15];
const result = await mapWithConcurrency(delays, 3, async (delay, index) => {
await new Promise((resolve) => setTimeout(resolve, delay));
return index * 2;
});
expect(result).toEqual([0, 2, 4, 6, 8]);
});

it("never runs more than `concurrency` items at once", async () => {
let inFlight = 0;
let maxInFlight = 0;
const items = Array.from({ length: 20 }, (_, i) => i);

await mapWithConcurrency(items, 4, async () => {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight--;
});

expect(maxInFlight).toBeLessThanOrEqual(4);
});

it("handles an empty list without error", async () => {
const result = await mapWithConcurrency([], 10, async (x) => x);
expect(result).toEqual([]);
});

it("handles concurrency greater than the item count", async () => {
const result = await mapWithConcurrency([1, 2, 3], 100, async (x) => x * 10);
expect(result).toEqual([10, 20, 30]);
});
});
29 changes: 29 additions & 0 deletions packages/schema-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,3 +541,32 @@ export function resolveEndpoint(

return { ...(result as unknown as EndpointDoc), overridden };
}

/** Bounded-concurrency version of `Promise.all`, used everywhere an
* endpoint list gets resolved (GET /api/spec, /api/diff, `vayo export`,
* `vayo diff`) via one DB round-trip per endpoint (`listOverrides`,
* `listExamples`, etc.). A real, large API can have hundreds of endpoints;
* running that many round-trips fully sequentially is safe but measured
* taking minutes against a real remote MongoDB cluster, and firing them all
* at once via a plain `Promise.all` risks overwhelming the database's own
* connection pool. This runs a fixed number of workers, each pulling the
* next item off a shared index, splitting the difference. */
export async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let nextIndex = 0;

async function worker(): Promise<void> {
while (nextIndex < items.length) {
const index = nextIndex++;
results[index] = await fn(items[index]!, index);
}
}

const workerCount = Math.min(concurrency, items.length);
await Promise.all(Array.from({ length: workerCount }, worker));
return results;
}
28 changes: 17 additions & 11 deletions packages/server/src/routes/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@
// versions.
import { Router } from "express";
import { z } from "zod";
import { resolveEndpoint } from "@vayo-hq/schema-engine";
import { resolveEndpoint, mapWithConcurrency } from "@vayo-hq/schema-engine";
import { compile, diffSpecs, type CompileOptions } from "@vayo-hq/openapi-compiler";
import type { ExampleDoc, ResolvedEndpoint, VayoDbAdapter } from "@vayo-hq/types";
import { requireRole, type VayoAuthedRequest } from "../auth-middleware.js";
import { autoCatchAsyncErrors } from "../error-handling.js";
import type { RouteDeps } from "../server-deps.js";

/** How many per-endpoint reads (overrides, examples) run at once. A real API
* can have hundreds of endpoints; a plain `Promise.all` firing that many
* simultaneous DB round-trips risks overwhelming the database's own
* connection pool — this is what the docs UI itself waits on every load, so
* a slow or stalled `/api/spec` response here is a real, visible loading
* delay, not just a CLI-only concern. */
const FETCH_CONCURRENCY = 20;

/** `compile()`'s `title`/`description`/`servers`/pinned examples, sourced
* from `vayo_settings`/`vayo_environments`/`vayo_examples`
* (docs/03-data-model.md) — the equivalent of swagger-jsdoc's static
Expand All @@ -26,12 +34,10 @@ async function compileOptionsFromDb(db: VayoDbAdapter, resolved: ResolvedEndpoin
.map((env) => ({ url: env.variables.baseUrl!, description: env.name }));

const pinnedExamplesByVayoId = new Map<string, ExampleDoc[]>();
await Promise.all(
resolved.map(async (endpoint) => {
const pinned = (await db.listExamples(endpoint.vayoId)).filter((example) => example.pinned);
if (pinned.length > 0) pinnedExamplesByVayoId.set(endpoint.vayoId, pinned);
}),
);
await mapWithConcurrency(resolved, FETCH_CONCURRENCY, async (endpoint) => {
const pinned = (await db.listExamples(endpoint.vayoId)).filter((example) => example.pinned);
if (pinned.length > 0) pinnedExamplesByVayoId.set(endpoint.vayoId, pinned);
});

const contact =
settings.contactName || settings.contactEmail || settings.contactUrl
Expand Down Expand Up @@ -68,8 +74,8 @@ export function createVersionsRouter({ db, io }: RouteDeps): Router {
router.get("/api/spec", requireRole("viewer"), async (req, res) => {
const version = typeof req.query.version === "string" ? req.query.version : "v1";
const endpoints = await db.listEndpoints(version);
const resolved = await Promise.all(
endpoints.map(async (endpoint) => resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId))),
const resolved = await mapWithConcurrency(endpoints, FETCH_CONCURRENCY, async (endpoint) =>
resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId)),
);
try {
const doc = await compile(resolved, version, await compileOptionsFromDb(db, resolved));
Expand Down Expand Up @@ -144,8 +150,8 @@ export function createVersionsRouter({ db, io }: RouteDeps): Router {

async function compileVersion(version: string) {
const endpoints = await db.listEndpoints(version);
const resolved = await Promise.all(
endpoints.map(async (endpoint) => resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId))),
const resolved = await mapWithConcurrency(endpoints, FETCH_CONCURRENCY, async (endpoint) =>
resolveEndpoint(endpoint, await db.listOverrides(endpoint.vayoId)),
);
return compile(resolved, version);
}
Expand Down
16 changes: 13 additions & 3 deletions packages/ui/src/DocsApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ export function DocsApp({
const [me, setMe] = useState<CurrentMember | null>(null);
const [doc, setDoc] = useState<OpenApiDoc | null>(null);
const [folders, setFolders] = useState<FolderDoc[]>([]);
// True until the first spec/folders fetch resolves — an empty `folders`
// during that window means "haven't heard back yet," not "there's
// nothing here." A large real API can take several real seconds to
// answer, and without this the sidebar/main pane flash "No endpoints
// yet" the whole time, then swap to the real content once it arrives.
const [initialLoadPending, setInitialLoadPending] = useState(true);
const [selectedVayoId, setSelectedVayoId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabId>("details");
// "endpoint" = today's one-at-a-time workspace (Details/Flowmap/History/
Expand Down Expand Up @@ -297,7 +303,8 @@ export function DocsApp({
if (!token) return;
refetchSpecAndFolders()
.then(() => setError(null))
.catch((err) => setError(err instanceof ApiError ? err.message : "Failed to load spec"));
.catch((err) => setError(err instanceof ApiError ? err.message : "Failed to load spec"))
.finally(() => setInitialLoadPending(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config, token, activeVersion]);

Expand Down Expand Up @@ -702,6 +709,7 @@ export function DocsApp({
onMoveToFolder={canEdit ? handleMoveToFolder : noop}
onAutoOrganize={canEdit ? handleAutoOrganize : noop}
onBlockedMove={setError}
isLoading={initialLoadPending}
/>
<main className="docs-app__main">
{error && <div className="banner banner--error">{error}</div>}
Expand Down Expand Up @@ -729,12 +737,14 @@ export function DocsApp({
onTryIt={tryItFromFullDoc}
onSectionInView={setSelectedVayoId}
settings={settings}
isLoading={initialLoadPending}
/>
)}
{viewMode === "endpoint" && !selected && (
<div className="empty-state">
No endpoints captured yet — hit some routes on your API, or create one manually, and they&apos;ll show up
here.
{initialLoadPending
? "Loading endpoints…"
: "No endpoints captured yet — hit some routes on your API, or create one manually, and they'll show up here."}
</div>
)}
{viewMode === "endpoint" && selected && (
Expand Down
13 changes: 12 additions & 1 deletion packages/ui/src/components/FolderTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ interface FolderTreeProps {
* any other failed action already uses), rather than the drag silently
* doing nothing with no explanation. */
onBlockedMove: (message: string) => void;
/** True until the first spec/folders fetch resolves — an empty `tree`
* during that window means "haven't heard back yet," not "there's
* nothing here," so this keeps the sidebar from flashing "No endpoints
* yet" for a real project that's just slow to answer (a large API can
* take several real seconds), before the actual data arrives moments
* later and replaces it. */
isLoading?: boolean;
}

function nodeIdentity(node: TreeNode): { kind: "folder" | "endpoint"; id: string; label: string; method?: string } {
Expand Down Expand Up @@ -546,7 +553,11 @@ export function FolderTree(props: FolderTreeProps): JSX.Element {
</DragOverlay>
</DndContext>

{rows.length === 0 && <p className="sidebar__empty muted">No endpoints yet.</p>}
{rows.length === 0 && (props.isLoading ? (
<p className="sidebar__empty muted">Loading endpoints…</p>
) : (
<p className="sidebar__empty muted">No endpoints yet.</p>
))}

{props.canEdit && (
<button type="button" className="sidebar__new-endpoint" onClick={() => props.onCreateEndpoint(null)}>
Expand Down
Loading
Loading