diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 08d93dc..c0d80f4 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -11,31 +11,39 @@ namespace KTXM\ChronoManager\Controllers; use InvalidArgumentException; use KTXC\Http\Response\JsonResponse; +use KTXC\Http\Response\Response; +use KTXC\Http\Response\StreamedNdJsonResponse; use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; -use KTXF\Resource\Selector\SourceSelector; +use KTXF\Json\JsonSerializable; +use KTXF\Resource\Identifier\CollectionIdentifier; +use KTXF\Resource\Identifier\EntityIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifiers; +use KTXF\Resource\Identifier\ServiceIdentifier; use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXM\ChronoManager\Manager; +use KTXM\ChronoManager\Stream\ExpectedTotal; use Psr\Log\LoggerInterface; use Throwable; class DefaultController extends ControllerAbstract { - private const ERR_MISSING_PROVIDER = 'Missing parameter: provider'; + private const ERR_MISSING_PROVIDER = 'Missing parameter: provider'; private const ERR_MISSING_IDENTIFIER = 'Missing parameter: identifier'; private const ERR_MISSING_SERVICE = 'Missing parameter: service'; - private const ERR_MISSING_COLLECTION = 'Missing parameter: collection'; private const ERR_MISSING_DATA = 'Missing parameter: data'; private const ERR_MISSING_SOURCES = 'Missing parameter: sources'; - private const ERR_MISSING_IDENTIFIERS = 'Missing parameter: identifiers'; + private const ERR_MISSING_TARGET = 'Missing parameter: target'; + private const ERR_MISSING_TARGETS = 'Missing parameter: targets'; private const ERR_INVALID_OPERATION = 'Invalid operation: '; private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string'; private const ERR_INVALID_SERVICE = 'Invalid parameter: service must be a string'; private const ERR_INVALID_IDENTIFIER = 'Invalid parameter: identifier must be a string'; - private const ERR_INVALID_COLLECTION = 'Invalid parameter: collection must be a string or integer'; private const ERR_INVALID_SOURCES = 'Invalid parameter: sources must be an array'; - private const ERR_INVALID_IDENTIFIERS = 'Invalid parameter: identifiers must be an array'; + private const ERR_INVALID_TARGET = 'Invalid parameter: target must be an array'; + private const ERR_INVALID_TARGETS = 'Invalid parameter: targets must be an array'; private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array'; public function __construct( @@ -45,6 +53,19 @@ class DefaultController extends ControllerAbstract { private readonly LoggerInterface $logger, ) {} + /** + * Main API endpoint for chrono operations + * + * Single operation: + * { + * "version": 1, + * "transaction": "tx-1", + * "operation": "entity.create", + * "data": {...} + * } + * + * @return JsonResponse + */ #[AuthenticatedRoute('/v1', name: 'chrono.manager.v1', methods: ['POST'])] public function index( int $version, @@ -52,7 +73,7 @@ class DefaultController extends ControllerAbstract { string|null $operation = null, array|null $data = null, string|null $user = null - ): JsonResponse { + ): Response { // authorize request $tenantId = $this->tenantIdentity->identifier(); @@ -61,7 +82,12 @@ class DefaultController extends ControllerAbstract { try { if ($operation !== null) { - $result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], []); + $result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], $version, $transaction); + + if ($result instanceof Response) { + return $result; + } + return new JsonResponse([ 'version' => $version, 'transaction' => $transaction, @@ -88,10 +114,11 @@ class DefaultController extends ControllerAbstract { } } + /** * Process a single operation */ - private function processOperation(string $tenantId, string $userId, string $operation, array $data): mixed { + private function processOperation(string $tenantId, string $userId, string $operation, array $data, int $version = 1, string $transaction = ''): mixed { return match ($operation) { // Provider operations 'provider.list' => $this->providerList($tenantId, $userId, $data), @@ -105,7 +132,7 @@ class DefaultController extends ControllerAbstract { 'service.create' => $this->serviceCreate($tenantId, $userId, $data), 'service.update' => $this->serviceUpdate($tenantId, $userId, $data), 'service.delete' => $this->serviceDelete($tenantId, $userId, $data), - 'service.test' => $this->serviceTest($tenantId, $userId, $data), + 'service.test' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), // Collection operations 'collection.list' => $this->collectionList($tenantId, $userId, $data), @@ -116,16 +143,16 @@ class DefaultController extends ControllerAbstract { 'collection.delete' => $this->collectionDelete($tenantId, $userId, $data), // Entity operations - 'entity.list' => $this->entityList($tenantId, $userId, $data), + 'entity.listBulk' => $this->entityListBulk($tenantId, $userId, $data), + 'entity.listStream' => $this->entityListStream($tenantId, $userId, $data, $version, $transaction), 'entity.fetch' => $this->entityFetch($tenantId, $userId, $data), 'entity.extant' => $this->entityExtant($tenantId, $userId, $data), + 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), 'entity.create' => $this->entityCreate($tenantId, $userId, $data), 'entity.update' => $this->entityUpdate($tenantId, $userId, $data), 'entity.delete' => $this->entityDelete($tenantId, $userId, $data), - 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), - 'entity.move' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), - 'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), - + 'entity.move' => $this->entityMove($tenantId, $userId, $data), + default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation) }; } @@ -134,40 +161,48 @@ class DefaultController extends ControllerAbstract { private function providerList(string $tenantId, string $userId, array $data): mixed { - $sources = null; - if (isset($data['sources']) && is_array($data['sources'])) { - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - } + if (isset($data['targets'])) { + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + + foreach ($data['targets'] as $target) { + if (!is_string($target)) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + } + } - return $this->manager->providerList($tenantId, $userId, $sources); + return $this->manager->providerList($tenantId, $userId, $data['targets'] ?? []); } private function providerFetch(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); } - if (!is_string($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } - return $this->manager->providerFetch($tenantId, $userId, $data['identifier']); + return $this->manager->providerFetch($tenantId, $userId, $data['target']); + } private function providerExtant(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); - } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); - } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); + } + + foreach ($data['targets'] as $target) { + if (!is_string($target)) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + } - return $this->manager->providerExtant($tenantId, $userId, $sources); + return $this->manager->providerExtant($tenantId, $userId, $data['targets']); } @@ -175,13 +210,17 @@ class DefaultController extends ControllerAbstract { private function serviceList(string $tenantId, string $userId, array $data): mixed { - $sources = null; - if (isset($data['sources']) && is_array($data['sources'])) { - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); + $targets = null; + if (isset($data['targets']) && is_array($data['targets'])) { + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof CollectionIdentifier && !$target instanceof ServiceIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers'); + } + } } - return $this->manager->serviceList($tenantId, $userId, $sources); + return $this->manager->serviceList($tenantId, $userId, $targets); } @@ -205,16 +244,20 @@ class DefaultController extends ControllerAbstract { private function serviceExtant(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - - return $this->manager->serviceExtant($tenantId, $userId, $sources); + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof ServiceIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service identifiers'); + } + } + + return $this->manager->serviceExtant($tenantId, $userId, $targets); } private function serviceCreate(string $tenantId, string $userId, array $data): mixed { @@ -258,13 +301,17 @@ class DefaultController extends ControllerAbstract { if (!is_array($data['data'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } + if (isset($data['delta']) && !is_bool($data['delta'])) { + throw new InvalidArgumentException('Invalid parameter: delta must be a boolean'); + } return $this->manager->serviceUpdate( $tenantId, $userId, $data['provider'], $data['identifier'], - $data['data'] + $data['data'], + $data['delta'] ?? false, ); } @@ -290,36 +337,17 @@ class DefaultController extends ControllerAbstract { ); } - private function serviceTest(string $tenantId, string $userId, array $data): mixed { - - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); - } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); - } - - if (!isset($data['identifier']) && !isset($data['location']) && !isset($data['identity'])) { - throw new InvalidArgumentException('Either a service identifier or location and identity must be provided for service test'); - } - - return $this->manager->serviceTest( - $tenantId, - $userId, - $data['provider'], - $data['identifier'] ?? null, - $data['location'] ?? null, - $data['identity'] ?? null, - ); - } - // ==================== Collection Operations ==================== private function collectionList(string $tenantId, string $userId, array $data): mixed { $sources = null; if (isset($data['sources']) && is_array($data['sources'])) { - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); + $sources = ResourceIdentifiers::fromArray($data['sources']); + foreach ($sources as $source) { + if (!$source instanceof CollectionIdentifier && !$source instanceof ServiceIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers'); + } + } } $filter = $data['filter'] ?? null; @@ -328,47 +356,45 @@ class DefaultController extends ControllerAbstract { return $this->manager->collectionList($tenantId, $userId, $sources, $filter, $sort); } - private function collectionExtant(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); - } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); - } - - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - - return $this->manager->collectionExtant($tenantId, $userId, $sources); - } - private function collectionFetch(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } - if (!isset($data['service'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + + $targetIdentifiers = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targetIdentifiers as $targetIdentifier) { + if (!$targetIdentifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } } - if (!is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); - } - if (!is_string($data['identifier']) && !is_int($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); - } - - return $this->manager->collectionFetch( + + $list = $this->manager->collectionFetch( $tenantId, $userId, - $data['provider'], - $data['service'], - $data['identifier'] + $targetIdentifier ); + return $list; + } + + private function collectionExtant(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); + } + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + + $sources = ResourceIdentifiers::fromArray($data['targets']); + foreach ($sources as $source) { + if (!$source instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service, provider:service:collection, or provider:service:collection:entity identifiers'); + } + } + + return $this->manager->collectionExtant($tenantId, $userId, $sources); } private function collectionCreate(string $tenantId, string $userId, array $data): mixed { @@ -384,8 +410,8 @@ class DefaultController extends ControllerAbstract { if (!is_string($data['service'])) { throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); } - if (isset($data['collection']) && !is_string($data['collection']) && !is_int($data['collection'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + if (isset($data['target']) && !is_string($data['target']) && !is_int($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } if (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); @@ -393,35 +419,30 @@ class DefaultController extends ControllerAbstract { if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } + + if (isset($data['target'])) { + $targetIdentifier = ResourceIdentifier::fromString($data['target']); + if (!$targetIdentifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } + } return $this->manager->collectionCreate( $tenantId, $userId, $data['provider'], $data['service'], - $data['collection'] ?? null, + $targetIdentifier ?? null, $data['properties'] ); } private function collectionUpdate(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); - } - if (!is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); - } - if (!is_string($data['identifier']) && !is_int($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } if (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); @@ -429,182 +450,337 @@ class DefaultController extends ControllerAbstract { if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } + + $targetIdentifier = ResourceIdentifier::fromString($data['target']); + if (!$targetIdentifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } return $this->manager->collectionUpdate( $tenantId, $userId, - $data['provider'], - $data['service'], - $data['identifier'], + $targetIdentifier, $data['properties'] ); } private function collectionDelete(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } - if (!isset($data['service'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); - } - if (!is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); - } - if (!is_string($data['identifier']) && !is_int($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + + $targetIdentifier = ResourceIdentifier::fromString($data['target']); + if (!$targetIdentifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); } - return $this->manager->collectionDelete( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['identifier'], - $data['options'] ?? [] - ); + $result = $this->manager->collectionDelete($tenantId, $userId, $targetIdentifier, $data['options'] ?? [] ); + + if (is_bool($result)) { + return [ + 'disposition' => 'deleted' + ]; + } + + if ($result instanceof JsonSerializable) { + return [ + 'disposition' => 'moved', + 'mutation' => $result + ]; + } + + return $result; } // ==================== Entity Operations ==================== - private function entityList(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); - } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); - } + private function entityListBulk(string $tenantId, string $userId, array $data): mixed { - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); + if (isset($data['sources'])) { + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $sources = ResourceIdentifiers::fromArray($data['sources']); + foreach ($sources as $source) { + if (!$source instanceof ServiceIdentifier && !$source instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service or provider:service:collection identifiers'); + } + } + } else { + $sources = null; + } $filter = $data['filter'] ?? null; $sort = $data['sort'] ?? null; $range = $data['range'] ?? null; - return $this->manager->entityList($tenantId, $userId, $sources, $filter, $sort, $range); + return $this->manager->entityListBulk($tenantId, $userId, $sources, $filter, $sort, $range); } + private function entityListStream(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse { + + if (isset($data['sources'])) { + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $sources = ResourceIdentifiers::fromArray($data['sources']); + foreach ($sources as $source) { + if (!$source instanceof ServiceIdentifier && !$source instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service or provider:service:collection identifiers'); + } + } + } else { + $sources = null; + } + + $filter = $data['filter'] ?? null; + $sort = $data['sort'] ?? null; + $range = $data['range'] ?? null; + + $entities = $this->manager->entityListStream($tenantId, $userId, $sources, $filter, $sort, $range); + + return new StreamedNdJsonResponse( + $this->streamEnvelope($entities, $version, $transaction), + 1, + 200, + ['Content-Type' => 'application/json'], + ); + } + + /** + * Import vCards into a collection, streaming one NDJSON event per contact. + * + * Request data: { target: "provider:service:collection", data: "", options?: {...} } + */ + private function entityImport(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse { + + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); + } + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); + } + if (!isset($data['data']) || !is_string($data['data']) || trim($data['data']) === '') { + throw new InvalidArgumentException('Invalid parameter: data must be a non-empty string'); + } + + $target = ResourceIdentifier::fromString($data['target']); + if (!$target instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } + + $options = ImportOptions::fromArray($data['options'] ?? []); + + // Spill the payload to a temp file and release the in-memory copy before the + // (potentially long) streaming parse, so peak memory stays at one contact object. + $tempFile = tmpfile(); + if ($tempFile === false) { + throw new \RuntimeException('Unable to allocate temporary file for import'); + } + fwrite($tempFile, $data['data']); + unset($data); + rewind($tempFile); + + $events = $this->importService->import($tempFile, $target, $options, $tenantId, $userId); + $frames = $this->streamEnvelope($events, $version, $transaction); + + // Stream the envelope, releasing the spilled temp file once it is fully + // drained (or the client disconnects) so it never outlives its stream. + $response = (function () use ($frames, $tempFile): \Generator { + try { + yield from $frames; + } finally { + fclose($tempFile); + } + })(); + + return new StreamedNdJsonResponse($response, 1, 200); + } + + /** + * Wrap a generator of JsonSerializable domain objects in the canonical NDJSON + * stream envelope shared by every streaming operation: + * + * control:start {version, transaction, total?} — total? = expected count + * data {data} — one per domain object + * error {message} — on failure, then stop + * control:end {total} — total = objects emitted + * + * If the generator leads with an {@see ExpectedTotal} event, its value is + * folded into the start frame's `total` (the progress denominator) rather + * than emitted as a data frame. + * + * @param \Generator<\JsonSerializable> $items + */ + private function streamEnvelope(\Generator $items, int $version, string $transaction): \Generator { + // Peek the first event: an expected-total marker rides on the start frame. + $expected = null; + $items->rewind(); + if ($items->valid() && $items->current() instanceof ExpectedTotal) { + $expected = $items->current()->expectedTotal(); + $items->next(); + } + + $start = ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction]; + if ($expected !== null) { + $start['total'] = $expected; + } + yield $start; + + $total = 0; + try { + for (; $items->valid(); $items->next()) { + $item = $items->current(); + if (!$item instanceof \JsonSerializable) { + continue; + } + yield ['type' => 'data', 'data' => $item->jsonSerialize()]; + $total++; + } + } catch (\Throwable $t) { + $this->logger->error('Error streaming response', ['exception' => $t]); + yield ['type' => 'error', 'message' => $t->getMessage()]; + return; + } + + yield ['type' => 'control', 'status' => 'end', 'total' => $total]; + } + private function entityFetch(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider'])) { - throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } - if (!isset($data['service'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); - } - if (!is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException(self::ERR_MISSING_COLLECTION); - } - if (!is_string($data['collection']) && !is_int($data['collection'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); - } - if (!isset($data['identifiers'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIERS); - } - if (!is_array($data['identifiers'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIERS); + + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection:entity identifiers'); + } } - return $this->manager->entityFetch( + return $this->manager->entityFetchBulk( $tenantId, $userId, - $data['provider'], - $data['service'], - $data['collection'], - $data['identifiers'] + ...$targets->all() ); } private function entityExtant(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - - return $this->manager->entityExtant($tenantId, $userId, $sources); + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof CollectionIdentifier && !$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection or provider:service:collection:entity identifiers'); + } + } + + return $this->manager->entityExtant($tenantId, $userId, $targets); } - private function entityCreate(string $tenantId, string $userId, array $data = []): mixed { - - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); - } - if (!isset($data['service']) || !is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); - } - $properties = $data['properties'] ?? $data['data'] ?? null; - if (!is_array($properties)) { - throw new InvalidArgumentException('Invalid parameter: properties must be an array'); - } - $options = $data['options'] ?? []; - - return $this->manager->entityCreate($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $properties, $options); - - } - - private function entityUpdate(string $tenantId, string $userId, array $data = []): mixed { - - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); - } - if (!isset($data['service']) || !is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); - } - $properties = $data['properties'] ?? $data['data'] ?? null; - if (!is_array($properties)) { - throw new InvalidArgumentException('Invalid parameter: properties must be an array'); - } - - return $this->manager->entityUpdate($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $data['identifier'], $properties); - - } - - private function entityDelete(string $tenantId, string $userId, array $data = []): mixed { - - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); - } - if (!isset($data['service']) || !is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); - } - - return $this->manager->entityDelete($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $data['identifier']); - - } - private function entityDelta(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); + } + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof CollectionIdentifier && !$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection or provider:service:collection:signature identifiers'); + } + } + + return $this->manager->entityDelta($tenantId, $userId, $targets); + } + + private function entityCreate(string $tenantId, string $userId, array $data = []): mixed { + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); + } + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); + } + if (!isset($data['properties'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['properties'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + $target = ResourceIdentifier::fromString($data['target']); + if (!$target instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } + + $options = $data['options'] ?? []; + + return $this->manager->entityCreate($tenantId, $userId, $target, $data['properties'], $options); + } + + private function entityUpdate(string $tenantId, string $userId, array $data = []): mixed { + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); + } + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); + } + if (!isset($data['properties'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['properties'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + $target = ResourceIdentifier::fromString($data['target']); + if (!$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection:entity'); + } + + return $this->manager->entityModify($tenantId, $userId, $target, $data['properties']); + } + + private function entityDelete(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['targets'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGETS); + } + if (!is_array($data['targets'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGETS); + } + + $targets = ResourceIdentifiers::fromArray($data['targets']); + foreach ($targets as $target) { + if (!$target instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: targets must contain provider:service:collection:entity identifiers'); + } + } + + return $this->manager->entityDelete($tenantId, $userId, ...$targets->all()); + } + + private function entityMove(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['target'])) { + throw new InvalidArgumentException(self::ERR_MISSING_TARGET); + } + if (!is_string($data['target'])) { + throw new InvalidArgumentException(self::ERR_INVALID_TARGET); + } if (!isset($data['sources'])) { throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); } @@ -612,10 +788,19 @@ class DefaultController extends ControllerAbstract { throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - - return $this->manager->entityDelta($tenantId, $userId, $sources); + $target = ResourceIdentifier::fromString($data['target']); + if (!$target instanceof CollectionIdentifier) { + throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); + } + + $sources = ResourceIdentifiers::fromArray($data['sources']); + foreach ($sources as $source) { + if (!$source instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers'); + } + } + + return $this->manager->entityMove($tenantId, $userId, $target, ...$sources->all()); } } \ No newline at end of file diff --git a/lib/Manager.php b/lib/Manager.php index 03a537c..ab3935a 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -7,27 +7,21 @@ namespace KTXM\ChronoManager; use InvalidArgumentException; use KTXC\Resource\ProviderManager; use KTXF\Chrono\Collection\CollectionBaseInterface; -use KTXF\Chrono\Collection\CollectionMutableInterface; -use KTXF\Chrono\Collection\ICollectionBase; +use KTXF\Chrono\Collection\CollectionPropertiesMutableInterface; use KTXF\Chrono\Provider\ProviderBaseInterface; use KTXF\Chrono\Provider\ProviderServiceMutateInterface; -use KTXF\Chrono\Provider\ProviderServiceTestInterface; use KTXF\Chrono\Service\ServiceBaseInterface; use KTXF\Chrono\Service\ServiceCollectionMutableInterface; use KTXF\Chrono\Service\ServiceEntityMutableInterface; use KTXF\Chrono\Entity\EntityBaseInterface; -use KTXF\Chrono\Entity\EntityMutableInterface; +use KTXF\Chrono\Entity\EntityPropertiesMutableInterface; +use KTXF\Chrono\Service\ServiceMutableInterface; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\EntityIdentifier; -use KTXF\Resource\Provider\ResourceServiceIdentityInterface; -use KTXF\Resource\Provider\ResourceServiceLocationInterface; +use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeType; -use KTXF\Resource\Selector\CollectionSelector; -use KTXF\Resource\Selector\EntitySelector; -use KTXF\Resource\Selector\ServiceSelector; -use KTXF\Resource\Selector\SourceSelector; use KTXF\Resource\Sort\ISort; use Psr\Log\LoggerInterface; @@ -46,48 +40,46 @@ class Manager { /** * Retrieve available providers * - * @param SourceSelector|null $sources collection of provider identifiers - * + * @param array|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, ?SourceSelector $sources = null): array { - // determine filter from sources - $filter = ($sources !== null && $sources->identifiers() !== []) ? $sources->identifiers() : null; + public function providerList(string $tenantId, string $userId, array|null $targets = []): array { // retrieve providers from provider manager - return $this->providerManager->providers(ProviderBaseInterface::TYPE_CHRONO, $filter); + 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 $provider provider identifier - * + * @param string $target provider identifier + * * @return ProviderBaseInterface * @throws InvalidArgumentException */ - public function providerFetch(string $tenantId, string $userId, string $provider): ProviderBaseInterface { + public function providerFetch(string $tenantId, string $userId, string $target): ProviderBaseInterface { // retrieve provider - $providers = $this->providerList($tenantId, $userId, new SourceSelector([$provider => true])); - if (!isset($providers[$provider])) { - throw new InvalidArgumentException("Provider '$provider' not found"); + $providers = $this->providerList($tenantId, $userId, [$target]); + if (!isset($providers[$target])) { + throw new InvalidArgumentException("Provider '$target' not found"); } - return $providers[$provider]; + return $providers[$target]; } /** * Confirm which providers are available * - * @param SourceSelector|null $sources collection of provider identifiers to confirm - * + * @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, SourceSelector $sources): array { + public function providerExtant(string $tenantId, string $userId, array $targets): array { // determine which providers are available - $providersResolved = $this->providerList($tenantId, $userId, $sources); + $providersResolved = $this->providerList($tenantId, $userId, $targets); $providersAvailable = array_keys($providersResolved); - $providersUnavailable = array_diff($sources->identifiers(), $providersAvailable); + $providersUnavailable = array_diff($targets, $providersAvailable); // construct response data $responseData = array_merge( array_fill_keys($providersAvailable, true), @@ -101,18 +93,24 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param SourceSelector|null $sources list of provider and service identifiers - * - * @return array> collections of available services e.g. ['provider1' => ['service1' => IServiceBase], 'provider2' => ['service2' => IServiceBase]] + * @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, ?SourceSelector $sources = null): array { + public function serviceList(string $tenantId, string $userId, ?ResourceIdentifiers $targets = null): array { // retrieve providers - $providers = $this->providerList($tenantId, $userId, $sources); + $providerFilter = $targets !== null ? $targets->providers() : null; + $providers = $this->providerList($tenantId, $userId, $providerFilter); // retrieve services for each provider $responseData = []; foreach ($providers as $provider) { - $serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : []; - $services = $provider->serviceList($tenantId, $userId, $serviceFilter); + 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; @@ -125,7 +123,7 @@ class Manager { * @param string $userId user identifier * @param string $providerId provider identifier * @param string|int $serviceId service identifier - * + * * @return ServiceBaseInterface * @throws InvalidArgumentException */ @@ -144,24 +142,23 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param SourceSelector|null $sources collection of provider and service identifiers to confirm - * + * @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, SourceSelector $sources): array { - // retrieve providers - $providers = $this->providerList($tenantId, $userId, $sources); - $providersRequested = $sources->identifiers(); + 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 $provider) { - $serviceSelector = $sources[$provider->identifier()]; - $serviceAvailability = $provider->serviceExtant($tenantId, $userId, ...$serviceSelector->identifiers()); - $responseData[$provider->identifier()] = $serviceAvailability; + foreach ($providers as $providerId => $provider) { + $servicesRequested = $targets->byProvider($providerId)->services(); + $responseData[$providerId] = $provider->serviceExtant($tenantId, $userId, ...$servicesRequested); } return $responseData; } @@ -186,16 +183,19 @@ class Manager { 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); } @@ -210,30 +210,37 @@ class Manager { * @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): ServiceBaseInterface { + 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); - + $service->jsonDeserialize($data, $delta); + // Modify the service $provider->serviceModify($tenantId, $userId, $service); - + // Fetch and return the updated service return $provider->serviceFetch($tenantId, $userId, $serviceId); } @@ -256,102 +263,22 @@ class Manager { // 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"); + 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); } - - /** - * Test a mail service connection - * - * Tests connectivity and authentication for either an existing service - * or a fresh configuration. Delegates to the appropriate provider. - * - * @since 2025.05.01 - * - * @param string $tenantId Tenant identifier - * @param string $userId User identifier for context - * @param string $providerId Provider ID (for existing service or targeted test) - * @param string|int|null $serviceId Service ID (for existing service test) - * @param ResourceServiceLocationInterface|array|null $location Service location (for fresh config test) - * @param ResourceServiceIdentityInterface|array|null $identity Service credentials (for fresh config test) - * - * @return array Test results - * - * @throws InvalidArgumentException If invalid parameters - */ - public function serviceTest( - string $tenantId, - string $userId, - string $providerId, - string|int|null $serviceId = null, - ResourceServiceLocationInterface|array|null $location = null, - ResourceServiceIdentityInterface|array|null $identity = null - ): array { - // retrieve provider - $provider = $this->providerFetch($tenantId, $userId, $providerId); - if ($provider instanceof ProviderServiceTestInterface === false) { - throw new InvalidArgumentException("Provider '$providerId' does not support service testing"); - } - - // Testing existing service - if ($providerId !== null && $serviceId !== null) { - // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if ($service === null) { - throw new InvalidArgumentException("Service not found: $providerId/$serviceId"); - } - - try { - return $provider->serviceTest($service); - } catch (\Throwable $e) { - throw new InvalidArgumentException('Service test failed: ' . $e->getMessage()); - } - } - - // Testing fresh configuration - if ($location !== null && $identity !== null) { - - if ($provider instanceof ProviderServiceMutateInterface === false) { - throw new InvalidArgumentException("Provider '$providerId' does not support fresh service configuration testing"); - } - - if (empty($location['type'])) { - throw new InvalidArgumentException('Service location not valid'); - } - - if (empty($identity['type'])) { - throw new InvalidArgumentException('Service identity not valid'); - } - - /** @var ServiceMutableInterface $service */ - $service = $provider->serviceFresh(); - if ($location instanceof ResourceServiceLocationInterface === false) { - $location = $service->freshLocation($location['type'], (array)$location); - $service->setLocation($location); - } - if ($identity instanceof ResourceServiceIdentityInterface === false) { - $identity = $service->freshIdentity($identity['type'], (array)$identity); - $service->setIdentity($identity); - } - - return $provider->serviceTest($service); - } - - throw new InvalidArgumentException( - 'Either (provider + service) or (provider + location + identity) must be provided' - ); - } - // ==================== Collection Operations ==================== /** @@ -361,27 +288,26 @@ class Manager { * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context - * @param SourceSelector|null $sources Provider/service sources + * @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 + * @return array>> Collections grouped by provider/service */ - public function collectionList(string $tenantId, ?string $userId, ?SourceSelector $sources = null, ?IFilter $filter = null, ?ISort $sort = null): array { + public function collectionList(string $tenantId, ?string $userId, ?ResourceIdentifiers $targets = null, ?IFilter $filter = null, ?ISort $sort = null): array { // confirm that sources are provided - if ($sources === null) { - $sources = new SourceSelector([]); + if ($targets === null) { + $targets = new ResourceIdentifiers([]); } - // retrieve providers - $providers = $this->providerList($tenantId, $userId, $sources); // retrieve services for each provider + $aggregateServices = $this->serviceList($tenantId, $userId, $targets); + // retrieve collections for each service $responseData = []; - foreach ($providers as $provider) { - $serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : []; - /** @var ServiceBaseInterface[] $services */ - $services = $provider->serviceList($tenantId, $userId, $serviceFilter); - // retrieve collections for each service + foreach ($aggregateServices as $services) { foreach ($services as $service) { + if ($service->getEnabled() === false) { + continue; + } // construct filter for collections $collectionFilter = null; if ($filter !== null && $filter !== []) { @@ -398,64 +324,19 @@ class Manager { $collectionSort->condition($attribute, $direction); } } - $collections = $service->collectionList('', $collectionFilter, $collectionSort); - if ($collections !== []) { - $responseData[$provider->identifier()][$service->identifier()] = $collections; - } - } - } - return $responseData; - } - /** - * Check if collections exist - * - * @since 2025.05.01 - * - * @param string $tenantId Tenant identifier - * @param string|null $userId User identifier for context - * @param SourceSelector $sources Collection sources with identifiers - * - * @return array>> Existence map grouped by provider/service - */ - public function collectionExtant(string $tenantId, ?string $userId, SourceSelector $sources): array { - // retrieve available providers - $providers = $this->providerList($tenantId, $userId, $sources); - $providersRequested = $sources->identifiers(); - $providersUnavailable = array_diff($providersRequested, array_keys($providers)); - - // initialize response with unavailable providers - $responseData = array_fill_keys($providersUnavailable, false); - - // check services and collections for each available provider - foreach ($providers as $provider) { - $serviceSelector = $sources[$provider->identifier()]; - $servicesRequested = $serviceSelector->identifiers(); - /** @var ServiceBaseInterface[] $servicesAvailable */ - $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); - $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); - - // mark unavailable services as false - if ($servicesUnavailable !== []) { - $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); - } - - // confirm collections for each available service - foreach ($servicesAvailable as $service) { - $collectionSelector = $serviceSelector[$service->identifier()]; - $collectionsRequested = $collectionSelector->identifiers(); - - if ($collectionsRequested === []) { - continue; + $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; } - - // check each requested collection - $collectionsAvailable = $service->collectionExtant(...$collectionsRequested); - $collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable)); - $responseData[$provider->identifier()][$service->identifier()] = array_merge( - $collectionsAvailable, - array_fill_keys($collectionsUnavailable, false) - ); } } return $responseData; @@ -468,20 +349,66 @@ class Manager { * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context - * @param string $providerId Provider identifier - * @param string|int $serviceId Service identifier - * @param string|int $collectionId Collection identifier + * @param CollectionIdentifier $target Target collection identifier * * @return CollectionBaseInterface|null */ - public function collectionFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId): ?CollectionBaseInterface { + public function collectionFetch(string $tenantId, ?string $userId, CollectionIdentifier $target): ?CollectionBaseInterface { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if ($service === null) { - return null; - } + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // retrieve collection - return $service->collectionFetch($collectionId); + 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; } /** @@ -489,41 +416,34 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param string $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int|null $collectionId collection identifier (parent collection) - * @param CollectionMutableInterface|array $object collection to create + * @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 $providerId, string|int $serviceId, string|int|null $collectionId, CollectionMutableInterface|array $object, array $options = []): CollectionBaseInterface { + 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, $providerId, $serviceId); - + $service = $this->serviceFetch($tenantId, $userId, $provider, $service); // Check if service supports collection creation - if (!($service instanceof ServiceCollectionMutableInterface)) { - throw new InvalidArgumentException("Service does not support 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_CREATE)) { - throw new InvalidArgumentException("Service is not capable of creating collections"); + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of creating collections"); } - - if (is_array($object)) { - $collection = $service->collectionFresh(); - $collection->getProperties()->jsonDeserialize($object); - } else { - $collection = $object; + // convert properties if necessary + if ($properties instanceof CollectionPropertiesMutableInterface === false) { + $properties = $service->collectionFresh()->getProperties()->jsonDeserialize($properties); } - - // construct parent collection identifier (null for root) - $target = $collectionId !== null - ? new CollectionIdentifier($providerId, (string) $serviceId, (string) $collectionId) - : null; - // Create collection - return $service->collectionCreate($target, $collection->getProperties(), $options); + return $service->collectionCreate($target, $properties, $options); } /** @@ -531,38 +451,31 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param string $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * @param CollectionMutableInterface|array $object collection to modify - * + * @param CollectionIdentifier $target target collection identifier + * @param CollectionPropertiesMutableInterface|array $properties properties to modify + * * @return CollectionBaseInterface * @throws InvalidArgumentException */ - public function collectionUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, CollectionMutableInterface|array $object): CollectionBaseInterface { + public function collectionUpdate(string $tenantId, string $userId, CollectionIdentifier $target, CollectionPropertiesMutableInterface|array $properties): CollectionBaseInterface { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - // Check if service supports collection creation - if (!($service instanceof ServiceCollectionMutableInterface)) { - throw new InvalidArgumentException("Service does not support collection mutations"); + $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 is not capable of updating collections"); + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of updating collections"); } - - if (is_array($object)) { - $collection = $service->collectionFresh(); - $collection->getProperties()->jsonDeserialize($object); - } else { - $collection = $object; + // convert properties if necessary + if ($properties instanceof CollectionPropertiesMutableInterface === false) { + $properties = $service->collectionFresh()->getProperties()->jsonDeserialize($properties); } - - // construct target collection identifier - $target = new CollectionIdentifier($providerId, (string) $serviceId, (string) $collectionId); - // Update collection - return $service->collectionUpdate($target, $collection->getProperties()); + return $service->collectionUpdate($target, $properties); } /** @@ -572,33 +485,28 @@ class Manager { * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context - * @param string $providerId Provider identifier - * @param string|int $serviceId Service identifier - * @param string|int $collectionId Collection identifier + * @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|null + * @return CollectionBaseInterface|bool */ - public function collectionDelete(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $options = []): bool { + public function collectionDelete(string $tenantId, ?string $userId, CollectionIdentifier $target, array $options = []): CollectionBaseInterface | bool { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // Check if service supports collection deletion - if (!($service instanceof ServiceCollectionMutableInterface)) { - throw new InvalidArgumentException("Service does not support 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_DELETE)) { - throw new InvalidArgumentException("Service is not capable of deleting collections"); + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of deleting collections"); } - + // convert options $force = $options['force'] ?? false; - - // construct target collection identifier - $target = new CollectionIdentifier($providerId, (string) $serviceId, (string) $collectionId); - - // delete collection (service returns the collection object on soft delete, true on hard delete) - $result = $service->collectionDelete($target, $force); - - return $result === true || $result instanceof CollectionBaseInterface; + // delete collection + return $service->collectionDelete($target, $force); } // ==================== Entity Operations ==================== @@ -610,38 +518,42 @@ class Manager { * * @param string $tenantId Tenant identifier * @param string $userId User identifier - * @param SourceSelector $sources Entity sources with collection identifiers + * @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 + * @return array>>> Entities grouped by provider/service/collection */ - public function entityList(string $tenantId, string $userId, SourceSelector $sources, array|null $filter = null, array|null $sort = null, array|null $range = null): array { - // retrieve providers - $providers = $this->providerList($tenantId, $userId, $sources); + 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 ($providers as $provider) { - // retrieve services for each provider - $serviceSelector = $sources[$provider->identifier()]; - $servicesSelected = $provider->serviceList($tenantId,$userId, $serviceSelector->identifiers()); + foreach ($aggregateServices as $services) { /** @var ServiceBaseInterface $service */ - foreach ($servicesSelected as $service) { - // retrieve collections for each service - $collectionSelector = $serviceSelector[$service->identifier()]; - $collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; - if ($collectionSelected === []) { - $collections = $service->collectionList(''); - $collectionSelected = array_map( - fn($collection) => $collection->identifier(), - $collections - ); - } - if ($collectionSelected === []) { - continue; - } - // construct filter for entities + 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(); @@ -663,7 +575,7 @@ class Manager { $entityRange = $service->entityListRange(RangeType::from($range['type'])); // Cast to IRangeTally if the range type is TALLY if ($entityRange->type() === RangeType::TALLY) { - /** @var IRangeTally $entityRange */ + /** @var \KTXF\Resource\Range\IRangeTally $entityRange */ if (isset($range['anchor'])) { $entityRange->setAnchor(RangeAnchorType::from($range['anchor'])); } @@ -675,116 +587,222 @@ class Manager { } } } - // 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[$provider->identifier()][$service->identifier()][$collectionId] = $entities; - } - } + // 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; } - + /** - * Fetch specific messages + * Stream entities * - * @since 2025.05.01 + * @since 2026.02.01 * * @param string $tenantId Tenant identifier - * @param string|null $userId User identifier for context - * @param string $providerId Provider identifier - * @param string|int $serviceId Service identifier - * @param string|int $collectionId Collection identifier - * @param array $identifiers Message identifiers + * @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 Messages indexed by ID + * @return \Generator Yields each entity as it is retrieved */ - public function entityFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - // construct entity identifiers - $targets = array_map( - fn($identifier) => new EntityIdentifier($providerId, (string) $serviceId, (string) $collectionId, (string) $identifier), - $identifiers - ); - - // retrieve entities - return $service->entityFetchBulk(...$targets); + 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); + } + } + } } /** - * Check if messages exist + * Fetch specific entities in bulk * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context - * @param SourceSelector $sources Message sources with identifiers + * @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, SourceSelector $sources): array { - // confirm that sources are provided - if ($sources === null) { - $sources = new SourceSelector([]); - } - // retrieve available providers - $providers = $this->providerList($tenantId, $userId, $sources); - $providersRequested = $sources->identifiers(); - $providersUnavailable = array_diff($providersRequested, array_keys($providers)); - - // initialize response with unavailable providers + 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 ($providers as $provider) { - $serviceSelector = $sources[$provider->identifier()]; - $servicesRequested = $serviceSelector->identifiers(); - /** @var ServiceBaseInterface[] $servicesAvailable */ - $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); - $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); - + 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[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); + $responseData[$providerId] = array_fill_keys($servicesUnavailable, false); } - // check collections and entities for each available service - foreach ($servicesAvailable as $service) { - $collectionSelector = $serviceSelector[$service->identifier()]; - $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; - + 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[$provider->identifier()][$service->identifier()][$collectionId] = false; + $responseData[$providerId][$service->identifier()][$collectionId] = false; continue; } - - // extract entity identifiers from collection selector - $entitySelector = $collectionSelector[$collectionId]; - - // handle both array of entity IDs and boolean true (meaning check if collection exists) - if ($entitySelector instanceof EntitySelector) { - // check specific entities within the collection - $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $service->entityExtant($collectionId, ...$entitySelector->identifiers()); - } elseif ($entitySelector === true) { + // extract entity identifiers requested for this collection + $entitiesRequested = $serviceTargets->byCollection($collectionId)->entities(); + if ($entitiesRequested === []) { // just checking if collection exists (already confirmed above) - $responseData[$provider->identifier()][$service->identifier()][$collectionId] = true; + $responseData[$providerId][$service->identifier()][$collectionId] = true; + continue; } + // check specific entities within the collection + $responseData[$providerId][$service->identifier()][$collectionId] = $service->entityExtant($collectionId, ...$entitiesRequested); } } } @@ -792,48 +810,51 @@ class Manager { } /** - * Get message delta/changes + * Get entity delta/changes * * @since 2025.05.01 * * @param string $tenantId Tenant identifier * @param string|null $userId User identifier for context - * @param SourceSelector $sources Message sources with signatures + * @param ResourceIdentifiers $targets Entity sources with signatures * * @return array>> Delta grouped by provider/service/collection */ - public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array { - // confirm that sources are provided - if ($sources === null) { - $sources = new SourceSelector([]); - } - // retrieve providers - $providers = $this->providerList($tenantId, $userId, $sources); - $providersRequested = $sources->identifiers(); - $providersUnavailable = array_diff($providersRequested, array_keys($providers)); - // initialize response with unavailable providers + 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 ($providers as $provider) { - $serviceSelector = $sources[$provider->identifier()]; - $servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : []; - /** @var ServiceBaseInterface[] $services */ - $services = $provider->serviceList($tenantId, $userId, $servicesRequested); + 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[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); + $responseData[$providerId] = array_fill_keys($servicesUnavailable, false); } // iterate through available services foreach ($services as $service) { - $collectionSelector = $serviceSelector[$service->identifier()]; - $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; - if ($collectionsRequested === []) { - $responseData[$provider->identifier()][$service->identifier()] = false; + // 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) { - $entitySelector = $collectionSelector[$collection] ?? null; - $responseData[$provider->identifier()][$service->identifier()][$collection] = $service->entityDelta($collection, $entitySelector); + // 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); } } } @@ -845,38 +866,32 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param string $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * @param EntityMutableInterface|array $entity entity to create - * @param array $options additional options - * + * @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, string $providerId, string|int $serviceId, string|int $collectionId, EntityMutableInterface|array $object, array $options = []): EntityBaseInterface { + public function entityCreate(string $tenantId, string $userId, CollectionIdentifier $target, EntityPropertiesMutableInterface|array $properties, array $options = []): EntityBaseInterface { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); // Check if service supports entity creation - if (!($service instanceof ServiceEntityMutableInterface)) { - throw new InvalidArgumentException("Service does not support entity mutations"); + 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 is not capable of creating entities"); + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of creating entities"); } - - if (is_array($object)) { - $entity = $service->entityFresh(); - $entity->getProperties()->jsonDeserialize($object); - } else { - $entity = $object; + // convert properties if necessary + if ($properties instanceof EntityPropertiesMutableInterface === false) { + $properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties); } - - // construct target collection identifier - $target = new CollectionIdentifier($providerId, (string) $serviceId, (string) $collectionId); - - return $service->entityCreate($target, $entity->getProperties(), $options); + // create entity + return $service->entityCreate($target, $properties, $options); } /** @@ -884,68 +899,210 @@ class Manager { * * @param string $tenantId tenant identifier * @param string $userId user identifier - * @param string|int $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * @param string|int $identifier entity identifier - * @param EntityBaseInterface|array $entity entity with modifications - * + * @param EntityIdentifier $target target entity identifier + * @param EntityPropertiesMutableInterface|array $properties properties to modify + * * @return EntityBaseInterface * @throws InvalidArgumentException */ - public function entityUpdate(string $tenantId, string $userId, string|int $providerId, string|int $serviceId, string|int $collectionId, string|int $identifier, EntityBaseInterface|array $object): EntityBaseInterface { + public function entityModify(string $tenantId, string $userId, EntityIdentifier $target, EntityPropertiesMutableInterface|array $properties): EntityBaseInterface { // retrieve service - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - // Check if service supports entity creation - if (!($service instanceof ServiceEntityMutableInterface)) { - throw new InvalidArgumentException("Service does not support entity mutations"); + $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 is not capable of modifying entities"); + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of modifying entities"); } - - if (is_array($object)) { - $entity = $service->entityFresh(); - $entity->getProperties()->jsonDeserialize($object); - } else { - $entity = $object; + // convert properties if necessary + if ($properties instanceof EntityPropertiesMutableInterface === false) { + $properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties); } - - // construct target entity identifier - $target = new EntityIdentifier($providerId, (string) $serviceId, (string) $collectionId, (string) $identifier); - - return $service->entityModify($target, $entity->getProperties()); + // modify entity + return $service->entityModify($target, $properties); } /** - * Destroy an entity from a collection + * Deletes entities * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param string|int $providerId provider identifier - * @param string|int $serviceId service identifier - * @param string|int $collectionId collection identifier - * @param string|int $identifier entity identifier - * - * @return bool - * @throws InvalidArgumentException + * @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, string|int $providerId, string|int $serviceId, string|int $collectionId, string|int $identifier): bool { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!($service instanceof ServiceEntityMutableInterface)) { - throw new InvalidArgumentException('Service does not support entity destruction'); + public function entityDelete(string $tenantId, string $userId, EntityIdentifier ...$targets): array { + $operationOutcome = []; + $targetIdentifiers = new ResourceIdentifiers(); + + foreach ($targets as $target) { + $targetIdentifiers->add($target); } - // construct target entity identifier - $target = new EntityIdentifier($providerId, (string) $serviceId, (string) $collectionId, (string) $identifier); + // 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())); + } + } - // delete entity (returns results keyed by source identifier) - $results = $service->entityDelete($target); - $outcome = $results[(string) $target] ?? null; + return $operationOutcome; + } - return $outcome !== null && ($outcome['disposition'] ?? 'error') !== 'error'; + /** + * 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; } } + diff --git a/lib/Stream/ExpectedTotal.php b/lib/Stream/ExpectedTotal.php new file mode 100644 index 0000000..f7e09fa --- /dev/null +++ b/lib/Stream/ExpectedTotal.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ChronoManager\Stream; + +/** + * Implemented by a stream event that declares, up front, how many data frames + * are expected to follow. When such an event leads a stream generator, the + * envelope writer folds its value into the `control:start` frame's `total` + * (the progress denominator) instead of emitting it as a `data` frame. + */ +interface ExpectedTotal { + public function expectedTotal(): int; +}