Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
SyncCursor
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
8 / 8
20
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 since
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 after
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fromToken
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
12
 token
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 format
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 utc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filter
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Api;
6
7use Celema\Core\Exception\HttpBadRequest;
8use DateTimeImmutable;
9use DateTimeZone;
10use JsonException;
11
12/**
13 * A position in the change order, which runs by change time, to the
14 * microsecond, and then by uid. A client goes on from the token it got, so
15 * nodes changed at the same time are neither skipped nor repeated.
16 */
17final readonly class SyncCursor
18{
19    private const string FORMAT = 'Y-m-d\\TH:i:s.u\\Z';
20
21    private function __construct(
22        public string $changed,
23        public ?string $uid,
24    ) {}
25
26    /** Before the nodes changed after this time. */
27    public static function since(string $time): self
28    {
29        return new self(self::format($time), null);
30    }
31
32    /** After the node changed at this time, as the database gives it, with this uid. */
33    public static function after(string $changed, string $uid): self
34    {
35        return new self(self::format($changed), $uid);
36    }
37
38    public static function fromToken(mixed $cursor): self
39    {
40        $invalid = new HttpBadRequest(payload: ['message' => '`cursor` is the token of an earlier sync response.']);
41        $json = is_string($cursor) ? base64_decode(strtr($cursor, '-_', '+/'), true) : false;
42
43        try {
44            $position = $json === false ? null : json_decode($json, true, 2, JSON_THROW_ON_ERROR);
45        } catch (JsonException) {
46            throw $invalid;
47        }
48
49        if (!is_array($position) || !array_is_list($position) || count($position) !== 2) {
50            throw $invalid;
51        }
52
53        [$changed, $uid] = $position;
54
55        $time = is_string($changed) ? DateTimeImmutable::createFromFormat(self::FORMAT, $changed, self::utc()) : false;
56
57        if ($time === false || $time->format(self::FORMAT) !== $changed || !is_string($uid) && $uid !== null) {
58            throw $invalid;
59        }
60
61        return new self($changed, $uid);
62    }
63
64    public function token(): string
65    {
66        return rtrim(
67            strtr(base64_encode(json_encode([$this->changed, $this->uid], JSON_THROW_ON_ERROR)), '+/', '-_'),
68            '=',
69        );
70    }
71
72    private static function format(string $time): string
73    {
74        return new DateTimeImmutable($time)
75            ->setTimezone(self::utc())
76            ->format(self::FORMAT);
77    }
78
79    private static function utc(): DateTimeZone
80    {
81        return new DateTimeZone('UTC');
82    }
83
84    /** @return array<string, mixed> a filter for the nodes after this position */
85    public function filter(): array
86    {
87        if ($this->uid === null) {
88            return ['changed' => ['gt' => $this->changed]];
89        }
90
91        return [
92            'or' => [
93                ['changed' => ['gt' => $this->changed]],
94                ['changed' => $this->changed, 'uid' => ['gt' => $this->uid]],
95            ],
96        ];
97    }
98}