Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/fe-1172-optional-link-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@blockprotocol/graph": minor

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@blockprotocol/graph is at 0.4.3, i.e. pre-1.0. Under semver's 0.x convention β€” and changesets' handling of 0.x packages β€” a breaking change is expressed as a minor bump (0.4.x β†’ 0.5.0); a major bump would promote the package to 1.0.0, which is not intended here. Note also that runtime behavior is unchanged: these functions could always return undefined targets, and the previous types overpromised β€” this change surfaces that pre-existing unsoundness at compile time rather than introducing new behavior.

---

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.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
16 changes: 14 additions & 2 deletions apps/hash-frontend/src/components/hooks/use-account-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
66 changes: 39 additions & 27 deletions apps/hash-frontend/src/lib/user-and-org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`,
);
}

Expand Down Expand Up @@ -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;
Expand All @@ -468,7 +484,7 @@ export const constructUser = (params: {
if (
entityTypeIds.includes(systemLinkEntityTypes.hasAvatar.linkEntityTypeId)
) {
avatarLinkAndEntities.push(linkAndEntity);
avatarLinkAndEntities.push({ linkEntity, rightEntity });
continue;
}

Expand All @@ -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;
}

Expand All @@ -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"],
Expand All @@ -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<ImageFile>,
linkEntity: avatarLinkAndEntity.linkEntity,
imageEntity: avatarLinkAndEntity.rightEntity as Entity<ImageFile>,
}
: 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<ImageFile>,
linkEntity: coverImageLinkAndEntity.linkEntity,
imageEntity: coverImageLinkAndEntity.rightEntity as Entity<ImageFile>,
}
: 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<HasBio>,
profileBioEntity: hasBioLinkAndEntities[0]
.rightEntity[0]! as Entity<ProfileBio>,
linkEntity: hasBioLinkAndEntity.linkEntity as LinkEntity<HasBio>,
profileBioEntity: hasBioLinkAndEntity.rightEntity as Entity<ProfileBio>,
}
: undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand All @@ -260,7 +260,7 @@ export const useNotificationsWithLinksContextValue =
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.occurredInComment.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];

if (occurredInComment) {
return {
Expand Down Expand Up @@ -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 ||
Expand All @@ -336,7 +336,7 @@ export const useNotificationsWithLinksContextValue =
isLinkAndRightEntityWithLinkType(
systemLinkEntityTypes.repliedToComment.linkEntityTypeId,
),
)?.rightEntity[0];
)?.rightEntity?.[0];

if (repliedToComment) {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Query>);
.map(({ rightEntity }) => rightEntity![0] as HashEntity<Query>);

return existingQueries[0];
}, [blockSubgraph, blockDataEntity]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export const MentionDisplay: FunctionComponent<MentionDisplayProps> = ({
),
);

const targetEntity = outgoingLinkAndTargetEntities?.rightEntity[0];
const targetEntity = outgoingLinkAndTargetEntities?.rightEntity?.[0];

const targetEntityLabel = targetEntity
? generateEntityLabel(entitySubgraph, targetEntity)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ export const MentionSuggester: FunctionComponent<MentionSuggesterProps> = ({
).map<Extract<SubMenuItem, { kind: "outgoing-link" }>>(
({
linkEntity: linkEntityRevisions,
rightEntity: rightEntityRevisions,
rightEntity: rightEntityRevisions = [],
}) => {
const [linkEntity] = linkEntityRevisions;
const [rightEntity] = rightEntityRevisions;
Expand Down
4 changes: 2 additions & 2 deletions apps/hash-frontend/src/pages/shared/claims-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading