- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
300 lines
7.7 KiB
PHP
300 lines
7.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Ksuid;
|
|
|
|
use BcMath\Number;
|
|
use DateTimeImmutable;
|
|
use InvalidArgumentException;
|
|
|
|
/**
|
|
* KSUID Value Object
|
|
*
|
|
* K-Sortable Unique Identifier
|
|
* - 160-bit (27 characters Base62)
|
|
* - 32-bit timestamp + 128-bit random payload
|
|
* - Lexicographically sortable by creation time
|
|
* - URL-safe, case-sensitive
|
|
*/
|
|
final readonly class Ksuid
|
|
{
|
|
public const int ENCODED_LENGTH = 27;
|
|
public const int TIMESTAMP_BYTES = 4;
|
|
public const int PAYLOAD_BYTES = 16;
|
|
public const int TOTAL_BYTES = self::TIMESTAMP_BYTES + self::PAYLOAD_BYTES; // 20
|
|
|
|
// KSUID epoch: 2014-05-13T16:53:20Z (Unix timestamp 1400000000)
|
|
public const int EPOCH = 1400000000;
|
|
|
|
// Base62 alphabet: 0-9, A-Z, a-z
|
|
public const string ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
|
|
private string $value;
|
|
|
|
private int $timestamp;
|
|
|
|
private string $payload;
|
|
|
|
public function __construct(string $value)
|
|
{
|
|
if (empty($value)) {
|
|
throw new InvalidArgumentException('KSUID cannot be empty');
|
|
}
|
|
|
|
if (strlen($value) !== self::ENCODED_LENGTH) {
|
|
throw new InvalidArgumentException('KSUID must be exactly ' . self::ENCODED_LENGTH . ' characters long');
|
|
}
|
|
|
|
if (! $this->isValidBase62($value)) {
|
|
throw new InvalidArgumentException('KSUID contains invalid characters');
|
|
}
|
|
|
|
$this->value = $value;
|
|
$this->parseComponents();
|
|
}
|
|
|
|
/**
|
|
* Create from raw bytes
|
|
*/
|
|
public static function fromBytes(string $bytes): self
|
|
{
|
|
if (strlen($bytes) !== self::TOTAL_BYTES) {
|
|
throw new InvalidArgumentException('KSUID bytes must be exactly ' . self::TOTAL_BYTES . ' bytes');
|
|
}
|
|
|
|
return new self(self::encodeBase62($bytes));
|
|
}
|
|
|
|
/**
|
|
* Create from string
|
|
*/
|
|
public static function fromString(string $value): self
|
|
{
|
|
return new self($value);
|
|
}
|
|
|
|
/**
|
|
* Create from timestamp and payload
|
|
*/
|
|
public static function fromTimestampAndPayload(int $timestamp, string $payload): self
|
|
{
|
|
if (strlen($payload) !== self::PAYLOAD_BYTES) {
|
|
throw new InvalidArgumentException('Payload must be exactly ' . self::PAYLOAD_BYTES . ' bytes');
|
|
}
|
|
|
|
// Convert to KSUID timestamp (subtract epoch)
|
|
$ksuidTimestamp = $timestamp - self::EPOCH;
|
|
if ($ksuidTimestamp < 0) {
|
|
throw new InvalidArgumentException('Timestamp cannot be before KSUID epoch');
|
|
}
|
|
|
|
if ($ksuidTimestamp > 0xFFFFFFFF) {
|
|
throw new InvalidArgumentException('Timestamp overflow');
|
|
}
|
|
|
|
// Pack timestamp as big-endian 32-bit unsigned integer
|
|
$timestampBytes = pack('N', $ksuidTimestamp);
|
|
$bytes = $timestampBytes . $payload;
|
|
|
|
return self::fromBytes($bytes);
|
|
}
|
|
|
|
/**
|
|
* Get the string representation
|
|
*/
|
|
public function toString(): string
|
|
{
|
|
return $this->value;
|
|
}
|
|
|
|
/**
|
|
* Get the string value
|
|
*/
|
|
public function getValue(): string
|
|
{
|
|
return $this->value;
|
|
}
|
|
|
|
/**
|
|
* Magic method for string conversion
|
|
*/
|
|
public function __toString(): string
|
|
{
|
|
return $this->value;
|
|
}
|
|
|
|
/**
|
|
* Get the timestamp component (Unix timestamp)
|
|
*/
|
|
public function getTimestamp(): int
|
|
{
|
|
return $this->timestamp;
|
|
}
|
|
|
|
/**
|
|
* Get the timestamp as DateTime
|
|
*/
|
|
public function getDateTime(): DateTimeImmutable
|
|
{
|
|
return DateTimeImmutable::createFromFormat('U', (string)$this->timestamp);
|
|
}
|
|
|
|
/**
|
|
* Get the raw payload bytes
|
|
*/
|
|
public function getPayload(): string
|
|
{
|
|
return $this->payload;
|
|
}
|
|
|
|
/**
|
|
* Get raw bytes representation
|
|
*/
|
|
public function getBytes(): string
|
|
{
|
|
return self::decodeBase62($this->value);
|
|
}
|
|
|
|
/**
|
|
* Check equality with another KSUID
|
|
*/
|
|
public function equals(self $other): bool
|
|
{
|
|
return $this->value === $other->value;
|
|
}
|
|
|
|
/**
|
|
* Compare with another KSUID for sorting
|
|
*/
|
|
public function compare(self $other): int
|
|
{
|
|
return strcmp($this->value, $other->value);
|
|
}
|
|
|
|
/**
|
|
* Check if this KSUID is older than another
|
|
*/
|
|
public function isOlderThan(self $other): bool
|
|
{
|
|
return $this->timestamp < $other->timestamp;
|
|
}
|
|
|
|
/**
|
|
* Check if this KSUID is newer than another
|
|
*/
|
|
public function isNewerThan(self $other): bool
|
|
{
|
|
return $this->timestamp > $other->timestamp;
|
|
}
|
|
|
|
/**
|
|
* Get age in seconds
|
|
*/
|
|
public function getAgeInSeconds(): int
|
|
{
|
|
return time() - $this->timestamp;
|
|
}
|
|
|
|
/**
|
|
* Validate Base62 characters
|
|
*/
|
|
private function isValidBase62(string $value): bool
|
|
{
|
|
$pattern = '/^[' . preg_quote(self::ALPHABET, '/') . ']+$/';
|
|
|
|
return preg_match($pattern, $value) === 1;
|
|
}
|
|
|
|
/**
|
|
* Parse timestamp and payload components from the value
|
|
*/
|
|
private function parseComponents(): void
|
|
{
|
|
$bytes = self::decodeBase62($this->value);
|
|
|
|
// Extract timestamp (first 4 bytes)
|
|
$timestampBytes = substr($bytes, 0, self::TIMESTAMP_BYTES);
|
|
$ksuidTimestamp = unpack('N', $timestampBytes)[1];
|
|
$this->timestamp = $ksuidTimestamp + self::EPOCH;
|
|
|
|
// Extract payload (remaining 16 bytes)
|
|
$this->payload = substr($bytes, self::TIMESTAMP_BYTES);
|
|
}
|
|
|
|
/**
|
|
* Encode bytes to Base62
|
|
*/
|
|
private static function encodeBase62(string $bytes): string
|
|
{
|
|
// Convert bytes to big integer using BcMath\Number
|
|
$num = new Number(0);
|
|
$base = new Number(1);
|
|
$base256 = new Number(256);
|
|
|
|
// Process each byte from right to left
|
|
for ($i = strlen($bytes) - 1; $i >= 0; $i--) {
|
|
$byte = new Number(ord($bytes[$i]));
|
|
$num = $num->add($byte->mul($base));
|
|
$base = $base->mul($base256);
|
|
}
|
|
|
|
// Convert to Base62
|
|
$alphabet = self::ALPHABET;
|
|
$encoded = '';
|
|
$base62 = new Number(62);
|
|
$zero = new Number(0);
|
|
|
|
if ($num->compare($zero) === 0) {
|
|
return str_repeat($alphabet[0], self::ENCODED_LENGTH);
|
|
}
|
|
|
|
while ($num->compare($zero) > 0) {
|
|
[$quotient, $remainder] = $num->divmod($base62);
|
|
$encoded = $alphabet[(int)$remainder->__toString()] . $encoded;
|
|
$num = $quotient;
|
|
}
|
|
|
|
// Pad with leading zeros if necessary
|
|
return str_pad($encoded, self::ENCODED_LENGTH, $alphabet[0], STR_PAD_LEFT);
|
|
}
|
|
|
|
/**
|
|
* Decode Base62 to bytes
|
|
*/
|
|
private static function decodeBase62(string $encoded): string
|
|
{
|
|
$alphabet = self::ALPHABET;
|
|
$base62 = new Number(62);
|
|
$num = new Number(0);
|
|
$multiplier = new Number(1);
|
|
|
|
// Convert from Base62 to big integer
|
|
for ($i = strlen($encoded) - 1; $i >= 0; $i--) {
|
|
$char = $encoded[$i];
|
|
$value = strpos($alphabet, $char);
|
|
|
|
if ($value === false) {
|
|
throw new InvalidArgumentException('Invalid Base62 character: ' . $char);
|
|
}
|
|
|
|
$num = $num->add(new Number($value)->mul($multiplier));
|
|
$multiplier = $multiplier->mul($base62);
|
|
}
|
|
|
|
// Convert big integer to bytes
|
|
$bytes = '';
|
|
$base256 = new Number(256);
|
|
$zero = new Number(0);
|
|
|
|
while ($num->compare($zero) > 0) {
|
|
[$quotient, $remainder] = $num->divmod($base256);
|
|
$bytes = chr((int)$remainder->__toString()) . $bytes;
|
|
$num = $quotient;
|
|
}
|
|
|
|
// Pad with leading zero bytes if necessary
|
|
return str_pad($bytes, self::TOTAL_BYTES, "\0", STR_PAD_LEFT);
|
|
}
|
|
}
|