From 24d5410a09f309a489c5b9c2cb2da57454d1c507 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Thu, 9 Jul 2026 19:39:29 -0400 Subject: [PATCH] refactor: use new documents interfaces Signed-off-by: Sebastian Krupinski --- .../Personal/CollectionProperties.php | 4 +- lib/Providers/Personal/CollectionResource.php | 23 +- lib/Providers/Personal/EntityProperties.php | 20 +- lib/Providers/Personal/EntityResource.php | 23 +- lib/Providers/Personal/PersonalService.php | 551 +++++++++--------- lib/Providers/Provider.php | 14 +- lib/Store/MetaStore.php | 35 +- 7 files changed, 337 insertions(+), 333 deletions(-) diff --git a/lib/Providers/Personal/CollectionProperties.php b/lib/Providers/Personal/CollectionProperties.php index 7620fce..45d53d9 100644 --- a/lib/Providers/Personal/CollectionProperties.php +++ b/lib/Providers/Personal/CollectionProperties.php @@ -9,7 +9,7 @@ declare(strict_types=1); namespace KTXM\ProviderLocalDocuments\Providers\Personal; -use KTXF\Resource\Documents\Collection\CollectionPropertiesMutableAbstract; +use KTXF\Documents\Collection\CollectionPropertiesMutableAbstract; class CollectionProperties extends CollectionPropertiesMutableAbstract { @@ -23,7 +23,7 @@ class CollectionProperties extends CollectionPropertiesMutableAbstract { } if (isset($data['label'])) { - $this->data[self::JSON_PROPERTY_LABEL] = (string) $data['label']; + $this->data[self::PROPERTY_LABEL] = (string) $data['label']; } return $this; diff --git a/lib/Providers/Personal/CollectionResource.php b/lib/Providers/Personal/CollectionResource.php index a9fc41b..ee60c4f 100644 --- a/lib/Providers/Personal/CollectionResource.php +++ b/lib/Providers/Personal/CollectionResource.php @@ -9,7 +9,8 @@ declare(strict_types=1); namespace KTXM\ProviderLocalDocuments\Providers\Personal; -use KTXF\Resource\Documents\Collection\CollectionMutableAbstract; +use KTXF\Utile\Timestamp; +use KTXF\Documents\Collection\CollectionMutableAbstract; class CollectionResource extends CollectionMutableAbstract { @@ -26,11 +27,11 @@ class CollectionResource extends CollectionMutableAbstract { $data = (array) $data; } - $this->data[static::JSON_PROPERTY_COLLECTION] = $data['cid'] ?? null; - $this->data[static::JSON_PROPERTY_IDENTIFIER] = $data['nid']; - $this->data[static::JSON_PROPERTY_CREATED] = $data['created'] ?? null; - $this->data[static::JSON_PROPERTY_MODIFIED] = $data['modified'] ?? null; - $this->data[static::JSON_PROPERTY_SIGNATURE] = $data['signature'] ?? null; + $this->data[static::PROPERTY_COLLECTION] = $data['cid'] ?? null; + $this->data[static::PROPERTY_IDENTIFIER] = $data['nid']; + $this->data[static::PROPERTY_CREATED] = $data['created'] ?? null; + $this->data[static::PROPERTY_MODIFIED] = $data['modified'] ?? null; + $this->data[static::PROPERTY_SIGNATURE] = $data['signature'] ?? null; $this->getProperties()->fromStore($data['properties'] ?? []); return $this; @@ -41,11 +42,11 @@ class CollectionResource extends CollectionMutableAbstract { $properties = $this->getProperties(); $data = array_filter([ - 'cid' => $this->data[static::JSON_PROPERTY_COLLECTION] ?? null, - 'nid' => $this->data[static::JSON_PROPERTY_IDENTIFIER] ?? null, - 'created' => $this->data[static::JSON_PROPERTY_CREATED] ?? null, - 'modified' => $this->data[static::JSON_PROPERTY_MODIFIED] ?? null, - 'signature' => $this->data[static::JSON_PROPERTY_SIGNATURE] ?? null, + 'cid' => $this->data[static::PROPERTY_COLLECTION] ?? null, + 'nid' => $this->data[static::PROPERTY_IDENTIFIER] ?? null, + 'created' => Timestamp::normalize($this->data[static::PROPERTY_CREATED] ?? null), + 'modified' => Timestamp::normalize($this->data[static::PROPERTY_MODIFIED] ?? null), + 'signature' => $this->data[static::PROPERTY_SIGNATURE] ?? null, 'properties' => $properties ? $properties->toStore() : null, ], static fn($value) => $value !== null); diff --git a/lib/Providers/Personal/EntityProperties.php b/lib/Providers/Personal/EntityProperties.php index 4dd731a..7548dec 100644 --- a/lib/Providers/Personal/EntityProperties.php +++ b/lib/Providers/Personal/EntityProperties.php @@ -9,7 +9,7 @@ declare(strict_types=1); namespace KTXM\ProviderLocalDocuments\Providers\Personal; -use KTXF\Resource\Documents\Entity\EntityPropertiesMutableAbstract; +use KTXF\Documents\Entity\EntityPropertiesMutableAbstract; class EntityProperties extends EntityPropertiesMutableAbstract { @@ -23,23 +23,23 @@ class EntityProperties extends EntityPropertiesMutableAbstract { } if (isset($data['size'])) { - $this->data[self::JSON_PROPERTY_SIZE] = (int) $data['size']; + $this->data[self::PROPERTY_SIZE] = (int) $data['size']; } if (isset($data['label'])) { - $this->data[self::JSON_PROPERTY_LABEL] = (string) $data['label']; + $this->data[self::PROPERTY_LABEL] = (string) $data['label']; } if (isset($data['mime'])) { - $this->data[self::JSON_PROPERTY_MIME] = (string) $data['mime']; + $this->data[self::PROPERTY_MIME] = (string) $data['mime']; } if (isset($data['format'])) { - $this->data[self::JSON_PROPERTY_FORMAT] = (string) $data['format']; + $this->data[self::PROPERTY_FORMAT] = (string) $data['format']; } if (isset($data['encoding'])) { - $this->data[self::JSON_PROPERTY_ENCODING] = (string) $data['encoding']; + $this->data[self::PROPERTY_ENCODING] = (string) $data['encoding']; } return $this; @@ -50,10 +50,10 @@ class EntityProperties extends EntityPropertiesMutableAbstract { */ public function toStore(): array { return array_filter([ - 'label' => $this->data[self::JSON_PROPERTY_LABEL], - 'mime' => $this->data[self::JSON_PROPERTY_MIME] ?? 'application/octet-stream', - 'format' => $this->data[self::JSON_PROPERTY_FORMAT] ?? null, - 'encoding' => $this->data[self::JSON_PROPERTY_ENCODING] ?? null, + 'label' => $this->data[self::PROPERTY_LABEL], + 'mime' => $this->data[self::PROPERTY_MIME] ?? 'application/octet-stream', + 'format' => $this->data[self::PROPERTY_FORMAT] ?? null, + 'encoding' => $this->data[self::PROPERTY_ENCODING] ?? null, ], static fn($value) => $value !== null); } } diff --git a/lib/Providers/Personal/EntityResource.php b/lib/Providers/Personal/EntityResource.php index 0bb66dc..459c304 100644 --- a/lib/Providers/Personal/EntityResource.php +++ b/lib/Providers/Personal/EntityResource.php @@ -9,7 +9,8 @@ declare(strict_types=1); namespace KTXM\ProviderLocalDocuments\Providers\Personal; -use KTXF\Resource\Documents\Entity\EntityMutableAbstract; +use KTXF\Utile\Timestamp; +use KTXF\Documents\Entity\EntityMutableAbstract; class EntityResource extends EntityMutableAbstract { @@ -26,11 +27,11 @@ class EntityResource extends EntityMutableAbstract { $data = (array) $data; } - $this->data[static::JSON_PROPERTY_COLLECTION] = $data['cid'] ?? null; - $this->data[static::JSON_PROPERTY_IDENTIFIER] = $data['nid']; - $this->data[static::JSON_PROPERTY_CREATED] = $data['created'] ?? null; - $this->data[static::JSON_PROPERTY_MODIFIED] = $data['modified'] ?? null; - $this->data[static::JSON_PROPERTY_SIGNATURE] = $data['signature'] ?? null; + $this->data[static::PROPERTY_COLLECTION] = $data['cid'] ?? null; + $this->data[static::PROPERTY_IDENTIFIER] = $data['nid']; + $this->data[static::PROPERTY_CREATED] = $data['created'] ?? null; + $this->data[static::PROPERTY_MODIFIED] = $data['modified'] ?? null; + $this->data[static::PROPERTY_SIGNATURE] = $data['signature'] ?? null; $this->getProperties()->fromStore($data['properties'] ?? []); return $this; @@ -41,11 +42,11 @@ class EntityResource extends EntityMutableAbstract { $properties = $this->getProperties(); $data = array_filter([ - 'cid' => $this->data[static::JSON_PROPERTY_COLLECTION] ?? null, - 'nid' => $this->data[static::JSON_PROPERTY_IDENTIFIER] ?? null, - 'created' => $this->data[static::JSON_PROPERTY_CREATED] ?? null, - 'modified' => $this->data[static::JSON_PROPERTY_MODIFIED] ?? null, - 'signature' => $this->data[static::JSON_PROPERTY_SIGNATURE] ?? null, + 'cid' => $this->data[static::PROPERTY_COLLECTION] ?? null, + 'nid' => $this->data[static::PROPERTY_IDENTIFIER] ?? null, + 'created' => Timestamp::normalize($this->data[static::PROPERTY_CREATED] ?? null), + 'modified' => Timestamp::normalize($this->data[static::PROPERTY_MODIFIED] ?? null), + 'signature' => $this->data[static::PROPERTY_SIGNATURE] ?? null, 'properties' => $properties ? $properties->toStore() : null, ], static fn($value) => $value !== null); diff --git a/lib/Providers/Personal/PersonalService.php b/lib/Providers/Personal/PersonalService.php index f177f41..ddfe9c1 100644 --- a/lib/Providers/Personal/PersonalService.php +++ b/lib/Providers/Personal/PersonalService.php @@ -9,9 +9,23 @@ declare(strict_types=1); namespace KTXM\ProviderLocalDocuments\Providers\Personal; +use Generator; +use KTXF\Blob\Signature; +use KTXF\Documents\Collection\CollectionBaseInterface; +use KTXF\Documents\Collection\CollectionPropertiesBaseInterface; +use KTXF\Documents\Entity\EntityPropertiesMutableInterface; +use KTXF\Documents\Service\ServiceBaseInterface; +use KTXF\Documents\Service\ServiceCollectionMutableInterface; +use KTXF\Documents\Service\ServiceEntityMutableInterface; +use KTXF\Files\Node\NodeType; +use KTXF\Resource\Delta\Delta; 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\RangeTally; use KTXF\Resource\Range\RangeType; @@ -19,15 +33,6 @@ use KTXF\Resource\Sort\ISort; use KTXF\Resource\Sort\Sort; use KTXM\ProviderLocalDocuments\Store\BlobStore; use KTXM\ProviderLocalDocuments\Store\MetaStore; -use KTXF\Blob\Signature; -use KTXF\Files\Node\NodeType; -use KTXF\Resource\Delta\Delta; -use KTXF\Resource\Documents\Collection\CollectionMutableInterface; -use KTXF\Resource\Documents\Entity\EntityMutableInterface; -use KTXF\Resource\Documents\Service\ServiceBaseInterface; -use KTXF\Resource\Documents\Service\ServiceCollectionMutableInterface; -use KTXF\Resource\Documents\Service\ServiceEntityMutableInterface; -use KTXM\ProviderLocalDocuments\Providers\Personal\CollectionResource; class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface { @@ -58,6 +63,8 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI self::CAPABILITY_COLLECTION_CREATE => true, self::CAPABILITY_COLLECTION_UPDATE => true, self::CAPABILITY_COLLECTION_DELETE => true, + self::CAPABILITY_COLLECTION_MOVE => true, + self::CAPABILITY_COLLECTION_COPY => true, self::CAPABILITY_ENTITY_LIST => true, self::CAPABILITY_ENTITY_LIST_FILTER => [ self::CAPABILITY_ENTITY_FILTER_ALL => 's:200:256:256', @@ -74,14 +81,15 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI 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_UPDATE => true, + self::CAPABILITY_ENTITY_MODIFY => true, self::CAPABILITY_ENTITY_DELETE => true, + self::CAPABILITY_ENTITY_MOVE => true, + self::CAPABILITY_ENTITY_COPY => true, self::CAPABILITY_ENTITY_READ => true, self::CAPABILITY_ENTITY_WRITE => true, ]; @@ -114,18 +122,18 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI return $this; } - + public function jsonSerialize(): array { return array_filter([ - self::JSON_PROPERTY_TYPE => self::JSON_TYPE, - self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, - self::JSON_PROPERTY_IDENTIFIER => self::SERVICE_IDENTIFIER, - self::JSON_PROPERTY_LABEL => self::SERVICE_LABEL, - self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, - self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, - self::JSON_PROPERTY_LOCATION => null, - self::JSON_PROPERTY_IDENTITY => null, - self::JSON_PROPERTY_AUXILIARY => [], + 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); } @@ -173,9 +181,17 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI return []; } + /** + * Normalize a collection location, treating null and '' as the root collection + */ + private function normalizeLocation(string|int|null $location): string|int { + return ($location === null || $location === '') ? self::ROOT_ID : $location; + } + // Collection operations - public function collectionList(string|int|null $location = null, ?IFilter $filter = null, ?ISort $sort = null): array { + public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array { + $location = $this->normalizeLocation($location); $entries = $this->metaStore->collectionList($this->serviceTenantId, $this->serviceUserId, $location, $filter, $sort); // cache collections foreach ($entries as $id => $collection) { @@ -209,12 +225,18 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI return array_merge($cached, $fromStore); } + /** + * Determine whether a single collection exists and belongs to the current user + */ + private function collectionAccessible(string|int $collection): bool { + $response = $this->collectionExtant(null, $collection); + return ($response[$collection] ?? false) === true; + } + public function collectionFetch(string|int|null $identifier): ?CollectionResource { // null is root - if ($identifier === null) { - $identifier = self::ROOT_ID; - } - // check cache first + $identifier = $this->normalizeLocation($identifier); + // check cache first if (isset($this->serviceCollectionCache[$identifier])) { return $this->serviceCollectionCache[$identifier]; } @@ -226,7 +248,7 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI } return null; } - + public function collectionFresh(): CollectionResource { $collection = new CollectionResource(); $collection->fromStore([ @@ -242,11 +264,27 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI return $collection; } - public function collectionCreate(string|int|null $location, CollectionMutableInterface $collection, array $options = []): CollectionResource { - // null is root - if ($location === null) { - $location = self::ROOT_ID; + /** + * Convert incoming collection properties into a native CollectionResource + */ + private function collectionNative(CollectionPropertiesBaseInterface $properties, string|int|null $identifier = null): CollectionResource { + if ($properties instanceof CollectionResource) { + $native = clone $properties; + } else { + $native = $this->collectionFresh(); + $native->jsonDeserialize([ + CollectionResource::PROPERTY_IDENTIFIER => $identifier, + CollectionResource::PROPERTY_PROPERTIES => $properties->jsonSerialize(), + ]); } + return $native; + } + + public function collectionCreate(CollectionIdentifierInterface|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionResource { + // null target is root + $location = $this->normalizeLocation($target?->collection()); + // convert properties to a native collection + $collection = $this->collectionNative($properties); // Create in meta store $node = $this->metaStore->collectionCreate($this->serviceTenantId, $this->serviceUserId, $location, $collection, $options); // cache collection @@ -254,23 +292,27 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI return $node; } - public function collectionUpdate(string|int $identifier, CollectionMutableInterface $collection): CollectionResource { + public function collectionUpdate(CollectionIdentifierInterface $target, CollectionPropertiesBaseInterface $properties): CollectionResource { + $identifier = $target->collection(); + // convert properties to a native collection + $collection = $this->collectionNative($properties, $identifier); // Modify in meta store $node = $this->metaStore->collectionModify($this->serviceTenantId, $this->serviceUserId, $identifier, $collection); - + // update cache $this->serviceCollectionCache[$node->identifier()] = $node; return $node; } - public function collectionDelete(string|int $identifier, bool $force = false, bool $recursive = false): bool { + public function collectionDelete(CollectionIdentifierInterface $target, bool $force = false): CollectionBaseInterface | true { + $identifier = $target->collection(); // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot destroy root collection"); } - // If not forcing and not recursive, ensure the collection is empty - if (!$force && !$recursive) { + // If not forcing, ensure the collection is empty + if (!$force) { $children = $this->metaStore->nodeList($this->serviceTenantId, $this->serviceUserId, $identifier, false); if (!empty($children)) { throw new InvalidParameterException("Collection is not empty: $identifier"); @@ -280,34 +322,32 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI // Delete blob files for all entities within the collection tree $descendants = $this->metaStore->nodeList($this->serviceTenantId, $this->serviceUserId, $identifier, true); foreach ($descendants as $nodeId => $node) { - if ($node->isEntity()) { + if ($node instanceof EntityResource) { $this->blobStore->blobDelete((string)$nodeId); } } // Delete from meta store (handles recursive collection/entity meta deletion internally) - $result = $this->metaStore->collectionDestroy($this->serviceTenantId, $this->serviceUserId, $identifier); + $this->metaStore->collectionDestroy($this->serviceTenantId, $this->serviceUserId, $identifier); // remove from cache unset($this->serviceCollectionCache[$identifier]); - return $result; + return true; } - public function collectionCopy(string|int $identifier, string|int|null $location): CollectionResource { + public function collectionCopy(CollectionIdentifierInterface|null $target, CollectionIdentifierInterface $source): CollectionResource { + $identifier = $source->collection(); // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot copy root collection"); } // Verify collection exists - $extant = $this->collectionExtant($identifier, $identifier); - if (!($extant[$identifier] ?? false)) { + if ($this->collectionAccessible($identifier) === false) { throw new InvalidParameterException("Collection not found: $identifier"); } - - // null location is root - if ($location === null) { - $location = self::ROOT_ID; - } + + // null target is root + $location = $this->normalizeLocation($target?->collection()); // Copy in meta store $node = $this->metaStore->collectionCopy($this->serviceTenantId, $this->serviceUserId, $identifier, $location); @@ -316,24 +356,22 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI return $node; } - public function collectionMove(string|int $identifier, string|int|null $location): CollectionResource { + public function collectionMove(CollectionIdentifierInterface|null $target, CollectionIdentifierInterface $source): CollectionResource { + $identifier = $source->collection(); // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot move root collection"); } // Verify collection exists - $extant = $this->collectionExtant($identifier, $identifier); - if (!($extant[$identifier] ?? false)) { + if ($this->collectionAccessible($identifier) === false) { throw new InvalidParameterException("Collection not found: $identifier"); } - // null location is root - if ($location === null) { - $location = self::ROOT_ID; - } + // null target is root + $location = $this->normalizeLocation($target?->collection()); // Move in meta store $node = $this->metaStore->collectionMove($this->serviceTenantId, $this->serviceUserId, $identifier, $location); - + // update cache $this->serviceCollectionCache[$node->identifier()] = $node; return $node; @@ -341,20 +379,22 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI // Entity operations - public function entityList(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { + public function entityListBulk(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } + $collection = $this->normalizeLocation($collection); $entries = $this->metaStore->entityList($this->serviceTenantId, $this->serviceUserId, $collection, $filter, $sort, $range); // cache entities - foreach ($entries as $id => $entity) { + foreach ($entries as $entity) { $this->serviceEntityCache[$entity->identifier()] = $entity; } return $entries ?? []; } + public function entityListStream(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator { + yield from $this->entityListBulk($collection, $filter, $sort, $range, $properties); + } + public function entityListFilter(): IFilter { return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []); } @@ -370,41 +410,37 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI return new RangeTally(); } - public function entityFetch(string|int|null $collection, string|int ...$identifiers): array { - // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } - + public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array { $result = []; - $toFetch = []; - - // check cache first - foreach ($identifiers as $id) { + // group identifiers by collection + $byCollection = []; + foreach ($identifiers as $identifier) { + $id = $identifier->entity(); + // check cache first if (isset($this->serviceEntityCache[$id])) { $result[$id] = $this->serviceEntityCache[$id]; - } else { - $toFetch[] = $id; + continue; } + $byCollection[$this->normalizeLocation($identifier->collection())][] = $id; } - // fetch remaining from store - if (!empty($toFetch)) { - $fetched = $this->metaStore->entityFetch($this->serviceTenantId, $this->serviceUserId, $collection, ...$toFetch); + foreach ($byCollection as $collection => $entityIds) { + $fetched = $this->metaStore->entityFetch($this->serviceTenantId, $this->serviceUserId, $collection, ...$entityIds); foreach ($fetched as $id => $entity) { $this->serviceEntityCache[$id] = $entity; $result[$id] = $entity; } } - return $result; } + public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator { + yield from $this->entityFetchBulk(...$identifiers); + } + public function entityExtant(string|int|null $collection, string|int ...$identifiers): array { // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } + $collection = $this->normalizeLocation($collection); $result = []; foreach ($identifiers as $id) { @@ -427,202 +463,209 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI 'uid' => $this->serviceUserId, 'cid' => null, 'nid' => null, - 'created' => (new \DateTimeImmutable())->format('c'), + 'created' => (int) round(microtime(true) * 1000), 'properties' => [], ]); return $entity; } - public function entityCreate(string|int|null $collection, EntityMutableInterface $entity, array $options = []): EntityResource { - // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; + /** + * Convert incoming entity properties into a native EntityResource + */ + private function entityNative(EntityPropertiesMutableInterface $properties, string|int|null $identifier = null): EntityResource { + if ($properties instanceof EntityResource) { + $native = clone $properties; + } else { + $native = $this->entityFresh(); + $native->jsonDeserialize([ + EntityResource::PROPERTY_IDENTIFIER => $identifier, + EntityResource::PROPERTY_PROPERTIES => $properties->jsonSerialize(), + ]); } + return $native; + } + + public function entityCreate(CollectionIdentifierInterface $target, EntityPropertiesMutableInterface $properties, array $options = []): EntityResource { + // null collection is root + $collection = $this->normalizeLocation($target->collection()); + // convert properties to a native entity + $entity = $this->entityNative($properties); // Create in meta store $result = $this->metaStore->entityCreate($this->serviceTenantId, $this->serviceUserId, $collection, $entity, $options); - + // Write meta file for recovery $this->blobStore->metaWrite((string)$result->identifier(), $this->buildEntityMeta($result)); - + // cache entity $this->serviceEntityCache[$result->identifier()] = $result; return $result; } - public function entityUpdate(string|int|null $collection, string|int $identifier, EntityMutableInterface $entity): EntityResource { + public function entityModify(EntityIdentifierInterface $target, EntityPropertiesMutableInterface $properties): EntityResource { // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } + $collection = $this->normalizeLocation($target->collection()); + $identifier = $target->entity(); + // convert properties to a native entity + $entity = $this->entityNative($properties, $identifier); // Modify in meta store $result = $this->metaStore->entityModify($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $entity); - + // Update meta file for recovery $this->blobStore->metaWrite((string)$result->identifier(), $this->buildEntityMeta($result)); - + // update cache $this->serviceEntityCache[$result->identifier()] = $result; return $result; } - public function entityDelete(string|int|null $collection, string|int $identifier): EntityResource { - // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; + public function entityDelete(EntityIdentifierInterface ...$targets): array { + $results = []; + foreach ($targets as $target) { + $key = (string) $target; + $collection = $this->normalizeLocation($target->collection()); + $identifier = $target->entity(); + // validate entity extant and ownership + $extant = $this->entityExtant($collection, $identifier); + if (($extant[$identifier] ?? false) === false) { + $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $target]; + continue; + } + + // Delete from blob store + $this->blobStore->blobDelete((string)$identifier); + + // Delete from meta store + $this->metaStore->entityDestroy($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); + + // remove from cache + unset($this->serviceEntityCache[$identifier]); + $results[$key] = ['disposition' => 'deleted', 'destination' => null, 'mutation' => $target]; } - - /** @var EntityResource[] $entities */ - $entities = $this->entityFetch($collection, $identifier); - - if (!isset($entities[$identifier])) { - throw new InvalidParameterException("Entity not found: $identifier"); - } - - $entity = $entities[$identifier]; - - // Delete from blob store - $this->blobStore->blobDelete($entity->identifier()); - - // Delete from meta store - $this->metaStore->entityDestroy($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); - - // remove from cache - unset($this->serviceEntityCache[$entity->identifier()]); - return $entity; + return $results; } - public function entityDelta(string|int|null $collection, string $signature, string $detail = 'ids'): Delta { + public function entityDelta(string|int|null $collection, string $signature): Delta { // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } - return new Delta(); // $this->metaStore->entityDelta($this->serviceTenantId, $this->serviceUserId, $collection, $signature, $detail); + $collection = $this->normalizeLocation($collection); + return new Delta(); // $this->metaStore->entityDelta($this->serviceTenantId, $this->serviceUserId, $collection, $signature); } - public function entityCopy(string|int|null $collection, string|int $identifier, string|int|null $destination): EntityResource { - // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } - // null destination is root - if ($destination === null) { - $destination = self::ROOT_ID; - } + public function entityMove(CollectionIdentifierInterface $target, EntityIdentifierInterface ...$sources): array { + return $this->entityRelocate($target, true, ...$sources); + } - // Verify entity exists + public function entityCopy(CollectionIdentifierInterface $target, EntityIdentifierInterface ...$sources): array { + return $this->entityRelocate($target, false, ...$sources); + } + + /** + * Relocate (move or copy) entities into a target collection. + * + * Moves retain the entity identifier (blob path is keyed by entity id and never + * changes); copies receive a fresh identifier and a duplicated blob. + * + * @param CollectionIdentifierInterface $target destination collection identifier + * @param bool $remove true to move (relocate 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 = $this->normalizeLocation($target->collection()); + // validate target collection extant and ownership + $targetValid = $targetCollection === self::ROOT_ID || $this->collectionAccessible($targetCollection); + foreach ($sources as $source) { + $key = (string) $source; + $sourceCollection = $this->normalizeLocation($source->collection()); + $identifier = $source->entity(); + // validate target collection + if ($targetValid === false) { + $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $source]; + continue; + } + // validate source entity extant and ownership + $extant = $this->entityExtant($sourceCollection, $identifier); + if (($extant[$identifier] ?? false) === false) { + $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $source]; + continue; + } + if ($remove) { + // Move in meta store (blob path never changes since it is keyed by entity id) + $node = $this->metaStore->entityMove($this->serviceTenantId, $this->serviceUserId, $sourceCollection, $identifier, $targetCollection); + } else { + // Copy in meta store (creates new entity with new id) + $node = $this->metaStore->entityCopy($this->serviceTenantId, $this->serviceUserId, $sourceCollection, $identifier, $targetCollection); + // Copy blob content (new entity has new id = new file) + $content = $this->blobStore->blobRead((string)$identifier); + if ($content !== null) { + $this->blobStore->blobWrite((string)$node->identifier(), $content); + } + } + // Write meta file for recovery + $this->blobStore->metaWrite((string)$node->identifier(), $this->buildEntityMeta($node)); + // cache entity + $this->serviceEntityCache[$node->identifier()] = $node; + // construct destination and mutation identifiers + $destination = new CollectionIdentifier($this->provider(), $this->identifier(), (string)$targetCollection); + $mutation = new EntityIdentifier($this->provider(), $this->identifier(), (string)$targetCollection, (string)$node->identifier()); + $results[$key] = ['disposition' => $disposition, 'destination' => $destination, 'mutation' => $mutation]; + } + return $results; + } + + // Entity content (blob) operations + + /** + * Confirm an entity exists, returning its normalized collection identifier + */ + private function entityAccessible(EntityIdentifierInterface $target): string|int|false { + $collection = $this->normalizeLocation($target->collection()); + $identifier = $target->entity(); $extant = $this->entityExtant($collection, $identifier); - if (!($extant[$identifier] ?? false)) { - throw new InvalidParameterException("Entity not found: $identifier"); - } - - // Copy in meta store (creates new entity with new ID) - $node = $this->metaStore->entityCopy($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $destination); - - // Copy file content (new entity has new UUID = new file) - $content = $this->blobStore->blobRead((string)$identifier); - if ($content !== null) { - $this->blobStore->blobWrite((string)$node->identifier(), $content); - } - - // Write meta file for recovery - $this->blobStore->metaWrite((string)$node->identifier(), $this->buildEntityMeta($node)); - - // cache entity - $this->serviceEntityCache[$node->identifier()] = $node; - return $node; + return ($extant[$identifier] ?? false) ? $collection : false; } - - public function entityMove(string|int|null $collection, string|int $identifier, string|int|null $destination): EntityResource { - // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } - // null destination is root - if ($destination === null) { - $destination = self::ROOT_ID; - } - // Verify entity exists - $extant = $this->entityExtant($collection, $identifier); - if (!($extant[$identifier] ?? false)) { - throw new InvalidParameterException("Entity not found: $identifier"); - } - - // Move in meta store (file path never changes since it's based on entity ID) - $node = $this->metaStore->entityMove($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $destination); - - // Update meta file for recovery (parent changed) - $this->blobStore->metaWrite((string)$node->identifier(), $this->buildEntityMeta($node)); - - // update cache - $this->serviceEntityCache[$node->identifier()] = $node; - return $node; - } - - public function entityRead(string|int|null $collection, string|int $identifier): ?string { - // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } - - $entities = $this->entityFetch($collection, $identifier); - if (!isset($entities[$identifier])) { + public function entityRead(EntityIdentifierInterface $target): ?string { + if ($this->entityAccessible($target) === false) { return null; } - return $this->blobStore->blobRead((string)$identifier); + return $this->blobStore->blobRead((string)$target->entity()); } - public function entityReadStream(string|int|null $collection, string|int $identifier) { - // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } - - $entities = $this->entityFetch($collection, $identifier); - if (!isset($entities[$identifier])) { + public function entityReadStream(EntityIdentifierInterface $target) { + if ($this->entityAccessible($target) === false) { return null; } - - return $this->blobStore->blobReadStream((string)$identifier); + return $this->blobStore->blobReadStream((string)$target->entity()); } - public function entityReadChunk(string|int|null $collection, string|int $identifier, int $offset, int $length): ?string { - // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } - - $entities = $this->entityFetch($collection, $identifier); - if (!isset($entities[$identifier])) { + public function entityReadChunk(EntityIdentifierInterface $target, int $offset, int $length): ?string { + if ($this->entityAccessible($target) === false) { return null; } - - return $this->blobStore->blobReadChunk((string)$identifier, $offset, $length); + return $this->blobStore->blobReadChunk((string)$target->entity(), $offset, $length); } - public function entityWrite(string|int|null $collection, string|int $identifier, string $data): int { - // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } - - // verify entity exists - $extant = $this->entityExtant($collection, $identifier); - if (!($extant[$identifier] ?? false)) { + public function entityWrite(EntityIdentifierInterface $target, string $data): int { + $collection = $this->entityAccessible($target); + $identifier = $target->entity(); + if ($collection === false) { throw new InvalidParameterException("Entity not found: $identifier"); } - + // detect MIME type and format from content header $signature = Signature::detect(substr($data, 0, Signature::HEADER_SIZE)); - + // Write blob data $size = $this->blobStore->blobWrite((string)$identifier, $data); if ($size === null) { throw new \RuntimeException("Failed to write to entity: $identifier"); } - + // Update meta store $result = $this->metaStore->entityModify($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, [ 'properties' => [ @@ -631,25 +674,20 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI 'format' => $signature['format'], ], ], true); - + // Update meta blob $this->blobStore->metaWrite($result->identifier(), $this->buildEntityMeta($result)); - + // update cache $this->serviceEntityCache[$result->identifier()] = $result; return $size; } - - public function entityWriteStream(string|int|null $collection, string|int $identifier) { - // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } - // Verify entity exists - $extant = $this->entityExtant($collection, $identifier); - if (!($extant[$identifier] ?? false)) { + public function entityWriteStream(EntityIdentifierInterface $target) { + $collection = $this->entityAccessible($target); + $identifier = $target->entity(); + if ($collection === false) { throw new InvalidParameterException("Entity not found: $identifier"); } @@ -669,36 +707,31 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI // update meta blob $this->blobStore->metaWrite($result->identifier(), $this->buildEntityMeta($result)); - + // update cache $this->serviceEntityCache[$result->identifier()] = $result; return $stream; } - public function entityWriteChunk(string|int|null $collection, string|int $identifier, int $offset, string $data): int { - // null collection is root - if ($collection === null) { - $collection = self::ROOT_ID; - } - - // Verify entity exists - $extant = $this->entityExtant($collection, $identifier); - if (!($extant[$identifier] ?? false)) { + public function entityWriteChunk(EntityIdentifierInterface $target, int $offset, string $data): int { + $collection = $this->entityAccessible($target); + $identifier = $target->entity(); + if ($collection === false) { throw new InvalidParameterException("Entity not found: $identifier"); } - + // Detect MIME type and format from first chunk (offset === 0) $signature = null; if ($offset === 0) { $signature = Signature::detect(substr($data, 0, Signature::HEADER_SIZE)); } - + $bytes = $this->blobStore->blobWriteChunk((string)$identifier, $offset, $data); if ($bytes === null) { throw new \RuntimeException("Failed to write chunk to entity: $identifier"); } - + // update meta store $size = $this->blobStore->blobSize((string)$identifier) ?? 0; $result = $this->metaStore->entityModify($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, [ @@ -711,41 +744,13 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI // Update meta blob $this->blobStore->metaWrite($result->identifier(), $this->buildEntityMeta($result)); - + // update cache $this->serviceEntityCache[$result->identifier()] = $result; - + return $bytes; } - // Node operations - - public function nodeList(string|int|null $collection = null, bool $recursive = false, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { - if ($collection === null) { - $collection = self::ROOT_ID; - } - return $this->metaStore->nodeList($this->serviceTenantId, $this->serviceUserId, $collection, $recursive, $filter, $sort, $range, $properties); - } - - public function nodeListFilter(): IFilter { - return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []); - } - - public function nodeListSort(): ISort { - return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []); - } - - public function nodeListRange(RangeType $type): IRange { - if ($type !== RangeType::TALLY) { - throw new InvalidParameterException("Invalid: Node range of type '{$type->value}' is not supported"); - } - return new RangeTally(); - } - - public function nodeDelta(string|int|null $collection, string $signature, string $detail = 'ids'): Delta { - return new Delta(); - } - /** * Build metadata array for an entity to store in .meta file * @@ -758,8 +763,8 @@ class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableI 'uid' => $this->serviceUserId, 'cid' => $node->collection(), 'nid' => $node->identifier(), - 'created' => $node->created()?->format('c'), - 'modified' => $node->modified()?->format('c'), + 'created' => $node->created()?->getTimestamp() !== null ? (((int) $node->created()?->format('U')) * 1000) + (int) $node->created()?->format('v') : null, + 'modified' => $node->modified()?->getTimestamp() !== null ? (((int) $node->modified()?->format('U')) * 1000) + (int) $node->modified()?->format('v') : null, 'size' => $node->getProperties()->size(), 'label' => $node->getProperties()->getLabel(), 'mime' => $node->getProperties()->getMime(), diff --git a/lib/Providers/Provider.php b/lib/Providers/Provider.php index 53273f1..528a387 100644 --- a/lib/Providers/Provider.php +++ b/lib/Providers/Provider.php @@ -11,8 +11,8 @@ namespace KTXM\ProviderLocalDocuments\Providers; use DI\Attribute\Inject; use Psr\Container\ContainerInterface; -use KTXF\Resource\Documents\Provider\ProviderBaseInterface; -use KTXF\Resource\Documents\Service\ServiceBaseInterface; +use KTXF\Documents\Provider\ProviderBaseInterface; +use KTXF\Documents\Service\ServiceBaseInterface; use KTXM\ProviderLocalDocuments\Module; use KTXM\ProviderLocalDocuments\Providers\Personal\PersonalService; @@ -41,10 +41,10 @@ class Provider implements ProviderBaseInterface { public function jsonSerialize(): array { return [ - self::JSON_PROPERTY_TYPE => self::JSON_TYPE, - self::JSON_PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER, - self::JSON_PROPERTY_LABEL => self::PROVIDER_LABEL, - self::JSON_PROPERTY_CAPABILITIES => $this->providerAbilities, + self::PROPERTY_TYPE => self::JSON_TYPE, + self::PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER, + self::PROPERTY_LABEL => self::PROVIDER_LABEL, + self::PROPERTY_CAPABILITIES => $this->providerAbilities, ]; } @@ -55,7 +55,7 @@ class Provider implements ProviderBaseInterface { public function type(): string { - return self::TYPE_CHRONO; + return self::TYPE_DOCUMENT; } public function identifier(): string diff --git a/lib/Store/MetaStore.php b/lib/Store/MetaStore.php index 802c037..9717ea8 100644 --- a/lib/Store/MetaStore.php +++ b/lib/Store/MetaStore.php @@ -220,8 +220,8 @@ class MetaStore { 'cid' => empty($location) ? self::ROOT_COLLECTION_ID : $location, 'nid' => UUID::v4(), 'type' => NodeType::Collection->value, - 'created' => date('c'), - 'modified' => date('c'), + 'created' => (int) round(microtime(true) * 1000), + 'modified' => (int) round(microtime(true) * 1000), 'properties' => $collection->getProperties()->toStore(), ]; $data['signature'] = time(); @@ -237,7 +237,7 @@ class MetaStore { */ public function collectionModify(string $tenantId, string $userId, string|int $identifier, CollectionResource $collection): CollectionResource { $data = [ - 'modified' => date('c'), + 'modified' => (int) round(microtime(true) * 1000), 'properties' => $collection->getProperties()->toStore(), ]; $data['signature'] = time(); @@ -287,7 +287,7 @@ class MetaStore { public function collectionMove(string $tenantId, string $userId, string|int $identifier, string|int|null $location): CollectionResource { $data = [ 'cid' => empty($location) ? self::ROOT_COLLECTION_ID : $location, - 'modified' => date('c'), + 'modified' => (int) round(microtime(true) * 1000), ]; $this->_store->selectCollection($this->_NodeTable)->updateOne( @@ -311,7 +311,7 @@ class MetaStore { $source = $collections[$identifier]; $newCollection = $this->collectionFresh(); - $newCollection->setLabel($source->getLabel()); + $newCollection->getProperties()->fromStore($source->getProperties()->toStore()); $newNode = $this->collectionCreate($tenantId, $userId, $location, $newCollection); @@ -420,8 +420,8 @@ class MetaStore { 'cid' => empty($collection) ? self::ROOT_COLLECTION_ID : $collection, 'nid' => UUID::v4(), 'type' => NodeType::Entity->value, - 'created' => date('c'), - 'modified' => date('c'), + 'created' => (int) round(microtime(true) * 1000), + 'modified' => (int) round(microtime(true) * 1000), 'properties' => $entity->getProperties()->toStore(), ]; $entity['signature'] = time(); @@ -452,7 +452,7 @@ class MetaStore { $attributes['properties'] = $data['properties'] ?? []; } - $attributes['modified'] = date('c'); + $attributes['modified'] = (int) round(microtime(true) * 1000); $attributes['signature'] = time(); $this->_store->selectCollection($this->_NodeTable)->updateOne( @@ -491,9 +491,9 @@ class MetaStore { */ public function entityMove(string $tenantId, string $userId, string|int|null $collection, string|int $identifier, string|int|null $destination): EntityResource { $data = [ - 'pid' => $destination, - 'modifiedOn' => date('c'), - 'modifiedBy' => $userId, + 'cid' => empty($destination) ? self::ROOT_COLLECTION_ID : $destination, + 'modified' => (int) round(microtime(true) * 1000), + 'signature' => time(), ]; $this->_store->selectCollection($this->_NodeTable)->updateOne( @@ -501,8 +501,8 @@ class MetaStore { ['$set' => $data] ); $this->chronicleNode($tenantId, $userId, $identifier, 2); - - $entities = $this->entityFetch($tenantId, $userId, $destination, $identifier); + + $entities = $this->entityFetch($tenantId, $userId, empty($destination) ? self::ROOT_COLLECTION_ID : $destination, $identifier); return $entities[$identifier]; } @@ -517,11 +517,8 @@ class MetaStore { $source = $entities[$identifier]; $newEntity = new EntityResource(); - $newEntity->setLabel($source->getLabel()); - $newEntity->setMime($source->getMime()); - $newEntity->setFormat($source->getFormat()); - $newEntity->setEncoding($source->getEncoding()); - + $newEntity->getProperties()->fromStore($source->getProperties()->toStore()); + return $this->entityCreate($tenantId, $userId, $destination, $newEntity, ['path' => null]); } @@ -596,7 +593,7 @@ class MetaStore { $cursor = $this->_store->selectCollection($this->_NodeTable)->find([ 'tid' => $tenantId, 'uid' => $userId, - 'pid' => $parentId, + 'cid' => $parentId, 'type' => NodeType::Collection->value, ]);