1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus MCP SDK package.
7: *
8: * (c) 2026 John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Mcp\Server;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Core\Schema\Cursor;
18: use Nexus\Mcp\Server\Exception\InvalidCursorException;
19:
20: /**
21: * Shared cursor scaffolding for in-memory per-feature stores.
22: *
23: * @template TEntry of object
24: */
25: abstract readonly class AbstractPaginatedStore
26: {
27: public const int DEFAULT_PAGE_SIZE = 50;
28:
29: /**
30: * Subclass override. Used as the prefix in constructor-time assert messages.
31: */
32: protected const string STORE_LABEL = 'Store';
33:
34: /**
35: * @var array<non-empty-string, int<0, max>>
36: */
37: private array $keyIndex;
38:
39: /**
40: * @param array<non-empty-string, TEntry> $entries
41: */
42: public function __construct(protected array $entries = [], protected int $pageSize = self::DEFAULT_PAGE_SIZE)
43: {
44: Assert::that($entries)
45: ->keys()
46: ->isNonEmptyString(\sprintf('%s entry key must be a non-empty string.', static::STORE_LABEL))
47: ;
48: Assert::that($pageSize)
49: ->isPositiveInt(\sprintf('%s page size must be a positive integer, {value} given.', static::STORE_LABEL))
50: ;
51:
52: $this->keyIndex = array_flip(array_keys($entries));
53: }
54:
55: /**
56: * @template TItem of object
57: * @template TResult of object
58: *
59: * @param \Closure(TEntry): TItem $transform
60: * @param \Closure(list<TItem>, ?Cursor): TResult $resultBuilder
61: *
62: * @return TResult
63: *
64: * @throws InvalidCursorException
65: */
66: final protected function paginate(?Cursor $cursor, \Closure $transform, \Closure $resultBuilder): object
67: {
68: $startIndex = $this->resolveStartIndex($cursor);
69: $page = \array_slice($this->entries, $startIndex, $this->pageSize);
70: $items = array_values(array_map($transform, $page));
71:
72: $hasMore = $startIndex + \count($page) < \count($this->entries);
73: $nextCursor = $hasMore ? new Cursor((string) array_key_last($page)) : null;
74:
75: return $resultBuilder($items, $nextCursor);
76: }
77:
78: private function resolveStartIndex(?Cursor $cursor): int
79: {
80: if (null === $cursor) {
81: return 0;
82: }
83:
84: $raw = $cursor->cursor;
85:
86: if (! isset($this->keyIndex[$raw])) {
87: throw new InvalidCursorException($raw);
88: }
89:
90: return $this->keyIndex[$raw] + 1;
91: }
92: }
93: