From 0e3860c16d362a9b93ce0fdc3451f7ac15a22b0c Mon Sep 17 00:00:00 2001 From: Diego Pinate Date: Tue, 7 Jul 2026 16:06:00 -0400 Subject: [PATCH 01/11] Add support for using Changed Elements V3 API for change visualization. --- packages/changed-elements-react/README.md | 38 +- .../src/api/VersionCompare.ts | 2 + .../src/clients/ComparisonJobClient.ts | 8 +- .../src/clients/DiffJobClient.ts | 457 +++++++++++++++++ .../src/clients/IComparisonJobClient.ts | 66 ++- packages/changed-elements-react/src/index.ts | 6 +- .../src/tests/DiffJobClient.test.ts | 480 ++++++++++++++++++ .../src/widgets/ChangedElementsWidget.tsx | 46 +- .../components/VersionCompareSelectModal.tsx | 22 +- packages/test-app-frontend/.env | 3 + .../src/App/ITwinJsApp/ITwinJsApp.tsx | 19 +- packages/test-app-frontend/src/environment.ts | 1 + 12 files changed, 1096 insertions(+), 52 deletions(-) create mode 100644 packages/changed-elements-react/src/clients/DiffJobClient.ts create mode 100644 packages/changed-elements-react/src/tests/DiffJobClient.test.ts diff --git a/packages/changed-elements-react/README.md b/packages/changed-elements-react/README.md index 8ec3f9b1..988362ad 100644 --- a/packages/changed-elements-react/README.md +++ b/packages/changed-elements-react/README.md @@ -2,7 +2,7 @@ ## About -This package provides React components that help implement iTwin version comparison workflows. These components are designed to communicate with [iTwin Platform Changed Elements API](https://developer.bentley.com/apis/changed-elements-v2), which is used to retrieve data about iModel change history. +This package provides React components that help implement iTwin version comparison workflows. These components are designed to communicate with iTwin Platform Changed Elements APIs (v1/v2/v3) to retrieve data about iModel change history. ## Installation @@ -18,9 +18,14 @@ To begin using this package in your application, you will need to: 2. Provide `` somewhere in your app. ```tsx - import { VersionCompareContext } from "@itwin/changed-elements-react"; + import { ComparisonJobClient, VersionCompareContext } from "@itwin/changed-elements-react"; - + const comparisonJobClient = new ComparisonJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: VersionCompare.getAccessToken, + }); + + ``` @@ -41,7 +46,7 @@ To begin using this package in your application, you will need to: import { ChangedElementsWidget } from "@itwin/changed-elements-react"; , ``` +### V3 setup + +Use `DiffJobClient` in `VersionCompareContext` and set `apiVersion="v3"` on `ChangedElementsWidget`. + +```tsx +import { DiffJobClient, VersionCompareContext } from "@itwin/changed-elements-react"; + +const comparisonJobClient = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: VersionCompare.getAccessToken, + iModelsClient: iTwinIModelsClient, + diffingStrategy: "VersionCompare", +}); + + + + +``` + +`useV2Widget` is still supported for backward compatibility, but `apiVersion` is the preferred prop moving forward. + 5. The `` is an **experimental** React component that lets users inspect differences in properties between versions, generate reports, search for changed elements, and control element visibility. The following code shows an example widget initialization (does not show off all props). ```ts diff --git a/packages/changed-elements-react/src/api/VersionCompare.ts b/packages/changed-elements-react/src/api/VersionCompare.ts index 5382bdee..05845df5 100644 --- a/packages/changed-elements-react/src/api/VersionCompare.ts +++ b/packages/changed-elements-react/src/api/VersionCompare.ts @@ -19,6 +19,8 @@ export interface VersionCompareFeatureTracking { trackVersionSelectorV2Usage: () => void; /** Track when the user opens the version compare selector dialog to start a comparison */ trackVersionSelectorUsage: () => void; + /** Track when the user opens the version compare selector dialog to start a comparison using V3 API */ + trackVersionSelectorV3Usage?: () => void; /** Tracks when the user does a property comparison and opens the side-by-side frontstage */ trackPropertyComparisonUsage: () => void; /** Tracks when the user opens the change report dialog */ diff --git a/packages/changed-elements-react/src/clients/ComparisonJobClient.ts b/packages/changed-elements-react/src/clients/ComparisonJobClient.ts index cc681861..74894049 100644 --- a/packages/changed-elements-react/src/clients/ComparisonJobClient.ts +++ b/packages/changed-elements-react/src/clients/ComparisonJobClient.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { ChangedElementsPayload, IComparisonJobClient, ComparisonJob, GetComparisonJobParams, GetComparisonJobResultParams, - PostComparisonJobParams, + PostComparisonJobParams, PostComparisonJobParamsWithIds, DeleteComparisonJobParams } from "./IComparisonJobClient.js"; import { callITwinApi, throwBadResponseCodeError } from "./iTwinApi.js"; @@ -15,6 +15,7 @@ export interface ComparisonJobClientParams { } export class ComparisonJobClient implements IComparisonJobClient { + public readonly apiVersion = "v2" as const; private static readonly _acceptHeader = "application/vnd.bentley.itwin-platform.v2+json"; private _baseUrl: string; private _getAccessToken: () => Promise; @@ -87,7 +88,12 @@ export class ComparisonJobClient implements IComparisonJobClient { * @returns ComparisonJob * @throws on a non 2XX response. */ + public async postComparisonJob(args: PostComparisonJobParamsWithIds): Promise; public async postComparisonJob(args: PostComparisonJobParams): Promise { + if (!args.startChangesetId || !args.endChangesetId) { + throw new Error("ComparisonJobClient requires startChangesetId and endChangesetId."); + } + return callITwinApi({ url: `${this._baseUrl}/comparisonJob`, method: "POST", diff --git a/packages/changed-elements-react/src/clients/DiffJobClient.ts b/packages/changed-elements-react/src/clients/DiffJobClient.ts new file mode 100644 index 00000000..e6fe30ff --- /dev/null +++ b/packages/changed-elements-react/src/clients/DiffJobClient.ts @@ -0,0 +1,457 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +import type { + ChangedElementsPayload, ComparisonJob, DeleteComparisonJobParams, DiffingStrategy, GetComparisonJobParams, + GetComparisonJobResultParams, IComparisonJobClient, PostComparisonJobParams +} from "./IComparisonJobClient.js"; +import type { IModelsClient } from "./iModelsClient.js"; +import { callITwinApi, throwBadResponseCodeError } from "./iTwinApi.js"; + +/** + * Structured error thrown when a requested diff job cannot be found. + * Satisfies the `{ code: "ComparisonNotFound" }` contract checked by callers. + */ +class ComparisonNotFoundError extends Error { + public readonly code = "ComparisonNotFound" as const; + constructor(message: string) { + super(message); + this.name = "ComparisonNotFoundError"; + } +} + +export interface DiffJobClientParams { + baseUrl: string; + getAccessToken: () => Promise; + iModelsClient: IModelsClient; + diffingStrategy?: DiffingStrategy; +} + +type DiffJob = { + jobId: string; + status: "Queued" | "Started" | "Completed" | "Failed"; + iTwinId: string; + iModelId: string; + startChangesetIndex: number; + endChangesetIndex: number; + diffingPlan?: { + strategy?: string; + }; + href?: string; + error?: string; + completedAgents?: number; + totalAgents?: number; +}; + +type DiffJobResponse = { + job: DiffJob; +}; + +type DiffJobListResponse = { + jobs: DiffJob[]; +}; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export class DiffJobClient implements IComparisonJobClient { + public readonly apiVersion = "v3" as const; + private static readonly _acceptHeader = "application/vnd.bentley.itwin-platform.v3+json"; + private readonly _baseUrl: string; + private readonly _getAccessToken: () => Promise; + private readonly _iModelsClient: IModelsClient; + private readonly _diffingStrategy: DiffingStrategy; + private readonly _changesetIndexCache = new Map(); + private readonly _compositeJobStrategyCache = new Map(); + + constructor(args: DiffJobClientParams) { + this._baseUrl = args.baseUrl; + this._getAccessToken = args.getAccessToken; + this._iModelsClient = args.iModelsClient; + this._diffingStrategy = args.diffingStrategy ?? "VersionCompare"; + } + + public async getComparisonJob(args: GetComparisonJobParams): Promise { + try { + if (uuidPattern.test(args.jobId)) { + const response = await callITwinApi({ + url: this._buildJobUrl(args.jobId, args.iTwinId, args.iModelId), + method: "GET", + getAccessToken: this._getAccessToken, + signal: args.signal, + headers: { + Accept: DiffJobClient._acceptHeader, + ...args.headers, + }, + }) as unknown as DiffJobResponse; + return this._normalizeJob(response.job); + } + + const pair = await this._resolveCompositeJobId(args.iModelId, args.jobId); + if (!pair) { + throw new ComparisonNotFoundError(`Unsupported job identifier format: ${args.jobId}`); + } + + const preferredStrategy = this._getPreferredStrategy(args.jobId); + + let listResponse = await this._listDiffJobs({ + iTwinId: args.iTwinId, + iModelId: args.iModelId, + startChangesetIndex: pair.startChangesetIndex, + endChangesetIndex: pair.endChangesetIndex, + diffingStrategy: preferredStrategy, + signal: args.signal, + headers: args.headers, + }); + + let firstJob = listResponse.jobs.find((job) => this._isMatchingStrategy(job, preferredStrategy)); + if (!firstJob) { + // Fall back to an unfiltered list query to avoid missing jobs created with a non-default strategy. + listResponse = await this._listDiffJobs({ + iTwinId: args.iTwinId, + iModelId: args.iModelId, + startChangesetIndex: pair.startChangesetIndex, + endChangesetIndex: pair.endChangesetIndex, + signal: args.signal, + headers: args.headers, + }); + firstJob = this._pickBestStrategyMatch(listResponse.jobs, preferredStrategy); + } + + if (!firstJob) { + throw new ComparisonNotFoundError("Requested comparison is not available."); + } + + const detailedResponse = await callITwinApi({ + url: this._buildJobUrl(firstJob.jobId, args.iTwinId, args.iModelId), + method: "GET", + getAccessToken: this._getAccessToken, + signal: args.signal, + headers: { + Accept: DiffJobClient._acceptHeader, + ...args.headers, + }, + }) as unknown as DiffJobResponse; + + return this._normalizeJob(detailedResponse.job, pair.startChangesetId, pair.endChangesetId); + } catch (error: unknown) { + throw this._normalizeNotFoundError(error); + } + } + + public async deleteComparisonJob(args: DeleteComparisonJobParams): Promise { + let jobId = args.jobId; + if (!uuidPattern.test(jobId)) { + const pair = await this._resolveCompositeJobId(args.iModelId, jobId); + if (!pair) + return; + const preferredStrategy = this._getPreferredStrategy(jobId); + let listResponse = await this._listDiffJobs({ + iTwinId: args.iTwinId, + iModelId: args.iModelId, + startChangesetIndex: pair.startChangesetIndex, + endChangesetIndex: pair.endChangesetIndex, + diffingStrategy: preferredStrategy, + signal: args.signal, + headers: args.headers, + }); + + let match = listResponse.jobs.find((job) => this._isMatchingStrategy(job, preferredStrategy)); + if (!match) { + listResponse = await this._listDiffJobs({ + iTwinId: args.iTwinId, + iModelId: args.iModelId, + startChangesetIndex: pair.startChangesetIndex, + endChangesetIndex: pair.endChangesetIndex, + signal: args.signal, + headers: args.headers, + }); + match = this._pickBestStrategyMatch(listResponse.jobs, preferredStrategy); + } + + if (!match) { + throw new ComparisonNotFoundError("Requested comparison is not available."); + } + jobId = match.jobId; + } + + await callITwinApi({ + url: this._buildJobUrl(jobId, args.iTwinId, args.iModelId), + method: "DELETE", + getAccessToken: this._getAccessToken, + signal: args.signal, + headers: { + Accept: DiffJobClient._acceptHeader, + ...args.headers, + }, + }); + } + + public async getComparisonJobResult(args: GetComparisonJobResultParams): Promise { + // The href is a pre-signed URL (e.g. Azure Blob SAS URL); no Authorization header is needed or wanted. + const response = await fetch( + args.comparisonJob.comparison.href, + { + method: "GET", + headers: { + Accept: DiffJobClient._acceptHeader, + }, + }, + ); + + if (!response.ok) { + await throwBadResponseCodeError(response, "Changed Elements request failed."); + } + return response.json() as unknown as Promise; + } + + public async postComparisonJob(args: PostComparisonJobParams): Promise { + const startChangesetIndex = args.startChangesetIndex ?? + await this._resolveChangesetIndex(args.iModelId, args.startChangesetId); + const endChangesetIndex = args.endChangesetIndex ?? + await this._resolveChangesetIndex(args.iModelId, args.endChangesetId); + + const requestedStrategy = args.diffingStrategy ?? this._diffingStrategy; + + const response = await callITwinApi({ + url: `${this._baseUrl}/diff`, + method: "POST", + getAccessToken: this._getAccessToken, + signal: args.signal, + headers: { + Accept: DiffJobClient._acceptHeader, + ...args.headers, + }, + body: { + iTwinId: args.iTwinId, + iModelId: args.iModelId, + startChangesetIndex, + endChangesetIndex, + diffingPlan: { + strategy: requestedStrategy, + }, + }, + }) as unknown as DiffJobResponse; + + if (args.startChangesetId && args.endChangesetId) { + this._compositeJobStrategyCache.set(`${args.startChangesetId}-${args.endChangesetId}`, requestedStrategy); + } + + return this._normalizeJob(response.job, args.startChangesetId, args.endChangesetId); + } + + private async _resolveChangesetIndex(iModelId: string, changesetId: string | undefined): Promise { + if (!changesetId) { + throw new Error("Missing required changeset identifier."); + } + + const cacheKey = `${iModelId}:${changesetId}`; + const cached = this._changesetIndexCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const changeset = await this._iModelsClient.getChangeset({ + iModelId, + changesetId, + }); + if (!changeset) { + throw new ComparisonNotFoundError(`Could not resolve changeset index for '${changesetId}'.`); + } + + this._changesetIndexCache.set(cacheKey, changeset.index); + return changeset.index; + } + + private _normalizeJob(job: DiffJob, startChangesetId?: string, endChangesetId?: string): ComparisonJob { + const common = { + jobId: job.jobId, + iTwinId: job.iTwinId, + iModelId: job.iModelId, + startChangesetId, + endChangesetId, + startChangesetIndex: job.startChangesetIndex, + endChangesetIndex: job.endChangesetIndex, + diffingPlan: { + strategy: this._normalizeStrategy(job.diffingPlan?.strategy) ?? this._diffingStrategy, + }, + }; + + switch (job.status) { + case "Completed": + if (!job.href) { + throw new Error(`Completed diff job '${job.jobId}' is missing required href.`); + } + return { + comparisonJob: { + ...common, + status: "Completed", + comparison: { + href: job.href, + }, + }, + }; + case "Started": + return { + comparisonJob: { + ...common, + status: "Started", + completedAgents: job.completedAgents, + totalAgents: job.totalAgents, + currentProgress: job.completedAgents ?? 0, + maxProgress: job.totalAgents ?? 0, + }, + }; + case "Failed": + return { + comparisonJob: { + ...common, + status: "Failed", + errorDetails: job.error ?? "Diff job failed.", + }, + }; + case "Queued": + return { + comparisonJob: { + ...common, + status: "Queued", + }, + }; + default: + throw new Error(`Received unsupported diff job status '${String(job.status)}'.`); + } + } + + private async _resolveCompositeJobId( + iModelId: string, + jobId: string, + ): Promise<{ + startChangesetId: string; + endChangesetId: string; + startChangesetIndex: number; + endChangesetIndex: number; + } | undefined> { + const separatorIndexes: number[] = []; + for (let i = 0; i < jobId.length; i++) { + if (jobId[i] === "-") { + separatorIndexes.push(i); + } + } + + if (separatorIndexes.length === 0) { + return undefined; + } + + // Sort candidates by distance from the midpoint so that equal-length ID pairs + // (e.g. two UUIDs or two SHA-1 hashes) are resolved in a single attempt rather + // than scanning from the leftmost dash inward. + const midpoint = (jobId.length - 1) / 2; + const sorted = separatorIndexes.slice().sort( + (a, b) => Math.abs(a - midpoint) - Math.abs(b - midpoint), + ); + + // We try every possible split to support opaque changeset ids. + for (const splitIndex of sorted) { + const startChangesetId = jobId.slice(0, splitIndex); + const endChangesetId = jobId.slice(splitIndex + 1); + if (!startChangesetId || !endChangesetId) { + continue; + } + + try { + const startChangesetIndex = await this._resolveChangesetIndex(iModelId, startChangesetId); + const endChangesetIndex = await this._resolveChangesetIndex(iModelId, endChangesetId); + return { + startChangesetId, + endChangesetId, + startChangesetIndex, + endChangesetIndex, + }; + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? (error as Record).code + : undefined; + if (code === "ComparisonNotFound") { + // Try next split candidate when one side of the split does not resolve. + continue; + } + + throw error; + } + } + + return undefined; + } + + private async _listDiffJobs(args: { + iTwinId: string; + iModelId: string; + startChangesetIndex: number; + endChangesetIndex: number; + diffingStrategy?: DiffingStrategy; + signal?: AbortSignal; + headers?: Record; + }): Promise { + const url = new URL(`${this._baseUrl}/diff`); + url.searchParams.set("iTwinId", args.iTwinId); + url.searchParams.set("iModelId", args.iModelId); + url.searchParams.set("startChangesetIndex", String(args.startChangesetIndex)); + url.searchParams.set("endChangesetIndex", String(args.endChangesetIndex)); + if (args.diffingStrategy) { + url.searchParams.set("diffingStrategy", args.diffingStrategy); + } + + return callITwinApi({ + url: url.toString(), + method: "GET", + getAccessToken: this._getAccessToken, + signal: args.signal, + headers: { + Accept: DiffJobClient._acceptHeader, + ...args.headers, + }, + }) as unknown as Promise; + } + + private _buildJobUrl(jobId: string, iTwinId: string, iModelId: string): string { + const url = new URL(`${this._baseUrl}/diff/${encodeURIComponent(jobId)}`); + url.searchParams.set("iTwinId", iTwinId); + url.searchParams.set("iModelId", iModelId); + return url.toString(); + } + + private _getPreferredStrategy(jobId: string): DiffingStrategy { + return this._compositeJobStrategyCache.get(jobId) ?? this._diffingStrategy; + } + + private _pickBestStrategyMatch(jobs: DiffJob[], preferredStrategy: DiffingStrategy): DiffJob | undefined { + return jobs.find((job) => this._isMatchingStrategy(job, preferredStrategy)) + ?? jobs.find((job) => this._isMatchingStrategy(job, this._diffingStrategy)); + } + + private _isMatchingStrategy(job: DiffJob, strategy: DiffingStrategy): boolean { + return this._normalizeStrategy(job.diffingPlan?.strategy) === strategy; + } + + private _normalizeStrategy(strategy: string | undefined): DiffingStrategy | undefined { + switch (strategy?.toLowerCase()) { + case "basic": return "Basic"; + case "full": return "Full"; + case "versioncompare": return "VersionCompare"; + default: return undefined; + } + } + + private _normalizeNotFoundError(error: unknown): unknown { + if (error && typeof error === "object" && "code" in error) { + const code = (error as Record).code; + if (code === "DiffJobNotFound") { + const rawMessage = (error as Record).message; + const message = typeof rawMessage === "string" ? rawMessage : "Requested comparison is not available."; + return new ComparisonNotFoundError(message); + } + } + + return error; + } +} diff --git a/packages/changed-elements-react/src/clients/IComparisonJobClient.ts b/packages/changed-elements-react/src/clients/IComparisonJobClient.ts index 0293f114..4dc345ad 100644 --- a/packages/changed-elements-react/src/clients/IComparisonJobClient.ts +++ b/packages/changed-elements-react/src/clients/IComparisonJobClient.ts @@ -4,7 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { ChangedElements } from "@itwin/core-common"; +export type ChangedElementsApiVersion = "v2" | "v3"; +export type DiffingStrategy = "Basic" | "VersionCompare" | "Full"; + export interface IComparisonJobClient { + /** Indicates which Changed Elements API version this client targets. */ + readonly apiVersion: ChangedElementsApiVersion; + /** Gets comparison job status. Throws on encountering an error or receiving non-success response code. */ getComparisonJob(args: GetComparisonJobParams): Promise; @@ -31,14 +37,22 @@ export interface DeleteComparisonJobParams extends GetComparisonJobParams { } export type ComparisonJob = ComparisonJobCompleted | ComparisonJobStarted | ComparisonJobQueued | ComparisonJobFailed; +interface ComparisonJobCommon { + jobId: string; + iTwinId: string; + iModelId: string; + startChangesetId?: string; + endChangesetId?: string; + startChangesetIndex?: number; + endChangesetIndex?: number; + diffingPlan?: { + strategy: DiffingStrategy; + }; +} + export interface ComparisonJobCompleted { - comparisonJob: { + comparisonJob: ComparisonJobCommon & { status: "Completed"; - jobId: string; - iTwinId: string; - iModelId: string; - startChangesetId: string; - endChangesetId: string; comparison: { href: string; }; @@ -46,37 +60,24 @@ export interface ComparisonJobCompleted { } export interface ComparisonJobStarted { - comparisonJob: { + comparisonJob: ComparisonJobCommon & { status: "Started"; - jobId: string; - iTwinId: string; - iModelId: string; - startChangesetId: string; - endChangesetId: string; currentProgress: number; maxProgress: number; + completedAgents?: number; + totalAgents?: number; }; } export interface ComparisonJobQueued { - comparisonJob: { + comparisonJob: ComparisonJobCommon & { status: "Queued"; - jobId: string; - iTwinId: string; - iModelId: string; - startChangesetId: string; - endChangesetId: string; }; } export interface ComparisonJobFailed { - comparisonJob: { + comparisonJob: ComparisonJobCommon & { status: "Failed"; - jobId: string; - iTwinId: string; - iModelId: string; - startChangesetId: string; - endChangesetId: string; errorDetails: string; }; } @@ -89,13 +90,28 @@ export interface ChangedElementsPayload { changedElements: ChangedElements; } -export interface PostComparisonJobParams extends BodilessRequest { +interface PostComparisonJobParamsBase extends BodilessRequest { iTwinId: string; iModelId: string; + diffingStrategy?: DiffingStrategy; +} + +export interface PostComparisonJobParamsWithIds extends PostComparisonJobParamsBase { startChangesetId: string; endChangesetId: string; + startChangesetIndex?: never; + endChangesetIndex?: never; } +export interface PostComparisonJobParamsWithIndexes extends PostComparisonJobParamsBase { + startChangesetIndex: number; + endChangesetIndex: number; + startChangesetId?: string; + endChangesetId?: string; +} + +export type PostComparisonJobParams = PostComparisonJobParamsWithIds | PostComparisonJobParamsWithIndexes; + export interface CommonRequestParams { signal?: AbortSignal | undefined; headers?: Record | undefined; diff --git a/packages/changed-elements-react/src/index.ts b/packages/changed-elements-react/src/index.ts index aac74c77..d83f6825 100644 --- a/packages/changed-elements-react/src/index.ts +++ b/packages/changed-elements-react/src/index.ts @@ -17,9 +17,13 @@ export type { MainVisualizationOptions, VisualizationHandler } from "./api/Visua export { ComparisonJobClient, type ComparisonJobClientParams, } from "./clients/ComparisonJobClient.js"; +export { + DiffJobClient, type DiffJobClientParams, +} from "./clients/DiffJobClient.js"; export type { + ChangedElementsApiVersion, ComparisonJob, ComparisonJobCompleted, ComparisonJobFailed, ComparisonJobQueued, - ComparisonJobStarted, + ComparisonJobStarted, DiffingStrategy, IComparisonJobClient, } from "./clients/IComparisonJobClient.js"; export type { Changeset, GetChangesetsParams, GetNamedVersionsParams, IModelsClient, NamedVersion, diff --git a/packages/changed-elements-react/src/tests/DiffJobClient.test.ts b/packages/changed-elements-react/src/tests/DiffJobClient.test.ts new file mode 100644 index 00000000..0c86a1ac --- /dev/null +++ b/packages/changed-elements-react/src/tests/DiffJobClient.test.ts @@ -0,0 +1,480 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DiffJobClient } from "../clients/DiffJobClient.js"; +import type { IModelsClient } from "../clients/iModelsClient.js"; + +const createChangeset = (id: string, index: number) => ({ + id, + index, + displayName: `changeset-${index}`, + description: "", + parentId: "", + creatorId: "", + pushDateTime: "", +}); + +describe("DiffJobClient", () => { + const iTwinId = "a1b2c3d4-0000-0000-0000-000000000000"; + const iModelId = "b1b2c3d4-0000-0000-0000-000000000000"; + const startChangesetId = "11111111-1111-4111-8111-111111111111"; + const endChangesetId = "22222222-2222-4222-8222-222222222222"; + + const iModelsClient = { + getChangesets: vi.fn(), + getNamedVersions: vi.fn(), + getChangeset: vi.fn(), + } as unknown as IModelsClient; + + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.mocked(iModelsClient.getChangeset).mockReset(); + vi.mocked(iModelsClient.getChangesets).mockReset(); + vi.mocked(iModelsClient.getNamedVersions).mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("posts v3 diff jobs using VersionCompare strategy and changeset indexes", async () => { + vi.mocked(iModelsClient.getChangeset) + .mockResolvedValueOnce(createChangeset(startChangesetId, 4)) + .mockResolvedValueOnce(createChangeset(endChangesetId, 8)); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + job: { + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Queued", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingPlan: { strategy: "VersionCompare" }, + }, + }), { status: 202 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + diffingStrategy: "VersionCompare", + }); + + const result = await client.postComparisonJob({ + iTwinId, + iModelId, + startChangesetId, + endChangesetId, + headers: { "Content-Type": "application/json" }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, request] = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(request.body as string) as { + startChangesetIndex: number; + endChangesetIndex: number; + diffingPlan: { strategy: string; }; + }; + expect(body.startChangesetIndex).toBe(4); + expect(body.endChangesetIndex).toBe(8); + expect(body.diffingPlan.strategy).toBe("VersionCompare"); + expect(result.comparisonJob.status).toBe("Queued"); + expect(result.comparisonJob.startChangesetId).toBe(startChangesetId); + expect(result.comparisonJob.endChangesetId).toBe(endChangesetId); + }); + + it("reuses per-request strategy when resolving composite ids after posting", async () => { + vi.mocked(iModelsClient.getChangeset) + .mockResolvedValueOnce(createChangeset(startChangesetId, 4)) + .mockResolvedValueOnce(createChangeset(endChangesetId, 8)); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + job: { + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Queued", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingPlan: { strategy: "Full" }, + }, + }), { status: 202 })); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + jobs: [{ + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Queued", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingPlan: { strategy: "Full" }, + }], + }), { status: 200 })); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + job: { + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Queued", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingPlan: { strategy: "Full" }, + }, + }), { status: 200 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + diffingStrategy: "VersionCompare", + }); + + await client.postComparisonJob({ + iTwinId, + iModelId, + startChangesetId, + endChangesetId, + diffingStrategy: "Full", + headers: { "Content-Type": "application/json" }, + }); + + await client.getComparisonJob({ + iTwinId, + iModelId, + jobId: `${startChangesetId}-${endChangesetId}`, + }); + + const [listUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(listUrl).toContain("diffingStrategy=Full"); + }); + + it("resolves composite ids through list and hydrates completed href from detailed endpoint", async () => { + vi.mocked(iModelsClient.getChangeset) + .mockResolvedValueOnce(createChangeset(startChangesetId, 4)) + .mockResolvedValueOnce(createChangeset(endChangesetId, 8)); + + const jobId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + jobs: [{ + jobId, + status: "Completed", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingPlan: { strategy: "VersionCompare" }, + }], + }), { status: 200 })); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + job: { + jobId, + status: "Completed", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingPlan: { strategy: "VersionCompare" }, + href: "https://example.test/results", + }, + }), { status: 200 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + const result = await client.getComparisonJob({ + iTwinId, + iModelId, + jobId: `${startChangesetId}-${endChangesetId}`, + }); + + expect(result.comparisonJob.status).toBe("Completed"); + if (result.comparisonJob.status !== "Completed") { + throw new Error("Expected completed comparison job."); + } + expect(result.comparisonJob.comparison.href).toBe("https://example.test/results"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("resolves UUID composite job IDs by trying the midpoint split first", async () => { + // startChangesetId and endChangesetId are both UUIDs (36 chars each). + // Their composite "uuid1-uuid2" has 9 dashes; the correct separator is at + // index 36 — the midpoint. After sorting by distance from midpoint, this + // candidate is tried first, so getChangeset is called exactly twice. + vi.mocked(iModelsClient.getChangeset) + .mockImplementation(async ({ changesetId }) => { + if (changesetId === startChangesetId) + return createChangeset(startChangesetId, 4); + if (changesetId === endChangesetId) + return createChangeset(endChangesetId, 8); + return undefined; + }); + + const jobId = `${startChangesetId}-${endChangesetId}`; + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + jobs: [{ + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Queued", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingPlan: { strategy: "VersionCompare" }, + }], + }), { status: 200 })); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + job: { + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Queued", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingPlan: { strategy: "VersionCompare" }, + }, + }), { status: 200 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + const result = await client.getComparisonJob({ iTwinId, iModelId, jobId }); + + expect(result.comparisonJob.status).toBe("Queued"); + // Midpoint-first sorting means the correct split is tried on the first attempt. + expect(vi.mocked(iModelsClient.getChangeset)).toHaveBeenCalledTimes(2); + expect(vi.mocked(iModelsClient.getChangeset)).toHaveBeenCalledWith({ iModelId, changesetId: startChangesetId }); + expect(vi.mocked(iModelsClient.getChangeset)).toHaveBeenCalledWith({ iModelId, changesetId: endChangesetId }); + }); + + it("maps DiffJobNotFound to ComparisonNotFound", async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + code: "DiffJobNotFound", + message: "Requested DiffJob is not available.", + }, + }), { status: 404 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + await expect(client.getComparisonJob({ + iTwinId, + iModelId, + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + })).rejects.toMatchObject({ code: "ComparisonNotFound" }); + }); + + it("resolves hash-like composite ids and issues filtered /diff query", async () => { + const hashStart = "16063aa71dfbcee75d32a7c5a31ca40e9bb2b094"; + const hashEnd = "8968f5c4449d26c0dababf37aed17dcc49d7059f"; + + vi.mocked(iModelsClient.getChangeset) + .mockImplementation(async ({ changesetId }) => { + if (changesetId === hashStart) + return createChangeset(hashStart, 3); + if (changesetId === hashEnd) + return createChangeset(hashEnd, 7); + return undefined; + }); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + jobs: [{ + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Queued", + iTwinId, + iModelId, + startChangesetIndex: 3, + endChangesetIndex: 7, + diffingPlan: { strategy: "VersionCompare" }, + }], + }), { status: 200 })); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + job: { + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Queued", + iTwinId, + iModelId, + startChangesetIndex: 3, + endChangesetIndex: 7, + diffingPlan: { strategy: "VersionCompare" }, + }, + }), { status: 200 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + const result = await client.getComparisonJob({ + iTwinId, + iModelId, + jobId: `${hashStart}-${hashEnd}`, + }); + + expect(result.comparisonJob.status).toBe("Queued"); + expect(fetchMock).toHaveBeenCalled(); + const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(firstUrl).toContain("/diff?"); + expect(firstUrl).toContain("startChangesetIndex=3"); + expect(firstUrl).toContain("endChangesetIndex=7"); + expect(firstUrl).toContain("diffingStrategy=VersionCompare"); + }); + + it("forwards signal and headers when listing jobs for composite ids", async () => { + vi.mocked(iModelsClient.getChangeset) + .mockResolvedValueOnce(createChangeset(startChangesetId, 4)) + .mockResolvedValueOnce(createChangeset(endChangesetId, 8)); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + jobs: [{ + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Queued", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingPlan: { strategy: "VersionCompare" }, + }], + }), { status: 200 })); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + job: { + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Queued", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingPlan: { strategy: "VersionCompare" }, + }, + }), { status: 200 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + const signal = new AbortController().signal; + await client.getComparisonJob({ + iTwinId, + iModelId, + jobId: `${startChangesetId}-${endChangesetId}`, + signal, + headers: { + "X-Test-Header": "test-value", + }, + }); + + const [, firstRequest] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(firstRequest.signal).toBe(signal); + expect(firstRequest.headers).toMatchObject({ + "X-Test-Header": "test-value", + }); + }); + + it("does not delete a fallback strategy job when preferred strategy is not found", async () => { + vi.mocked(iModelsClient.getChangeset) + .mockResolvedValueOnce(createChangeset(startChangesetId, 4)) + .mockResolvedValueOnce(createChangeset(endChangesetId, 8)); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ jobs: [] }), { status: 200 })); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + jobs: [{ + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Queued", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingPlan: { strategy: "Full" }, + }], + }), { status: 200 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + diffingStrategy: "VersionCompare", + }); + + await expect(client.deleteComparisonJob({ + iTwinId, + iModelId, + jobId: `${startChangesetId}-${endChangesetId}`, + })).rejects.toMatchObject({ code: "ComparisonNotFound" }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit]; + const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(firstUrl).toContain("diffingStrategy=VersionCompare"); + expect(secondUrl).toContain("/diff?"); + }); + + it("does not mask iModelsClient failures while resolving composite ids", async () => { + vi.mocked(iModelsClient.getChangeset).mockRejectedValue(new Error("iModels unavailable")); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + await expect(client.getComparisonJob({ + iTwinId, + iModelId, + jobId: `${startChangesetId}-${endChangesetId}`, + })).rejects.toThrow("iModels unavailable"); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("throws on unsupported status values returned by API", async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + job: { + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "UnknownFutureStatus", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + }, + }), { status: 200 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + await expect(client.getComparisonJob({ + iTwinId, + iModelId, + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + })).rejects.toThrow("unsupported diff job status"); + }); +}); diff --git a/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx b/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx index 923921e9..db3a876e 100644 --- a/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx +++ b/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx @@ -20,6 +20,7 @@ import { VersionCompareManager } from "../api/VersionCompareManager.js"; import { CenteredDiv } from "../common/CenteredDiv.js"; import { EmptyStateComponent } from "../common/EmptyStateComponent.js"; import { Widget as WidgetComponent } from "../common/Widget/Widget.js"; +import type { ChangedElementsApiVersion } from "../clients/IComparisonJobClient.js"; import { PropertyLabelCache } from "../dialogs/PropertyLabelCache.js"; import { ReportGeneratorDialog } from "../dialogs/ReportGeneratorDialog.js"; import { ChangedElementsInspector } from "./EnhancedElementsInspector.js"; @@ -39,6 +40,16 @@ import { Documentation } from "./Documentation.js"; export const changedElementsWidgetAttachToViewportEvent = new BeEvent<(vp: ScreenViewport) => void>(); +export type ChangedElementsWidgetApiVersion = "v1" | ChangedElementsApiVersion; + +const resolveWidgetApiVersion = (props: Pick): ChangedElementsWidgetApiVersion => { + if (props.apiVersion) { + return props.apiVersion; + } + + return props.useV2Widget ? "v2" : "v1"; +}; + /** Props for changed elements widget. */ export interface ChangedElementsWidgetProps { /** IModel Connection that is being visualized. */ @@ -57,10 +68,17 @@ export interface ChangedElementsWidgetProps { /** * Optional. If true will use v2 dialog and will run comparison jobs for faster * comparisons. + * @deprecated Use `apiVersion` instead. * @beta */ useV2Widget?: boolean; + /** + * Optional. Controls which Changed Elements API workflow the widget should use. + * If not provided, legacy `useV2Widget` is used for compatibility. + */ + apiVersion?: ChangedElementsWidgetApiVersion; + /** * Optional. If set information button will show documentation link. * @beta @@ -74,17 +92,17 @@ export interface ChangedElementsWidgetProps { */ usingExperimentalSelector?: boolean; - /** Optional. Supply a link for feedback. Should only be used if v2 is enabled. */ + /** Optional. Supply a link for feedback. Should only be used in comparison-job mode (v2/v3). */ feedbackUrl?: string; /** - * Optional. When enabled will toast messages regarding job status. If not defined - * will default to false and will not show toasts (Only for V2). + * Optional. When enabled will toast messages regarding job status. If not defined + * will default to false and will not show toasts (comparison-job mode only). */ enableComparisonJobUpdateToasts?: boolean; /** - * On Job Update (Only for V2). Optional. A callback function for handling job + * On Job Update (comparison-job mode only). Optional. A callback function for handling job * updates. * * @param comparisonJobUpdateType param for the type of update: @@ -148,9 +166,10 @@ export class ChangedElementsWidget extends Component { + const isComparisonJobWidget = resolveWidgetApiVersion(this.props) !== "v1"; this.setState({ message: IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.comparisonNotActive"), - description: this.props.useV2Widget + description: isComparisonJobWidget ? IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.versionCompareGettingStartedV2") : IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.comparisonGetStarted"), loading: false, @@ -215,7 +234,7 @@ export class ChangedElementsWidget extends Component => { + const resolvedApiVersion = resolveWidgetApiVersion(this.props); + if (resolvedApiVersion === "v3") { + this.state.manager.featureTracking?.trackVersionSelectorV3Usage?.(); + } + this.setState({ versionSelectDialogVisible: true }); }; @@ -392,6 +416,9 @@ export class ChangedElementsWidget extends Component @@ -408,7 +435,7 @@ export class ChangedElementsWidget extends Component { - this.props.useV2Widget && this.props.feedbackUrl && + isComparisonJobWidget && this.props.feedbackUrl && } { - this.props.useV2Widget + isComparisonJobWidget ? ( diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx index 043f8ac4..b0fc99b9 100644 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx +++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useState, type ReactNode } from "react"; import { VersionCompareUtils, VersionCompareVerboseMessages } from "../../../api/VerboseMessages.js"; import { VersionCompare } from "../../../api/VersionCompare.js"; import type { - ComparisonJob, ComparisonJobCompleted, IComparisonJobClient + ChangedElementsApiVersion, ComparisonJob, ComparisonJobCompleted, IComparisonJobClient } from "../../../clients/IComparisonJobClient.js"; import type { IModelsClient, NamedVersion } from "../../../clients/iModelsClient.js"; import { arrayToMap, tryXTimes } from "../../../utils/utils.js"; @@ -34,6 +34,8 @@ import "./styles/ComparisonJobWidget.scss"; export interface VersionCompareSelectDialogV2Props { /** IModel Connection that is being visualized. */ iModelConnection: IModelConnection; + /** Comparison API version expected by the current widget mode. */ + apiVersion: ChangedElementsApiVersion; /** onClose triggered when user clicks start comparison or closes dialog.*/ onClose: (() => void) | undefined; "data-testid"?: string; @@ -61,10 +63,13 @@ export interface VersionCompareSelectDialogV2Props { export function VersionCompareSelectDialogV2(props: VersionCompareSelectDialogV2Props) { const { comparisonJobClient, iModelsClient } = useVersionCompare(); if (!comparisonJobClient) { - throw new Error("V2 Client Is Not Initialized In Given Context."); + throw new Error("Comparison job client is not initialized in the current VersionCompareContext."); + } + if (comparisonJobClient.apiVersion !== props.apiVersion) { + throw new Error(`Comparison job client apiVersion mismatch. Expected '${props.apiVersion}', got '${comparisonJobClient.apiVersion}'.`); } if (!iModelsClient) { - throw new Error("V1 Client Is Not Initialized In Given Context."); + throw new Error("iModels client is not initialized in the current VersionCompareContext."); } const { openDialog, closedDialog, getDialogOpen, addRunningJob, removeRunningJob, getRunningJobs , getPendingJobs, removePendingJob, addPendingJob, getToastsEnabled, runOnJobUpdate } = React.useContext(V2DialogContext); @@ -290,7 +295,8 @@ type handleJobErrorArgs = Omit Promise = async (args) => { - args.addPendingJob(args.comparisonJob.comparisonJob.jobId, { + const pendingJobId = createJobId(args.targetVersion, args.currentVersion); + args.addPendingJob(pendingJobId, { targetNamedVersion: args.targetVersion, currentNamedVersion: args.currentVersion, }); @@ -307,7 +313,7 @@ const handleJobError: (args: handleJobErrorArgs) => Promise = asy startChangesetId: args.targetVersion.changesetId as string, endChangesetId: args.currentVersion.changesetId as string, })); - args.removePendingJob(job.comparisonJob.jobId); + args.removePendingJob(pendingJobId); return job; }, 3); }; @@ -350,7 +356,7 @@ const pollUntilCurrentRunningJobsCompleteAndToast = async (args: PollForInProgre jobId: runningJob?.comparisonJob?.comparisonJob.jobId as string, }); if (completedJob.comparisonJob.status === "Failed") { - args.removeRunningJob(runningJob?.comparisonJob?.comparisonJob.jobId as string); + args.removeRunningJob(createJobId(runningJob.targetNamedVersion, runningJob.currentNamedVersion)); continue; } notifyComparisonCompletion({ @@ -368,7 +374,7 @@ const pollUntilCurrentRunningJobsCompleteAndToast = async (args: PollForInProgre toaster: args.toaster, }); } catch (error) { - args.removeRunningJob(runningJob?.comparisonJob?.comparisonJob.jobId as string); + args.removeRunningJob(createJobId(runningJob.targetNamedVersion, runningJob.currentNamedVersion)); throw error; } } @@ -400,7 +406,7 @@ type ConditionallyToastCompletionArgs = { }; const notifyComparisonCompletion = (args: ConditionallyToastCompletionArgs) => { if (args.currentJobRsp.comparisonJob.status === "Completed") { - args.removeRunningJob(args.runningJob?.comparisonJob?.comparisonJob.jobId as string); + args.removeRunningJob(createJobId(args.runningJob.targetNamedVersion, args.runningJob.currentNamedVersion)); if (!VersionCompare.manager?.isComparing && !args.getDialogOpen()) { if (args.getToastsEnabled()) { toastComparisonJobComplete({ diff --git a/packages/test-app-frontend/.env b/packages/test-app-frontend/.env index 0abd0973..1efcbb85 100644 --- a/packages/test-app-frontend/.env +++ b/packages/test-app-frontend/.env @@ -30,3 +30,6 @@ VITE_RUN_EXPERIMENTAL=true # Set to "true" to enable direct comparison mode, which uses the ChangesRpcInterface to get changes from backend instead of service VITE_USE_DIRECT_COMPARISON= + +# Set to "true" to enable V3 API. If "false", V2 API will be used. V3 API is required for direct comparison mode. +VITE_USE_V3_COMPARISON= diff --git a/packages/test-app-frontend/src/App/ITwinJsApp/ITwinJsApp.tsx b/packages/test-app-frontend/src/App/ITwinJsApp/ITwinJsApp.tsx index 683b8ca1..7999f97c 100644 --- a/packages/test-app-frontend/src/App/ITwinJsApp/ITwinJsApp.tsx +++ b/packages/test-app-frontend/src/App/ITwinJsApp/ITwinJsApp.tsx @@ -6,13 +6,14 @@ import { AppNotificationManager, ConfigurableUiContent, FrontstageUtilities, IModelViewportControl, ReducerRegistryInstance, - StagePanelLocation, StagePanelSection, StagePanelState, StageUsage, + StagePanelLocation, StagePanelSection, StagePanelState, StageUsage, UiFramework, UiItemsManager, type UiItemsProvider, type Widget } from "@itwin/appui-react"; import { ChangedECInstance, ChangedElementsWidget, ComparisonJobClient, + DiffJobClient, ITwinIModelsClient, NamedVersionSelectorWidget, VersionCompare, @@ -37,7 +38,7 @@ import { PresentationRpcInterface } from "@itwin/presentation-common"; import { Presentation } from "@itwin/presentation-frontend"; import { useEffect, useMemo, useState, type ReactElement } from "react"; import { ChangesRpcInterface, RelationshipClassWithDirection } from "../../../../test-app-backend/src/RPC/ChangesRpcInterface.js"; -import { applyUrlPrefix, localBackendPort, runExperimental, useDirectComparison, usingLocalBackend } from "../../environment.js"; +import { applyUrlPrefix, localBackendPort, runExperimental, useDirectComparison, useV3Comparison, usingLocalBackend } from "../../environment.js"; import { LoadingScreen } from "../common/LoadingScreen.js"; import { AppUiVisualizationHandler } from "./AppUi/AppUiVisualizationHandler.js"; import { UIFramework } from "./AppUi/UiFramework.js"; @@ -119,12 +120,21 @@ export function ITwinJsApp(props: ITwinJsAppProps): ReactElement | null { const comparisonJobClient = useMemo( () => { + if (useV3Comparison) { + return new DiffJobClient({ + baseUrl: applyUrlPrefix("https://api.bentley.com/changedelements"), + getAccessToken: VersionCompare.getAccessToken, + iModelsClient, + diffingStrategy: "VersionCompare", + }); + } + return new ComparisonJobClient({ baseUrl: applyUrlPrefix("https://api.bentley.com/changedelements"), getAccessToken: VersionCompare.getAccessToken, }); }, - [], + [iModelsClient], ); if (loadingState === "opening-imodel") { @@ -155,6 +165,7 @@ const savedFilters = new MockSavedFiltersManager(); /** Simple console log testing functions for feature tracking implementation */ const featureTrackingTesterFunctions: VersionCompareFeatureTracking = { trackVersionSelectorV2Usage: () => { console.log("trackVersionSelectorV2Usage"); }, + trackVersionSelectorV3Usage: () => { console.log("trackVersionSelectorV3Usage"); }, trackVersionSelectorUsage: () => { console.log("trackVersionSelectorUsage"); }, trackPropertyComparisonUsage: () => { console.log("trackPropertyComparisonUsage"); }, trackChangeReportGenerationUsage: () => { console.log("trackChangeReportGenerationUsage"); }, @@ -359,7 +370,7 @@ class MainFrontstageItemsProvider implements UiItemsProvider { return [{ id: "ChangedElementsWidget", content: Date: Tue, 7 Jul 2026 16:11:35 -0400 Subject: [PATCH 02/11] pnpm changeset --- .changeset/moody-hounds-fail.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/moody-hounds-fail.md diff --git a/.changeset/moody-hounds-fail.md b/.changeset/moody-hounds-fail.md new file mode 100644 index 00000000..2fc3cfa2 --- /dev/null +++ b/.changeset/moody-hounds-fail.md @@ -0,0 +1,19 @@ +--- +"@itwin/changed-elements-react": major +--- + +Add support for Changed Elements API v3 alongside v1/v2. + +**Breaking changes:** +- `IComparisonJobClient` now requires `readonly apiVersion: "v2" | "v3"` field. Custom client implementations must add this property. + +**New features:** +- `DiffJobClient` class for v3 API (`/diff`) with changeset Id resolution and progress tracking. +- `apiVersion` prop on `ChangedElementsWidget` to select API workflow (v1/v2/v3). Replaces reliance on `useV2Widget`. + +**Deprecations:** +- `useV2Widget` prop on `ChangedElementsWidget` is now deprecated in favor of `apiVersion`. + +**Backward compatibility:** +- `useV2Widget` continues to work for existing consumers; no immediate migration required. +- All v1/v2 call sites unaffected when `apiVersion` is omitted. From cc480959edfa7dd590ef042f4bb12a0e081d8378 Mon Sep 17 00:00:00 2001 From: Diego Pinate Date: Tue, 7 Jul 2026 16:38:21 -0400 Subject: [PATCH 03/11] Comment fixes. Handle lowercase strategies. --- packages/changed-elements-react/README.md | 4 +- .../src/clients/DiffJobClient.ts | 33 +++-- .../src/tests/DiffJobClient.test.ts | 120 ++++++++++++++++++ 3 files changed, 142 insertions(+), 15 deletions(-) diff --git a/packages/changed-elements-react/README.md b/packages/changed-elements-react/README.md index 988362ad..7f293e97 100644 --- a/packages/changed-elements-react/README.md +++ b/packages/changed-elements-react/README.md @@ -18,7 +18,7 @@ To begin using this package in your application, you will need to: 2. Provide `` somewhere in your app. ```tsx - import { ComparisonJobClient, VersionCompareContext } from "@itwin/changed-elements-react"; + import { ComparisonJobClient, VersionCompare, VersionCompareContext } from "@itwin/changed-elements-react"; const comparisonJobClient = new ComparisonJobClient({ baseUrl: "https://api.bentley.com/changedelements", @@ -60,7 +60,7 @@ To begin using this package in your application, you will need to: Use `DiffJobClient` in `VersionCompareContext` and set `apiVersion="v3"` on `ChangedElementsWidget`. ```tsx -import { DiffJobClient, VersionCompareContext } from "@itwin/changed-elements-react"; +import { DiffJobClient, VersionCompare, VersionCompareContext } from "@itwin/changed-elements-react"; const comparisonJobClient = new DiffJobClient({ baseUrl: "https://api.bentley.com/changedelements", diff --git a/packages/changed-elements-react/src/clients/DiffJobClient.ts b/packages/changed-elements-react/src/clients/DiffJobClient.ts index e6fe30ff..cafa2383 100644 --- a/packages/changed-elements-react/src/clients/DiffJobClient.ts +++ b/packages/changed-elements-react/src/clients/DiffJobClient.ts @@ -38,6 +38,7 @@ type DiffJob = { diffingPlan?: { strategy?: string; }; + diffingStrategy?: string; href?: string; error?: string; completedAgents?: number; @@ -143,8 +144,9 @@ export class DiffJobClient implements IComparisonJobClient { let jobId = args.jobId; if (!uuidPattern.test(jobId)) { const pair = await this._resolveCompositeJobId(args.iModelId, jobId); - if (!pair) - return; + if (!pair) { + throw new ComparisonNotFoundError(`Unsupported job identifier format: ${jobId}`); + } const preferredStrategy = this._getPreferredStrategy(jobId); let listResponse = await this._listDiffJobs({ iTwinId: args.iTwinId, @@ -175,16 +177,20 @@ export class DiffJobClient implements IComparisonJobClient { jobId = match.jobId; } - await callITwinApi({ - url: this._buildJobUrl(jobId, args.iTwinId, args.iModelId), - method: "DELETE", - getAccessToken: this._getAccessToken, - signal: args.signal, - headers: { - Accept: DiffJobClient._acceptHeader, - ...args.headers, - }, - }); + try { + await callITwinApi({ + url: this._buildJobUrl(jobId, args.iTwinId, args.iModelId), + method: "DELETE", + getAccessToken: this._getAccessToken, + signal: args.signal, + headers: { + Accept: DiffJobClient._acceptHeader, + ...args.headers, + }, + }); + } catch (error: unknown) { + throw this._normalizeNotFoundError(error); + } } public async getComparisonJobResult(args: GetComparisonJobResultParams): Promise { @@ -193,6 +199,7 @@ export class DiffJobClient implements IComparisonJobClient { args.comparisonJob.comparison.href, { method: "GET", + signal: args.signal, headers: { Accept: DiffJobClient._acceptHeader, }, @@ -430,7 +437,7 @@ export class DiffJobClient implements IComparisonJobClient { } private _isMatchingStrategy(job: DiffJob, strategy: DiffingStrategy): boolean { - return this._normalizeStrategy(job.diffingPlan?.strategy) === strategy; + return this._normalizeStrategy(job.diffingPlan?.strategy ?? job.diffingStrategy) === strategy; } private _normalizeStrategy(strategy: string | undefined): DiffingStrategy | undefined { diff --git a/packages/changed-elements-react/src/tests/DiffJobClient.test.ts b/packages/changed-elements-react/src/tests/DiffJobClient.test.ts index 0c86a1ac..cfb09630 100644 --- a/packages/changed-elements-react/src/tests/DiffJobClient.test.ts +++ b/packages/changed-elements-react/src/tests/DiffJobClient.test.ts @@ -209,6 +209,58 @@ describe("DiffJobClient", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("matches lowercase diffingStrategy values from list responses", async () => { + vi.mocked(iModelsClient.getChangeset) + .mockResolvedValueOnce(createChangeset(startChangesetId, 4)) + .mockResolvedValueOnce(createChangeset(endChangesetId, 8)); + + const jobId = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + jobs: [{ + jobId, + status: "Completed", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingStrategy: "versioncompare", + }], + }), { status: 200 })); + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + job: { + jobId, + status: "Completed", + iTwinId, + iModelId, + startChangesetIndex: 4, + endChangesetIndex: 8, + diffingStrategy: "versioncompare", + href: "https://example.test/results", + }, + }), { status: 200 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + const result = await client.getComparisonJob({ + iTwinId, + iModelId, + jobId: `${startChangesetId}-${endChangesetId}`, + }); + + expect(result.comparisonJob.status).toBe("Completed"); + if (result.comparisonJob.status !== "Completed") { + throw new Error("Expected completed comparison job."); + } + expect(result.comparisonJob.comparison.href).toBe("https://example.test/results"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("resolves UUID composite job IDs by trying the midpoint split first", async () => { // startChangesetId and endChangesetId are both UUIDs (36 chars each). // Their composite "uuid1-uuid2" has 9 dashes; the correct separator is at @@ -285,6 +337,43 @@ describe("DiffJobClient", () => { })).rejects.toMatchObject({ code: "ComparisonNotFound" }); }); + it("rejects unsupported delete job identifiers instead of silently succeeding", async () => { + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + await expect(client.deleteComparisonJob({ + iTwinId, + iModelId, + jobId: "not-a-composite-job-id", + })).rejects.toMatchObject({ code: "ComparisonNotFound" }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("maps DiffJobNotFound during delete to ComparisonNotFound", async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + code: "DiffJobNotFound", + message: "Requested DiffJob is not available.", + }, + }), { status: 404 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + await expect(client.deleteComparisonJob({ + iTwinId, + iModelId, + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + })).rejects.toMatchObject({ code: "ComparisonNotFound" }); + }); + it("resolves hash-like composite ids and issues filtered /diff query", async () => { const hashStart = "16063aa71dfbcee75d32a7c5a31ca40e9bb2b094"; const hashEnd = "8968f5c4449d26c0dababf37aed17dcc49d7059f"; @@ -396,6 +485,37 @@ describe("DiffJobClient", () => { }); }); + it("forwards signal when downloading comparison job results", async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + changedElements: { + elements: [], + }, + }), { status: 200 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + const signal = new AbortController().signal; + await client.getComparisonJobResult({ + signal, + comparisonJob: { + jobId: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + status: "Completed", + iTwinId, + iModelId, + comparison: { + href: "https://example.test/results", + }, + }, + }); + + const [, request] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(request.signal).toBe(signal); + }); + it("does not delete a fallback strategy job when preferred strategy is not found", async () => { vi.mocked(iModelsClient.getChangeset) .mockResolvedValueOnce(createChangeset(startChangesetId, 4)) From 08bb746edd47716771372fbf43aeb2a69dfe0e05 Mon Sep 17 00:00:00 2001 From: Diego Pinate Date: Wed, 8 Jul 2026 10:04:35 -0400 Subject: [PATCH 04/11] Address PR comments: _normalizeJob strategy fallback; delete comparison errors are normalized now; fix running job id potential mismatch; type-safety fix; simple doc fix on .env template --- .../src/clients/DiffJobClient.ts | 61 ++++++++++--------- .../src/clients/IComparisonJobClient.ts | 2 +- .../src/tests/DiffJobClient.test.ts | 27 ++++++++ .../components/VersionCompareSelectModal.tsx | 2 +- packages/test-app-frontend/.env | 3 +- 5 files changed, 63 insertions(+), 32 deletions(-) diff --git a/packages/changed-elements-react/src/clients/DiffJobClient.ts b/packages/changed-elements-react/src/clients/DiffJobClient.ts index cafa2383..3347f304 100644 --- a/packages/changed-elements-react/src/clients/DiffJobClient.ts +++ b/packages/changed-elements-react/src/clients/DiffJobClient.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import type { ChangedElementsPayload, ComparisonJob, DeleteComparisonJobParams, DiffingStrategy, GetComparisonJobParams, - GetComparisonJobResultParams, IComparisonJobClient, PostComparisonJobParams + GetComparisonJobResultParams, IComparisonJobClient, PostComparisonJobParams, PostComparisonJobParamsWithIds, + PostComparisonJobParamsWithIndexes } from "./IComparisonJobClient.js"; import type { IModelsClient } from "./iModelsClient.js"; import { callITwinApi, throwBadResponseCodeError } from "./iTwinApi.js"; @@ -141,43 +142,43 @@ export class DiffJobClient implements IComparisonJobClient { } public async deleteComparisonJob(args: DeleteComparisonJobParams): Promise { - let jobId = args.jobId; - if (!uuidPattern.test(jobId)) { - const pair = await this._resolveCompositeJobId(args.iModelId, jobId); - if (!pair) { - throw new ComparisonNotFoundError(`Unsupported job identifier format: ${jobId}`); - } - const preferredStrategy = this._getPreferredStrategy(jobId); - let listResponse = await this._listDiffJobs({ - iTwinId: args.iTwinId, - iModelId: args.iModelId, - startChangesetIndex: pair.startChangesetIndex, - endChangesetIndex: pair.endChangesetIndex, - diffingStrategy: preferredStrategy, - signal: args.signal, - headers: args.headers, - }); - - let match = listResponse.jobs.find((job) => this._isMatchingStrategy(job, preferredStrategy)); - if (!match) { - listResponse = await this._listDiffJobs({ + try { + let jobId = args.jobId; + if (!uuidPattern.test(jobId)) { + const pair = await this._resolveCompositeJobId(args.iModelId, jobId); + if (!pair) { + throw new ComparisonNotFoundError(`Unsupported job identifier format: ${jobId}`); + } + const preferredStrategy = this._getPreferredStrategy(jobId); + let listResponse = await this._listDiffJobs({ iTwinId: args.iTwinId, iModelId: args.iModelId, startChangesetIndex: pair.startChangesetIndex, endChangesetIndex: pair.endChangesetIndex, + diffingStrategy: preferredStrategy, signal: args.signal, headers: args.headers, }); - match = this._pickBestStrategyMatch(listResponse.jobs, preferredStrategy); - } - if (!match) { - throw new ComparisonNotFoundError("Requested comparison is not available."); + let match = listResponse.jobs.find((job) => this._isMatchingStrategy(job, preferredStrategy)); + if (!match) { + listResponse = await this._listDiffJobs({ + iTwinId: args.iTwinId, + iModelId: args.iModelId, + startChangesetIndex: pair.startChangesetIndex, + endChangesetIndex: pair.endChangesetIndex, + signal: args.signal, + headers: args.headers, + }); + match = this._pickBestStrategyMatch(listResponse.jobs, preferredStrategy); + } + + if (!match) { + throw new ComparisonNotFoundError("Requested comparison is not available."); + } + jobId = match.jobId; } - jobId = match.jobId; - } - try { await callITwinApi({ url: this._buildJobUrl(jobId, args.iTwinId, args.iModelId), method: "DELETE", @@ -212,6 +213,8 @@ export class DiffJobClient implements IComparisonJobClient { return response.json() as unknown as Promise; } + public async postComparisonJob(args: PostComparisonJobParamsWithIds): Promise; + public async postComparisonJob(args: PostComparisonJobParamsWithIndexes): Promise; public async postComparisonJob(args: PostComparisonJobParams): Promise { const startChangesetIndex = args.startChangesetIndex ?? await this._resolveChangesetIndex(args.iModelId, args.startChangesetId); @@ -280,7 +283,7 @@ export class DiffJobClient implements IComparisonJobClient { startChangesetIndex: job.startChangesetIndex, endChangesetIndex: job.endChangesetIndex, diffingPlan: { - strategy: this._normalizeStrategy(job.diffingPlan?.strategy) ?? this._diffingStrategy, + strategy: this._normalizeStrategy(job.diffingPlan?.strategy ?? job.diffingStrategy) ?? this._diffingStrategy, }, }; diff --git a/packages/changed-elements-react/src/clients/IComparisonJobClient.ts b/packages/changed-elements-react/src/clients/IComparisonJobClient.ts index 4dc345ad..0a13c6a3 100644 --- a/packages/changed-elements-react/src/clients/IComparisonJobClient.ts +++ b/packages/changed-elements-react/src/clients/IComparisonJobClient.ts @@ -21,7 +21,7 @@ export interface IComparisonJobClient { getComparisonJobResult(args: GetComparisonJobResultParams): Promise; /** Starts comparison job. Throws on encountering an error or receiving non-success response code. */ - postComparisonJob(args: PostComparisonJobParams): Promise; + postComparisonJob(args: PostComparisonJobParamsWithIds): Promise; } diff --git a/packages/changed-elements-react/src/tests/DiffJobClient.test.ts b/packages/changed-elements-react/src/tests/DiffJobClient.test.ts index cfb09630..3786cc2e 100644 --- a/packages/changed-elements-react/src/tests/DiffJobClient.test.ts +++ b/packages/changed-elements-react/src/tests/DiffJobClient.test.ts @@ -258,6 +258,7 @@ describe("DiffJobClient", () => { throw new Error("Expected completed comparison job."); } expect(result.comparisonJob.comparison.href).toBe("https://example.test/results"); + expect(result.comparisonJob.diffingPlan?.strategy).toBe("VersionCompare"); expect(fetchMock).toHaveBeenCalledTimes(2); }); @@ -374,6 +375,32 @@ describe("DiffJobClient", () => { })).rejects.toMatchObject({ code: "ComparisonNotFound" }); }); + it("maps DiffJobNotFound raised while resolving a composite id for delete to ComparisonNotFound", async () => { + vi.mocked(iModelsClient.getChangeset) + .mockResolvedValueOnce(createChangeset(startChangesetId, 4)) + .mockResolvedValueOnce(createChangeset(endChangesetId, 8)); + + // The list query used to resolve the composite id into a UUID fails with DiffJobNotFound. + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + code: "DiffJobNotFound", + message: "Requested DiffJob is not available.", + }, + }), { status: 404 })); + + const client = new DiffJobClient({ + baseUrl: "https://api.bentley.com/changedelements", + getAccessToken: async () => "Bearer token", + iModelsClient, + }); + + await expect(client.deleteComparisonJob({ + iTwinId, + iModelId, + jobId: `${startChangesetId}-${endChangesetId}`, + })).rejects.toMatchObject({ code: "ComparisonNotFound" }); + }); + it("resolves hash-like composite ids and issues filtered /diff query", async () => { const hashStart = "16063aa71dfbcee75d32a7c5a31ca40e9bb2b094"; const hashEnd = "8968f5c4449d26c0dababf37aed17dcc49d7059f"; diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx index b0fc99b9..e9aad9c7 100644 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx +++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/components/VersionCompareSelectModal.tsx @@ -434,7 +434,7 @@ const notifyComparisonCompletion = (args: ConditionallyToastCompletionArgs) => { const pollUpdateCurrentEntriesForModal = async (args: PollForInProgressJobsArgs) => { const currentVersionId = args.iModelConnection?.changeset.id; let entries = args.namedVersionLoaderState!.namedVersions.entries.slice(); - const currentRunningJobsMap = arrayToMap(args.getRunningJobs(), (job: JobAndNamedVersions) => { return job.comparisonJob?.comparisonJob.jobId as string; }); + const currentRunningJobsMap = arrayToMap(args.getRunningJobs(), (job: JobAndNamedVersions) => { return createJobId(job.targetNamedVersion, job.currentNamedVersion); }); if (areJobsInProgress(entries, args.getRunningJobs)) { const idEntryMap = arrayToMap(entries, (entry: VersionState) => { return entry.version.id; }); let updatingEntries = getUpdatingEntries(entries, currentVersionId, currentRunningJobsMap); diff --git a/packages/test-app-frontend/.env b/packages/test-app-frontend/.env index 1efcbb85..4790588c 100644 --- a/packages/test-app-frontend/.env +++ b/packages/test-app-frontend/.env @@ -31,5 +31,6 @@ VITE_RUN_EXPERIMENTAL=true # Set to "true" to enable direct comparison mode, which uses the ChangesRpcInterface to get changes from backend instead of service VITE_USE_DIRECT_COMPARISON= -# Set to "true" to enable V3 API. If "false", V2 API will be used. V3 API is required for direct comparison mode. +# Set to "true" to enable V3 API. If "false", V2 API will be used. Direct comparison mode +# (VITE_USE_DIRECT_COMPARISON) does not require this flag; it is independent of API version. VITE_USE_V3_COMPARISON= From 3c8061a0ce998349498810dfec287d4d601ba223 Mon Sep 17 00:00:00 2001 From: Diego Pinate Date: Wed, 8 Jul 2026 10:55:14 -0400 Subject: [PATCH 05/11] Audit fixes --- package.json | 4 +++- pnpm-lock.yaml | 50 ++++++++++++-------------------------------------- 2 files changed, 15 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index b13f1d7c..6bbce37d 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,9 @@ "form-data@<4.0.6": ">=4.0.6", "dompurify@<=3.4.10": ">=3.4.11", "@google-cloud/storage@<7.21.0": ">=7.21.0", - "uuid@<11.1.1": ">=11.1.1" + "uuid@<11.1.1": ">=11.1.1", + "linkify-it@<=5.0.0": ">=5.0.1", + "js-yaml@<3.15.0": ">=3.15.0" } }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d99861c..8e5dbd48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,8 @@ overrides: dompurify@<=3.4.10: '>=3.4.11' '@google-cloud/storage@<7.21.0': '>=7.21.0' uuid@<11.1.1: '>=11.1.1' + linkify-it@<=5.0.0: '>=5.0.1' + js-yaml@<3.15.0: '>=3.15.0' importers: @@ -1924,9 +1926,6 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2440,11 +2439,6 @@ packages: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -3071,10 +3065,6 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true @@ -3202,8 +3192,8 @@ packages: linebreak@1.1.0: resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} - linkify-it@2.2.0: - resolution: {integrity: sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} load-json-file@4.0.0: resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} @@ -4012,9 +4002,6 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4270,8 +4257,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - uc.micro@1.0.6: - resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} uid-safe@2.1.5: resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} @@ -5151,7 +5138,7 @@ snapshots: '@itwin/itwinui-react': 3.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) classnames: 2.5.1 immer: 10.1.1 - linkify-it: 2.2.0 + linkify-it: 5.0.2 lodash: 4.18.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -5168,7 +5155,7 @@ snapshots: '@itwin/itwinui-react': 3.19.4(react-dom@18.0.0(react@18.3.1))(react@18.3.1) classnames: 2.5.1 immer: 10.1.1 - linkify-it: 2.2.0 + linkify-it: 5.0.2 lodash: 4.18.1 react: 18.3.1 react-dom: 18.0.0(react@18.3.1) @@ -6339,10 +6326,6 @@ snapshots: arg@4.1.3: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} aria-query@5.3.0: @@ -7025,8 +7008,6 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 3.4.3 - esprima@4.0.1: {} - esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -7803,11 +7784,6 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -7920,9 +7896,9 @@ snapshots: base64-js: 0.0.8 unicode-trie: 2.0.0 - linkify-it@2.2.0: + linkify-it@5.0.2: dependencies: - uc.micro: 1.0.6 + uc.micro: 2.1.0 load-json-file@4.0.0: dependencies: @@ -8440,7 +8416,7 @@ snapshots: read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.14.2 + js-yaml: 4.2.0 pify: 4.0.1 strip-bom: 3.0.0 @@ -8778,8 +8754,6 @@ snapshots: spdx-license-ids@3.0.23: {} - sprintf-js@1.0.3: {} - stackback@0.0.2: {} statuses@2.0.1: {} @@ -9071,7 +9045,7 @@ snapshots: typescript@5.5.4: {} - uc.micro@1.0.6: {} + uc.micro@2.1.0: {} uid-safe@2.1.5: dependencies: From d7cbc6a4088e6da21a6e6d604496d593d8c0ce7b Mon Sep 17 00:00:00 2001 From: Diego Pinate Date: Wed, 8 Jul 2026 11:37:35 -0400 Subject: [PATCH 06/11] Fix changeset pipeline step --- package.json | 3 ++- pnpm-lock.yaml | 31 ++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6bbce37d..91e5487d 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "@google-cloud/storage@<7.21.0": ">=7.21.0", "uuid@<11.1.1": ">=11.1.1", "linkify-it@<=5.0.0": ">=5.0.1", - "js-yaml@<3.15.0": ">=3.15.0" + "js-yaml@<3.15.0": ">=3.15.0", + "read-yaml-file>js-yaml": "^3.14.1" } }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e5dbd48..3cb4406a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,7 @@ overrides: uuid@<11.1.1: '>=11.1.1' linkify-it@<=5.0.0: '>=5.0.1' js-yaml@<3.15.0: '>=3.15.0' + read-yaml-file>js-yaml: ^3.14.1 importers: @@ -1926,6 +1927,9 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2439,6 +2443,11 @@ packages: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -3065,6 +3074,10 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true @@ -4002,6 +4015,9 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -6326,6 +6342,10 @@ snapshots: arg@4.1.3: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-query@5.3.0: @@ -7008,6 +7028,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 3.4.3 + esprima@4.0.1: {} + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -7784,6 +7806,11 @@ snapshots: js-tokens@9.0.1: {} + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -8416,7 +8443,7 @@ snapshots: read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 4.2.0 + js-yaml: 3.15.0 pify: 4.0.1 strip-bom: 3.0.0 @@ -8754,6 +8781,8 @@ snapshots: spdx-license-ids@3.0.23: {} + sprintf-js@1.0.3: {} + stackback@0.0.2: {} statuses@2.0.1: {} From 3cd5ba779dfcecebcd5083ac24d9649ad26a7251 Mon Sep 17 00:00:00 2001 From: Diego Pinate Date: Wed, 8 Jul 2026 13:23:58 -0400 Subject: [PATCH 07/11] Beta annotation removal --- .../src/widgets/ChangedElementsWidget.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx b/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx index db3a876e..646725b2 100644 --- a/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx +++ b/packages/changed-elements-react/src/widgets/ChangedElementsWidget.tsx @@ -69,7 +69,6 @@ export interface ChangedElementsWidgetProps { * Optional. If true will use v2 dialog and will run comparison jobs for faster * comparisons. * @deprecated Use `apiVersion` instead. - * @beta */ useV2Widget?: boolean; @@ -81,7 +80,6 @@ export interface ChangedElementsWidgetProps { /** * Optional. If set information button will show documentation link. - * @beta */ documentationHref?: string; From f0c7f1c9cc6599de21acf4b03958e0c530e0e11a Mon Sep 17 00:00:00 2001 From: Diego Pinate Date: Wed, 8 Jul 2026 14:05:00 -0400 Subject: [PATCH 08/11] Type validation for client responses. Add error handling on caller sites. --- .../public/locales/en/VersionCompare.json | 1 + .../NamedVersionSelector.tsx | 5 +- .../src/ResizeObserver.tsx | 2 +- .../src/clients/ComparisonJobClient.ts | 19 +++- .../src/clients/DiffJobClient.ts | 44 ++++++++- .../src/clients/iTwinApi.ts | 28 +++++- .../src/clients/iTwinIModelsClient.ts | 38 +++++--- .../src/clients/typeGuards.ts | 94 +++++++++++++++++++ .../widgets/VersionCompareSelectWidget.tsx | 36 +++++-- .../common/versionCompareV2WidgetUtils.ts | 21 +++-- 10 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 packages/changed-elements-react/src/clients/typeGuards.ts diff --git a/packages/changed-elements-react/public/locales/en/VersionCompare.json b/packages/changed-elements-react/public/locales/en/VersionCompare.json index 7c40e1ca..965d7f15 100644 --- a/packages/changed-elements-react/public/locales/en/VersionCompare.json +++ b/packages/changed-elements-react/public/locales/en/VersionCompare.json @@ -74,6 +74,7 @@ "error_versionCompare": "Version Compare Error", "error_cantStart": "Could not start comparison against version", "error_invalidToken": "Could not authenticate user for version compare usage", + "error_cantLoadNamedVersions": "Could not load named versions and changesets", "msg_openingTarget": "Opening target iModel", "msg_getChangedElements": "Requesting changed elements", "msg_initializingComparison": "Initializing comparison", diff --git a/packages/changed-elements-react/src/NamedVersionSelector/NamedVersionSelector.tsx b/packages/changed-elements-react/src/NamedVersionSelector/NamedVersionSelector.tsx index 2d016eab..e98da647 100644 --- a/packages/changed-elements-react/src/NamedVersionSelector/NamedVersionSelector.tsx +++ b/packages/changed-elements-react/src/NamedVersionSelector/NamedVersionSelector.tsx @@ -124,7 +124,7 @@ export function NamedVersionSelectorWidget(props: Readonly true, iModelsClient, }); + if (!started) { + setDisableStartComparison(false); + } }; const stopComparisonCallback = useCallback(async () => { diff --git a/packages/changed-elements-react/src/ResizeObserver.tsx b/packages/changed-elements-react/src/ResizeObserver.tsx index 8f4ef889..2ce53c30 100644 --- a/packages/changed-elements-react/src/ResizeObserver.tsx +++ b/packages/changed-elements-react/src/ResizeObserver.tsx @@ -113,7 +113,7 @@ export interface ResizeObserverWrapperProps extends Omit( function ResizeObserverWrapper(props, ref) { - const divRef = useRef(null as unknown as HTMLDivElement); + const divRef = useRef(null); const size = useResizeObserver(divRef); const mergedRefs = useMemo(() => mergeRefs(divRef, ref), [divRef, ref]); return
{size && props.children(size)}
; diff --git a/packages/changed-elements-react/src/clients/ComparisonJobClient.ts b/packages/changed-elements-react/src/clients/ComparisonJobClient.ts index 74894049..8ecabf65 100644 --- a/packages/changed-elements-react/src/clients/ComparisonJobClient.ts +++ b/packages/changed-elements-react/src/clients/ComparisonJobClient.ts @@ -8,6 +8,7 @@ import type { DeleteComparisonJobParams } from "./IComparisonJobClient.js"; import { callITwinApi, throwBadResponseCodeError } from "./iTwinApi.js"; +import { isChangedElementsPayload, isComparisonJob } from "./typeGuards.js"; export interface ComparisonJobClientParams { baseUrl: string; @@ -31,7 +32,7 @@ export class ComparisonJobClient implements IComparisonJobClient { * @throws on a non 2XX response */ public async deleteComparisonJob(args: DeleteComparisonJobParams): Promise { - return callITwinApi({ + await callITwinApi({ url: `${this._baseUrl}/comparisonJob/${args.jobId}/iTwin/${args.iTwinId}/iModel/${args.iModelId}`, method: "DELETE", getAccessToken: this._getAccessToken, @@ -40,7 +41,7 @@ export class ComparisonJobClient implements IComparisonJobClient { Accept: ComparisonJobClient._acceptHeader, ...args.headers, }, - }) as unknown as Promise; + }); } /** @@ -58,7 +59,8 @@ export class ComparisonJobClient implements IComparisonJobClient { Accept: ComparisonJobClient._acceptHeader, ...args.headers, }, - }) as unknown as Promise; + validate: isComparisonJob, + }); } /** @@ -80,7 +82,13 @@ export class ComparisonJobClient implements IComparisonJobClient { if (!response.ok) { await throwBadResponseCodeError(response, "Changed Elements request failed."); } - return response.json() as unknown as Promise; + + const body: unknown = await response.json(); + if (!isChangedElementsPayload(body)) { + throw new Error(`Changed Elements request to ${args.comparisonJob.comparison.href} returned an unexpected response shape.`); + } + + return body; } /** @@ -109,6 +117,7 @@ export class ComparisonJobClient implements IComparisonJobClient { startChangesetId: args.startChangesetId, endChangesetId: args.endChangesetId, }, - }) as unknown as Promise; + validate: isComparisonJob, + }); } } diff --git a/packages/changed-elements-react/src/clients/DiffJobClient.ts b/packages/changed-elements-react/src/clients/DiffJobClient.ts index 3347f304..43788146 100644 --- a/packages/changed-elements-react/src/clients/DiffJobClient.ts +++ b/packages/changed-elements-react/src/clients/DiffJobClient.ts @@ -9,6 +9,7 @@ import type { } from "./IComparisonJobClient.js"; import type { IModelsClient } from "./iModelsClient.js"; import { callITwinApi, throwBadResponseCodeError } from "./iTwinApi.js"; +import { isChangedElementsPayload, isRecord } from "./typeGuards.js"; /** * Structured error thrown when a requested diff job cannot be found. @@ -54,6 +55,29 @@ type DiffJobListResponse = { jobs: DiffJob[]; }; +function isDiffJob(value: unknown): value is DiffJob { + // `status` is intentionally only checked to be a string here (not restricted to `diffJobStatuses`): + // unrecognized status values are a valid API response and are rejected with a specific + // "unsupported diff job status" error by `_normalizeJob` instead. + return ( + isRecord(value) && + typeof value.jobId === "string" && + typeof value.status === "string" && + typeof value.iTwinId === "string" && + typeof value.iModelId === "string" && + typeof value.startChangesetIndex === "number" && + typeof value.endChangesetIndex === "number" + ); +} + +function isDiffJobResponse(value: unknown): value is DiffJobResponse { + return isRecord(value) && isDiffJob(value.job); +} + +function isDiffJobListResponse(value: unknown): value is DiffJobListResponse { + return isRecord(value) && Array.isArray(value.jobs) && value.jobs.every(isDiffJob); +} + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export class DiffJobClient implements IComparisonJobClient { @@ -85,7 +109,8 @@ export class DiffJobClient implements IComparisonJobClient { Accept: DiffJobClient._acceptHeader, ...args.headers, }, - }) as unknown as DiffJobResponse; + validate: isDiffJobResponse, + }); return this._normalizeJob(response.job); } @@ -133,7 +158,8 @@ export class DiffJobClient implements IComparisonJobClient { Accept: DiffJobClient._acceptHeader, ...args.headers, }, - }) as unknown as DiffJobResponse; + validate: isDiffJobResponse, + }); return this._normalizeJob(detailedResponse.job, pair.startChangesetId, pair.endChangesetId); } catch (error: unknown) { @@ -210,7 +236,13 @@ export class DiffJobClient implements IComparisonJobClient { if (!response.ok) { await throwBadResponseCodeError(response, "Changed Elements request failed."); } - return response.json() as unknown as Promise; + + const body: unknown = await response.json(); + if (!isChangedElementsPayload(body)) { + throw new Error(`Changed Elements request to ${args.comparisonJob.comparison.href} returned an unexpected response shape.`); + } + + return body; } public async postComparisonJob(args: PostComparisonJobParamsWithIds): Promise; @@ -241,7 +273,8 @@ export class DiffJobClient implements IComparisonJobClient { strategy: requestedStrategy, }, }, - }) as unknown as DiffJobResponse; + validate: isDiffJobResponse, + }); if (args.startChangesetId && args.endChangesetId) { this._compositeJobStrategyCache.set(`${args.startChangesetId}-${args.endChangesetId}`, requestedStrategy); @@ -420,7 +453,8 @@ export class DiffJobClient implements IComparisonJobClient { Accept: DiffJobClient._acceptHeader, ...args.headers, }, - }) as unknown as Promise; + validate: isDiffJobListResponse, + }); } private _buildJobUrl(jobId: string, iTwinId: string, iModelId: string): string { diff --git a/packages/changed-elements-react/src/clients/iTwinApi.ts b/packages/changed-elements-react/src/clients/iTwinApi.ts index 511181c6..5e35a7a9 100644 --- a/packages/changed-elements-react/src/clients/iTwinApi.ts +++ b/packages/changed-elements-react/src/clients/iTwinApi.ts @@ -13,7 +13,21 @@ export interface CallITwinApiParams { body?: Record | undefined; } -export async function callITwinApi(args: CallITwinApiParams): Promise | undefined> { +/** + * Calls the iTwin API and returns the raw, unvalidated JSON body. + * @throws on a non 2XX response. + */ +export async function callITwinApi(args: CallITwinApiParams): Promise | undefined>; +/** + * Calls the iTwin API and validates the JSON body against the provided type guard. + * @throws on a non 2XX response, or if the response body does not satisfy `validate`. + */ +export async function callITwinApi( + args: CallITwinApiParams & { validate: (value: unknown) => value is T; }, +): Promise; +export async function callITwinApi( + args: CallITwinApiParams & { validate?: (value: unknown) => value is T; }, +): Promise | undefined> { const response = await fetch( args.url, { @@ -30,10 +44,16 @@ export async function callITwinApi(args: CallITwinApiParams): Promise { - const changeset = await callITwinApi({ + const response = await callITwinApi({ url: `${this.baseUrl}/${args.iModelId}/changesets/${args.changesetId}`, getAccessToken: this.getAccessToken, signal: args.signal, headers: { Accept: acceptMimeType }, }); - return changeset?.changeset as Changeset | undefined; + if (response === undefined) { + return undefined; + } + if (!isChangeset(response.changeset)) { + throw new Error(`Changeset request for ${args.changesetId} returned an unexpected response shape.`); + } + return response.changeset; } public async getChangesets(args: GetChangesetsParams): Promise { @@ -42,12 +50,15 @@ export class ITwinIModelsClient implements IModelsClient { headers: { Accept: acceptMimeType }, }); - const pages: Array = []; + const pages: Changeset[][] = []; for await (const page of iterator) { - pages.push((page as { changesets: unknown[]; }).changesets); + if (!isChangesetsPage(page)) { + throw new Error(`Changesets request for iModel ${args.iModelId} returned an unexpected response shape.`); + } + pages.push(page.changesets); } - return pages.flat() as Changeset[]; + return pages.flat(); } public async getNamedVersions(args: GetNamedVersionsParams): Promise { @@ -64,8 +75,8 @@ export class ITwinIModelsClient implements IModelsClient { signal: args.signal, headers: { Accept: acceptMimeType, Prefer: "return=representation" }, }); - if (!response || !Array.isArray(response?.namedVersions)) return []; - return response.namedVersions as NamedVersion[]; + if (!response || !isNamedVersionsPage(response)) return []; + return response.namedVersions; } if (args.orderby) urlParams = `${urlParams}$orderBy=${args.orderby} `; @@ -78,17 +89,20 @@ export class ITwinIModelsClient implements IModelsClient { headers: { Accept: acceptMimeType, Prefer: "return=representation" }, }); - const pages: Array = []; + const pages: NamedVersionResponseItem[][] = []; for await (const page of iterator) { - pages.push((page as { namedVersions: unknown[]; }).namedVersions); + if (!isNamedVersionsPage(page)) { + throw new Error(`Named versions request for iModel ${args.iModelId} returned an unexpected response shape.`); + } + pages.push(page.namedVersions); } - let result = pages.flat(); + let result: NamedVersionResponseItem[] = pages.flat(); if (!this.showHiddenNamedVersions) { - result = (result as Array<{ state: "visible" | "hidden"; }>).filter(({ state }) => state === "visible"); + result = result.filter(({ state }) => state === "visible"); } - return result as NamedVersion[]; + return result; } } diff --git a/packages/changed-elements-react/src/clients/typeGuards.ts b/packages/changed-elements-react/src/clients/typeGuards.ts new file mode 100644 index 00000000..afcc26ad --- /dev/null +++ b/packages/changed-elements-react/src/clients/typeGuards.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +import type { ChangedElementsPayload, ComparisonJob } from "./IComparisonJobClient.js"; +import type { Changeset, NamedVersion } from "./iModelsClient.js"; + +/** Narrows `value` to a plain object record, allowing safe property access without casting. */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Validates the shape of a `comparisonJob` API response (Changed Elements v2). */ +export function isComparisonJob(value: unknown): value is ComparisonJob { + if (!isRecord(value) || !isRecord(value.comparisonJob)) { + return false; + } + + const job = value.comparisonJob; + if ( + typeof job.jobId !== "string" || + typeof job.iTwinId !== "string" || + typeof job.iModelId !== "string" + ) { + return false; + } + + switch (job.status) { + case "Completed": + return isRecord(job.comparison) && typeof job.comparison.href === "string"; + case "Started": + return typeof job.currentProgress === "number" && typeof job.maxProgress === "number"; + case "Queued": + return true; + case "Failed": + return typeof job.errorDetails === "string"; + default: + return false; + } +} + +/** + * Validates the shape of a Changed Elements payload. Only the top-level `changedElements` + * property is checked -- the nested `ChangedElements` structure (from `@itwin/core-common`) + * is not exhaustively validated. + */ +export function isChangedElementsPayload(value: unknown): value is ChangedElementsPayload { + return isRecord(value) && isRecord(value.changedElements); +} + +/** Validates the shape of a single Changeset returned by the iTwin iModels API. */ +export function isChangeset(value: unknown): value is Changeset { + return ( + isRecord(value) && + typeof value.id === "string" && + typeof value.displayName === "string" && + typeof value.description === "string" && + typeof value.index === "number" && + typeof value.parentId === "string" && + typeof value.creatorId === "string" && + typeof value.pushDateTime === "string" + ); +} + +/** Validates a paged `{ changesets: Changeset[] }` response. */ +export function isChangesetsPage(value: unknown): value is { changesets: Changeset[]; } { + return isRecord(value) && Array.isArray(value.changesets) && value.changesets.every(isChangeset); +} + +/** + * Raw named version item as returned by the API, including the `state` field used to filter + * hidden Named Versions. `state` is not part of the public `NamedVersion` type, but the extra + * property is structurally compatible with `NamedVersion[]` when returned. + */ +export type NamedVersionResponseItem = NamedVersion & { state: "visible" | "hidden"; }; + +/** Validates the shape of a single Named Version returned by the iTwin iModels API. */ +export function isNamedVersionResponseItem(value: unknown): value is NamedVersionResponseItem { + return ( + isRecord(value) && + typeof value.id === "string" && + typeof value.displayName === "string" && + (typeof value.changesetId === "string" || value.changesetId === null) && + typeof value.changesetIndex === "number" && + (typeof value.description === "string" || value.description === null) && + typeof value.createdDateTime === "string" && + (value.state === "visible" || value.state === "hidden") + ); +} + +/** Validates a paged `{ namedVersions: NamedVersionResponseItem[] }` response. */ +export function isNamedVersionsPage(value: unknown): value is { namedVersions: NamedVersionResponseItem[]; } { + return isRecord(value) && Array.isArray(value.namedVersions) && value.namedVersions.every(isNamedVersionResponseItem); +} diff --git a/packages/changed-elements-react/src/widgets/VersionCompareSelectWidget.tsx b/packages/changed-elements-react/src/widgets/VersionCompareSelectWidget.tsx index f9f14243..2755d1e4 100644 --- a/packages/changed-elements-react/src/widgets/VersionCompareSelectWidget.tsx +++ b/packages/changed-elements-react/src/widgets/VersionCompareSelectWidget.tsx @@ -3,7 +3,7 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import { Logger } from "@itwin/core-bentley"; -import { IModelApp, IModelConnection } from "@itwin/core-frontend"; +import { IModelApp, IModelConnection, NotifyMessageDetails, OutputMessagePriority } from "@itwin/core-frontend"; import { Button, Modal, ModalButtonBar, ModalContent, ProgressLinear, ProgressRadial, Radio, Text } from "@itwin/itwinui-react"; @@ -185,11 +185,35 @@ function usePagedNamedVersionLoader( let disposed = false; void (async () => { - const [namedVersions, changesets] = await Promise.all([ - iModelsClient.getNamedVersions({ iModelId }), - // Changesets need to be in descending index order - iModelsClient.getChangesets({ iModelId }).then((changesets) => changesets.slice().reverse()), - ]); + let namedVersions: NamedVersion[]; + let changesets: Changeset[]; + try { + [namedVersions, changesets] = await Promise.all([ + iModelsClient.getNamedVersions({ iModelId }), + // Changesets need to be in descending index order + iModelsClient.getChangesets({ iModelId }).then((changesets) => changesets.slice().reverse()), + ]); + } catch (error) { + if (disposed) { + return; + } + + const errorMessage = error instanceof Error ? error.message : "Unknown Error"; + Logger.logError(VersionCompare.logCategory, `Could not load named versions and changesets: ${errorMessage}`); + IModelApp.notifications.outputMessage( + new NotifyMessageDetails( + OutputMessagePriority.Error, + IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.error_versionCompare"), + IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.error_cantLoadNamedVersions"), + ), + ); + setResult({ + namedVersions: { entries: [], currentVersion: undefined }, + changesets: [], + }); + return; + } + if (disposed) { return; } diff --git a/packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareV2WidgetUtils.ts b/packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareV2WidgetUtils.ts index e7824d86..3153ac8b 100644 --- a/packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareV2WidgetUtils.ts +++ b/packages/changed-elements-react/src/widgets/comparisonJobWidget/common/versionCompareV2WidgetUtils.ts @@ -3,7 +3,7 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import { Logger } from "@itwin/core-bentley"; -import type { IModelConnection } from "@itwin/core-frontend"; +import { IModelApp, NotifyMessageDetails, OutputMessagePriority, type IModelConnection } from "@itwin/core-frontend"; import { VersionCompare } from "../../../api/VersionCompare.js"; import type { ComparisonJobCompleted, ComparisonJobStarted, IComparisonJobClient } from "../../../clients/IComparisonJobClient.js"; import type { IModelsClient, NamedVersion } from "../../../clients/iModelsClient.js"; @@ -23,7 +23,7 @@ export type ManagerStartComparisonV2Args = { iModelsClient: IModelsClient; }; -export const runManagerStartComparisonV2 = async (args: ManagerStartComparisonV2Args) => { +export const runManagerStartComparisonV2 = async (args: ManagerStartComparisonV2Args): Promise => { if (VersionCompare.manager?.isComparing) { await VersionCompare.manager?.stopComparison(); } @@ -43,14 +43,14 @@ export const runManagerStartComparisonV2 = async (args: ManagerStartComparisonV2 const manager = VersionCompare.manager; if (!manager) { Logger.logError("VersionCompare", "VersionCompare manager is not initialized"); - return; + return false; } try { const targetVersion = await updateTargetVersion(args.iModelConnection, args.targetVersion, args.iModelsClient); if (VersionCompare.changesProvider) { await manager.startDirectComparison(args.iModelConnection, args.currentVersion, targetVersion); - return; + return true; } const changedElements = await args.comparisonJobClient.getComparisonJobResult(args.comparisonJob); @@ -60,10 +60,17 @@ export const runManagerStartComparisonV2 = async (args: ManagerStartComparisonV2 await updateTargetVersion(args.iModelConnection, args.targetVersion, args.iModelsClient), [changedElements.changedElements], ); + return true; } catch (error: unknown) { - if (error instanceof Error) { - Logger.logError("VersionCompare", `Error starting comparison: ${error.message}`); - } + const errorMessage = error instanceof Error ? error.message : "Unknown Error"; + Logger.logError("VersionCompare", `Error starting comparison: ${errorMessage}`); + + const briefError = IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.error_versionCompare"); + const detailed = IModelApp.localization.getLocalizedString("VersionCompare:versionCompare.error_cantStart"); + IModelApp.notifications.outputMessage( + new NotifyMessageDetails(OutputMessagePriority.Error, briefError, `${detailed}: ${errorMessage}`), + ); + return false; } }; From 4d748357bdc9fd2320b058f275a1f532b170d337 Mon Sep 17 00:00:00 2001 From: Diego Pinate Date: Wed, 8 Jul 2026 14:05:24 -0400 Subject: [PATCH 09/11] Add API documentation links to README --- packages/changed-elements-react/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/changed-elements-react/README.md b/packages/changed-elements-react/README.md index 7f293e97..77cf5a04 100644 --- a/packages/changed-elements-react/README.md +++ b/packages/changed-elements-react/README.md @@ -4,6 +4,11 @@ This package provides React components that help implement iTwin version comparison workflows. These components are designed to communicate with iTwin Platform Changed Elements APIs (v1/v2/v3) to retrieve data about iModel change history. +API Documentation: +- [V3 API Documentation - Tech Preview](https://developer.bentley.com/apis/changed-elements-v3/) +- [V2 API Documentation](https://developer.bentley.com/apis/changed-elements-v2/) +- [V1 API Documentation - DEPRECATED](https://developer.bentley.com/apis/changed-elements/) + ## Installation ```shell From e44e1573fb43c4a67d1d27161249c6e629239364 Mon Sep 17 00:00:00 2001 From: Diego Pinate Date: Wed, 8 Jul 2026 14:05:43 -0400 Subject: [PATCH 10/11] Remove backwards compatibility section --- .changeset/moody-hounds-fail.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.changeset/moody-hounds-fail.md b/.changeset/moody-hounds-fail.md index 2fc3cfa2..240c3e9f 100644 --- a/.changeset/moody-hounds-fail.md +++ b/.changeset/moody-hounds-fail.md @@ -13,7 +13,3 @@ Add support for Changed Elements API v3 alongside v1/v2. **Deprecations:** - `useV2Widget` prop on `ChangedElementsWidget` is now deprecated in favor of `apiVersion`. - -**Backward compatibility:** -- `useV2Widget` continues to work for existing consumers; no immediate migration required. -- All v1/v2 call sites unaffected when `apiVersion` is omitted. From 1f954160d61477ae4b4b2c4a0611c30bdf7db285 Mon Sep 17 00:00:00 2001 From: Diego Pinate Date: Wed, 8 Jul 2026 14:20:41 -0400 Subject: [PATCH 11/11] Add documentation to DiffJobClient --- .../src/clients/DiffJobClient.ts | 175 +++++++++++++++++- 1 file changed, 172 insertions(+), 3 deletions(-) diff --git a/packages/changed-elements-react/src/clients/DiffJobClient.ts b/packages/changed-elements-react/src/clients/DiffJobClient.ts index 43788146..243f824b 100644 --- a/packages/changed-elements-react/src/clients/DiffJobClient.ts +++ b/packages/changed-elements-react/src/clients/DiffJobClient.ts @@ -16,49 +16,94 @@ import { isChangedElementsPayload, isRecord } from "./typeGuards.js"; * Satisfies the `{ code: "ComparisonNotFound" }` contract checked by callers. */ class ComparisonNotFoundError extends Error { + /** Discriminant used by callers to detect this error type without an `instanceof` check across module boundaries. */ public readonly code = "ComparisonNotFound" as const; + + /** @param message Human-readable description of why the comparison could not be found. */ constructor(message: string) { super(message); this.name = "ComparisonNotFoundError"; } } +/** Constructor parameters for {@link DiffJobClient}. */ export interface DiffJobClientParams { + /** Base URL of the Changed Elements v3 (diff) API. */ baseUrl: string; + + /** Callback that resolves the access token used to authenticate API requests. */ getAccessToken: () => Promise; + + /** Client used to resolve changeset ids to changeset indexes. */ iModelsClient: IModelsClient; + + /** Diffing strategy requested when creating new diff jobs. Defaults to `"VersionCompare"`. */ diffingStrategy?: DiffingStrategy; } +/** Raw diff job shape as returned by the Changed Elements v3 (diff) API. */ type DiffJob = { + /** Unique identifier of the diff job. */ jobId: string; + + /** Current processing status of the diff job. */ status: "Queued" | "Started" | "Completed" | "Failed"; + + /** iTwin id the diff job belongs to. */ iTwinId: string; + + /** iModel id the diff job belongs to. */ iModelId: string; + + /** Changeset index the comparison starts from. */ startChangesetIndex: number; + + /** Changeset index the comparison ends at. */ endChangesetIndex: number; + + /** Diffing plan used to create the job, if reported by the API. */ diffingPlan?: { + /** Raw (non-normalized) strategy name. */ strategy?: string; }; + + /** Raw (non-normalized) strategy name, reported directly on the job by some API versions. */ diffingStrategy?: string; + + /** Pre-signed URL to the diff result. Present once the job status is `"Completed"`. */ href?: string; + + /** Error message. Present when the job status is `"Failed"`. */ error?: string; + + /** Number of diff agents that have finished processing. Present while the job is `"Started"`. */ completedAgents?: number; + + /** Total number of diff agents assigned to the job. Present while the job is `"Started"`. */ totalAgents?: number; }; +/** Response body of a single diff job request (`GET`/`POST .../diff/{jobId}`). */ type DiffJobResponse = { + /** The diff job returned by the API. */ job: DiffJob; }; +/** Response body of a diff job list request (`GET .../diff`). */ type DiffJobListResponse = { + /** Diff jobs matching the list query. */ jobs: DiffJob[]; }; +/** + * Type guard validating the shape of a raw {@link DiffJob} returned by the API. + * + * `status` is intentionally only checked to be a string here (not restricted to the known status + * literals): unrecognized status values are a valid API response and are rejected with a + * specific "unsupported diff job status" error by `DiffJobClient`'s private `_normalizeJob` instead. + * @param value Unknown value to validate. + */ function isDiffJob(value: unknown): value is DiffJob { - // `status` is intentionally only checked to be a string here (not restricted to `diffJobStatuses`): - // unrecognized status values are a valid API response and are rejected with a specific - // "unsupported diff job status" error by `_normalizeJob` instead. return ( isRecord(value) && typeof value.jobId === "string" && @@ -70,26 +115,56 @@ function isDiffJob(value: unknown): value is DiffJob { ); } +/** + * Type guard validating the shape of a {@link DiffJobResponse}. + * @param value Unknown value to validate. + */ function isDiffJobResponse(value: unknown): value is DiffJobResponse { return isRecord(value) && isDiffJob(value.job); } +/** + * Type guard validating the shape of a {@link DiffJobListResponse}. + * @param value Unknown value to validate. + */ function isDiffJobListResponse(value: unknown): value is DiffJobListResponse { return isRecord(value) && Array.isArray(value.jobs) && value.jobs.every(isDiffJob); } +/** Matches a RFC 4122 v1-v5 UUID string, used to distinguish real diff job ids from composite (changeset pair) ids. */ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +/** + * `IComparisonJobClient` implementation backed by the Changed Elements v3 (diff) API. + * + * Unlike {@link ComparisonJobClient} (v2), this client accepts composite job ids of the form + * `${startChangesetId}-${endChangesetId}` in addition to real diff job UUIDs, resolving them to + * changeset indexes via the provided `IModelsClient` and caching the results. + */ export class DiffJobClient implements IComparisonJobClient { + /** Indicates this client targets the Changed Elements v3 API. */ public readonly apiVersion = "v3" as const; private static readonly _acceptHeader = "application/vnd.bentley.itwin-platform.v3+json"; + + /** Base URL of the Changed Elements v3 (diff) API. */ private readonly _baseUrl: string; + + /** Resolves the access token used to authenticate API requests. */ private readonly _getAccessToken: () => Promise; + + /** Client used to resolve changeset ids to changeset indexes. */ private readonly _iModelsClient: IModelsClient; + + /** Default diffing strategy requested when creating new diff jobs. */ private readonly _diffingStrategy: DiffingStrategy; + + /** Caches resolved changeset indexes, keyed by `${iModelId}:${changesetId}`. */ private readonly _changesetIndexCache = new Map(); + + /** Caches the diffing strategy used for composite job ids, keyed by `${startChangesetId}-${endChangesetId}`. */ private readonly _compositeJobStrategyCache = new Map(); + /** @param args Constructor parameters, see {@link DiffJobClientParams}. */ constructor(args: DiffJobClientParams) { this._baseUrl = args.baseUrl; this._getAccessToken = args.getAccessToken; @@ -97,6 +172,15 @@ export class DiffJobClient implements IComparisonJobClient { this._diffingStrategy = args.diffingStrategy ?? "VersionCompare"; } + /** + * Gets comparison job status. + * + * Accepts either a real diff job UUID, or a composite id of the form + * `${startChangesetId}-${endChangesetId}`, which is resolved to the matching diff job. + * @param args iTwin/iModel/job identification and request options. + * @returns ComparisonJob + * @throws a `ComparisonNotFoundError` (`{ code: "ComparisonNotFound" }`) if no matching job is found, or on any other non-2XX response. + */ public async getComparisonJob(args: GetComparisonJobParams): Promise { try { if (uuidPattern.test(args.jobId)) { @@ -167,6 +251,15 @@ export class DiffJobClient implements IComparisonJobClient { } } + /** + * Deletes comparison job. + * + * Accepts either a real diff job UUID, or a composite id of the form + * `${startChangesetId}-${endChangesetId}`, which is resolved to the matching diff job before deletion. + * @param args iTwin/iModel/job identification and request options. + * @returns void + * @throws a `ComparisonNotFoundError` (`{ code: "ComparisonNotFound" }`) if no matching job is found, or on any other non-2XX response. + */ public async deleteComparisonJob(args: DeleteComparisonJobParams): Promise { try { let jobId = args.jobId; @@ -220,6 +313,12 @@ export class DiffJobClient implements IComparisonJobClient { } } + /** + * Gets changed elements for given comparisonJob. + * @param args The comparison job whose result should be fetched, plus request options. + * @returns ChangedElements + * @throws on a non 2XX response, or if the response body does not match the expected shape. + */ public async getComparisonJobResult(args: GetComparisonJobResultParams): Promise { // The href is a pre-signed URL (e.g. Azure Blob SAS URL); no Authorization header is needed or wanted. const response = await fetch( @@ -245,7 +344,21 @@ export class DiffJobClient implements IComparisonJobClient { return body; } + /** + * Starts comparison job using changeset ids. + * + * Ids are resolved to changeset indexes (and cached) via the `IModelsClient`. + * @param args iTwin/iModel identification, changeset ids, diffing strategy, and request options. + * @returns ComparisonJob + * @throws on a non 2XX response. + */ public async postComparisonJob(args: PostComparisonJobParamsWithIds): Promise; + /** + * Starts comparison job using explicit changeset indexes. + * @param args iTwin/iModel identification, changeset indexes, diffing strategy, and request options. + * @returns ComparisonJob + * @throws on a non 2XX response. + */ public async postComparisonJob(args: PostComparisonJobParamsWithIndexes): Promise; public async postComparisonJob(args: PostComparisonJobParams): Promise { const startChangesetIndex = args.startChangesetIndex ?? @@ -283,6 +396,12 @@ export class DiffJobClient implements IComparisonJobClient { return this._normalizeJob(response.job, args.startChangesetId, args.endChangesetId); } + /** + * Resolves a changeset id to its numeric changeset index via the `IModelsClient`, caching the result. + * @param iModelId iModel the changeset belongs to. + * @param changesetId Changeset id to resolve, or `undefined`. + * @throws if `changesetId` is undefined, or if the changeset cannot be found (as a `ComparisonNotFoundError`). + */ private async _resolveChangesetIndex(iModelId: string, changesetId: string | undefined): Promise { if (!changesetId) { throw new Error("Missing required changeset identifier."); @@ -306,6 +425,13 @@ export class DiffJobClient implements IComparisonJobClient { return changeset.index; } + /** + * Converts a raw {@link DiffJob} (v3 API shape) into the public, discriminated-union `ComparisonJob` shape. + * @param job Raw diff job to normalize. + * @param startChangesetId Changeset id the comparison starts from, if known (e.g. from a composite job id). + * @param endChangesetId Changeset id the comparison ends at, if known (e.g. from a composite job id). + * @throws if `job.status` is `"Completed"` but missing its result `href`, or if `job.status` is not a recognized value. + */ private _normalizeJob(job: DiffJob, startChangesetId?: string, endChangesetId?: string): ComparisonJob { const common = { jobId: job.jobId, @@ -365,6 +491,15 @@ export class DiffJobClient implements IComparisonJobClient { } } + /** + * Attempts to split a composite job id (`${startChangesetId}-${endChangesetId}`) into its two + * changeset ids and resolve both to changeset indexes, trying every dash as a possible split + * point (starting from the one closest to the midpoint) to support opaque changeset ids that + * may themselves contain dashes. + * @param iModelId iModel the changesets belong to. + * @param jobId Composite job id of the form `${startChangesetId}-${endChangesetId}`. + * @returns The resolved changeset id/index pair, or `undefined` if `jobId` has no dashes or no split resolves successfully. + */ private async _resolveCompositeJobId( iModelId: string, jobId: string, @@ -426,6 +561,11 @@ export class DiffJobClient implements IComparisonJobClient { return undefined; } + /** + * Lists diff jobs matching the given iTwin/iModel/changeset-range (and optionally strategy) query. + * @param args iTwin/iModel identification, changeset index range, optional diffing strategy filter, and request options. + * @throws on a non 2XX response, or if the response body does not match the expected shape. + */ private async _listDiffJobs(args: { iTwinId: string; iModelId: string; @@ -457,6 +597,12 @@ export class DiffJobClient implements IComparisonJobClient { }); } + /** + * Builds the URL for a single diff job resource (`.../diff/{jobId}?iTwinId=...&iModelId=...`). + * @param jobId Diff job id. + * @param iTwinId iTwin id the job belongs to. + * @param iModelId iModel id the job belongs to. + */ private _buildJobUrl(jobId: string, iTwinId: string, iModelId: string): string { const url = new URL(`${this._baseUrl}/diff/${encodeURIComponent(jobId)}`); url.searchParams.set("iTwinId", iTwinId); @@ -464,19 +610,38 @@ export class DiffJobClient implements IComparisonJobClient { return url.toString(); } + /** + * Returns the diffing strategy previously used for a composite job id, falling back to the client's default. + * @param jobId Composite job id previously passed to {@link DiffJobClient.postComparisonJob}. + */ private _getPreferredStrategy(jobId: string): DiffingStrategy { return this._compositeJobStrategyCache.get(jobId) ?? this._diffingStrategy; } + /** + * Picks the best matching job from an unfiltered job list: prefers a job matching + * `preferredStrategy`, falling back to one matching the client's default strategy. + * @param jobs Candidate jobs to search. + * @param preferredStrategy Diffing strategy to prefer. + */ private _pickBestStrategyMatch(jobs: DiffJob[], preferredStrategy: DiffingStrategy): DiffJob | undefined { return jobs.find((job) => this._isMatchingStrategy(job, preferredStrategy)) ?? jobs.find((job) => this._isMatchingStrategy(job, this._diffingStrategy)); } + /** + * Checks whether a job's (normalized) diffing strategy matches `strategy`. + * @param job Job to check. + * @param strategy Diffing strategy to compare against. + */ private _isMatchingStrategy(job: DiffJob, strategy: DiffingStrategy): boolean { return this._normalizeStrategy(job.diffingPlan?.strategy ?? job.diffingStrategy) === strategy; } + /** + * Normalizes a raw, case-insensitive strategy string from the API into a `DiffingStrategy`, or `undefined` if unrecognized. + * @param strategy Raw strategy string reported by the API, or `undefined`. + */ private _normalizeStrategy(strategy: string | undefined): DiffingStrategy | undefined { switch (strategy?.toLowerCase()) { case "basic": return "Basic"; @@ -486,6 +651,10 @@ export class DiffJobClient implements IComparisonJobClient { } } + /** + * Converts a `{ code: "DiffJobNotFound" }` error from the API into a `ComparisonNotFoundError`; other errors are passed through unchanged. + * @param error Error caught from an API call. + */ private _normalizeNotFoundError(error: unknown): unknown { if (error && typeof error === "object" && "code" in error) { const code = (error as Record).code;