A barebones implementation of an atproto PDS in PHP and Slim Framework 4.
pds php atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: migrate in-memory data repositories to persistent sql handlers

Andrés Ignacio Torres (May 18, 2026, 7:42 PM -0700) 1373f6b8 caacbdef

+3639 -2900
+8
app/constants.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + // Named container keys for the per-DB Database singletons. 6 + const DB_ACCOUNT = 'db.account'; 7 + const DB_SEQUENCER = 'db.sequencer'; 8 + const DB_DID_CACHE = 'db.didCache';
+115 -40
app/repositories.php
··· 26 26 use App\Domain\Repo\RepoRootRepository; 27 27 use App\Domain\Sequencer\SequencerRepository; 28 28 use App\Infrastructure\Atproto\AppView\GuzzleAppViewClient; 29 - use App\Infrastructure\Persistence\Account\AppPassword\InMemoryAppPasswordRepository; 30 - use App\Infrastructure\Persistence\Account\EmailToken\InMemoryEmailTokenRepository; 31 - use App\Infrastructure\Persistence\Account\InMemoryAccountRepository; 32 - use App\Infrastructure\Persistence\Account\InviteCode\InMemoryInviteCodeRepository; 33 - use App\Infrastructure\Persistence\Account\RefreshToken\InMemoryRefreshTokenRepository; 34 - use App\Infrastructure\Persistence\Actor\InMemoryActorRepository; 35 - use App\Infrastructure\Persistence\ActorStore\InMemoryActorStoreFactory; 36 - use App\Infrastructure\Persistence\Did\InMemoryDidCacheRepository; 37 - use App\Infrastructure\Persistence\Lexicon\InMemoryLexiconRepository; 38 - use App\Infrastructure\Persistence\OAuth\InMemoryAccountDeviceRepository; 39 - use App\Infrastructure\Persistence\OAuth\InMemoryAuthorizationRequestRepository; 40 - use App\Infrastructure\Persistence\OAuth\InMemoryAuthorizedClientRepository; 41 - use App\Infrastructure\Persistence\OAuth\InMemoryDeviceRepository; 42 - use App\Infrastructure\Persistence\OAuth\InMemoryOAuthTokenRepository; 43 - use App\Infrastructure\Persistence\OAuth\InMemoryUsedRefreshTokenRepository; 44 - use App\Infrastructure\Persistence\Repo\InMemoryRepoRootRepository; 45 - use App\Infrastructure\Persistence\Sequencer\InMemorySequencerRepository; 29 + use App\Infrastructure\Database\Database; 30 + use App\Infrastructure\Database\Schema\AccountSchema; 31 + use App\Infrastructure\Database\Schema\DidCacheSchema; 32 + use App\Infrastructure\Database\Schema\SequencerSchema; 33 + use App\Infrastructure\Persistence\Account\AppPassword\SqliteAppPasswordRepository; 34 + use App\Infrastructure\Persistence\Account\EmailToken\SqliteEmailTokenRepository; 35 + use App\Infrastructure\Persistence\Account\InviteCode\SqliteInviteCodeRepository; 36 + use App\Infrastructure\Persistence\Account\RefreshToken\SqliteRefreshTokenRepository; 37 + use App\Infrastructure\Persistence\Account\SqliteAccountRepository; 38 + use App\Infrastructure\Persistence\Actor\SqliteActorRepository; 39 + use App\Infrastructure\Persistence\ActorStore\SqliteActorStoreFactory; 40 + use App\Infrastructure\Persistence\Did\SqliteDidCacheRepository; 41 + use App\Infrastructure\Persistence\Lexicon\SqliteLexiconRepository; 42 + use App\Infrastructure\Persistence\OAuth\SqliteAccountDeviceRepository; 43 + use App\Infrastructure\Persistence\OAuth\SqliteAuthorizationRequestRepository; 44 + use App\Infrastructure\Persistence\OAuth\SqliteAuthorizedClientRepository; 45 + use App\Infrastructure\Persistence\OAuth\SqliteDeviceRepository; 46 + use App\Infrastructure\Persistence\OAuth\SqliteOAuthTokenRepository; 47 + use App\Infrastructure\Persistence\OAuth\SqliteUsedRefreshTokenRepository; 48 + use App\Infrastructure\Persistence\Repo\SqliteRepoRootRepository; 49 + use App\Infrastructure\Persistence\Sequencer\SqliteSequencerRepository; 46 50 use App\Infrastructure\Repo\NativeCarReader; 47 51 use App\Infrastructure\Repo\NativeCarWriter; 48 52 use App\Infrastructure\Repo\NativeDagCborDecoder; ··· 53 57 54 58 use function DI\autowire; 55 59 56 - return function (ContainerBuilder $containerBuilder) { 60 + require_once __DIR__ . '/constants.php'; 61 + 62 + /** 63 + * @param ContainerInterface $c 64 + * @return array{accountDb:string,sequencerDb:string,didCacheDb:string,actorStoreDir:string,blobstoreDir:string} 65 + */ 66 + $dbSettings = static function (ContainerInterface $c): array { 67 + $settingsService = $c->get(SettingsInterface::class); 68 + assert($settingsService instanceof SettingsInterface); 69 + /** @var array{accountDb:string,sequencerDb:string,didCacheDb:string,actorStoreDir:string,blobstoreDir:string}|null $settings */ 70 + $settings = $settingsService->get('database'); 71 + if (!is_array($settings)) { 72 + throw new \RuntimeException('Database settings are missing.'); 73 + } 74 + return $settings; 75 + }; 76 + 77 + /** 78 + * @param ContainerInterface $c 79 + * @param string $key 80 + * @return Database 81 + */ 82 + $getDb = static function (ContainerInterface $c, string $key): Database { 83 + $db = $c->get($key); 84 + assert($db instanceof Database); 85 + return $db; 86 + }; 87 + 88 + return function (ContainerBuilder $containerBuilder) use ($dbSettings, $getDb) { 57 89 $containerBuilder->addDefinitions([ 58 - AccountDeviceRepository::class => autowire(InMemoryAccountDeviceRepository::class), 59 - AccountRepository::class => autowire(InMemoryAccountRepository::class), 60 - ActorRepository::class => autowire(InMemoryActorRepository::class), 61 - ActorStoreFactory::class => autowire(InMemoryActorStoreFactory::class), 62 - AppPasswordRepository::class => autowire(InMemoryAppPasswordRepository::class), 90 + // Database singletons 91 + DB_ACCOUNT => function (ContainerInterface $c) use ($dbSettings): Database { 92 + $db = new Database($dbSettings($c)['accountDb']); 93 + AccountSchema::apply($db); 94 + return $db; 95 + }, 96 + DB_SEQUENCER => function (ContainerInterface $c) use ($dbSettings): Database { 97 + $db = new Database($dbSettings($c)['sequencerDb']); 98 + SequencerSchema::apply($db); 99 + return $db; 100 + }, 101 + DB_DID_CACHE => function (ContainerInterface $c) use ($dbSettings): Database { 102 + $db = new Database($dbSettings($c)['didCacheDb']); 103 + DidCacheSchema::apply($db); 104 + return $db; 105 + }, 106 + 107 + // Clients and services 63 108 AppViewClient::class => function (ContainerInterface $container): AppViewClient { 64 109 $settings = $container->get(SettingsInterface::class); 65 - $baseUrl = $settings->get('pds')['bskyAppViewUrl'] 110 + assert($settings instanceof SettingsInterface); 111 + /** @var array{bskyAppViewUrl?: string} $pdsSettings */ 112 + $pdsSettings = $settings->get('pds'); 113 + $baseUrl = $pdsSettings['bskyAppViewUrl'] 66 114 ?? throw new \RuntimeException('PDS_BSKY_APP_VIEW_URL is required to use the AppView client.'); 67 115 68 116 $httpClient = new GuzzleClient([ ··· 73 121 74 122 return new GuzzleAppViewClient($httpClient); 75 123 }, 76 - AuthorizationRequestRepository::class => autowire(InMemoryAuthorizationRequestRepository::class), 77 - AuthorizedClientRepository::class => autowire(InMemoryAuthorizedClientRepository::class), 78 - CarReader::class => autowire(NativeCarReader::class), 79 - CarWriter::class => autowire(NativeCarWriter::class), 80 - DagCborDecoder::class => autowire(NativeDagCborDecoder::class), 81 - DagCborEncoder::class => autowire(NativeDagCborEncoder::class), 82 - DeviceRepository::class => autowire(InMemoryDeviceRepository::class), 83 - DidCacheRepository::class => autowire(InMemoryDidCacheRepository::class), 84 - EmailTokenRepository::class => autowire(InMemoryEmailTokenRepository::class), 85 - InviteCodeRepository::class => autowire(InMemoryInviteCodeRepository::class), 86 - LexiconRepository::class => autowire(InMemoryLexiconRepository::class), 87 - OAuthTokenRepository::class => autowire(InMemoryOAuthTokenRepository::class), 88 - RefreshTokenRepository::class => autowire(InMemoryRefreshTokenRepository::class), 89 - RepoRootRepository::class => autowire(InMemoryRepoRootRepository::class), 90 - SequencerRepository::class => autowire(InMemorySequencerRepository::class), 91 - UsedRefreshTokenRepository::class => autowire(InMemoryUsedRefreshTokenRepository::class), 124 + AccountRepository::class => fn (ContainerInterface $c): SqliteAccountRepository => 125 + new SqliteAccountRepository($getDb($c, DB_ACCOUNT)), 126 + AccountDeviceRepository::class => fn (ContainerInterface $c): SqliteAccountDeviceRepository => 127 + new SqliteAccountDeviceRepository($getDb($c, DB_ACCOUNT)), 128 + ActorRepository::class => fn (ContainerInterface $c): SqliteActorRepository => 129 + new SqliteActorRepository($getDb($c, DB_ACCOUNT)), 130 + ActorStoreFactory::class => function (ContainerInterface $c) use ($dbSettings): SqliteActorStoreFactory { 131 + $settings = $dbSettings($c); 132 + return new SqliteActorStoreFactory( 133 + actorsDirectory: $settings['actorStoreDir'], 134 + blobsDirectory: $settings['blobstoreDir'], 135 + ); 136 + }, 137 + AuthorizationRequestRepository::class => fn (ContainerInterface $c): SqliteAuthorizationRequestRepository => 138 + new SqliteAuthorizationRequestRepository($getDb($c, DB_ACCOUNT)), 139 + AuthorizedClientRepository::class => fn (ContainerInterface $c): SqliteAuthorizedClientRepository => 140 + new SqliteAuthorizedClientRepository($getDb($c, DB_ACCOUNT)), 141 + CarReader::class => autowire(NativeCarReader::class), 142 + CarWriter::class => autowire(NativeCarWriter::class), 143 + DagCborDecoder::class => autowire(NativeDagCborDecoder::class), 144 + DagCborEncoder::class => autowire(NativeDagCborEncoder::class), 145 + DeviceRepository::class => fn (ContainerInterface $c): SqliteDeviceRepository => 146 + new SqliteDeviceRepository($getDb($c, DB_ACCOUNT)), 147 + DidCacheRepository::class => fn (ContainerInterface $c): SqliteDidCacheRepository => 148 + new SqliteDidCacheRepository($getDb($c, DB_DID_CACHE)), 149 + AppPasswordRepository::class => fn (ContainerInterface $c): SqliteAppPasswordRepository => 150 + new SqliteAppPasswordRepository($getDb($c, DB_ACCOUNT)), 151 + EmailTokenRepository::class => fn (ContainerInterface $c): SqliteEmailTokenRepository => 152 + new SqliteEmailTokenRepository($getDb($c, DB_ACCOUNT)), 153 + InviteCodeRepository::class => fn (ContainerInterface $c): SqliteInviteCodeRepository => 154 + new SqliteInviteCodeRepository($getDb($c, DB_ACCOUNT)), 155 + LexiconRepository::class => fn (ContainerInterface $c): SqliteLexiconRepository => 156 + new SqliteLexiconRepository($getDb($c, DB_ACCOUNT)), 157 + OAuthTokenRepository::class => fn (ContainerInterface $c): SqliteOAuthTokenRepository => 158 + new SqliteOAuthTokenRepository($getDb($c, DB_ACCOUNT)), 159 + RefreshTokenRepository::class => fn (ContainerInterface $c): SqliteRefreshTokenRepository => 160 + new SqliteRefreshTokenRepository($getDb($c, DB_ACCOUNT)), 161 + RepoRootRepository::class => fn (ContainerInterface $c): SqliteRepoRootRepository => 162 + new SqliteRepoRootRepository($getDb($c, DB_ACCOUNT)), 163 + SequencerRepository::class => fn (ContainerInterface $c): SqliteSequencerRepository => 164 + new SqliteSequencerRepository($getDb($c, DB_SEQUENCER)), 165 + UsedRefreshTokenRepository::class => fn (ContainerInterface $c): SqliteUsedRefreshTokenRepository => 166 + new SqliteUsedRefreshTokenRepository($getDb($c, DB_ACCOUNT)), 92 167 ]); 93 168 };
+13 -3
public/index.php
··· 13 13 use Slim\Factory\ServerRequestCreatorFactory; 14 14 15 15 require __DIR__ . '/../vendor/autoload.php'; 16 + require_once __DIR__ . '/../app/constants.php'; 16 17 17 18 // Loading environment variables from .env file, if it exists 18 19 $envPath = __DIR__ . '/../.env'; 19 20 if (is_file($envPath)) { 20 - $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../'); 21 - $dotenv->safeLoad(); 21 + $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../'); 22 + $dotenv->safeLoad(); 22 23 } 23 24 24 25 // Instantiate PHP-DI ContainerBuilder 25 26 $containerBuilder = new ContainerBuilder(); 26 27 28 + // @phpstan-ignore if.alwaysFalse (toggle for production) 27 29 if (false) { // Should be set to true in production 28 - $containerBuilder->enableCompilation(__DIR__ . '/../var/cache'); 30 + $containerBuilder->enableCompilation(__DIR__ . '/../var/cache'); 29 31 } 30 32 31 33 // Set up settings 34 + /** @var callable(\DI\ContainerBuilder<\DI\Container>): void $settings */ 32 35 $settings = require __DIR__ . '/../app/settings.php'; 33 36 $settings($containerBuilder); 34 37 35 38 // Set up dependencies 39 + /** @var callable(\DI\ContainerBuilder<\DI\Container>): void $dependencies */ 36 40 $dependencies = require __DIR__ . '/../app/dependencies.php'; 37 41 $dependencies($containerBuilder); 38 42 39 43 // Set up repositories 44 + /** @var callable(\DI\ContainerBuilder<\DI\Container>): void $repositories */ 40 45 $repositories = require __DIR__ . '/../app/repositories.php'; 41 46 $repositories($containerBuilder); 42 47 ··· 49 54 $callableResolver = $app->getCallableResolver(); 50 55 51 56 // Register middleware 57 + /** @var callable(\Slim\App<\Psr\Container\ContainerInterface|null>): void $middleware */ 52 58 $middleware = require __DIR__ . '/../app/middleware.php'; 53 59 $middleware($app); 54 60 55 61 // Register routes 62 + /** @var callable(\Slim\App<\Psr\Container\ContainerInterface|null>): void $routes */ 56 63 $routes = require __DIR__ . '/../app/routes.php'; 57 64 $routes($app); 58 65 59 66 /** @var SettingsInterface $settings */ 60 67 $settings = $container->get(SettingsInterface::class); 61 68 69 + /** @var bool $displayErrorDetails */ 62 70 $displayErrorDetails = $settings->get('displayErrorDetails'); 71 + /** @var bool $logError */ 63 72 $logError = $settings->get('logError'); 73 + /** @var bool $logErrorDetails */ 64 74 $logErrorDetails = $settings->get('logErrorDetails'); 65 75 66 76 // Create Request object from globals
-56
src/Infrastructure/Persistence/Account/AppPassword/InMemoryAppPasswordRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Account\AppPassword; 6 - 7 - use App\Domain\Account\AppPassword\AppPassword; 8 - use App\Domain\Account\AppPassword\AppPasswordNotFoundException; 9 - use App\Domain\Account\AppPassword\AppPasswordRepository; 10 - 11 - class InMemoryAppPasswordRepository implements AppPasswordRepository 12 - { 13 - /** 14 - * did => name => AppPassword. 15 - * 16 - * @var array<string, array<string, AppPassword>> 17 - */ 18 - private array $passwords = []; 19 - 20 - /** 21 - * @param AppPassword[] $seeds 22 - */ 23 - public function __construct(array $seeds = []) 24 - { 25 - foreach ($seeds as $p) { 26 - $this->passwords[$p->getDid()][$p->getName()] = $p; 27 - } 28 - } 29 - 30 - /** 31 - * @return AppPassword[] 32 - */ 33 - public function findAllForDid(string $did): array 34 - { 35 - return array_values($this->passwords[$did] ?? []); 36 - } 37 - 38 - public function findByDidAndName(string $did, string $name): AppPassword 39 - { 40 - if (!isset($this->passwords[$did][$name])) { 41 - throw new AppPasswordNotFoundException(); 42 - } 43 - 44 - return $this->passwords[$did][$name]; 45 - } 46 - 47 - public function save(AppPassword $appPassword): void 48 - { 49 - $this->passwords[$appPassword->getDid()][$appPassword->getName()] = $appPassword; 50 - } 51 - 52 - public function deleteByDidAndName(string $did, string $name): void 53 - { 54 - unset($this->passwords[$did][$name]); 55 - } 56 - }
+91
src/Infrastructure/Persistence/Account/AppPassword/SqliteAppPasswordRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Account\AppPassword; 6 + 7 + use App\Domain\Account\AppPassword\AppPassword; 8 + use App\Domain\Account\AppPassword\AppPasswordNotFoundException; 9 + use App\Domain\Account\AppPassword\AppPasswordRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteAppPasswordRepository implements AppPasswordRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + /** 21 + * @return AppPassword[] 22 + */ 23 + public function findAllForDid(string $did): array 24 + { 25 + $rows = $this->db->fetchAll( 26 + 'SELECT * FROM app_password WHERE did = ? ORDER BY name', 27 + [$did] 28 + ); 29 + 30 + $result = []; 31 + foreach ($rows as $row) { 32 + $result[] = $this->hydrate($row); 33 + } 34 + return $result; 35 + } 36 + 37 + public function findByDidAndName(string $did, string $name): AppPassword 38 + { 39 + $row = $this->db->fetchOne( 40 + 'SELECT * FROM app_password WHERE did = ? AND name = ?', 41 + [$did, $name] 42 + ); 43 + 44 + if ($row === null) { 45 + throw new AppPasswordNotFoundException(); 46 + } 47 + 48 + return $this->hydrate($row); 49 + } 50 + 51 + public function save(AppPassword $appPassword): void 52 + { 53 + $this->db->execute( 54 + 'INSERT INTO app_password (did, name, password_scrypt, created_at, privileged) 55 + VALUES (:did, :name, :password_scrypt, :created_at, :privileged) 56 + ON CONFLICT(did, name) DO UPDATE SET 57 + password_scrypt = excluded.password_scrypt, 58 + created_at = excluded.created_at, 59 + privileged = excluded.privileged', 60 + [ 61 + 'did' => $appPassword->getDid(), 62 + 'name' => $appPassword->getName(), 63 + 'password_scrypt' => $appPassword->getPasswordScrypt(), 64 + 'created_at' => $appPassword->getCreatedAt()->format(DATE_ATOM), 65 + 'privileged' => $appPassword->isPrivileged() ? 1 : 0, 66 + ] 67 + ); 68 + } 69 + 70 + public function deleteByDidAndName(string $did, string $name): void 71 + { 72 + $this->db->execute( 73 + 'DELETE FROM app_password WHERE did = ? AND name = ?', 74 + [$did, $name] 75 + ); 76 + } 77 + 78 + /** 79 + * @param array<string, mixed> $row 80 + */ 81 + private function hydrate(array $row): AppPassword 82 + { 83 + return new AppPassword( 84 + did: Row::str($row, 'did'), 85 + name: Row::str($row, 'name'), 86 + passwordScrypt: Row::str($row, 'password_scrypt'), 87 + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), 88 + privileged: Row::bool($row, 'privileged'), 89 + ); 90 + } 91 + }
-60
src/Infrastructure/Persistence/Account/EmailToken/InMemoryEmailTokenRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Account\EmailToken; 6 - 7 - use App\Domain\Account\EmailToken\EmailToken; 8 - use App\Domain\Account\EmailToken\EmailTokenNotFoundException; 9 - use App\Domain\Account\EmailToken\EmailTokenRepository; 10 - 11 - class InMemoryEmailTokenRepository implements EmailTokenRepository 12 - { 13 - /** 14 - * Two-level map: purpose => did => EmailToken. 15 - * (Only one active token per purpose+did at a time, matching reference behavior.) 16 - * 17 - * @var array<string, array<string, EmailToken>> 18 - */ 19 - private array $tokens = []; 20 - 21 - /** 22 - * @param EmailToken[] $seeds 23 - */ 24 - public function __construct(array $seeds = []) 25 - { 26 - foreach ($seeds as $token) { 27 - $this->tokens[$token->getPurpose()][$token->getDid()] = $token; 28 - } 29 - } 30 - 31 - public function findByPurposeAndDid(string $purpose, string $did): EmailToken 32 - { 33 - if (!isset($this->tokens[$purpose][$did])) { 34 - throw new EmailTokenNotFoundException(); 35 - } 36 - 37 - return $this->tokens[$purpose][$did]; 38 - } 39 - 40 - public function findByPurposeAndToken(string $purpose, string $token): EmailToken 41 - { 42 - foreach ($this->tokens[$purpose] ?? [] as $entry) { 43 - if ($entry->getToken() === $token) { 44 - return $entry; 45 - } 46 - } 47 - 48 - throw new EmailTokenNotFoundException(); 49 - } 50 - 51 - public function save(EmailToken $token): void 52 - { 53 - $this->tokens[$token->getPurpose()][$token->getDid()] = $token; 54 - } 55 - 56 - public function deleteByPurposeAndDid(string $purpose, string $did): void 57 - { 58 - unset($this->tokens[$purpose][$did]); 59 - } 60 - }
+85
src/Infrastructure/Persistence/Account/EmailToken/SqliteEmailTokenRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Account\EmailToken; 6 + 7 + use App\Domain\Account\EmailToken\EmailToken; 8 + use App\Domain\Account\EmailToken\EmailTokenNotFoundException; 9 + use App\Domain\Account\EmailToken\EmailTokenRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteEmailTokenRepository implements EmailTokenRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function findByPurposeAndDid(string $purpose, string $did): EmailToken 21 + { 22 + $row = $this->db->fetchOne( 23 + 'SELECT * FROM email_token WHERE purpose = ? AND did = ?', 24 + [$purpose, $did] 25 + ); 26 + 27 + if ($row === null) { 28 + throw new EmailTokenNotFoundException(); 29 + } 30 + 31 + return $this->hydrate($row); 32 + } 33 + 34 + public function findByPurposeAndToken(string $purpose, string $token): EmailToken 35 + { 36 + $row = $this->db->fetchOne( 37 + 'SELECT * FROM email_token WHERE purpose = ? AND token = ?', 38 + [$purpose, $token] 39 + ); 40 + 41 + if ($row === null) { 42 + throw new EmailTokenNotFoundException(); 43 + } 44 + 45 + return $this->hydrate($row); 46 + } 47 + 48 + public function save(EmailToken $token): void 49 + { 50 + $this->db->execute( 51 + 'INSERT INTO email_token (purpose, did, token, requested_at) 52 + VALUES (:purpose, :did, :token, :requested_at) 53 + ON CONFLICT(purpose, did) DO UPDATE SET 54 + token = excluded.token, 55 + requested_at = excluded.requested_at', 56 + [ 57 + 'purpose' => $token->getPurpose(), 58 + 'did' => $token->getDid(), 59 + 'token' => $token->getToken(), 60 + 'requested_at' => $token->getRequestedAt()->format(DATE_ATOM), 61 + ] 62 + ); 63 + } 64 + 65 + public function deleteByPurposeAndDid(string $purpose, string $did): void 66 + { 67 + $this->db->execute( 68 + 'DELETE FROM email_token WHERE purpose = ? AND did = ?', 69 + [$purpose, $did] 70 + ); 71 + } 72 + 73 + /** 74 + * @param array<string, mixed> $row 75 + */ 76 + private function hydrate(array $row): EmailToken 77 + { 78 + return new EmailToken( 79 + purpose: Row::str($row, 'purpose'), 80 + did: Row::str($row, 'did'), 81 + token: Row::str($row, 'token'), 82 + requestedAt: new DateTimeImmutable(Row::str($row, 'requested_at')), 83 + ); 84 + } 85 + }
-109
src/Infrastructure/Persistence/Account/InMemoryAccountRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Account; 6 - 7 - use App\Application\Settings\SettingsInterface; 8 - use App\Domain\Account\Account; 9 - use App\Domain\Account\AccountNotFoundException; 10 - use App\Domain\Account\AccountRepository; 11 - 12 - class InMemoryAccountRepository implements AccountRepository 13 - { 14 - /** 15 - * @var array<string, Account> 16 - */ 17 - private array $accountsByDid; 18 - 19 - /** 20 - * @param Account[]|null $accounts 21 - */ 22 - public function __construct(?SettingsInterface $settings = null, ?array $accounts = null) 23 - { 24 - if ($accounts === null) { 25 - /** @var array{hostname: string}|null $pdsSettings */ 26 - $pdsSettings = $settings?->get('pds'); 27 - $hostname = $pdsSettings['hostname'] ?? 'localhost'; 28 - $accounts = [ 29 - new Account( 30 - did: "did:web:alice.{$hostname}", 31 - email: "alice@{$hostname}", 32 - passwordScrypt: 'placeholder-scrypt-hash', 33 - emailConfirmedAt: new \DateTimeImmutable('2026-01-01T00:00:00Z'), 34 - invitesDisabled: false, 35 - ), 36 - new Account( 37 - did: "did:web:bob.{$hostname}", 38 - email: "bob@{$hostname}", 39 - passwordScrypt: 'placeholder-scrypt-hash', 40 - emailConfirmedAt: null, 41 - invitesDisabled: false, 42 - ), 43 - new Account( 44 - did: 'did:plc:carol000000000000000000000', 45 - email: "carol@{$hostname}", 46 - passwordScrypt: 'placeholder-scrypt-hash', 47 - emailConfirmedAt: new \DateTimeImmutable('2026-01-03T00:00:00Z'), 48 - invitesDisabled: true, 49 - ), 50 - ]; 51 - } 52 - 53 - $this->accountsByDid = []; 54 - foreach ($accounts as $account) { 55 - $this->accountsByDid[$account->getDid()] = $account; 56 - } 57 - } 58 - 59 - /** 60 - * {@inheritdoc} 61 - */ 62 - public function findAll(): array 63 - { 64 - return array_values($this->accountsByDid); 65 - } 66 - 67 - /** 68 - * {@inheritDoc} 69 - */ 70 - public function findAccountByHandle(string $handle): Account 71 - { 72 - // Not implemented yet 73 - throw new AccountNotFoundException(); 74 - } 75 - 76 - /** 77 - * {@inheritdoc} 78 - */ 79 - public function findAccountByDid(string $did): Account 80 - { 81 - $key = trim($did); 82 - 83 - if (!isset($this->accountsByDid[$key])) { 84 - throw new AccountNotFoundException(); 85 - } 86 - 87 - return $this->accountsByDid[$key]; 88 - } 89 - 90 - /** 91 - * {@inheritdoc} 92 - */ 93 - public function findAccountByEmail(string $email): Account 94 - { 95 - $needle = strtolower(trim($email)); 96 - 97 - if ($needle === '') { 98 - throw new AccountNotFoundException(); 99 - } 100 - 101 - foreach ($this->accountsByDid as $account) { 102 - if ($account->getEmail() === $needle) { 103 - return $account; 104 - } 105 - } 106 - 107 - throw new AccountNotFoundException(); 108 - } 109 - }
-98
src/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Account\InviteCode; 6 - 7 - use App\Domain\Account\InviteCode\InviteCode; 8 - use App\Domain\Account\InviteCode\InviteCodeNotFoundException; 9 - use App\Domain\Account\InviteCode\InviteCodeRepository; 10 - use App\Domain\Account\InviteCode\InviteCodeUse; 11 - 12 - class InMemoryInviteCodeRepository implements InviteCodeRepository 13 - { 14 - /** @var array<string, InviteCode> keyed by code */ 15 - private array $codes = []; 16 - 17 - /** @var array<string, InviteCodeUse[]> keyed by code */ 18 - private array $uses = []; 19 - 20 - /** 21 - * @param InviteCode[] $seeds 22 - * @param InviteCodeUse[] $useSeeds 23 - */ 24 - public function __construct(array $seeds = [], array $useSeeds = []) 25 - { 26 - foreach ($seeds as $code) { 27 - $this->codes[$code->getCode()] = $code; 28 - } 29 - foreach ($useSeeds as $use) { 30 - $this->uses[$use->getCode()][] = $use; 31 - } 32 - } 33 - 34 - /** 35 - * @return InviteCode[] 36 - */ 37 - public function findAll(): array 38 - { 39 - return array_values($this->codes); 40 - } 41 - 42 - public function findByCode(string $code): InviteCode 43 - { 44 - if (!isset($this->codes[$code])) { 45 - throw new InviteCodeNotFoundException(); 46 - } 47 - 48 - return $this->codes[$code]; 49 - } 50 - 51 - /** 52 - * @return InviteCode[] 53 - */ 54 - public function findAllForAccount(string $did): array 55 - { 56 - return array_values( 57 - array_filter( 58 - $this->codes, 59 - fn(InviteCode $c) => $c->getForAccount() === $did, 60 - ) 61 - ); 62 - } 63 - 64 - public function save(InviteCode $inviteCode): void 65 - { 66 - $this->codes[$inviteCode->getCode()] = $inviteCode; 67 - } 68 - 69 - public function disable(string $code): void 70 - { 71 - if (!isset($this->codes[$code])) { 72 - throw new InviteCodeNotFoundException(); 73 - } 74 - 75 - $old = $this->codes[$code]; 76 - $this->codes[$code] = new InviteCode( 77 - code: $old->getCode(), 78 - availableUses: $old->getAvailableUses(), 79 - disabled: true, 80 - forAccount: $old->getForAccount(), 81 - createdBy: $old->getCreatedBy(), 82 - createdAt: $old->getCreatedAt(), 83 - ); 84 - } 85 - 86 - /** 87 - * @return InviteCodeUse[] 88 - */ 89 - public function findUsesForCode(string $code): array 90 - { 91 - return $this->uses[$code] ?? []; 92 - } 93 - 94 - public function recordUse(InviteCodeUse $use): void 95 - { 96 - $this->uses[$use->getCode()][] = $use; 97 - } 98 - }
+156
src/Infrastructure/Persistence/Account/InviteCode/SqliteInviteCodeRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Account\InviteCode; 6 + 7 + use App\Domain\Account\InviteCode\InviteCode; 8 + use App\Domain\Account\InviteCode\InviteCodeNotFoundException; 9 + use App\Domain\Account\InviteCode\InviteCodeRepository; 10 + use App\Domain\Account\InviteCode\InviteCodeUse; 11 + use App\Infrastructure\Database\Database; 12 + use App\Infrastructure\Database\Row; 13 + use DateTimeImmutable; 14 + 15 + class SqliteInviteCodeRepository implements InviteCodeRepository 16 + { 17 + public function __construct(private readonly Database $db) 18 + { 19 + } 20 + 21 + /** 22 + * @return InviteCode[] 23 + */ 24 + public function findAll(): array 25 + { 26 + $rows = $this->db->fetchAll('SELECT * FROM invite_code ORDER BY code'); 27 + 28 + $result = []; 29 + foreach ($rows as $row) { 30 + $result[] = $this->hydrateCode($row); 31 + } 32 + return $result; 33 + } 34 + 35 + public function findByCode(string $code): InviteCode 36 + { 37 + $row = $this->db->fetchOne('SELECT * FROM invite_code WHERE code = ?', [$code]); 38 + 39 + if ($row === null) { 40 + throw new InviteCodeNotFoundException(); 41 + } 42 + 43 + return $this->hydrateCode($row); 44 + } 45 + 46 + /** 47 + * @return InviteCode[] 48 + */ 49 + public function findAllForAccount(string $did): array 50 + { 51 + $rows = $this->db->fetchAll( 52 + 'SELECT * FROM invite_code WHERE for_account = ? ORDER BY code', 53 + [$did] 54 + ); 55 + 56 + $result = []; 57 + foreach ($rows as $row) { 58 + $result[] = $this->hydrateCode($row); 59 + } 60 + return $result; 61 + } 62 + 63 + public function save(InviteCode $inviteCode): void 64 + { 65 + $this->db->execute( 66 + 'INSERT INTO invite_code 67 + (code, available_uses, disabled, for_account, created_by, created_at) 68 + VALUES 69 + (:code, :available_uses, :disabled, :for_account, :created_by, :created_at) 70 + ON CONFLICT(code) DO UPDATE SET 71 + available_uses = excluded.available_uses, 72 + disabled = excluded.disabled, 73 + for_account = excluded.for_account, 74 + created_by = excluded.created_by, 75 + created_at = excluded.created_at', 76 + [ 77 + 'code' => $inviteCode->getCode(), 78 + 'available_uses' => $inviteCode->getAvailableUses(), 79 + 'disabled' => $inviteCode->isDisabled() ? 1 : 0, 80 + 'for_account' => $inviteCode->getForAccount(), 81 + 'created_by' => $inviteCode->getCreatedBy(), 82 + 'created_at' => $inviteCode->getCreatedAt()->format(DATE_ATOM), 83 + ] 84 + ); 85 + } 86 + 87 + public function disable(string $code): void 88 + { 89 + $exists = $this->db->fetchOne('SELECT 1 FROM invite_code WHERE code = ?', [$code]); 90 + if ($exists === null) { 91 + throw new InviteCodeNotFoundException(); 92 + } 93 + 94 + $this->db->execute( 95 + 'UPDATE invite_code SET disabled = 1 WHERE code = ?', 96 + [$code] 97 + ); 98 + } 99 + 100 + /** 101 + * @return InviteCodeUse[] 102 + */ 103 + public function findUsesForCode(string $code): array 104 + { 105 + $rows = $this->db->fetchAll( 106 + 'SELECT * FROM invite_code_use WHERE code = ? ORDER BY used_at', 107 + [$code] 108 + ); 109 + 110 + $result = []; 111 + foreach ($rows as $row) { 112 + $result[] = $this->hydrateUse($row); 113 + } 114 + return $result; 115 + } 116 + 117 + public function recordUse(InviteCodeUse $use): void 118 + { 119 + $this->db->execute( 120 + 'INSERT OR IGNORE INTO invite_code_use (code, used_by, used_at) 121 + VALUES (?, ?, ?)', 122 + [ 123 + $use->getCode(), 124 + $use->getUsedBy(), 125 + $use->getUsedAt()->format(DATE_ATOM), 126 + ] 127 + ); 128 + } 129 + 130 + /** 131 + * @param array<string, mixed> $row 132 + */ 133 + private function hydrateCode(array $row): InviteCode 134 + { 135 + return new InviteCode( 136 + code: Row::str($row, 'code'), 137 + availableUses: Row::int($row, 'available_uses'), 138 + disabled: Row::bool($row, 'disabled'), 139 + forAccount: Row::str($row, 'for_account'), 140 + createdBy: Row::str($row, 'created_by'), 141 + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), 142 + ); 143 + } 144 + 145 + /** 146 + * @param array<string, mixed> $row 147 + */ 148 + private function hydrateUse(array $row): InviteCodeUse 149 + { 150 + return new InviteCodeUse( 151 + code: Row::str($row, 'code'), 152 + usedBy: Row::str($row, 'used_by'), 153 + usedAt: new DateTimeImmutable(Row::str($row, 'used_at')), 154 + ); 155 + } 156 + }
-65
src/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Account\RefreshToken; 6 - 7 - use App\Domain\Account\RefreshToken\RefreshToken; 8 - use App\Domain\Account\RefreshToken\RefreshTokenNotFoundException; 9 - use App\Domain\Account\RefreshToken\RefreshTokenRepository; 10 - 11 - class InMemoryRefreshTokenRepository implements RefreshTokenRepository 12 - { 13 - /** @var array<string, RefreshToken> keyed by token id */ 14 - private array $tokens = []; 15 - 16 - /** 17 - * @param RefreshToken[] $seeds 18 - */ 19 - public function __construct(array $seeds = []) 20 - { 21 - foreach ($seeds as $token) { 22 - $this->tokens[$token->getId()] = $token; 23 - } 24 - } 25 - 26 - public function findById(string $id): RefreshToken 27 - { 28 - if (!isset($this->tokens[$id])) { 29 - throw new RefreshTokenNotFoundException(); 30 - } 31 - 32 - return $this->tokens[$id]; 33 - } 34 - 35 - /** 36 - * @return RefreshToken[] 37 - */ 38 - public function findAllForDid(string $did): array 39 - { 40 - return array_values( 41 - array_filter( 42 - $this->tokens, 43 - fn(RefreshToken $t) => $t->getDid() === $did, 44 - ) 45 - ); 46 - } 47 - 48 - public function save(RefreshToken $token): void 49 - { 50 - $this->tokens[$token->getId()] = $token; 51 - } 52 - 53 - public function deleteById(string $id): void 54 - { 55 - unset($this->tokens[$id]); 56 - } 57 - 58 - public function deleteAllForDid(string $did): void 59 - { 60 - $this->tokens = array_filter( 61 - $this->tokens, 62 - fn(RefreshToken $t) => $t->getDid() !== $did, 63 - ); 64 - } 65 - }
+90
src/Infrastructure/Persistence/Account/RefreshToken/SqliteRefreshTokenRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Account\RefreshToken; 6 + 7 + use App\Domain\Account\RefreshToken\RefreshToken; 8 + use App\Domain\Account\RefreshToken\RefreshTokenNotFoundException; 9 + use App\Domain\Account\RefreshToken\RefreshTokenRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + 13 + class SqliteRefreshTokenRepository implements RefreshTokenRepository 14 + { 15 + public function __construct(private readonly Database $db) 16 + { 17 + } 18 + 19 + public function findById(string $id): RefreshToken 20 + { 21 + $row = $this->db->fetchOne('SELECT * FROM refresh_token WHERE id = ?', [$id]); 22 + 23 + if ($row === null) { 24 + throw new RefreshTokenNotFoundException(); 25 + } 26 + 27 + return $this->hydrate($row); 28 + } 29 + 30 + /** 31 + * @return RefreshToken[] 32 + */ 33 + public function findAllForDid(string $did): array 34 + { 35 + $rows = $this->db->fetchAll( 36 + 'SELECT * FROM refresh_token WHERE did = ? ORDER BY id', 37 + [$did] 38 + ); 39 + 40 + $result = []; 41 + foreach ($rows as $row) { 42 + $result[] = $this->hydrate($row); 43 + } 44 + return $result; 45 + } 46 + 47 + public function save(RefreshToken $token): void 48 + { 49 + $this->db->execute( 50 + 'INSERT INTO refresh_token (id, did, expires_at, app_password_name, next_id) 51 + VALUES (:id, :did, :expires_at, :app_password_name, :next_id) 52 + ON CONFLICT(id) DO UPDATE SET 53 + did = excluded.did, 54 + expires_at = excluded.expires_at, 55 + app_password_name = excluded.app_password_name, 56 + next_id = excluded.next_id', 57 + [ 58 + 'id' => $token->getId(), 59 + 'did' => $token->getDid(), 60 + 'expires_at' => $token->getExpiresAt(), 61 + 'app_password_name' => $token->getAppPasswordName(), 62 + 'next_id' => $token->getNextId(), 63 + ] 64 + ); 65 + } 66 + 67 + public function deleteById(string $id): void 68 + { 69 + $this->db->execute('DELETE FROM refresh_token WHERE id = ?', [$id]); 70 + } 71 + 72 + public function deleteAllForDid(string $did): void 73 + { 74 + $this->db->execute('DELETE FROM refresh_token WHERE did = ?', [$did]); 75 + } 76 + 77 + /** 78 + * @param array<string, mixed> $row 79 + */ 80 + private function hydrate(array $row): RefreshToken 81 + { 82 + return new RefreshToken( 83 + id: Row::str($row, 'id'), 84 + did: Row::str($row, 'did'), 85 + expiresAt: Row::str($row, 'expires_at'), 86 + appPasswordName: Row::nstr($row, 'app_password_name'), 87 + nextId: Row::nstr($row, 'next_id'), 88 + ); 89 + } 90 + }
+117
src/Infrastructure/Persistence/Account/SqliteAccountRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Account; 6 + 7 + use App\Domain\Account\Account; 8 + use App\Domain\Account\AccountNotFoundException; 9 + use App\Domain\Account\AccountRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteAccountRepository implements AccountRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + /** 21 + * @return Account[] 22 + */ 23 + public function findAll(): array 24 + { 25 + $rows = $this->db->fetchAll('SELECT * FROM account ORDER BY did'); 26 + 27 + $result = []; 28 + foreach ($rows as $row) { 29 + $result[] = $this->hydrate($row); 30 + } 31 + return $result; 32 + } 33 + 34 + public function findAccountByHandle(string $handle): Account 35 + { 36 + $handle = strtolower(trim($handle)); 37 + if ($handle === '') { 38 + throw new AccountNotFoundException(); 39 + } 40 + 41 + $row = $this->db->fetchOne( 42 + 'SELECT a.* FROM account a JOIN actor ac ON ac.did = a.did WHERE ac.handle = ?', 43 + [$handle] 44 + ); 45 + 46 + if ($row === null) { 47 + throw new AccountNotFoundException(); 48 + } 49 + 50 + return $this->hydrate($row); 51 + } 52 + 53 + public function findAccountByDid(string $did): Account 54 + { 55 + $row = $this->db->fetchOne('SELECT * FROM account WHERE did = ?', [trim($did)]); 56 + 57 + if ($row === null) { 58 + throw new AccountNotFoundException(); 59 + } 60 + 61 + return $this->hydrate($row); 62 + } 63 + 64 + public function findAccountByEmail(string $email): Account 65 + { 66 + $email = strtolower(trim($email)); 67 + if ($email === '') { 68 + throw new AccountNotFoundException(); 69 + } 70 + 71 + $row = $this->db->fetchOne('SELECT * FROM account WHERE email = ?', [$email]); 72 + 73 + if ($row === null) { 74 + throw new AccountNotFoundException(); 75 + } 76 + 77 + return $this->hydrate($row); 78 + } 79 + 80 + public function save(Account $account): void 81 + { 82 + $this->db->execute( 83 + 'INSERT INTO account (did, email, password_scrypt, email_confirmed_at, invites_disabled) 84 + VALUES (:did, :email, :password_scrypt, :email_confirmed_at, :invites_disabled) 85 + ON CONFLICT(did) DO UPDATE SET 86 + email = excluded.email, 87 + password_scrypt = excluded.password_scrypt, 88 + email_confirmed_at = excluded.email_confirmed_at, 89 + invites_disabled = excluded.invites_disabled', 90 + [ 91 + 'did' => $account->getDid(), 92 + 'email' => $account->getEmail(), 93 + 'password_scrypt' => $account->getPasswordScrypt(), 94 + 'email_confirmed_at' => $account->getEmailConfirmedAt()?->format(DATE_ATOM), 95 + 'invites_disabled' => $account->isInvitesDisabled() ? 1 : 0, 96 + ] 97 + ); 98 + } 99 + 100 + /** 101 + * @param array<string, mixed> $row 102 + */ 103 + private function hydrate(array $row): Account 104 + { 105 + $emailConfirmedAt = Row::nstr($row, 'email_confirmed_at'); 106 + 107 + return new Account( 108 + did: Row::str($row, 'did'), 109 + email: Row::str($row, 'email'), 110 + passwordScrypt: Row::str($row, 'password_scrypt'), 111 + emailConfirmedAt: $emailConfirmedAt === null 112 + ? null 113 + : new DateTimeImmutable($emailConfirmedAt), 114 + invitesDisabled: Row::bool($row, 'invites_disabled'), 115 + ); 116 + } 117 + }
-94
src/Infrastructure/Persistence/Actor/InMemoryActorRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Actor; 6 - 7 - use App\Application\Settings\SettingsInterface; 8 - use App\Domain\Actor\Actor; 9 - use App\Domain\Actor\ActorNotFoundException; 10 - use App\Domain\Actor\ActorRepository; 11 - 12 - class InMemoryActorRepository implements ActorRepository 13 - { 14 - /** 15 - * @var array<string, Actor> 16 - */ 17 - private array $actorsByDid; 18 - 19 - /** 20 - * @param Actor[]|null $actors 21 - */ 22 - public function __construct(?SettingsInterface $settings = null, ?array $actors = null) 23 - { 24 - if ($actors === null) { 25 - /** @var array{hostname: string}|null $pdsSettings */ 26 - $pdsSettings = $settings?->get('pds'); 27 - $hostname = $pdsSettings['hostname'] ?? 'localhost'; 28 - $actors = [ 29 - new Actor( 30 - "did:web:alice.{$hostname}", 31 - "alice.{$hostname}", 32 - new \DateTimeImmutable('2026-01-01T00:00:00Z') 33 - ), 34 - new Actor( 35 - "did:web:bob.{$hostname}", 36 - "bob.{$hostname}", 37 - new \DateTimeImmutable('2026-01-02T00:00:00Z') 38 - ), 39 - new Actor( 40 - 'did:plc:carol000000000000000000000', 41 - "carol.{$hostname}", 42 - new \DateTimeImmutable('2026-01-03T00:00:00Z') 43 - ), 44 - ]; 45 - } 46 - 47 - $this->actorsByDid = []; 48 - foreach ($actors as $actor) { 49 - $this->actorsByDid[$actor->getDid()] = $actor; 50 - } 51 - } 52 - 53 - /** 54 - * {@inheritdoc} 55 - */ 56 - public function findAll(): array 57 - { 58 - return array_values($this->actorsByDid); 59 - } 60 - 61 - /** 62 - * {@inheritdoc} 63 - */ 64 - public function findActorByDid(string $did): Actor 65 - { 66 - $key = trim($did); 67 - 68 - if (!isset($this->actorsByDid[$key])) { 69 - throw new ActorNotFoundException(); 70 - } 71 - 72 - return $this->actorsByDid[$key]; 73 - } 74 - 75 - /** 76 - * {@inheritdoc} 77 - */ 78 - public function findActorByHandle(string $handle): Actor 79 - { 80 - $needle = strtolower(trim($handle)); 81 - 82 - if ($needle === '') { 83 - throw new ActorNotFoundException(); 84 - } 85 - 86 - foreach ($this->actorsByDid as $actor) { 87 - if ($actor->getHandle() === $needle) { 88 - return $actor; 89 - } 90 - } 91 - 92 - throw new ActorNotFoundException(); 93 - } 94 - }
+100
src/Infrastructure/Persistence/Actor/SqliteActorRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Actor; 6 + 7 + use App\Domain\Actor\Actor; 8 + use App\Domain\Actor\ActorNotFoundException; 9 + use App\Domain\Actor\ActorRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteActorRepository implements ActorRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + /** 21 + * @return Actor[] 22 + */ 23 + public function findAll(): array 24 + { 25 + $rows = $this->db->fetchAll('SELECT * FROM actor ORDER BY did'); 26 + 27 + $result = []; 28 + foreach ($rows as $row) { 29 + $result[] = $this->hydrate($row); 30 + } 31 + return $result; 32 + } 33 + 34 + public function findActorByDid(string $did): Actor 35 + { 36 + $row = $this->db->fetchOne('SELECT * FROM actor WHERE did = ?', [trim($did)]); 37 + 38 + if ($row === null) { 39 + throw new ActorNotFoundException(); 40 + } 41 + 42 + return $this->hydrate($row); 43 + } 44 + 45 + public function findActorByHandle(string $handle): Actor 46 + { 47 + $handle = strtolower(trim($handle)); 48 + if ($handle === '') { 49 + throw new ActorNotFoundException(); 50 + } 51 + 52 + $row = $this->db->fetchOne('SELECT * FROM actor WHERE handle = ?', [$handle]); 53 + 54 + if ($row === null) { 55 + throw new ActorNotFoundException(); 56 + } 57 + 58 + return $this->hydrate($row); 59 + } 60 + 61 + public function save(Actor $actor): void 62 + { 63 + $this->db->execute( 64 + 'INSERT INTO actor (did, handle, created_at, takedown_ref, deactivated_at, delete_after) 65 + VALUES (:did, :handle, :created_at, :takedown_ref, :deactivated_at, :delete_after) 66 + ON CONFLICT(did) DO UPDATE SET 67 + handle = excluded.handle, 68 + created_at = excluded.created_at, 69 + takedown_ref = excluded.takedown_ref, 70 + deactivated_at = excluded.deactivated_at, 71 + delete_after = excluded.delete_after', 72 + [ 73 + 'did' => $actor->getDid(), 74 + 'handle' => $actor->getHandle(), 75 + 'created_at' => $actor->getCreatedAt()->format(DATE_ATOM), 76 + 'takedown_ref' => $actor->getTakedownRef(), 77 + 'deactivated_at' => $actor->getDeactivatedAt()?->format(DATE_ATOM), 78 + 'delete_after' => $actor->getDeleteAfter()?->format(DATE_ATOM), 79 + ] 80 + ); 81 + } 82 + 83 + /** 84 + * @param array<string, mixed> $row 85 + */ 86 + private function hydrate(array $row): Actor 87 + { 88 + $deactivatedAt = Row::nstr($row, 'deactivated_at'); 89 + $deleteAfter = Row::nstr($row, 'delete_after'); 90 + 91 + return new Actor( 92 + did: Row::str($row, 'did'), 93 + handle: Row::nstr($row, 'handle'), 94 + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), 95 + takedownRef: Row::nstr($row, 'takedown_ref'), 96 + deactivatedAt: $deactivatedAt === null ? null : new DateTimeImmutable($deactivatedAt), 97 + deleteAfter: $deleteAfter === null ? null : new DateTimeImmutable($deleteAfter), 98 + ); 99 + } 100 + }
-52
src/Infrastructure/Persistence/ActorStore/InMemoryActorStoreFactory.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\ActorStore; 6 - 7 - use App\Domain\ActorStore\ActorStore; 8 - use App\Domain\ActorStore\ActorStoreFactory; 9 - use App\Infrastructure\Persistence\Blob\InMemoryBlobRepository; 10 - use App\Infrastructure\Persistence\Blob\InMemoryBlobStore; 11 - use App\Infrastructure\Persistence\Preference\InMemoryAccountPrefRepository; 12 - use App\Infrastructure\Persistence\Record\InMemoryBacklinkRepository; 13 - use App\Infrastructure\Persistence\Record\InMemoryRecordBlobRepository; 14 - use App\Infrastructure\Persistence\Record\InMemoryRecordRepository; 15 - use App\Infrastructure\Persistence\Repo\InMemoryRepoBlockRepository; 16 - use App\Infrastructure\Persistence\Repo\InMemoryRepoRootRepository; 17 - 18 - /** 19 - * In-memory implementation of ActorStoreFactory. 20 - * 21 - * Lazily creates and caches a full set of in-memory repositories for each DID. 22 - * Destroying a store wipes all state for that DID (e.g. on account deletion). 23 - */ 24 - class InMemoryActorStoreFactory implements ActorStoreFactory 25 - { 26 - /** @var array<string, ActorStore> keyed by DID */ 27 - private array $stores = []; 28 - 29 - public function get(string $did): ActorStore 30 - { 31 - if (!isset($this->stores[$did])) { 32 - $this->stores[$did] = new ActorStore( 33 - did: $did, 34 - records: new InMemoryRecordRepository(), 35 - recordBlobs: new InMemoryRecordBlobRepository(), 36 - backlinks: new InMemoryBacklinkRepository(), 37 - blobs: new InMemoryBlobRepository(), 38 - blobStore: new InMemoryBlobStore(), 39 - repoBlocks: new InMemoryRepoBlockRepository(), 40 - prefs: new InMemoryAccountPrefRepository(), 41 - repoRoot: new InMemoryRepoRootRepository(), 42 - ); 43 - } 44 - 45 - return $this->stores[$did]; 46 - } 47 - 48 - public function destroy(string $did): void 49 - { 50 - unset($this->stores[$did]); 51 - } 52 - }
+113
src/Infrastructure/Persistence/ActorStore/SqliteActorStoreFactory.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\ActorStore; 6 + 7 + use App\Domain\ActorStore\ActorStore; 8 + use App\Domain\ActorStore\ActorStoreFactory; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\ActorStoreSchema; 11 + use App\Infrastructure\Persistence\Blob\DiskBlobStore; 12 + use App\Infrastructure\Persistence\Blob\SqliteBlobRepository; 13 + use App\Infrastructure\Persistence\Preference\SqliteAccountPrefRepository; 14 + use App\Infrastructure\Persistence\Record\SqliteBacklinkRepository; 15 + use App\Infrastructure\Persistence\Record\SqliteRecordBlobRepository; 16 + use App\Infrastructure\Persistence\Record\SqliteRecordRepository; 17 + use App\Infrastructure\Persistence\Repo\SqliteRepoBlockRepository; 18 + use App\Infrastructure\Persistence\Repo\SqliteRepoRootRepository; 19 + 20 + /** 21 + * SQLite-backed actor-store factory. 22 + * 23 + * Each DID gets its own SQLite database file named after the SHA-256 of 24 + * the DID. Blob bytes for each DID live under a sibling directory. 25 + */ 26 + class SqliteActorStoreFactory implements ActorStoreFactory 27 + { 28 + /** @var array<string, ActorStore> keyed by DID */ 29 + private array $cache = []; 30 + 31 + public function __construct( 32 + private readonly string $actorsDirectory, 33 + private readonly string $blobsDirectory, 34 + ) { 35 + } 36 + 37 + public function get(string $did): ActorStore 38 + { 39 + if (isset($this->cache[$did])) { 40 + return $this->cache[$did]; 41 + } 42 + 43 + $db = new Database($this->dbLocation($did)); 44 + ActorStoreSchema::apply($db); 45 + 46 + $blobStore = new DiskBlobStore($this->blobDir($did)); 47 + 48 + return $this->cache[$did] = new ActorStore( 49 + did: $did, 50 + records: new SqliteRecordRepository($db), 51 + recordBlobs: new SqliteRecordBlobRepository($db), 52 + backlinks: new SqliteBacklinkRepository($db), 53 + blobs: new SqliteBlobRepository($db), 54 + blobStore: $blobStore, 55 + repoBlocks: new SqliteRepoBlockRepository($db), 56 + prefs: new SqliteAccountPrefRepository($db), 57 + repoRoot: new SqliteRepoRootRepository($db), 58 + ); 59 + } 60 + 61 + public function destroy(string $did): void 62 + { 63 + unset($this->cache[$did]); 64 + 65 + $loc = $this->dbLocation($did); 66 + if ($loc !== ':memory:' && is_file($loc)) { 67 + @unlink($loc); 68 + @unlink($loc . '-wal'); 69 + @unlink($loc . '-shm'); 70 + } 71 + 72 + $blobDir = $this->blobDir($did); 73 + if (is_dir($blobDir)) { 74 + self::removeDirectory($blobDir); 75 + } 76 + } 77 + 78 + private function dbLocation(string $did): string 79 + { 80 + if ($this->actorsDirectory === ':memory:') { 81 + return ':memory:'; 82 + } 83 + 84 + return rtrim($this->actorsDirectory, '/') . '/' . $this->safeId($did) . '.sqlite'; 85 + } 86 + 87 + private function blobDir(string $did): string 88 + { 89 + return rtrim($this->blobsDirectory, '/') . '/' . $this->safeId($did); 90 + } 91 + 92 + private function safeId(string $did): string 93 + { 94 + return hash('sha256', $did); 95 + } 96 + 97 + private static function removeDirectory(string $dir): void 98 + { 99 + $iterator = new \RecursiveIteratorIterator( 100 + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), 101 + \RecursiveIteratorIterator::CHILD_FIRST, 102 + ); 103 + foreach ($iterator as $file) { 104 + assert($file instanceof \SplFileInfo); 105 + if ($file->isDir()) { 106 + @rmdir($file->getPathname()); 107 + } else { 108 + @unlink($file->getPathname()); 109 + } 110 + } 111 + @rmdir($dir); 112 + } 113 + }
-70
src/Infrastructure/Persistence/Blob/InMemoryBlobRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Blob; 6 - 7 - use App\Domain\Blob\Blob; 8 - use App\Domain\Blob\BlobNotFoundException; 9 - use App\Domain\Blob\BlobRepository; 10 - 11 - class InMemoryBlobRepository implements BlobRepository 12 - { 13 - /** @var array<string, Blob> keyed by cid */ 14 - private array $blobs = []; 15 - 16 - /** 17 - * @param Blob[] $seeds 18 - */ 19 - public function __construct(array $seeds = []) 20 - { 21 - foreach ($seeds as $blob) { 22 - $this->blobs[$blob->getCid()] = $blob; 23 - } 24 - } 25 - 26 - public function findByCid(string $cid): Blob 27 - { 28 - if (!isset($this->blobs[$cid])) { 29 - throw new BlobNotFoundException(); 30 - } 31 - 32 - return $this->blobs[$cid]; 33 - } 34 - 35 - /** 36 - * @return Blob[] 37 - */ 38 - public function findAll(): array 39 - { 40 - return array_values($this->blobs); 41 - } 42 - 43 - /** 44 - * @return Blob[] 45 - */ 46 - public function findTemporary(): array 47 - { 48 - return array_values( 49 - array_filter( 50 - $this->blobs, 51 - fn(Blob $b) => $b->getTempKey() !== null, 52 - ) 53 - ); 54 - } 55 - 56 - public function save(Blob $blob): void 57 - { 58 - $this->blobs[$blob->getCid()] = $blob; 59 - } 60 - 61 - public function deleteByCid(string $cid): void 62 - { 63 - unset($this->blobs[$cid]); 64 - } 65 - 66 - public function deleteAll(): void 67 - { 68 - $this->blobs = []; 69 - } 70 - }
-130
src/Infrastructure/Persistence/Blob/InMemoryBlobStore.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Blob; 6 - 7 - use App\Domain\Blob\BlobNotFoundException; 8 - use App\Domain\Blob\BlobStore; 9 - use Slim\Psr7\Stream; 10 - use Psr\Http\Message\StreamInterface; 11 - 12 - class InMemoryBlobStore implements BlobStore 13 - { 14 - /** @var array<string, string> tempKey => raw bytes */ 15 - private array $temp = []; 16 - 17 - /** @var array<string, string> cid => raw bytes */ 18 - private array $perm = []; 19 - 20 - /** @var array<string, true> set of quarantined CIDs */ 21 - private array $quarantined = []; 22 - 23 - public function putTemp(string $bytes): string 24 - { 25 - $key = bin2hex(random_bytes(16)); 26 - $this->temp[$key] = $bytes; 27 - 28 - return $key; 29 - } 30 - 31 - public function makePermanent(string $tempKey, string $cid): void 32 - { 33 - if (!isset($this->temp[$tempKey])) { 34 - throw new BlobNotFoundException(); 35 - } 36 - 37 - $this->perm[$cid] = $this->temp[$tempKey]; 38 - unset($this->temp[$tempKey]); 39 - } 40 - 41 - public function putPermanent(string $cid, string $bytes): void 42 - { 43 - $this->perm[$cid] = $bytes; 44 - } 45 - 46 - public function hasTemp(string $tempKey): bool 47 - { 48 - return isset($this->temp[$tempKey]); 49 - } 50 - 51 - public function hasStored(string $cid): bool 52 - { 53 - return isset($this->perm[$cid]); 54 - } 55 - 56 - public function getBytes(string $cid): string 57 - { 58 - if (!isset($this->perm[$cid])) { 59 - throw new BlobNotFoundException(); 60 - } 61 - 62 - return $this->perm[$cid]; 63 - } 64 - 65 - public function getStream(string $cid): StreamInterface 66 - { 67 - $bytes = $this->getBytes($cid); 68 - $resource = fopen('php://temp', 'r+'); 69 - 70 - if ($resource === false) { 71 - throw new \RuntimeException('Could not open php://temp stream.'); 72 - } 73 - 74 - fwrite($resource, $bytes); 75 - rewind($resource); 76 - 77 - return new Stream($resource); 78 - } 79 - 80 - public function delete(string $cid): void 81 - { 82 - if (!isset($this->perm[$cid])) { 83 - throw new BlobNotFoundException(); 84 - } 85 - 86 - unset($this->perm[$cid], $this->quarantined[$cid]); 87 - } 88 - 89 - /** 90 - * @param string[] $cids 91 - */ 92 - public function deleteMany(array $cids): void 93 - { 94 - foreach ($cids as $cid) { 95 - unset($this->perm[$cid], $this->quarantined[$cid]); 96 - } 97 - } 98 - 99 - public function deleteTemp(string $tempKey): void 100 - { 101 - if (!isset($this->temp[$tempKey])) { 102 - throw new BlobNotFoundException(); 103 - } 104 - 105 - unset($this->temp[$tempKey]); 106 - } 107 - 108 - public function quarantine(string $cid): void 109 - { 110 - if (!isset($this->perm[$cid])) { 111 - throw new BlobNotFoundException(); 112 - } 113 - 114 - $this->quarantined[$cid] = true; 115 - } 116 - 117 - public function unquarantine(string $cid): void 118 - { 119 - if (!isset($this->perm[$cid])) { 120 - throw new BlobNotFoundException(); 121 - } 122 - 123 - unset($this->quarantined[$cid]); 124 - } 125 - 126 - public function isQuarantined(string $cid): bool 127 - { 128 - return isset($this->quarantined[$cid]); 129 - } 130 - }
+107
src/Infrastructure/Persistence/Blob/SqliteBlobRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Blob; 6 + 7 + use App\Domain\Blob\Blob; 8 + use App\Domain\Blob\BlobNotFoundException; 9 + use App\Domain\Blob\BlobRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteBlobRepository implements BlobRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function findByCid(string $cid): Blob 21 + { 22 + $row = $this->db->fetchOne('SELECT * FROM blob WHERE cid = ?', [$cid]); 23 + 24 + if ($row === null) { 25 + throw new BlobNotFoundException(); 26 + } 27 + 28 + return $this->hydrate($row); 29 + } 30 + 31 + /** 32 + * @return Blob[] 33 + */ 34 + public function findAll(): array 35 + { 36 + $rows = $this->db->fetchAll('SELECT * FROM blob ORDER BY cid'); 37 + 38 + $result = []; 39 + foreach ($rows as $row) { 40 + $result[] = $this->hydrate($row); 41 + } 42 + return $result; 43 + } 44 + 45 + /** 46 + * @return Blob[] 47 + */ 48 + public function findTemporary(): array 49 + { 50 + $rows = $this->db->fetchAll( 51 + 'SELECT * FROM blob WHERE temp_key IS NOT NULL ORDER BY cid' 52 + ); 53 + 54 + $result = []; 55 + foreach ($rows as $row) { 56 + $result[] = $this->hydrate($row); 57 + } 58 + return $result; 59 + } 60 + 61 + public function save(Blob $blob): void 62 + { 63 + $this->db->execute( 64 + 'INSERT INTO blob (cid, mime_type, size, temp_key, created_at, takedown_ref) 65 + VALUES (:cid, :mime_type, :size, :temp_key, :created_at, :takedown_ref) 66 + ON CONFLICT(cid) DO UPDATE SET 67 + mime_type = excluded.mime_type, 68 + size = excluded.size, 69 + temp_key = excluded.temp_key, 70 + created_at = excluded.created_at, 71 + takedown_ref = excluded.takedown_ref', 72 + [ 73 + 'cid' => $blob->getCid(), 74 + 'mime_type' => $blob->getMimeType(), 75 + 'size' => $blob->getSize(), 76 + 'temp_key' => $blob->getTempKey(), 77 + 'created_at' => $blob->getCreatedAt()->format(DATE_ATOM), 78 + 'takedown_ref' => $blob->getTakedownRef(), 79 + ] 80 + ); 81 + } 82 + 83 + public function deleteByCid(string $cid): void 84 + { 85 + $this->db->execute('DELETE FROM blob WHERE cid = ?', [$cid]); 86 + } 87 + 88 + public function deleteAll(): void 89 + { 90 + $this->db->pdo()->exec('DELETE FROM blob'); 91 + } 92 + 93 + /** 94 + * @param array<string, mixed> $row 95 + */ 96 + private function hydrate(array $row): Blob 97 + { 98 + return new Blob( 99 + cid: Row::str($row, 'cid'), 100 + mimeType: Row::str($row, 'mime_type'), 101 + size: Row::int($row, 'size'), 102 + tempKey: Row::nstr($row, 'temp_key'), 103 + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), 104 + takedownRef: Row::nstr($row, 'takedown_ref'), 105 + ); 106 + } 107 + }
-62
src/Infrastructure/Persistence/Did/InMemoryDidCacheRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Did; 6 - 7 - use App\Domain\Did\DidCacheRepository; 8 - use App\Domain\Did\DidDocEntry; 9 - use App\Domain\Did\DidDocEntryNotFoundException; 10 - use DateTimeImmutable; 11 - 12 - class InMemoryDidCacheRepository implements DidCacheRepository 13 - { 14 - /** @var array<string, DidDocEntry> keyed by did */ 15 - private array $cache = []; 16 - 17 - /** 18 - * @param DidDocEntry[] $seeds 19 - */ 20 - public function __construct(array $seeds = []) 21 - { 22 - foreach ($seeds as $entry) { 23 - $this->cache[$entry->getDid()] = $entry; 24 - } 25 - } 26 - 27 - public function get(string $did): DidDocEntry 28 - { 29 - if (!isset($this->cache[$did])) { 30 - throw new DidDocEntryNotFoundException(); 31 - } 32 - 33 - return $this->cache[$did]; 34 - } 35 - 36 - /** 37 - * @param array<string, mixed> $doc 38 - */ 39 - public function set(string $did, array $doc): void 40 - { 41 - $this->cache[$did] = new DidDocEntry( 42 - did: $did, 43 - doc: $doc, 44 - updatedAt: new DateTimeImmutable(), 45 - ); 46 - } 47 - 48 - public function has(string $did): bool 49 - { 50 - return isset($this->cache[$did]); 51 - } 52 - 53 - public function clear(string $did): void 54 - { 55 - unset($this->cache[$did]); 56 - } 57 - 58 - public function clearAll(): void 59 - { 60 - $this->cache = []; 61 - } 62 - }
+72
src/Infrastructure/Persistence/Did/SqliteDidCacheRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Did; 6 + 7 + use App\Domain\Did\DidCacheRepository; 8 + use App\Domain\Did\DidDocEntry; 9 + use App\Domain\Did\DidDocEntryNotFoundException; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteDidCacheRepository implements DidCacheRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function get(string $did): DidDocEntry 21 + { 22 + $row = $this->db->fetchOne('SELECT * FROM did_doc WHERE did = ?', [$did]); 23 + 24 + if ($row === null) { 25 + throw new DidDocEntryNotFoundException(); 26 + } 27 + 28 + $doc = json_decode(Row::str($row, 'doc_json'), true, 512, JSON_THROW_ON_ERROR); 29 + assert(is_array($doc)); 30 + /** @var array<string, mixed> $doc */ 31 + 32 + return new DidDocEntry( 33 + did: Row::str($row, 'did'), 34 + doc: $doc, 35 + updatedAt: new DateTimeImmutable(Row::str($row, 'updated_at')), 36 + ); 37 + } 38 + 39 + /** 40 + * @param array<string, mixed> $doc 41 + */ 42 + public function set(string $did, array $doc): void 43 + { 44 + $this->db->execute( 45 + 'INSERT INTO did_doc (did, doc_json, updated_at) 46 + VALUES (?, ?, ?) 47 + ON CONFLICT(did) DO UPDATE SET 48 + doc_json = excluded.doc_json, 49 + updated_at = excluded.updated_at', 50 + [ 51 + $did, 52 + json_encode($doc, JSON_THROW_ON_ERROR), 53 + (new DateTimeImmutable())->format(DATE_ATOM), 54 + ] 55 + ); 56 + } 57 + 58 + public function has(string $did): bool 59 + { 60 + return $this->db->fetchOne('SELECT 1 FROM did_doc WHERE did = ?', [$did]) !== null; 61 + } 62 + 63 + public function clear(string $did): void 64 + { 65 + $this->db->execute('DELETE FROM did_doc WHERE did = ?', [$did]); 66 + } 67 + 68 + public function clearAll(): void 69 + { 70 + $this->db->pdo()->exec('DELETE FROM did_doc'); 71 + } 72 + }
-52
src/Infrastructure/Persistence/Lexicon/InMemoryLexiconRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Lexicon; 6 - 7 - use App\Domain\Lexicon\LexiconEntry; 8 - use App\Domain\Lexicon\LexiconEntryNotFoundException; 9 - use App\Domain\Lexicon\LexiconRepository; 10 - 11 - class InMemoryLexiconRepository implements LexiconRepository 12 - { 13 - /** @var array<string, LexiconEntry> keyed by nsid */ 14 - private array $entries = []; 15 - 16 - /** 17 - * @param LexiconEntry[] $seeds 18 - */ 19 - public function __construct(array $seeds = []) 20 - { 21 - foreach ($seeds as $entry) { 22 - $this->entries[$entry->getNsid()] = $entry; 23 - } 24 - } 25 - 26 - public function findByNsid(string $nsid): LexiconEntry 27 - { 28 - if (!isset($this->entries[$nsid])) { 29 - throw new LexiconEntryNotFoundException(); 30 - } 31 - 32 - return $this->entries[$nsid]; 33 - } 34 - 35 - /** 36 - * @return LexiconEntry[] 37 - */ 38 - public function findAll(): array 39 - { 40 - return array_values($this->entries); 41 - } 42 - 43 - public function save(LexiconEntry $entry): void 44 - { 45 - $this->entries[$entry->getNsid()] = $entry; 46 - } 47 - 48 - public function deleteByNsid(string $nsid): void 49 - { 50 - unset($this->entries[$nsid]); 51 - } 52 - }
+100
src/Infrastructure/Persistence/Lexicon/SqliteLexiconRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Lexicon; 6 + 7 + use App\Domain\Lexicon\LexiconEntry; 8 + use App\Domain\Lexicon\LexiconEntryNotFoundException; 9 + use App\Domain\Lexicon\LexiconRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteLexiconRepository implements LexiconRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function findByNsid(string $nsid): LexiconEntry 21 + { 22 + $row = $this->db->fetchOne('SELECT * FROM lexicon WHERE nsid = ?', [$nsid]); 23 + 24 + if ($row === null) { 25 + throw new LexiconEntryNotFoundException(); 26 + } 27 + 28 + return $this->hydrate($row); 29 + } 30 + 31 + /** 32 + * @return LexiconEntry[] 33 + */ 34 + public function findAll(): array 35 + { 36 + $rows = $this->db->fetchAll('SELECT * FROM lexicon ORDER BY nsid'); 37 + 38 + $result = []; 39 + foreach ($rows as $row) { 40 + $result[] = $this->hydrate($row); 41 + } 42 + return $result; 43 + } 44 + 45 + public function save(LexiconEntry $entry): void 46 + { 47 + $lexicon = $entry->getLexicon(); 48 + $lexiconJson = $lexicon === null 49 + ? null 50 + : json_encode($lexicon, JSON_THROW_ON_ERROR); 51 + 52 + $this->db->execute( 53 + 'INSERT INTO lexicon (nsid, created_at, updated_at, last_succeeded_at, uri, lexicon_json) 54 + VALUES (:nsid, :created_at, :updated_at, :last_succeeded_at, :uri, :lexicon_json) 55 + ON CONFLICT(nsid) DO UPDATE SET 56 + created_at = excluded.created_at, 57 + updated_at = excluded.updated_at, 58 + last_succeeded_at = excluded.last_succeeded_at, 59 + uri = excluded.uri, 60 + lexicon_json = excluded.lexicon_json', 61 + [ 62 + 'nsid' => $entry->getNsid(), 63 + 'created_at' => $entry->getCreatedAt()->format(DATE_ATOM), 64 + 'updated_at' => $entry->getUpdatedAt()->format(DATE_ATOM), 65 + 'last_succeeded_at' => $entry->getLastSucceededAt()?->format(DATE_ATOM), 66 + 'uri' => $entry->getUri(), 67 + 'lexicon_json' => $lexiconJson, 68 + ] 69 + ); 70 + } 71 + 72 + public function deleteByNsid(string $nsid): void 73 + { 74 + $this->db->execute('DELETE FROM lexicon WHERE nsid = ?', [$nsid]); 75 + } 76 + 77 + /** 78 + * @param array<string, mixed> $row 79 + */ 80 + private function hydrate(array $row): LexiconEntry 81 + { 82 + $lexiconJson = Row::nstr($row, 'lexicon_json'); 83 + $lexicon = $lexiconJson === null 84 + ? null 85 + : json_decode($lexiconJson, true, 512, JSON_THROW_ON_ERROR); 86 + assert($lexicon === null || is_array($lexicon)); 87 + /** @var array<string, mixed>|null $lexicon */ 88 + 89 + $lastSucceededAt = Row::nstr($row, 'last_succeeded_at'); 90 + 91 + return new LexiconEntry( 92 + nsid: Row::str($row, 'nsid'), 93 + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), 94 + updatedAt: new DateTimeImmutable(Row::str($row, 'updated_at')), 95 + lastSucceededAt: $lastSucceededAt === null ? null : new DateTimeImmutable($lastSucceededAt), 96 + uri: Row::nstr($row, 'uri'), 97 + lexicon: $lexicon, 98 + ); 99 + } 100 + }
-75
src/Infrastructure/Persistence/OAuth/InMemoryAccountDeviceRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\OAuth; 6 - 7 - use App\Domain\OAuth\AccountDevice; 8 - use App\Domain\OAuth\AccountDeviceNotFoundException; 9 - use App\Domain\OAuth\AccountDeviceRepository; 10 - 11 - class InMemoryAccountDeviceRepository implements AccountDeviceRepository 12 - { 13 - /** 14 - * Flat list; composite key (did, deviceId). 15 - * 16 - * @var AccountDevice[] 17 - */ 18 - private array $entries = []; 19 - 20 - /** 21 - * @param AccountDevice[] $seeds 22 - */ 23 - public function __construct(array $seeds = []) 24 - { 25 - $this->entries = $seeds; 26 - } 27 - 28 - public function findByDidAndDeviceId(string $did, string $deviceId): AccountDevice 29 - { 30 - foreach ($this->entries as $entry) { 31 - if ($entry->getDid() === $did && $entry->getDeviceId() === $deviceId) { 32 - return $entry; 33 - } 34 - } 35 - 36 - throw new AccountDeviceNotFoundException(); 37 - } 38 - 39 - /** 40 - * @return AccountDevice[] 41 - */ 42 - public function findAllForDid(string $did): array 43 - { 44 - return array_values( 45 - array_filter( 46 - $this->entries, 47 - fn(AccountDevice $e) => $e->getDid() === $did, 48 - ) 49 - ); 50 - } 51 - 52 - public function save(AccountDevice $accountDevice): void 53 - { 54 - foreach ($this->entries as $i => $existing) { 55 - if ( 56 - $existing->getDid() === $accountDevice->getDid() 57 - && $existing->getDeviceId() === $accountDevice->getDeviceId() 58 - ) { 59 - $this->entries[$i] = $accountDevice; 60 - return; 61 - } 62 - } 63 - $this->entries[] = $accountDevice; 64 - } 65 - 66 - public function deleteByDidAndDeviceId(string $did, string $deviceId): void 67 - { 68 - $this->entries = array_values( 69 - array_filter( 70 - $this->entries, 71 - fn(AccountDevice $e) => !($e->getDid() === $did && $e->getDeviceId() === $deviceId), 72 - ) 73 - ); 74 - } 75 - }
-69
src/Infrastructure/Persistence/OAuth/InMemoryAuthorizationRequestRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\OAuth; 6 - 7 - use App\Domain\OAuth\AuthorizationRequest; 8 - use App\Domain\OAuth\AuthorizationRequestNotFoundException; 9 - use App\Domain\OAuth\AuthorizationRequestRepository; 10 - 11 - class InMemoryAuthorizationRequestRepository implements AuthorizationRequestRepository 12 - { 13 - /** @var array<string, AuthorizationRequest> keyed by request id */ 14 - private array $requests = []; 15 - 16 - /** 17 - * @param AuthorizationRequest[] $seeds 18 - */ 19 - public function __construct(array $seeds = []) 20 - { 21 - foreach ($seeds as $req) { 22 - $this->requests[$req->getId()] = $req; 23 - } 24 - } 25 - 26 - public function findById(string $id): AuthorizationRequest 27 - { 28 - if (!isset($this->requests[$id])) { 29 - throw new AuthorizationRequestNotFoundException(); 30 - } 31 - 32 - return $this->requests[$id]; 33 - } 34 - 35 - public function findByCode(string $code): AuthorizationRequest 36 - { 37 - foreach ($this->requests as $req) { 38 - if ($req->getCode() === $code) { 39 - return $req; 40 - } 41 - } 42 - 43 - throw new AuthorizationRequestNotFoundException(); 44 - } 45 - 46 - public function save(AuthorizationRequest $request): void 47 - { 48 - $this->requests[$request->getId()] = $request; 49 - } 50 - 51 - public function deleteById(string $id): void 52 - { 53 - unset($this->requests[$id]); 54 - } 55 - 56 - public function deleteExpired(): int 57 - { 58 - $now = new \DateTimeImmutable(); 59 - $count = 0; 60 - foreach ($this->requests as $id => $req) { 61 - if ($req->getExpiresAt() < $now) { 62 - unset($this->requests[$id]); 63 - $count++; 64 - } 65 - } 66 - 67 - return $count; 68 - } 69 - }
-85
src/Infrastructure/Persistence/OAuth/InMemoryAuthorizedClientRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\OAuth; 6 - 7 - use App\Domain\OAuth\AuthorizedClient; 8 - use App\Domain\OAuth\AuthorizedClientNotFoundException; 9 - use App\Domain\OAuth\AuthorizedClientRepository; 10 - 11 - class InMemoryAuthorizedClientRepository implements AuthorizedClientRepository 12 - { 13 - /** 14 - * Flat list; composite key (did, clientId). 15 - * 16 - * @var AuthorizedClient[] 17 - */ 18 - private array $entries = []; 19 - 20 - /** 21 - * @param AuthorizedClient[] $seeds 22 - */ 23 - public function __construct(array $seeds = []) 24 - { 25 - $this->entries = $seeds; 26 - } 27 - 28 - public function findByDidAndClientId(string $did, string $clientId): AuthorizedClient 29 - { 30 - foreach ($this->entries as $entry) { 31 - if ($entry->getDid() === $did && $entry->getClientId() === $clientId) { 32 - return $entry; 33 - } 34 - } 35 - 36 - throw new AuthorizedClientNotFoundException(); 37 - } 38 - 39 - /** 40 - * @return AuthorizedClient[] 41 - */ 42 - public function findAllForDid(string $did): array 43 - { 44 - return array_values( 45 - array_filter( 46 - $this->entries, 47 - fn(AuthorizedClient $e) => $e->getDid() === $did, 48 - ) 49 - ); 50 - } 51 - 52 - public function save(AuthorizedClient $authorizedClient): void 53 - { 54 - foreach ($this->entries as $i => $existing) { 55 - if ( 56 - $existing->getDid() === $authorizedClient->getDid() 57 - && $existing->getClientId() === $authorizedClient->getClientId() 58 - ) { 59 - $this->entries[$i] = $authorizedClient; 60 - return; 61 - } 62 - } 63 - $this->entries[] = $authorizedClient; 64 - } 65 - 66 - public function deleteByDidAndClientId(string $did, string $clientId): void 67 - { 68 - $this->entries = array_values( 69 - array_filter( 70 - $this->entries, 71 - fn(AuthorizedClient $e) => !($e->getDid() === $did && $e->getClientId() === $clientId), 72 - ) 73 - ); 74 - } 75 - 76 - public function deleteAllForDid(string $did): void 77 - { 78 - $this->entries = array_values( 79 - array_filter( 80 - $this->entries, 81 - fn(AuthorizedClient $e) => $e->getDid() !== $did, 82 - ) 83 - ); 84 - } 85 - }
-44
src/Infrastructure/Persistence/OAuth/InMemoryDeviceRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\OAuth; 6 - 7 - use App\Domain\OAuth\Device; 8 - use App\Domain\OAuth\DeviceNotFoundException; 9 - use App\Domain\OAuth\DeviceRepository; 10 - 11 - class InMemoryDeviceRepository implements DeviceRepository 12 - { 13 - /** @var array<string, Device> keyed by device id */ 14 - private array $devices = []; 15 - 16 - /** 17 - * @param Device[] $seeds 18 - */ 19 - public function __construct(array $seeds = []) 20 - { 21 - foreach ($seeds as $device) { 22 - $this->devices[$device->getId()] = $device; 23 - } 24 - } 25 - 26 - public function findById(string $id): Device 27 - { 28 - if (!isset($this->devices[$id])) { 29 - throw new DeviceNotFoundException(); 30 - } 31 - 32 - return $this->devices[$id]; 33 - } 34 - 35 - public function save(Device $device): void 36 - { 37 - $this->devices[$device->getId()] = $device; 38 - } 39 - 40 - public function deleteById(string $id): void 41 - { 42 - unset($this->devices[$id]); 43 - } 44 - }
-87
src/Infrastructure/Persistence/OAuth/InMemoryOAuthTokenRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\OAuth; 6 - 7 - use App\Domain\OAuth\OAuthToken; 8 - use App\Domain\OAuth\OAuthTokenNotFoundException; 9 - use App\Domain\OAuth\OAuthTokenRepository; 10 - 11 - class InMemoryOAuthTokenRepository implements OAuthTokenRepository 12 - { 13 - /** @var array<string, OAuthToken> keyed by tokenId */ 14 - private array $tokens = []; 15 - 16 - /** 17 - * @param OAuthToken[] $seeds 18 - */ 19 - public function __construct(array $seeds = []) 20 - { 21 - foreach ($seeds as $token) { 22 - $this->tokens[$token->getTokenId()] = $token; 23 - } 24 - } 25 - 26 - public function findByTokenId(string $tokenId): OAuthToken 27 - { 28 - if (!isset($this->tokens[$tokenId])) { 29 - throw new OAuthTokenNotFoundException(); 30 - } 31 - 32 - return $this->tokens[$tokenId]; 33 - } 34 - 35 - public function findByCode(string $code): OAuthToken 36 - { 37 - foreach ($this->tokens as $token) { 38 - if ($token->getCode() === $code) { 39 - return $token; 40 - } 41 - } 42 - 43 - throw new OAuthTokenNotFoundException(); 44 - } 45 - 46 - public function findByRefreshToken(string $refreshToken): OAuthToken 47 - { 48 - foreach ($this->tokens as $token) { 49 - if ($token->getCurrentRefreshToken() === $refreshToken) { 50 - return $token; 51 - } 52 - } 53 - 54 - throw new OAuthTokenNotFoundException(); 55 - } 56 - 57 - /** 58 - * @return OAuthToken[] 59 - */ 60 - public function findAllForDid(string $did): array 61 - { 62 - return array_values( 63 - array_filter( 64 - $this->tokens, 65 - fn(OAuthToken $t) => $t->getDid() === $did, 66 - ) 67 - ); 68 - } 69 - 70 - public function save(OAuthToken $token): void 71 - { 72 - $this->tokens[$token->getTokenId()] = $token; 73 - } 74 - 75 - public function deleteByTokenId(string $tokenId): void 76 - { 77 - unset($this->tokens[$tokenId]); 78 - } 79 - 80 - public function deleteAllForDid(string $did): void 81 - { 82 - $this->tokens = array_filter( 83 - $this->tokens, 84 - fn(OAuthToken $t) => $t->getDid() !== $did, 85 - ); 86 - } 87 - }
-42
src/Infrastructure/Persistence/OAuth/InMemoryUsedRefreshTokenRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\OAuth; 6 - 7 - use App\Domain\OAuth\UsedRefreshToken; 8 - use App\Domain\OAuth\UsedRefreshTokenRepository; 9 - 10 - class InMemoryUsedRefreshTokenRepository implements UsedRefreshTokenRepository 11 - { 12 - /** @var array<string, UsedRefreshToken> keyed by refreshToken string */ 13 - private array $tokens = []; 14 - 15 - /** 16 - * @param UsedRefreshToken[] $seeds 17 - */ 18 - public function __construct(array $seeds = []) 19 - { 20 - foreach ($seeds as $token) { 21 - $this->tokens[$token->getRefreshToken()] = $token; 22 - } 23 - } 24 - 25 - public function exists(string $refreshToken): bool 26 - { 27 - return isset($this->tokens[$refreshToken]); 28 - } 29 - 30 - public function save(UsedRefreshToken $usedRefreshToken): void 31 - { 32 - $this->tokens[$usedRefreshToken->getRefreshToken()] = $usedRefreshToken; 33 - } 34 - 35 - public function deleteAllForTokenId(int $tokenId): void 36 - { 37 - $this->tokens = array_filter( 38 - $this->tokens, 39 - fn(UsedRefreshToken $t) => $t->getTokenId() !== $tokenId, 40 - ); 41 - } 42 - }
+88
src/Infrastructure/Persistence/OAuth/SqliteAccountDeviceRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\OAuth; 6 + 7 + use App\Domain\OAuth\AccountDevice; 8 + use App\Domain\OAuth\AccountDeviceNotFoundException; 9 + use App\Domain\OAuth\AccountDeviceRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteAccountDeviceRepository implements AccountDeviceRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function findByDidAndDeviceId(string $did, string $deviceId): AccountDevice 21 + { 22 + $row = $this->db->fetchOne( 23 + 'SELECT * FROM account_device WHERE did = ? AND device_id = ?', 24 + [$did, $deviceId] 25 + ); 26 + 27 + if ($row === null) { 28 + throw new AccountDeviceNotFoundException(); 29 + } 30 + 31 + return $this->hydrate($row); 32 + } 33 + 34 + /** 35 + * @return AccountDevice[] 36 + */ 37 + public function findAllForDid(string $did): array 38 + { 39 + $rows = $this->db->fetchAll( 40 + 'SELECT * FROM account_device WHERE did = ? ORDER BY device_id', 41 + [$did] 42 + ); 43 + 44 + $result = []; 45 + foreach ($rows as $row) { 46 + $result[] = $this->hydrate($row); 47 + } 48 + return $result; 49 + } 50 + 51 + public function save(AccountDevice $accountDevice): void 52 + { 53 + $this->db->execute( 54 + 'INSERT INTO account_device (did, device_id, created_at, updated_at) 55 + VALUES (:did, :device_id, :created_at, :updated_at) 56 + ON CONFLICT(did, device_id) DO UPDATE SET 57 + created_at = excluded.created_at, 58 + updated_at = excluded.updated_at', 59 + [ 60 + 'did' => $accountDevice->getDid(), 61 + 'device_id' => $accountDevice->getDeviceId(), 62 + 'created_at' => $accountDevice->getCreatedAt()->format(DATE_ATOM), 63 + 'updated_at' => $accountDevice->getUpdatedAt()->format(DATE_ATOM), 64 + ] 65 + ); 66 + } 67 + 68 + public function deleteByDidAndDeviceId(string $did, string $deviceId): void 69 + { 70 + $this->db->execute( 71 + 'DELETE FROM account_device WHERE did = ? AND device_id = ?', 72 + [$did, $deviceId] 73 + ); 74 + } 75 + 76 + /** 77 + * @param array<string, mixed> $row 78 + */ 79 + private function hydrate(array $row): AccountDevice 80 + { 81 + return new AccountDevice( 82 + did: Row::str($row, 'did'), 83 + deviceId: Row::str($row, 'device_id'), 84 + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), 85 + updatedAt: new DateTimeImmutable(Row::str($row, 'updated_at')), 86 + ); 87 + } 88 + }
+121
src/Infrastructure/Persistence/OAuth/SqliteAuthorizationRequestRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\OAuth; 6 + 7 + use App\Domain\OAuth\AuthorizationRequest; 8 + use App\Domain\OAuth\AuthorizationRequestNotFoundException; 9 + use App\Domain\OAuth\AuthorizationRequestRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteAuthorizationRequestRepository implements AuthorizationRequestRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function findById(string $id): AuthorizationRequest 21 + { 22 + $row = $this->db->fetchOne( 23 + 'SELECT * FROM authorization_request WHERE id = ?', 24 + [$id] 25 + ); 26 + 27 + if ($row === null) { 28 + throw new AuthorizationRequestNotFoundException(); 29 + } 30 + 31 + return $this->hydrate($row); 32 + } 33 + 34 + public function findByCode(string $code): AuthorizationRequest 35 + { 36 + $row = $this->db->fetchOne( 37 + 'SELECT * FROM authorization_request WHERE code = ?', 38 + [$code] 39 + ); 40 + 41 + if ($row === null) { 42 + throw new AuthorizationRequestNotFoundException(); 43 + } 44 + 45 + return $this->hydrate($row); 46 + } 47 + 48 + public function save(AuthorizationRequest $request): void 49 + { 50 + $clientAuth = $request->getClientAuth(); 51 + $clientAuthJson = $clientAuth === null 52 + ? null 53 + : json_encode($clientAuth, JSON_THROW_ON_ERROR); 54 + 55 + $this->db->execute( 56 + 'INSERT INTO authorization_request 57 + (id, did, device_id, client_id, client_auth_json, parameters_json, expires_at, code) 58 + VALUES 59 + (:id, :did, :device_id, :client_id, :client_auth_json, :parameters_json, :expires_at, :code) 60 + ON CONFLICT(id) DO UPDATE SET 61 + did = excluded.did, 62 + device_id = excluded.device_id, 63 + client_id = excluded.client_id, 64 + client_auth_json = excluded.client_auth_json, 65 + parameters_json = excluded.parameters_json, 66 + expires_at = excluded.expires_at, 67 + code = excluded.code', 68 + [ 69 + 'id' => $request->getId(), 70 + 'did' => $request->getDid(), 71 + 'device_id' => $request->getDeviceId(), 72 + 'client_id' => $request->getClientId(), 73 + 'client_auth_json' => $clientAuthJson, 74 + 'parameters_json' => json_encode($request->getParameters(), JSON_THROW_ON_ERROR), 75 + 'expires_at' => $request->getExpiresAt()->format(DATE_ATOM), 76 + 'code' => $request->getCode(), 77 + ] 78 + ); 79 + } 80 + 81 + public function deleteById(string $id): void 82 + { 83 + $this->db->execute('DELETE FROM authorization_request WHERE id = ?', [$id]); 84 + } 85 + 86 + public function deleteExpired(): int 87 + { 88 + return $this->db->execute( 89 + 'DELETE FROM authorization_request WHERE expires_at < ?', 90 + [(new DateTimeImmutable())->format(DATE_ATOM)] 91 + ); 92 + } 93 + 94 + /** 95 + * @param array<string, mixed> $row 96 + */ 97 + private function hydrate(array $row): AuthorizationRequest 98 + { 99 + $clientAuthJson = Row::nstr($row, 'client_auth_json'); 100 + $clientAuth = $clientAuthJson === null 101 + ? null 102 + : json_decode($clientAuthJson, true, 512, JSON_THROW_ON_ERROR); 103 + $parameters = json_decode(Row::str($row, 'parameters_json'), true, 512, JSON_THROW_ON_ERROR); 104 + 105 + assert(is_array($parameters)); 106 + assert($clientAuth === null || is_array($clientAuth)); 107 + /** @var array<string, mixed> $parameters */ 108 + /** @var array<string, mixed>|null $clientAuth */ 109 + 110 + return new AuthorizationRequest( 111 + id: Row::str($row, 'id'), 112 + did: Row::nstr($row, 'did'), 113 + deviceId: Row::nstr($row, 'device_id'), 114 + clientId: Row::str($row, 'client_id'), 115 + clientAuth: $clientAuth, 116 + parameters: $parameters, 117 + expiresAt: new DateTimeImmutable(Row::str($row, 'expires_at')), 118 + code: Row::nstr($row, 'code'), 119 + ); 120 + } 121 + }
+100
src/Infrastructure/Persistence/OAuth/SqliteAuthorizedClientRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\OAuth; 6 + 7 + use App\Domain\OAuth\AuthorizedClient; 8 + use App\Domain\OAuth\AuthorizedClientNotFoundException; 9 + use App\Domain\OAuth\AuthorizedClientRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteAuthorizedClientRepository implements AuthorizedClientRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function findByDidAndClientId(string $did, string $clientId): AuthorizedClient 21 + { 22 + $row = $this->db->fetchOne( 23 + 'SELECT * FROM authorized_client WHERE did = ? AND client_id = ?', 24 + [$did, $clientId] 25 + ); 26 + 27 + if ($row === null) { 28 + throw new AuthorizedClientNotFoundException(); 29 + } 30 + 31 + return $this->hydrate($row); 32 + } 33 + 34 + /** 35 + * @return AuthorizedClient[] 36 + */ 37 + public function findAllForDid(string $did): array 38 + { 39 + $rows = $this->db->fetchAll( 40 + 'SELECT * FROM authorized_client WHERE did = ? ORDER BY client_id', 41 + [$did] 42 + ); 43 + 44 + $result = []; 45 + foreach ($rows as $row) { 46 + $result[] = $this->hydrate($row); 47 + } 48 + return $result; 49 + } 50 + 51 + public function save(AuthorizedClient $authorizedClient): void 52 + { 53 + $this->db->execute( 54 + 'INSERT INTO authorized_client (did, client_id, created_at, updated_at, data_json) 55 + VALUES (:did, :client_id, :created_at, :updated_at, :data_json) 56 + ON CONFLICT(did, client_id) DO UPDATE SET 57 + created_at = excluded.created_at, 58 + updated_at = excluded.updated_at, 59 + data_json = excluded.data_json', 60 + [ 61 + 'did' => $authorizedClient->getDid(), 62 + 'client_id' => $authorizedClient->getClientId(), 63 + 'created_at' => $authorizedClient->getCreatedAt()->format(DATE_ATOM), 64 + 'updated_at' => $authorizedClient->getUpdatedAt()->format(DATE_ATOM), 65 + 'data_json' => json_encode($authorizedClient->getData(), JSON_THROW_ON_ERROR), 66 + ] 67 + ); 68 + } 69 + 70 + public function deleteByDidAndClientId(string $did, string $clientId): void 71 + { 72 + $this->db->execute( 73 + 'DELETE FROM authorized_client WHERE did = ? AND client_id = ?', 74 + [$did, $clientId] 75 + ); 76 + } 77 + 78 + public function deleteAllForDid(string $did): void 79 + { 80 + $this->db->execute('DELETE FROM authorized_client WHERE did = ?', [$did]); 81 + } 82 + 83 + /** 84 + * @param array<string, mixed> $row 85 + */ 86 + private function hydrate(array $row): AuthorizedClient 87 + { 88 + $data = json_decode(Row::str($row, 'data_json'), true, 512, JSON_THROW_ON_ERROR); 89 + assert(is_array($data)); 90 + /** @var array<string, mixed> $data */ 91 + 92 + return new AuthorizedClient( 93 + did: Row::str($row, 'did'), 94 + clientId: Row::str($row, 'client_id'), 95 + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), 96 + updatedAt: new DateTimeImmutable(Row::str($row, 'updated_at')), 97 + data: $data, 98 + ); 99 + } 100 + }
+61
src/Infrastructure/Persistence/OAuth/SqliteDeviceRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\OAuth; 6 + 7 + use App\Domain\OAuth\Device; 8 + use App\Domain\OAuth\DeviceNotFoundException; 9 + use App\Domain\OAuth\DeviceRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteDeviceRepository implements DeviceRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function findById(string $id): Device 21 + { 22 + $row = $this->db->fetchOne('SELECT * FROM device WHERE id = ?', [$id]); 23 + 24 + if ($row === null) { 25 + throw new DeviceNotFoundException(); 26 + } 27 + 28 + return new Device( 29 + id: Row::str($row, 'id'), 30 + sessionId: Row::str($row, 'session_id'), 31 + userAgent: Row::nstr($row, 'user_agent'), 32 + ipAddress: Row::str($row, 'ip_address'), 33 + lastSeenAt: new DateTimeImmutable(Row::str($row, 'last_seen_at')), 34 + ); 35 + } 36 + 37 + public function save(Device $device): void 38 + { 39 + $this->db->execute( 40 + 'INSERT INTO device (id, session_id, user_agent, ip_address, last_seen_at) 41 + VALUES (:id, :session_id, :user_agent, :ip_address, :last_seen_at) 42 + ON CONFLICT(id) DO UPDATE SET 43 + session_id = excluded.session_id, 44 + user_agent = excluded.user_agent, 45 + ip_address = excluded.ip_address, 46 + last_seen_at = excluded.last_seen_at', 47 + [ 48 + 'id' => $device->getId(), 49 + 'session_id' => $device->getSessionId(), 50 + 'user_agent' => $device->getUserAgent(), 51 + 'ip_address' => $device->getIpAddress(), 52 + 'last_seen_at' => $device->getLastSeenAt()->format(DATE_ATOM), 53 + ] 54 + ); 55 + } 56 + 57 + public function deleteById(string $id): void 58 + { 59 + $this->db->execute('DELETE FROM device WHERE id = ?', [$id]); 60 + } 61 + }
+160
src/Infrastructure/Persistence/OAuth/SqliteOAuthTokenRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\OAuth; 6 + 7 + use App\Domain\OAuth\OAuthToken; 8 + use App\Domain\OAuth\OAuthTokenNotFoundException; 9 + use App\Domain\OAuth\OAuthTokenRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteOAuthTokenRepository implements OAuthTokenRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function findByTokenId(string $tokenId): OAuthToken 21 + { 22 + return $this->fetchOneBy('token_id = ?', [$tokenId]); 23 + } 24 + 25 + public function findByCode(string $code): OAuthToken 26 + { 27 + return $this->fetchOneBy('code = ?', [$code]); 28 + } 29 + 30 + public function findByRefreshToken(string $refreshToken): OAuthToken 31 + { 32 + return $this->fetchOneBy('current_refresh_token = ?', [$refreshToken]); 33 + } 34 + 35 + /** 36 + * @return OAuthToken[] 37 + */ 38 + public function findAllForDid(string $did): array 39 + { 40 + $rows = $this->db->fetchAll( 41 + 'SELECT * FROM oauth_token WHERE did = ? ORDER BY id', 42 + [$did] 43 + ); 44 + 45 + $result = []; 46 + foreach ($rows as $row) { 47 + $result[] = $this->hydrate($row); 48 + } 49 + return $result; 50 + } 51 + 52 + public function save(OAuthToken $token): void 53 + { 54 + $details = $token->getDetails(); 55 + $detailsJson = $details === null 56 + ? null 57 + : json_encode($details, JSON_THROW_ON_ERROR); 58 + 59 + $this->db->execute( 60 + 'INSERT INTO oauth_token 61 + (id, did, token_id, created_at, updated_at, expires_at, client_id, 62 + client_auth_json, device_id, parameters_json, details_json, code, 63 + current_refresh_token, scope) 64 + VALUES 65 + (:id, :did, :token_id, :created_at, :updated_at, :expires_at, :client_id, 66 + :client_auth_json, :device_id, :parameters_json, :details_json, :code, 67 + :current_refresh_token, :scope) 68 + ON CONFLICT(token_id) DO UPDATE SET 69 + did = excluded.did, 70 + created_at = excluded.created_at, 71 + updated_at = excluded.updated_at, 72 + expires_at = excluded.expires_at, 73 + client_id = excluded.client_id, 74 + client_auth_json = excluded.client_auth_json, 75 + device_id = excluded.device_id, 76 + parameters_json = excluded.parameters_json, 77 + details_json = excluded.details_json, 78 + code = excluded.code, 79 + current_refresh_token = excluded.current_refresh_token, 80 + scope = excluded.scope', 81 + [ 82 + 'id' => $token->getId() === 0 ? null : $token->getId(), 83 + 'did' => $token->getDid(), 84 + 'token_id' => $token->getTokenId(), 85 + 'created_at' => $token->getCreatedAt()->format(DATE_ATOM), 86 + 'updated_at' => $token->getUpdatedAt()->format(DATE_ATOM), 87 + 'expires_at' => $token->getExpiresAt()->format(DATE_ATOM), 88 + 'client_id' => $token->getClientId(), 89 + 'client_auth_json' => json_encode($token->getClientAuth(), JSON_THROW_ON_ERROR), 90 + 'device_id' => $token->getDeviceId(), 91 + 'parameters_json' => json_encode($token->getParameters(), JSON_THROW_ON_ERROR), 92 + 'details_json' => $detailsJson, 93 + 'code' => $token->getCode(), 94 + 'current_refresh_token' => $token->getCurrentRefreshToken(), 95 + 'scope' => $token->getScope(), 96 + ] 97 + ); 98 + } 99 + 100 + public function deleteByTokenId(string $tokenId): void 101 + { 102 + $this->db->execute('DELETE FROM oauth_token WHERE token_id = ?', [$tokenId]); 103 + } 104 + 105 + public function deleteAllForDid(string $did): void 106 + { 107 + $this->db->execute('DELETE FROM oauth_token WHERE did = ?', [$did]); 108 + } 109 + 110 + /** 111 + * @param array<string|int, scalar|null> $params 112 + */ 113 + private function fetchOneBy(string $where, array $params): OAuthToken 114 + { 115 + $row = $this->db->fetchOne("SELECT * FROM oauth_token WHERE $where", $params); 116 + 117 + if ($row === null) { 118 + throw new OAuthTokenNotFoundException(); 119 + } 120 + 121 + return $this->hydrate($row); 122 + } 123 + 124 + /** 125 + * @param array<string, mixed> $row 126 + */ 127 + private function hydrate(array $row): OAuthToken 128 + { 129 + $clientAuth = json_decode(Row::str($row, 'client_auth_json'), true, 512, JSON_THROW_ON_ERROR); 130 + $parameters = json_decode(Row::str($row, 'parameters_json'), true, 512, JSON_THROW_ON_ERROR); 131 + $detailsJson = Row::nstr($row, 'details_json'); 132 + $details = $detailsJson === null 133 + ? null 134 + : json_decode($detailsJson, true, 512, JSON_THROW_ON_ERROR); 135 + 136 + assert(is_array($clientAuth)); 137 + assert(is_array($parameters)); 138 + assert($details === null || is_array($details)); 139 + /** @var array<string, mixed> $clientAuth */ 140 + /** @var array<string, mixed> $parameters */ 141 + /** @var array<string, mixed>|null $details */ 142 + 143 + return new OAuthToken( 144 + id: Row::int($row, 'id'), 145 + did: Row::str($row, 'did'), 146 + tokenId: Row::str($row, 'token_id'), 147 + createdAt: new DateTimeImmutable(Row::str($row, 'created_at')), 148 + updatedAt: new DateTimeImmutable(Row::str($row, 'updated_at')), 149 + expiresAt: new DateTimeImmutable(Row::str($row, 'expires_at')), 150 + clientId: Row::str($row, 'client_id'), 151 + clientAuth: $clientAuth, 152 + deviceId: Row::nstr($row, 'device_id'), 153 + parameters: $parameters, 154 + details: $details, 155 + code: Row::nstr($row, 'code'), 156 + currentRefreshToken: Row::nstr($row, 'current_refresh_token'), 157 + scope: Row::nstr($row, 'scope'), 158 + ); 159 + } 160 + }
+46
src/Infrastructure/Persistence/OAuth/SqliteUsedRefreshTokenRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\OAuth; 6 + 7 + use App\Domain\OAuth\UsedRefreshToken; 8 + use App\Domain\OAuth\UsedRefreshTokenRepository; 9 + use App\Infrastructure\Database\Database; 10 + 11 + class SqliteUsedRefreshTokenRepository implements UsedRefreshTokenRepository 12 + { 13 + public function __construct(private readonly Database $db) 14 + { 15 + } 16 + 17 + public function exists(string $refreshToken): bool 18 + { 19 + $stmt = $this->db->pdo()->prepare( 20 + 'SELECT 1 FROM used_refresh_token WHERE refresh_token = ?' 21 + ); 22 + $stmt->execute([$refreshToken]); 23 + 24 + return $stmt->fetchColumn() !== false; 25 + } 26 + 27 + public function save(UsedRefreshToken $usedRefreshToken): void 28 + { 29 + $stmt = $this->db->pdo()->prepare( 30 + 'INSERT INTO used_refresh_token (refresh_token, token_id) VALUES (?, ?) 31 + ON CONFLICT(refresh_token) DO UPDATE SET token_id = excluded.token_id' 32 + ); 33 + $stmt->execute([ 34 + $usedRefreshToken->getRefreshToken(), 35 + $usedRefreshToken->getTokenId(), 36 + ]); 37 + } 38 + 39 + public function deleteAllForTokenId(int $tokenId): void 40 + { 41 + $stmt = $this->db->pdo()->prepare( 42 + 'DELETE FROM used_refresh_token WHERE token_id = ?' 43 + ); 44 + $stmt->execute([$tokenId]); 45 + } 46 + }
-93
src/Infrastructure/Persistence/Preference/InMemoryAccountPrefRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Preference; 6 - 7 - use App\Domain\Preference\AccountPref; 8 - use App\Domain\Preference\AccountPrefNotFoundException; 9 - use App\Domain\Preference\AccountPrefRepository; 10 - 11 - class InMemoryAccountPrefRepository implements AccountPrefRepository 12 - { 13 - /** @var array<int, AccountPref> keyed by id */ 14 - private array $prefs = []; 15 - 16 - private int $nextId = 1; 17 - 18 - /** 19 - * @param AccountPref[] $seeds 20 - */ 21 - public function __construct(array $seeds = []) 22 - { 23 - foreach ($seeds as $pref) { 24 - $this->prefs[$pref->getId()] = $pref; 25 - if ($pref->getId() >= $this->nextId) { 26 - $this->nextId = $pref->getId() + 1; 27 - } 28 - } 29 - } 30 - 31 - /** 32 - * @return AccountPref[] 33 - */ 34 - public function findAll(): array 35 - { 36 - return array_values($this->prefs); 37 - } 38 - 39 - /** 40 - * @return AccountPref[] 41 - */ 42 - public function findByName(string $name): array 43 - { 44 - return array_values( 45 - array_filter( 46 - $this->prefs, 47 - fn(AccountPref $p) => $p->getName() === $name, 48 - ) 49 - ); 50 - } 51 - 52 - public function findById(int $id): AccountPref 53 - { 54 - if (!isset($this->prefs[$id])) { 55 - throw new AccountPrefNotFoundException(); 56 - } 57 - 58 - return $this->prefs[$id]; 59 - } 60 - 61 - public function save(AccountPref $pref): AccountPref 62 - { 63 - $id = $pref->getId() === 0 ? $this->nextId++ : $pref->getId(); 64 - 65 - $saved = new AccountPref( 66 - id: $id, 67 - name: $pref->getName(), 68 - valueJson: $pref->getValueJson(), 69 - ); 70 - $this->prefs[$id] = $saved; 71 - 72 - return $saved; 73 - } 74 - 75 - public function deleteById(int $id): void 76 - { 77 - unset($this->prefs[$id]); 78 - } 79 - 80 - public function deleteByName(string $name): void 81 - { 82 - foreach ($this->prefs as $id => $pref) { 83 - if ($pref->getName() === $name) { 84 - unset($this->prefs[$id]); 85 - } 86 - } 87 - } 88 - 89 - public function deleteAll(): void 90 - { 91 - $this->prefs = []; 92 - } 93 - }
+117
src/Infrastructure/Persistence/Preference/SqliteAccountPrefRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Preference; 6 + 7 + use App\Domain\Preference\AccountPref; 8 + use App\Domain\Preference\AccountPrefNotFoundException; 9 + use App\Domain\Preference\AccountPrefRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + 13 + class SqliteAccountPrefRepository implements AccountPrefRepository 14 + { 15 + public function __construct(private readonly Database $db) 16 + { 17 + } 18 + 19 + /** 20 + * @return AccountPref[] 21 + */ 22 + public function findAll(): array 23 + { 24 + $rows = $this->db->fetchAll('SELECT * FROM account_pref ORDER BY id'); 25 + 26 + $result = []; 27 + foreach ($rows as $row) { 28 + $result[] = $this->hydrate($row); 29 + } 30 + return $result; 31 + } 32 + 33 + /** 34 + * @return AccountPref[] 35 + */ 36 + public function findByName(string $name): array 37 + { 38 + $rows = $this->db->fetchAll( 39 + 'SELECT * FROM account_pref WHERE name = ? ORDER BY id', 40 + [$name] 41 + ); 42 + 43 + $result = []; 44 + foreach ($rows as $row) { 45 + $result[] = $this->hydrate($row); 46 + } 47 + return $result; 48 + } 49 + 50 + public function findById(int $id): AccountPref 51 + { 52 + $row = $this->db->fetchOne('SELECT * FROM account_pref WHERE id = ?', [$id]); 53 + 54 + if ($row === null) { 55 + throw new AccountPrefNotFoundException(); 56 + } 57 + 58 + return $this->hydrate($row); 59 + } 60 + 61 + public function save(AccountPref $pref): AccountPref 62 + { 63 + if ($pref->getId() === 0) { 64 + $this->db->execute( 65 + 'INSERT INTO account_pref (name, value_json) VALUES (?, ?)', 66 + [$pref->getName(), $pref->getValueJson()] 67 + ); 68 + 69 + return new AccountPref( 70 + id: (int) $this->db->pdo()->lastInsertId(), 71 + name: $pref->getName(), 72 + valueJson: $pref->getValueJson(), 73 + ); 74 + } 75 + 76 + $this->db->execute( 77 + 'INSERT INTO account_pref (id, name, value_json) VALUES (:id, :name, :value_json) 78 + ON CONFLICT(id) DO UPDATE SET 79 + name = excluded.name, 80 + value_json = excluded.value_json', 81 + [ 82 + 'id' => $pref->getId(), 83 + 'name' => $pref->getName(), 84 + 'value_json' => $pref->getValueJson(), 85 + ] 86 + ); 87 + 88 + return $pref; 89 + } 90 + 91 + public function deleteById(int $id): void 92 + { 93 + $this->db->execute('DELETE FROM account_pref WHERE id = ?', [$id]); 94 + } 95 + 96 + public function deleteByName(string $name): void 97 + { 98 + $this->db->execute('DELETE FROM account_pref WHERE name = ?', [$name]); 99 + } 100 + 101 + public function deleteAll(): void 102 + { 103 + $this->db->pdo()->exec('DELETE FROM account_pref'); 104 + } 105 + 106 + /** 107 + * @param array<string, mixed> $row 108 + */ 109 + private function hydrate(array $row): AccountPref 110 + { 111 + return new AccountPref( 112 + id: Row::int($row, 'id'), 113 + name: Row::str($row, 'name'), 114 + valueJson: Row::str($row, 'value_json'), 115 + ); 116 + } 117 + }
-77
src/Infrastructure/Persistence/Record/InMemoryBacklinkRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Record; 6 - 7 - use App\Domain\Record\Backlink; 8 - use App\Domain\Record\BacklinkRepository; 9 - 10 - class InMemoryBacklinkRepository implements BacklinkRepository 11 - { 12 - /** @var Backlink[] */ 13 - private array $entries = []; 14 - 15 - /** 16 - * @param Backlink[] $seeds 17 - */ 18 - public function __construct(array $seeds = []) 19 - { 20 - $this->entries = $seeds; 21 - } 22 - 23 - /** 24 - * @return Backlink[] 25 - */ 26 - public function findByUri(string $uri): array 27 - { 28 - return array_values( 29 - array_filter( 30 - $this->entries, 31 - fn(Backlink $b) => $b->getUri() === $uri, 32 - ) 33 - ); 34 - } 35 - 36 - /** 37 - * @return Backlink[] 38 - */ 39 - public function findByLinkTo(string $linkTo): array 40 - { 41 - return array_values( 42 - array_filter( 43 - $this->entries, 44 - fn(Backlink $b) => $b->getLinkTo() === $linkTo, 45 - ) 46 - ); 47 - } 48 - 49 - public function save(Backlink $backlink): void 50 - { 51 - foreach ($this->entries as $existing) { 52 - if ( 53 - $existing->getUri() === $backlink->getUri() 54 - && $existing->getPath() === $backlink->getPath() 55 - && $existing->getLinkTo() === $backlink->getLinkTo() 56 - ) { 57 - return; 58 - } 59 - } 60 - $this->entries[] = $backlink; 61 - } 62 - 63 - public function deleteByUri(string $uri): void 64 - { 65 - $this->entries = array_values( 66 - array_filter( 67 - $this->entries, 68 - fn(Backlink $b) => $b->getUri() !== $uri, 69 - ) 70 - ); 71 - } 72 - 73 - public function deleteAll(): void 74 - { 75 - $this->entries = []; 76 - } 77 - }
-81
src/Infrastructure/Persistence/Record/InMemoryRecordBlobRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Record; 6 - 7 - use App\Domain\Record\RecordBlob; 8 - use App\Domain\Record\RecordBlobRepository; 9 - 10 - class InMemoryRecordBlobRepository implements RecordBlobRepository 11 - { 12 - /** @var RecordBlob[] */ 13 - private array $entries = []; 14 - 15 - /** 16 - * @param RecordBlob[] $seeds 17 - */ 18 - public function __construct(array $seeds = []) 19 - { 20 - $this->entries = $seeds; 21 - } 22 - 23 - /** 24 - * @return RecordBlob[] 25 - */ 26 - public function findByBlobCid(string $blobCid): array 27 - { 28 - return array_values( 29 - array_filter( 30 - $this->entries, 31 - fn(RecordBlob $rb) => $rb->getBlobCid() === $blobCid, 32 - ) 33 - ); 34 - } 35 - 36 - /** 37 - * @return RecordBlob[] 38 - */ 39 - public function findByRecordUri(string $recordUri): array 40 - { 41 - return array_values( 42 - array_filter( 43 - $this->entries, 44 - fn(RecordBlob $rb) => $rb->getRecordUri() === $recordUri, 45 - ) 46 - ); 47 - } 48 - 49 - public function save(RecordBlob $recordBlob): void 50 - { 51 - foreach ($this->entries as $existing) { 52 - if ( 53 - $existing->getBlobCid() === $recordBlob->getBlobCid() 54 - && $existing->getRecordUri() === $recordBlob->getRecordUri() 55 - ) { 56 - return; 57 - } 58 - } 59 - $this->entries[] = $recordBlob; 60 - } 61 - 62 - public function deleteByRecordUri(string $recordUri): void 63 - { 64 - $this->entries = array_values( 65 - array_filter( 66 - $this->entries, 67 - fn(RecordBlob $rb) => $rb->getRecordUri() !== $recordUri, 68 - ) 69 - ); 70 - } 71 - 72 - public function deleteByBlobCid(string $blobCid): void 73 - { 74 - $this->entries = array_values( 75 - array_filter( 76 - $this->entries, 77 - fn(RecordBlob $rb) => $rb->getBlobCid() !== $blobCid, 78 - ) 79 - ); 80 - } 81 - }
-70
src/Infrastructure/Persistence/Record/InMemoryRecordRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Record; 6 - 7 - use App\Domain\Record\Record; 8 - use App\Domain\Record\RecordNotFoundException; 9 - use App\Domain\Record\RecordRepository; 10 - 11 - class InMemoryRecordRepository implements RecordRepository 12 - { 13 - /** @var array<string, Record> keyed by uri */ 14 - private array $records = []; 15 - 16 - /** 17 - * @param Record[] $seeds 18 - */ 19 - public function __construct(array $seeds = []) 20 - { 21 - foreach ($seeds as $record) { 22 - $this->records[$record->getUri()] = $record; 23 - } 24 - } 25 - 26 - public function findByUri(string $uri): Record 27 - { 28 - if (!isset($this->records[$uri])) { 29 - throw new RecordNotFoundException(); 30 - } 31 - 32 - return $this->records[$uri]; 33 - } 34 - 35 - /** 36 - * @return Record[] 37 - */ 38 - public function findByCollection(string $collection): array 39 - { 40 - return array_values( 41 - array_filter( 42 - $this->records, 43 - fn(Record $r) => $r->getCollection() === $collection, 44 - ) 45 - ); 46 - } 47 - 48 - /** 49 - * @return Record[] 50 - */ 51 - public function findAll(): array 52 - { 53 - return array_values($this->records); 54 - } 55 - 56 - public function save(Record $record): void 57 - { 58 - $this->records[$record->getUri()] = $record; 59 - } 60 - 61 - public function deleteByUri(string $uri): void 62 - { 63 - unset($this->records[$uri]); 64 - } 65 - 66 - public function deleteAll(): void 67 - { 68 - $this->records = []; 69 - } 70 - }
+85
src/Infrastructure/Persistence/Record/SqliteBacklinkRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Record; 6 + 7 + use App\Domain\Record\Backlink; 8 + use App\Domain\Record\BacklinkRepository; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Row; 11 + 12 + class SqliteBacklinkRepository implements BacklinkRepository 13 + { 14 + public function __construct(private readonly Database $db) 15 + { 16 + } 17 + 18 + /** 19 + * @return Backlink[] 20 + */ 21 + public function findByUri(string $uri): array 22 + { 23 + $rows = $this->db->fetchAll( 24 + 'SELECT * FROM backlink WHERE uri = ? ORDER BY path, link_to', 25 + [$uri] 26 + ); 27 + 28 + $result = []; 29 + foreach ($rows as $row) { 30 + $result[] = $this->hydrate($row); 31 + } 32 + return $result; 33 + } 34 + 35 + /** 36 + * @return Backlink[] 37 + */ 38 + public function findByLinkTo(string $linkTo): array 39 + { 40 + $rows = $this->db->fetchAll( 41 + 'SELECT * FROM backlink WHERE link_to = ? ORDER BY uri, path', 42 + [$linkTo] 43 + ); 44 + 45 + $result = []; 46 + foreach ($rows as $row) { 47 + $result[] = $this->hydrate($row); 48 + } 49 + return $result; 50 + } 51 + 52 + public function save(Backlink $backlink): void 53 + { 54 + $this->db->execute( 55 + 'INSERT OR IGNORE INTO backlink (uri, path, link_to) VALUES (?, ?, ?)', 56 + [ 57 + $backlink->getUri(), 58 + $backlink->getPath(), 59 + $backlink->getLinkTo(), 60 + ] 61 + ); 62 + } 63 + 64 + public function deleteByUri(string $uri): void 65 + { 66 + $this->db->execute('DELETE FROM backlink WHERE uri = ?', [$uri]); 67 + } 68 + 69 + public function deleteAll(): void 70 + { 71 + $this->db->pdo()->exec('DELETE FROM backlink'); 72 + } 73 + 74 + /** 75 + * @param array<string, mixed> $row 76 + */ 77 + private function hydrate(array $row): Backlink 78 + { 79 + return new Backlink( 80 + uri: Row::str($row, 'uri'), 81 + path: Row::str($row, 'path'), 82 + linkTo: Row::str($row, 'link_to'), 83 + ); 84 + } 85 + }
+80
src/Infrastructure/Persistence/Record/SqliteRecordBlobRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Record; 6 + 7 + use App\Domain\Record\RecordBlob; 8 + use App\Domain\Record\RecordBlobRepository; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Row; 11 + 12 + class SqliteRecordBlobRepository implements RecordBlobRepository 13 + { 14 + public function __construct(private readonly Database $db) 15 + { 16 + } 17 + 18 + /** 19 + * @return RecordBlob[] 20 + */ 21 + public function findByBlobCid(string $blobCid): array 22 + { 23 + $rows = $this->db->fetchAll( 24 + 'SELECT * FROM record_blob WHERE blob_cid = ? ORDER BY record_uri', 25 + [$blobCid] 26 + ); 27 + 28 + $result = []; 29 + foreach ($rows as $row) { 30 + $result[] = $this->hydrate($row); 31 + } 32 + return $result; 33 + } 34 + 35 + /** 36 + * @return RecordBlob[] 37 + */ 38 + public function findByRecordUri(string $recordUri): array 39 + { 40 + $rows = $this->db->fetchAll( 41 + 'SELECT * FROM record_blob WHERE record_uri = ? ORDER BY blob_cid', 42 + [$recordUri] 43 + ); 44 + 45 + $result = []; 46 + foreach ($rows as $row) { 47 + $result[] = $this->hydrate($row); 48 + } 49 + return $result; 50 + } 51 + 52 + public function save(RecordBlob $recordBlob): void 53 + { 54 + $this->db->execute( 55 + 'INSERT OR IGNORE INTO record_blob (blob_cid, record_uri) VALUES (?, ?)', 56 + [$recordBlob->getBlobCid(), $recordBlob->getRecordUri()] 57 + ); 58 + } 59 + 60 + public function deleteByRecordUri(string $recordUri): void 61 + { 62 + $this->db->execute('DELETE FROM record_blob WHERE record_uri = ?', [$recordUri]); 63 + } 64 + 65 + public function deleteByBlobCid(string $blobCid): void 66 + { 67 + $this->db->execute('DELETE FROM record_blob WHERE blob_cid = ?', [$blobCid]); 68 + } 69 + 70 + /** 71 + * @param array<string, mixed> $row 72 + */ 73 + private function hydrate(array $row): RecordBlob 74 + { 75 + return new RecordBlob( 76 + blobCid: Row::str($row, 'blob_cid'), 77 + recordUri: Row::str($row, 'record_uri'), 78 + ); 79 + } 80 + }
+111
src/Infrastructure/Persistence/Record/SqliteRecordRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Record; 6 + 7 + use App\Domain\Record\Record; 8 + use App\Domain\Record\RecordNotFoundException; 9 + use App\Domain\Record\RecordRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteRecordRepository implements RecordRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function findByUri(string $uri): Record 21 + { 22 + $row = $this->db->fetchOne('SELECT * FROM record WHERE uri = ?', [$uri]); 23 + 24 + if ($row === null) { 25 + throw new RecordNotFoundException(); 26 + } 27 + 28 + return $this->hydrate($row); 29 + } 30 + 31 + /** 32 + * @return Record[] 33 + */ 34 + public function findByCollection(string $collection): array 35 + { 36 + $rows = $this->db->fetchAll( 37 + 'SELECT * FROM record WHERE collection = ? ORDER BY uri', 38 + [$collection] 39 + ); 40 + 41 + $result = []; 42 + foreach ($rows as $row) { 43 + $result[] = $this->hydrate($row); 44 + } 45 + return $result; 46 + } 47 + 48 + /** 49 + * @return Record[] 50 + */ 51 + public function findAll(): array 52 + { 53 + $rows = $this->db->fetchAll('SELECT * FROM record ORDER BY uri'); 54 + 55 + $result = []; 56 + foreach ($rows as $row) { 57 + $result[] = $this->hydrate($row); 58 + } 59 + return $result; 60 + } 61 + 62 + public function save(Record $record): void 63 + { 64 + $this->db->execute( 65 + 'INSERT INTO record (uri, cid, collection, rkey, repo_rev, indexed_at, takedown_ref) 66 + VALUES (:uri, :cid, :collection, :rkey, :repo_rev, :indexed_at, :takedown_ref) 67 + ON CONFLICT(uri) DO UPDATE SET 68 + cid = excluded.cid, 69 + collection = excluded.collection, 70 + rkey = excluded.rkey, 71 + repo_rev = excluded.repo_rev, 72 + indexed_at = excluded.indexed_at, 73 + takedown_ref = excluded.takedown_ref', 74 + [ 75 + 'uri' => $record->getUri(), 76 + 'cid' => $record->getCid(), 77 + 'collection' => $record->getCollection(), 78 + 'rkey' => $record->getRkey(), 79 + 'repo_rev' => $record->getRepoRev(), 80 + 'indexed_at' => $record->getIndexedAt()->format(DATE_ATOM), 81 + 'takedown_ref' => $record->getTakedownRef(), 82 + ] 83 + ); 84 + } 85 + 86 + public function deleteByUri(string $uri): void 87 + { 88 + $this->db->execute('DELETE FROM record WHERE uri = ?', [$uri]); 89 + } 90 + 91 + public function deleteAll(): void 92 + { 93 + $this->db->pdo()->exec('DELETE FROM record'); 94 + } 95 + 96 + /** 97 + * @param array<string, mixed> $row 98 + */ 99 + private function hydrate(array $row): Record 100 + { 101 + return new Record( 102 + uri: Row::str($row, 'uri'), 103 + cid: Row::str($row, 'cid'), 104 + collection: Row::str($row, 'collection'), 105 + rkey: Row::str($row, 'rkey'), 106 + repoRev: Row::str($row, 'repo_rev'), 107 + indexedAt: new DateTimeImmutable(Row::str($row, 'indexed_at')), 108 + takedownRef: Row::nstr($row, 'takedown_ref'), 109 + ); 110 + } 111 + }
-70
src/Infrastructure/Persistence/Repo/InMemoryRepoBlockRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Repo; 6 - 7 - use App\Domain\Repo\RepoBlock; 8 - use App\Domain\Repo\RepoBlockNotFoundException; 9 - use App\Domain\Repo\RepoBlockRepository; 10 - 11 - class InMemoryRepoBlockRepository implements RepoBlockRepository 12 - { 13 - /** @var array<string, RepoBlock> keyed by cid */ 14 - private array $blocks = []; 15 - 16 - /** 17 - * @param RepoBlock[] $seeds 18 - */ 19 - public function __construct(array $seeds = []) 20 - { 21 - foreach ($seeds as $block) { 22 - $this->blocks[$block->getCid()] = $block; 23 - } 24 - } 25 - 26 - public function findByCid(string $cid): RepoBlock 27 - { 28 - if (!isset($this->blocks[$cid])) { 29 - throw new RepoBlockNotFoundException(); 30 - } 31 - 32 - return $this->blocks[$cid]; 33 - } 34 - 35 - /** 36 - * @return RepoBlock[] 37 - */ 38 - public function findByRepoRev(string $repoRev): array 39 - { 40 - return array_values( 41 - array_filter( 42 - $this->blocks, 43 - fn(RepoBlock $b) => $b->getRepoRev() === $repoRev, 44 - ) 45 - ); 46 - } 47 - 48 - /** 49 - * @return RepoBlock[] 50 - */ 51 - public function findAll(): array 52 - { 53 - return array_values($this->blocks); 54 - } 55 - 56 - public function save(RepoBlock $block): void 57 - { 58 - $this->blocks[$block->getCid()] = $block; 59 - } 60 - 61 - public function deleteByCid(string $cid): void 62 - { 63 - unset($this->blocks[$cid]); 64 - } 65 - 66 - public function deleteAll(): void 67 - { 68 - $this->blocks = []; 69 - } 70 - }
-44
src/Infrastructure/Persistence/Repo/InMemoryRepoRootRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Repo; 6 - 7 - use App\Domain\Repo\RepoRoot; 8 - use App\Domain\Repo\RepoRootNotFoundException; 9 - use App\Domain\Repo\RepoRootRepository; 10 - 11 - class InMemoryRepoRootRepository implements RepoRootRepository 12 - { 13 - /** @var array<string, RepoRoot> keyed by did */ 14 - private array $roots = []; 15 - 16 - /** 17 - * @param RepoRoot[] $seeds 18 - */ 19 - public function __construct(array $seeds = []) 20 - { 21 - foreach ($seeds as $root) { 22 - $this->roots[$root->getDid()] = $root; 23 - } 24 - } 25 - 26 - public function findByDid(string $did): RepoRoot 27 - { 28 - if (!isset($this->roots[$did])) { 29 - throw new RepoRootNotFoundException(); 30 - } 31 - 32 - return $this->roots[$did]; 33 - } 34 - 35 - public function upsert(RepoRoot $repoRoot): void 36 - { 37 - $this->roots[$repoRoot->getDid()] = $repoRoot; 38 - } 39 - 40 - public function deleteByDid(string $did): void 41 - { 42 - unset($this->roots[$did]); 43 - } 44 - }
+112
src/Infrastructure/Persistence/Repo/SqliteRepoBlockRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Repo; 6 + 7 + use App\Domain\Repo\RepoBlock; 8 + use App\Domain\Repo\RepoBlockNotFoundException; 9 + use App\Domain\Repo\RepoBlockRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use PDO; 13 + 14 + class SqliteRepoBlockRepository implements RepoBlockRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function findByCid(string $cid): RepoBlock 21 + { 22 + $row = $this->db->fetchOne('SELECT * FROM repo_block WHERE cid = ?', [$cid]); 23 + 24 + if ($row === null) { 25 + throw new RepoBlockNotFoundException(); 26 + } 27 + 28 + return $this->hydrate($row); 29 + } 30 + 31 + /** 32 + * @return RepoBlock[] 33 + */ 34 + public function findByRepoRev(string $repoRev): array 35 + { 36 + $rows = $this->db->fetchAll( 37 + 'SELECT * FROM repo_block WHERE repo_rev = ? ORDER BY cid', 38 + [$repoRev] 39 + ); 40 + 41 + $result = []; 42 + foreach ($rows as $row) { 43 + $result[] = $this->hydrate($row); 44 + } 45 + return $result; 46 + } 47 + 48 + /** 49 + * @return RepoBlock[] 50 + */ 51 + public function findAll(): array 52 + { 53 + $rows = $this->db->fetchAll('SELECT * FROM repo_block ORDER BY cid'); 54 + 55 + $result = []; 56 + foreach ($rows as $row) { 57 + $result[] = $this->hydrate($row); 58 + } 59 + return $result; 60 + } 61 + 62 + public function save(RepoBlock $block): void 63 + { 64 + $stmt = $this->db->pdo()->prepare( 65 + 'INSERT INTO repo_block (cid, repo_rev, size, content) 66 + VALUES (:cid, :repo_rev, :size, :content) 67 + ON CONFLICT(cid) DO UPDATE SET 68 + repo_rev = excluded.repo_rev, 69 + size = excluded.size, 70 + content = excluded.content' 71 + ); 72 + assert($stmt !== false); 73 + $stmt->bindValue(':cid', $block->getCid()); 74 + $stmt->bindValue(':repo_rev', $block->getRepoRev()); 75 + $stmt->bindValue(':size', $block->getSize(), PDO::PARAM_INT); 76 + $stmt->bindValue(':content', $block->getContent(), PDO::PARAM_LOB); 77 + $stmt->execute(); 78 + } 79 + 80 + public function deleteByCid(string $cid): void 81 + { 82 + $this->db->execute('DELETE FROM repo_block WHERE cid = ?', [$cid]); 83 + } 84 + 85 + public function deleteAll(): void 86 + { 87 + $this->db->pdo()->exec('DELETE FROM repo_block'); 88 + } 89 + 90 + /** 91 + * @param array<string, mixed> $row 92 + */ 93 + private function hydrate(array $row): RepoBlock 94 + { 95 + // Some PDO/sqlite versions return BLOB columns as resources; normalize. 96 + $content = $row['content'] ?? null; 97 + if (is_resource($content)) { 98 + $content = stream_get_contents($content); 99 + if ($content === false) { 100 + $content = ''; 101 + } 102 + } 103 + assert(is_string($content)); 104 + 105 + return new RepoBlock( 106 + cid: Row::str($row, 'cid'), 107 + repoRev: Row::str($row, 'repo_rev'), 108 + size: Row::int($row, 'size'), 109 + content: $content, 110 + ); 111 + } 112 + }
+58
src/Infrastructure/Persistence/Repo/SqliteRepoRootRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Repo; 6 + 7 + use App\Domain\Repo\RepoRoot; 8 + use App\Domain\Repo\RepoRootNotFoundException; 9 + use App\Domain\Repo\RepoRootRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Row; 12 + use DateTimeImmutable; 13 + 14 + class SqliteRepoRootRepository implements RepoRootRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function findByDid(string $did): RepoRoot 21 + { 22 + $row = $this->db->fetchOne('SELECT * FROM repo_root WHERE did = ?', [$did]); 23 + 24 + if ($row === null) { 25 + throw new RepoRootNotFoundException(); 26 + } 27 + 28 + return new RepoRoot( 29 + did: Row::str($row, 'did'), 30 + cid: Row::str($row, 'cid'), 31 + rev: Row::str($row, 'rev'), 32 + indexedAt: new DateTimeImmutable(Row::str($row, 'indexed_at')), 33 + ); 34 + } 35 + 36 + public function upsert(RepoRoot $repoRoot): void 37 + { 38 + $this->db->execute( 39 + 'INSERT INTO repo_root (did, cid, rev, indexed_at) 40 + VALUES (:did, :cid, :rev, :indexed_at) 41 + ON CONFLICT(did) DO UPDATE SET 42 + cid = excluded.cid, 43 + rev = excluded.rev, 44 + indexed_at = excluded.indexed_at', 45 + [ 46 + 'did' => $repoRoot->getDid(), 47 + 'cid' => $repoRoot->getCid(), 48 + 'rev' => $repoRoot->getRev(), 49 + 'indexed_at' => $repoRoot->getIndexedAt()->format(DATE_ATOM), 50 + ] 51 + ); 52 + } 53 + 54 + public function deleteByDid(string $did): void 55 + { 56 + $this->db->execute('DELETE FROM repo_root WHERE did = ?', [$did]); 57 + } 58 + }
-88
src/Infrastructure/Persistence/Sequencer/InMemorySequencerRepository.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace App\Infrastructure\Persistence\Sequencer; 6 - 7 - use App\Domain\Sequencer\RepoSeqEvent; 8 - use App\Domain\Sequencer\SequencerRepository; 9 - use DateTimeImmutable; 10 - 11 - class InMemorySequencerRepository implements SequencerRepository 12 - { 13 - /** @var RepoSeqEvent[] ordered by seq ascending */ 14 - private array $events = []; 15 - 16 - private int $counter; 17 - 18 - /** 19 - * @param RepoSeqEvent[] $seeds 20 - */ 21 - public function __construct(array $seeds = []) 22 - { 23 - $this->counter = 0; 24 - foreach ($seeds as $event) { 25 - $this->events[] = $event; 26 - if ($event->getSeq() > $this->counter) { 27 - $this->counter = $event->getSeq(); 28 - } 29 - } 30 - } 31 - 32 - public function append(string $did, string $eventType, string $event): int 33 - { 34 - $seq = ++$this->counter; 35 - 36 - $this->events[] = new RepoSeqEvent( 37 - seq: $seq, 38 - did: $did, 39 - eventType: $eventType, 40 - event: $event, 41 - sequencedAt: new DateTimeImmutable(), 42 - ); 43 - 44 - return $seq; 45 - } 46 - 47 - public function latestSeq(): ?int 48 - { 49 - if (empty($this->events)) { 50 - return null; 51 - } 52 - 53 - return max(array_map(fn(RepoSeqEvent $e) => $e->getSeq(), $this->events)); 54 - } 55 - 56 - /** 57 - * @return RepoSeqEvent[] 58 - */ 59 - public function readRange(int $afterSeq, int $limit = 500): array 60 - { 61 - $results = array_filter( 62 - $this->events, 63 - fn(RepoSeqEvent $e) => $e->getSeq() > $afterSeq, 64 - ); 65 - 66 - usort($results, fn(RepoSeqEvent $a, RepoSeqEvent $b) => $a->getSeq() <=> $b->getSeq()); 67 - 68 - return array_slice($results, 0, $limit); 69 - } 70 - 71 - public function invalidateForDid(string $did): void 72 - { 73 - $this->events = array_map(function (RepoSeqEvent $e) use ($did): RepoSeqEvent { 74 - if ($e->getDid() !== $did || $e->isInvalidated()) { 75 - return $e; 76 - } 77 - 78 - return new RepoSeqEvent( 79 - seq: $e->getSeq(), 80 - did: $e->getDid(), 81 - eventType: $e->getEventType(), 82 - event: $e->getEvent(), 83 - sequencedAt: $e->getSequencedAt(), 84 - invalidated: true, 85 - ); 86 - }, $this->events); 87 - } 88 - }
+88
src/Infrastructure/Persistence/Sequencer/SqliteSequencerRepository.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Infrastructure\Persistence\Sequencer; 6 + 7 + use App\Domain\Sequencer\RepoSeqEvent; 8 + use App\Domain\Sequencer\SequencerRepository; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Row; 11 + use DateTimeImmutable; 12 + use PDO; 13 + 14 + class SqliteSequencerRepository implements SequencerRepository 15 + { 16 + public function __construct(private readonly Database $db) 17 + { 18 + } 19 + 20 + public function append(string $did, string $eventType, string $event): int 21 + { 22 + $pdo = $this->db->pdo(); 23 + $stmt = $pdo->prepare( 24 + 'INSERT INTO repo_seq (did, event_type, event, sequenced_at, invalidated) 25 + VALUES (?, ?, ?, ?, 0)' 26 + ); 27 + assert($stmt !== false); 28 + $stmt->bindValue(1, $did); 29 + $stmt->bindValue(2, $eventType); 30 + $stmt->bindValue(3, $event, PDO::PARAM_LOB); 31 + $stmt->bindValue(4, (new DateTimeImmutable())->format(DATE_ATOM)); 32 + $stmt->execute(); 33 + 34 + return (int) $pdo->lastInsertId(); 35 + } 36 + 37 + public function latestSeq(): ?int 38 + { 39 + $query = $this->db->pdo()->query('SELECT MAX(seq) FROM repo_seq'); 40 + assert($query !== false); 41 + $result = $query->fetchColumn(); 42 + 43 + if ($result === false || $result === null) { 44 + return null; 45 + } 46 + 47 + return (int) $result; 48 + } 49 + 50 + /** 51 + * @return RepoSeqEvent[] 52 + */ 53 + public function readRange(int $afterSeq, int $limit = 500): array 54 + { 55 + $stmt = $this->db->pdo()->prepare( 56 + 'SELECT * FROM repo_seq WHERE seq > ? ORDER BY seq ASC LIMIT ?' 57 + ); 58 + assert($stmt !== false); 59 + $stmt->bindValue(1, $afterSeq, PDO::PARAM_INT); 60 + $stmt->bindValue(2, $limit, PDO::PARAM_INT); 61 + $stmt->execute(); 62 + 63 + /** @var list<array<string, mixed>> $rows */ 64 + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); 65 + 66 + $events = []; 67 + foreach ($rows as $row) { 68 + $events[] = new RepoSeqEvent( 69 + seq: Row::int($row, 'seq'), 70 + did: Row::str($row, 'did'), 71 + eventType: Row::str($row, 'event_type'), 72 + event: Row::str($row, 'event'), 73 + sequencedAt: new DateTimeImmutable(Row::str($row, 'sequenced_at')), 74 + invalidated: Row::bool($row, 'invalidated'), 75 + ); 76 + } 77 + 78 + return $events; 79 + } 80 + 81 + public function invalidateForDid(string $did): void 82 + { 83 + $this->db->execute( 84 + 'UPDATE repo_seq SET invalidated = 1 WHERE did = ? AND invalidated = 0', 85 + [$did] 86 + ); 87 + } 88 + }
-58
tests/Infrastructure/Persistence/Account/AppPassword/InMemoryAppPasswordRepositoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\Account\AppPassword; 6 - 7 - use App\Domain\Account\AppPassword\AppPassword; 8 - use App\Domain\Account\AppPassword\AppPasswordNotFoundException; 9 - use App\Infrastructure\Persistence\Account\AppPassword\InMemoryAppPasswordRepository; 10 - use DateTimeImmutable; 11 - use Tests\TestCase; 12 - 13 - class InMemoryAppPasswordRepositoryTest extends TestCase 14 - { 15 - private function makePassword(string $did = 'did:plc:alice', string $name = 'main'): AppPassword 16 - { 17 - return new AppPassword( 18 - did: $did, 19 - name: $name, 20 - passwordScrypt: 'hash', 21 - createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 22 - ); 23 - } 24 - 25 - public function testFindAllForDid(): void 26 - { 27 - $p1 = $this->makePassword(name: 'main'); 28 - $p2 = $this->makePassword(name: 'ci'); 29 - $repo = new InMemoryAppPasswordRepository([$p1, $p2]); 30 - 31 - $this->assertCount(2, $repo->findAllForDid('did:plc:alice')); 32 - } 33 - 34 - public function testFindByDidAndName(): void 35 - { 36 - $p = $this->makePassword(); 37 - $repo = new InMemoryAppPasswordRepository([$p]); 38 - 39 - $this->assertSame($p, $repo->findByDidAndName('did:plc:alice', 'main')); 40 - } 41 - 42 - public function testFindThrowsWhenMissing(): void 43 - { 44 - $repo = new InMemoryAppPasswordRepository(); 45 - 46 - $this->expectException(AppPasswordNotFoundException::class); 47 - $repo->findByDidAndName('did:plc:alice', 'nope'); 48 - } 49 - 50 - public function testDeleteByDidAndName(): void 51 - { 52 - $p = $this->makePassword(); 53 - $repo = new InMemoryAppPasswordRepository([$p]); 54 - $repo->deleteByDidAndName('did:plc:alice', 'main'); 55 - 56 - $this->assertEmpty($repo->findAllForDid('did:plc:alice')); 57 - } 58 - }
+70
tests/Infrastructure/Persistence/Account/AppPassword/SqliteAppPasswordRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\Account\AppPassword; 6 + 7 + use App\Domain\Account\AppPassword\AppPassword; 8 + use App\Domain\Account\AppPassword\AppPasswordNotFoundException; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\AccountSchema; 11 + use App\Infrastructure\Persistence\Account\AppPassword\SqliteAppPasswordRepository; 12 + use DateTimeImmutable; 13 + use Tests\TestCase; 14 + 15 + class SqliteAppPasswordRepositoryTest extends TestCase 16 + { 17 + private function newRepo(): SqliteAppPasswordRepository 18 + { 19 + $db = new Database(':memory:'); 20 + AccountSchema::apply($db); 21 + 22 + return new SqliteAppPasswordRepository($db); 23 + } 24 + 25 + private function makePassword(string $did = 'did:plc:alice', string $name = 'main'): AppPassword 26 + { 27 + return new AppPassword( 28 + did: $did, 29 + name: $name, 30 + passwordScrypt: 'hash', 31 + createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 32 + ); 33 + } 34 + 35 + public function testFindAllForDid(): void 36 + { 37 + $repo = $this->newRepo(); 38 + $repo->save($this->makePassword(name: 'main')); 39 + $repo->save($this->makePassword(name: 'ci')); 40 + 41 + $this->assertCount(2, $repo->findAllForDid('did:plc:alice')); 42 + } 43 + 44 + public function testFindByDidAndName(): void 45 + { 46 + $repo = $this->newRepo(); 47 + $p = $this->makePassword(); 48 + $repo->save($p); 49 + 50 + $found = $repo->findByDidAndName('did:plc:alice', 'main'); 51 + $this->assertSame('main', $found->getName()); 52 + } 53 + 54 + public function testFindThrowsWhenMissing(): void 55 + { 56 + $repo = $this->newRepo(); 57 + 58 + $this->expectException(AppPasswordNotFoundException::class); 59 + $repo->findByDidAndName('did:plc:alice', 'nope'); 60 + } 61 + 62 + public function testDeleteByDidAndName(): void 63 + { 64 + $repo = $this->newRepo(); 65 + $repo->save($this->makePassword()); 66 + $repo->deleteByDidAndName('did:plc:alice', 'main'); 67 + 68 + $this->assertEmpty($repo->findAllForDid('did:plc:alice')); 69 + } 70 + }
+24 -13
tests/Infrastructure/Persistence/Account/EmailToken/InMemoryEmailTokenRepositoryTest.php tests/Infrastructure/Persistence/Account/EmailToken/SqliteEmailTokenRepositoryTest.php
··· 6 6 7 7 use App\Domain\Account\EmailToken\EmailToken; 8 8 use App\Domain\Account\EmailToken\EmailTokenNotFoundException; 9 - use App\Infrastructure\Persistence\Account\EmailToken\InMemoryEmailTokenRepository; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\AccountSchema; 11 + use App\Infrastructure\Persistence\Account\EmailToken\SqliteEmailTokenRepository; 10 12 use DateTimeImmutable; 11 13 use Tests\TestCase; 12 14 13 - class InMemoryEmailTokenRepositoryTest extends TestCase 15 + class SqliteEmailTokenRepositoryTest extends TestCase 14 16 { 17 + private function newRepo(): SqliteEmailTokenRepository 18 + { 19 + $db = new Database(':memory:'); 20 + AccountSchema::apply($db); 21 + 22 + return new SqliteEmailTokenRepository($db); 23 + } 24 + 15 25 private function makeToken( 16 26 string $purpose = EmailToken::PURPOSE_CONFIRM_EMAIL, 17 27 string $did = 'did:plc:alice', ··· 27 37 28 38 public function testFindByPurposeAndDid(): void 29 39 { 40 + $repo = $this->newRepo(); 30 41 $t = $this->makeToken(); 31 - $repo = new InMemoryEmailTokenRepository([$t]); 42 + $repo->save($t); 32 43 33 44 $result = $repo->findByPurposeAndDid(EmailToken::PURPOSE_CONFIRM_EMAIL, 'did:plc:alice'); 34 - $this->assertSame($t, $result); 45 + $this->assertSame('abc123', $result->getToken()); 35 46 } 36 47 37 48 public function testFindByPurposeAndToken(): void 38 49 { 50 + $repo = $this->newRepo(); 39 51 $t = $this->makeToken(); 40 - $repo = new InMemoryEmailTokenRepository([$t]); 52 + $repo->save($t); 41 53 42 54 $result = $repo->findByPurposeAndToken(EmailToken::PURPOSE_CONFIRM_EMAIL, 'abc123'); 43 - $this->assertSame($t, $result); 55 + $this->assertSame('did:plc:alice', $result->getDid()); 44 56 } 45 57 46 58 public function testFindThrowsWhenMissing(): void 47 59 { 48 - $repo = new InMemoryEmailTokenRepository(); 60 + $repo = $this->newRepo(); 49 61 50 62 $this->expectException(EmailTokenNotFoundException::class); 51 63 $repo->findByPurposeAndDid(EmailToken::PURPOSE_RESET_PASSWORD, 'did:plc:alice'); ··· 53 65 54 66 public function testSaveOverwritesPreviousForSamePurposeAndDid(): void 55 67 { 56 - $t1 = $this->makeToken(token: 'old'); 57 - $t2 = $this->makeToken(token: 'new'); 58 - $repo = new InMemoryEmailTokenRepository([$t1]); 59 - $repo->save($t2); 68 + $repo = $this->newRepo(); 69 + $repo->save($this->makeToken(token: 'old')); 70 + $repo->save($this->makeToken(token: 'new')); 60 71 61 72 $result = $repo->findByPurposeAndDid(EmailToken::PURPOSE_CONFIRM_EMAIL, 'did:plc:alice'); 62 73 $this->assertSame('new', $result->getToken()); ··· 64 75 65 76 public function testDeleteByPurposeAndDid(): void 66 77 { 67 - $t = $this->makeToken(); 68 - $repo = new InMemoryEmailTokenRepository([$t]); 78 + $repo = $this->newRepo(); 79 + $repo->save($this->makeToken()); 69 80 $repo->deleteByPurposeAndDid(EmailToken::PURPOSE_CONFIRM_EMAIL, 'did:plc:alice'); 70 81 71 82 $this->expectException(EmailTokenNotFoundException::class);
-84
tests/Infrastructure/Persistence/Account/InMemoryAccountRepositoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\Account; 6 - 7 - use App\Application\Settings\Settings; 8 - use App\Domain\Account\Account; 9 - use App\Domain\Account\AccountNotFoundException; 10 - use App\Infrastructure\Persistence\Account\InMemoryAccountRepository; 11 - use Tests\TestCase; 12 - 13 - class InMemoryAccountRepositoryTest extends TestCase 14 - { 15 - public function testFindAllReturnsProvidedAccounts(): void 16 - { 17 - $alice = new Account('did:web:alice.pds.test', 'alice@pds.test', 'hash'); 18 - $repository = new InMemoryAccountRepository(null, [$alice]); 19 - 20 - $this->assertEquals([$alice], $repository->findAll()); 21 - } 22 - 23 - public function testFindAllSeedsFromSettings(): void 24 - { 25 - $settings = new Settings(['pds' => ['hostname' => 'pds.test']]); 26 - $repository = new InMemoryAccountRepository($settings); 27 - 28 - $accounts = $repository->findAll(); 29 - 30 - $this->assertCount(3, $accounts); 31 - $this->assertSame('did:web:alice.pds.test', $accounts[0]->getDid()); 32 - $this->assertSame('alice@pds.test', $accounts[0]->getEmail()); 33 - } 34 - 35 - public function testFindAccountByDid(): void 36 - { 37 - $alice = new Account('did:web:alice.pds.test', 'alice@pds.test', 'hash'); 38 - $repository = new InMemoryAccountRepository(null, [$alice]); 39 - 40 - $this->assertEquals($alice, $repository->findAccountByDid('did:web:alice.pds.test')); 41 - } 42 - 43 - public function testFindAccountByDidThrowsWhenMissing(): void 44 - { 45 - $repository = new InMemoryAccountRepository(null, []); 46 - 47 - $this->expectException(AccountNotFoundException::class); 48 - $repository->findAccountByDid('did:web:missing.pds.test'); 49 - } 50 - 51 - public function testFindAccountByEmailIsCaseInsensitive(): void 52 - { 53 - $alice = new Account('did:web:alice.pds.test', 'alice@pds.test', 'hash'); 54 - $repository = new InMemoryAccountRepository(null, [$alice]); 55 - 56 - $this->assertEquals($alice, $repository->findAccountByEmail(' Alice@PDS.test ')); 57 - } 58 - 59 - public function testFindAccountByEmailThrowsWhenMissing(): void 60 - { 61 - $repository = new InMemoryAccountRepository(null, []); 62 - 63 - $this->expectException(AccountNotFoundException::class); 64 - $repository->findAccountByEmail('missing@pds.test'); 65 - } 66 - 67 - public function testSeedDataMirrorsActorDids(): void 68 - { 69 - $settings = new Settings(['pds' => ['hostname' => 'pds.test']]); 70 - $accountRepository = new InMemoryAccountRepository($settings); 71 - $actorRepository = new \App\Infrastructure\Persistence\Actor\InMemoryActorRepository($settings); 72 - 73 - $accountDids = array_map( 74 - static fn (Account $a): string => $a->getDid(), 75 - $accountRepository->findAll() 76 - ); 77 - $actorDids = array_map( 78 - static fn (\App\Domain\Actor\Actor $a): string => $a->getDid(), 79 - $actorRepository->findAll() 80 - ); 81 - 82 - $this->assertSame($actorDids, $accountDids); 83 - } 84 - }
+28 -17
tests/Infrastructure/Persistence/Account/InviteCode/InMemoryInviteCodeRepositoryTest.php tests/Infrastructure/Persistence/Account/InviteCode/SqliteInviteCodeRepositoryTest.php
··· 7 7 use App\Domain\Account\InviteCode\InviteCode; 8 8 use App\Domain\Account\InviteCode\InviteCodeNotFoundException; 9 9 use App\Domain\Account\InviteCode\InviteCodeUse; 10 - use App\Infrastructure\Persistence\Account\InviteCode\InMemoryInviteCodeRepository; 10 + use App\Infrastructure\Database\Database; 11 + use App\Infrastructure\Database\Schema\AccountSchema; 12 + use App\Infrastructure\Persistence\Account\InviteCode\SqliteInviteCodeRepository; 11 13 use DateTimeImmutable; 12 14 use Tests\TestCase; 13 15 14 - class InMemoryInviteCodeRepositoryTest extends TestCase 16 + class SqliteInviteCodeRepositoryTest extends TestCase 15 17 { 18 + private function newRepo(): SqliteInviteCodeRepository 19 + { 20 + $db = new Database(':memory:'); 21 + AccountSchema::apply($db); 22 + 23 + return new SqliteInviteCodeRepository($db); 24 + } 25 + 16 26 private function makeCode( 17 27 string $code = 'pds-abc-1234', 18 28 string $forAccount = 'did:plc:alice', ··· 29 39 30 40 public function testFindAll(): void 31 41 { 32 - $c1 = $this->makeCode('code1'); 33 - $c2 = $this->makeCode('code2'); 34 - $repo = new InMemoryInviteCodeRepository([$c1, $c2]); 42 + $repo = $this->newRepo(); 43 + $repo->save($this->makeCode('code1')); 44 + $repo->save($this->makeCode('code2')); 35 45 36 46 $this->assertCount(2, $repo->findAll()); 37 47 } 38 48 39 49 public function testFindByCode(): void 40 50 { 41 - $code = $this->makeCode(); 42 - $repo = new InMemoryInviteCodeRepository([$code]); 51 + $repo = $this->newRepo(); 52 + $repo->save($this->makeCode()); 43 53 44 - $this->assertSame($code, $repo->findByCode('pds-abc-1234')); 54 + $found = $repo->findByCode('pds-abc-1234'); 55 + $this->assertSame('pds-abc-1234', $found->getCode()); 45 56 } 46 57 47 58 public function testFindByCodeThrowsWhenMissing(): void 48 59 { 49 - $repo = new InMemoryInviteCodeRepository(); 60 + $repo = $this->newRepo(); 50 61 51 62 $this->expectException(InviteCodeNotFoundException::class); 52 63 $repo->findByCode('no-such-code'); ··· 54 65 55 66 public function testFindAllForAccount(): void 56 67 { 57 - $c1 = $this->makeCode('c1', 'did:plc:alice'); 58 - $c2 = $this->makeCode('c2', 'did:plc:bob'); 59 - $repo = new InMemoryInviteCodeRepository([$c1, $c2]); 68 + $repo = $this->newRepo(); 69 + $repo->save($this->makeCode('c1', 'did:plc:alice')); 70 + $repo->save($this->makeCode('c2', 'did:plc:bob')); 60 71 61 72 $results = $repo->findAllForAccount('did:plc:alice'); 62 73 $this->assertCount(1, $results); ··· 65 76 66 77 public function testDisable(): void 67 78 { 68 - $code = $this->makeCode(); 69 - $repo = new InMemoryInviteCodeRepository([$code]); 79 + $repo = $this->newRepo(); 80 + $repo->save($this->makeCode()); 70 81 $repo->disable('pds-abc-1234'); 71 82 72 83 $this->assertTrue($repo->findByCode('pds-abc-1234')->isDisabled()); ··· 74 85 75 86 public function testRecordUseAndFindUses(): void 76 87 { 77 - $code = $this->makeCode(); 78 - $repo = new InMemoryInviteCodeRepository([$code]); 79 - $use = new InviteCodeUse('pds-abc-1234', 'did:plc:bob', new DateTimeImmutable()); 88 + $repo = $this->newRepo(); 89 + $repo->save($this->makeCode()); 90 + $use = new InviteCodeUse('pds-abc-1234', 'did:plc:bob', new DateTimeImmutable('2026-01-02T00:00:00Z')); 80 91 $repo->recordUse($use); 81 92 82 93 $uses = $repo->findUsesForCode('pds-abc-1234');
-63
tests/Infrastructure/Persistence/Account/RefreshToken/InMemoryRefreshTokenRepositoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\Account\RefreshToken; 6 - 7 - use App\Domain\Account\RefreshToken\RefreshToken; 8 - use App\Domain\Account\RefreshToken\RefreshTokenNotFoundException; 9 - use App\Infrastructure\Persistence\Account\RefreshToken\InMemoryRefreshTokenRepository; 10 - use Tests\TestCase; 11 - 12 - class InMemoryRefreshTokenRepositoryTest extends TestCase 13 - { 14 - private function makeToken( 15 - string $id = 'token-abc', 16 - string $did = 'did:plc:alice', 17 - ): RefreshToken { 18 - return new RefreshToken( 19 - id: $id, 20 - did: $did, 21 - expiresAt: '2099-01-01T00:00:00Z', 22 - appPasswordName: null, 23 - nextId: null, 24 - ); 25 - } 26 - 27 - public function testFindById(): void 28 - { 29 - $token = $this->makeToken(); 30 - $repo = new InMemoryRefreshTokenRepository([$token]); 31 - 32 - $this->assertSame($token, $repo->findById('token-abc')); 33 - } 34 - 35 - public function testFindByIdThrowsWhenMissing(): void 36 - { 37 - $repo = new InMemoryRefreshTokenRepository(); 38 - 39 - $this->expectException(RefreshTokenNotFoundException::class); 40 - $repo->findById('nope'); 41 - } 42 - 43 - public function testFindAllForDid(): void 44 - { 45 - $t1 = $this->makeToken('t1', 'did:plc:alice'); 46 - $t2 = $this->makeToken('t2', 'did:plc:bob'); 47 - $repo = new InMemoryRefreshTokenRepository([$t1, $t2]); 48 - 49 - $results = $repo->findAllForDid('did:plc:alice'); 50 - $this->assertCount(1, $results); 51 - $this->assertSame('t1', $results[0]->getId()); 52 - } 53 - 54 - public function testDeleteAllForDid(): void 55 - { 56 - $t1 = $this->makeToken('t1', 'did:plc:alice'); 57 - $t2 = $this->makeToken('t2', 'did:plc:alice'); 58 - $repo = new InMemoryRefreshTokenRepository([$t1, $t2]); 59 - 60 - $repo->deleteAllForDid('did:plc:alice'); 61 - $this->assertEmpty($repo->findAllForDid('did:plc:alice')); 62 - } 63 - }
+74
tests/Infrastructure/Persistence/Account/RefreshToken/SqliteRefreshTokenRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\Account\RefreshToken; 6 + 7 + use App\Domain\Account\RefreshToken\RefreshToken; 8 + use App\Domain\Account\RefreshToken\RefreshTokenNotFoundException; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\AccountSchema; 11 + use App\Infrastructure\Persistence\Account\RefreshToken\SqliteRefreshTokenRepository; 12 + use Tests\TestCase; 13 + 14 + class SqliteRefreshTokenRepositoryTest extends TestCase 15 + { 16 + private function newRepo(): SqliteRefreshTokenRepository 17 + { 18 + $db = new Database(':memory:'); 19 + AccountSchema::apply($db); 20 + 21 + return new SqliteRefreshTokenRepository($db); 22 + } 23 + 24 + private function makeToken( 25 + string $id = 'token-abc', 26 + string $did = 'did:plc:alice', 27 + ): RefreshToken { 28 + return new RefreshToken( 29 + id: $id, 30 + did: $did, 31 + expiresAt: '2099-01-01T00:00:00Z', 32 + appPasswordName: null, 33 + nextId: null, 34 + ); 35 + } 36 + 37 + public function testFindById(): void 38 + { 39 + $repo = $this->newRepo(); 40 + $repo->save($this->makeToken()); 41 + 42 + $found = $repo->findById('token-abc'); 43 + $this->assertSame('token-abc', $found->getId()); 44 + } 45 + 46 + public function testFindByIdThrowsWhenMissing(): void 47 + { 48 + $repo = $this->newRepo(); 49 + 50 + $this->expectException(RefreshTokenNotFoundException::class); 51 + $repo->findById('nope'); 52 + } 53 + 54 + public function testFindAllForDid(): void 55 + { 56 + $repo = $this->newRepo(); 57 + $repo->save($this->makeToken('t1', 'did:plc:alice')); 58 + $repo->save($this->makeToken('t2', 'did:plc:bob')); 59 + 60 + $results = $repo->findAllForDid('did:plc:alice'); 61 + $this->assertCount(1, $results); 62 + $this->assertSame('t1', $results[0]->getId()); 63 + } 64 + 65 + public function testDeleteAllForDid(): void 66 + { 67 + $repo = $this->newRepo(); 68 + $repo->save($this->makeToken('t1', 'did:plc:alice')); 69 + $repo->save($this->makeToken('t2', 'did:plc:alice')); 70 + 71 + $repo->deleteAllForDid('did:plc:alice'); 72 + $this->assertEmpty($repo->findAllForDid('did:plc:alice')); 73 + } 74 + }
+68
tests/Infrastructure/Persistence/Account/SqliteAccountRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\Account; 6 + 7 + use App\Domain\Account\Account; 8 + use App\Domain\Account\AccountNotFoundException; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\AccountSchema; 11 + use App\Infrastructure\Persistence\Account\SqliteAccountRepository; 12 + use Tests\TestCase; 13 + 14 + class SqliteAccountRepositoryTest extends TestCase 15 + { 16 + private function newRepo(): SqliteAccountRepository 17 + { 18 + $db = new Database(':memory:'); 19 + AccountSchema::apply($db); 20 + 21 + return new SqliteAccountRepository($db); 22 + } 23 + 24 + public function testFindAllReturnsProvidedAccounts(): void 25 + { 26 + $repo = $this->newRepo(); 27 + $alice = new Account('did:web:alice.pds.test', 'alice@pds.test', 'hash'); 28 + $repo->save($alice); 29 + 30 + $this->assertEquals([$alice], $repo->findAll()); 31 + } 32 + 33 + public function testFindAccountByDid(): void 34 + { 35 + $repo = $this->newRepo(); 36 + $alice = new Account('did:web:alice.pds.test', 'alice@pds.test', 'hash'); 37 + $repo->save($alice); 38 + 39 + $found = $repo->findAccountByDid('did:web:alice.pds.test'); 40 + $this->assertSame('did:web:alice.pds.test', $found->getDid()); 41 + } 42 + 43 + public function testFindAccountByDidThrowsWhenMissing(): void 44 + { 45 + $repo = $this->newRepo(); 46 + 47 + $this->expectException(AccountNotFoundException::class); 48 + $repo->findAccountByDid('did:web:missing.pds.test'); 49 + } 50 + 51 + public function testFindAccountByEmailIsCaseInsensitive(): void 52 + { 53 + $repo = $this->newRepo(); 54 + $alice = new Account('did:web:alice.pds.test', 'alice@pds.test', 'hash'); 55 + $repo->save($alice); 56 + 57 + $found = $repo->findAccountByEmail(' Alice@PDS.test '); 58 + $this->assertSame('alice@pds.test', $found->getEmail()); 59 + } 60 + 61 + public function testFindAccountByEmailThrowsWhenMissing(): void 62 + { 63 + $repo = $this->newRepo(); 64 + 65 + $this->expectException(AccountNotFoundException::class); 66 + $repo->findAccountByEmail('missing@pds.test'); 67 + } 68 + }
-81
tests/Infrastructure/Persistence/Actor/InMemoryActorRepositoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\Actor; 6 - 7 - use App\Application\Settings\Settings; 8 - use App\Domain\Actor\Actor; 9 - use App\Domain\Actor\ActorNotFoundException; 10 - use App\Infrastructure\Persistence\Actor\InMemoryActorRepository; 11 - use DateTimeImmutable; 12 - use Tests\TestCase; 13 - 14 - class InMemoryActorRepositoryTest extends TestCase 15 - { 16 - private function makeActor(string $did, ?string $handle): Actor 17 - { 18 - return new Actor($did, $handle, new DateTimeImmutable('2026-01-01T00:00:00Z')); 19 - } 20 - 21 - public function testFindAllReturnsProvidedActors(): void 22 - { 23 - $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); 24 - $repository = new InMemoryActorRepository(null, [$alice]); 25 - 26 - $this->assertEquals([$alice], $repository->findAll()); 27 - } 28 - 29 - public function testFindAllSeedsFromSettings(): void 30 - { 31 - $settings = new Settings(['pds' => ['hostname' => 'pds.test']]); 32 - $repository = new InMemoryActorRepository($settings); 33 - 34 - $actors = $repository->findAll(); 35 - 36 - $this->assertCount(3, $actors); 37 - $this->assertSame('did:web:alice.pds.test', $actors[0]->getDid()); 38 - $this->assertSame('alice.pds.test', $actors[0]->getHandle()); 39 - } 40 - 41 - public function testFindActorByDid(): void 42 - { 43 - $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); 44 - $repository = new InMemoryActorRepository(null, [$alice]); 45 - 46 - $this->assertEquals($alice, $repository->findActorByDid('did:web:alice.pds.test')); 47 - } 48 - 49 - public function testFindActorByDidThrowsWhenMissing(): void 50 - { 51 - $repository = new InMemoryActorRepository(null, []); 52 - 53 - $this->expectException(ActorNotFoundException::class); 54 - $repository->findActorByDid('did:web:missing.pds.test'); 55 - } 56 - 57 - public function testFindActorByHandleIsCaseInsensitive(): void 58 - { 59 - $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); 60 - $repository = new InMemoryActorRepository(null, [$alice]); 61 - 62 - $this->assertEquals($alice, $repository->findActorByHandle(' ALICE.PDS.TEST ')); 63 - } 64 - 65 - public function testFindActorByHandleThrowsWhenMissing(): void 66 - { 67 - $repository = new InMemoryActorRepository(null, []); 68 - 69 - $this->expectException(ActorNotFoundException::class); 70 - $repository->findActorByHandle('missing.pds.test'); 71 - } 72 - 73 - public function testFindActorByHandleSkipsActorsWithoutHandle(): void 74 - { 75 - $headless = $this->makeActor('did:web:headless.pds.test', null); 76 - $repository = new InMemoryActorRepository(null, [$headless]); 77 - 78 - $this->expectException(ActorNotFoundException::class); 79 - $repository->findActorByHandle('headless.pds.test'); 80 - } 81 - }
+86
tests/Infrastructure/Persistence/Actor/SqliteActorRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\Actor; 6 + 7 + use App\Domain\Actor\Actor; 8 + use App\Domain\Actor\ActorNotFoundException; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\AccountSchema; 11 + use App\Infrastructure\Persistence\Actor\SqliteActorRepository; 12 + use DateTimeImmutable; 13 + use Tests\TestCase; 14 + 15 + class SqliteActorRepositoryTest extends TestCase 16 + { 17 + private function newRepo(): SqliteActorRepository 18 + { 19 + $db = new Database(':memory:'); 20 + AccountSchema::apply($db); 21 + 22 + return new SqliteActorRepository($db); 23 + } 24 + 25 + private function makeActor(string $did, ?string $handle): Actor 26 + { 27 + return new Actor($did, $handle, new DateTimeImmutable('2026-01-01T00:00:00Z')); 28 + } 29 + 30 + public function testFindAllReturnsProvidedActors(): void 31 + { 32 + $repo = $this->newRepo(); 33 + $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); 34 + $repo->save($alice); 35 + 36 + $all = $repo->findAll(); 37 + $this->assertCount(1, $all); 38 + $this->assertSame('did:web:alice.pds.test', $all[0]->getDid()); 39 + } 40 + 41 + public function testFindActorByDid(): void 42 + { 43 + $repo = $this->newRepo(); 44 + $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); 45 + $repo->save($alice); 46 + 47 + $found = $repo->findActorByDid('did:web:alice.pds.test'); 48 + $this->assertSame('did:web:alice.pds.test', $found->getDid()); 49 + } 50 + 51 + public function testFindActorByDidThrowsWhenMissing(): void 52 + { 53 + $repo = $this->newRepo(); 54 + 55 + $this->expectException(ActorNotFoundException::class); 56 + $repo->findActorByDid('did:web:missing.pds.test'); 57 + } 58 + 59 + public function testFindActorByHandleIsCaseInsensitive(): void 60 + { 61 + $repo = $this->newRepo(); 62 + $alice = $this->makeActor('did:web:alice.pds.test', 'alice.pds.test'); 63 + $repo->save($alice); 64 + 65 + $found = $repo->findActorByHandle(' ALICE.PDS.TEST '); 66 + $this->assertSame('did:web:alice.pds.test', $found->getDid()); 67 + } 68 + 69 + public function testFindActorByHandleThrowsWhenMissing(): void 70 + { 71 + $repo = $this->newRepo(); 72 + 73 + $this->expectException(ActorNotFoundException::class); 74 + $repo->findActorByHandle('missing.pds.test'); 75 + } 76 + 77 + public function testFindActorByHandleSkipsActorsWithoutHandle(): void 78 + { 79 + $repo = $this->newRepo(); 80 + $headless = $this->makeActor('did:web:headless.pds.test', null); 81 + $repo->save($headless); 82 + 83 + $this->expectException(ActorNotFoundException::class); 84 + $repo->findActorByHandle('headless.pds.test'); 85 + } 86 + }
-81
tests/Infrastructure/Persistence/ActorStore/InMemoryActorStoreFactoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\ActorStore; 6 - 7 - use App\Domain\Record\Record; 8 - use App\Infrastructure\Persistence\ActorStore\InMemoryActorStoreFactory; 9 - use DateTimeImmutable; 10 - use Tests\TestCase; 11 - 12 - class InMemoryActorStoreFactoryTest extends TestCase 13 - { 14 - public function testGetReturnsSameInstanceForSameDid(): void 15 - { 16 - $factory = new InMemoryActorStoreFactory(); 17 - 18 - $storeA1 = $factory->get('did:plc:alice'); 19 - $storeA2 = $factory->get('did:plc:alice'); 20 - 21 - $this->assertSame($storeA1, $storeA2); 22 - } 23 - 24 - public function testGetReturnsDifferentInstancesForDifferentDids(): void 25 - { 26 - $factory = new InMemoryActorStoreFactory(); 27 - 28 - $alice = $factory->get('did:plc:alice'); 29 - $bob = $factory->get('did:plc:bob'); 30 - 31 - $this->assertNotSame($alice, $bob); 32 - } 33 - 34 - public function testStoresAreIsolated(): void 35 - { 36 - $factory = new InMemoryActorStoreFactory(); 37 - 38 - $record = new Record( 39 - uri: 'at://did:plc:alice/app.bsky.feed.post/k1', 40 - cid: 'bafyreifoo', 41 - collection: 'app.bsky.feed.post', 42 - rkey: 'k1', 43 - repoRev: '3aaaa', 44 - indexedAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 45 - ); 46 - 47 - $factory->get('did:plc:alice')->getRecords()->save($record); 48 - 49 - // Bob's store must not see Alice's record 50 - $this->assertEmpty($factory->get('did:plc:bob')->getRecords()->findAll()); 51 - $this->assertCount(1, $factory->get('did:plc:alice')->getRecords()->findAll()); 52 - } 53 - 54 - public function testDestroyResetsState(): void 55 - { 56 - $factory = new InMemoryActorStoreFactory(); 57 - 58 - $record = new Record( 59 - uri: 'at://did:plc:alice/app.bsky.feed.post/k1', 60 - cid: 'bafyreifoo', 61 - collection: 'app.bsky.feed.post', 62 - rkey: 'k1', 63 - repoRev: '3aaaa', 64 - indexedAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 65 - ); 66 - 67 - $factory->get('did:plc:alice')->getRecords()->save($record); 68 - $factory->destroy('did:plc:alice'); 69 - 70 - // After destroy, a fresh empty store is created on next get 71 - $this->assertEmpty($factory->get('did:plc:alice')->getRecords()->findAll()); 72 - } 73 - 74 - public function testGetDid(): void 75 - { 76 - $factory = new InMemoryActorStoreFactory(); 77 - $store = $factory->get('did:plc:alice'); 78 - 79 - $this->assertSame('did:plc:alice', $store->getDid()); 80 - } 81 - }
+120
tests/Infrastructure/Persistence/ActorStore/SqliteActorStoreFactoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\ActorStore; 6 + 7 + use App\Domain\Record\Record; 8 + use App\Infrastructure\Persistence\ActorStore\SqliteActorStoreFactory; 9 + use DateTimeImmutable; 10 + use Tests\TestCase; 11 + 12 + class SqliteActorStoreFactoryTest extends TestCase 13 + { 14 + private string $tmpDir; 15 + 16 + protected function setUp(): void 17 + { 18 + parent::setUp(); 19 + $this->tmpDir = sys_get_temp_dir() . '/phpds-test-' . uniqid('', true); 20 + mkdir($this->tmpDir, 0o755, true); 21 + } 22 + 23 + protected function tearDown(): void 24 + { 25 + parent::tearDown(); 26 + $this->removeDirectory($this->tmpDir); 27 + } 28 + 29 + private function removeDirectory(string $dir): void 30 + { 31 + if (!is_dir($dir)) { 32 + return; 33 + } 34 + 35 + $iterator = new \RecursiveIteratorIterator( 36 + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), 37 + \RecursiveIteratorIterator::CHILD_FIRST, 38 + ); 39 + foreach ($iterator as $file) { 40 + assert($file instanceof \SplFileInfo); 41 + if ($file->isDir()) { 42 + @rmdir($file->getPathname()); 43 + } else { 44 + @unlink($file->getPathname()); 45 + } 46 + } 47 + @rmdir($dir); 48 + } 49 + 50 + private function newFactory(): SqliteActorStoreFactory 51 + { 52 + return new SqliteActorStoreFactory(':memory:', $this->tmpDir . '/blobs'); 53 + } 54 + 55 + public function testGetReturnsSameInstanceForSameDid(): void 56 + { 57 + $factory = $this->newFactory(); 58 + 59 + $storeA1 = $factory->get('did:plc:alice'); 60 + $storeA2 = $factory->get('did:plc:alice'); 61 + 62 + $this->assertSame($storeA1, $storeA2); 63 + } 64 + 65 + public function testGetReturnsDifferentInstancesForDifferentDids(): void 66 + { 67 + $factory = $this->newFactory(); 68 + 69 + $alice = $factory->get('did:plc:alice'); 70 + $bob = $factory->get('did:plc:bob'); 71 + 72 + $this->assertNotSame($alice, $bob); 73 + } 74 + 75 + public function testStoresAreIsolated(): void 76 + { 77 + $factory = $this->newFactory(); 78 + 79 + $record = new Record( 80 + uri: 'at://did:plc:alice/app.bsky.feed.post/k1', 81 + cid: 'bafyreifoo', 82 + collection: 'app.bsky.feed.post', 83 + rkey: 'k1', 84 + repoRev: '3aaaa', 85 + indexedAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 86 + ); 87 + 88 + $factory->get('did:plc:alice')->getRecords()->save($record); 89 + 90 + $this->assertEmpty($factory->get('did:plc:bob')->getRecords()->findAll()); 91 + $this->assertCount(1, $factory->get('did:plc:alice')->getRecords()->findAll()); 92 + } 93 + 94 + public function testDestroyResetsState(): void 95 + { 96 + $factory = $this->newFactory(); 97 + 98 + $record = new Record( 99 + uri: 'at://did:plc:alice/app.bsky.feed.post/k1', 100 + cid: 'bafyreifoo', 101 + collection: 'app.bsky.feed.post', 102 + rkey: 'k1', 103 + repoRev: '3aaaa', 104 + indexedAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 105 + ); 106 + 107 + $factory->get('did:plc:alice')->getRecords()->save($record); 108 + $factory->destroy('did:plc:alice'); 109 + 110 + $this->assertEmpty($factory->get('did:plc:alice')->getRecords()->findAll()); 111 + } 112 + 113 + public function testGetDid(): void 114 + { 115 + $factory = $this->newFactory(); 116 + $store = $factory->get('did:plc:alice'); 117 + 118 + $this->assertSame('did:plc:alice', $store->getDid()); 119 + } 120 + }
-73
tests/Infrastructure/Persistence/Blob/InMemoryBlobRepositoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\Blob; 6 - 7 - use App\Domain\Blob\Blob; 8 - use App\Domain\Blob\BlobNotFoundException; 9 - use App\Infrastructure\Persistence\Blob\InMemoryBlobRepository; 10 - use DateTimeImmutable; 11 - use Tests\TestCase; 12 - 13 - class InMemoryBlobRepositoryTest extends TestCase 14 - { 15 - private function makeBlob( 16 - string $cid = 'bafyreib', 17 - ?string $tempKey = null, 18 - ): Blob { 19 - return new Blob( 20 - cid: $cid, 21 - mimeType: 'image/jpeg', 22 - size: 1024, 23 - tempKey: $tempKey, 24 - createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 25 - ); 26 - } 27 - 28 - public function testFindByCid(): void 29 - { 30 - $blob = $this->makeBlob(); 31 - $repo = new InMemoryBlobRepository([$blob]); 32 - 33 - $this->assertSame($blob, $repo->findByCid('bafyreib')); 34 - } 35 - 36 - public function testFindByCidThrowsWhenMissing(): void 37 - { 38 - $repo = new InMemoryBlobRepository(); 39 - 40 - $this->expectException(BlobNotFoundException::class); 41 - $repo->findByCid('nope'); 42 - } 43 - 44 - public function testFindTemporary(): void 45 - { 46 - $perm = $this->makeBlob(cid: 'cid1', tempKey: null); 47 - $temp = $this->makeBlob(cid: 'cid2', tempKey: 'tmpkey123'); 48 - $repo = new InMemoryBlobRepository([$perm, $temp]); 49 - 50 - $temps = $repo->findTemporary(); 51 - $this->assertCount(1, $temps); 52 - $this->assertSame('cid2', $temps[0]->getCid()); 53 - } 54 - 55 - public function testFindAll(): void 56 - { 57 - $b1 = $this->makeBlob(cid: 'c1'); 58 - $b2 = $this->makeBlob(cid: 'c2'); 59 - $repo = new InMemoryBlobRepository([$b1, $b2]); 60 - 61 - $this->assertCount(2, $repo->findAll()); 62 - } 63 - 64 - public function testDeleteAll(): void 65 - { 66 - $b1 = $this->makeBlob(cid: 'c1'); 67 - $b2 = $this->makeBlob(cid: 'c2'); 68 - $repo = new InMemoryBlobRepository([$b1, $b2]); 69 - $repo->deleteAll(); 70 - 71 - $this->assertEmpty($repo->findAll()); 72 - } 73 - }
-83
tests/Infrastructure/Persistence/Blob/InMemoryBlobStoreTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\Blob; 6 - 7 - use App\Domain\Blob\BlobNotFoundException; 8 - use App\Infrastructure\Persistence\Blob\InMemoryBlobStore; 9 - use Tests\TestCase; 10 - 11 - class InMemoryBlobStoreTest extends TestCase 12 - { 13 - public function testPutTempAndMakePermanent(): void 14 - { 15 - $store = new InMemoryBlobStore(); 16 - 17 - $tempKey = $store->putTemp('hello bytes'); 18 - $this->assertTrue($store->hasTemp($tempKey)); 19 - $this->assertFalse($store->hasStored('bafyreicid1')); 20 - 21 - $store->makePermanent($tempKey, 'bafyreicid1'); 22 - 23 - $this->assertFalse($store->hasTemp($tempKey)); 24 - $this->assertTrue($store->hasStored('bafyreicid1')); 25 - $this->assertSame('hello bytes', $store->getBytes('bafyreicid1')); 26 - } 27 - 28 - public function testMakePermanentThrowsForMissingTempKey(): void 29 - { 30 - $store = new InMemoryBlobStore(); 31 - 32 - $this->expectException(BlobNotFoundException::class); 33 - $store->makePermanent('no-such-key', 'bafyreicid2'); 34 - } 35 - 36 - public function testPutPermanentAndGetStream(): void 37 - { 38 - $store = new InMemoryBlobStore(); 39 - $store->putPermanent('bafyreicid3', 'stream content'); 40 - 41 - $stream = $store->getStream('bafyreicid3'); 42 - $this->assertSame('stream content', (string) $stream); 43 - } 44 - 45 - public function testDeleteThrowsForMissing(): void 46 - { 47 - $store = new InMemoryBlobStore(); 48 - 49 - $this->expectException(BlobNotFoundException::class); 50 - $store->delete('bafyreinone'); 51 - } 52 - 53 - public function testDeleteMany(): void 54 - { 55 - $store = new InMemoryBlobStore(); 56 - $store->putPermanent('cid1', 'a'); 57 - $store->putPermanent('cid2', 'b'); 58 - $store->deleteMany(['cid1', 'cid2']); 59 - 60 - $this->assertFalse($store->hasStored('cid1')); 61 - $this->assertFalse($store->hasStored('cid2')); 62 - } 63 - 64 - public function testQuarantineAndUnquarantine(): void 65 - { 66 - $store = new InMemoryBlobStore(); 67 - $store->putPermanent('cidq', 'data'); 68 - 69 - $store->quarantine('cidq'); 70 - $this->assertTrue($store->isQuarantined('cidq')); 71 - 72 - $store->unquarantine('cidq'); 73 - $this->assertFalse($store->isQuarantined('cidq')); 74 - } 75 - 76 - public function testQuarantineThrowsForMissing(): void 77 - { 78 - $store = new InMemoryBlobStore(); 79 - 80 - $this->expectException(BlobNotFoundException::class); 81 - $store->quarantine('no-such-cid'); 82 - } 83 - }
+84
tests/Infrastructure/Persistence/Blob/SqliteBlobRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\Blob; 6 + 7 + use App\Domain\Blob\Blob; 8 + use App\Domain\Blob\BlobNotFoundException; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\ActorStoreSchema; 11 + use App\Infrastructure\Persistence\Blob\SqliteBlobRepository; 12 + use DateTimeImmutable; 13 + use Tests\TestCase; 14 + 15 + class SqliteBlobRepositoryTest extends TestCase 16 + { 17 + private function newRepo(): SqliteBlobRepository 18 + { 19 + $db = new Database(':memory:'); 20 + ActorStoreSchema::apply($db); 21 + 22 + return new SqliteBlobRepository($db); 23 + } 24 + 25 + private function makeBlob( 26 + string $cid = 'bafyreib', 27 + ?string $tempKey = null, 28 + ): Blob { 29 + return new Blob( 30 + cid: $cid, 31 + mimeType: 'image/jpeg', 32 + size: 1024, 33 + tempKey: $tempKey, 34 + createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 35 + ); 36 + } 37 + 38 + public function testFindByCid(): void 39 + { 40 + $repo = $this->newRepo(); 41 + $repo->save($this->makeBlob()); 42 + 43 + $found = $repo->findByCid('bafyreib'); 44 + $this->assertSame('bafyreib', $found->getCid()); 45 + } 46 + 47 + public function testFindByCidThrowsWhenMissing(): void 48 + { 49 + $repo = $this->newRepo(); 50 + 51 + $this->expectException(BlobNotFoundException::class); 52 + $repo->findByCid('nope'); 53 + } 54 + 55 + public function testFindTemporary(): void 56 + { 57 + $repo = $this->newRepo(); 58 + $repo->save($this->makeBlob(cid: 'cid1', tempKey: null)); 59 + $repo->save($this->makeBlob(cid: 'cid2', tempKey: 'tmpkey123')); 60 + 61 + $temps = $repo->findTemporary(); 62 + $this->assertCount(1, $temps); 63 + $this->assertSame('cid2', $temps[0]->getCid()); 64 + } 65 + 66 + public function testFindAll(): void 67 + { 68 + $repo = $this->newRepo(); 69 + $repo->save($this->makeBlob(cid: 'c1')); 70 + $repo->save($this->makeBlob(cid: 'c2')); 71 + 72 + $this->assertCount(2, $repo->findAll()); 73 + } 74 + 75 + public function testDeleteAll(): void 76 + { 77 + $repo = $this->newRepo(); 78 + $repo->save($this->makeBlob(cid: 'c1')); 79 + $repo->save($this->makeBlob(cid: 'c2')); 80 + $repo->deleteAll(); 81 + 82 + $this->assertEmpty($repo->findAll()); 83 + } 84 + }
+18 -8
tests/Infrastructure/Persistence/Did/InMemoryDidCacheRepositoryTest.php tests/Infrastructure/Persistence/Did/SqliteDidCacheRepositoryTest.php
··· 5 5 namespace Tests\Infrastructure\Persistence\Did; 6 6 7 7 use App\Domain\Did\DidDocEntryNotFoundException; 8 - use App\Infrastructure\Persistence\Did\InMemoryDidCacheRepository; 8 + use App\Infrastructure\Database\Database; 9 + use App\Infrastructure\Database\Schema\DidCacheSchema; 10 + use App\Infrastructure\Persistence\Did\SqliteDidCacheRepository; 9 11 use Tests\TestCase; 10 12 11 - class InMemoryDidCacheRepositoryTest extends TestCase 13 + class SqliteDidCacheRepositoryTest extends TestCase 12 14 { 15 + private function newRepo(): SqliteDidCacheRepository 16 + { 17 + $db = new Database(':memory:'); 18 + DidCacheSchema::apply($db); 19 + 20 + return new SqliteDidCacheRepository($db); 21 + } 22 + 13 23 public function testSetAndGet(): void 14 24 { 15 - $repo = new InMemoryDidCacheRepository(); 25 + $repo = $this->newRepo(); 16 26 $doc = ['id' => 'did:plc:alice', '@context' => ['https://www.w3.org/ns/did/v1']]; 17 27 $repo->set('did:plc:alice', $doc); 18 28 ··· 23 33 24 34 public function testGetThrowsWhenMissing(): void 25 35 { 26 - $repo = new InMemoryDidCacheRepository(); 36 + $repo = $this->newRepo(); 27 37 28 38 $this->expectException(DidDocEntryNotFoundException::class); 29 39 $repo->get('did:plc:missing'); ··· 31 41 32 42 public function testHas(): void 33 43 { 34 - $repo = new InMemoryDidCacheRepository(); 44 + $repo = $this->newRepo(); 35 45 $this->assertFalse($repo->has('did:plc:alice')); 36 46 $repo->set('did:plc:alice', ['id' => 'did:plc:alice']); 37 47 $this->assertTrue($repo->has('did:plc:alice')); ··· 39 49 40 50 public function testClear(): void 41 51 { 42 - $repo = new InMemoryDidCacheRepository(); 52 + $repo = $this->newRepo(); 43 53 $repo->set('did:plc:alice', ['id' => 'did:plc:alice']); 44 54 $repo->clear('did:plc:alice'); 45 55 ··· 48 58 49 59 public function testClearAll(): void 50 60 { 51 - $repo = new InMemoryDidCacheRepository(); 61 + $repo = $this->newRepo(); 52 62 $repo->set('did:plc:alice', ['id' => 'did:plc:alice']); 53 63 $repo->set('did:plc:bob', ['id' => 'did:plc:bob']); 54 64 $repo->clearAll(); ··· 59 69 60 70 public function testSetUpdatesExistingEntry(): void 61 71 { 62 - $repo = new InMemoryDidCacheRepository(); 72 + $repo = $this->newRepo(); 63 73 $repo->set('did:plc:alice', ['id' => 'did:plc:alice', 'version' => 1]); 64 74 $repo->set('did:plc:alice', ['id' => 'did:plc:alice', 'version' => 2]); 65 75
+23 -12
tests/Infrastructure/Persistence/Lexicon/InMemoryLexiconRepositoryTest.php tests/Infrastructure/Persistence/Lexicon/SqliteLexiconRepositoryTest.php
··· 6 6 7 7 use App\Domain\Lexicon\LexiconEntry; 8 8 use App\Domain\Lexicon\LexiconEntryNotFoundException; 9 - use App\Infrastructure\Persistence\Lexicon\InMemoryLexiconRepository; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\AccountSchema; 11 + use App\Infrastructure\Persistence\Lexicon\SqliteLexiconRepository; 10 12 use DateTimeImmutable; 11 13 use Tests\TestCase; 12 14 13 - class InMemoryLexiconRepositoryTest extends TestCase 15 + class SqliteLexiconRepositoryTest extends TestCase 14 16 { 17 + private function newRepo(): SqliteLexiconRepository 18 + { 19 + $db = new Database(':memory:'); 20 + AccountSchema::apply($db); 21 + 22 + return new SqliteLexiconRepository($db); 23 + } 24 + 15 25 private function makeEntry(string $nsid = 'com.example.foo'): LexiconEntry 16 26 { 17 27 return new LexiconEntry( ··· 26 36 27 37 public function testFindByNsid(): void 28 38 { 29 - $entry = $this->makeEntry(); 30 - $repo = new InMemoryLexiconRepository([$entry]); 39 + $repo = $this->newRepo(); 40 + $repo->save($this->makeEntry()); 31 41 32 - $this->assertSame($entry, $repo->findByNsid('com.example.foo')); 42 + $found = $repo->findByNsid('com.example.foo'); 43 + $this->assertSame('com.example.foo', $found->getNsid()); 33 44 } 34 45 35 46 public function testFindByNsidThrowsWhenMissing(): void 36 47 { 37 - $repo = new InMemoryLexiconRepository(); 48 + $repo = $this->newRepo(); 38 49 39 50 $this->expectException(LexiconEntryNotFoundException::class); 40 51 $repo->findByNsid('nope'); ··· 42 53 43 54 public function testFindAll(): void 44 55 { 45 - $repo = new InMemoryLexiconRepository([ 46 - $this->makeEntry('com.example.a'), 47 - $this->makeEntry('com.example.b'), 48 - ]); 56 + $repo = $this->newRepo(); 57 + $repo->save($this->makeEntry('com.example.a')); 58 + $repo->save($this->makeEntry('com.example.b')); 49 59 50 60 $this->assertCount(2, $repo->findAll()); 51 61 } 52 62 53 63 public function testSaveInsertsAndUpdates(): void 54 64 { 55 - $repo = new InMemoryLexiconRepository(); 65 + $repo = $this->newRepo(); 56 66 $repo->save($this->makeEntry('com.example.a')); 57 67 $this->assertCount(1, $repo->findAll()); 58 68 ··· 72 82 73 83 public function testDeleteByNsid(): void 74 84 { 75 - $repo = new InMemoryLexiconRepository([$this->makeEntry()]); 85 + $repo = $this->newRepo(); 86 + $repo->save($this->makeEntry()); 76 87 $repo->deleteByNsid('com.example.foo'); 77 88 78 89 $this->assertCount(0, $repo->findAll());
+25 -16
tests/Infrastructure/Persistence/OAuth/InMemoryAccountDeviceRepositoryTest.php tests/Infrastructure/Persistence/OAuth/SqliteAccountDeviceRepositoryTest.php
··· 6 6 7 7 use App\Domain\OAuth\AccountDevice; 8 8 use App\Domain\OAuth\AccountDeviceNotFoundException; 9 - use App\Infrastructure\Persistence\OAuth\InMemoryAccountDeviceRepository; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\AccountSchema; 11 + use App\Infrastructure\Persistence\OAuth\SqliteAccountDeviceRepository; 10 12 use DateTimeImmutable; 11 13 use Tests\TestCase; 12 14 13 - class InMemoryAccountDeviceRepositoryTest extends TestCase 15 + class SqliteAccountDeviceRepositoryTest extends TestCase 14 16 { 17 + private function newRepo(): SqliteAccountDeviceRepository 18 + { 19 + $db = new Database(':memory:'); 20 + AccountSchema::apply($db); 21 + 22 + return new SqliteAccountDeviceRepository($db); 23 + } 24 + 15 25 private function makeEntry(string $did = 'did:plc:alice', string $deviceId = 'dev-1'): AccountDevice 16 26 { 17 27 return new AccountDevice( ··· 24 34 25 35 public function testFindByDidAndDeviceId(): void 26 36 { 27 - $entry = $this->makeEntry(); 28 - $repo = new InMemoryAccountDeviceRepository([$entry]); 37 + $repo = $this->newRepo(); 38 + $repo->save($this->makeEntry()); 29 39 30 - $this->assertSame($entry, $repo->findByDidAndDeviceId('did:plc:alice', 'dev-1')); 40 + $found = $repo->findByDidAndDeviceId('did:plc:alice', 'dev-1'); 41 + $this->assertSame('dev-1', $found->getDeviceId()); 31 42 } 32 43 33 44 public function testFindByDidAndDeviceIdThrowsWhenMissing(): void 34 45 { 35 - $repo = new InMemoryAccountDeviceRepository(); 46 + $repo = $this->newRepo(); 36 47 37 48 $this->expectException(AccountDeviceNotFoundException::class); 38 49 $repo->findByDidAndDeviceId('did:plc:alice', 'dev-1'); ··· 40 51 41 52 public function testFindAllForDid(): void 42 53 { 43 - $repo = new InMemoryAccountDeviceRepository([ 44 - $this->makeEntry('did:plc:alice', 'dev-1'), 45 - $this->makeEntry('did:plc:alice', 'dev-2'), 46 - $this->makeEntry('did:plc:bob', 'dev-3'), 47 - ]); 54 + $repo = $this->newRepo(); 55 + $repo->save($this->makeEntry('did:plc:alice', 'dev-1')); 56 + $repo->save($this->makeEntry('did:plc:alice', 'dev-2')); 57 + $repo->save($this->makeEntry('did:plc:bob', 'dev-3')); 48 58 49 59 $this->assertCount(2, $repo->findAllForDid('did:plc:alice')); 50 60 $this->assertCount(1, $repo->findAllForDid('did:plc:bob')); ··· 53 63 54 64 public function testSaveInsertsNewAndUpdatesExisting(): void 55 65 { 56 - $repo = new InMemoryAccountDeviceRepository(); 66 + $repo = $this->newRepo(); 57 67 $repo->save($this->makeEntry()); 58 68 $this->assertCount(1, $repo->findAllForDid('did:plc:alice')); 59 69 ··· 74 84 75 85 public function testDeleteByDidAndDeviceId(): void 76 86 { 77 - $repo = new InMemoryAccountDeviceRepository([ 78 - $this->makeEntry('did:plc:alice', 'dev-1'), 79 - $this->makeEntry('did:plc:alice', 'dev-2'), 80 - ]); 87 + $repo = $this->newRepo(); 88 + $repo->save($this->makeEntry('did:plc:alice', 'dev-1')); 89 + $repo->save($this->makeEntry('did:plc:alice', 'dev-2')); 81 90 82 91 $repo->deleteByDidAndDeviceId('did:plc:alice', 'dev-1'); 83 92
+31 -14
tests/Infrastructure/Persistence/OAuth/InMemoryAuthorizationRequestRepositoryTest.php tests/Infrastructure/Persistence/OAuth/SqliteAuthorizationRequestRepositoryTest.php
··· 6 6 7 7 use App\Domain\OAuth\AuthorizationRequest; 8 8 use App\Domain\OAuth\AuthorizationRequestNotFoundException; 9 - use App\Infrastructure\Persistence\OAuth\InMemoryAuthorizationRequestRepository; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\AccountSchema; 11 + use App\Infrastructure\Persistence\OAuth\SqliteAuthorizationRequestRepository; 10 12 use DateTimeImmutable; 11 13 use Tests\TestCase; 12 14 13 - class InMemoryAuthorizationRequestRepositoryTest extends TestCase 15 + class SqliteAuthorizationRequestRepositoryTest extends TestCase 14 16 { 17 + private function newRepo(): SqliteAuthorizationRequestRepository 18 + { 19 + $db = new Database(':memory:'); 20 + AccountSchema::apply($db); 21 + 22 + return new SqliteAuthorizationRequestRepository($db); 23 + } 24 + 15 25 private function makeRequest( 16 26 string $id = 'req-1', 17 27 ?string $code = 'auth-code', ··· 31 41 32 42 public function testFindByIdAndFindByCode(): void 33 43 { 34 - $req = $this->makeRequest(); 35 - $repo = new InMemoryAuthorizationRequestRepository([$req]); 44 + $repo = $this->newRepo(); 45 + $repo->save($this->makeRequest()); 46 + 47 + $byId = $repo->findById('req-1'); 48 + $byCode = $repo->findByCode('auth-code'); 36 49 37 - $this->assertSame($req, $repo->findById('req-1')); 38 - $this->assertSame($req, $repo->findByCode('auth-code')); 50 + $this->assertSame('req-1', $byId->getId()); 51 + $this->assertSame('req-1', $byCode->getId()); 39 52 } 40 53 41 54 public function testFindByIdThrowsWhenMissing(): void 42 55 { 43 - $repo = new InMemoryAuthorizationRequestRepository(); 56 + $repo = $this->newRepo(); 44 57 45 58 $this->expectException(AuthorizationRequestNotFoundException::class); 46 59 $repo->findById('nope'); ··· 48 61 49 62 public function testFindByCodeThrowsWhenMissing(): void 50 63 { 51 - $repo = new InMemoryAuthorizationRequestRepository([$this->makeRequest(code: null)]); 64 + $repo = $this->newRepo(); 65 + $repo->save($this->makeRequest(code: null)); 52 66 53 67 $this->expectException(AuthorizationRequestNotFoundException::class); 54 68 $repo->findByCode('nope'); ··· 56 70 57 71 public function testSaveInsertsAndUpdates(): void 58 72 { 59 - $repo = new InMemoryAuthorizationRequestRepository(); 73 + $repo = $this->newRepo(); 60 74 $repo->save($this->makeRequest(id: 'req-1', code: 'a')); 61 75 $repo->save($this->makeRequest(id: 'req-1', code: 'b')); 62 76 ··· 65 79 66 80 public function testDeleteById(): void 67 81 { 68 - $repo = new InMemoryAuthorizationRequestRepository([$this->makeRequest()]); 82 + $repo = $this->newRepo(); 83 + $repo->save($this->makeRequest()); 69 84 $repo->deleteById('req-1'); 70 85 71 86 $this->expectException(AuthorizationRequestNotFoundException::class); ··· 74 89 75 90 public function testDeleteExpiredRemovesPastEntriesOnly(): void 76 91 { 77 - $past = $this->makeRequest(id: 'expired', expiresAt: new DateTimeImmutable('-1 hour')); 78 - $future = $this->makeRequest(id: 'live', expiresAt: new DateTimeImmutable('+1 hour')); 79 - $repo = new InMemoryAuthorizationRequestRepository([$past, $future]); 92 + $repo = $this->newRepo(); 93 + $repo->save($this->makeRequest(id: 'expired', expiresAt: new DateTimeImmutable('-1 hour'))); 94 + $repo->save($this->makeRequest(id: 'live', expiresAt: new DateTimeImmutable('+1 hour'))); 80 95 81 96 $removed = $repo->deleteExpired(); 82 97 83 98 $this->assertSame(1, $removed); 84 - $this->assertSame($future, $repo->findById('live')); 99 + $live = $repo->findById('live'); 100 + $this->assertSame('live', $live->getId()); 101 + 85 102 $this->expectException(AuthorizationRequestNotFoundException::class); 86 103 $repo->findById('expired'); 87 104 }
+30 -22
tests/Infrastructure/Persistence/OAuth/InMemoryAuthorizedClientRepositoryTest.php tests/Infrastructure/Persistence/OAuth/SqliteAuthorizedClientRepositoryTest.php
··· 6 6 7 7 use App\Domain\OAuth\AuthorizedClient; 8 8 use App\Domain\OAuth\AuthorizedClientNotFoundException; 9 - use App\Infrastructure\Persistence\OAuth\InMemoryAuthorizedClientRepository; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\AccountSchema; 11 + use App\Infrastructure\Persistence\OAuth\SqliteAuthorizedClientRepository; 10 12 use DateTimeImmutable; 11 13 use Tests\TestCase; 12 14 13 - class InMemoryAuthorizedClientRepositoryTest extends TestCase 15 + class SqliteAuthorizedClientRepositoryTest extends TestCase 14 16 { 17 + private function newRepo(): SqliteAuthorizedClientRepository 18 + { 19 + $db = new Database(':memory:'); 20 + AccountSchema::apply($db); 21 + 22 + return new SqliteAuthorizedClientRepository($db); 23 + } 24 + 15 25 private function makeEntry( 16 26 string $did = 'did:plc:alice', 17 27 string $clientId = 'https://app.test/client.json', ··· 27 37 28 38 public function testFindByDidAndClientId(): void 29 39 { 30 - $entry = $this->makeEntry(); 31 - $repo = new InMemoryAuthorizedClientRepository([$entry]); 40 + $repo = $this->newRepo(); 41 + $repo->save($this->makeEntry()); 32 42 33 - $this->assertSame($entry, $repo->findByDidAndClientId('did:plc:alice', 'https://app.test/client.json')); 43 + $found = $repo->findByDidAndClientId('did:plc:alice', 'https://app.test/client.json'); 44 + $this->assertSame('did:plc:alice', $found->getDid()); 34 45 } 35 46 36 47 public function testFindByDidAndClientIdThrowsWhenMissing(): void 37 48 { 38 - $repo = new InMemoryAuthorizedClientRepository(); 49 + $repo = $this->newRepo(); 39 50 40 51 $this->expectException(AuthorizedClientNotFoundException::class); 41 52 $repo->findByDidAndClientId('did:plc:alice', 'nope'); ··· 43 54 44 55 public function testFindAllForDid(): void 45 56 { 46 - $repo = new InMemoryAuthorizedClientRepository([ 47 - $this->makeEntry('did:plc:alice', 'c1'), 48 - $this->makeEntry('did:plc:alice', 'c2'), 49 - $this->makeEntry('did:plc:bob', 'c3'), 50 - ]); 57 + $repo = $this->newRepo(); 58 + $repo->save($this->makeEntry('did:plc:alice', 'c1')); 59 + $repo->save($this->makeEntry('did:plc:alice', 'c2')); 60 + $repo->save($this->makeEntry('did:plc:bob', 'c3')); 51 61 52 62 $this->assertCount(2, $repo->findAllForDid('did:plc:alice')); 53 63 $this->assertCount(1, $repo->findAllForDid('did:plc:bob')); ··· 55 65 56 66 public function testSaveInsertsAndUpdates(): void 57 67 { 58 - $repo = new InMemoryAuthorizedClientRepository(); 68 + $repo = $this->newRepo(); 59 69 $repo->save($this->makeEntry()); 60 70 $this->assertCount(1, $repo->findAllForDid('did:plc:alice')); 61 71 ··· 71 81 $this->assertCount(1, $repo->findAllForDid('did:plc:alice')); 72 82 $this->assertSame( 73 83 ['new' => true], 74 - $repo->findByDidAndClientId('did:plc:alice', 'https://app.test/client.json')->getData() 84 + $repo->findByDidAndClientId('did:plc:alice', 'https://app.test/client.json')->getData(), 75 85 ); 76 86 } 77 87 78 88 public function testDeleteByDidAndClientId(): void 79 89 { 80 - $repo = new InMemoryAuthorizedClientRepository([ 81 - $this->makeEntry('did:plc:alice', 'c1'), 82 - $this->makeEntry('did:plc:alice', 'c2'), 83 - ]); 90 + $repo = $this->newRepo(); 91 + $repo->save($this->makeEntry('did:plc:alice', 'c1')); 92 + $repo->save($this->makeEntry('did:plc:alice', 'c2')); 84 93 85 94 $repo->deleteByDidAndClientId('did:plc:alice', 'c1'); 86 95 ··· 89 98 90 99 public function testDeleteAllForDid(): void 91 100 { 92 - $repo = new InMemoryAuthorizedClientRepository([ 93 - $this->makeEntry('did:plc:alice', 'c1'), 94 - $this->makeEntry('did:plc:alice', 'c2'), 95 - $this->makeEntry('did:plc:bob', 'c3'), 96 - ]); 101 + $repo = $this->newRepo(); 102 + $repo->save($this->makeEntry('did:plc:alice', 'c1')); 103 + $repo->save($this->makeEntry('did:plc:alice', 'c2')); 104 + $repo->save($this->makeEntry('did:plc:bob', 'c3')); 97 105 98 106 $repo->deleteAllForDid('did:plc:alice'); 99 107
+20 -8
tests/Infrastructure/Persistence/OAuth/InMemoryDeviceRepositoryTest.php tests/Infrastructure/Persistence/OAuth/SqliteDeviceRepositoryTest.php
··· 6 6 7 7 use App\Domain\OAuth\Device; 8 8 use App\Domain\OAuth\DeviceNotFoundException; 9 - use App\Infrastructure\Persistence\OAuth\InMemoryDeviceRepository; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\AccountSchema; 11 + use App\Infrastructure\Persistence\OAuth\SqliteDeviceRepository; 10 12 use DateTimeImmutable; 11 13 use Tests\TestCase; 12 14 13 - class InMemoryDeviceRepositoryTest extends TestCase 15 + class SqliteDeviceRepositoryTest extends TestCase 14 16 { 17 + private function newRepo(): SqliteDeviceRepository 18 + { 19 + $db = new Database(':memory:'); 20 + AccountSchema::apply($db); 21 + 22 + return new SqliteDeviceRepository($db); 23 + } 24 + 15 25 private function makeDevice(string $id = 'dev-1'): Device 16 26 { 17 27 return new Device( ··· 25 35 26 36 public function testFindById(): void 27 37 { 28 - $device = $this->makeDevice(); 29 - $repo = new InMemoryDeviceRepository([$device]); 38 + $repo = $this->newRepo(); 39 + $repo->save($this->makeDevice()); 30 40 31 - $this->assertSame($device, $repo->findById('dev-1')); 41 + $found = $repo->findById('dev-1'); 42 + $this->assertSame('dev-1', $found->getId()); 32 43 } 33 44 34 45 public function testFindByIdThrowsWhenMissing(): void 35 46 { 36 - $repo = new InMemoryDeviceRepository(); 47 + $repo = $this->newRepo(); 37 48 38 49 $this->expectException(DeviceNotFoundException::class); 39 50 $repo->findById('nope'); ··· 41 52 42 53 public function testSaveInsertsAndUpdates(): void 43 54 { 44 - $repo = new InMemoryDeviceRepository(); 55 + $repo = $this->newRepo(); 45 56 $repo->save($this->makeDevice()); 46 57 47 58 $updated = new Device( ··· 58 69 59 70 public function testDeleteById(): void 60 71 { 61 - $repo = new InMemoryDeviceRepository([$this->makeDevice()]); 72 + $repo = $this->newRepo(); 73 + $repo->save($this->makeDevice()); 62 74 $repo->deleteById('dev-1'); 63 75 64 76 $this->expectException(DeviceNotFoundException::class);
+36 -23
tests/Infrastructure/Persistence/OAuth/InMemoryOAuthTokenRepositoryTest.php tests/Infrastructure/Persistence/OAuth/SqliteOAuthTokenRepositoryTest.php
··· 6 6 7 7 use App\Domain\OAuth\OAuthToken; 8 8 use App\Domain\OAuth\OAuthTokenNotFoundException; 9 - use App\Infrastructure\Persistence\OAuth\InMemoryOAuthTokenRepository; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\AccountSchema; 11 + use App\Infrastructure\Persistence\OAuth\SqliteOAuthTokenRepository; 10 12 use DateTimeImmutable; 11 13 use Tests\TestCase; 12 14 13 - class InMemoryOAuthTokenRepositoryTest extends TestCase 15 + class SqliteOAuthTokenRepositoryTest extends TestCase 14 16 { 17 + private function newRepo(): SqliteOAuthTokenRepository 18 + { 19 + $db = new Database(':memory:'); 20 + AccountSchema::apply($db); 21 + 22 + return new SqliteOAuthTokenRepository($db); 23 + } 24 + 15 25 private function makeToken( 16 - int $id = 1, 17 26 string $tokenId = 'tok-1', 18 27 string $did = 'did:plc:alice', 19 28 ?string $code = 'code-1', 20 29 ?string $refresh = 'refresh-1', 21 30 ): OAuthToken { 22 31 return new OAuthToken( 23 - id: $id, 32 + id: 0, 24 33 did: $did, 25 34 tokenId: $tokenId, 26 35 createdAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), ··· 39 48 40 49 public function testFindByTokenId(): void 41 50 { 42 - $token = $this->makeToken(); 43 - $repo = new InMemoryOAuthTokenRepository([$token]); 51 + $repo = $this->newRepo(); 52 + $repo->save($this->makeToken()); 44 53 45 - $this->assertSame($token, $repo->findByTokenId('tok-1')); 54 + $found = $repo->findByTokenId('tok-1'); 55 + $this->assertSame('tok-1', $found->getTokenId()); 46 56 } 47 57 48 58 public function testFindByTokenIdThrowsWhenMissing(): void 49 59 { 50 - $repo = new InMemoryOAuthTokenRepository(); 60 + $repo = $this->newRepo(); 51 61 52 62 $this->expectException(OAuthTokenNotFoundException::class); 53 63 $repo->findByTokenId('nope'); ··· 55 65 56 66 public function testFindByCode(): void 57 67 { 58 - $repo = new InMemoryOAuthTokenRepository([$this->makeToken()]); 68 + $repo = $this->newRepo(); 69 + $repo->save($this->makeToken()); 59 70 60 71 $this->assertSame('tok-1', $repo->findByCode('code-1')->getTokenId()); 61 72 } 62 73 63 74 public function testFindByCodeThrowsWhenMissing(): void 64 75 { 65 - $repo = new InMemoryOAuthTokenRepository([$this->makeToken(code: null)]); 76 + $repo = $this->newRepo(); 77 + $repo->save($this->makeToken(code: null)); 66 78 67 79 $this->expectException(OAuthTokenNotFoundException::class); 68 80 $repo->findByCode('nope'); ··· 70 82 71 83 public function testFindByRefreshToken(): void 72 84 { 73 - $repo = new InMemoryOAuthTokenRepository([$this->makeToken()]); 85 + $repo = $this->newRepo(); 86 + $repo->save($this->makeToken()); 74 87 75 88 $this->assertSame('tok-1', $repo->findByRefreshToken('refresh-1')->getTokenId()); 76 89 } 77 90 78 91 public function testFindByRefreshTokenThrowsWhenMissing(): void 79 92 { 80 - $repo = new InMemoryOAuthTokenRepository([$this->makeToken(refresh: null)]); 93 + $repo = $this->newRepo(); 94 + $repo->save($this->makeToken(refresh: null)); 81 95 82 96 $this->expectException(OAuthTokenNotFoundException::class); 83 97 $repo->findByRefreshToken('nope'); ··· 85 99 86 100 public function testFindAllForDid(): void 87 101 { 88 - $repo = new InMemoryOAuthTokenRepository([ 89 - $this->makeToken(id: 1, tokenId: 't1', did: 'did:plc:alice', code: 'c1', refresh: 'r1'), 90 - $this->makeToken(id: 2, tokenId: 't2', did: 'did:plc:alice', code: 'c2', refresh: 'r2'), 91 - $this->makeToken(id: 3, tokenId: 't3', did: 'did:plc:bob', code: 'c3', refresh: 'r3'), 92 - ]); 102 + $repo = $this->newRepo(); 103 + $repo->save($this->makeToken(tokenId: 't1', did: 'did:plc:alice', code: 'c1', refresh: 'r1')); 104 + $repo->save($this->makeToken(tokenId: 't2', did: 'did:plc:alice', code: 'c2', refresh: 'r2')); 105 + $repo->save($this->makeToken(tokenId: 't3', did: 'did:plc:bob', code: 'c3', refresh: 'r3')); 93 106 94 107 $this->assertCount(2, $repo->findAllForDid('did:plc:alice')); 95 108 $this->assertCount(1, $repo->findAllForDid('did:plc:bob')); ··· 97 110 98 111 public function testSaveInsertsAndUpdates(): void 99 112 { 100 - $repo = new InMemoryOAuthTokenRepository(); 113 + $repo = $this->newRepo(); 101 114 $repo->save($this->makeToken()); 102 115 $repo->save($this->makeToken(code: 'updated')); 103 116 ··· 106 119 107 120 public function testDeleteByTokenId(): void 108 121 { 109 - $repo = new InMemoryOAuthTokenRepository([$this->makeToken()]); 122 + $repo = $this->newRepo(); 123 + $repo->save($this->makeToken()); 110 124 $repo->deleteByTokenId('tok-1'); 111 125 112 126 $this->expectException(OAuthTokenNotFoundException::class); ··· 115 129 116 130 public function testDeleteAllForDid(): void 117 131 { 118 - $repo = new InMemoryOAuthTokenRepository([ 119 - $this->makeToken(id: 1, tokenId: 't1', did: 'did:plc:alice', code: 'c1', refresh: 'r1'), 120 - $this->makeToken(id: 2, tokenId: 't2', did: 'did:plc:bob', code: 'c2', refresh: 'r2'), 121 - ]); 132 + $repo = $this->newRepo(); 133 + $repo->save($this->makeToken(tokenId: 't1', did: 'did:plc:alice', code: 'c1', refresh: 'r1')); 134 + $repo->save($this->makeToken(tokenId: 't2', did: 'did:plc:bob', code: 'c2', refresh: 'r2')); 122 135 123 136 $repo->deleteAllForDid('did:plc:alice'); 124 137
-58
tests/Infrastructure/Persistence/OAuth/InMemoryUsedRefreshTokenRepositoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\OAuth; 6 - 7 - use App\Domain\OAuth\UsedRefreshToken; 8 - use App\Infrastructure\Persistence\OAuth\InMemoryUsedRefreshTokenRepository; 9 - use Tests\TestCase; 10 - 11 - class InMemoryUsedRefreshTokenRepositoryTest extends TestCase 12 - { 13 - public function testExistsReturnsTrueForSeededTokens(): void 14 - { 15 - $repo = new InMemoryUsedRefreshTokenRepository([ 16 - new UsedRefreshToken(1, 'refresh-a'), 17 - ]); 18 - 19 - $this->assertTrue($repo->exists('refresh-a')); 20 - $this->assertFalse($repo->exists('refresh-b')); 21 - } 22 - 23 - public function testSaveMakesTokenExist(): void 24 - { 25 - $repo = new InMemoryUsedRefreshTokenRepository(); 26 - $repo->save(new UsedRefreshToken(1, 'refresh-a')); 27 - 28 - $this->assertTrue($repo->exists('refresh-a')); 29 - } 30 - 31 - public function testSaveOverwritesPriorEntryForSameRefreshTokenString(): void 32 - { 33 - $repo = new InMemoryUsedRefreshTokenRepository([ 34 - new UsedRefreshToken(1, 'refresh-a'), 35 - ]); 36 - $repo->save(new UsedRefreshToken(2, 'refresh-a')); 37 - 38 - $this->assertTrue($repo->exists('refresh-a')); 39 - // deleting by the old tokenId should not remove the rebound entry 40 - $repo->deleteAllForTokenId(1); 41 - $this->assertTrue($repo->exists('refresh-a')); 42 - } 43 - 44 - public function testDeleteAllForTokenId(): void 45 - { 46 - $repo = new InMemoryUsedRefreshTokenRepository([ 47 - new UsedRefreshToken(1, 'refresh-a'), 48 - new UsedRefreshToken(1, 'refresh-b'), 49 - new UsedRefreshToken(2, 'refresh-c'), 50 - ]); 51 - 52 - $repo->deleteAllForTokenId(1); 53 - 54 - $this->assertFalse($repo->exists('refresh-a')); 55 - $this->assertFalse($repo->exists('refresh-b')); 56 - $this->assertTrue($repo->exists('refresh-c')); 57 - } 58 - }
+65
tests/Infrastructure/Persistence/OAuth/SqliteUsedRefreshTokenRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\OAuth; 6 + 7 + use App\Domain\OAuth\UsedRefreshToken; 8 + use App\Infrastructure\Database\Database; 9 + use App\Infrastructure\Database\Schema\AccountSchema; 10 + use App\Infrastructure\Persistence\OAuth\SqliteUsedRefreshTokenRepository; 11 + use Tests\TestCase; 12 + 13 + class SqliteUsedRefreshTokenRepositoryTest extends TestCase 14 + { 15 + private function newRepo(): SqliteUsedRefreshTokenRepository 16 + { 17 + $db = new Database(':memory:'); 18 + AccountSchema::apply($db); 19 + 20 + return new SqliteUsedRefreshTokenRepository($db); 21 + } 22 + 23 + public function testExistsReturnsTrueForSavedTokens(): void 24 + { 25 + $repo = $this->newRepo(); 26 + $repo->save(new UsedRefreshToken(1, 'refresh-a')); 27 + 28 + $this->assertTrue($repo->exists('refresh-a')); 29 + $this->assertFalse($repo->exists('refresh-b')); 30 + } 31 + 32 + public function testSaveMakesTokenExist(): void 33 + { 34 + $repo = $this->newRepo(); 35 + $repo->save(new UsedRefreshToken(1, 'refresh-a')); 36 + 37 + $this->assertTrue($repo->exists('refresh-a')); 38 + } 39 + 40 + public function testSaveOverwritesPriorEntryForSameRefreshTokenString(): void 41 + { 42 + $repo = $this->newRepo(); 43 + $repo->save(new UsedRefreshToken(1, 'refresh-a')); 44 + $repo->save(new UsedRefreshToken(2, 'refresh-a')); 45 + 46 + $this->assertTrue($repo->exists('refresh-a')); 47 + // deleting by the old tokenId should not remove the rebound entry 48 + $repo->deleteAllForTokenId(1); 49 + $this->assertTrue($repo->exists('refresh-a')); 50 + } 51 + 52 + public function testDeleteAllForTokenId(): void 53 + { 54 + $repo = $this->newRepo(); 55 + $repo->save(new UsedRefreshToken(1, 'refresh-a')); 56 + $repo->save(new UsedRefreshToken(1, 'refresh-b')); 57 + $repo->save(new UsedRefreshToken(2, 'refresh-c')); 58 + 59 + $repo->deleteAllForTokenId(1); 60 + 61 + $this->assertFalse($repo->exists('refresh-a')); 62 + $this->assertFalse($repo->exists('refresh-b')); 63 + $this->assertTrue($repo->exists('refresh-c')); 64 + } 65 + }
+18 -8
tests/Infrastructure/Persistence/Preference/InMemoryAccountPrefRepositoryTest.php tests/Infrastructure/Persistence/Preference/SqliteAccountPrefRepositoryTest.php
··· 6 6 7 7 use App\Domain\Preference\AccountPref; 8 8 use App\Domain\Preference\AccountPrefNotFoundException; 9 - use App\Infrastructure\Persistence\Preference\InMemoryAccountPrefRepository; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\ActorStoreSchema; 11 + use App\Infrastructure\Persistence\Preference\SqliteAccountPrefRepository; 10 12 use Tests\TestCase; 11 13 12 - class InMemoryAccountPrefRepositoryTest extends TestCase 14 + class SqliteAccountPrefRepositoryTest extends TestCase 13 15 { 16 + private function newRepo(): SqliteAccountPrefRepository 17 + { 18 + $db = new Database(':memory:'); 19 + ActorStoreSchema::apply($db); 20 + 21 + return new SqliteAccountPrefRepository($db); 22 + } 23 + 14 24 private function makePref( 15 25 string $name = 'muted-words', 16 26 int $id = 0, ··· 20 30 21 31 public function testSaveAssignsAutoIncrementedId(): void 22 32 { 23 - $repo = new InMemoryAccountPrefRepository(); 33 + $repo = $this->newRepo(); 24 34 $saved = $repo->save($this->makePref()); 25 35 26 36 $this->assertSame(1, $saved->getId()); ··· 28 38 29 39 public function testFindById(): void 30 40 { 31 - $repo = new InMemoryAccountPrefRepository(); 41 + $repo = $this->newRepo(); 32 42 $saved = $repo->save($this->makePref()); 33 43 34 44 $result = $repo->findById($saved->getId()); ··· 37 47 38 48 public function testFindByIdThrowsWhenMissing(): void 39 49 { 40 - $repo = new InMemoryAccountPrefRepository(); 50 + $repo = $this->newRepo(); 41 51 42 52 $this->expectException(AccountPrefNotFoundException::class); 43 53 $repo->findById(999); ··· 45 55 46 56 public function testFindByName(): void 47 57 { 48 - $repo = new InMemoryAccountPrefRepository(); 58 + $repo = $this->newRepo(); 49 59 $repo->save($this->makePref(name: 'muted-words')); 50 60 $repo->save($this->makePref(name: 'muted-words')); 51 61 $repo->save($this->makePref(name: 'theme')); ··· 56 66 57 67 public function testDeleteByName(): void 58 68 { 59 - $repo = new InMemoryAccountPrefRepository(); 69 + $repo = $this->newRepo(); 60 70 $repo->save($this->makePref(name: 'muted-words')); 61 71 $repo->save($this->makePref(name: 'theme')); 62 72 $repo->deleteByName('muted-words'); ··· 67 77 68 78 public function testDeleteAll(): void 69 79 { 70 - $repo = new InMemoryAccountPrefRepository(); 80 + $repo = $this->newRepo(); 71 81 $repo->save($this->makePref()); 72 82 $repo->save($this->makePref(name: 'foo')); 73 83 $repo->deleteAll();
-69
tests/Infrastructure/Persistence/Record/InMemoryBacklinkRepositoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\Record; 6 - 7 - use App\Domain\Record\Backlink; 8 - use App\Infrastructure\Persistence\Record\InMemoryBacklinkRepository; 9 - use Tests\TestCase; 10 - 11 - class InMemoryBacklinkRepositoryTest extends TestCase 12 - { 13 - public function testFindByUriAndLinkTo(): void 14 - { 15 - $a = new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1'); 16 - $b = new Backlink('at://alice/like/2', 'subject.uri', 'at://bob/post/1'); 17 - $c = new Backlink('at://alice/like/3', 'subject.uri', 'at://carol/post/9'); 18 - $repo = new InMemoryBacklinkRepository([$a, $b, $c]); 19 - 20 - $this->assertCount(1, $repo->findByUri('at://alice/like/1')); 21 - $this->assertCount(2, $repo->findByLinkTo('at://bob/post/1')); 22 - $this->assertCount(0, $repo->findByUri('nope')); 23 - } 24 - 25 - public function testSaveIsIdempotentForDuplicateTriple(): void 26 - { 27 - $repo = new InMemoryBacklinkRepository(); 28 - $bl = new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1'); 29 - 30 - $repo->save($bl); 31 - $repo->save($bl); 32 - $repo->save(new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1')); 33 - 34 - $this->assertCount(1, $repo->findByUri('at://alice/like/1')); 35 - } 36 - 37 - public function testSaveAddsWhenPathOrLinkToDiffers(): void 38 - { 39 - $repo = new InMemoryBacklinkRepository(); 40 - $repo->save(new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1')); 41 - $repo->save(new Backlink('at://alice/like/1', 'reply.parent.uri', 'at://bob/post/1')); 42 - 43 - $this->assertCount(2, $repo->findByUri('at://alice/like/1')); 44 - } 45 - 46 - public function testDeleteByUri(): void 47 - { 48 - $repo = new InMemoryBacklinkRepository([ 49 - new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1'), 50 - new Backlink('at://alice/like/2', 'subject.uri', 'at://bob/post/1'), 51 - ]); 52 - 53 - $repo->deleteByUri('at://alice/like/1'); 54 - 55 - $this->assertCount(0, $repo->findByUri('at://alice/like/1')); 56 - $this->assertCount(1, $repo->findByLinkTo('at://bob/post/1')); 57 - } 58 - 59 - public function testDeleteAll(): void 60 - { 61 - $repo = new InMemoryBacklinkRepository([ 62 - new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1'), 63 - ]); 64 - 65 - $repo->deleteAll(); 66 - 67 - $this->assertCount(0, $repo->findByLinkTo('at://bob/post/1')); 68 - } 69 - }
-62
tests/Infrastructure/Persistence/Record/InMemoryRecordBlobRepositoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\Record; 6 - 7 - use App\Domain\Record\RecordBlob; 8 - use App\Infrastructure\Persistence\Record\InMemoryRecordBlobRepository; 9 - use Tests\TestCase; 10 - 11 - class InMemoryRecordBlobRepositoryTest extends TestCase 12 - { 13 - public function testFindByBlobCidAndRecordUri(): void 14 - { 15 - $repo = new InMemoryRecordBlobRepository([ 16 - new RecordBlob('blob-1', 'at://alice/post/1'), 17 - new RecordBlob('blob-2', 'at://alice/post/1'), 18 - new RecordBlob('blob-1', 'at://alice/post/2'), 19 - ]); 20 - 21 - $this->assertCount(2, $repo->findByBlobCid('blob-1')); 22 - $this->assertCount(2, $repo->findByRecordUri('at://alice/post/1')); 23 - $this->assertCount(0, $repo->findByBlobCid('nope')); 24 - } 25 - 26 - public function testSaveIsIdempotent(): void 27 - { 28 - $repo = new InMemoryRecordBlobRepository(); 29 - $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); 30 - $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); 31 - 32 - $this->assertCount(1, $repo->findByBlobCid('blob-1')); 33 - } 34 - 35 - public function testDeleteByRecordUri(): void 36 - { 37 - $repo = new InMemoryRecordBlobRepository([ 38 - new RecordBlob('blob-1', 'at://alice/post/1'), 39 - new RecordBlob('blob-2', 'at://alice/post/1'), 40 - new RecordBlob('blob-1', 'at://alice/post/2'), 41 - ]); 42 - 43 - $repo->deleteByRecordUri('at://alice/post/1'); 44 - 45 - $this->assertCount(0, $repo->findByRecordUri('at://alice/post/1')); 46 - $this->assertCount(1, $repo->findByRecordUri('at://alice/post/2')); 47 - } 48 - 49 - public function testDeleteByBlobCid(): void 50 - { 51 - $repo = new InMemoryRecordBlobRepository([ 52 - new RecordBlob('blob-1', 'at://alice/post/1'), 53 - new RecordBlob('blob-1', 'at://alice/post/2'), 54 - new RecordBlob('blob-2', 'at://alice/post/3'), 55 - ]); 56 - 57 - $repo->deleteByBlobCid('blob-1'); 58 - 59 - $this->assertCount(0, $repo->findByBlobCid('blob-1')); 60 - $this->assertCount(1, $repo->findByBlobCid('blob-2')); 61 - } 62 - }
-76
tests/Infrastructure/Persistence/Record/InMemoryRecordRepositoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\Record; 6 - 7 - use App\Domain\Record\Record; 8 - use App\Domain\Record\RecordNotFoundException; 9 - use App\Infrastructure\Persistence\Record\InMemoryRecordRepository; 10 - use DateTimeImmutable; 11 - use Tests\TestCase; 12 - 13 - class InMemoryRecordRepositoryTest extends TestCase 14 - { 15 - private function makeRecord( 16 - string $collection = 'app.bsky.feed.post', 17 - string $rkey = 'tid001', 18 - ): Record { 19 - $did = 'did:plc:alice'; 20 - 21 - return new Record( 22 - uri: "at://{$did}/{$collection}/{$rkey}", 23 - cid: 'bafyreirecord', 24 - collection: $collection, 25 - rkey: $rkey, 26 - repoRev: '3aaaa', 27 - indexedAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 28 - ); 29 - } 30 - 31 - public function testFindByUri(): void 32 - { 33 - $record = $this->makeRecord(); 34 - $repo = new InMemoryRecordRepository([$record]); 35 - 36 - $this->assertSame($record, $repo->findByUri('at://did:plc:alice/app.bsky.feed.post/tid001')); 37 - } 38 - 39 - public function testFindByUriThrowsWhenMissing(): void 40 - { 41 - $repo = new InMemoryRecordRepository(); 42 - 43 - $this->expectException(RecordNotFoundException::class); 44 - $repo->findByUri('at://did:plc:alice/app.bsky.feed.post/missing'); 45 - } 46 - 47 - public function testFindByCollection(): void 48 - { 49 - $r1 = $this->makeRecord(rkey: 'k1', collection: 'app.bsky.feed.post'); 50 - $r2 = $this->makeRecord(rkey: 'k2', collection: 'app.bsky.actor.profile'); 51 - $repo = new InMemoryRecordRepository([$r1, $r2]); 52 - 53 - $posts = $repo->findByCollection('app.bsky.feed.post'); 54 - $this->assertCount(1, $posts); 55 - $this->assertSame('k1', $posts[0]->getRkey()); 56 - } 57 - 58 - public function testFindAll(): void 59 - { 60 - $r1 = $this->makeRecord(rkey: 'k1'); 61 - $r2 = $this->makeRecord(rkey: 'k2'); 62 - $repo = new InMemoryRecordRepository([$r1, $r2]); 63 - 64 - $this->assertCount(2, $repo->findAll()); 65 - } 66 - 67 - public function testDeleteByUri(): void 68 - { 69 - $record = $this->makeRecord(); 70 - $repo = new InMemoryRecordRepository([$record]); 71 - $repo->deleteByUri('at://did:plc:alice/app.bsky.feed.post/tid001'); 72 - 73 - $this->expectException(RecordNotFoundException::class); 74 - $repo->findByUri('at://did:plc:alice/app.bsky.feed.post/tid001'); 75 - } 76 - }
+77
tests/Infrastructure/Persistence/Record/SqliteBacklinkRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\Record; 6 + 7 + use App\Domain\Record\Backlink; 8 + use App\Infrastructure\Database\Database; 9 + use App\Infrastructure\Database\Schema\ActorStoreSchema; 10 + use App\Infrastructure\Persistence\Record\SqliteBacklinkRepository; 11 + use Tests\TestCase; 12 + 13 + class SqliteBacklinkRepositoryTest extends TestCase 14 + { 15 + private function newRepo(): SqliteBacklinkRepository 16 + { 17 + $db = new Database(':memory:'); 18 + ActorStoreSchema::apply($db); 19 + 20 + return new SqliteBacklinkRepository($db); 21 + } 22 + 23 + public function testFindByUriAndLinkTo(): void 24 + { 25 + $repo = $this->newRepo(); 26 + $repo->save(new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1')); 27 + $repo->save(new Backlink('at://alice/like/2', 'subject.uri', 'at://bob/post/1')); 28 + $repo->save(new Backlink('at://alice/like/3', 'subject.uri', 'at://carol/post/9')); 29 + 30 + $this->assertCount(1, $repo->findByUri('at://alice/like/1')); 31 + $this->assertCount(2, $repo->findByLinkTo('at://bob/post/1')); 32 + $this->assertCount(0, $repo->findByUri('nope')); 33 + } 34 + 35 + public function testSaveIsIdempotentForDuplicateTriple(): void 36 + { 37 + $repo = $this->newRepo(); 38 + $bl = new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1'); 39 + 40 + $repo->save($bl); 41 + $repo->save($bl); 42 + $repo->save(new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1')); 43 + 44 + $this->assertCount(1, $repo->findByUri('at://alice/like/1')); 45 + } 46 + 47 + public function testSaveAddsWhenPathOrLinkToDiffers(): void 48 + { 49 + $repo = $this->newRepo(); 50 + $repo->save(new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1')); 51 + $repo->save(new Backlink('at://alice/like/1', 'reply.parent.uri', 'at://bob/post/1')); 52 + 53 + $this->assertCount(2, $repo->findByUri('at://alice/like/1')); 54 + } 55 + 56 + public function testDeleteByUri(): void 57 + { 58 + $repo = $this->newRepo(); 59 + $repo->save(new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1')); 60 + $repo->save(new Backlink('at://alice/like/2', 'subject.uri', 'at://bob/post/1')); 61 + 62 + $repo->deleteByUri('at://alice/like/1'); 63 + 64 + $this->assertCount(0, $repo->findByUri('at://alice/like/1')); 65 + $this->assertCount(1, $repo->findByLinkTo('at://bob/post/1')); 66 + } 67 + 68 + public function testDeleteAll(): void 69 + { 70 + $repo = $this->newRepo(); 71 + $repo->save(new Backlink('at://alice/like/1', 'subject.uri', 'at://bob/post/1')); 72 + 73 + $repo->deleteAll(); 74 + 75 + $this->assertCount(0, $repo->findByLinkTo('at://bob/post/1')); 76 + } 77 + }
+69
tests/Infrastructure/Persistence/Record/SqliteRecordBlobRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\Record; 6 + 7 + use App\Domain\Record\RecordBlob; 8 + use App\Infrastructure\Database\Database; 9 + use App\Infrastructure\Database\Schema\ActorStoreSchema; 10 + use App\Infrastructure\Persistence\Record\SqliteRecordBlobRepository; 11 + use Tests\TestCase; 12 + 13 + class SqliteRecordBlobRepositoryTest extends TestCase 14 + { 15 + private function newRepo(): SqliteRecordBlobRepository 16 + { 17 + $db = new Database(':memory:'); 18 + ActorStoreSchema::apply($db); 19 + 20 + return new SqliteRecordBlobRepository($db); 21 + } 22 + 23 + public function testFindByBlobCidAndRecordUri(): void 24 + { 25 + $repo = $this->newRepo(); 26 + $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); 27 + $repo->save(new RecordBlob('blob-2', 'at://alice/post/1')); 28 + $repo->save(new RecordBlob('blob-1', 'at://alice/post/2')); 29 + 30 + $this->assertCount(2, $repo->findByBlobCid('blob-1')); 31 + $this->assertCount(2, $repo->findByRecordUri('at://alice/post/1')); 32 + $this->assertCount(0, $repo->findByBlobCid('nope')); 33 + } 34 + 35 + public function testSaveIsIdempotent(): void 36 + { 37 + $repo = $this->newRepo(); 38 + $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); 39 + $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); 40 + 41 + $this->assertCount(1, $repo->findByBlobCid('blob-1')); 42 + } 43 + 44 + public function testDeleteByRecordUri(): void 45 + { 46 + $repo = $this->newRepo(); 47 + $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); 48 + $repo->save(new RecordBlob('blob-2', 'at://alice/post/1')); 49 + $repo->save(new RecordBlob('blob-1', 'at://alice/post/2')); 50 + 51 + $repo->deleteByRecordUri('at://alice/post/1'); 52 + 53 + $this->assertCount(0, $repo->findByRecordUri('at://alice/post/1')); 54 + $this->assertCount(1, $repo->findByRecordUri('at://alice/post/2')); 55 + } 56 + 57 + public function testDeleteByBlobCid(): void 58 + { 59 + $repo = $this->newRepo(); 60 + $repo->save(new RecordBlob('blob-1', 'at://alice/post/1')); 61 + $repo->save(new RecordBlob('blob-1', 'at://alice/post/2')); 62 + $repo->save(new RecordBlob('blob-2', 'at://alice/post/3')); 63 + 64 + $repo->deleteByBlobCid('blob-1'); 65 + 66 + $this->assertCount(0, $repo->findByBlobCid('blob-1')); 67 + $this->assertCount(1, $repo->findByBlobCid('blob-2')); 68 + } 69 + }
+87
tests/Infrastructure/Persistence/Record/SqliteRecordRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\Record; 6 + 7 + use App\Domain\Record\Record; 8 + use App\Domain\Record\RecordNotFoundException; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\ActorStoreSchema; 11 + use App\Infrastructure\Persistence\Record\SqliteRecordRepository; 12 + use DateTimeImmutable; 13 + use Tests\TestCase; 14 + 15 + class SqliteRecordRepositoryTest extends TestCase 16 + { 17 + private function newRepo(): SqliteRecordRepository 18 + { 19 + $db = new Database(':memory:'); 20 + ActorStoreSchema::apply($db); 21 + 22 + return new SqliteRecordRepository($db); 23 + } 24 + 25 + private function makeRecord( 26 + string $collection = 'app.bsky.feed.post', 27 + string $rkey = 'tid001', 28 + ): Record { 29 + $did = 'did:plc:alice'; 30 + 31 + return new Record( 32 + uri: "at://{$did}/{$collection}/{$rkey}", 33 + cid: 'bafyreirecord', 34 + collection: $collection, 35 + rkey: $rkey, 36 + repoRev: '3aaaa', 37 + indexedAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 38 + ); 39 + } 40 + 41 + public function testFindByUri(): void 42 + { 43 + $repo = $this->newRepo(); 44 + $repo->save($this->makeRecord()); 45 + 46 + $found = $repo->findByUri('at://did:plc:alice/app.bsky.feed.post/tid001'); 47 + $this->assertSame('at://did:plc:alice/app.bsky.feed.post/tid001', $found->getUri()); 48 + } 49 + 50 + public function testFindByUriThrowsWhenMissing(): void 51 + { 52 + $repo = $this->newRepo(); 53 + 54 + $this->expectException(RecordNotFoundException::class); 55 + $repo->findByUri('at://did:plc:alice/app.bsky.feed.post/missing'); 56 + } 57 + 58 + public function testFindByCollection(): void 59 + { 60 + $repo = $this->newRepo(); 61 + $repo->save($this->makeRecord(rkey: 'k1', collection: 'app.bsky.feed.post')); 62 + $repo->save($this->makeRecord(rkey: 'k2', collection: 'app.bsky.actor.profile')); 63 + 64 + $posts = $repo->findByCollection('app.bsky.feed.post'); 65 + $this->assertCount(1, $posts); 66 + $this->assertSame('k1', $posts[0]->getRkey()); 67 + } 68 + 69 + public function testFindAll(): void 70 + { 71 + $repo = $this->newRepo(); 72 + $repo->save($this->makeRecord(rkey: 'k1')); 73 + $repo->save($this->makeRecord(rkey: 'k2')); 74 + 75 + $this->assertCount(2, $repo->findAll()); 76 + } 77 + 78 + public function testDeleteByUri(): void 79 + { 80 + $repo = $this->newRepo(); 81 + $repo->save($this->makeRecord()); 82 + $repo->deleteByUri('at://did:plc:alice/app.bsky.feed.post/tid001'); 83 + 84 + $this->expectException(RecordNotFoundException::class); 85 + $repo->findByUri('at://did:plc:alice/app.bsky.feed.post/tid001'); 86 + } 87 + }
-73
tests/Infrastructure/Persistence/Repo/InMemoryRepoBlockRepositoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\Repo; 6 - 7 - use App\Domain\Repo\RepoBlock; 8 - use App\Domain\Repo\RepoBlockNotFoundException; 9 - use App\Infrastructure\Persistence\Repo\InMemoryRepoBlockRepository; 10 - use Tests\TestCase; 11 - 12 - class InMemoryRepoBlockRepositoryTest extends TestCase 13 - { 14 - private function makeBlock( 15 - string $cid = 'bafyreiblock', 16 - string $rev = '3aaaa', 17 - ): RepoBlock { 18 - return new RepoBlock( 19 - cid: $cid, 20 - repoRev: $rev, 21 - size: 42, 22 - content: 'placeholder-cbor', 23 - ); 24 - } 25 - 26 - public function testFindByCidReturnsSeededBlock(): void 27 - { 28 - $block = $this->makeBlock(); 29 - $repo = new InMemoryRepoBlockRepository([$block]); 30 - 31 - $this->assertSame($block, $repo->findByCid('bafyreiblock')); 32 - } 33 - 34 - public function testFindByCidThrowsWhenMissing(): void 35 - { 36 - $repo = new InMemoryRepoBlockRepository(); 37 - 38 - $this->expectException(RepoBlockNotFoundException::class); 39 - $repo->findByCid('bafyreinone'); 40 - } 41 - 42 - public function testFindByRepoRev(): void 43 - { 44 - $b1 = $this->makeBlock(cid: 'cid1', rev: 'rev-a'); 45 - $b2 = $this->makeBlock(cid: 'cid2', rev: 'rev-b'); 46 - $repo = new InMemoryRepoBlockRepository([$b1, $b2]); 47 - 48 - $results = $repo->findByRepoRev('rev-a'); 49 - $this->assertCount(1, $results); 50 - $this->assertSame('cid1', $results[0]->getCid()); 51 - } 52 - 53 - public function testSaveAndDeleteByCid(): void 54 - { 55 - $repo = new InMemoryRepoBlockRepository(); 56 - $block = $this->makeBlock(); 57 - $repo->save($block); 58 - $repo->deleteByCid('bafyreiblock'); 59 - 60 - $this->expectException(RepoBlockNotFoundException::class); 61 - $repo->findByCid('bafyreiblock'); 62 - } 63 - 64 - public function testDeleteAll(): void 65 - { 66 - $b1 = $this->makeBlock(cid: 'cid1'); 67 - $b2 = $this->makeBlock(cid: 'cid2'); 68 - $repo = new InMemoryRepoBlockRepository([$b1, $b2]); 69 - $repo->deleteAll(); 70 - 71 - $this->assertEmpty($repo->findAll()); 72 - } 73 - }
-70
tests/Infrastructure/Persistence/Repo/InMemoryRepoRootRepositoryTest.php
··· 1 - <?php 2 - 3 - declare(strict_types=1); 4 - 5 - namespace Tests\Infrastructure\Persistence\Repo; 6 - 7 - use App\Domain\Repo\RepoRoot; 8 - use App\Domain\Repo\RepoRootNotFoundException; 9 - use App\Infrastructure\Persistence\Repo\InMemoryRepoRootRepository; 10 - use DateTimeImmutable; 11 - use Tests\TestCase; 12 - 13 - class InMemoryRepoRootRepositoryTest extends TestCase 14 - { 15 - private function makeRoot(string $did = 'did:plc:abc123'): RepoRoot 16 - { 17 - return new RepoRoot( 18 - did: $did, 19 - cid: 'bafyreiabc', 20 - rev: '3aaaa', 21 - indexedAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 22 - ); 23 - } 24 - 25 - public function testFindByDidReturnsSeededRoot(): void 26 - { 27 - $root = $this->makeRoot(); 28 - $repo = new InMemoryRepoRootRepository([$root]); 29 - 30 - $this->assertSame($root, $repo->findByDid('did:plc:abc123')); 31 - } 32 - 33 - public function testFindByDidThrowsWhenMissing(): void 34 - { 35 - $repo = new InMemoryRepoRootRepository(); 36 - 37 - $this->expectException(RepoRootNotFoundException::class); 38 - $repo->findByDid('did:plc:missing'); 39 - } 40 - 41 - public function testUpsertStoresRoot(): void 42 - { 43 - $repo = new InMemoryRepoRootRepository(); 44 - $root = $this->makeRoot(); 45 - $repo->upsert($root); 46 - 47 - $this->assertSame($root, $repo->findByDid('did:plc:abc123')); 48 - } 49 - 50 - public function testUpsertOverwritesExisting(): void 51 - { 52 - $root1 = $this->makeRoot(); 53 - $repo = new InMemoryRepoRootRepository([$root1]); 54 - 55 - $root2 = new RepoRoot('did:plc:abc123', 'bafyreinew', '3bbbb', new DateTimeImmutable()); 56 - $repo->upsert($root2); 57 - 58 - $this->assertSame('bafyreinew', $repo->findByDid('did:plc:abc123')->getCid()); 59 - } 60 - 61 - public function testDeleteByDid(): void 62 - { 63 - $root = $this->makeRoot(); 64 - $repo = new InMemoryRepoRootRepository([$root]); 65 - $repo->deleteByDid('did:plc:abc123'); 66 - 67 - $this->expectException(RepoRootNotFoundException::class); 68 - $repo->findByDid('did:plc:abc123'); 69 - } 70 - }
+83
tests/Infrastructure/Persistence/Repo/SqliteRepoBlockRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\Repo; 6 + 7 + use App\Domain\Repo\RepoBlock; 8 + use App\Domain\Repo\RepoBlockNotFoundException; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\ActorStoreSchema; 11 + use App\Infrastructure\Persistence\Repo\SqliteRepoBlockRepository; 12 + use Tests\TestCase; 13 + 14 + class SqliteRepoBlockRepositoryTest extends TestCase 15 + { 16 + private function newRepo(): SqliteRepoBlockRepository 17 + { 18 + $db = new Database(':memory:'); 19 + ActorStoreSchema::apply($db); 20 + 21 + return new SqliteRepoBlockRepository($db); 22 + } 23 + 24 + private function makeBlock( 25 + string $cid = 'bafyreiblock', 26 + string $rev = '3aaaa', 27 + ): RepoBlock { 28 + return new RepoBlock( 29 + cid: $cid, 30 + repoRev: $rev, 31 + size: 42, 32 + content: 'placeholder-cbor', 33 + ); 34 + } 35 + 36 + public function testFindByCidReturnsSeededBlock(): void 37 + { 38 + $repo = $this->newRepo(); 39 + $repo->save($this->makeBlock()); 40 + 41 + $found = $repo->findByCid('bafyreiblock'); 42 + $this->assertSame('bafyreiblock', $found->getCid()); 43 + } 44 + 45 + public function testFindByCidThrowsWhenMissing(): void 46 + { 47 + $repo = $this->newRepo(); 48 + 49 + $this->expectException(RepoBlockNotFoundException::class); 50 + $repo->findByCid('bafyreinone'); 51 + } 52 + 53 + public function testFindByRepoRev(): void 54 + { 55 + $repo = $this->newRepo(); 56 + $repo->save($this->makeBlock(cid: 'cid1', rev: 'rev-a')); 57 + $repo->save($this->makeBlock(cid: 'cid2', rev: 'rev-b')); 58 + 59 + $results = $repo->findByRepoRev('rev-a'); 60 + $this->assertCount(1, $results); 61 + $this->assertSame('cid1', $results[0]->getCid()); 62 + } 63 + 64 + public function testSaveAndDeleteByCid(): void 65 + { 66 + $repo = $this->newRepo(); 67 + $repo->save($this->makeBlock()); 68 + $repo->deleteByCid('bafyreiblock'); 69 + 70 + $this->expectException(RepoBlockNotFoundException::class); 71 + $repo->findByCid('bafyreiblock'); 72 + } 73 + 74 + public function testDeleteAll(): void 75 + { 76 + $repo = $this->newRepo(); 77 + $repo->save($this->makeBlock(cid: 'cid1')); 78 + $repo->save($this->makeBlock(cid: 'cid2')); 79 + $repo->deleteAll(); 80 + 81 + $this->assertEmpty($repo->findAll()); 82 + } 83 + }
+82
tests/Infrastructure/Persistence/Repo/SqliteRepoRootRepositoryTest.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace Tests\Infrastructure\Persistence\Repo; 6 + 7 + use App\Domain\Repo\RepoRoot; 8 + use App\Domain\Repo\RepoRootNotFoundException; 9 + use App\Infrastructure\Database\Database; 10 + use App\Infrastructure\Database\Schema\ActorStoreSchema; 11 + use App\Infrastructure\Persistence\Repo\SqliteRepoRootRepository; 12 + use DateTimeImmutable; 13 + use Tests\TestCase; 14 + 15 + class SqliteRepoRootRepositoryTest extends TestCase 16 + { 17 + private function newRepo(): SqliteRepoRootRepository 18 + { 19 + $db = new Database(':memory:'); 20 + ActorStoreSchema::apply($db); 21 + 22 + return new SqliteRepoRootRepository($db); 23 + } 24 + 25 + private function makeRoot(string $did = 'did:plc:abc123'): RepoRoot 26 + { 27 + return new RepoRoot( 28 + did: $did, 29 + cid: 'bafyreiabc', 30 + rev: '3aaaa', 31 + indexedAt: new DateTimeImmutable('2026-01-01T00:00:00Z'), 32 + ); 33 + } 34 + 35 + public function testFindByDidReturnsSeededRoot(): void 36 + { 37 + $repo = $this->newRepo(); 38 + $repo->upsert($this->makeRoot()); 39 + 40 + $found = $repo->findByDid('did:plc:abc123'); 41 + $this->assertSame('did:plc:abc123', $found->getDid()); 42 + } 43 + 44 + public function testFindByDidThrowsWhenMissing(): void 45 + { 46 + $repo = $this->newRepo(); 47 + 48 + $this->expectException(RepoRootNotFoundException::class); 49 + $repo->findByDid('did:plc:missing'); 50 + } 51 + 52 + public function testUpsertStoresRoot(): void 53 + { 54 + $repo = $this->newRepo(); 55 + $root = $this->makeRoot(); 56 + $repo->upsert($root); 57 + 58 + $found = $repo->findByDid('did:plc:abc123'); 59 + $this->assertSame('bafyreiabc', $found->getCid()); 60 + } 61 + 62 + public function testUpsertOverwritesExisting(): void 63 + { 64 + $repo = $this->newRepo(); 65 + $repo->upsert($this->makeRoot()); 66 + 67 + $root2 = new RepoRoot('did:plc:abc123', 'bafyreinew', '3bbbb', new DateTimeImmutable()); 68 + $repo->upsert($root2); 69 + 70 + $this->assertSame('bafyreinew', $repo->findByDid('did:plc:abc123')->getCid()); 71 + } 72 + 73 + public function testDeleteByDid(): void 74 + { 75 + $repo = $this->newRepo(); 76 + $repo->upsert($this->makeRoot()); 77 + $repo->deleteByDid('did:plc:abc123'); 78 + 79 + $this->expectException(RepoRootNotFoundException::class); 80 + $repo->findByDid('did:plc:abc123'); 81 + } 82 + }
+22 -12
tests/Infrastructure/Persistence/Sequencer/InMemorySequencerRepositoryTest.php tests/Infrastructure/Persistence/Sequencer/SqliteSequencerRepositoryTest.php
··· 5 5 namespace Tests\Infrastructure\Persistence\Sequencer; 6 6 7 7 use App\Domain\Sequencer\RepoSeqEvent; 8 - use App\Infrastructure\Persistence\Sequencer\InMemorySequencerRepository; 8 + use App\Infrastructure\Database\Database; 9 + use App\Infrastructure\Database\Schema\SequencerSchema; 10 + use App\Infrastructure\Persistence\Sequencer\SqliteSequencerRepository; 9 11 use Tests\TestCase; 10 12 11 - class InMemorySequencerRepositoryTest extends TestCase 13 + class SqliteSequencerRepositoryTest extends TestCase 12 14 { 15 + private function newRepo(): SqliteSequencerRepository 16 + { 17 + $db = new Database(':memory:'); 18 + SequencerSchema::apply($db); 19 + 20 + return new SqliteSequencerRepository($db); 21 + } 22 + 13 23 public function testAppendReturnsMonotonicallyIncreasingSeq(): void 14 24 { 15 - $repo = new InMemorySequencerRepository(); 25 + $repo = $this->newRepo(); 16 26 17 27 $seq1 = $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'event1'); 18 28 $seq2 = $repo->append('did:plc:alice', RepoSeqEvent::TYPE_IDENTITY, 'event2'); ··· 25 35 26 36 public function testLatestSeqReturnsNullWhenEmpty(): void 27 37 { 28 - $repo = new InMemorySequencerRepository(); 38 + $repo = $this->newRepo(); 29 39 $this->assertNull($repo->latestSeq()); 30 40 } 31 41 32 42 public function testLatestSeqReturnsHighestSeq(): void 33 43 { 34 - $repo = new InMemorySequencerRepository(); 44 + $repo = $this->newRepo(); 35 45 $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e1'); 36 46 $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e2'); 37 47 ··· 40 50 41 51 public function testReadRangeReturnsEventsAfterGivenSeq(): void 42 52 { 43 - $repo = new InMemorySequencerRepository(); 44 - $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e1'); // seq=1 45 - $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e2'); // seq=2 46 - $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e3'); // seq=3 53 + $repo = $this->newRepo(); 54 + $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e1'); 55 + $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e2'); 56 + $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e3'); 47 57 48 58 $results = $repo->readRange(1); 49 59 $this->assertCount(2, $results); ··· 53 63 54 64 public function testReadRangeRespectsLimit(): void 55 65 { 56 - $repo = new InMemorySequencerRepository(); 66 + $repo = $this->newRepo(); 57 67 for ($i = 0; $i < 10; $i++) { 58 68 $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, "e{$i}"); 59 69 } ··· 64 74 65 75 public function testInvalidateForDidMarksEvents(): void 66 76 { 67 - $repo = new InMemorySequencerRepository(); 77 + $repo = $this->newRepo(); 68 78 $repo->append('did:plc:alice', RepoSeqEvent::TYPE_APPEND, 'e1'); 69 79 $repo->append('did:plc:bob', RepoSeqEvent::TYPE_APPEND, 'e2'); 70 80 $repo->invalidateForDid('did:plc:alice'); ··· 79 89 80 90 public function testReadRangeResultsAreInAscendingOrder(): void 81 91 { 82 - $repo = new InMemorySequencerRepository(); 92 + $repo = $this->newRepo(); 83 93 $repo->append('did:plc:a', RepoSeqEvent::TYPE_APPEND, 'e1'); 84 94 $repo->append('did:plc:b', RepoSeqEvent::TYPE_APPEND, 'e2'); 85 95 $repo->append('did:plc:c', RepoSeqEvent::TYPE_APPEND, 'e3');
+5
tests/bootstrap.php
··· 1 1 <?php 2 2 3 3 require __DIR__ . '/../vendor/autoload.php'; 4 + require_once __DIR__ . '/../app/constants.php'; 4 5 5 6 $hostname = is_string($_ENV['PDS_HOSTNAME'] ?? null) ? $_ENV['PDS_HOSTNAME'] : 'localhost'; 6 7 $_ENV['PDS_HOSTNAME'] = $hostname; ··· 11 12 : 'https://api.bsky.app'; 12 13 $_ENV['PDS_BSKY_APP_VIEW_URL'] = $appViewUrl; 13 14 putenv('PDS_BSKY_APP_VIEW_URL=' . $appViewUrl); 15 + 16 + $dataDir = ':memory:'; 17 + $_ENV['PDS_DATA_DIRECTORY'] = $dataDir; 18 + putenv('PDS_DATA_DIRECTORY=' . $dataDir);