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..1e58782fea0 100644 --- a/apps/hash-api/src/graphql/resolvers/knowledge/org/shared.ts +++ b/apps/hash-api/src/graphql/resolvers/knowledge/org/shared.ts @@ -82,7 +82,7 @@ export const getPendingOrgInvitationsFromSubgraph = async ( ); } - const orgEntity = linkAndOrgEntities[0]?.leftEntity[0]; + const orgEntity = linkAndOrgEntities[0]?.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..2757b5b7b09 100644 --- a/apps/hash-frontend/src/components/hooks/use-account-pages.ts +++ b/apps/hash-frontend/src/components/hooks/use-account-pages.ts @@ -71,12 +71,24 @@ export const useAccountPages = ( ); const parentLink = pageOutgoingLinks.find(({ linkEntity }) => - linkEntity[0]!.metadata.entityTypeIds.includes( + linkEntity[0]?.metadata.entityTypeIds.includes( systemLinkEntityTypes.hasParent.linkEntityTypeId, ), ); - const parentPage = parentLink?.rightEntity[0] ?? null; + const parentPage = parentLink ? parentLink.rightEntity?.[0] : null; + + if (parentLink && !parentPage) { + /** + * The query resolves one level of outgoing `has-parent` links, so if + * the link is in the subgraph its target page must be too. A missing + * parent page means the subgraph is internally inconsistent, which we + * surface loudly rather than silently treating the page as parentless. + */ + throw new Error( + `Invariant violation: has-parent link ${parentLink.linkEntity[0]?.metadata.recordId.entityId} on page ${latestPage.metadata.recordId.entityId} is missing its right (parent page) entity in the subgraph`, + ); + } 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/notifications.page/notifications-with-links-context.tsx b/apps/hash-frontend/src/pages/notifications.page/notifications-with-links-context.tsx index a5c2a7899e1..e312e0f068e 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 || @@ -260,7 +260,7 @@ export const useNotificationsWithLinksContextValue = isLinkAndRightEntityWithLinkType( systemLinkEntityTypes.occurredInComment.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; if (occurredInComment) { return { @@ -297,25 +297,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 || @@ -336,7 +336,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..0cb23b28ff9 100644 --- a/apps/hash-frontend/src/pages/shared/block-collection-contents.ts +++ b/apps/hash-frontend/src/pages/shared/block-collection-contents.ts @@ -171,7 +171,7 @@ export const getBlockCollectionContents = (params: { linkEntityRevisions[0].metadata.entityTypeIds.includes( systemLinkEntityTypes.hasData.linkEntityTypeId, ), - )?.rightEntity[0]; + )?.rightEntity?.[0]; if (!blockChildEntity) { throw new Error("Error fetching block data"); 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..87e80eb2eb5 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 @@ -81,12 +81,15 @@ export const BlockSelectDataModal: FunctionComponent< blockSubgraph, blockDataEntity.metadata.recordId.entityId, ) - .filter(({ linkEntity: linkEntityRevisions }) => - linkEntityRevisions[0]?.metadata.entityTypeIds.includes( - blockProtocolLinkEntityTypes.hasQuery.linkEntityTypeId, - ), + .filter( + ({ linkEntity: linkEntityRevisions, rightEntity }) => + linkEntityRevisions[0]?.metadata.entityTypeIds.includes( + blockProtocolLinkEntityTypes.hasQuery.linkEntityTypeId, + ) && + // the target entity may be missing from the subgraph – skip the link + !!rightEntity?.[0], ) - .map(({ rightEntity }) => rightEntity[0] as HashEntity); + .map(({ rightEntity }) => rightEntity![0] as HashEntity); return existingQueries[0]; }, [blockSubgraph, blockDataEntity]); 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..c389d81697c 100644 --- a/apps/hash-frontend/src/pages/shared/claims-table.tsx +++ b/apps/hash-frontend/src/pages/shared/claims-table.tsx @@ -429,13 +429,13 @@ export const ClaimsTable = memo( systemLinkEntityTypes.hasObject.linkEntityTypeId, ) ) { - objectEntityId = rightEntity[0]?.entityId; + objectEntityId = rightEntity?.[0]?.entityId; } else if ( linkEntity[0]?.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..03a3896a135 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 @@ -140,7 +140,7 @@ export const IncomingLinksSection = ({ !draftLinksToArchive.includes( incomingLinkAndSource.linkEntity[0].entityId, ) && - incomingLinkAndSource.leftEntity[0] && + incomingLinkAndSource.leftEntity?.[0] && !incomingLinkAndSource.leftEntity[0].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..785c63216b9 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 @@ -362,9 +362,14 @@ export const IncomingLinksTable = memo( linkEntityTypeIds.add(linkType.$id); } - const leftEntity = leftEntityRevisions[0]; + const leftEntity = leftEntityRevisions?.[0]; if (!leftEntity) { - throw new Error("Expected at least one left entity revision"); + /** + * 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 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..cbd6924c2e2 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 @@ -341,9 +341,14 @@ export const OutgoingLinksTable = memo( linkEntityTypeIds.add(linkType.$id); } - const rightEntity = rightEntityRevisions[0]; + const rightEntity = rightEntityRevisions?.[0]; if (!rightEntity) { - throw new Error("Expected at least one right entity revision"); + /** + * 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 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..864172aaea3 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 @@ -131,7 +131,7 @@ export const useFlowRunsUsage = ({ ); } - const incurredInEntity = incurredInLinkAndEntities[0]!.rightEntity[0]; + const incurredInEntity = incurredInLinkAndEntities[0]!.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 = () => {