diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 577e26e..4b13d2e 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -11,32 +11,44 @@ namespace KTXM\DocumentsManager\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\DocumentsManager\Manager; +use KTXM\DocumentsManager\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_SOURCE = 'Missing parameter: source'; + 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_SOURCE = 'Invalid parameter: source must be a string'; + private const ERR_INVALID_TARGET = 'Invalid parameter: target must be a string'; + private const ERR_INVALID_TARGETS = 'Invalid parameter: targets must be an array'; private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array'; + private const ERR_TARGET_COLLECTION = 'Invalid parameter: target must be provider:service:collection'; + private const ERR_TARGET_ENTITY = 'Invalid parameter: target must be provider:service:collection:entity'; public function __construct( private readonly SessionTenant $tenantIdentity, @@ -44,7 +56,20 @@ class DefaultController extends ControllerAbstract { private readonly Manager $manager, private readonly LoggerInterface $logger ) {} - + + /** + * Main API endpoint for documents operations + * + * Single operation: + * { + * "version": 1, + * "transaction": "tx-1", + * "operation": "entity.create", + * "data": {...} + * } + * + * @return Response + */ #[AuthenticatedRoute('/v1', name: 'documents.manager.v1', methods: ['POST'])] public function index( int $version, @@ -52,16 +77,21 @@ class DefaultController extends ControllerAbstract { string|null $operation = null, array|null $data = null, string|null $user = null - ): JsonResponse { + ): Response { // authorize request $tenantId = $this->tenantIdentity->identifier(); $userId = $this->userIdentity->identifier(); try { - + if ($operation !== null) { - $result = $this->process($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, @@ -70,9 +100,9 @@ class DefaultController extends ControllerAbstract { 'data' => $result ], JsonResponse::HTTP_OK); } - + throw new InvalidArgumentException('Operation must be provided'); - + } catch (Throwable $t) { $this->logger->error('Error processing request', ['exception' => $t]); return new JsonResponse([ @@ -91,7 +121,7 @@ class DefaultController extends ControllerAbstract { /** * Process a single operation */ - private function process(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), @@ -100,8 +130,8 @@ class DefaultController extends ControllerAbstract { // Service operations 'service.list' => $this->serviceList($tenantId, $userId, $data), - 'service.extant' => $this->serviceExtant($tenantId, $userId, $data), 'service.fetch' => $this->serviceFetch($tenantId, $userId, $data), + 'service.extant' => $this->serviceExtant($tenantId, $userId, $data), 'service.create' => $this->serviceCreate($tenantId, $userId, $data), 'service.update' => $this->serviceUpdate($tenantId, $userId, $data), 'service.delete' => $this->serviceDelete($tenantId, $userId, $data), @@ -109,8 +139,8 @@ class DefaultController extends ControllerAbstract { // Collection operations 'collection.list' => $this->collectionList($tenantId, $userId, $data), - 'collection.extant' => $this->collectionExtant($tenantId, $userId, $data), 'collection.fetch' => $this->collectionFetch($tenantId, $userId, $data), + 'collection.extant' => $this->collectionExtant($tenantId, $userId, $data), 'collection.create' => $this->collectionCreate($tenantId, $userId, $data), 'collection.update' => $this->collectionUpdate($tenantId, $userId, $data), 'collection.delete' => $this->collectionDelete($tenantId, $userId, $data), @@ -118,10 +148,11 @@ class DefaultController extends ControllerAbstract { 'collection.move' => $this->collectionMove($tenantId, $userId, $data), // Entity operations - 'entity.list' => $this->entityList($tenantId, $userId, $data), - 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), - 'entity.extant' => $this->entityExtant($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), @@ -131,53 +162,103 @@ class DefaultController extends ControllerAbstract { 'entity.read.chunk' => $this->entityReadChunk($tenantId, $userId, $data), 'entity.write' => $this->entityWrite($tenantId, $userId, $data), 'entity.write.chunk' => $this->entityWriteChunk($tenantId, $userId, $data), - - // Node operations (unified recursive) - 'node.list' => $this->nodeList($tenantId, $userId, $data), - 'node.delta' => $this->nodeDelta($tenantId, $userId, $data), - default => throw new InvalidArgumentException('Unknown operation: ' . $operation) - }; + default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation) + }; + } + + // ==================== Identifier Helpers ==================== + + /** + * Parse a required collection target identifier (provider:service:collection) + */ + private function collectionTarget(array $data, string $key = 'target'): CollectionIdentifier { + if (!isset($data[$key])) { + throw new InvalidArgumentException('Missing parameter: ' . $key); + } + if (!is_string($data[$key])) { + throw new InvalidArgumentException("Invalid parameter: $key must be a string"); + } + $identifier = ResourceIdentifier::fromString($data[$key]); + if (!$identifier instanceof CollectionIdentifier) { + throw new InvalidArgumentException(self::ERR_TARGET_COLLECTION); + } + return $identifier; + } + + /** + * Parse an optional collection target identifier (absent = root) + */ + private function collectionTargetOptional(array $data, string $key = 'target'): ?CollectionIdentifier { + if (!isset($data[$key]) || $data[$key] === null || $data[$key] === '') { + return null; + } + return $this->collectionTarget($data, $key); + } + + /** + * Parse a required entity target identifier (provider:service:collection:entity) + */ + private function entityTarget(array $data, string $key = 'target'): EntityIdentifier { + if (!isset($data[$key])) { + throw new InvalidArgumentException('Missing parameter: ' . $key); + } + if (!is_string($data[$key])) { + throw new InvalidArgumentException("Invalid parameter: $key must be a string"); + } + $identifier = ResourceIdentifier::fromString($data[$key]); + if (!$identifier instanceof EntityIdentifier) { + throw new InvalidArgumentException(self::ERR_TARGET_ENTITY); + } + return $identifier; } // ==================== Provider Operations ==================== 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']); - } - - return $this->manager->providerList($tenantId, $userId, $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, $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']); - - return $this->manager->providerExtant($tenantId, $userId, $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, $data['targets']); } @@ -185,45 +266,54 @@ 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); + } private function serviceFetch(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'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); - } - if (!is_string($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); - } - + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + return $this->manager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']); } private function serviceExtant(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->serviceExtant($tenantId, $userId, $sources); + + 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 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 { @@ -239,7 +329,7 @@ class DefaultController extends ControllerAbstract { if (!is_array($data['data'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } - + return $this->manager->serviceCreate( $tenantId, $userId, @@ -267,13 +357,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,7 +384,7 @@ class DefaultController extends ControllerAbstract { if (!is_string($data['identifier'])) { throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); } - + return $this->manager->serviceDelete( $tenantId, $userId, @@ -300,18 +394,18 @@ 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, @@ -327,57 +421,59 @@ class DefaultController extends ControllerAbstract { 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 or provider:service:collection identifiers'); + } + } } - + $filter = $data['filter'] ?? null; $sort = $data['sort'] ?? null; - + 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(self::ERR_TARGET_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:collection identifiers'); + } + } + + return $this->manager->collectionExtant($tenantId, $userId, $sources); } private function collectionCreate(string $tenantId, string $userId, array $data): mixed { @@ -393,454 +489,334 @@ 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['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); } if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } - + + $targetIdentifier = $this->collectionTargetOptional($data); + return $this->manager->collectionCreate( $tenantId, $userId, $data['provider'], $data['service'], - $data['collection'] ?? null, - $data['properties'] + $targetIdentifier, + $data['properties'], + $data['options'] ?? [] ); } private function collectionUpdate(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['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 (!isset($data['properties'])) { throw new InvalidArgumentException(self::ERR_MISSING_DATA); } if (!is_array($data['properties'])) { throw new InvalidArgumentException(self::ERR_INVALID_DATA); } - + + $targetIdentifier = $this->collectionTarget($data); + 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); + $targetIdentifier = $this->collectionTarget($data); + + $result = $this->manager->collectionDelete($tenantId, $userId, $targetIdentifier, $data['options'] ?? []); + + if (is_bool($result)) { + return [ + 'disposition' => 'deleted' + ]; } - if (!is_string($data['provider'])) { - throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + + if ($result instanceof JsonSerializable) { + return [ + 'disposition' => 'moved', + 'mutation' => $result + ]; } - 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); - } - - return $this->manager->collectionDelete( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['identifier'], - $data['options'] ?? [] - ); + + return $result; } - private function collectionCopy(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); - } - - $location = $data['location'] ?? null; - - return $this->manager->collectionCopy( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['identifier'], - $location - ); + private function collectionCopy(string $tenantId, string $userId, array $data): mixed { + $sourceIdentifier = $this->collectionTarget($data, 'source'); + $targetIdentifier = $this->collectionTargetOptional($data); + + return $this->manager->collectionCopy($tenantId, $userId, $targetIdentifier, $sourceIdentifier); } - private function collectionMove(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); - } - - $location = $data['location'] ?? null; - - return $this->manager->collectionMove( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['identifier'], - $location - ); + private function collectionMove(string $tenantId, string $userId, array $data): mixed { + $sourceIdentifier = $this->collectionTarget($data, 'source'); + $targetIdentifier = $this->collectionTargetOptional($data); + + return $this->manager->collectionMove($tenantId, $userId, $targetIdentifier, $sourceIdentifier); } // ==================== 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); - } - - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - - $filter = $data['filter'] ?? null; - $sort = $data['sort'] ?? null; - $range = $data['range'] ?? null; + private function entityListBulk(string $tenantId, string $userId, array $data): mixed { - return $this->manager->entityList($tenantId, $userId, $sources, $filter, $sort, $range); + 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->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'], + ); + } + + /** + * 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); + + $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'); + } } - 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); - } - - 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['properties'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['properties'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + $target = $this->collectionTarget($data); + $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['properties'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['properties'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + $target = $this->entityTarget($data); + + 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 { + return $this->entityRelocate($tenantId, $userId, $data, 'entityMove'); + } + + private function entityCopy(string $tenantId, string $userId, array $data): mixed { + return $this->entityRelocate($tenantId, $userId, $data, 'entityCopy'); + } + + /** + * Shared request handling for entity move/copy operations + */ + private function entityRelocate(string $tenantId, string $userId, array $data, string $method): 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->entityDelta($tenantId, $userId, $sources); + + $target = $this->collectionTarget($data); + + $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->{$method}($tenantId, $userId, $target, ...$sources->all()); } - private function entityCopy(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); - } - - $collection = $data['collection'] ?? null; - $destination = $data['destination'] ?? null; - - return $this->manager->entityCopy( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $collection, - $data['identifier'], - $destination - ); - } - - private function entityMove(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); - } - - $collection = $data['collection'] ?? null; - $destination = $data['destination'] ?? null; - - return $this->manager->entityMove( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $collection, - $data['identifier'], - $destination - ); - } + // ==================== Entity Content Operations ==================== private function entityRead(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['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['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); - } - if (!is_string($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); - } - - $content = $this->manager->entityRead( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['collection'], - $data['identifier'] - ); - + $target = $this->entityTarget($data); + + $content = $this->manager->entityRead($tenantId, $userId, $target); + return [ 'content' => $content !== null ? base64_encode($content) : null, 'encoding' => 'base64' ]; } - private function entityWrite(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['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['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); - } - if (!is_string($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); - } - if (!isset($data['content'])) { - throw new InvalidArgumentException(self::ERR_MISSING_DATA); - } - - // Decode content if base64 encoded - $content = $data['content']; - if (isset($data['encoding']) && $data['encoding'] === 'base64') { - $content = base64_decode($content); - if ($content === false) { - throw new InvalidArgumentException('Invalid base64 encoded content'); - } - } - - $bytesWritten = $this->manager->entityWrite( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['collection'], - $data['identifier'], - $content - ); - - return [ - 'bytesWritten' => $bytesWritten - ]; - } - private function entityReadChunk(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['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['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); - } - if (!is_string($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); - } if (!isset($data['offset']) || !is_int($data['offset'])) { throw new InvalidArgumentException('Missing parameter: offset'); } @@ -848,13 +824,12 @@ class DefaultController extends ControllerAbstract { throw new InvalidArgumentException('Missing parameter: length'); } + $target = $this->entityTarget($data); + $chunk = $this->manager->entityReadChunk( $tenantId, $userId, - $data['provider'], - $data['service'], - $data['collection'], - $data['identifier'], + $target, $data['offset'], $data['length'] ); @@ -867,31 +842,30 @@ class DefaultController extends ControllerAbstract { ]; } + private function entityWrite(string $tenantId, string $userId, array $data = []): mixed { + if (!isset($data['content'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + + $target = $this->entityTarget($data); + + // Decode content if base64 encoded + $content = $data['content']; + if (isset($data['encoding']) && $data['encoding'] === 'base64') { + $content = base64_decode($content); + if ($content === false) { + throw new InvalidArgumentException('Invalid base64 encoded content'); + } + } + + $bytesWritten = $this->manager->entityWrite($tenantId, $userId, $target, $content); + + return [ + 'bytesWritten' => $bytesWritten + ]; + } + private function entityWriteChunk(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['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['identifier'])) { - throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); - } - if (!is_string($data['identifier'])) { - throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); - } if (!isset($data['offset']) || !is_int($data['offset'])) { throw new InvalidArgumentException('Missing parameter: offset'); } @@ -899,6 +873,8 @@ class DefaultController extends ControllerAbstract { throw new InvalidArgumentException(self::ERR_MISSING_DATA); } + $target = $this->entityTarget($data); + // Decode content if base64 encoded $content = $data['content']; if (isset($data['encoding']) && $data['encoding'] === 'base64') { @@ -911,10 +887,7 @@ class DefaultController extends ControllerAbstract { $bytesWritten = $this->manager->entityWriteChunk( $tenantId, $userId, - $data['provider'], - $data['service'], - $data['collection'], - $data['identifier'], + $target, $data['offset'], $content ); @@ -925,68 +898,4 @@ class DefaultController extends ControllerAbstract { ]; } - // ==================== Node Operations (Unified/Recursive) ==================== - - private function nodeList(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['service'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); - } - if (!is_string($data['service'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); - } - - $provider = $data['provider']; - $service = $data['service']; - $collection = $data['collection'] ?? null; - $recursive = $data['recursive'] ?? false; - $filter = $data['filter'] ?? null; - $sort = $data['sort'] ?? null; - $range = $data['range'] ?? null; - - return $this->manager->nodeList( - $tenantId, - $userId, - $provider, - $service, - $collection, - $recursive, - $filter, - $sort, - $range - ); - } - - private function nodeDelta(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['signature']) || !is_string($data['signature'])) { - throw new InvalidArgumentException('Missing required parameter: signature'); - } - - $location = $data['location'] ?? null; - $recursive = $data['recursive'] ?? false; - $detail = $data['detail'] ?? 'ids'; - - return $this->manager->nodeDelta( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $location, - $data['signature'], - $recursive, - $detail - ); - } - } diff --git a/lib/Controllers/TransferController.php b/lib/Controllers/TransferController.php index 4e9277a..cfd42dd 100644 --- a/lib/Controllers/TransferController.php +++ b/lib/Controllers/TransferController.php @@ -15,6 +15,11 @@ use KTXC\Http\Response\StreamedResponse; use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; +use KTXF\Documents\Collection\CollectionBaseInterface; +use KTXF\Documents\Entity\EntityBaseInterface; +use KTXF\Resource\Identifier\CollectionIdentifier; +use KTXF\Resource\Identifier\EntityIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXM\DocumentsManager\Manager; use KTXM\DocumentsManager\Transfer\StreamingZip; @@ -23,7 +28,7 @@ use Throwable; /** * Controller for file transfers (downloads and uploads) - * + * * Handles binary file transfers that don't fit the JSON API pattern: * - Single file downloads (streamed) * - Multi-file downloads as ZIP (streamed) @@ -42,7 +47,7 @@ class TransferController extends ControllerAbstract /** * Download a single file - * + * * GET /download/entity/{provider}/{service}/{collection}/{identifier} */ #[AuthenticatedRoute( @@ -55,15 +60,11 @@ class TransferController extends ControllerAbstract $userId = $this->userIdentity->identifier(); try { + $target = new EntityIdentifier($provider, $service, $collection, $identifier); + // Fetch entity metadata - $entities = $this->manager->entityFetch( - $tenantId, - $userId, - $provider, - $service, - $collection, - [$identifier] - ); + /** @var EntityBaseInterface[] $entities */ + $entities = $this->manager->entityFetchBulk($tenantId, $userId, $target); if (empty($entities) || !isset($entities[$identifier])) { return new JsonResponse([ @@ -73,16 +74,9 @@ class TransferController extends ControllerAbstract } $entity = $entities[$identifier]; - + // Get the stream - $stream = $this->manager->entityReadStream( - $tenantId, - $userId, - $provider, - $service, - $collection, - $identifier - ); + $stream = $this->manager->entityReadStream($tenantId, $userId, $target); if ($stream === null) { return new JsonResponse([ @@ -114,7 +108,7 @@ class TransferController extends ControllerAbstract if ($size > 0) { $response->headers->set('Content-Length', (string) $size); } - $response->headers->set('Content-Disposition', + $response->headers->set('Content-Disposition', $response->headers->makeDisposition('attachment', $filename, $this->asciiFallback($filename)) ); $response->headers->set('Cache-Control', 'private, no-cache'); @@ -132,7 +126,7 @@ class TransferController extends ControllerAbstract /** * Download multiple files as a ZIP archive - * + * * GET /download/archive?provider=...&service=...&ids[]=...&ids[]=... */ #[AuthenticatedRoute( @@ -179,10 +173,7 @@ class TransferController extends ControllerAbstract $stream = $this->manager->entityReadStream( $tenantId, $userId, - $provider, - $service, - $file['collection'], - $file['id'] + new EntityIdentifier($provider, $service, (string)$file['collection'], (string)$file['id']) ); if ($stream !== null) { @@ -221,7 +212,7 @@ class TransferController extends ControllerAbstract /** * Download a collection (folder) as a ZIP archive with structure preserved - * + * * GET /download/collection/{provider}/{service}/{identifier} */ #[AuthenticatedRoute( @@ -239,9 +230,7 @@ class TransferController extends ControllerAbstract $collection = $this->manager->collectionFetch( $tenantId, $userId, - $provider, - $service, - $identifier + new CollectionIdentifier($provider, $service, $identifier) ); if ($collection === null) { @@ -251,7 +240,7 @@ class TransferController extends ControllerAbstract ], Response::HTTP_NOT_FOUND); } - $folderName = $collection->getLabel() ?? 'folder'; + $folderName = $collection->getProperties()->getLabel() ?? 'folder'; $archiveName = $this->sanitizeFilename($folderName) . '.zip'; // Build recursive file list @@ -275,10 +264,7 @@ class TransferController extends ControllerAbstract $stream = $this->manager->entityReadStream( $tenantId, $userId, - $provider, - $service, - $file['collection'], - $file['id'] + new EntityIdentifier($provider, $service, (string)$file['collection'], (string)$file['id']) ); if ($stream !== null) { @@ -325,13 +311,10 @@ class TransferController extends ControllerAbstract // Try as entity first if ($collection !== null) { /** @var EntityBaseInterface[] $entities */ - $entities = $this->manager->entityFetch( + $entities = $this->manager->entityFetchBulk( $tenantId, $userId, - $provider, - $service, - $collection, - [$id] + new EntityIdentifier($provider, $service, $collection, (string)$id) ); if (!empty($entities) && isset($entities[$id])) { @@ -340,8 +323,8 @@ class TransferController extends ControllerAbstract 'type' => 'file', 'id' => $id, 'collection' => $collection, - 'path' => $entity->getLabel() ?? $id, - 'modTime' => $entity->modifiedOn()?->getTimestamp(), + 'path' => $entity->getProperties()->getLabel() ?? $id, + 'modTime' => $entity->modified()?->getTimestamp(), ]; continue; } @@ -352,19 +335,17 @@ class TransferController extends ControllerAbstract $collectionNode = $this->manager->collectionFetch( $tenantId, $userId, - $provider, - $service, - $id + new CollectionIdentifier($provider, $service, (string)$id) ); if ($collectionNode !== null) { - $folderName = $collectionNode->getLabel() ?? $id; + $folderName = $collectionNode->getProperties()->getLabel() ?? $id; $subFiles = $this->resolveCollectionContents( $tenantId, $userId, $provider, $service, - $id, + (string)$id, $folderName ); $files = array_merge($files, $subFiles); @@ -407,17 +388,13 @@ class TransferController extends ControllerAbstract ]; } - // Get all nodes in this collection using nodeList with recursive=false - // We handle recursion ourselves to build proper paths + // List immediate child collections and entities of this collection; + // recursion is handled here to build proper archive paths + $sources = new ResourceIdentifiers(); + $sources->add(new CollectionIdentifier($provider, $service, $collectionId)); try { - $nodes = $this->manager->nodeList( - $tenantId, - $userId, - $provider, - $service, - $collectionId, - false // Not recursive - we handle it ourselves - ); + $collections = $this->manager->collectionList($tenantId, $userId, $sources)[$provider][$service] ?? []; + $entities = $this->manager->entityListBulk($tenantId, $userId, $sources)[$provider][$service][$collectionId] ?? []; } catch (Throwable $e) { $this->logger->warning('Failed to list collection contents', [ 'collection' => $collectionId, @@ -426,34 +403,35 @@ class TransferController extends ControllerAbstract return $files; } - foreach ($nodes as $node) { - $nodeName = $node->getLabel() ?? (string) $node->id(); + /** @var CollectionBaseInterface $node */ + foreach ($collections as $node) { + $nodeName = $node->getProperties()->getLabel() ?? (string) $node->identifier(); $nodePath = $basePath !== '' ? $basePath . '/' . $nodeName : $nodeName; + // Recursively get contents of sub-collection + $subFiles = $this->resolveCollectionContents( + $tenantId, + $userId, + $provider, + $service, + (string) $node->identifier(), + $nodePath, + $depth + 1, + $maxDepth + ); + $files = array_merge($files, $subFiles); + } - if ($node->isCollection()) { - // Recursively get contents of sub-collection - $subFiles = $this->resolveCollectionContents( - $tenantId, - $userId, - $provider, - $service, - (string) $node->id(), - $nodePath, - $depth + 1, - $maxDepth - ); - $files = array_merge($files, $subFiles); - } else { - // It's an entity (file) - /** @var INodeEntityBase $node */ - $files[] = [ - 'type' => 'file', - 'id' => (string) $node->id(), - 'collection' => $collectionId, - 'path' => $nodePath, - 'modTime' => $node->modifiedOn()?->getTimestamp(), - ]; - } + /** @var EntityBaseInterface $node */ + foreach ($entities as $node) { + $nodeName = $node->getProperties()->getLabel() ?? (string) $node->identifier(); + $nodePath = $basePath !== '' ? $basePath . '/' . $nodeName : $nodeName; + $files[] = [ + 'type' => 'file', + 'id' => (string) $node->identifier(), + 'collection' => $collectionId, + 'path' => $nodePath, + 'modTime' => $node->modified()?->getTimestamp(), + ]; } return $files; @@ -467,11 +445,11 @@ class TransferController extends ControllerAbstract // Remove or replace problematic characters $filename = preg_replace('/[<>:"\/\\|?*\x00-\x1F]/', '_', $filename); $filename = trim($filename, '. '); - + if ($filename === '') { $filename = 'download'; } - + return $filename; } diff --git a/lib/Manager.php b/lib/Manager.php index 133cd80..2f05ed6 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -6,37 +6,32 @@ namespace KTXM\DocumentsManager; use InvalidArgumentException; use KTXC\Resource\ProviderManager; -use KTXF\Files\Node\INodeBase; -use KTXF\Files\Node\INodeCollectionBase; -use KTXF\Files\Node\INodeCollectionMutable; -use KTXF\Files\Node\INodeEntityBase; -use KTXF\Files\Node\INodeEntityMutable; -use KTXF\Files\Service\IServiceCollectionMutable; -use KTXF\Files\Service\IServiceEntityMutable; -use KTXF\Resource\Documents\Collection\CollectionBaseInterface; -use KTXF\Resource\Documents\Collection\CollectionMutableInterface; -use KTXF\Resource\Documents\Entity\EntityBaseInterface; -use KTXF\Resource\Documents\Entity\EntityMutableInterface; -use KTXF\Resource\Documents\Provider\ProviderBaseInterface; -use KTXF\Resource\Documents\Provider\ProviderServiceMutateInterface; -use KTXF\Resource\Documents\Provider\ProviderServiceTestInterface; -use KTXF\Resource\Documents\Service\ServiceBaseInterface; -use KTXF\Resource\Documents\Service\ServiceCollectionMutableInterface; -use KTXF\Resource\Documents\Service\ServiceEntityMutableInterface; +use KTXF\Documents\Collection\CollectionBaseInterface; +use KTXF\Documents\Collection\CollectionPropertiesMutableInterface; +use KTXF\Documents\Entity\EntityBaseInterface; +use KTXF\Documents\Entity\EntityPropertiesMutableInterface; +use KTXF\Documents\Provider\ProviderBaseInterface; +use KTXF\Documents\Provider\ProviderServiceMutateInterface; +use KTXF\Documents\Provider\ProviderServiceTestInterface; +use KTXF\Documents\Service\ServiceBaseInterface; +use KTXF\Documents\Service\ServiceCollectionMutableInterface; +use KTXF\Documents\Service\ServiceEntityMutableInterface; +use KTXF\Documents\Service\ServiceMutableInterface; use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Identifier\CollectionIdentifier; +use KTXF\Resource\Identifier\EntityIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; -use KTXF\Resource\Range\IRangeTally; -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 KTXM\ProviderMailSystem\Providers\Service; use Psr\Log\LoggerInterface; +/** + * Documents Manager + * + * Provides unified document management across multiple providers + */ class Manager { public function __construct( @@ -47,48 +42,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_DOCUMENT, $filter); + return $this->providerManager->providers(ProviderBaseInterface::TYPE_DOCUMENT, $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), @@ -102,18 +95,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; @@ -126,7 +125,7 @@ class Manager { * @param string $userId user identifier * @param string $providerId provider identifier * @param string|int $serviceId service identifier - * + * * @return ServiceBaseInterface * @throws InvalidArgumentException */ @@ -145,24 +144,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; } @@ -187,16 +185,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); } @@ -211,30 +212,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); } @@ -257,37 +265,39 @@ 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. + * Test a service connection + * + * Tests either an existing service (provider + service) or a fresh + * configuration (provider + location + identity) * * @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( @@ -311,14 +321,14 @@ class Manager { 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) { @@ -347,7 +357,7 @@ class Manager { return $provider->serviceTest($service); } - + throw new InvalidArgumentException( 'Either (provider + service) or (provider + location + identity) must be provided' ); @@ -362,32 +372,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) { - // extract required required service identifiers for this provider from sources - $serviceSelector = $sources[$provider->identifier()] ?? null; - $serviceSelected = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : []; - /** @var ServiceBaseInterface[] $services */ - $services = $provider->serviceList($tenantId, $userId, $serviceSelected); - // retrieve collections for each service + foreach ($aggregateServices as $services) { foreach ($services as $service) { - // extract required collection identifiers for this service from sources - $collectionSelector = $serviceSelector[$service->identifier()] ?? null; - $collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [null]; + if ($service->getEnabled() === false) { + continue; + } // construct filter for collections $collectionFilter = null; if ($filter !== null && $filter !== []) { @@ -404,67 +408,19 @@ class Manager { $collectionSort->condition($attribute, $direction); } } - // retrieve collections - foreach ($collectionSelected as $collectionId) { - $collections = $service->collectionList($collectionId, $collectionFilter, $collectionSort); - if ($collections !== []) { - $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $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; @@ -477,20 +433,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(null, ...$collectionsRequested); + $collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable)); + $responseData[$providerId][$service->identifier()] = array_merge( + $collectionsAvailable, + array_fill_keys($collectionsUnavailable, false) + ); + } + } + return $responseData; } /** @@ -498,36 +500,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 (null for root) + * @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); } - // Create collection - return $service->collectionCreate($collectionId, $collection, $options); + return $service->collectionCreate($target, $properties, $options); } /** @@ -535,35 +535,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); } - // Update collection - return $service->collectionUpdate($collectionId, $collection); + return $service->collectionUpdate($target, $properties); } /** @@ -573,69 +569,91 @@ 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"); } - - $force = $options['force'] ?? false; - $recursive = $options['recursive'] ?? false; - + // convert options + $force = $options['force'] ?? ($options['recursive'] ?? false); // delete collection - return $service->collectionDelete($collectionId, $force, $recursive); + return $service->collectionDelete($target, $force); } /** - * Copy a collection + * Move a specific collection to a new parent collection + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param CollectionIdentifier|null $target Target parent collection identifier (null for root) + * @param CollectionIdentifier $source Source collection identifier (collection to move) + * + * @return CollectionBaseInterface Moved collection */ - public function collectionCopy( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $identifier, - string|int|null $location - ): INodeCollectionBase { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof IServiceCollectionMutable) { - throw new InvalidArgumentException('Service does not support collection copy'); - } - - return $service->collectionCopy($identifier, $location); + public function collectionMove(string $tenantId, ?string $userId, CollectionIdentifier|null $target, CollectionIdentifier $source): CollectionBaseInterface { + return $this->collectionRelocate($tenantId, $userId, $target, $source, ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_MOVE, 'collectionMove'); } /** - * Move a collection + * Copy a specific collection to a new parent collection + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param CollectionIdentifier|null $target Target parent collection identifier (null for root) + * @param CollectionIdentifier $source Source collection identifier (collection to copy) + * + * @return CollectionBaseInterface Copied collection */ - public function collectionMove( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $identifier, - string|int|null $location - ): INodeCollectionBase { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof IServiceCollectionMutable) { - throw new InvalidArgumentException('Service does not support collection move'); + public function collectionCopy(string $tenantId, ?string $userId, CollectionIdentifier|null $target, CollectionIdentifier $source): CollectionBaseInterface { + return $this->collectionRelocate($tenantId, $userId, $target, $source, ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_COPY, 'collectionCopy'); + } + + /** + * Shared implementation for collection move/copy operations + * + * @param string $capability Required service capability + * @param string $method Service method to invoke (collectionMove|collectionCopy) + */ + private function collectionRelocate(string $tenantId, ?string $userId, CollectionIdentifier|null $target, CollectionIdentifier $source, string $capability, string $method): CollectionBaseInterface { + // validate that source and target are the same provider and service + if ($target !== null && ($source->provider() !== $target->provider() || $source->service() !== $target->service())) { + throw new InvalidArgumentException("Source '{$source->service()}' and target '{$target->service()}' collections must belong to the same provider and service"); } - - return $service->collectionMove($identifier, $location); + // Validate that source and target are not the same + if ($target !== null && $source->collection() === $target->collection()) { + throw new InvalidArgumentException("Source '{$source->collection()}' and target '{$target->collection()}' collections are the same"); + } + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $source->provider(), $source->service()); + // Check if service supports collection relocation + 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($capability)) { + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of this collection operation"); + } + // relocate collection + return $service->{$method}($target, $source); } // ==================== Entity Operations ==================== @@ -647,28 +665,36 @@ 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) { - // extract required collection identifiers for this service from sources - $collectionSelector = $serviceSelector[$service->identifier()] ?? null; - $collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [null]; - // 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 === []) { + // documents are hierarchical: service level selection lists the root collection + $collectionSelected = ['']; + } + // construct filter for entities $entityFilter = null; if ($filter !== null && $filter !== []) { $entityFilter = $service->entityListFilter(); @@ -687,125 +713,206 @@ class Manager { // construct range for entities $entityRange = null; if ($range !== null && $range !== [] && isset($range['type'])) { - $entityRange = $service->entityListRange(RangeType::from($range['type'])); - // Cast to IRangeTally if the range type is TALLY - if ($entityRange->type() === RangeType::TALLY) { - /** @var 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']); - } - } + $entityRange = $service->entityListRange(RangeType::from($range['type']))->jsonDeserialize($range); } - // retrieve entities for each collection - foreach ($collectionSelected as $collectionId) { - $entities = $service->entityList($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); - - // retrieve collection - return $service->entityFetch($collectionId, ...$identifiers); + 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 === []) { + // documents are hierarchical: service level selection lists the root collection + $collectionSelected = ['']; + } + // 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']))->jsonDeserialize($range); + } + // 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) { + $collectionExists = $service->collectionExtant(null, (string)$collectionId); + if (($collectionExists[(string)$collectionId] ?? false) === false) { // 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); } } } @@ -813,48 +920,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); } } } @@ -866,35 +976,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); } - - return $service->entityCreate($collectionId, $entity, $options); + // create entity + return $service->entityCreate($target, $properties, $options); } /** @@ -902,289 +1009,337 @@ 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->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_CREATE)) { - throw new InvalidArgumentException("Service is not capable of creating entities"); + if ($service instanceof ServiceEntityMutableInterface === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' does not support entity mutations"); } - - if (is_array($object)) { - $entity = $service->entityFresh(); - $entity->getProperties()->jsonDeserialize($object); - } else { - $entity = $object; + if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_MODIFY)) { + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of modifying entities"); } - - return $service->entityUpdate($collectionId, $identifier, $entity); + // convert properties if necessary + if ($properties instanceof EntityPropertiesMutableInterface === false) { + $properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties); + } + // 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); } - - $entity = $service->entityDelete($collectionId, $identifier); - - return $entity !== null; + + // process targets grouped by provider + foreach ($targetIdentifiers->providers() as $providerId) { + // retrieve provider and validate + $providerTargets = $targetIdentifiers->byProvider($providerId); + // process targets grouped by service for this provider + foreach ($providerTargets->services() as $serviceId) { + // extract services requested for this provider + $serviceTargets = $providerTargets->byService($serviceId); + // retrieve and validate service + $service = null; + $error = null; + try { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + } catch (\Throwable $e) { + $error = "Service $serviceId not found"; + } + if ($service instanceof ServiceEntityMutableInterface === false && $error === null) { + $error = "Service $serviceId does not support entity mutation"; + } + if ($error === null && !$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_DELETE)) { + $error = "Service $serviceId does not support entity deletion"; + } + // on error, mark all identifiers for this service as failed and continue to next service + if ($error !== null) { + foreach ($serviceTargets as $identifier) { + $operationOutcome[(string)$identifier] = ['disposition' => 'error', 'error' => $error]; + } + continue; + } + /** @var ServiceEntityMutableInterface $service */ + $operationOutcome = array_merge($operationOutcome, $service->entityDelete(...$serviceTargets->all())); + } + } + + return $operationOutcome; } /** - * Copy an entity + * 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 entityCopy( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $collection, - string|int $identifier, - string|int|null $destination - ): INodeEntityBase { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof ServiceEntityMutableInterface) { - throw new InvalidArgumentException('Service does not support entity copy'); - } - - return $service->entityCopy($collection, $identifier, $destination); + public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, EntityIdentifier ...$sources): array { + return $this->entityRelocate( + $tenantId, + $userId, + $target, + ServiceEntityMutableInterface::CAPABILITY_ENTITY_MOVE, + 'entityMove', + $sources + ); } /** - * Move an entity + * 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 entityMove( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $collection, - string|int $identifier, - string|int|null $destination - ): INodeEntityBase { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof ServiceEntityMutableInterface) { - throw new InvalidArgumentException('Service does not support entity move'); + 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 } - - return $service->entityMove($collection, $identifier, $destination); + 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; + } + + // ==================== Entity Content Operations ==================== + + /** + * Retrieve a service for a content operation, validating the read capability + */ + private function serviceForRead(string $tenantId, string $userId, EntityIdentifier $target): ServiceBaseInterface { + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); + if ($service->getEnabled() === false) { + throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled"); + } + if (!$service->capable(ServiceBaseInterface::CAPABILITY_ENTITY_READ)) { + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of reading entity content"); + } + return $service; + } + + /** + * Retrieve a service for a content operation, validating the write capability + */ + private function serviceForWrite(string $tenantId, string $userId, EntityIdentifier $target): ServiceEntityMutableInterface { + $service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service()); + 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_WRITE)) { + throw new InvalidArgumentException("Service '{$service->identifier()}' is not capable of writing entity content"); + } + return $service; } /** * Read entity content + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param EntityIdentifier $target Target entity identifier + * + * @return string|null Entity content or null if not found */ - public function entityRead( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $collection, - string|int $identifier - ): ?string { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - return $service->entityRead($collection, $identifier); + public function entityRead(string $tenantId, string $userId, EntityIdentifier $target): ?string { + return $this->serviceForRead($tenantId, $userId, $target)->entityRead($target); } /** * Read entity content as stream - * + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param EntityIdentifier $target Target entity identifier + * * @return resource|null */ - public function entityReadStream( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $collection, - string|int $identifier - ) { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - return $service->entityReadStream($collection, $identifier); + public function entityReadStream(string $tenantId, string $userId, EntityIdentifier $target) { + return $this->serviceForRead($tenantId, $userId, $target)->entityReadStream($target); } /** * Read entity content chunk + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param EntityIdentifier $target Target entity identifier + * @param int $offset Byte offset to start reading from + * @param int $length Number of bytes to read + * + * @return string|null Content chunk or null if not found */ - public function entityReadChunk( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $collection, - string|int $identifier, - int $offset, - int $length - ): ?string { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - return $service->entityReadChunk($collection, $identifier, $offset, $length); + public function entityReadChunk(string $tenantId, string $userId, EntityIdentifier $target, int $offset, int $length): ?string { + return $this->serviceForRead($tenantId, $userId, $target)->entityReadChunk($target, $offset, $length); } /** * Write entity content + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param EntityIdentifier $target Target entity identifier + * @param string $data Content to write + * + * @return int Number of bytes written */ - public function entityWrite( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $collection, - string|int $identifier, - string $data - ): int { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof ServiceEntityMutableInterface) { - throw new InvalidArgumentException('Service does not support entity write'); - } - - return $service->entityWrite($collection, $identifier, $data); + public function entityWrite(string $tenantId, string $userId, EntityIdentifier $target, string $data): int { + return $this->serviceForWrite($tenantId, $userId, $target)->entityWrite($target, $data); } /** * Write entity content from stream - * + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param EntityIdentifier $target Target entity identifier + * * @return resource|null */ - public function entityWriteStream( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $collection, - string|int $identifier - ) { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof ServiceEntityMutableInterface) { - throw new InvalidArgumentException('Service does not support entity write stream'); - } - - return $service->entityWriteStream($collection, $identifier); + public function entityWriteStream(string $tenantId, string $userId, EntityIdentifier $target) { + return $this->serviceForWrite($tenantId, $userId, $target)->entityWriteStream($target); } /** * Write entity content chunk + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param EntityIdentifier $target Target entity identifier + * @param int $offset Byte offset to start writing at + * @param string $data Content chunk to write + * + * @return int Number of bytes written */ - public function entityWriteChunk( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $collection, - string|int $identifier, - int $offset, - string $data - ): int { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof ServiceEntityMutableInterface) { - throw new InvalidArgumentException('Service does not support entity write chunk'); - } - - return $service->entityWriteChunk($collection, $identifier, $offset, $data); + public function entityWriteChunk(string $tenantId, string $userId, EntityIdentifier $target, int $offset, string $data): int { + return $this->serviceForWrite($tenantId, $userId, $target)->entityWriteChunk($target, $offset, $data); } - // ==================== Node Operations (Unified/Recursive) ==================== - - /** - * List nodes (collections and entities) at a location - * - * @return array - */ - public function nodeList( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $location = null, - bool $recursive = false, - ?array $filter = null, - ?array $sort = null, - ?array $range = null - ): array { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - // construct filter for collections - $nodeFilter = null; - if ($filter !== null && $filter !== []) { - $nodeFilter = $service->nodeListFilter(); - foreach ($filter as $attribute => $value) { - $nodeFilter->condition($attribute, $value); - } - } - // construct sort for collections - $nodeSort = null; - if ($sort !== null && $sort !== []) { - $nodeSort = $service->nodeListSort(); - foreach ($sort as $attribute => $direction) { - $nodeSort->condition($attribute, $direction); - } - } - - // construct range - $nodeRange = null; - if ($range !== null && $range !== [] && isset($range['type'])) { - $nodeRange = $service->nodeListRange(RangeType::from($range['type'])); - if ($nodeRange instanceof IRangeTally) { - if (isset($range['anchor'])) { - $nodeRange->setAnchor(RangeAnchorType::from($range['anchor'])); - } - if (isset($range['position'])) { - $nodeRange->setPosition($range['position']); - } - if (isset($range['tally'])) { - $nodeRange->setTally($range['tally']); - } - } - } - - return $service->nodeList($location, $recursive, $nodeFilter, $nodeSort, $nodeRange); - } - - /** - * Get node delta/changes since a signature - */ - public function nodeDelta( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $location, - string $signature, - bool $recursive = false, - string $detail = 'ids' - ): array { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - return $service->nodeDelta($location, $signature, $recursive, $detail); - } } diff --git a/lib/Stream/ExpectedTotal.php b/lib/Stream/ExpectedTotal.php new file mode 100644 index 0000000..66fcb22 --- /dev/null +++ b/lib/Stream/ExpectedTotal.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\DocumentsManager\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; +} diff --git a/src/models/collection.ts b/src/models/collection.ts index 9777b3c..1e0da0a 100644 --- a/src/models/collection.ts +++ b/src/models/collection.ts @@ -4,6 +4,19 @@ import type { CollectionContentTypes, CollectionInterface, CollectionModelInterface, CollectionPropertiesInterface } from "@/types/collection"; +/** + * Reduce a serialized resource identifier ("provider:service:collection") + * to its own (last) segment; plain values pass through unchanged + */ +function plainIdentifier(value: string | number | null | undefined): string | number | null { + if (value === null || value === undefined || value === '') { + return value ?? null; + } + const text = String(value); + const index = text.lastIndexOf(':'); + return index >= 0 ? text.slice(index + 1) : value; +} + export class CollectionObject implements CollectionModelInterface { _data!: CollectionInterface; @@ -24,7 +37,14 @@ export class CollectionObject implements CollectionModelInterface { } fromJson(data: CollectionInterface): CollectionObject { - this._data = data; + // the wire format carries full resource identifiers (provider:service:collection); + // reduce them to plain ids, which is what the stores and UI operate on + this._data = { + ...data, + service: plainIdentifier(data.service) ?? '', + collection: plainIdentifier(data.collection), + identifier: plainIdentifier(data.identifier) ?? '', + }; if (data.properties) { this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface); } diff --git a/src/models/entity.ts b/src/models/entity.ts index 31689e9..dbfaff3 100644 --- a/src/models/entity.ts +++ b/src/models/entity.ts @@ -5,6 +5,19 @@ import type { EntityInterface, EntityModelInterface } from "@/types/entity"; import type { DocumentInterface, DocumentModelInterface } from "@/types/document"; import { DocumentObject } from "./document"; +/** + * Reduce a serialized resource identifier ("provider:service:collection:entity") + * to its own (last) segment; plain values pass through unchanged + */ +function plainIdentifier(value: string | number | null | undefined): string | number | null { + if (value === null || value === undefined || value === '') { + return value ?? null; + } + const text = String(value); + const index = text.lastIndexOf(':'); + return index >= 0 ? text.slice(index + 1) : value; +} + export class EntityObject implements EntityModelInterface { _data!: EntityInterface; @@ -25,7 +38,14 @@ export class EntityObject implements EntityModelInterface { } fromJson(data: EntityInterface): EntityObject { - this._data = data + // the wire format carries full resource identifiers (provider:service:collection:entity); + // reduce them to plain ids, which is what the stores and UI operate on + this._data = { + ...data, + service: String(plainIdentifier(data.service) ?? ''), + collection: plainIdentifier(data.collection) ?? '', + identifier: plainIdentifier(data.identifier) ?? '', + } if (data.properties) { this._data.properties = new DocumentObject().fromJson(data.properties as DocumentInterface); } diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index c677469..b146119 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -16,6 +16,10 @@ import type { CollectionUpdateRequest, CollectionDeleteResponse, CollectionDeleteRequest, + CollectionCopyRequest, + CollectionCopyResponse, + CollectionMoveRequest, + CollectionMoveResponse, CollectionInterface, } from '../types/collection'; import { useIntegrationStore } from '@KTXC/stores/integrationStore'; @@ -141,15 +145,39 @@ export const collectionService = { /** * Delete a collection - * + * * @param request - delete request parameters - * + * * @returns Promise with deletion result */ async delete(request: CollectionDeleteRequest): Promise { return await transceivePost('collection.delete', request); }, + /** + * Copy a collection to a new parent + * + * @param request - copy request parameters + * + * @returns Promise with copied collection object + */ + async copy(request: CollectionCopyRequest): Promise { + const response = await transceivePost('collection.copy', request); + return createCollectionObject(response); + }, + + /** + * Move a collection to a new parent + * + * @param request - move request parameters + * + * @returns Promise with moved collection object + */ + async move(request: CollectionMoveRequest): Promise { + const response = await transceivePost('collection.move', request); + return createCollectionObject(response); + }, + }; export default collectionService; diff --git a/src/services/entityService.ts b/src/services/entityService.ts index c0711a9..0ae5101 100644 --- a/src/services/entityService.ts +++ b/src/services/entityService.ts @@ -2,7 +2,7 @@ * Entity management service */ -import { transceivePost } from './transceive'; +import { transceivePost, transceiveStream } from './transceive'; import type { EntityListRequest, EntityListResponse, @@ -18,6 +18,10 @@ import type { EntityDeleteResponse, EntityDeltaRequest, EntityDeltaResponse, + EntityCopyRequest, + EntityCopyResponse, + EntityMoveRequest, + EntityMoveResponse, EntityReadRequest, EntityReadResponse, EntityWriteRequest, @@ -43,14 +47,14 @@ function createEntityObject(data: EntityInterface): EntityObject { export const entityService = { /** - * Retrieve list of entities, optionally filtered by source selector - * + * Retrieve list of entities, optionally filtered by source identifiers + * * @param request - list request parameters - * + * * @returns Promise with entity object list grouped by provider, service, collection, and entity identifier */ - async list(request: EntityListRequest = {}): Promise>>>> { - const response = await transceivePost('entity.list', request); + async listBulk(request: EntityListRequest = {}): Promise>>>> { + const response = await transceivePost('entity.listBulk', request); // Convert nested response to EntityObject instances const providerList: Record>>> = {}; @@ -73,11 +77,25 @@ export const entityService = { return providerList; }, + /** + * Stream entities one by one, invoking the callback for each entity + * + * @param request - list request parameters + * @param onEntity - callback invoked for each streamed entity + * + * @returns Promise with the total number of streamed entities + */ + async listStream(request: EntityListRequest, onEntity: (entity: EntityObject) => void): Promise<{ total: number }> { + return await transceiveStream('entity.listStream', request, (entityData) => { + onEntity(createEntityObject(entityData)); + }); + }, + /** * Retrieve a specific entity by provider and identifier - * + * * @param request - fetch request parameters - * + * * @returns Promise with entity objects keyed by identifier */ async fetch(request: EntityFetchRequest): Promise> { @@ -128,16 +146,38 @@ export const entityService = { }, /** - * Delete an entity - * + * Delete entities + * * @param request - delete request parameters - * - * @returns Promise with deletion result + * + * @returns Promise with per-entity disposition results */ async delete(request: EntityDeleteRequest): Promise { return await transceivePost('entity.delete', request); }, + /** + * Copy entities to another collection + * + * @param request - copy request parameters + * + * @returns Promise with per-entity disposition results + */ + async copy(request: EntityCopyRequest): Promise { + return await transceivePost('entity.copy', request); + }, + + /** + * Move entities to another collection + * + * @param request - move request parameters + * + * @returns Promise with per-entity disposition results + */ + async move(request: EntityMoveRequest): Promise { + return await transceivePost('entity.move', request); + }, + /** * Retrieve delta changes for entities * diff --git a/src/services/index.ts b/src/services/index.ts index abf6c61..f7892df 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -6,4 +6,3 @@ export { providerService } from './providerService'; export { serviceService } from './serviceService'; export { collectionService } from './collectionService'; export { entityService } from './entityService'; -export { nodeService } from './nodeService'; diff --git a/src/services/nodeService.ts b/src/services/nodeService.ts deleted file mode 100644 index 9a929f6..0000000 --- a/src/services/nodeService.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Node (unified collection/entity) management service - */ - -import { transceivePost } from './transceive' -import type { ListFilter, ListSort, ListRange } from '../types/common' -import type { CollectionInterface } from '../types/collection' -import type { EntityInterface } from '../types/entity' - -export type NodeItem = CollectionInterface | EntityInterface - -export interface NodeListRequest { - provider: string - service: string | number - location?: string | number | null - recursive?: boolean - filter?: ListFilter | null - sort?: ListSort | null - range?: ListRange | null -} - -export type NodeListResponse = Record - -export interface NodeDeltaRequest { - provider: string - service: string | number - location?: string | number | null - signature: string - recursive?: boolean - detail?: 'ids' | 'full' -} - -export interface NodeDeltaResult { - added: Array - modified: Array - removed: Array - signature: string -} - -export const nodeService = { - - async list(request: NodeListRequest): Promise { - const response = await transceivePost('node.list', { - provider: request.provider, - service: request.service, - location: request.location ?? null, - recursive: request.recursive ?? false, - filter: request.filter ?? null, - sort: request.sort ?? null, - range: request.range ?? null, - }) - - return Object.values(response) - }, - - async delta(request: NodeDeltaRequest): Promise { - return await transceivePost('node.delta', { - provider: request.provider, - service: request.service, - location: request.location ?? null, - signature: request.signature, - recursive: request.recursive ?? false, - detail: request.detail ?? 'ids', - }) - }, -} - -export default nodeService diff --git a/src/services/transceive.ts b/src/services/transceive.ts index 2f9f47d..99a43a6 100644 --- a/src/services/transceive.ts +++ b/src/services/transceive.ts @@ -4,7 +4,7 @@ */ import { createFetchWrapper } from '@KTXC'; -import type { ApiRequest, ApiResponse } from '../types/common'; +import type { ApiRequest, ApiResponse, ApiStreamResponse } from '../types/common'; const fetchWrapper = createFetchWrapper(); const API_URL = '/m/documents_manager/v1'; @@ -40,11 +40,90 @@ export async function transceivePost( }; const response: ApiResponse = await fetchWrapper.post(API_URL, request); - + if (response.status === 'error') { const errorMessage = `[${operation}] ${response.data.message}${response.data.code ? ` (code: ${response.data.code})` : ''}`; throw new Error(errorMessage); } - + return response.data; } + +/** + * Stream an NDJSON API response, unwrapping data frames for the caller. + * + * @param operation - Operation name, e.g. 'entity.listStream' + * @param data - Operation-specific request data + * @param onData - Synchronous callback invoked for every unwrapped data payload. + * @param options - Optional `user` override and an `onStart` hook. + * @returns Promise resolving to the final stream total from the control/end frame + */ +export async function transceiveStream( + operation: string, + data: TRequest, + onData: (data: TData) => void, + options?: { user?: string; onStart?: (expected?: number) => void } +): Promise<{ total: number }> { + const request: ApiRequest = { + version: API_VERSION, + transaction: generateTransactionId(), + operation, + data, + user: options?.user, + }; + + let total = 0; + + const dispatch = (line: string): void => { + const message = JSON.parse(line) as ApiStreamResponse; + + if (message.type === 'control') { + if (message.status === 'start') { + options?.onStart?.(message.total); + } else if (message.status === 'end') { + total = message.total; + } + return; + } + + if (message.type === 'error') { + throw new Error(`[${operation}] ${message.message}`); + } + + onData(message.data); + }; + + await fetchWrapper.post(API_URL, request, { + headers: { 'Accept': 'application/json' }, + onStream: async (response: Response) => { + if (!response.body) { + throw new Error(`[${operation}] Response body is not readable`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop()!; + + for (const line of lines) { + if (line.trim()) dispatch(line); + } + } + + if (buffer.trim()) dispatch(buffer); + } finally { + reader.releaseLock(); + } + }, + }); + + return { total }; +} diff --git a/src/stores/collectionsStore.ts b/src/stores/collectionsStore.ts index 699c9a2..2cc64ce 100644 --- a/src/stores/collectionsStore.ts +++ b/src/stores/collectionsStore.ts @@ -6,11 +6,11 @@ import { ref, computed, readonly } from 'vue' import { defineStore } from 'pinia' import { collectionService } from '../services/collectionService' import type { - SourceSelector, + CollectionIdentifier, + ServiceIdentifier, ListFilter, ListSort, CollectionMutableProperties, - CollectionDeleteResponse, } from '../types' import { CollectionObject } from '../models/collection' @@ -71,7 +71,7 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = } // Actions - async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort): Promise> { + async function list(sources?: (ServiceIdentifier | CollectionIdentifier)[], filter?: ListFilter, sort?: ListSort): Promise> { transceiving.value = true try { const response = await collectionService.list({ sources, filter, sort }) @@ -101,7 +101,7 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = async function fetch(provider: string, service: string | number, identifier: string | number): Promise { transceiving.value = true try { - const response = await collectionService.fetch({ provider, service, collection: identifier }) + const response = await collectionService.fetch({ targets: [`${provider}:${service}:${identifier}`] }) const key = identifierKey(response.provider, response.service, response.identifier) _collections.value[key] = response @@ -115,10 +115,10 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = } } - async function extant(sources: SourceSelector) { + async function extant(targets: CollectionIdentifier[]) { transceiving.value = true try { - const response = await collectionService.extant({ sources }) + const response = await collectionService.extant({ targets }) console.debug('[Documents Manager][Store] - Successfully checked collection availability') return response } catch (error: any) { @@ -137,7 +137,10 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = ): Promise { transceiving.value = true try { - const response = await collectionService.create({ provider, service, collection, properties }) + const target: CollectionIdentifier | undefined = collection !== null && collection !== undefined && collection !== '' + ? `${provider}:${service}:${collection}` + : undefined + const response = await collectionService.create({ provider, service: String(service), target, properties }) const key = identifierKey(response.provider, response.service, response.identifier) _collections.value[key] = response @@ -159,7 +162,7 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = ): Promise { transceiving.value = true try { - const response = await collectionService.update({ provider, service, identifier, properties }) + const response = await collectionService.update({ target: `${provider}:${service}:${identifier}`, properties }) const key = identifierKey(response.provider, response.service, response.identifier) _collections.value[key] = response @@ -173,17 +176,18 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () = } } - async function remove(provider: string, service: string | number, identifier: string | number): Promise { + async function remove(provider: string, service: string | number, identifier: string | number): Promise<{ success: boolean }> { transceiving.value = true try { - const response = await collectionService.delete({ provider, service, identifier }) - if (response.success) { + const response = await collectionService.delete({ target: `${provider}:${service}:${identifier}` }) + const success = response.disposition === 'deleted' || response.disposition === 'moved' + if (success) { const key = identifierKey(provider, service, identifier) delete _collections.value[key] } console.debug('[Documents Manager][Store] - Successfully deleted collection:', `${provider}:${service}:${identifier}`) - return response + return { success } } catch (error: any) { console.error('[Documents Manager][Store] - Failed to delete collection:', error) throw error diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts index 967607e..40b764b 100644 --- a/src/stores/entitiesStore.ts +++ b/src/stores/entitiesStore.ts @@ -7,12 +7,13 @@ import { defineStore } from 'pinia' import { entityService } from '../services/entityService' import { EntityObject } from '../models' import type { - SourceSelector, + CollectionIdentifier, + EntityIdentifier, + ServiceIdentifier, ListFilter, ListSort, ListRange, DocumentInterface, - EntityDeleteResponse, EntityDeltaResponse, } from '../types' @@ -100,10 +101,10 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { } // Actions - async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise> { + async function list(sources?: (ServiceIdentifier | CollectionIdentifier)[], filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise> { transceiving.value = true try { - const response = await entityService.list({ sources, filter, sort, range }) + const response = await entityService.listBulk({ sources, filter, sort, range }) const hydrated: Record = {} Object.entries(response).forEach(([providerId, providerServices]) => { @@ -137,7 +138,8 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { ): Promise> { transceiving.value = true try { - const response = await entityService.fetch({ provider, service, collection, identifiers }) + const targets: EntityIdentifier[] = identifiers.map((identifier) => `${provider}:${service}:${collection}:${identifier}` as EntityIdentifier) + const response = await entityService.fetch({ targets }) const hydrated: Record = {} Object.entries(response).forEach(([identifier, entityObj]) => { @@ -156,10 +158,10 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { } } - async function extant(sources: SourceSelector) { + async function extant(targets: (CollectionIdentifier | EntityIdentifier)[]) { transceiving.value = true try { - const response = await entityService.extant({ sources }) + const response = await entityService.extant({ targets }) console.debug('[Documents Manager][Store] - Successfully checked entity availability') return response } catch (error: any) { @@ -179,7 +181,7 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { ): Promise { transceiving.value = true try { - const response = await entityService.create({ provider, service, collection, properties, options }) + const response = await entityService.create({ target: `${provider}:${service}:${collection}`, properties, options }) const key = identifierKey(response.provider, response.service, response.collection, response.identifier) _entities.value[key] = response @@ -202,7 +204,7 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { ): Promise { transceiving.value = true try { - const response = await entityService.update({ provider, service, collection, identifier, properties }) + const response = await entityService.update({ target: `${provider}:${service}:${collection}:${identifier}`, properties }) const key = identifierKey(response.provider, response.service, response.collection, response.identifier) _entities.value[key] = response @@ -221,17 +223,19 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { service: string | number, collection: string | number, identifier: string | number, - ): Promise { + ): Promise<{ success: boolean }> { transceiving.value = true try { - const response = await entityService.delete({ provider, service, collection, identifier }) - if (response.success) { + const target: EntityIdentifier = `${provider}:${service}:${collection}:${identifier}` + const response = await entityService.delete({ targets: [target] }) + const success = response[target]?.disposition === 'deleted' + if (success) { const key = identifierKey(provider, service, collection, identifier) delete _entities.value[key] } - console.debug('[Documents Manager][Store] - Successfully deleted entity:', `${provider}:${service}:${collection}:${identifier}`) - return response + console.debug('[Documents Manager][Store] - Successfully deleted entity:', target) + return { success } } catch (error: any) { console.error('[Documents Manager][Store] - Failed to delete entity:', error) throw error @@ -240,10 +244,10 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { } } - async function delta(sources: SourceSelector): Promise { + async function delta(targets: (CollectionIdentifier | EntityIdentifier)[]): Promise { transceiving.value = true try { - const response = await entityService.delta({ sources }) + const response = await entityService.delta({ targets }) Object.entries(response).forEach(([provider, providerData]) => { if (providerData === false) return @@ -280,7 +284,7 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { ): Promise { transceiving.value = true try { - const response = await entityService.read({ provider, service, collection, identifier }) + const response = await entityService.read({ target: `${provider}:${service}:${collection}:${identifier}` }) return response.content } catch (error: any) { console.error('[Documents Manager][Store] - Failed to read entity:', error) @@ -299,7 +303,7 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { ): Promise { transceiving.value = true try { - const response = await entityService.write({ provider, service, collection, identifier, content, encoding: 'base64' }) + const response = await entityService.write({ target: `${provider}:${service}:${collection}:${identifier}`, content, encoding: 'base64' }) return response.bytesWritten } catch (error: any) { console.error('[Documents Manager][Store] - Failed to write entity:', error) diff --git a/src/stores/nodesStore.ts b/src/stores/nodesStore.ts index b495e4e..25944f1 100644 --- a/src/stores/nodesStore.ts +++ b/src/stores/nodesStore.ts @@ -5,7 +5,8 @@ import { computed, ref, readonly } from 'vue' import { defineStore } from 'pinia' import type { - SourceSelector, + CollectionIdentifier, + ServiceIdentifier, ListFilter, ListSort, ListRange, @@ -112,13 +113,9 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { ): Promise { error.value = null try { - const sources: SourceSelector = { - [providerId]: { - [String(serviceId)]: collectionId === null - ? true - : { [String(collectionId)]: true }, - }, - } + const sources: (ServiceIdentifier | CollectionIdentifier)[] = collectionId === null + ? [`${providerId}:${serviceId}`] + : [`${providerId}:${serviceId}:${collectionId}`] await collectionsStore.list(sources, filter, sort) return collectionsStore.collectionsForService(providerId, serviceId) @@ -138,13 +135,9 @@ export const useNodesStore = defineStore('documentsNodesStore', () => { ): Promise { error.value = null try { - const sources: SourceSelector = { - [providerId]: { - [String(serviceId)]: collectionId === null - ? true - : { [String(collectionId)]: true }, - }, - } + const sources: (ServiceIdentifier | CollectionIdentifier)[] = collectionId === null + ? [`${providerId}:${serviceId}`] + : [`${providerId}:${serviceId}:${collectionId}`] await entitiesStore.list(sources, filter, sort, range) return entitiesStore.entitiesForCollection(providerId, serviceId, collectionId) diff --git a/src/stores/providersStore.ts b/src/stores/providersStore.ts index dde4c2e..b8ac02d 100644 --- a/src/stores/providersStore.ts +++ b/src/stores/providersStore.ts @@ -6,7 +6,7 @@ import { ref, computed, readonly } from 'vue' import { defineStore } from 'pinia' import { providerService } from '../services' import { ProviderObject } from '../models/provider' -import type { SourceSelector } from '../types' +import type { ProviderIdentifier } from '../types' export const useProvidersStore = defineStore('documentsProvidersStore', () => { // State @@ -54,10 +54,10 @@ export const useProvidersStore = defineStore('documentsProvidersStore', () => { * * @returns Promise with provider object list keyed by provider identifier */ - async function list(sources?: SourceSelector): Promise> { + async function list(targets?: ProviderIdentifier[]): Promise> { transceiving.value = true try { - const providers = await providerService.list({ sources }) + const providers = await providerService.list({ targets }) // Merge retrieved providers into state _providers.value = { ..._providers.value, ...providers } @@ -82,7 +82,7 @@ export const useProvidersStore = defineStore('documentsProvidersStore', () => { async function fetch(identifier: string): Promise { transceiving.value = true try { - const provider = await providerService.fetch({ identifier }) + const provider = await providerService.fetch({ target: identifier }) // Merge fetched provider into state _providers.value[provider.identifier] = provider @@ -100,14 +100,14 @@ export const useProvidersStore = defineStore('documentsProvidersStore', () => { /** * Retrieve provider availability status for a given source selector * - * @param sources - source selector to check availability for + * @param targets - provider identifiers to check availability for * * @returns Promise with provider availability status */ - async function extant(sources: SourceSelector) { + async function extant(targets: ProviderIdentifier[]) { transceiving.value = true try { - const response = await providerService.extant({ sources }) + const response = await providerService.extant({ targets }) Object.entries(response).forEach(([providerId, providerStatus]) => { if (providerStatus === false) { @@ -115,7 +115,7 @@ export const useProvidersStore = defineStore('documentsProvidersStore', () => { } }) - console.debug('[Documents Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'providers') + console.debug('[Documents Manager][Store] - Successfully checked', targets ? targets.length : 0, 'providers') return response } catch (error: any) { console.error('[Documents Manager][Store] - Failed to check providers:', error) diff --git a/src/stores/servicesStore.ts b/src/stores/servicesStore.ts index 27e9b78..a6e88ee 100644 --- a/src/stores/servicesStore.ts +++ b/src/stores/servicesStore.ts @@ -7,7 +7,7 @@ import { defineStore } from 'pinia' import { serviceService } from '../services' import { ServiceObject } from '../models/service' import type { - SourceSelector, + ServiceIdentifier, ServiceInterface, } from '../types' @@ -76,14 +76,14 @@ export const useServicesStore = defineStore('documentsServicesStore', () => { /** * Retrieve all or specific services, optionally filtered by source selector * - * @param sources - optional source selector + * @param targets - optional service identifiers * * @returns Promise with service object list keyed by provider and service identifier */ - async function list(sources?: SourceSelector): Promise> { + async function list(targets?: ServiceIdentifier[]): Promise> { transceiving.value = true try { - const response = await serviceService.list({ sources }) + const response = await serviceService.list({ targets }) // Flatten nested structure: provider-id: { service-id: object } -> "provider-id:service-id": object const services: Record = {} @@ -137,16 +137,16 @@ export const useServicesStore = defineStore('documentsServicesStore', () => { /** * Retrieve service availability status for a given source selector * - * @param sources - source selector to check availability for + * @param targets - service identifiers to check availability for * * @returns Promise with service availability status */ - async function extant(sources: SourceSelector) { + async function extant(targets: ServiceIdentifier[]) { transceiving.value = true try { - const response = await serviceService.extant({ sources }) + const response = await serviceService.extant({ targets }) - console.debug('[Documents Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'services') + console.debug('[Documents Manager][Store] - Successfully checked', targets ? targets.length : 0, 'services') return response } catch (error: any) { console.error('[Documents Manager][Store] - Failed to check services:', error) diff --git a/src/types/collection.ts b/src/types/collection.ts index 4645ce3..df37ab2 100644 --- a/src/types/collection.ts +++ b/src/types/collection.ts @@ -1,7 +1,7 @@ /** * Collection type definitions */ -import type { ListFilter, ListSort, SourceSelector } from './common'; +import type { CollectionIdentifier, ListFilter, ListSort, ServiceIdentifier } from './common'; export interface CollectionModelInterface extends Omit { @@ -46,7 +46,7 @@ export interface CollectionPropertiesInterface extends CollectionMutableProperti * Collection list */ export interface CollectionListRequest { - sources?: SourceSelector; + sources?: (ServiceIdentifier | CollectionIdentifier)[]; filter?: ListFilter; sort?: ListSort; } @@ -63,9 +63,7 @@ export interface CollectionListResponse { * Collection fetch */ export interface CollectionFetchRequest { - provider: string; - service: string | number; - collection: string | number; + targets: CollectionIdentifier[]; } export interface CollectionFetchResponse extends CollectionInterface {} @@ -74,7 +72,7 @@ export interface CollectionFetchResponse extends CollectionInterface {} * Collection extant */ export interface CollectionExtantRequest { - sources: SourceSelector; + targets: CollectionIdentifier[]; } export interface CollectionExtantResponse { @@ -91,8 +89,9 @@ export interface CollectionExtantResponse { export interface CollectionCreateRequest { provider: string; service: string | number; - collection?: string | number | null; // Parent Collection Identifier + target?: CollectionIdentifier | null; // Parent collection identifier (absent for root) properties: CollectionMutableProperties; + options?: Record; } export interface CollectionCreateResponse extends CollectionInterface {} @@ -101,9 +100,7 @@ export interface CollectionCreateResponse extends CollectionInterface {} * Collection modify */ export interface CollectionUpdateRequest { - provider: string; - service: string | number; - identifier: string | number; + target: CollectionIdentifier; properties: CollectionMutableProperties; } @@ -113,26 +110,23 @@ export interface CollectionUpdateResponse extends CollectionInterface {} * Collection delete */ export interface CollectionDeleteRequest { - provider: string; - service: string | number; - identifier: string | number; + target: CollectionIdentifier; options?: { force?: boolean; // Whether to force delete even if collection is not empty }; } export interface CollectionDeleteResponse { - success: boolean; + disposition: 'deleted' | 'moved'; + mutation?: CollectionInterface; } /** * Collection copy */ export interface CollectionCopyRequest { - provider: string; - service: string; - identifier: string; - location?: string | null; + source: CollectionIdentifier; + target?: CollectionIdentifier | null; // Destination parent (absent for root) } export interface CollectionCopyResponse extends CollectionInterface {} @@ -141,10 +135,8 @@ export interface CollectionCopyResponse extends CollectionInterface {} * Collection move */ export interface CollectionMoveRequest { - provider: string; - service: string; - identifier: string; - location?: string | null; + source: CollectionIdentifier; + target?: CollectionIdentifier | null; // Destination parent (absent for root) } export interface CollectionMoveResponse extends CollectionInterface {} \ No newline at end of file diff --git a/src/types/common.ts b/src/types/common.ts index ab912d4..9a9cd59 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -44,33 +44,57 @@ export interface ApiErrorResponse { export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; /** - * Selector for targeting specific providers, services, collections, or entities in list or extant operations. - * - * Example usage: - * { - * "provider1": true, // Select all services/collections/entities under provider1 - * "provider2": { - * "serviceA": true, // Select all collections/entities under serviceA of provider2 - * "serviceB": { - * "collectionX": true, // Select all entities under collectionX of serviceB of provider2 - * "collectionY": [1, 2, 3] // Select entities with identifiers 1, 2, and 3 under collectionY of serviceB of provider2 - * } - * } - * } + * Stream control start line. */ -export type SourceSelector = { - [provider: string]: boolean | ServiceSelector; -}; +export interface ApiStreamStartResponse { + type: 'control'; + status: 'start'; + version: number; + transaction: string; + total?: number; +} -export type ServiceSelector = { - [service: string]: boolean | CollectionSelector; -}; +/** + * Stream control end line + */ +export interface ApiStreamEndResponse { + type: 'control'; + status: 'end'; + total: number; +} -export type CollectionSelector = { - [collection: string | number]: boolean | EntitySelector; -}; +/** + * Stream error line + */ +export interface ApiStreamErrorResponse { + type: 'error'; + message: string; +} -export type EntitySelector = (string | number)[]; +export interface ApiStreamDataResponse { + type: 'data'; + data: T; +} + +/** + * Shared stream control lines + */ +export type ApiStreamResponse = + | ApiStreamStartResponse + | ApiStreamEndResponse + | ApiStreamErrorResponse + | ApiStreamDataResponse; + +/** + * Identifiers for targeting specific providers, services, collections, or entities in list or extant operations. + * + * Operations accept flat arrays of colon-separated identifier strings, e.g. + * ["default:personal:00000000-0000-0000-0000-000000000000", "default:personal:folder1:file1"]. + */ +export type ProviderIdentifier = `${string}`; +export type ServiceIdentifier = `${string}:${string}`; +export type CollectionIdentifier = `${string}:${string}:${string | number}`; +export type EntityIdentifier = `${string}:${string}:${string}:${string | number}`; /** diff --git a/src/types/entity.ts b/src/types/entity.ts index dd66e2f..5134d6c 100644 --- a/src/types/entity.ts +++ b/src/types/entity.ts @@ -1,7 +1,7 @@ /** * Entity type definitions */ -import type { ListFilter, ListRange, ListSort, SourceSelector } from './common'; +import type { CollectionIdentifier, EntityIdentifier, ListFilter, ListRange, ListSort, ServiceIdentifier } from './common'; import type { DocumentInterface, DocumentModelInterface } from './document'; /** @@ -25,11 +25,21 @@ export interface EntityInterface { properties: T; } +/** + * Entity mutation result (delete/move/copy operations) + */ +export interface EntityMutationResult { + disposition: 'deleted' | 'moved' | 'copied' | 'error'; + destination?: CollectionIdentifier | null; + mutation?: EntityIdentifier; + error?: string; +} + /** * Entity list */ export interface EntityListRequest { - sources?: SourceSelector; + sources?: (ServiceIdentifier | CollectionIdentifier)[]; filter?: ListFilter; sort?: ListSort; range?: ListRange; @@ -49,10 +59,7 @@ export interface EntityListResponse { * Entity fetch */ export interface EntityFetchRequest { - provider: string; - service: string | number; - collection: string | number; - identifiers: (string | number)[]; + targets: EntityIdentifier[]; } export interface EntityFetchResponse { @@ -63,7 +70,7 @@ export interface EntityFetchResponse { * Entity extant */ export interface EntityExtantRequest { - sources: SourceSelector; + targets: (CollectionIdentifier | EntityIdentifier)[]; } export interface EntityExtantResponse { @@ -80,9 +87,7 @@ export interface EntityExtantResponse { * Entity create */ export interface EntityCreateRequest { - provider: string; - service: string | number; - collection: string | number; + target: CollectionIdentifier; properties: T; options?: Record; } @@ -93,10 +98,7 @@ export interface EntityCreateResponse extends EntityInter * Entity update */ export interface EntityUpdateRequest { - provider: string; - service: string | number; - collection: string | number; - identifier: string | number; + target: EntityIdentifier; properties: T; } @@ -106,21 +108,18 @@ export interface EntityUpdateResponse extends EntityInter * Entity delete */ export interface EntityDeleteRequest { - provider: string; - service: string | number; - collection: string | number; - identifier: string | number; + targets: EntityIdentifier[]; } export interface EntityDeleteResponse { - success: boolean; + [identifier: string]: EntityMutationResult; } /** * Entity delta */ export interface EntityDeltaRequest { - sources: SourceSelector; + targets: (CollectionIdentifier | EntityIdentifier)[]; } export interface EntityDeltaResponse { @@ -140,36 +139,31 @@ export interface EntityDeltaResponse { * Entity copy */ export interface EntityCopyRequest { - provider: string; - service: string | number; - collection: string | number; - identifier: string | number; - destination?: string | null; + target: CollectionIdentifier; + sources: EntityIdentifier[]; } -export interface EntityCopyResponse extends EntityInterface {} +export interface EntityCopyResponse { + [identifier: string]: EntityMutationResult; +} /** * Entity move */ export interface EntityMoveRequest { - provider: string; - service: string | number; - collection: string | number; - identifier: string | number; - destination?: string | null; + target: CollectionIdentifier; + sources: EntityIdentifier[]; } -export interface EntityMoveResponse extends EntityInterface {} +export interface EntityMoveResponse { + [identifier: string]: EntityMutationResult; +} /** * Entity read content */ export interface EntityReadRequest { - provider: string; - service: string | number; - collection: string | number; - identifier: string | number; + target: EntityIdentifier; } export interface EntityReadResult { @@ -183,10 +177,7 @@ export type EntityReadResponse = EntityReadResult; * Entity write content */ export interface EntityWriteRequest { - provider: string; - service: string | number; - collection: string | number; - identifier: string | number; + target: EntityIdentifier; content: string; encoding?: 'base64'; } diff --git a/src/types/index.ts b/src/types/index.ts index c065da1..d78ba26 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,4 +4,3 @@ export type * from './service'; export type * from './collection'; export type * from './entity'; export type * from './document'; -export type * from './node'; diff --git a/src/types/node.ts b/src/types/node.ts deleted file mode 100644 index 68c220c..0000000 --- a/src/types/node.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Node types for combined operations - */ - -import type { CollectionInterface } from "./collection"; -import type { ApiResponse, ListFilterCondition, ListRange, ListSort } from "./common"; -import type { EntityInterface } from "./entity"; - -export interface NodeListRequest { - provider: string; - service: string; - location?: string | null; - recursive?: boolean; - filter?: ListFilterCondition | null; - sort?: ListSort | null; - range?: ListRange | null; -} - -export type NodeListResponse = ApiResponse; - -export interface NodeDeltaRequest { - provider: string; - service: string; - location?: string | null; - signature: string; - recursive?: boolean; - detail?: 'ids' | 'full'; -} - -export interface NodeDeltaResult { - added: string[]; - modified: string[]; - removed: string[]; - signature: string; -} - -export type NodeDeltaResponse = ApiResponse; diff --git a/src/types/provider.ts b/src/types/provider.ts index b9632c8..0355791 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -1,7 +1,7 @@ /** * Provider type definitions */ -import type { SourceSelector } from "./common"; +import type { ProviderIdentifier } from "./common"; /** * Provider capabilities @@ -32,7 +32,7 @@ export interface ProviderInterface { * Provider list */ export interface ProviderListRequest { - sources?: SourceSelector; + targets?: ProviderIdentifier[]; } export interface ProviderListResponse { @@ -40,19 +40,19 @@ export interface ProviderListResponse { } /** - * Provider fetch + * Provider fetch */ export interface ProviderFetchRequest { - identifier: string; + target: ProviderIdentifier; } export interface ProviderFetchResponse extends ProviderInterface {} /** - * Provider extant + * Provider extant */ export interface ProviderExtantRequest { - sources: SourceSelector; + targets: ProviderIdentifier[]; } export interface ProviderExtantResponse { diff --git a/src/types/service.ts b/src/types/service.ts index b5562b3..2eeddb6 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -1,7 +1,7 @@ /** * Service type definitions */ -import type { SourceSelector, ListFilterComparisonOperator } from './common'; +import type { ServiceIdentifier as ServiceIdentifierString, ListFilterComparisonOperator } from './common'; /** * Service capabilities @@ -66,7 +66,7 @@ export interface ServiceInterface { * Service list */ export interface ServiceListRequest { - sources?: SourceSelector; + targets?: ServiceIdentifierString[]; } export interface ServiceListResponse { @@ -89,7 +89,7 @@ export interface ServiceFetchResponse extends ServiceInterface {} * Service extant */ export interface ServiceExtantRequest { - sources: SourceSelector; + targets: ServiceIdentifierString[]; } export interface ServiceExtantResponse {