* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderLocalChrono\Providers\Personal; use Generator; use KTXF\Chrono\Collection\CollectionBaseInterface; use KTXF\Chrono\Collection\CollectionPropertiesBaseInterface; use KTXF\Chrono\Entity\EntityBaseInterface; use KTXF\Chrono\Entity\EntityPropertiesMutableInterface; use KTXF\Chrono\Entity\EntityType; use KTXF\Chrono\Service\ServiceBaseInterface; use KTXF\Chrono\Service\ServiceCollectionMutableInterface; use KTXF\Chrono\Service\ServiceEntityMutableInterface; use KTXF\Resource\Delta\Delta; use KTXF\Resource\Delta\DeltaCollection; use KTXF\Resource\Exceptions\InvalidParameterException; use KTXF\Resource\Filter\Filter; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\CollectionIdentifierInterface; use KTXF\Resource\Identifier\EntityIdentifier; use KTXF\Resource\Identifier\EntityIdentifierInterface; use KTXF\Resource\Range\IRange; use KTXF\Resource\Range\RangeDate; use KTXF\Resource\Range\RangeTally; use KTXF\Resource\Range\RangeType; use KTXF\Resource\Sort\ISort; use KTXF\Resource\Sort\Sort; use KTXM\ProviderLocalChrono\Store\Personal\Store; class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface { private const PROVIDER_IDENTIFIER = 'default'; private const SERVICE_IDENTIFIER = 'personal'; private const SERVICE_LABEL = 'Personal Calendar Service'; protected array $serviceCollectionCache = []; protected ?string $serviceTenantId = null; protected ?string $serviceUserId = null; protected ?bool $serviceEnabled = true; private array $serviceAbilities = [ self::CAPABILITY_COLLECTION_LIST => true, self::CAPABILITY_COLLECTION_LIST_FILTER => [ self::CAPABILITY_COLLECTION_FILTER_LABEL => 's:100:256:256', ], self::CAPABILITY_COLLECTION_LIST_SORT => [ self::CAPABILITY_COLLECTION_SORT_LABEL, self::CAPABILITY_COLLECTION_SORT_RANK, ], self::CAPABILITY_COLLECTION_EXTANT => true, self::CAPABILITY_COLLECTION_FETCH => true, self::CAPABILITY_COLLECTION_CREATE => true, self::CAPABILITY_COLLECTION_UPDATE => true, self::CAPABILITY_COLLECTION_DELETE => true, self::CAPABILITY_ENTITY_LIST => true, self::CAPABILITY_ENTITY_LIST_FILTER => [ self::CAPABILITY_ENTITY_FILTER_ALL => 's:200:256:256', self::CAPABILITY_ENTITY_FILTER_ID => 's:100:256:256', self::CAPABILITY_ENTITY_FILTER_URID => 's:100:256:256', self::CAPABILITY_ENTITY_FILTER_LABEL => 's:100:256:256', ], self::CAPABILITY_ENTITY_LIST_SORT => [ self::CAPABILITY_ENTITY_SORT_ID, self::CAPABILITY_ENTITY_SORT_URID, ], self::CAPABILITY_ENTITY_LIST_RANGE => [ self::CAPABILITY_ENTITY_RANGE_TALLY => [ self::CAPABILITY_ENTITY_RANGE_TALLY_ABSOLUTE, self::CAPABILITY_ENTITY_RANGE_TALLY_RELATIVE ], self::CAPABILITY_ENTITY_RANGE_DATE => true, ], self::CAPABILITY_ENTITY_DELTA => true, self::CAPABILITY_ENTITY_EXTANT => true, self::CAPABILITY_ENTITY_FETCH => true, self::CAPABILITY_ENTITY_CREATE => true, self::CAPABILITY_ENTITY_MODIFY => true, self::CAPABILITY_ENTITY_DELETE => true, self::CAPABILITY_ENTITY_MOVE => true, self::CAPABILITY_ENTITY_COPY => true, ]; public function __construct( private Store $store, ) {} public function initialize(string $tenantId, string $userId): self { $this->serviceTenantId = $tenantId; $this->serviceUserId = $userId; return $this; } public function jsonSerialize(): array { return array_filter([ self::PROPERTY_TYPE => self::JSON_TYPE, self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, self::PROPERTY_IDENTIFIER => self::SERVICE_IDENTIFIER, self::PROPERTY_LABEL => self::SERVICE_LABEL, self::PROPERTY_ENABLED => $this->serviceEnabled, self::PROPERTY_CAPABILITIES => $this->serviceAbilities, self::PROPERTY_LOCATION => null, self::PROPERTY_IDENTITY => null, self::PROPERTY_AUXILIARY => [], ], fn($v) => $v !== null); } public function capable(string $value): bool { return isset($this->serviceAbilities[$value]); } public function capabilities(): array { return $this->serviceAbilities; } public function provider(): string { return self::PROVIDER_IDENTIFIER; } public function identifier(): string { return self::SERVICE_IDENTIFIER; } public function getLabel(): string { return (string)self::SERVICE_LABEL; } public function getEnabled(): bool { return (bool)$this->serviceEnabled; } public function setEnabled(bool $enabled): static { $this->serviceEnabled = $enabled; return $this; } public function getLocation(): null { return null; } public function getIdentity(): null { return null; } public function getAuxiliary(): array { return []; } public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array { $entries = $this->store->collectionList($this->serviceTenantId, $this->serviceUserId, $filter, $sort); $this->serviceCollectionCache = $entries; return $entries ?? []; } public function collectionListFilter(): Filter { return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []); } public function collectionListSort(): Sort { return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []); } public function collectionFetch(string|int $identifier): ?CollectionBaseInterface { // determine if collection is cached if (isset($this->serviceCollectionCache[$identifier])) { return $this->serviceCollectionCache[$identifier]; } // retrieve from store $collection = $this->store->collectionFetch($this->serviceTenantId, $this->serviceUserId, (string)$identifier); if ($collection !== null) { $collectionIdentifier = $collection->identifier(); if ($collectionIdentifier !== null) { $this->serviceCollectionCache[(string) $collectionIdentifier] = $collection; } return $collection; } return null; } public function collectionExtant(string|int $location, string|int ...$identifiers): array { $resolvedIdentifiers = $identifiers !== [] ? $identifiers : [$location]; $response = []; foreach ($resolvedIdentifiers as $identifier) { if (isset($this->serviceCollectionCache[$identifier])) { $response[$identifier] = true; continue; } $exists = $this->store->collectionExtant($this->serviceTenantId, $this->serviceUserId, (string)$identifier); $response[$identifier] = $exists; if ($exists) { $collection = $this->store->collectionFetch($this->serviceTenantId, $this->serviceUserId, (string)$identifier); if ($collection !== null) { $this->serviceCollectionCache[$identifier] = $collection; } } } return $response; } /** * Determine whether a single collection exists and belongs to the current user. * * @param string|int $collection collection identifier * * @return bool */ private function collectionAccessible(string|int $collection): bool { $response = $this->collectionExtant($collection); return ($response[$collection] ?? false) === true; } public function collectionFresh(): CollectionResource { return new CollectionResource(); } public function collectionCreate(CollectionIdentifierInterface|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionBaseInterface { // convert collection to a native type if needed if (!($properties instanceof CollectionResource)) { $nativeCollection = $this->collectionFresh(); $nativeCollection->jsonDeserialize([ CollectionResource::PROPERTY_PROPERTIES => $properties->jsonSerialize(), ]); } else { $nativeCollection = clone $properties; } // create collection in store $result = $this->store->collectionCreate($this->serviceTenantId, $this->serviceUserId, $nativeCollection); $resultIdentifier = $result->identifier(); if ($resultIdentifier !== null) { $this->serviceCollectionCache[(string) $resultIdentifier] = $result; } return $result; } public function collectionUpdate(CollectionIdentifierInterface $target, CollectionPropertiesBaseInterface $properties): CollectionBaseInterface { $id = $target->collection(); // validate ownership if ($this->collectionAccessible($id) === false) { throw new InvalidParameterException("Invalid: Collection identifier '$id' does not exist or does not belong to user '{$this->serviceUserId}'"); } // convert collection to a native type if needed if (!($properties instanceof CollectionResource)) { $nativeCollection = new CollectionResource(); $nativeCollection->jsonDeserialize([ CollectionResource::PROPERTY_IDENTIFIER => $id, CollectionResource::PROPERTY_PROPERTIES => $properties->jsonSerialize(), ]); } else { $nativeCollection = clone $properties; if ($nativeCollection->identifier() === null) { $data = $nativeCollection->jsonSerialize(); $data[CollectionResource::PROPERTY_IDENTIFIER] = $id; $nativeCollection->jsonDeserialize($data); } } // modify collection in store $result = $this->store->collectionModify($this->serviceTenantId, $this->serviceUserId, $nativeCollection); return $result; } public function collectionDelete(CollectionIdentifierInterface $target, bool $force = false): CollectionBaseInterface | true { $id = $target->collection(); // validate ownership if ($this->collectionAccessible($id) === false) { throw new InvalidParameterException("Invalid: Collection identifier '$id' does not exist or does not belong to user '{$this->serviceUserId}'"); } // destroy collection in store $collection = $this->store->collectionFetch($this->serviceTenantId, $this->serviceUserId, $id); if ($collection === null) { throw new InvalidParameterException("Invalid: Collection identifier '$id' could not be retrieved"); } $this->store->collectionDestroy($this->serviceTenantId, $this->serviceUserId, $collection); unset($this->serviceCollectionCache[$id]); return true; } // Entity operations public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { // validate id if (!is_string($collection)) { throw new InvalidParameterException("Invalid: Collection identifier '$collection' is not valid"); } // validate ownership if ($this->collectionAccessible($collection) === false) { throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); } // retrieve entities from store $entries = $this->store->entityList($this->serviceTenantId, $this->serviceUserId, $collection, $filter, $sort, $range, $properties); return $entries ?? []; } public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator { // validate id if (!is_string($collection)) { throw new InvalidParameterException("Invalid: Collection identifier '$collection' is not valid"); } // validate ownership if ($this->collectionAccessible($collection) === false) { throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); } // retrieve entities from store and yield one by one $entries = $this->store->entityList($this->serviceTenantId, $this->serviceUserId, $collection, $filter, $sort, $range, $properties); yield from ($entries ?? []); } public function entityListFilter(): Filter { return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []); } public function entityListSort(): Sort { return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []); } public function entityListRange(RangeType $type): IRange { // validate type if ($type === RangeType::TALLY) { return new RangeTally(); } if ($type === RangeType::DATE) { return new RangeDate(); } throw new InvalidParameterException("Invalid: Entity range of type '{$type->value}' is not valid"); } public function entityExtant(string|int $collection, string|int ...$identifiers): array { // validate id if (!is_string($collection)) { throw new InvalidParameterException("Invalid: Collection identifier '$collection' is not valid"); } // validate ownership if ($this->collectionAccessible($collection) === false) { throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); } // retrieve entity status from store return $this->store->entityExtant($collection, ...$identifiers); } public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta { // validate id if (!is_string($collection)) { throw new InvalidParameterException("Invalid: Collection identifier '$collection' is not valid"); } // validate ownership if ($this->collectionAccessible($collection) === false) { throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); } // retrieve entity delta from store $delta = $this->store->chronicleReminisce($this->serviceTenantId, $collection, $signature); return new Delta( new DeltaCollection($delta['additions'] ?? []), new DeltaCollection($delta['modifications'] ?? []), new DeltaCollection($delta['deletions'] ?? []), $delta['signature'] ?? '' ); } public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array { // group identifiers by collection $byCollection = []; foreach ($identifiers as $identifier) { $byCollection[$identifier->collection()][] = $identifier->entity(); } $results = []; foreach ($byCollection as $collection => $entityIds) { // skip collections that don't exist or don't belong to user if ($this->collectionAccessible($collection) === false) { continue; } $entries = $this->store->entityFetch($this->serviceTenantId, $this->serviceUserId, $collection, ...$entityIds); if ($entries) { $results = array_merge($results, $entries); } } return $results; } public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator { // group identifiers by collection $byCollection = []; foreach ($identifiers as $identifier) { $byCollection[$identifier->collection()][] = $identifier->entity(); } foreach ($byCollection as $collection => $entityIds) { // skip collections that don't exist or don't belong to user if ($this->collectionAccessible($collection) === false) { continue; } $entries = $this->store->entityFetch($this->serviceTenantId, $this->serviceUserId, $collection, ...$entityIds); if ($entries) { yield from $entries; } } } public function entityFresh(EntityType $type = EntityType::Event): EntityResource { $entity = new EntityResource(); $entity->setProperties($type->instantiate()); return $entity; } public function entityCreate(CollectionIdentifierInterface $target, EntityPropertiesMutableInterface $properties, array $options = []): EntityBaseInterface { $collection = $target->collection(); // validate collection extant and ownership if ($this->collectionAccessible($collection) === false) { throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); } // wrap the typed entity object in a native resource $nativeEntity = $this->entityFresh(); $nativeEntity->setProperties($properties); // create entity in store (store will handle userId and collection) $result = $this->store->entityCreate($this->serviceTenantId, $this->serviceUserId, $collection, $nativeEntity); return $result; } public function entityModify(EntityIdentifierInterface $target, EntityPropertiesMutableInterface $properties): EntityBaseInterface { $collection = $target->collection(); $identifier = $target->entity(); // validate collection extant and ownership if ($this->collectionAccessible($collection) === false) { throw new InvalidParameterException("Invalid: Collection identifier '$collection' does not exist or does not belong to user '{$this->serviceUserId}'"); } // validate entity extant and ownership $extant = $this->store->entityExtant($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); if (!isset($extant[$identifier]) || $extant[$identifier] === false) { throw new InvalidParameterException("Invalid: Entity identifier '$identifier' does not exist or does not belong to collection '$collection' or user '{$this->serviceUserId}'"); } // wrap the typed entity object in a native resource $nativeEntity = $this->entityFresh(); $nativeEntity->setProperties($properties); // modify entity in store $result = $this->store->entityModify($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $nativeEntity); return $result; } public function entityDelete(EntityIdentifierInterface ...$targets): array { $results = []; foreach ($targets as $target) { $key = (string) $target; $collection = $target->collection(); $identifier = $target->entity(); // validate collection extant and ownership if ($this->collectionAccessible($collection) === false) { $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $target]; continue; } // validate entity extant and ownership $extant = $this->store->entityExtant($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); if (!isset($extant[$identifier]) || $extant[$identifier] === false) { $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $target]; continue; } // destroy entity in store $this->store->entityDestroyById($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); $results[$key] = ['disposition' => 'deleted', 'destination' => null, 'mutation' => $target]; } return $results; } public function entityMove(CollectionIdentifierInterface $target, EntityIdentifierInterface ...$sources): array { return $this->entityRelocate($target, true, ...$sources); } public function entityCopy(CollectionIdentifierInterface $target, EntityIdentifierInterface ...$sources): array { return $this->entityRelocate($target, false, ...$sources); } /** * Relocate (move or copy) entities into a target collection. * * The store assigns a fresh identifier to each relocated entity, so the * mutation identifier reflects the entity's new location. * * @param CollectionIdentifierInterface $target destination collection identifier * @param bool $remove true to move (delete the source), false to copy * @param EntityIdentifierInterface ...$sources source entity identifiers * * @return array */ private function entityRelocate(CollectionIdentifierInterface $target, bool $remove, EntityIdentifierInterface ...$sources): array { $results = []; $disposition = $remove ? 'moved' : 'copied'; $targetCollection = $target->collection(); // validate target collection extant and ownership $targetValid = $this->collectionAccessible($targetCollection); foreach ($sources as $source) { $key = (string) $source; $sourceCollection = $source->collection(); $identifier = $source->entity(); // validate target collection if ($targetValid === false) { $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $source]; continue; } // validate source collection extant and ownership if ($this->collectionAccessible($sourceCollection) === false) { $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $source]; continue; } // fetch source entity (scoped to user; also confirms the entity exists) $entries = $this->store->entityFetch($this->serviceTenantId, $this->serviceUserId, $sourceCollection, $identifier); $entity = $entries[$identifier] ?? null; if ($entity === null) { $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $source]; continue; } // create a copy in the target collection (store assigns a fresh identifier) $created = $this->store->entityCreate($this->serviceTenantId, $this->serviceUserId, $targetCollection, $entity); // remove the source entity when moving if ($remove) { $this->store->entityDestroyById($this->serviceTenantId, $this->serviceUserId, $sourceCollection, $identifier); } // construct destination and mutation identifiers $destination = new CollectionIdentifier($this->provider(), $this->identifier(), $targetCollection); $mutation = new EntityIdentifier($this->provider(), $this->identifier(), $targetCollection, (string) $created->identifier()); $results[$key] = ['disposition' => $disposition, 'destination' => $destination, 'mutation' => $mutation]; } return $results; } }