|null $targets collection of provider identifiers * * @return array collection of available providers e.g. ['provider1' => IProvider, 'provider2' => IProvider] */ public function providerList(string $tenantId, string $userId, array|null $targets = []): array { // retrieve providers from provider manager return $this->providerManager->providers(ProviderBaseInterface::TYPE_CHRONO, $targets ?: null); } /** * Retrieve specific provider for specific user * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param string $target provider identifier * * @return ProviderBaseInterface * @throws InvalidArgumentException */ public function providerFetch(string $tenantId, string $userId, string $target): ProviderBaseInterface { // retrieve provider $providers = $this->providerList($tenantId, $userId, [$target]); if (!isset($providers[$target])) { throw new InvalidArgumentException("Provider '$target' not found"); } return $providers[$target]; } /** * Confirm which providers are available * * @param array $targets collection of provider identifiers to confirm * * @return array collection of providers and their availability status e.g. ['provider1' => true, 'provider2' => false] */ public function providerExtant(string $tenantId, string $userId, array $targets): array { // determine which providers are available $providersResolved = $this->providerList($tenantId, $userId, $targets); $providersAvailable = array_keys($providersResolved); $providersUnavailable = array_diff($targets, $providersAvailable); // construct response data $responseData = array_merge( array_fill_keys($providersAvailable, true), array_fill_keys($providersUnavailable, false) ); return $responseData; } /** * Retrieve available services for specific user * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param ResourceIdentifiers|null $targets list of provider and service identifiers * * @return array> collections of available services e.g. ['provider1' => ['service1' => IServiceBase], 'provider2' => ['service2' => IServiceBase]] */ public function serviceList(string $tenantId, string $userId, ?ResourceIdentifiers $targets = null): array { // retrieve providers $providerFilter = $targets !== null ? $targets->providers() : null; $providers = $this->providerList($tenantId, $userId, $providerFilter); // retrieve services for each provider $responseData = []; foreach ($providers as $provider) { if ($targets !== null) { $servicesSelected = $targets->byProvider($provider->identifier()); $servicesFilter = $servicesSelected->services(); } else { $servicesFilter = []; } $services = $provider->serviceList($tenantId, $userId, $servicesFilter); $responseData[$provider->identifier()] = $services; } return $responseData; } /** * Retrieve service for specific user * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param string $providerId provider identifier * @param string|int $serviceId service identifier * * @return ServiceBaseInterface * @throws InvalidArgumentException */ public function serviceFetch(string $tenantId, string $userId, string $providerId, string|int $serviceId): ServiceBaseInterface { // retrieve provider and service $service = $this->providerFetch($tenantId, $userId, $providerId)->serviceFetch($tenantId, $userId, $serviceId); if ($service === null) { throw new InvalidArgumentException("Service '$serviceId' not found for provider '$providerId'"); } // retrieve services return $service; } /** * Confirm which services are available * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param ResourceIdentifiers $targets collection of provider and service identifiers to confirm * * @return array collection of providers and their availability status e.g. ['provider1' => ['service1' => false], 'provider2' => ['service2' => true, 'service3' => true]] */ public function serviceExtant(string $tenantId, string $userId, ResourceIdentifiers $targets): array { // retrieve available providers $providersRequested = $targets->providers(); $providers = $this->providerList($tenantId, $userId, $providersRequested); $providersUnavailable = array_diff($providersRequested, array_keys($providers)); // initialize response with unavailable providers $responseData = array_fill_keys($providersUnavailable, false); // retrieve services for each available provider foreach ($providers as $providerId => $provider) { $servicesRequested = $targets->byProvider($providerId)->services(); $responseData[$providerId] = $provider->serviceExtant($tenantId, $userId, ...$servicesRequested); } return $responseData; } /** * Create a new service * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param string $providerId Provider identifier * @param array $data Service configuration data * * @return ServiceBaseInterface Created service * * @throws InvalidArgumentException If provider doesn't support service creation */ public function serviceCreate(string $tenantId, ?string $userId, string $providerId, array $data): ServiceBaseInterface { // retrieve provider and service $provider = $this->providerFetch($tenantId, $userId, $providerId); if ($provider instanceof ProviderServiceMutateInterface === false) { throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); } if ($provider->capable(ProviderServiceMutateInterface::CAPABILITY_SERVICE_CREATE) === false) { throw new InvalidArgumentException("Provider '$providerId' is not capable of creating services"); } // Create a fresh service instance $service = $provider->serviceFresh(); // Deserialize the data into the service $service->jsonDeserialize($data); // Create the service $serviceId = $provider->serviceCreate($tenantId, $userId, $service); // Fetch and return the created service return $provider->serviceFetch($tenantId, $userId, $serviceId); } /** * Update an existing service * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string $userId User identifier for context * @param string $providerId Provider identifier * @param string|int $serviceId Service identifier * @param array $data Updated service configuration data * @param bool $delta Whether the update is a delta (partial) update or a full replacement * * @return ServiceBaseInterface Updated service * * @throws InvalidArgumentException If provider doesn't support service modification or service not found */ public function serviceUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, array $data, bool $delta = false): ServiceBaseInterface { // retrieve provider and service $provider = $this->providerFetch($tenantId, $userId, $providerId); if ($provider instanceof ProviderServiceMutateInterface === false) { throw new InvalidArgumentException("Provider '$providerId' does not support service modification"); } if ($provider->capable(ProviderServiceMutateInterface::CAPABILITY_SERVICE_MODIFY) === false) { throw new InvalidArgumentException("Provider '$providerId' is not capable of modifying services"); } // Fetch existing service $service = $provider->serviceFetch($tenantId, $userId, $serviceId); if ($service === null) { throw new InvalidArgumentException("Service '$serviceId' not found"); } if ($service instanceof ServiceMutableInterface === false) { throw new InvalidArgumentException("Service '$serviceId' is not mutable and cannot be updated"); } // Update with new data $service->jsonDeserialize($data, $delta); // Modify the service $provider->serviceModify($tenantId, $userId, $service); // Fetch and return the updated service return $provider->serviceFetch($tenantId, $userId, $serviceId); } /** * Delete a service * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string $userId User identifier for context * @param string $providerId Provider identifier * @param string|int $serviceId Service identifier * * @return bool True if service was deleted * * @throws InvalidArgumentException If provider doesn't support service deletion or service not found */ public function serviceDelete(string $tenantId, string $userId, string $providerId, string|int $serviceId): bool { // retrieve provider and service $provider = $this->providerFetch($tenantId, $userId, $providerId); if ($provider instanceof ProviderServiceMutateInterface === false) { throw new InvalidArgumentException("Provider '$providerId' does not support service deletion"); } if ($provider->capable(ProviderServiceMutateInterface::CAPABILITY_SERVICE_DESTROY) === false) { throw new InvalidArgumentException("Provider '$providerId' is not capable of deleting services"); } // Fetch existing service $service = $provider->serviceFetch($tenantId, $userId, $serviceId); if ($service === null) { throw new InvalidArgumentException("Service '$serviceId' not found"); } // Delete the service return $provider->serviceDestroy($tenantId, $userId, $service); } // ==================== Collection Operations ==================== /** * List collections across services * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param ResourceIdentifiers|null $targets Provider/service sources * @param IFilter|null $filter Collection filter * @param ISort|null $sort Collection sort * * @return array>> Collections grouped by provider/service */ public function collectionList(string $tenantId, ?string $userId, ?ResourceIdentifiers $targets = null, ?IFilter $filter = null, ?ISort $sort = null): array { // confirm that sources are provided if ($targets === null) { $targets = new ResourceIdentifiers([]); } // retrieve services for each provider $aggregateServices = $this->serviceList($tenantId, $userId, $targets); // retrieve collections for each service $responseData = []; foreach ($aggregateServices as $services) { foreach ($services as $service) { if ($service->getEnabled() === false) { continue; } // construct filter for collections $collectionFilter = null; if ($filter !== null && $filter !== []) { $collectionFilter = $service->collectionListFilter(); foreach ($filter as $attribute => $value) { $collectionFilter->condition($attribute, $value); } } // construct sort for collections $collectionSort = null; if ($sort !== null && $sort !== []) { $collectionSort = $service->collectionListSort(); foreach ($sort as $attribute => $direction) { $collectionSort->condition($attribute, $direction); } } $collectionIdentifiers = $targets->byProvider($service->provider())->byService($service->identifier())->collections(); if ($collectionIdentifiers !== []) { $collections = []; foreach ($collectionIdentifiers as $collectionIdentifier) { $collections = array_merge($collections, $service->collectionList($collectionIdentifier, $collectionFilter, $collectionSort)); } } else { $collections = $service->collectionList('', $collectionFilter, $collectionSort); } if ($collections !== []) { $responseData[$service->provider()][$service->identifier()] = $collections; } } } return $responseData; } /** * Fetch a specific collection * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param CollectionIdentifier $target Target collection identifier * * @return CollectionBaseInterface|null */ public function collectionFetch(string $tenantId, ?string $userId, CollectionIdentifier $target): ?CollectionBaseInterface { // retrieve service $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // retrieve collection return $service->collectionFetch($target->collection()); } /** * Check if collections exist * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param ResourceIdentifiers $targets Collection sources with identifiers * * @return array>> Existence map grouped by provider/service */ public function collectionExtant(string $tenantId, ?string $userId, ResourceIdentifiers $targets): array { // retrieve available services grouped by provider $aggregateServices = $this->serviceList($tenantId, $userId, $targets); // initialize response with unavailable providers marked as false $providersRequested = $targets->providers(); $providersUnavailable = array_diff($providersRequested, array_keys($aggregateServices)); $responseData = array_fill_keys($providersUnavailable, false); // check services and collections for each available provider foreach ($aggregateServices as $providerId => $services) { // mark unavailable services as false $servicesRequested = $targets->byProvider($providerId)->services(); $servicesUnavailable = array_diff($servicesRequested, array_keys($services)); if ($servicesUnavailable !== []) { $responseData[$providerId] = array_fill_keys($servicesUnavailable, false); } // confirm collections for each available service foreach ($services as $service) { // omit disabled services if ($service->getEnabled() === false) { $responseData[$providerId][$service->identifier()] = false; continue; } // extract collections requested for this service $collectionsRequested = $targets->byProvider($providerId)->byService($service->identifier())->collections(); if ($collectionsRequested === []) { continue; } // check each requested collection $collectionsAvailable = $service->collectionExtant(...$collectionsRequested); $collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable)); $responseData[$providerId][$service->identifier()] = array_merge( $collectionsAvailable, array_fill_keys($collectionsUnavailable, false) ); } } return $responseData; } /** * Create a new collection for a specific user * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param string $provider provider identifier * @param string|int $service service identifier * @param CollectionIdentifier|null $target target parent collection identifier * @param CollectionPropertiesMutableInterface|array $properties properties for the new collection * @param array $options additional options for creation * * @return CollectionBaseInterface * @throws InvalidArgumentException */ public function collectionCreate(string $tenantId, string $userId, string $provider, string|int $service, CollectionIdentifier|null $target, CollectionPropertiesMutableInterface|array $properties, array $options = []): CollectionBaseInterface { // retrieve service $service = $this->serviceFetch($tenantId, $userId, $provider, $service); // Check if service supports collection creation if ($service->getEnabled() === false) { throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled"); } if ($service instanceof ServiceCollectionMutableInterface === false) { throw new InvalidArgumentException("Service '{$service->identifier()}' does not support collection mutations"); } if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_CREATE)) { throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of creating collections"); } // convert properties if necessary if ($properties instanceof CollectionPropertiesMutableInterface === false) { $properties = $service->collectionFresh()->getProperties()->jsonDeserialize($properties); } // Create collection return $service->collectionCreate($target, $properties, $options); } /** * Modify an existing collection for a specific user * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param CollectionIdentifier $target target collection identifier * @param CollectionPropertiesMutableInterface|array $properties properties to modify * * @return CollectionBaseInterface * @throws InvalidArgumentException */ public function collectionUpdate(string $tenantId, string $userId, CollectionIdentifier $target, CollectionPropertiesMutableInterface|array $properties): CollectionBaseInterface { // retrieve service $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // Check if service supports collection mutations if ($service->getEnabled() === false) { throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled"); } if ($service instanceof ServiceCollectionMutableInterface === false) { throw new InvalidArgumentException("Service '{$service->identifier()}' does not support collection mutations"); } if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_UPDATE)) { throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of updating collections"); } // convert properties if necessary if ($properties instanceof CollectionPropertiesMutableInterface === false) { $properties = $service->collectionFresh()->getProperties()->jsonDeserialize($properties); } // Update collection return $service->collectionUpdate($target, $properties); } /** * Delete a specific collection * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param CollectionIdentifier $target Target collection identifier * @param array $options Additional options for deletion (e.g., 'force' => true to force delete even if not empty) * * @return CollectionBaseInterface|bool */ public function collectionDelete(string $tenantId, ?string $userId, CollectionIdentifier $target, array $options = []): CollectionBaseInterface | bool { // retrieve service $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // Check if service supports collection deletion if ($service->getEnabled() === false) { throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled"); } if ($service instanceof ServiceCollectionMutableInterface === false) { throw new InvalidArgumentException("Service '{$service->identifier()}' does not support collection mutations"); } if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_DELETE)) { throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of deleting collections"); } // convert options $force = $options['force'] ?? false; // delete collection return $service->collectionDelete($target, $force); } // ==================== Entity Operations ==================== /** * List entities in a collection * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string $userId User identifier * @param ResourceIdentifiers|null $targets Entity sources with collection identifiers * @param array|null $filter Entity filter * @param array|null $sort Entity sort * @param array|null $range Entity range/pagination * * @return array>>> Entities grouped by provider/service/collection */ public function entityListBulk(string $tenantId, string $userId, ?ResourceIdentifiers $targets = null, array|null $filter = null, array|null $sort = null, array|null $range = null): array { // confirm that sources are provided if ($targets === null) { $targets = new ResourceIdentifiers([]); } // retrieve services for each provider $aggregateServices = $this->serviceList($tenantId, $userId, $targets); // retrieve entities for each service $responseData = []; foreach ($aggregateServices as $services) { /** @var ServiceBaseInterface $service */ foreach ($services as $service) { // omit disabled services if ($service->getEnabled() === false) { continue; } // retrieve collections for each service $collectionSelected = $targets->byProvider($service->provider())->byService($service->identifier())->collections(); if ($collectionSelected === []) { $collections = $service->collectionList(''); $collectionSelected = array_map( fn($collection) => $collection->identifier(), $collections ); } if ($collectionSelected === []) { continue; } // construct filter for entities $entityFilter = null; if ($filter !== null && $filter !== []) { $entityFilter = $service->entityListFilter(); foreach ($filter as $attribute => $value) { $entityFilter->condition($attribute, $value); } } // construct sort for entities $entitySort = null; if ($sort !== null && $sort !== []) { $entitySort = $service->entityListSort(); foreach ($sort as $attribute => $direction) { $entitySort->condition($attribute, $direction); } } // construct range for entities $entityRange = null; if ($range !== null && $range !== [] && isset($range['type'])) { $entityRange = $service->entityListRange(RangeType::from($range['type'])); // Cast to IRangeTally if the range type is TALLY if ($entityRange->type() === RangeType::TALLY) { /** @var \KTXF\Resource\Range\IRangeTally $entityRange */ if (isset($range['anchor'])) { $entityRange->setAnchor(RangeAnchorType::from($range['anchor'])); } if (isset($range['position'])) { $entityRange->setPosition($range['position']); } if (isset($range['tally'])) { $entityRange->setTally($range['tally']); } } } // retrieve entities for each collection foreach ($collectionSelected as $collectionId) { $entities = $service->entityListBulk($collectionId, $entityFilter, $entitySort, $entityRange, null); // skip collections with no entities if ($entities === []) { continue; } $responseData[$service->provider()][$service->identifier()][$collectionId] = $entities; } } } return $responseData; } /** * Stream entities * * @since 2026.02.01 * * @param string $tenantId Tenant identifier * @param string $userId User identifier * @param ResourceIdentifiers|null $targets Entity sources with collection identifiers * @param array|null $filter Entity filter * @param array|null $sort Entity sort * @param array|null $range Entity range/pagination * * @return \Generator Yields each entity as it is retrieved */ public function entityListStream(string $tenantId, string $userId, ?ResourceIdentifiers $targets = null, array|null $filter = null, array|null $sort = null, array|null $range = null): \Generator { // confirm that sources are provided if ($targets === null) { $targets = new ResourceIdentifiers([]); } // retrieve services for each provider $aggregateServices = $this->serviceList($tenantId, $userId, $targets); foreach ($aggregateServices as $services) { /** @var ServiceBaseInterface $service */ foreach ($services as $service) { // omit disabled services if ($service->getEnabled() === false) { continue; } // retrieve collections for each service $collectionSelected = $targets->byProvider($service->provider())->byService($service->identifier())->collections(); if ($collectionSelected === []) { $collections = $service->collectionList(''); $collectionSelected = array_map( fn($collection) => $collection->identifier(), $collections ); } if ($collectionSelected === []) { continue; } // construct filter for entities $entityFilter = null; if ($filter !== null && $filter !== []) { $entityFilter = $service->entityListFilter(); foreach ($filter as $attribute => $value) { $entityFilter->condition($attribute, $value); } } // construct sort for entities $entitySort = null; if ($sort !== null && $sort !== []) { $entitySort = $service->entityListSort(); foreach ($sort as $attribute => $direction) { $entitySort->condition($attribute, $direction); } } // construct range for entities $entityRange = null; if ($range !== null && $range !== [] && isset($range['type'])) { $entityRange = $service->entityListRange(RangeType::from($range['type'])); if ($entityRange->type() === RangeType::TALLY) { /** @var \KTXF\Resource\Range\IRangeTally $entityRange */ if (isset($range['anchor'])) { $entityRange->setAnchor(RangeAnchorType::from($range['anchor'])); } if (isset($range['position'])) { $entityRange->setPosition($range['position']); } if (isset($range['tally'])) { $entityRange->setTally($range['tally']); } } } // yield entities for each collection individually foreach ($collectionSelected as $collectionId) { yield from $service->entityListStream($collectionId, $entityFilter, $entitySort, $entityRange, null); } } } } /** * Fetch specific entities in bulk * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param EntityIdentifier ...$identifiers Specific entity identifiers to fetch * * @return array */ public function entityFetchBulk(string $tenantId, ?string $userId, EntityIdentifier ...$identifiers): array { // group identifiers by provider/service $groupedIdentifiers = []; foreach ($identifiers as $identifier) { $groupedIdentifiers[$identifier->provider()][$identifier->service()][] = $identifier; } // retrieve each service and fetch entities $list = []; foreach ($groupedIdentifiers as $providerId => $services) { foreach ($services as $serviceId => $entities) { $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); if ($service->getEnabled() === false) { throw new InvalidArgumentException("Service '{$providerId}:{$serviceId}' not found or is disabled"); } // retrieve entities and merge into list $list = array_merge($list, $service->entityFetchBulk(...$entities)); } } return $list; } /** * Fetch specific entities as a stream * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param EntityIdentifier ...$identifiers Specific entity identifiers to fetch * * @return \Generator */ public function entityFetchStream(string $tenantId, ?string $userId, EntityIdentifier ...$identifiers): \Generator { // group identifiers by provider/service $groupedIdentifiers = []; foreach ($identifiers as $identifier) { $groupedIdentifiers[$identifier->provider()][$identifier->service()][] = $identifier; } // retrieve each service and fetch entities foreach ($groupedIdentifiers as $providerId => $services) { foreach ($services as $serviceId => $entities) { $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); if ($service->getEnabled() === false) { throw new InvalidArgumentException("Service '{$providerId}:{$serviceId}' not found or is disabled"); } // retrieve entities and yield each one yield from $service->entityFetchStream(...$entities); } } } /** * Check if entities exist * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param ResourceIdentifiers $targets Entity sources with identifiers * * @return array>>> Existence map grouped by provider/service/collection */ public function entityExtant(string $tenantId, string $userId, ResourceIdentifiers $targets): array { // retrieve available services grouped by provider $aggregateServices = $this->serviceList($tenantId, $userId, $targets); // initialize response with unavailable providers marked as false $providersRequested = $targets->providers(); $providersUnavailable = array_diff($providersRequested, array_keys($aggregateServices)); $responseData = array_fill_keys($providersUnavailable, false); // check services, collections, and entities for each available provider foreach ($aggregateServices as $providerId => $services) { // mark unavailable services as false $servicesRequested = $targets->byProvider($providerId)->services(); $servicesUnavailable = array_diff($servicesRequested, array_keys($services)); if ($servicesUnavailable !== []) { $responseData[$providerId] = array_fill_keys($servicesUnavailable, false); } // check collections and entities for each available service foreach ($services as $service) { // omit disabled services if ($service->getEnabled() === false) { $responseData[$providerId][$service->identifier()] = false; continue; } // extract collections requested for this service $serviceTargets = $targets->byProvider($providerId)->byService($service->identifier()); $collectionsRequested = $serviceTargets->collections(); if ($collectionsRequested === []) { continue; } // check entities for each requested collection foreach ($collectionsRequested as $collectionId) { // first check if collection exists $collectionExists = $service->collectionExtant((string)$collectionId); if (!$collectionExists) { // collection doesn't exist, mark as false $responseData[$providerId][$service->identifier()][$collectionId] = false; continue; } // extract entity identifiers requested for this collection $entitiesRequested = $serviceTargets->byCollection($collectionId)->entities(); if ($entitiesRequested === []) { // just checking if collection exists (already confirmed above) $responseData[$providerId][$service->identifier()][$collectionId] = true; continue; } // check specific entities within the collection $responseData[$providerId][$service->identifier()][$collectionId] = $service->entityExtant($collectionId, ...$entitiesRequested); } } } return $responseData; } /** * Get entity delta/changes * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param ResourceIdentifiers $targets Entity sources with signatures * * @return array>> Delta grouped by provider/service/collection */ public function entityDelta(string $tenantId, string $userId, ResourceIdentifiers $targets): array { // retrieve available services grouped by provider $aggregateServices = $this->serviceList($tenantId, $userId, $targets); // initialize response with unavailable providers marked as false $providersRequested = $targets->providers(); $providersUnavailable = array_diff($providersRequested, array_keys($aggregateServices)); $responseData = array_fill_keys($providersUnavailable, false); // iterate through available providers foreach ($aggregateServices as $providerId => $services) { // mark unavailable services as false $servicesRequested = $targets->byProvider($providerId)->services(); $servicesUnavailable = array_diff($servicesRequested, array_keys($services)); if ($servicesUnavailable !== []) { $responseData[$providerId] = array_fill_keys($servicesUnavailable, false); } // iterate through available services foreach ($services as $service) { // omit disabled services if ($service->getEnabled() === false) { $responseData[$providerId][$service->identifier()] = false; continue; } // extract collections requested for this service $serviceTargets = $targets->byProvider($providerId)->byService($service->identifier()); $collectionsRequested = $serviceTargets->collections(); if ($collectionsRequested === []) { $responseData[$providerId][$service->identifier()] = false; continue; } // check delta for each requested collection foreach ($collectionsRequested as $collection) { // signature for the collection is carried in the entity slot of the identifier $signature = $serviceTargets->byCollection($collection)->entities()[0] ?? ''; $responseData[$providerId][$service->identifier()][$collection] = $service->entityDelta($collection, $signature); } } } return $responseData; } /** * Create a new entity in a collection * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param CollectionIdentifier $target target collection identifier * @param EntityPropertiesMutableInterface|array $properties properties for the new entity * @param array $options additional options for creation * * @return EntityBaseInterface * @throws InvalidArgumentException */ public function entityCreate(string $tenantId, string $userId, CollectionIdentifier $target, EntityPropertiesMutableInterface|array $properties, array $options = []): EntityBaseInterface { // retrieve service $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // Check if service supports entity creation if ($service->getEnabled() === false) { throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled"); } if ($service instanceof ServiceEntityMutableInterface === false) { throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity mutations"); } if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_CREATE)) { throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of creating entities"); } // convert properties if necessary if ($properties instanceof EntityPropertiesMutableInterface === false) { $properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties); } // create entity return $service->entityCreate($target, $properties, $options); } /** * Modify an existing entity in a collection * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param EntityIdentifier $target target entity identifier * @param EntityPropertiesMutableInterface|array $properties properties to modify * * @return EntityBaseInterface * @throws InvalidArgumentException */ public function entityModify(string $tenantId, string $userId, EntityIdentifier $target, EntityPropertiesMutableInterface|array $properties): EntityBaseInterface { // retrieve service $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // Check if service supports entity modification if ($service->getEnabled() === false) { throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled"); } if ($service instanceof ServiceEntityMutableInterface === false) { throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity mutations"); } if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_MODIFY)) { throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of modifying entities"); } // convert properties if necessary if ($properties instanceof EntityPropertiesMutableInterface === false) { $properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties); } // modify entity return $service->entityModify($target, $properties); } /** * Deletes entities * * @since 2026.04.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param EntityIdentifier ...$targets Source entities to delete * * @return array Results keyed by source entity identifier */ public function entityDelete(string $tenantId, string $userId, EntityIdentifier ...$targets): array { $operationOutcome = []; $targetIdentifiers = new ResourceIdentifiers(); foreach ($targets as $target) { $targetIdentifiers->add($target); } // process targets grouped by provider foreach ($targetIdentifiers->providers() as $providerId) { // retrieve provider and validate $providerTargets = $targetIdentifiers->byProvider($providerId); // process targets grouped by service for this provider foreach ($providerTargets->services() as $serviceId) { // extract services requested for this provider $serviceTargets = $providerTargets->byService($serviceId); // retrieve and validate service $service = null; $error = null; try { $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); } catch (\Throwable $e) { $error = "Service $serviceId not found"; } if ($service instanceof ServiceEntityMutableInterface === false && $error === null) { $error = "Service $serviceId does not support entity mutation"; } if ($error === null && !$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_DELETE)) { $error = "Service $serviceId does not support entity deletion"; } // on error, mark all identifiers for this service as failed and continue to next service if ($error !== null) { foreach ($serviceTargets as $identifier) { $operationOutcome[(string)$identifier] = ['disposition' => 'error', 'error' => $error]; } continue; } /** @var ServiceEntityMutableInterface $service */ $operationOutcome = array_merge($operationOutcome, $service->entityDelete(...$serviceTargets->all())); } } return $operationOutcome; } /** * Moves entities to another collection * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param CollectionIdentifier $target Target collection identifier * @param EntityIdentifier ...$sources Source entities to move * * @return array Results keyed by source entity identifier */ public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, EntityIdentifier ...$sources): array { return $this->entityRelocate( $tenantId, $userId, $target, ServiceEntityMutableInterface::CAPABILITY_ENTITY_MOVE, 'entityMove', $sources ); } /** * Copies entities to another collection * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param CollectionIdentifier $target Target collection identifier * @param EntityIdentifier ...$sources Source entities to copy * * @return array Results keyed by source entity identifier */ public function entityCopy(string $tenantId, string $userId, CollectionIdentifier $target, EntityIdentifier ...$sources): array { return $this->entityRelocate( $tenantId, $userId, $target, ServiceEntityMutableInterface::CAPABILITY_ENTITY_COPY, 'entityCopy', $sources ); } /** * Shared implementation for entity move/copy operations * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context * @param CollectionIdentifier $target Target collection identifier * @param string $capability Required service capability * @param string $method Service method to invoke (entityMove|entityCopy) * @param EntityIdentifier[] $sources Source entities to relocate * * @return array Results keyed by source entity identifier */ private function entityRelocate(string $tenantId, ?string $userId, CollectionIdentifier $target, string $capability, string $method, array $sources): array { $operationOutcome = []; // retrieve and validate service $targetService = null; $error = null; try { $targetService = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); } catch (\Throwable $e) { // do nothing here, error will be handled in validation below } if ($targetService === null || $targetService->getEnabled() === false) { $error = "Service {$target->service()} not found or is disabled"; } if ($targetService instanceof ServiceEntityMutableInterface === false && $error === null) { $error = "Service {$target->service()} does not support entity mutation"; } if ($error === null && !$targetService->capable($capability)) { $error = "Service {$target->service()} does not support this entity operation"; } // on error, mark all identifiers as failed if ($error !== null) { foreach ($sources as $identifier) { $operationOutcome[(string)$identifier] = ['disposition' => 'error', 'error' => $error]; } return $operationOutcome; } // validate that sources and target are the same service and group sources by service for processing $groupedSources = []; foreach ($sources as $source) { if ($source->provider() !== $target->provider() || $source->service() !== $target->service()) { $operationOutcome[(string)$source] = [ 'disposition' => 'error', 'error' => "Source '{$source}' and target '{$target}' must belong to the same provider and service" ]; continue; } $groupedSources[] = $source; } if ($groupedSources === []) { return $operationOutcome; } // perform operation for entities on the same service as the target $operationOutcome = array_merge( $operationOutcome, $targetService->{$method}($target, ...$groupedSources) ); return $operationOutcome; } }