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
This commit is contained in:
299
src/Framework/Ksuid/Ksuid.php
Normal file
299
src/Framework/Ksuid/Ksuid.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user