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\Core\Schema\Enum\CacheScope;
19: use Nexus\Mcp\Server\Exception\InvalidCursorException;
20:
21: /**
22: * Shared cursor scaffolding for in-memory per-feature stores.
23: *
24: * @template TEntry of object
25: */
26: abstract readonly class AbstractPaginatedStore
27: {
28: public const int DEFAULT_PAGE_SIZE = 50;
29:
30: /**
31: * Subclass override. Used as the prefix in constructor-time assert messages.
32: */
33: protected const string STORE_LABEL = 'Store';
34:
35: /**
36: * @var array<non-empty-string, int<0, max>>
37: */
38: private array $keyIndex;
39:
40: /**
41: * @param array<non-empty-string, TEntry> $entries
42: */
43: public function __construct(
44: protected array $entries = [],
45: protected int $pageSize = self::DEFAULT_PAGE_SIZE,
46: protected int $ttlMs = 0,
47: protected CacheScope $cacheScope = CacheScope::Private,
48: ) {
49: Assert::that($entries)
50: ->keys()
51: ->isNonEmptyString(\sprintf('%s entry key must be a non-empty string.', static::STORE_LABEL))
52: ;
53: Assert::that($pageSize)
54: ->isPositiveInt(\sprintf('%s page size must be a positive integer, {value} given.', static::STORE_LABEL))
55: ;
56: Assert::that($ttlMs)
57: ->isNaturalInt(\sprintf('%s TTL must be a non-negative integer, {value} given.', static::STORE_LABEL))
58: ;
59:
60: $this->keyIndex = array_flip(array_keys($entries));
61: }
62:
63: /**
64: * @template TItem of object
65: * @template TResult of object
66: *
67: * @param \Closure(TEntry): TItem $transform
68: * @param \Closure(list<TItem>, ?Cursor, int, CacheScope): TResult $resultBuilder
69: *
70: * @return TResult
71: *
72: * @throws InvalidCursorException
73: */
74: final protected function paginate(?Cursor $cursor, \Closure $transform, \Closure $resultBuilder): object
75: {
76: $startIndex = $this->resolveStartIndex($cursor);
77: $page = \array_slice($this->entries, $startIndex, $this->pageSize);
78: $items = array_values(array_map($transform, $page));
79:
80: $hasMore = $startIndex + \count($page) < \count($this->entries);
81: $nextCursor = $hasMore ? new Cursor(cursor: (string) array_key_last($page)) : null;
82:
83: return $resultBuilder($items, $nextCursor, $this->ttlMs, $this->cacheScope);
84: }
85:
86: private function resolveStartIndex(?Cursor $cursor): int
87: {
88: if (null === $cursor) {
89: return 0;
90: }
91:
92: $raw = $cursor->cursor;
93:
94: if (! isset($this->keyIndex[$raw])) {
95: throw new InvalidCursorException($raw);
96: }
97:
98: return $this->keyIndex[$raw] + 1;
99: }
100: }
101: