* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\FileProviderLocal\Store; use KTXC\Db\DataStore; use KTXF\Files\Node\INodeCollectionMutable; use KTXF\Files\Node\INodeEntityMutable; use KTXF\Files\Node\NodeType; use KTXF\Resource\Filter\Filter; use KTXF\Resource\Filter\FilterComparisonOperator; use KTXF\Resource\Filter\FilterConjunctionOperator; use KTXF\Resource\Range\IRange; use KTXF\Resource\Range\IRangeTally; use KTXF\Resource\Range\RangeType; use KTXF\Resource\Sort\Sort; use KTXF\Utile\UUID; use KTXM\FileProviderLocal\Providers\Personal\NodeCollection; use KTXM\FileProviderLocal\Providers\Personal\NodeEntity; class MetaStore { protected string $_NodeTable = 'file_provider_local_node'; protected string $_ChronicleTable = 'file_provider_local_chronicle'; protected array $_CollectionFilterAttributeMap = [ 'id' => 'nid', 'label' => 'name', 'parent' => 'pid', ]; protected array $_EntityFilterAttributeMap = [ 'id' => 'nid', 'label' => 'name', ]; protected array $_NodeFilterAttributeMap = [ 'id' => 'nid', 'label' => 'name', 'type' => 'type', 'parent' => 'pid', ]; protected array $_SortAttributeMap = [ 'label' => 'name', 'type' => 'type', 'size' => 'size', 'type' => 'type', 'createdOn' => 'createdOn', 'modifiedOn' => 'modifiedOn', ]; public function __construct( protected readonly DataStore $_store ) { } protected function constructFilter(array $map, Filter $filter): array { $mongoFilter = []; foreach ($filter->conditions() as $entry) { if (!isset($map[$entry['attribute']])) { continue; } $attribute = $map[$entry['attribute']]; $value = $entry['value']; $comparator = $entry['comparator'] ?? FilterComparisonOperator::EQ; $condition = match ($comparator) { FilterComparisonOperator::EQ => $value, FilterComparisonOperator::NEQ => ['$ne' => $value], FilterComparisonOperator::GT => ['$gt' => $value], FilterComparisonOperator::GTE => ['$gte' => $value], FilterComparisonOperator::LT => ['$lt' => $value], FilterComparisonOperator::LTE => ['$lte' => $value], FilterComparisonOperator::IN => ['$in' => is_array($value) ? $value : [$value]], FilterComparisonOperator::NIN => ['$nin' => is_array($value) ? $value : [$value]], FilterComparisonOperator::LIKE => ['$regex' => $value, '$options' => 'i'], FilterComparisonOperator::NLIKE => ['$not' => ['$regex' => $value, '$options' => 'i']], default => $value }; if (isset($mongoFilter[$attribute])) { if ($entry['conjunction'] === FilterConjunctionOperator::OR) { $mongoFilter['$or'][] = [$attribute => $condition]; } else { if (is_array($mongoFilter[$attribute]) && !isset($mongoFilter[$attribute]['$and'])) { $mongoFilter[$attribute] = ['$and' => [$mongoFilter[$attribute], $condition]]; } else { $mongoFilter[$attribute] = $condition; } } } else { $mongoFilter[$attribute] = $condition; } } return $mongoFilter; } protected function constructSort(array $map, Sort $sort): array { $mongoSort = []; foreach ($sort->conditions() as $entry) { if (!isset($map[$entry['attribute']])) { continue; } $attribute = $map[$entry['attribute']]; $direction = $entry['direction'] ? 1 : -1; $mongoSort[$attribute] = $direction; } return $mongoSort; } // ========== Collection Operations ========== /** * List collections from data store * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param string|int|null $location parent collection (null for root) * @param Filter|null $filter filter options * @param Sort|null $sort sort options * * @return array */ public function collectionList(string $tenantId, string $userId, string|int|null $location = null, ?Filter $filter = null, ?Sort $sort = null): array { $query = [ 'tid' => $tenantId, 'uid' => $userId, 'pid' => $location, 'type' => NodeType::Collection->value, ]; if ($filter !== null) { $filterConditions = $this->constructFilter($this->_CollectionFilterAttributeMap, $filter); $query = array_merge($query, $filterConditions); } $options = []; if ($sort !== null) { $sortConditions = $this->constructSort($this->_SortAttributeMap, $sort); $options['sort'] = $sortConditions; } else { $options['sort'] = ['name' => 1]; } $cursor = $this->_store->selectCollection($this->_NodeTable)->find($query, $options); $list = []; foreach ($cursor as $entry) { $node = (new NodeCollection())->fromStore($entry); $list[$node->id()] = $node; } return $list; } /** * Check if a collection exists */ public function collectionExtant(string $tenantId, string $userId, string|int $identifier): bool { $cursor = $this->_store->selectCollection($this->_NodeTable)->findOne([ 'tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier, 'type' => NodeType::Collection->value, ]); return $cursor !== null; } /** * Fetch a collection */ public function collectionFetch(string $tenantId, string $userId, string|int ...$identifiers): array { $cursor = $this->_store->selectCollection($this->_NodeTable)->find([ 'tid' => $tenantId, 'uid' => $userId, 'nid' => ['$in' => $identifiers], 'type' => NodeType::Collection->value, ]); $list = []; foreach ($cursor as $entry) { $node = (new NodeCollection())->fromStore($entry); $list[$node->id()] = $node; } return $list; } /** * Create a collection */ public function collectionCreate(string $tenantId, string $userId, string|int|null $location, INodeCollectionMutable $collection, array $options = []): NodeCollection { $data = [ 'tid' => $tenantId, 'uid' => $userId, 'nid' => UUID::v4(), 'pid' => $location, 'type' => NodeType::Collection->value, 'createdBy' => $userId, 'createdOn' => date('c'), 'modifiedBy' => $userId, 'modifiedOn' => date('c'), 'owner' => $userId, 'label' => $collection->getLabel(), ]; $data['signature'] = md5(json_encode([$data['label'], $data['modifiedOn']])); $this->_store->selectCollection($this->_NodeTable)->insertOne($data); $this->chronicleNode($tenantId, $userId, $data['nid'], 1); return (new NodeCollection())->fromStore($data); } /** * Modify a collection */ public function collectionModify(string $tenantId, string $userId, string|int $identifier, INodeCollectionMutable $collection): NodeCollection { $data = [ 'modifiedOn' => date('c'), 'modifiedBy' => $userId, 'label' => $collection->getLabel(), ]; $data['signature'] = md5(json_encode([$data['label'], $data['modifiedOn']])); $this->_store->selectCollection($this->_NodeTable)->updateOne( ['tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier], ['$set' => $data] ); $this->chronicleNode($tenantId, $userId, $identifier, 2); $collections = $this->collectionFetch($tenantId, $userId, $identifier); return $collections[$identifier]; } /** * Destroy a collection and its children */ public function collectionDestroy(string $tenantId, string $userId, string|int $identifier): bool { // Delete children recursively $children = $this->nodeList($tenantId, $userId, $identifier, true); foreach ($children as $childId => $child) { $this->_store->selectCollection($this->_NodeTable)->deleteOne([ 'tid' => $tenantId, 'uid' => $userId, 'nid' => $childId ]); $this->chronicleNode($tenantId, $userId, $childId, 3); } // Delete the collection itself $result = $this->_store->selectCollection($this->_NodeTable)->deleteOne([ 'tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier ]); if ($result->getDeletedCount() === 1) { $this->chronicleNode($tenantId, $userId, $identifier, 3); return true; } return false; } /** * Move a collection */ public function collectionMove(string $tenantId, string $userId, string|int $identifier, string|int|null $location): NodeCollection { $data = [ 'pid' => $location, 'modifiedBy' => $userId, 'modifiedOn' => date('c'), ]; $this->_store->selectCollection($this->_NodeTable)->updateOne( ['tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier], ['$set' => $data] ); $this->chronicleNode($tenantId, $userId, $identifier, 2); $collections = $this->collectionFetch($tenantId, $userId, $identifier); return $collections[$identifier]; } /** * Copy a collection */ public function collectionCopy(string $tenantId, string $userId, string|int $identifier, string|int|null $location): NodeCollection { $collections = $this->collectionFetch($tenantId, $userId, $identifier); if (!isset($collections[$identifier])) { throw new \RuntimeException("Collection not found: $identifier"); } $source = $collections[$identifier]; $newCollection = new NodeCollection(); $newCollection->setLabel($source->getLabel()); $newNode = $this->collectionCreate($tenantId, $userId, $location, $newCollection); // Copy children recursively $children = $this->nodeList($tenantId, $userId, $identifier, false); foreach ($children as $childId => $child) { if ($child->isCollection()) { $this->collectionCopy($tenantId, $userId, $childId, $newNode->id()); } else { $this->entityCopy($tenantId, $userId, $identifier, $childId, $newNode->id()); } } return $newNode; } // ========== Entity Operations ========== /** * List entities in a collection */ public function entityList(string $tenantId, string $userId, string|int $collection, ?Filter $filter = null, ?Sort $sort = null, ?IRange $range = null): array { $query = [ 'tid' => $tenantId, 'uid' => $userId, 'pid' => $collection, 'type' => NodeType::Entity->value, ]; if ($filter !== null) { $filterConditions = $this->constructFilter($this->_EntityFilterAttributeMap, $filter); $query = array_merge($query, $filterConditions); } $options = []; if ($sort !== null) { $sortConditions = $this->constructSort($this->_SortAttributeMap, $sort); $options['sort'] = $sortConditions; } else { $options['sort'] = ['name' => 1]; } if ($range !== null && $range->type() === RangeType::TALLY && $range instanceof IRangeTally) { $options['skip'] = $range->getPosition(); $options['limit'] = $range->getTally(); } $cursor = $this->_store->selectCollection($this->_NodeTable)->find($query, $options); $list = []; foreach ($cursor as $entry) { $node = (new NodeEntity())->fromStore($entry); $list[$node->id()] = $node; } return $list; } /** * Check if an entity exists */ public function entityExtant(string $tenantId, string $userId, string|int $collection, string|int $identifier): bool { $cursor = $this->_store->selectCollection($this->_NodeTable)->findOne([ 'tid' => $tenantId, 'uid' => $userId, 'pid' => $collection, 'nid' => $identifier, 'type' => NodeType::Entity->value, ]); return $cursor !== null; } /** * Fetch entities */ public function entityFetch(string $tenantId, string $userId, string|int $collection, string|int ...$identifiers): array { $cursor = $this->_store->selectCollection($this->_NodeTable)->find([ 'tid' => $tenantId, 'uid' => $userId, 'pid' => $collection, 'nid' => ['$in' => $identifiers], 'type' => NodeType::Entity->value, ]); $list = []; foreach ($cursor as $entry) { $node = (new NodeEntity())->fromStore($entry); $list[$node->id()] = $node; } return $list; } /** * Create an entity */ public function entityCreate(string $tenantId, string $userId, string|int|null $collection, INodeEntityMutable $entity, array $options = []): NodeEntity { $data = [ 'tid' => $tenantId, 'uid' => $userId, 'nid' => UUID::v4(), 'pid' => $collection, 'type' => NodeType::Entity->value, 'createdOn' => date('c'), 'createdBy' => $userId, 'modifiedOn' => date('c'), 'modifiedBy' => $userId, 'size' => 0, 'mime' => $entity->getMime(), 'format' => $entity->getFormat(), 'encoding' => $entity->getEncoding(), 'label' => $entity->getLabel(), ]; $data['signature'] = md5(json_encode([$data['label'], $data['size'], $data['mime'], $data['modifiedOn']])); $this->_store->selectCollection($this->_NodeTable)->insertOne($data); $this->chronicleNode($tenantId, $userId, $data['nid'], 1); return (new NodeEntity())->fromStore($data); } /** * Modify an entity */ public function entityModify(string $tenantId, string $userId, string|int|null $collection, string|int $identifier, INodeEntityMutable $entity): NodeEntity { $data = [ 'label' => $entity->getLabel(), 'mime' => $entity->getMime(), 'format' => $entity->getFormat(), 'encoding' => $entity->getEncoding(), 'modifiedOn' => date('c'), 'modifiedBy' => $userId, ]; $data['signature'] = md5(json_encode([$data['label'], $data['mime'], $data['modifiedOn']])); $this->_store->selectCollection($this->_NodeTable)->updateOne( ['tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier], ['$set' => $data] ); $this->chronicleNode($tenantId, $userId, $identifier, 2); $entities = $this->entityFetch($tenantId, $userId, $collection, $identifier); return $entities[$identifier]; } /** * Update entity attributes * * Supported attributes: size, format, mime, encoding, label * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param string|int $collection collection identifier * @param string|int $identifier entity identifier * @param array $attributes key-value pairs of attributes to update */ public function entityUpdate(string $tenantId, string $userId, string|int $collection, string|int $identifier, array $attributes): void { // Filter to allowed attributes only $allowed = ['size', 'format', 'mime', 'encoding', 'label']; $data = array_intersect_key($attributes, array_flip($allowed)); if (empty($data)) { return; } // Always update modification timestamp $data['modifiedOn'] = date('c'); $this->_store->selectCollection($this->_NodeTable)->updateOne( ['tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier], ['$set' => $data] ); $this->chronicleNode($tenantId, $userId, $identifier, 2); } /** * Destroy an entity */ public function entityDestroy(string $tenantId, string $userId, string|int|null $collection, string|int $identifier): bool { $result = $this->_store->selectCollection($this->_NodeTable)->deleteOne([ 'tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier ]); if ($result->getDeletedCount() === 1) { $this->chronicleNode($tenantId, $userId, $identifier, 3); return true; } return false; } /** * Move an entity to another collection */ public function entityMove(string $tenantId, string $userId, string|int|null $collection, string|int $identifier, string|int|null $destination): NodeEntity { $data = [ 'pid' => $destination, 'modifiedOn' => date('c'), 'modifiedBy' => $userId, ]; $this->_store->selectCollection($this->_NodeTable)->updateOne( ['tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier], ['$set' => $data] ); $this->chronicleNode($tenantId, $userId, $identifier, 2); $entities = $this->entityFetch($tenantId, $userId, $destination, $identifier); return $entities[$identifier]; } /** * Copy an entity */ public function entityCopy(string $tenantId, string $userId, string|int|null $collection, string|int $identifier, string|int|null $destination): NodeEntity { $entities = $this->entityFetch($tenantId, $userId, $collection, $identifier); if (!isset($entities[$identifier])) { throw new \RuntimeException("Entity not found: $identifier"); } $source = $entities[$identifier]; $newEntity = new NodeEntity(); $newEntity->setLabel($source->getLabel()); $newEntity->setMime($source->getMime()); $newEntity->setFormat($source->getFormat()); $newEntity->setEncoding($source->getEncoding()); return $this->entityCreate($tenantId, $userId, $destination, $newEntity, ['path' => null]); } /** * Entity delta (changes since signature) */ public function entityDelta(string $tenantId, string $userId, string|int $collection, string $signature, string $detail = 'ids'): array { return $this->internalDelta($tenantId, $userId, $collection, $signature, false, $detail); } // ========== Node Operations (Unified/Recursive) ========== /** * List all nodes (collections and entities) */ public function nodeList(string $tenantId, string $userId, string|int|null $location = null, bool $recursive = false, ?Filter $filter = null, ?Sort $sort = null, ?IRange $range = null): array { $query = [ 'tid' => $tenantId, 'uid' => $userId, ]; // For non-recursive, filter by parent if (!$recursive) { $query['pid'] = $location; } elseif ($location !== null) { // For recursive with specific location, we need to get all descendants // This requires getting all collections first and building a list of IDs $allCollectionIds = $this->getDescendantCollectionIds($tenantId, $userId, $location); $allCollectionIds[] = $location; $query['$or'] = [ ['pid' => ['$in' => $allCollectionIds]], ['nid' => ['$in' => $allCollectionIds]], ]; } if ($filter !== null) { $filterConditions = $this->constructFilter($this->_NodeFilterAttributeMap, $filter); $query = array_merge($query, $filterConditions); } $options = []; if ($sort !== null) { $sortConditions = $this->constructSort($this->_SortAttributeMap, $sort); $options['sort'] = $sortConditions; } else { $options['sort'] = ['type' => -1, 'name' => 1]; // folders first } if ($range !== null && $range->type() === RangeType::TALLY && $range instanceof IRangeTally) { $options['skip'] = $range->getPosition(); $options['limit'] = $range->getTally(); } $cursor = $this->_store->selectCollection($this->_NodeTable)->find($query, $options); $list = []; foreach ($cursor as $entry) { $nodeType = $entry['type'] ?? NodeType::Entity->value; if ($nodeType === NodeType::Collection->value) { $node = (new NodeCollection())->fromStore($entry); } else { $node = (new NodeEntity())->fromStore($entry); } $list[$node->id()] = $node; } return $list; } /** * Get all descendant collection IDs (helper for recursive operations) */ private function getDescendantCollectionIds(string $tenantId, string $userId, string|int $parentId): array { $cursor = $this->_store->selectCollection($this->_NodeTable)->find([ 'tid' => $tenantId, 'uid' => $userId, 'pid' => $parentId, 'type' => NodeType::Collection->value, ]); $ids = []; foreach ($cursor as $entry) { $id = $entry['nid']; $ids[] = $id; $childIds = $this->getDescendantCollectionIds($tenantId, $userId, $id); $ids = array_merge($ids, $childIds); } return $ids; } /** * Node delta (changes since signature, optionally recursive) */ public function nodeDelta(string $tenantId, string $userId, string|int|null $location, string $signature, bool $recursive = false, string $detail = 'ids'): array { return $this->internalDelta($tenantId, $userId, $location, $signature, $recursive, $detail); } /** * Internal delta implementation */ private function internalDelta(string $tenantId, string $userId, string|int|null $location, string $signature, bool $recursive, string $detail): array { $tokenApex = $this->chronicleApex($tenantId, $userId, false); $tokenNadir = !empty($signature) ? base64_decode($signature) : ''; $initial = !is_numeric($tokenNadir); $tokenNadir = $initial ? 0 : (int)$tokenNadir; $matchStage = [ '$match' => [ 'tid' => $tenantId, 'uid' => $userId, ] ]; if (!$initial) { $matchStage['$match']['signature'] = [ '$gt' => $tokenNadir, '$lte' => (int)$tokenApex ]; } $pipeline = [ $matchStage, [ '$group' => [ '_id' => '$nid', 'operation' => ['$max' => '$operation'], 'nid' => ['$first' => '$nid'] ] ] ]; if ($initial) { $pipeline[] = ['$match' => ['operation' => ['$ne' => 3]]]; } $cursor = $this->_store->selectCollection($this->_ChronicleTable)->aggregate($pipeline); $added = []; $updated = []; $deleted = []; foreach ($cursor as $entry) { $id = $entry['nid']; $op = $entry['operation']; if ($op === 3) { $deleted[] = $id; } elseif ($op === 1) { $added[] = $id; } else { $updated[] = $id; } } // If detail is 'ids', just return IDs if ($detail === 'ids') { return [ 'added' => $added, 'updated' => $updated, 'deleted' => $deleted, 'signature' => base64_encode((string)$tokenApex), ]; } // For meta/full, fetch node data $addedNodes = []; $updatedNodes = []; if (!empty($added)) { $allIds = $added; $nodes = $this->fetchNodesByIds($tenantId, $userId, $allIds); foreach ($added as $id) { if (isset($nodes[$id])) { $addedNodes[$id] = $nodes[$id]; } } } if (!empty($updated)) { $nodes = $this->fetchNodesByIds($tenantId, $userId, $updated); foreach ($updated as $id) { if (isset($nodes[$id])) { $updatedNodes[$id] = $nodes[$id]; } } } return [ 'added' => $addedNodes, 'updated' => $updatedNodes, 'deleted' => $deleted, 'signature' => base64_encode((string)$tokenApex), ]; } /** * Fetch nodes by IDs (returns both collections and entities) */ private function fetchNodesByIds(string $tenantId, string $userId, array $ids): array { $cursor = $this->_store->selectCollection($this->_NodeTable)->find([ 'tid' => $tenantId, 'uid' => $userId, 'nid' => ['$in' => $ids] ]); $list = []; foreach ($cursor as $entry) { $nodeType = $entry['type'] ?? NodeType::Entity->value; if ($nodeType === NodeType::Collection->value) { $node = (new NodeCollection())->fromStore($entry); } else { $node = (new NodeEntity())->fromStore($entry); } $list[$node->id()] = $node; } return $list; } // ========== Chronicle Operations ========== /** * Chronicle a node operation * * @since Release 1.0.0 * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param string|int $nodeId node identifier * @param int $operation operation type (1 = Created, 2 = Modified, 3 = Deleted) */ private function chronicleNode(string $tenantId, string $userId, string|int $nodeId, int $operation): void { // Get current max signature $signature = $this->chronicleApex($tenantId, $userId, false); // Insert chronicle entry $this->_store->selectCollection($this->_ChronicleTable)->insertOne([ 'tid' => $tenantId, 'uid' => $userId, 'nid' => $nodeId, 'operation' => $operation, 'signature' => (int)$signature + 1, 'mutatedOn' => time(), ]); } /** * Get the apex (highest) signature for a user's chronicle * * @since Release 1.0.0 * * @param string $tenantId tenant identifier * @param string $userId user identifier * @param bool $encode whether to encode the result * * @return int|string */ public function chronicleApex(string $tenantId, string $userId, bool $encode = true): int|string { $cursor = $this->_store->selectCollection($this->_ChronicleTable)->aggregate([ [ '$match' => ['tid' => $tenantId, 'uid' => $userId] ], [ '$group' => [ '_id' => null, 'maxToken' => ['$max' => '$signature'] ] ] ]); $result = $cursor->toArray(); $stampApex = !empty($result) ? ($result[0]['maxToken'] ?? 0) : 0; if ($encode) { return base64_encode((string)max(0, $stampApex)); } else { return max(0, $stampApex); } } }