diff --git a/.changeset/fe-1172-optional-link-targets.md b/.changeset/fe-1172-optional-link-targets.md new file mode 100644 index 00000000000..dc49eb641b1 --- /dev/null +++ b/.changeset/fe-1172-optional-link-targets.md @@ -0,0 +1,5 @@ +--- +"@blockprotocol/graph": minor +--- + +Make `rightEntity` / `leftEntity` on `LinkEntityAndRightEntity` / `LinkEntityAndLeftEntity` possibly `undefined`, reflecting what `getOutgoingLinkAndTargetEntities` / `getIncomingLinkAndSourceEntities` actually return when a link's `has-right-entity` / `has-left-entity` edge is not resolved into the subgraph. Previously an unsafe cast hid this, allowing runtime crashes when consumers indexed into a missing target entity array. diff --git a/apps/hash-api/src/graphql/resolvers/knowledge/org/shared.ts b/apps/hash-api/src/graphql/resolvers/knowledge/org/shared.ts index e56150675e7..4c71b0bf2d1 100644 --- a/apps/hash-api/src/graphql/resolvers/knowledge/org/shared.ts +++ b/apps/hash-api/src/graphql/resolvers/knowledge/org/shared.ts @@ -62,9 +62,10 @@ export const getPendingOrgInvitationsFromSubgraph = async ( LinkEntityAndLeftEntity[] >(subgraph, root.entityId); - const linkEntity = linkAndOrgEntities[0]?.linkEntity[0]; + const [linkAndOrgEntity] = linkAndOrgEntities; + const linkEntity = linkAndOrgEntity?.linkEntity[0]; - if (!linkEntity) { + if (!linkAndOrgEntity || !linkEntity) { throw new Error( `Pending invitation with entityId ${root.entityId} has no incoming link.`, ); @@ -82,7 +83,7 @@ export const getPendingOrgInvitationsFromSubgraph = async ( ); } - const orgEntity = linkAndOrgEntities[0]?.leftEntity[0]; + const orgEntity = linkAndOrgEntity.leftEntity?.[0]; if (!orgEntity) { throw new Error( diff --git a/apps/hash-frontend/src/components/hooks/use-account-pages.ts b/apps/hash-frontend/src/components/hooks/use-account-pages.ts index e261cc67334..7cba8156a32 100644 --- a/apps/hash-frontend/src/components/hooks/use-account-pages.ts +++ b/apps/hash-frontend/src/components/hooks/use-account-pages.ts @@ -70,13 +70,23 @@ export const useAccountPages = ( latestPage.metadata.recordId.entityId, ); - const parentLink = pageOutgoingLinks.find(({ linkEntity }) => - linkEntity[0]!.metadata.entityTypeIds.includes( - systemLinkEntityTypes.hasParent.linkEntityTypeId, - ), + const [parentLink] = pageOutgoingLinks.flatMap( + ({ linkEntity, rightEntity }) => + linkEntity[0]?.metadata.entityTypeIds.includes( + systemLinkEntityTypes.hasParent.linkEntityTypeId, + ) + ? [{ rightEntity }] + : [], ); - const parentPage = parentLink?.rightEntity[0] ?? null; + /** + * A `has-parent` link can be present in the subgraph while its target + * page is legitimately absent (`rightEntity` missing or empty): the + * parent page may be archived (its revisions no longer overlap the + * queried interval) or not visible to the requester. Treat such pages + * as parentless rather than erroring. + */ + const parentPage = parentLink?.rightEntity?.[0] ?? null; return { ...simplifyProperties(latestPage.properties as PageProperties), diff --git a/apps/hash-frontend/src/lib/user-and-org.ts b/apps/hash-frontend/src/lib/user-and-org.ts index 79821ea0962..7107fac6380 100644 --- a/apps/hash-frontend/src/lib/user-and-org.ts +++ b/apps/hash-frontend/src/lib/user-and-org.ts @@ -194,7 +194,11 @@ export const constructOrg = (params: { continue; } - const rightEntityRevision = rightEntity[0]; + /** + * The right entity may be missing from the subgraph (e.g. if it was not + * resolved when the subgraph was produced) – skip the link in that case. + */ + const rightEntityRevision = rightEntity?.[0]; if (!rightEntityRevision) { continue; @@ -269,11 +273,17 @@ export const constructOrg = (params: { continue; } - const userEntityRevision = leftEntity[0]; + const userEntityRevision = leftEntity?.[0]; if (!userEntityRevision) { + /** + * User entities are public – if the membership link is in the subgraph, + * its left (user) entity must be too. A missing user entity means the + * subgraph is internally inconsistent, which we surface loudly rather + * than silently dropping the membership. + */ throw new Error( - `Failed to find the current user entity associated with the membership with entity ID: ${linkEntityRevision.metadata.recordId.entityId}`, + `Invariant violation: membership link ${linkEntityRevision.metadata.recordId.entityId} is missing its left (user) entity in the subgraph`, ); } @@ -450,14 +460,20 @@ export const constructUser = (params: { intervalForTimestamp(currentTimestamp()), ); - const avatarLinkAndEntities: LinkEntityAndRightEntity[] = []; - const coverImageLinkAndEntities: LinkEntityAndRightEntity[] = []; - const hasBioLinkAndEntities: LinkEntityAndRightEntity[] = []; + /** + * These hold the latest revision of each link and its target entity, taken + * from the revision arrays after checking that both are present. + */ + type LinkAndTargetRevision = { linkEntity: LinkEntity; rightEntity: Entity }; + + const avatarLinkAndEntities: LinkAndTargetRevision[] = []; + const coverImageLinkAndEntities: LinkAndTargetRevision[] = []; + const hasBioLinkAndEntities: LinkAndTargetRevision[] = []; const hasServiceAccounts: User["hasServiceAccounts"] = []; for (const linkAndEntity of outgoingLinkAndTargetEntities) { const linkEntity = linkAndEntity.linkEntity[0]; - const rightEntity = linkAndEntity.rightEntity[0]; + const rightEntity = linkAndEntity.rightEntity?.[0]; if (!linkEntity || !rightEntity) { continue; @@ -468,7 +484,7 @@ export const constructUser = (params: { if ( entityTypeIds.includes(systemLinkEntityTypes.hasAvatar.linkEntityTypeId) ) { - avatarLinkAndEntities.push(linkAndEntity); + avatarLinkAndEntities.push({ linkEntity, rightEntity }); continue; } @@ -477,12 +493,12 @@ export const constructUser = (params: { systemLinkEntityTypes.hasCoverImage.linkEntityTypeId, ) ) { - coverImageLinkAndEntities.push(linkAndEntity); + coverImageLinkAndEntities.push({ linkEntity, rightEntity }); continue; } if (entityTypeIds.includes(systemLinkEntityTypes.hasBio.linkEntityTypeId)) { - hasBioLinkAndEntities.push(linkAndEntity); + hasBioLinkAndEntities.push({ linkEntity, rightEntity }); continue; } @@ -491,7 +507,7 @@ export const constructUser = (params: { systemLinkEntityTypes.hasServiceAccount.linkEntityTypeId, ) ) { - const serviceAccountEntity = linkAndEntity.rightEntity[0]!; + const serviceAccountEntity = rightEntity; const { profileUrl } = simplifyProperties( serviceAccountEntity.properties as ServiceAccount["properties"], @@ -510,31 +526,27 @@ export const constructUser = (params: { } } - const hasAvatar = avatarLinkAndEntities[0] + const [avatarLinkAndEntity] = avatarLinkAndEntities; + const hasAvatar = avatarLinkAndEntity ? { - // these are each arrays because each entity can have multiple revisions - linkEntity: avatarLinkAndEntities[0].linkEntity[0]!, - imageEntity: avatarLinkAndEntities[0] - .rightEntity[0]! as Entity, + linkEntity: avatarLinkAndEntity.linkEntity, + imageEntity: avatarLinkAndEntity.rightEntity as Entity, } : undefined; - const hasCoverImage = coverImageLinkAndEntities[0] + const [coverImageLinkAndEntity] = coverImageLinkAndEntities; + const hasCoverImage = coverImageLinkAndEntity ? { - // these are each arrays because each entity can have multiple revisions - linkEntity: coverImageLinkAndEntities[0].linkEntity[0]!, - imageEntity: coverImageLinkAndEntities[0] - .rightEntity[0]! as Entity, + linkEntity: coverImageLinkAndEntity.linkEntity, + imageEntity: coverImageLinkAndEntity.rightEntity as Entity, } : undefined; - const hasBio = hasBioLinkAndEntities[0] + const [hasBioLinkAndEntity] = hasBioLinkAndEntities; + const hasBio = hasBioLinkAndEntity ? { - // these are each arrays because each entity can have multiple revisions - linkEntity: hasBioLinkAndEntities[0] - .linkEntity[0]! as LinkEntity, - profileBioEntity: hasBioLinkAndEntities[0] - .rightEntity[0]! as Entity, + linkEntity: hasBioLinkAndEntity.linkEntity as LinkEntity, + profileBioEntity: hasBioLinkAndEntity.rightEntity as Entity, } : undefined; diff --git a/apps/hash-frontend/src/pages/dashboard/[dashboard-id].page.tsx b/apps/hash-frontend/src/pages/dashboard/[dashboard-id].page.tsx index 7f1dce0bf25..a2c201d0e25 100644 --- a/apps/hash-frontend/src/pages/dashboard/[dashboard-id].page.tsx +++ b/apps/hash-frontend/src/pages/dashboard/[dashboard-id].page.tsx @@ -230,7 +230,7 @@ const DashboardPage: NextPageWithLayout = () => { continue; } - const itemEntity = rightEntity[0] as + const itemEntity = rightEntity?.[0] as | HashEntity | undefined; diff --git a/apps/hash-frontend/src/pages/notifications.page/notifications-with-links-context.tsx b/apps/hash-frontend/src/pages/notifications.page/notifications-with-links-context.tsx index a5c2a7899e1..66cacaa47d3 100644 --- a/apps/hash-frontend/src/pages/notifications.page/notifications-with-links-context.tsx +++ b/apps/hash-frontend/src/pages/notifications.page/notifications-with-links-context.tsx @@ -221,25 +221,25 @@ export const useNotificationsWithLinksContextValue = isLinkAndRightEntityWithLinkType( systemLinkEntityTypes.occurredInEntity.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; const occurredInBlock = outgoingLinks.find( isLinkAndRightEntityWithLinkType( systemLinkEntityTypes.occurredInBlock.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; const occurredInText = outgoingLinks.find( isLinkAndRightEntityWithLinkType( systemLinkEntityTypes.occurredInText.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; const triggeredByUserEntity = outgoingLinks.find( isLinkAndRightEntityWithLinkType( systemLinkEntityTypes.triggeredByUser.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; if ( !occurredInEntity || @@ -247,9 +247,27 @@ export const useNotificationsWithLinksContextValue = !occurredInText || !triggeredByUserEntity ) { - throw new Error( - `Mention notification "${entityId}" is missing required links`, + /** + * The linked entities may be missing from the subgraph, e.g. + * because the user no longer has permission to view them – + * skip the notification rather than failing the whole list. + */ + const missingLinks = Object.entries({ + occurredInEntity, + occurredInBlock, + occurredInText, + triggeredByUser: triggeredByUserEntity, + }) + .filter(([, linkedEntity]) => !linkedEntity) + .map(([linkName]) => linkName) + .join(", "); + + // eslint-disable-next-line no-console -- TODO: consider using logger + console.warn( + `Skipping mention notification ${entityId} because the target of the following link(s) could not be resolved: ${missingLinks}`, ); + + return null; } const triggeredByUser = constructMinimalUser({ @@ -260,7 +278,7 @@ export const useNotificationsWithLinksContextValue = isLinkAndRightEntityWithLinkType( systemLinkEntityTypes.occurredInComment.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; if (occurredInComment) { return { @@ -297,25 +315,25 @@ export const useNotificationsWithLinksContextValue = isLinkAndRightEntityWithLinkType( systemLinkEntityTypes.occurredInEntity.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; const occurredInBlock = outgoingLinks.find( isLinkAndRightEntityWithLinkType( systemLinkEntityTypes.occurredInBlock.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; const triggeredByComment = outgoingLinks.find( isLinkAndRightEntityWithLinkType( systemLinkEntityTypes.triggeredByComment.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; const triggeredByUserEntity = outgoingLinks.find( isLinkAndRightEntityWithLinkType( systemLinkEntityTypes.triggeredByUser.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; if ( !occurredInEntity || @@ -323,9 +341,27 @@ export const useNotificationsWithLinksContextValue = !triggeredByComment || !triggeredByUserEntity ) { - throw new Error( - `Comment notification "${entityId}" is missing required links`, + /** + * The linked entities may be missing from the subgraph, e.g. + * because the user no longer has permission to view them – + * skip the notification rather than failing the whole list. + */ + const missingLinks = Object.entries({ + occurredInEntity, + occurredInBlock, + triggeredByComment, + triggeredByUser: triggeredByUserEntity, + }) + .filter(([, linkedEntity]) => !linkedEntity) + .map(([linkName]) => linkName) + .join(", "); + + // eslint-disable-next-line no-console -- TODO: consider using logger + console.warn( + `Skipping comment notification ${entityId} because the target of the following link(s) could not be resolved: ${missingLinks}`, ); + + return null; } const triggeredByUser = constructMinimalUser({ @@ -336,7 +372,7 @@ export const useNotificationsWithLinksContextValue = isLinkAndRightEntityWithLinkType( systemLinkEntityTypes.repliedToComment.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; if (repliedToComment) { return { diff --git a/apps/hash-frontend/src/pages/settings/integrations/linear/use-linear-integrations.ts b/apps/hash-frontend/src/pages/settings/integrations/linear/use-linear-integrations.ts index 8eef0e84a1b..a6142357b93 100644 --- a/apps/hash-frontend/src/pages/settings/integrations/linear/use-linear-integrations.ts +++ b/apps/hash-frontend/src/pages/settings/integrations/linear/use-linear-integrations.ts @@ -125,7 +125,19 @@ export const useLinearIntegrations = (): { }) .map((linkAndTarget) => { const linkEntity = linkAndTarget.linkEntity[0]!; - const rightEntity = linkAndTarget.rightEntity[0]!; + const rightEntity = linkAndTarget.rightEntity?.[0]; + + if (!rightEntity) { + /** + * The target of a `syncLinearDataWith` link is a user or org + * (web) entity, which is public – if the link is in the + * subgraph its target must be too. A missing target means the + * subgraph is internally inconsistent. + */ + throw new Error( + `Invariant violation: syncLinearDataWith link ${linkEntity.metadata.recordId.entityId} is missing its right (web) entity in the subgraph`, + ); + } const { linearTeamId: linearTeamIds } = simplifyProperties( linkEntity.properties as SyncLinearDataWithProperties, diff --git a/apps/hash-frontend/src/pages/shared/block-collection-contents.ts b/apps/hash-frontend/src/pages/shared/block-collection-contents.ts index 57fcb9f572b..cfd6a86d789 100644 --- a/apps/hash-frontend/src/pages/shared/block-collection-contents.ts +++ b/apps/hash-frontend/src/pages/shared/block-collection-contents.ts @@ -11,7 +11,11 @@ import { } from "@local/hash-isomorphic-utils/ontology-type-ids"; import type { QueryEntitySubgraphQueryVariables } from "../../graphql/api-types.gen"; -import type { EntityRootType, Subgraph } from "@blockprotocol/graph"; +import type { + EntityRootType, + LinkEntityAndRightEntity, + Subgraph, +} from "@blockprotocol/graph"; import type { EntityId, EntityUuid } from "@blockprotocol/type-system"; import type { HashEntity, HashLinkEntity } from "@local/hash-graph-sdk/entity"; import type { BlockCollectionContentItem } from "@local/hash-isomorphic-utils/entity"; @@ -129,34 +133,51 @@ export const getBlockCollectionContents = (params: { ); const outgoingContentLinks = getOutgoingLinkAndTargetEntities< - { - linkEntity: - | HashLinkEntity[] - | HashLinkEntity[]; - rightEntity: HashEntity[]; - }[] + LinkEntityAndRightEntity< + HashEntity, + | HashLinkEntity + | HashLinkEntity + >[] >(blockCollectionSubgraph, blockCollectionEntityId) - .filter( - ({ linkEntity: linkEntityRevisions }) => - linkEntityRevisions[0] && - linkEntityRevisions[0].metadata.entityTypeIds.includes( - isCanvas - ? systemLinkEntityTypes.hasSpatiallyPositionedContent - .linkEntityTypeId - : systemLinkEntityTypes.hasIndexedContent.linkEntityTypeId, - ), + .flatMap( + ({ + linkEntity: linkEntityRevisions, + rightEntity: rightEntityRevisions, + }) => { + const containsLinkEntity = linkEntityRevisions[0]; + + if ( + !containsLinkEntity?.metadata.entityTypeIds.includes( + isCanvas + ? systemLinkEntityTypes.hasSpatiallyPositionedContent + .linkEntityTypeId + : systemLinkEntityTypes.hasIndexedContent.linkEntityTypeId, + ) + ) { + return []; + } + + const rightEntity = rightEntityRevisions?.[0]; + + if (!rightEntity) { + /** + * The content link is present but no revision of the block it + * points at is visible in the queried interval, e.g. because the + * block has been archived or is not visible to the requester – + * skip it rather than failing the whole collection. + */ + return []; + } + + return [{ containsLinkEntity, rightEntity }]; + }, ) .sort((a, b) => - sortBlockCollectionLinks(a.linkEntity[0]!, b.linkEntity[0]!), + sortBlockCollectionLinks(a.containsLinkEntity, b.containsLinkEntity), ); return outgoingContentLinks.map( - ({ - linkEntity: containsLinkEntityRevisions, - rightEntity: rightEntityRevisions, - }) => { - const rightEntity = rightEntityRevisions[0]!; - + ({ containsLinkEntity, rightEntity }) => { const componentId = rightEntity.properties[ "https://hash.ai/@h/types/property-type/component-id/" @@ -165,20 +186,20 @@ export const getBlockCollectionContents = (params: { const blockChildEntity = getOutgoingLinkAndTargetEntities( blockCollectionSubgraph, rightEntity.metadata.recordId.entityId, - ).find( - ({ linkEntity: linkEntityRevisions }) => - linkEntityRevisions[0] && - linkEntityRevisions[0].metadata.entityTypeIds.includes( - systemLinkEntityTypes.hasData.linkEntityTypeId, - ), - )?.rightEntity[0]; + ).find(({ linkEntity: linkEntityRevisions }) => { + const linkEntityRevision = linkEntityRevisions[0]; + + return linkEntityRevision?.metadata.entityTypeIds.includes( + systemLinkEntityTypes.hasData.linkEntityTypeId, + ); + })?.rightEntity?.[0]; if (!blockChildEntity) { throw new Error("Error fetching block data"); } return { - linkEntity: containsLinkEntityRevisions[0]!, + linkEntity: containsLinkEntity, rightEntity: { metadata: rightEntity.metadata, properties: rightEntity.properties, diff --git a/apps/hash-frontend/src/pages/shared/block-collection/block-context-menu/block-select-data-modal.tsx b/apps/hash-frontend/src/pages/shared/block-collection/block-context-menu/block-select-data-modal.tsx index 174b23174e4..46f61a6a54f 100644 --- a/apps/hash-frontend/src/pages/shared/block-collection/block-context-menu/block-select-data-modal.tsx +++ b/apps/hash-frontend/src/pages/shared/block-collection/block-context-menu/block-select-data-modal.tsx @@ -72,23 +72,44 @@ export const BlockSelectDataModal: FunctionComponent< setBlockSubgraph(subgraph); }, [blockDataEntity, fetchBlockSubgraph, setBlockSubgraph]); - const existingQuery = useMemo(() => { + const { existingQueryLinkEntity, existingQuery } = useMemo(() => { if (!blockDataEntity || !blockSubgraph) { - return undefined; + return { existingQueryLinkEntity: undefined, existingQuery: undefined }; } const existingQueries = getOutgoingLinkAndTargetEntities( blockSubgraph, blockDataEntity.metadata.recordId.entityId, - ) - .filter(({ linkEntity: linkEntityRevisions }) => - linkEntityRevisions[0]?.metadata.entityTypeIds.includes( + ).flatMap(({ linkEntity: linkEntityRevisions, rightEntity }) => { + const linkEntity = linkEntityRevisions[0]; + + if ( + !linkEntity?.metadata.entityTypeIds.includes( blockProtocolLinkEntityTypes.hasQuery.linkEntityTypeId, - ), - ) - .map(({ rightEntity }) => rightEntity[0] as HashEntity); + ) + ) { + return []; + } + + /** + * The query entity may live in a different web to the block (it is + * created in the web of whichever user configured the block), so the + * viewer may be able to see the link but not its target. Track the + * link separately from the target – whether a query already exists + * must be decided on the link's presence, otherwise saving would + * create a duplicate query entity and link. + */ + const targetEntity = rightEntity?.[0] as HashEntity | undefined; + + return [{ linkEntity, targetEntity }]; + }); - return existingQueries[0]; + const [existingQueryPair] = existingQueries; + + return { + existingQueryLinkEntity: existingQueryPair?.linkEntity, + existingQuery: existingQueryPair?.targetEntity, + }; }, [blockSubgraph, blockDataEntity]); const [initialQueryEntityId, setInitialQueryEntityId] = useState(); @@ -138,6 +159,13 @@ export const BlockSelectDataModal: FunctionComponent< return; } + if (existingQueryLinkEntity) { + // the editor is not rendered in this state – guard against creating a duplicate query anyway + throw new Error( + `Cannot create a new query for block ${blockDataEntity.metadata.recordId.entityId}: its existing has-query link ${existingQueryLinkEntity.metadata.recordId.entityId} has a target which could not be resolved`, + ); + } + const { data: queryEntity } = await createEntity({ data: { entityTypeIds: [blockProtocolEntityTypes.query.entityTypeId], @@ -176,6 +204,7 @@ export const BlockSelectDataModal: FunctionComponent< refetchBlockSubgraph, blockDataEntity, existingQuery, + existingQueryLinkEntity, createEntity, onClose, ], @@ -250,25 +279,44 @@ export const BlockSelectDataModal: FunctionComponent< - + palette.red[70], + }} + > + This block already has a query attached, but it could not be loaded + – you may not have permission to view it. The query cannot be edited + or replaced here. + + ) : ( + + )} ); diff --git a/apps/hash-frontend/src/pages/shared/block-collection/mention-view/mention-display.tsx b/apps/hash-frontend/src/pages/shared/block-collection/mention-view/mention-display.tsx index fc1c4a9f367..0c83aba73e0 100644 --- a/apps/hash-frontend/src/pages/shared/block-collection/mention-view/mention-display.tsx +++ b/apps/hash-frontend/src/pages/shared/block-collection/mention-view/mention-display.tsx @@ -139,7 +139,7 @@ export const MentionDisplay: FunctionComponent = ({ ), ); - const targetEntity = outgoingLinkAndTargetEntities?.rightEntity[0]; + const targetEntity = outgoingLinkAndTargetEntities?.rightEntity?.[0]; const targetEntityLabel = targetEntity ? generateEntityLabel(entitySubgraph, targetEntity) diff --git a/apps/hash-frontend/src/pages/shared/block-collection/shared/mention-suggester.tsx b/apps/hash-frontend/src/pages/shared/block-collection/shared/mention-suggester.tsx index 4df9dbea351..7c290013fe1 100644 --- a/apps/hash-frontend/src/pages/shared/block-collection/shared/mention-suggester.tsx +++ b/apps/hash-frontend/src/pages/shared/block-collection/shared/mention-suggester.tsx @@ -398,7 +398,7 @@ export const MentionSuggester: FunctionComponent = ({ ).map>( ({ linkEntity: linkEntityRevisions, - rightEntity: rightEntityRevisions, + rightEntity: rightEntityRevisions = [], }) => { const [linkEntity] = linkEntityRevisions; const [rightEntity] = rightEntityRevisions; diff --git a/apps/hash-frontend/src/pages/shared/claims-table.tsx b/apps/hash-frontend/src/pages/shared/claims-table.tsx index 720f3db3581..ca0ea9a304a 100644 --- a/apps/hash-frontend/src/pages/shared/claims-table.tsx +++ b/apps/hash-frontend/src/pages/shared/claims-table.tsx @@ -424,18 +424,20 @@ export const ClaimsTable = memo( linkEntity, rightEntity, } of outgoingLinkAndTargetEntities) { + const linkEntityRevision = linkEntity[0]; + if ( - linkEntity[0]?.metadata.entityTypeIds.includes( + linkEntityRevision?.metadata.entityTypeIds.includes( systemLinkEntityTypes.hasObject.linkEntityTypeId, ) ) { - objectEntityId = rightEntity[0]?.entityId; + objectEntityId = rightEntity?.[0]?.entityId; } else if ( - linkEntity[0]?.metadata.entityTypeIds.includes( + linkEntityRevision?.metadata.entityTypeIds.includes( systemLinkEntityTypes.hasSubject.linkEntityTypeId, ) ) { - subjectEntityId = rightEntity[0]?.entityId; + subjectEntityId = rightEntity?.[0]?.entityId; } } diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index 4bbcbfacd02..4cf8aab5a69 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -135,13 +135,14 @@ export const IncomingLinksSection = ({ editorSubgraph.temporalAxes.resolved.variable.axis ], ).filter((incomingLinkAndSource) => { + const linkEntityRevision = incomingLinkAndSource.linkEntity[0]; + const leftEntityRevision = incomingLinkAndSource.leftEntity?.[0]; + return ( - incomingLinkAndSource.linkEntity[0] && - !draftLinksToArchive.includes( - incomingLinkAndSource.linkEntity[0].entityId, - ) && - incomingLinkAndSource.leftEntity[0] && - !incomingLinkAndSource.leftEntity[0].metadata.entityTypeIds.includes( + linkEntityRevision && + !draftLinksToArchive.includes(linkEntityRevision.entityId) && + leftEntityRevision && + !leftEntityRevision.metadata.entityTypeIds.includes( systemEntityTypes.claim.entityTypeId, ) ); diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index cc3096f117e..c8a9c875e95 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -358,13 +358,18 @@ export const IncomingLinksTable = memo( linkEntity, ); - for (const linkType of linkEntityClosedMultiType.allOf) { - linkEntityTypeIds.add(linkType.$id); + const leftEntity = leftEntityRevisions?.[0]; + if (!leftEntity) { + /** + * The source entity may be missing from the subgraph (e.g. if it + * was not resolved when the subgraph was produced) – skip the link + * rather than crashing. + */ + continue; } - const leftEntity = leftEntityRevisions[0]; - if (!leftEntity) { - throw new Error("Expected at least one left entity revision"); + for (const linkType of linkEntityClosedMultiType.allOf) { + linkEntityTypeIds.add(linkType.$id); } const leftEntityClosedMultiType = getClosedMultiEntityTypeFromMap( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index 336903f8b02..6aaaaeef0dd 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -337,13 +337,18 @@ export const OutgoingLinksTable = memo( linkEntity, ); - for (const linkType of linkEntityClosedMultiType.allOf) { - linkEntityTypeIds.add(linkType.$id); + const rightEntity = rightEntityRevisions?.[0]; + if (!rightEntity) { + /** + * The target entity may be missing from the subgraph (e.g. if it + * was not resolved when the subgraph was produced) – skip the link + * rather than crashing. + */ + continue; } - const rightEntity = rightEntityRevisions[0]; - if (!rightEntity) { - throw new Error("Expected at least one right entity revision"); + for (const linkType of linkEntityClosedMultiType.allOf) { + linkEntityTypeIds.add(linkType.$id); } const rightEntityClosedMultiType = getClosedMultiEntityTypeFromMap( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts index 11c69d00872..caa2ed268ad 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts @@ -145,7 +145,7 @@ export const useRows = ({ ); } - const targetEntityRevisions = [...entities.rightEntity]; + const targetEntityRevisions = [...(entities.rightEntity ?? [])]; targetEntityRevisions.sort((entityA, entityB) => intervalCompareWithInterval( entityA.metadata.temporalVersioning[variableAxis], @@ -156,9 +156,12 @@ export const useRows = ({ const latestTargetEntityRevision = targetEntityRevisions.at(-1); if (!latestTargetEntityRevision) { - throw new Error( - `Couldn't find a target link entity revision from ${entity.metadata.recordId.entityId}, this is likely an implementation bug in the stdlib`, - ); + /** + * The target entity may be missing from the subgraph (e.g. if it + * was not resolved when the subgraph was produced) – skip the + * link rather than crashing. + */ + continue; } const { entityTypeIds, recordId } = latestLinkEntityRevision.metadata; diff --git a/apps/hash-frontend/src/pages/shared/entity/get-entity-multi-type-dependencies.ts b/apps/hash-frontend/src/pages/shared/entity/get-entity-multi-type-dependencies.ts index 797dedcf686..7c444bba1a7 100644 --- a/apps/hash-frontend/src/pages/shared/entity/get-entity-multi-type-dependencies.ts +++ b/apps/hash-frontend/src/pages/shared/entity/get-entity-multi-type-dependencies.ts @@ -83,7 +83,7 @@ export const getEntityMultiTypeDependencies = ({ console.warn(`Link entity not found in subgraph`); } - const rightEntityRevision = rightEntity[0]; + const rightEntityRevision = rightEntity?.[0]; if (rightEntityRevision) { addEntityTypeIdsToSet( @@ -110,7 +110,7 @@ export const getEntityMultiTypeDependencies = ({ console.warn(`Link entity not found in subgraph`); } - const leftEntityRevision = leftEntity[0]; + const leftEntityRevision = leftEntity?.[0]; if (leftEntityRevision) { addEntityTypeIdsToSet(uniqueJoinedMultiEntityTypeIds, leftEntityRevision); diff --git a/apps/hash-frontend/src/pages/shared/use-flow-runs-usage.ts b/apps/hash-frontend/src/pages/shared/use-flow-runs-usage.ts index 57f13dd4818..8e7c4ee0672 100644 --- a/apps/hash-frontend/src/pages/shared/use-flow-runs-usage.ts +++ b/apps/hash-frontend/src/pages/shared/use-flow-runs-usage.ts @@ -125,13 +125,18 @@ export const useFlowRunsUsage = ({ ), ); - if (incurredInLinkAndEntities.length !== 1) { + const [incurredInLinkAndEntity] = incurredInLinkAndEntities; + + if ( + !incurredInLinkAndEntity || + incurredInLinkAndEntities.length !== 1 + ) { throw new Error( `Expected exactly one incurredIn link for Flow service usage record ${usageRecord.metadata.recordId.entityId}, got ${incurredInLinkAndEntities.length}.`, ); } - const incurredInEntity = incurredInLinkAndEntities[0]!.rightEntity[0]; + const incurredInEntity = incurredInLinkAndEntity.rightEntity?.[0]; if (!incurredInEntity) { return false; diff --git a/apps/hash-frontend/src/pages/signin.page.tsx b/apps/hash-frontend/src/pages/signin.page.tsx index 9226bf85fab..4a59c29acce 100644 --- a/apps/hash-frontend/src/pages/signin.page.tsx +++ b/apps/hash-frontend/src/pages/signin.page.tsx @@ -571,6 +571,9 @@ const SigninPage: NextPageWithLayout = () => {