From 4aeb34c29cd5c50d48df842310094c87abaffd01 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 12:51:42 +0000 Subject: [PATCH 1/7] Create the file's link entity in the web of the file entity The web the link is created in decides who can see it. Deriving it from the uploaded file entity keeps a link in the same web as the image it points at. --- apps/hash-frontend/src/shared/file-upload-context.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/hash-frontend/src/shared/file-upload-context.tsx b/apps/hash-frontend/src/shared/file-upload-context.tsx index 3a3d613aeab..8c058086cb0 100644 --- a/apps/hash-frontend/src/shared/file-upload-context.tsx +++ b/apps/hash-frontend/src/shared/file-upload-context.tsx @@ -8,6 +8,7 @@ import { } from "react"; import { v4 as uuid } from "uuid"; +import { extractWebIdFromEntityId } from "@blockprotocol/type-system"; import { HashEntity, HashLinkEntity, @@ -493,6 +494,9 @@ export const FileUploadsProvider = ({ children }: PropsWithChildren) => { ? mergePropertyObjectAndMetadata(linkProperties, undefined) : { value: {} }, makePublic, + webId: extractWebIdFromEntityId( + fileEntity.metadata.recordId.entityId, + ), }, }); From 251c04d8b23a2d1ba78cd6ae4dfa2e93045411d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 12:51:51 +0000 Subject: [PATCH 2/7] Add a migration moving avatar links into the image's web Each link outside the web of the image it points at is archived and replaced by a link in that web, carrying the same endpoints, properties and public visibility. A link which already has a replacement is only archived, so the migration is safe to re-run. --- ...ove-avatar-links-to-image-web.migration.ts | 25 +++ .../migrate-ontology-types/util.ts | 29 +++ .../util/relocate-links.ts | 204 ++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts create mode 100644 apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/relocate-links.ts diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts new file mode 100644 index 00000000000..53df9d7fbf6 --- /dev/null +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts @@ -0,0 +1,25 @@ +import { systemLinkEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; + +import { relocateLinksToRightEntityWeb } from "../util"; + +import type { MigrationFunction } from "../types"; + +/** + * This migration moves each `Has Avatar` link into the web of the avatar image it points at, where the link is in a + * different web. A link outside the image's web is only visible to actors with a role in the web it happens to be in, + * which for an organization's avatar excludes the other members of the organization. + */ +const migrate: MigrationFunction = async ({ + context, + authentication, + migrationState, +}) => { + await relocateLinksToRightEntityWeb(context, authentication, { + linkEntityTypeBaseUrl: + systemLinkEntityTypes.hasAvatar.linkEntityTypeBaseUrl, + }); + + return migrationState; +}; + +export default migrate; diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts index df6ca6a7a8f..f987ab527da 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts @@ -37,6 +37,7 @@ import { createDataType } from "../../ontology/primitive/data-type"; import { createEntityType } from "../../ontology/primitive/entity-type"; import { createPropertyType } from "../../ontology/primitive/property-type"; import { getOrCreateOwningWebId } from "../system-webs-and-entities"; +import { relocateWebLinksToRightEntityWeb } from "./util/relocate-links"; import { upgradeWebEntities } from "./util/upgrade-entities"; import { upgradeEntityTypeDependencies } from "./util/upgrade-entity-type-dependencies"; @@ -1018,3 +1019,31 @@ export const upgradeEntitiesToNewTypeVersion: ImpureGraphFunction< }); } }; + +/** + * Move the links of the given type into the web of their right entity, wherever the two differ. + */ +export const relocateLinksToRightEntityWeb: ImpureGraphFunction< + { linkEntityTypeBaseUrl: BaseUrl }, + Promise, + false, + true +> = async (context, authentication, { linkEntityTypeBaseUrl }) => { + /** + * We have to do this web-by-web because we don't have a single actor that can see all entities in all webs + */ + const { users, orgs } = await getExistingUsersAndOrgs( + context, + authentication, + {}, + ); + + for (const webEntity of [...users, ...orgs]) { + await relocateWebLinksToRightEntityWeb({ + authentication, + context, + linkEntityTypeBaseUrl, + webId: extractWebIdFromEntityId(webEntity.metadata.recordId.entityId), + }); + } +}; diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/relocate-links.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/relocate-links.ts new file mode 100644 index 00000000000..2224d15cc45 --- /dev/null +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/relocate-links.ts @@ -0,0 +1,204 @@ +import { + extractDraftIdFromEntityId, + extractEntityUuidFromEntityId, + extractWebIdFromEntityId, +} from "@blockprotocol/type-system"; +import { getWebMachineId } from "@local/hash-backend-utils/machine-actors"; +import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { queryPolicies } from "@local/hash-graph-sdk/policy"; +import { generateUuid } from "@local/hash-isomorphic-utils/generate-uuid"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; + +import { createLinkEntity } from "../../../knowledge/primitive/link-entity"; + +import type { ImpureGraphContext } from "../../../context-types"; +import type { + ActorEntityUuid, + BaseUrl, + EntityId, + EntityUuid, + WebId, +} from "@blockprotocol/type-system"; +import type { AllFilter } from "@local/hash-graph-client"; +import type { AuthenticationContext } from "@local/hash-graph-sdk/authentication-context"; +import type { CreateEntityParameters } from "@local/hash-graph-sdk/entity"; + +const publicViewPolicyName = (entityUuid: EntityUuid) => + `public-view-entity-${entityUuid}`; + +const getWebBotAuthentication = async (params: { + authentication: AuthenticationContext; + context: ImpureGraphContext; + webId: WebId; +}): Promise => { + const { authentication, context, webId } = params; + + const webBotAccountId = await getWebMachineId(context, authentication, { + webId, + }); + + if (!webBotAccountId) { + throw new Error(`Failed to get web bot account ID for web ID: ${webId}`); + } + + return { actorId: webBotAccountId as ActorEntityUuid }; +}; + +const linkFilter = (params: { + linkEntityTypeBaseUrl: BaseUrl; + webId: WebId; +}): AllFilter["all"] => [ + { + equal: [ + { path: ["type", "baseUrl"] }, + { parameter: params.linkEntityTypeBaseUrl }, + ], + }, + { equal: [{ path: ["webId"] }, { parameter: params.webId }] }, + { equal: [{ path: ["archived"] }, { parameter: false }] }, +]; + +const endpointFilter = (params: { + leftEntityId: EntityId; + rightEntityId: EntityId; +}): AllFilter["all"] => [ + { + equal: [ + { path: ["leftEntity", "uuid"] }, + { parameter: extractEntityUuidFromEntityId(params.leftEntityId) }, + ], + }, + { + equal: [ + { path: ["leftEntity", "webId"] }, + { parameter: extractWebIdFromEntityId(params.leftEntityId) }, + ], + }, + { + equal: [ + { path: ["rightEntity", "uuid"] }, + { parameter: extractEntityUuidFromEntityId(params.rightEntityId) }, + ], + }, + { + equal: [ + { path: ["rightEntity", "webId"] }, + { parameter: extractWebIdFromEntityId(params.rightEntityId) }, + ], + }, +]; + +/** + * Move the links of the given type in the given web into the web of their right entity, by archiving each link and + * creating a replacement which has the same endpoints, properties and public visibility. + * + * A link which already has a replacement in the right entity's web is only archived, so that a run which is + * interrupted between the two steps does not leave a duplicate behind. + */ +export const relocateWebLinksToRightEntityWeb = async (params: { + authentication: AuthenticationContext; + context: ImpureGraphContext; + linkEntityTypeBaseUrl: BaseUrl; + webId: WebId; +}) => { + const { authentication, context, linkEntityTypeBaseUrl, webId } = params; + + const webBotAuthentication = await getWebBotAuthentication({ + authentication, + context, + webId, + }); + + const { entities: linkEntities } = await queryEntities( + context, + webBotAuthentication, + { + filter: { all: linkFilter({ linkEntityTypeBaseUrl, webId }) }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + for (const linkEntity of linkEntities) { + const { linkData } = linkEntity; + const linkEntityId = linkEntity.metadata.recordId.entityId; + + if (!linkData) { + throw new Error(`Entity ${linkEntityId} has no link data`); + } + + const { leftEntityId, rightEntityId } = linkData; + const rightEntityWebId = extractWebIdFromEntityId(rightEntityId); + + if (rightEntityWebId === webId) { + continue; + } + + const rightWebBotAuthentication = await getWebBotAuthentication({ + authentication, + context, + webId: rightEntityWebId, + }); + + const { entities: existingReplacements } = await queryEntities( + context, + rightWebBotAuthentication, + { + filter: { + all: [ + ...linkFilter({ + linkEntityTypeBaseUrl, + webId: rightEntityWebId, + }), + ...endpointFilter({ leftEntityId, rightEntityId }), + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + if (existingReplacements.length === 0) { + const publiclyViewable = await queryPolicies( + context.graphApi, + authentication, + { + name: publicViewPolicyName( + extractEntityUuidFromEntityId(linkEntityId), + ), + }, + ).then((policies) => policies.length > 0); + + const entityUuid = generateUuid() as EntityUuid; + + const policies: CreateEntityParameters["policies"] = publiclyViewable + ? [ + { + name: publicViewPolicyName(entityUuid), + effect: "permit", + actions: ["viewEntity"], + principal: null, + } as const, + ] + : undefined; + + await createLinkEntity(context, rightWebBotAuthentication, { + webId: rightEntityWebId, + entityUuid, + entityTypeIds: linkEntity.metadata.entityTypeIds, + properties: linkEntity.propertiesWithMetadata, + linkData: { leftEntityId, rightEntityId }, + draft: extractDraftIdFromEntityId(linkEntityId) !== undefined, + policies, + }); + } + + await linkEntity.archive( + context.graphApi, + webBotAuthentication, + context.provenance, + ); + } +}; From 832ccbb89d6d9ae82b303fcaa3e59dbcd6a0894d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 10:12:09 +0000 Subject: [PATCH 3/7] Inline avatar link relocation into the migration --- ...ove-avatar-links-to-image-web.migration.ts | 212 +++++++++++++++++- .../migrate-ontology-types/util.ts | 29 --- .../util/relocate-links.ts | 204 ----------------- 3 files changed, 204 insertions(+), 241 deletions(-) delete mode 100644 apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/relocate-links.ts diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts index 53df9d7fbf6..73ad67860bb 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts @@ -1,23 +1,219 @@ +import { + extractDraftIdFromEntityId, + extractEntityUuidFromEntityId, + extractWebIdFromEntityId, +} from "@blockprotocol/type-system"; +import { getWebMachineId } from "@local/hash-backend-utils/machine-actors"; +import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { queryPolicies } from "@local/hash-graph-sdk/policy"; +import { generateUuid } from "@local/hash-isomorphic-utils/generate-uuid"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; import { systemLinkEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; -import { relocateLinksToRightEntityWeb } from "../util"; +import { createLinkEntity } from "../../../knowledge/primitive/link-entity"; +import { getExistingUsersAndOrgs } from "../util"; +import type { ImpureGraphContext } from "../../../context-types"; import type { MigrationFunction } from "../types"; +import type { + ActorEntityUuid, + EntityId, + EntityUuid, + WebId, +} from "@blockprotocol/type-system"; +import type { AllFilter } from "@local/hash-graph-client"; +import type { AuthenticationContext } from "@local/hash-graph-sdk/authentication-context"; +import type { CreateEntityParameters } from "@local/hash-graph-sdk/entity"; + +const hasAvatarBaseUrl = systemLinkEntityTypes.hasAvatar.linkEntityTypeBaseUrl; + +const publicViewPolicyName = (entityUuid: EntityUuid) => + `public-view-entity-${entityUuid}`; + +const getWebBotAuthentication = async ( + context: ImpureGraphContext, + authentication: AuthenticationContext, + webId: WebId, +): Promise => { + const webBotAccountId = await getWebMachineId(context, authentication, { + webId, + }); + + if (!webBotAccountId) { + throw new Error(`Failed to get web bot account ID for web ID: ${webId}`); + } + + return { actorId: webBotAccountId as ActorEntityUuid }; +}; + +const hasAvatarLinkFilter = (webId: WebId): AllFilter["all"] => [ + { + equal: [{ path: ["type", "baseUrl"] }, { parameter: hasAvatarBaseUrl }], + }, + { equal: [{ path: ["webId"] }, { parameter: webId }] }, + { equal: [{ path: ["archived"] }, { parameter: false }] }, +]; + +const endpointFilter = (params: { + leftEntityId: EntityId; + rightEntityId: EntityId; +}): AllFilter["all"] => [ + { + equal: [ + { path: ["leftEntity", "uuid"] }, + { parameter: extractEntityUuidFromEntityId(params.leftEntityId) }, + ], + }, + { + equal: [ + { path: ["leftEntity", "webId"] }, + { parameter: extractWebIdFromEntityId(params.leftEntityId) }, + ], + }, + { + equal: [ + { path: ["rightEntity", "uuid"] }, + { parameter: extractEntityUuidFromEntityId(params.rightEntityId) }, + ], + }, + { + equal: [ + { path: ["rightEntity", "webId"] }, + { parameter: extractWebIdFromEntityId(params.rightEntityId) }, + ], + }, +]; + +const moveWebAvatarLinksToImageWeb = async ( + context: ImpureGraphContext, + authentication: AuthenticationContext, + webId: WebId, +) => { + const webBotAuthentication = await getWebBotAuthentication( + context, + authentication, + webId, + ); + + const { entities: linkEntities } = await queryEntities( + context, + webBotAuthentication, + { + filter: { all: hasAvatarLinkFilter(webId) }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + for (const linkEntity of linkEntities) { + const { linkData } = linkEntity; + const linkEntityId = linkEntity.metadata.recordId.entityId; + + if (!linkData) { + throw new Error(`Entity ${linkEntityId} has no link data`); + } + + const { leftEntityId, rightEntityId } = linkData; + const imageWebId = extractWebIdFromEntityId(rightEntityId); + + if (imageWebId === webId) { + continue; + } + + const imageWebBotAuthentication = await getWebBotAuthentication( + context, + authentication, + imageWebId, + ); + + const { entities: existingReplacements } = await queryEntities( + context, + imageWebBotAuthentication, + { + filter: { + all: [ + ...hasAvatarLinkFilter(imageWebId), + ...endpointFilter({ leftEntityId, rightEntityId }), + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + if (existingReplacements.length === 0) { + const publiclyViewable = await queryPolicies( + context.graphApi, + authentication, + { + name: publicViewPolicyName( + extractEntityUuidFromEntityId(linkEntityId), + ), + }, + ).then((policies) => policies.length > 0); + + const entityUuid = generateUuid() as EntityUuid; + + const policies: CreateEntityParameters["policies"] = publiclyViewable + ? [ + { + name: publicViewPolicyName(entityUuid), + effect: "permit", + actions: ["viewEntity"], + principal: null, + } as const, + ] + : undefined; + + await createLinkEntity(context, imageWebBotAuthentication, { + webId: imageWebId, + entityUuid, + entityTypeIds: linkEntity.metadata.entityTypeIds, + properties: linkEntity.propertiesWithMetadata, + linkData: { leftEntityId, rightEntityId }, + draft: extractDraftIdFromEntityId(linkEntityId) !== undefined, + policies, + }); + } + + await linkEntity.archive( + context.graphApi, + webBotAuthentication, + context.provenance, + ); + } +}; /** - * This migration moves each `Has Avatar` link into the web of the avatar image it points at, where the link is in a - * different web. A link outside the image's web is only visible to actors with a role in the web it happens to be in, - * which for an organization's avatar excludes the other members of the organization. + * Moves each `Has Avatar` link into the web of the image it points at, by archiving the link and creating a + * replacement there with the same endpoints, properties, draft state and public visibility. A link outside the + * image's web is only visible to actors with a role in the web it is in, which for an organization's avatar + * excludes the other members of the organization. + * + * The webs are visited one at a time as each web's bot, since no single actor can see the entities of every web. + * A link which already has a replacement is only archived, so a run interrupted between the two steps leaves no + * duplicate behind. */ const migrate: MigrationFunction = async ({ context, authentication, migrationState, }) => { - await relocateLinksToRightEntityWeb(context, authentication, { - linkEntityTypeBaseUrl: - systemLinkEntityTypes.hasAvatar.linkEntityTypeBaseUrl, - }); + const { users, orgs } = await getExistingUsersAndOrgs( + context, + authentication, + {}, + ); + + for (const webEntity of [...users, ...orgs]) { + await moveWebAvatarLinksToImageWeb( + context, + authentication, + extractWebIdFromEntityId(webEntity.metadata.recordId.entityId), + ); + } return migrationState; }; diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts index f987ab527da..df6ca6a7a8f 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts @@ -37,7 +37,6 @@ import { createDataType } from "../../ontology/primitive/data-type"; import { createEntityType } from "../../ontology/primitive/entity-type"; import { createPropertyType } from "../../ontology/primitive/property-type"; import { getOrCreateOwningWebId } from "../system-webs-and-entities"; -import { relocateWebLinksToRightEntityWeb } from "./util/relocate-links"; import { upgradeWebEntities } from "./util/upgrade-entities"; import { upgradeEntityTypeDependencies } from "./util/upgrade-entity-type-dependencies"; @@ -1019,31 +1018,3 @@ export const upgradeEntitiesToNewTypeVersion: ImpureGraphFunction< }); } }; - -/** - * Move the links of the given type into the web of their right entity, wherever the two differ. - */ -export const relocateLinksToRightEntityWeb: ImpureGraphFunction< - { linkEntityTypeBaseUrl: BaseUrl }, - Promise, - false, - true -> = async (context, authentication, { linkEntityTypeBaseUrl }) => { - /** - * We have to do this web-by-web because we don't have a single actor that can see all entities in all webs - */ - const { users, orgs } = await getExistingUsersAndOrgs( - context, - authentication, - {}, - ); - - for (const webEntity of [...users, ...orgs]) { - await relocateWebLinksToRightEntityWeb({ - authentication, - context, - linkEntityTypeBaseUrl, - webId: extractWebIdFromEntityId(webEntity.metadata.recordId.entityId), - }); - } -}; diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/relocate-links.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/relocate-links.ts deleted file mode 100644 index 2224d15cc45..00000000000 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util/relocate-links.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { - extractDraftIdFromEntityId, - extractEntityUuidFromEntityId, - extractWebIdFromEntityId, -} from "@blockprotocol/type-system"; -import { getWebMachineId } from "@local/hash-backend-utils/machine-actors"; -import { queryEntities } from "@local/hash-graph-sdk/entity"; -import { queryPolicies } from "@local/hash-graph-sdk/policy"; -import { generateUuid } from "@local/hash-isomorphic-utils/generate-uuid"; -import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; - -import { createLinkEntity } from "../../../knowledge/primitive/link-entity"; - -import type { ImpureGraphContext } from "../../../context-types"; -import type { - ActorEntityUuid, - BaseUrl, - EntityId, - EntityUuid, - WebId, -} from "@blockprotocol/type-system"; -import type { AllFilter } from "@local/hash-graph-client"; -import type { AuthenticationContext } from "@local/hash-graph-sdk/authentication-context"; -import type { CreateEntityParameters } from "@local/hash-graph-sdk/entity"; - -const publicViewPolicyName = (entityUuid: EntityUuid) => - `public-view-entity-${entityUuid}`; - -const getWebBotAuthentication = async (params: { - authentication: AuthenticationContext; - context: ImpureGraphContext; - webId: WebId; -}): Promise => { - const { authentication, context, webId } = params; - - const webBotAccountId = await getWebMachineId(context, authentication, { - webId, - }); - - if (!webBotAccountId) { - throw new Error(`Failed to get web bot account ID for web ID: ${webId}`); - } - - return { actorId: webBotAccountId as ActorEntityUuid }; -}; - -const linkFilter = (params: { - linkEntityTypeBaseUrl: BaseUrl; - webId: WebId; -}): AllFilter["all"] => [ - { - equal: [ - { path: ["type", "baseUrl"] }, - { parameter: params.linkEntityTypeBaseUrl }, - ], - }, - { equal: [{ path: ["webId"] }, { parameter: params.webId }] }, - { equal: [{ path: ["archived"] }, { parameter: false }] }, -]; - -const endpointFilter = (params: { - leftEntityId: EntityId; - rightEntityId: EntityId; -}): AllFilter["all"] => [ - { - equal: [ - { path: ["leftEntity", "uuid"] }, - { parameter: extractEntityUuidFromEntityId(params.leftEntityId) }, - ], - }, - { - equal: [ - { path: ["leftEntity", "webId"] }, - { parameter: extractWebIdFromEntityId(params.leftEntityId) }, - ], - }, - { - equal: [ - { path: ["rightEntity", "uuid"] }, - { parameter: extractEntityUuidFromEntityId(params.rightEntityId) }, - ], - }, - { - equal: [ - { path: ["rightEntity", "webId"] }, - { parameter: extractWebIdFromEntityId(params.rightEntityId) }, - ], - }, -]; - -/** - * Move the links of the given type in the given web into the web of their right entity, by archiving each link and - * creating a replacement which has the same endpoints, properties and public visibility. - * - * A link which already has a replacement in the right entity's web is only archived, so that a run which is - * interrupted between the two steps does not leave a duplicate behind. - */ -export const relocateWebLinksToRightEntityWeb = async (params: { - authentication: AuthenticationContext; - context: ImpureGraphContext; - linkEntityTypeBaseUrl: BaseUrl; - webId: WebId; -}) => { - const { authentication, context, linkEntityTypeBaseUrl, webId } = params; - - const webBotAuthentication = await getWebBotAuthentication({ - authentication, - context, - webId, - }); - - const { entities: linkEntities } = await queryEntities( - context, - webBotAuthentication, - { - filter: { all: linkFilter({ linkEntityTypeBaseUrl, webId }) }, - temporalAxes: currentTimeInstantTemporalAxes, - includeDrafts: true, - includePermissions: false, - }, - ); - - for (const linkEntity of linkEntities) { - const { linkData } = linkEntity; - const linkEntityId = linkEntity.metadata.recordId.entityId; - - if (!linkData) { - throw new Error(`Entity ${linkEntityId} has no link data`); - } - - const { leftEntityId, rightEntityId } = linkData; - const rightEntityWebId = extractWebIdFromEntityId(rightEntityId); - - if (rightEntityWebId === webId) { - continue; - } - - const rightWebBotAuthentication = await getWebBotAuthentication({ - authentication, - context, - webId: rightEntityWebId, - }); - - const { entities: existingReplacements } = await queryEntities( - context, - rightWebBotAuthentication, - { - filter: { - all: [ - ...linkFilter({ - linkEntityTypeBaseUrl, - webId: rightEntityWebId, - }), - ...endpointFilter({ leftEntityId, rightEntityId }), - ], - }, - temporalAxes: currentTimeInstantTemporalAxes, - includeDrafts: true, - includePermissions: false, - }, - ); - - if (existingReplacements.length === 0) { - const publiclyViewable = await queryPolicies( - context.graphApi, - authentication, - { - name: publicViewPolicyName( - extractEntityUuidFromEntityId(linkEntityId), - ), - }, - ).then((policies) => policies.length > 0); - - const entityUuid = generateUuid() as EntityUuid; - - const policies: CreateEntityParameters["policies"] = publiclyViewable - ? [ - { - name: publicViewPolicyName(entityUuid), - effect: "permit", - actions: ["viewEntity"], - principal: null, - } as const, - ] - : undefined; - - await createLinkEntity(context, rightWebBotAuthentication, { - webId: rightEntityWebId, - entityUuid, - entityTypeIds: linkEntity.metadata.entityTypeIds, - properties: linkEntity.propertiesWithMetadata, - linkData: { leftEntityId, rightEntityId }, - draft: extractDraftIdFromEntityId(linkEntityId) !== undefined, - policies, - }); - } - - await linkEntity.archive( - context.graphApi, - webBotAuthentication, - context.provenance, - ); - } -}; From 9964b4c4fb10d295e31fa3315d8749c059c5deb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:46:26 +0000 Subject: [PATCH 4/7] Keep the workspace switcher avatar background opaque in every row state --- .../layout-with-sidebar/sidebar/workspace-switcher.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx index d39582aead4..4c8656af56a 100644 --- a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx +++ b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx @@ -179,6 +179,13 @@ export const WorkspaceSwitcher = () => { src={avatarSrc} size={26} borderRadius={isOrg ? "4px" : undefined} + sx={ + avatarSrc + ? { + backgroundColor: ({ palette }) => palette.common.white, + } + : undefined + } title={ webId === authenticatedUser.accountId ? authenticatedUser.displayName From 7872f9b3837a675b9c63ec19e807e7dca169c1d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:48:53 +0000 Subject: [PATCH 5/7] Keep the sidebar header avatar background opaque when the button is hovered --- .../layout-with-sidebar/sidebar/workspace-switcher.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx index 4c8656af56a..77ce44a90e6 100644 --- a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx +++ b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/workspace-switcher.tsx @@ -123,6 +123,11 @@ export const WorkspaceSwitcher = () => { src={activeWorkspace.avatarSrc} title={activeWorkspace.name} borderRadius={activeWorkspace.isOrg ? "4px" : undefined} + sx={ + activeWorkspace.avatarSrc + ? { backgroundColor: ({ palette }) => palette.common.white } + : undefined + } /> Date: Fri, 4 Sep 2026 15:57:24 +0000 Subject: [PATCH 6/7] Keep only the newest avatar link per user and organization Collect every `Has Avatar` link across all webs, group them by the user or organization they belong to, keep the one with the latest `createdAtDecisionTime` in the web of its image, and archive the rest. An image left with no link pointing at it in any web is archived too. --- ...ove-avatar-links-to-image-web.migration.ts | 435 +++++++++++++----- 1 file changed, 328 insertions(+), 107 deletions(-) diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts index 73ad67860bb..af906a65045 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts @@ -21,29 +21,26 @@ import type { EntityUuid, WebId, } from "@blockprotocol/type-system"; -import type { AllFilter } from "@local/hash-graph-client"; +import type { AllFilter, Filter } from "@local/hash-graph-client"; import type { AuthenticationContext } from "@local/hash-graph-sdk/authentication-context"; -import type { CreateEntityParameters } from "@local/hash-graph-sdk/entity"; +import type { + CreateEntityParameters, + HashEntity, +} from "@local/hash-graph-sdk/entity"; const hasAvatarBaseUrl = systemLinkEntityTypes.hasAvatar.linkEntityTypeBaseUrl; const publicViewPolicyName = (entityUuid: EntityUuid) => `public-view-entity-${entityUuid}`; -const getWebBotAuthentication = async ( - context: ImpureGraphContext, - authentication: AuthenticationContext, - webId: WebId, -): Promise => { - const webBotAccountId = await getWebMachineId(context, authentication, { - webId, - }); - - if (!webBotAccountId) { - throw new Error(`Failed to get web bot account ID for web ID: ${webId}`); - } - - return { actorId: webBotAccountId as ActorEntityUuid }; +type AvatarLink = { + linkEntity: HashEntity; + linkEntityId: EntityId; + leftEntityId: EntityId; + rightEntityId: EntityId; + webId: WebId; + webBotAuthentication: AuthenticationContext; + createdAt: number; }; const hasAvatarLinkFilter = (webId: WebId): AllFilter["all"] => [ @@ -54,47 +51,89 @@ const hasAvatarLinkFilter = (webId: WebId): AllFilter["all"] => [ { equal: [{ path: ["archived"] }, { parameter: false }] }, ]; -const endpointFilter = (params: { - leftEntityId: EntityId; - rightEntityId: EntityId; -}): AllFilter["all"] => [ +const rightEntityFilter = (rightEntityId: EntityId): AllFilter["all"] => [ { equal: [ - { path: ["leftEntity", "uuid"] }, - { parameter: extractEntityUuidFromEntityId(params.leftEntityId) }, + { path: ["rightEntity", "uuid"] }, + { parameter: extractEntityUuidFromEntityId(rightEntityId) }, ], }, { equal: [ - { path: ["leftEntity", "webId"] }, - { parameter: extractWebIdFromEntityId(params.leftEntityId) }, + { path: ["rightEntity", "webId"] }, + { parameter: extractWebIdFromEntityId(rightEntityId) }, ], }, +]; + +const endpointFilter = (params: { + leftEntityId: EntityId; + rightEntityId: EntityId; +}): AllFilter["all"] => [ { equal: [ - { path: ["rightEntity", "uuid"] }, - { parameter: extractEntityUuidFromEntityId(params.rightEntityId) }, + { path: ["leftEntity", "uuid"] }, + { parameter: extractEntityUuidFromEntityId(params.leftEntityId) }, ], }, { equal: [ - { path: ["rightEntity", "webId"] }, - { parameter: extractWebIdFromEntityId(params.rightEntityId) }, + { path: ["leftEntity", "webId"] }, + { parameter: extractWebIdFromEntityId(params.leftEntityId) }, ], }, + ...rightEntityFilter(params.rightEntityId), ]; -const moveWebAvatarLinksToImageWeb = async ( +const compareNewestFirst = (first: AvatarLink, second: AvatarLink): number => { + if (first.createdAt !== second.createdAt) { + return second.createdAt - first.createdAt; + } + + const firstInImageWeb = + first.webId === extractWebIdFromEntityId(first.rightEntityId); + const secondInImageWeb = + second.webId === extractWebIdFromEntityId(second.rightEntityId); + + return Number(secondInImageWeb) - Number(firstInImageWeb); +}; + +const createWebBotAuthenticationGetter = ( context: ImpureGraphContext, authentication: AuthenticationContext, - webId: WebId, ) => { - const webBotAuthentication = await getWebBotAuthentication( - context, - authentication, - webId, - ); + const webBotAuthentications = new Map(); + + return async (webId: WebId): Promise => { + const cachedAuthentication = webBotAuthentications.get(webId); + + if (cachedAuthentication) { + return cachedAuthentication; + } + + const webBotAccountId = await getWebMachineId(context, authentication, { + webId, + }); + + if (!webBotAccountId) { + throw new Error(`Failed to get web bot account ID for web ID: ${webId}`); + } + + const webBotAuthentication = { + actorId: webBotAccountId as ActorEntityUuid, + }; + + webBotAuthentications.set(webId, webBotAuthentication); + + return webBotAuthentication; + }; +}; +const collectWebAvatarLinks = async ( + context: ImpureGraphContext, + webBotAuthentication: AuthenticationContext, + webId: WebId, +): Promise => { const { entities: linkEntities } = await queryEntities( context, webBotAuthentication, @@ -106,112 +145,294 @@ const moveWebAvatarLinksToImageWeb = async ( }, ); - for (const linkEntity of linkEntities) { - const { linkData } = linkEntity; + return linkEntities.map((linkEntity) => { const linkEntityId = linkEntity.metadata.recordId.entityId; - if (!linkData) { + if (!linkEntity.linkData) { throw new Error(`Entity ${linkEntityId} has no link data`); } - const { leftEntityId, rightEntityId } = linkData; - const imageWebId = extractWebIdFromEntityId(rightEntityId); + return { + linkEntity, + linkEntityId, + leftEntityId: linkEntity.linkData.leftEntityId, + rightEntityId: linkEntity.linkData.rightEntityId, + webId, + webBotAuthentication, + createdAt: new Date( + linkEntity.metadata.provenance.createdAtDecisionTime, + ).getTime(), + }; + }); +}; - if (imageWebId === webId) { - continue; - } +const archiveAvatarLink = async ( + context: ImpureGraphContext, + avatarLink: AvatarLink, +) => + avatarLink.linkEntity.archive( + context.graphApi, + avatarLink.webBotAuthentication, + context.provenance, + ); - const imageWebBotAuthentication = await getWebBotAuthentication( - context, +const moveAvatarLinkToImageWeb = async ( + context: ImpureGraphContext, + authentication: AuthenticationContext, + imageWebBotAuthentication: AuthenticationContext, + avatarLink: AvatarLink, +) => { + const { linkEntity, linkEntityId, leftEntityId, rightEntityId } = avatarLink; + const imageWebId = extractWebIdFromEntityId(rightEntityId); + + const { entities: existingReplacements } = await queryEntities( + context, + imageWebBotAuthentication, + { + filter: { + all: [ + ...hasAvatarLinkFilter(imageWebId), + ...endpointFilter({ leftEntityId, rightEntityId }), + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + if (existingReplacements.length === 0) { + const publiclyViewable = await queryPolicies( + context.graphApi, authentication, - imageWebId, - ); + { + name: publicViewPolicyName(extractEntityUuidFromEntityId(linkEntityId)), + }, + ).then((policies) => policies.length > 0); - const { entities: existingReplacements } = await queryEntities( - context, - imageWebBotAuthentication, + const entityUuid = generateUuid() as EntityUuid; + + const policies: CreateEntityParameters["policies"] = publiclyViewable + ? [ + { + name: publicViewPolicyName(entityUuid), + effect: "permit", + actions: ["viewEntity"], + principal: null, + } as const, + ] + : undefined; + + await createLinkEntity(context, imageWebBotAuthentication, { + webId: imageWebId, + entityUuid, + entityTypeIds: linkEntity.metadata.entityTypeIds, + properties: linkEntity.propertiesWithMetadata, + linkData: { leftEntityId, rightEntityId }, + draft: extractDraftIdFromEntityId(linkEntityId) !== undefined, + policies, + }); + } + + await archiveAvatarLink(context, avatarLink); +}; + +const findReferencedImages = async ( + context: ImpureGraphContext, + webBotAuthentication: AuthenticationContext, + webId: WebId, + imageEntityIds: EntityId[], +): Promise> => { + const incomingLinkFilter: Filter = { + all: [ + { equal: [{ path: ["webId"] }, { parameter: webId }] }, + { equal: [{ path: ["archived"] }, { parameter: false }] }, { - filter: { - all: [ - ...hasAvatarLinkFilter(imageWebId), - ...endpointFilter({ leftEntityId, rightEntityId }), - ], - }, - temporalAxes: currentTimeInstantTemporalAxes, - includeDrafts: true, - includePermissions: false, + any: imageEntityIds.map((imageEntityId) => ({ + all: rightEntityFilter(imageEntityId), + })), }, - ); + ], + }; - if (existingReplacements.length === 0) { - const publiclyViewable = await queryPolicies( - context.graphApi, - authentication, - { - name: publicViewPolicyName( - extractEntityUuidFromEntityId(linkEntityId), - ), - }, - ).then((policies) => policies.length > 0); - - const entityUuid = generateUuid() as EntityUuid; - - const policies: CreateEntityParameters["policies"] = publiclyViewable - ? [ - { - name: publicViewPolicyName(entityUuid), - effect: "permit", - actions: ["viewEntity"], - principal: null, - } as const, - ] - : undefined; - - await createLinkEntity(context, imageWebBotAuthentication, { - webId: imageWebId, - entityUuid, - entityTypeIds: linkEntity.metadata.entityTypeIds, - properties: linkEntity.propertiesWithMetadata, - linkData: { leftEntityId, rightEntityId }, - draft: extractDraftIdFromEntityId(linkEntityId) !== undefined, - policies, - }); - } + const { entities: incomingLinks } = await queryEntities( + context, + webBotAuthentication, + { + filter: incomingLinkFilter, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); - await linkEntity.archive( + return new Set( + incomingLinks.flatMap((incomingLink) => + incomingLink.linkData ? [incomingLink.linkData.rightEntityId] : [], + ), + ); +}; + +const archiveImage = async ( + context: ImpureGraphContext, + imageWebBotAuthentication: AuthenticationContext, + imageEntityId: EntityId, +) => { + const { entities: images } = await queryEntities( + context, + imageWebBotAuthentication, + { + filter: { + all: [ + { + equal: [ + { path: ["uuid"] }, + { parameter: extractEntityUuidFromEntityId(imageEntityId) }, + ], + }, + { + equal: [ + { path: ["webId"] }, + { parameter: extractWebIdFromEntityId(imageEntityId) }, + ], + }, + { equal: [{ path: ["archived"] }, { parameter: false }] }, + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: true, + includePermissions: false, + }, + ); + + for (const image of images) { + await image.archive( context.graphApi, - webBotAuthentication, + imageWebBotAuthentication, context.provenance, ); } }; /** - * Moves each `Has Avatar` link into the web of the image it points at, by archiving the link and creating a - * replacement there with the same endpoints, properties, draft state and public visibility. A link outside the - * image's web is only visible to actors with a role in the web it is in, which for an organization's avatar - * excludes the other members of the organization. + * Leaves each user and organization with a single `Has Avatar` link, in the web of the image it points at. + * + * A link outside the image's web is only visible to actors with a role in the web it is in, which for an + * organization's avatar excludes the other members of the organization. An organization whose avatar was + * invisible to a member looked to that member as having none, so several links to several images can exist for + * the same organization, spread across the personal webs of whoever uploaded them. + * + * The webs are visited one at a time as each web's bot, since no single actor can see the entities of every web, + * and the links found are grouped by the user or organization they belong to. Within a group the link with the + * latest `createdAtDecisionTime` wins; a winner outside its image's web is archived and recreated there with the + * same endpoints, properties, draft state and public visibility, and every other link in the group is archived. + * An image left with no non-archived link pointing at it, in any web, is archived too. * - * The webs are visited one at a time as each web's bot, since no single actor can see the entities of every web. - * A link which already has a replacement is only archived, so a run interrupted between the two steps leaves no - * duplicate behind. + * A winner which already has a replacement in the image's web is only archived, and a run which finds one link + * per user or organization, in the image's web, changes nothing, so the migration can be rerun or resumed after + * an interruption without leaving duplicates. */ const migrate: MigrationFunction = async ({ context, authentication, migrationState, }) => { + const getWebBotAuthentication = createWebBotAuthenticationGetter( + context, + authentication, + ); + const { users, orgs } = await getExistingUsersAndOrgs( context, authentication, {}, ); - for (const webEntity of [...users, ...orgs]) { - await moveWebAvatarLinksToImageWeb( + const webIds = [...users, ...orgs].map((webEntity) => + extractWebIdFromEntityId(webEntity.metadata.recordId.entityId), + ); + + const avatarLinksByLeftEntity = new Map(); + + for (const webId of webIds) { + const avatarLinks = await collectWebAvatarLinks( context, - authentication, - extractWebIdFromEntityId(webEntity.metadata.recordId.entityId), + await getWebBotAuthentication(webId), + webId, + ); + + for (const avatarLink of avatarLinks) { + const leftEntityLinks = + avatarLinksByLeftEntity.get(avatarLink.leftEntityId) ?? []; + + leftEntityLinks.push(avatarLink); + avatarLinksByLeftEntity.set(avatarLink.leftEntityId, leftEntityLinks); + } + } + + const keptImageEntityIds = new Set(); + const supersededImageEntityIds = new Set(); + + for (const avatarLinks of avatarLinksByLeftEntity.values()) { + const [newestLink, ...supersededLinks] = avatarLinks + .slice() + .sort(compareNewestFirst); + + if (!newestLink) { + continue; + } + + keptImageEntityIds.add(newestLink.rightEntityId); + + const imageWebId = extractWebIdFromEntityId(newestLink.rightEntityId); + + if (newestLink.webId !== imageWebId) { + await moveAvatarLinkToImageWeb( + context, + authentication, + await getWebBotAuthentication(imageWebId), + newestLink, + ); + } + + for (const supersededLink of supersededLinks) { + await archiveAvatarLink(context, supersededLink); + supersededImageEntityIds.add(supersededLink.rightEntityId); + } + } + + const candidateImageEntityIds = [...supersededImageEntityIds].filter( + (imageEntityId) => !keptImageEntityIds.has(imageEntityId), + ); + + if (candidateImageEntityIds.length === 0) { + return migrationState; + } + + const referencedImageEntityIds = new Set(); + + for (const webId of webIds) { + const referencedInWeb = await findReferencedImages( + context, + await getWebBotAuthentication(webId), + webId, + candidateImageEntityIds, + ); + + for (const imageEntityId of referencedInWeb) { + referencedImageEntityIds.add(imageEntityId); + } + } + + for (const imageEntityId of candidateImageEntityIds) { + if (referencedImageEntityIds.has(imageEntityId)) { + continue; + } + + await archiveImage( + context, + await getWebBotAuthentication(extractWebIdFromEntityId(imageEntityId)), + imageEntityId, ); } From 600c40fc606d67bb713f19edb475ac38d4823fd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 17:26:39 +0000 Subject: [PATCH 7/7] Paginate migration entity queries so archive decisions see every entity --- ...ove-avatar-links-to-image-web.migration.ts | 6 ++-- .../migrate-ontology-types/util.ts | 30 +++++++++++++++++-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts index af906a65045..7eae6a91db5 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/migrations/031-move-avatar-links-to-image-web.migration.ts @@ -11,7 +11,7 @@ import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/gra import { systemLinkEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import { createLinkEntity } from "../../../knowledge/primitive/link-entity"; -import { getExistingUsersAndOrgs } from "../util"; +import { getExistingUsersAndOrgs, queryAllEntityPages } from "../util"; import type { ImpureGraphContext } from "../../../context-types"; import type { MigrationFunction } from "../types"; @@ -134,7 +134,7 @@ const collectWebAvatarLinks = async ( webBotAuthentication: AuthenticationContext, webId: WebId, ): Promise => { - const { entities: linkEntities } = await queryEntities( + const linkEntities = await queryAllEntityPages( context, webBotAuthentication, { @@ -255,7 +255,7 @@ const findReferencedImages = async ( ], }; - const { entities: incomingLinks } = await queryEntities( + const incomingLinks = await queryAllEntityPages( context, webBotAuthentication, { diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts index df6ca6a7a8f..e3b8f94d62c 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts @@ -68,6 +68,10 @@ import type { VersionedUrl, } from "@blockprotocol/type-system"; import type { UpdatePropertyType } from "@local/hash-graph-client"; +import type { + HashEntity, + QueryEntitiesRequest, +} from "@local/hash-graph-sdk/entity"; import type { ConstructDataTypeParams } from "@local/hash-graph-sdk/ontology"; import type { SchemaKind, @@ -933,12 +937,32 @@ export const getEntitiesByType: ImpureGraphFunction< includePermissions: false, }).then(({ entities }) => entities); +export const queryAllEntityPages: ImpureGraphFunction< + QueryEntitiesRequest, + Promise +> = async (context, authentication, request) => { + const entities: HashEntity[] = []; + let cursor = request.cursor; + + do { + const response = await queryEntities(context, authentication, { + ...request, + cursor, + }); + + entities.push(...response.entities); + cursor = response.cursor; + } while (cursor); + + return entities; +}; + export const getExistingUsersAndOrgs: ImpureGraphFunction< Record, Promise<{ users: Entity[]; orgs: Entity[] }> > = async (context, authentication) => { - const [{ entities: users }, { entities: orgs }] = await Promise.all([ - queryEntities(context, authentication, { + const [users, orgs] = await Promise.all([ + queryAllEntityPages(context, authentication, { filter: { all: [ { @@ -953,7 +977,7 @@ export const getExistingUsersAndOrgs: ImpureGraphFunction< includeDrafts: false, includePermissions: false, }), - queryEntities(context, authentication, { + queryAllEntityPages(context, authentication, { filter: { all: [ {