Files
michaelschiemer/src/Framework/Ksuid/Ksuid.php
Michael Schiemer 55a330b223 Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
2025-08-11 20:13:26 +02:00

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);
}
}