Maintain local ⭤ remote in sync with automatic AT Protocol parity for Laravel (alpha & unstable)

Add export and discovery utilities

+65
src/Discovery/DiscoveryResult.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Discovery; 4 + 5 + /** 6 + * Immutable value object representing the result of a discovery operation. 7 + */ 8 + readonly class DiscoveryResult 9 + { 10 + public function __construct( 11 + public bool $success, 12 + public array $dids = [], 13 + public int $total = 0, 14 + public ?string $error = null, 15 + public bool $incomplete = false, 16 + ) {} 17 + 18 + /** 19 + * Check if the discovery operation succeeded. 20 + */ 21 + public function isSuccess(): bool 22 + { 23 + return $this->success; 24 + } 25 + 26 + /** 27 + * Check if the discovery operation failed. 28 + */ 29 + public function isFailed(): bool 30 + { 31 + return ! $this->success; 32 + } 33 + 34 + /** 35 + * Check if the discovery was stopped before completion (e.g., limit reached). 36 + */ 37 + public function isIncomplete(): bool 38 + { 39 + return $this->incomplete; 40 + } 41 + 42 + /** 43 + * Create a successful result. 44 + */ 45 + public static function success(array $dids, bool $incomplete = false): self 46 + { 47 + return new self( 48 + success: true, 49 + dids: $dids, 50 + total: count($dids), 51 + incomplete: $incomplete, 52 + ); 53 + } 54 + 55 + /** 56 + * Create a failed result. 57 + */ 58 + public static function failed(string $error): self 59 + { 60 + return new self( 61 + success: false, 62 + error: $error, 63 + ); 64 + } 65 + }
+119
src/Discovery/DiscoveryService.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Discovery; 4 + 5 + use BackedEnum; 6 + use Generator; 7 + use SocialDept\AtpClient\Facades\Atp; 8 + use SocialDept\AtpParity\Import\ImportService; 9 + use Throwable; 10 + 11 + /** 12 + * Service for discovering DIDs with records in specific collections. 13 + */ 14 + class DiscoveryService 15 + { 16 + public function __construct( 17 + protected ImportService $importService 18 + ) {} 19 + 20 + /** 21 + * Discover all DIDs with records in a collection. 22 + * 23 + * @return Generator<string> Yields DIDs 24 + */ 25 + public function discoverDids(string|BackedEnum $collection, ?int $limit = null): Generator 26 + { 27 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 28 + $cursor = null; 29 + $count = 0; 30 + 31 + do { 32 + $response = Atp::atproto->sync->listReposByCollection( 33 + collection: $collection, 34 + limit: min(500, $limit ? $limit - $count : 500), 35 + cursor: $cursor, 36 + ); 37 + 38 + foreach ($response->repos as $repo) { 39 + $did = $repo['did'] ?? null; 40 + 41 + if ($did) { 42 + yield $did; 43 + $count++; 44 + 45 + if ($limit !== null && $count >= $limit) { 46 + return; 47 + } 48 + } 49 + } 50 + 51 + $cursor = $response->cursor; 52 + } while ($cursor !== null); 53 + } 54 + 55 + /** 56 + * Discover DIDs and return as an array. 57 + */ 58 + public function discover(string|BackedEnum $collection, ?int $limit = null): DiscoveryResult 59 + { 60 + try { 61 + $dids = iterator_to_array($this->discoverDids($collection, $limit)); 62 + $incomplete = $limit !== null && count($dids) >= $limit; 63 + 64 + return DiscoveryResult::success($dids, $incomplete); 65 + } catch (Throwable $e) { 66 + return DiscoveryResult::failed($e->getMessage()); 67 + } 68 + } 69 + 70 + /** 71 + * Discover and import all users for a collection. 72 + */ 73 + public function discoverAndImport( 74 + string|BackedEnum $collection, 75 + ?int $limit = null, 76 + ?callable $onProgress = null 77 + ): DiscoveryResult { 78 + $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 79 + 80 + try { 81 + $dids = []; 82 + $count = 0; 83 + 84 + foreach ($this->discoverDids($collection, $limit) as $did) { 85 + $dids[] = $did; 86 + $count++; 87 + 88 + // Start import for this DID 89 + $this->importService->import($did, [$collection]); 90 + 91 + if ($onProgress) { 92 + $onProgress($did, $count); 93 + } 94 + } 95 + 96 + $incomplete = $limit !== null && count($dids) >= $limit; 97 + 98 + return DiscoveryResult::success($dids, $incomplete); 99 + } catch (Throwable $e) { 100 + return DiscoveryResult::failed($e->getMessage()); 101 + } 102 + } 103 + 104 + /** 105 + * Count total DIDs with records in a collection. 106 + * 107 + * Note: This iterates through all results, which can be slow. 108 + */ 109 + public function count(string|BackedEnum $collection): int 110 + { 111 + $count = 0; 112 + 113 + foreach ($this->discoverDids($collection) as $_) { 114 + $count++; 115 + } 116 + 117 + return $count; 118 + } 119 + }
+55
src/Export/ExportResult.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Export; 4 + 5 + /** 6 + * Value object representing the result of an export operation. 7 + */ 8 + readonly class ExportResult 9 + { 10 + public function __construct( 11 + public bool $success, 12 + public ?string $path = null, 13 + public ?int $size = null, 14 + public ?string $error = null, 15 + ) {} 16 + 17 + /** 18 + * Check if the export operation succeeded. 19 + */ 20 + public function isSuccess(): bool 21 + { 22 + return $this->success; 23 + } 24 + 25 + /** 26 + * Check if the export operation failed. 27 + */ 28 + public function isFailed(): bool 29 + { 30 + return ! $this->success; 31 + } 32 + 33 + /** 34 + * Create a successful result. 35 + */ 36 + public static function success(string $path, int $size): self 37 + { 38 + return new self( 39 + success: true, 40 + path: $path, 41 + size: $size, 42 + ); 43 + } 44 + 45 + /** 46 + * Create a failed result. 47 + */ 48 + public static function failed(string $error): self 49 + { 50 + return new self( 51 + success: false, 52 + error: $error, 53 + ); 54 + } 55 + }
+142
src/Export/ExportService.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Export; 4 + 5 + use BackedEnum; 6 + use Generator; 7 + use SocialDept\AtpClient\Facades\Atp; 8 + use SocialDept\AtpParity\Import\ImportService; 9 + use SocialDept\AtpParity\MapperRegistry; 10 + use Throwable; 11 + 12 + /** 13 + * Service for exporting AT Protocol repositories. 14 + */ 15 + class ExportService 16 + { 17 + public function __construct( 18 + protected MapperRegistry $registry, 19 + protected ImportService $importService 20 + ) {} 21 + 22 + /** 23 + * Download a user's repository as CAR data. 24 + */ 25 + public function downloadRepo(string $did, ?string $since = null): RepoExport 26 + { 27 + $response = Atp::atproto->sync->getRepo($did, $since); 28 + $carData = $response->body(); 29 + 30 + return new RepoExport( 31 + did: $did, 32 + carData: $carData, 33 + size: strlen($carData), 34 + ); 35 + } 36 + 37 + /** 38 + * Export a repository to a local file. 39 + */ 40 + public function exportToFile(string $did, string $path, ?string $since = null): ExportResult 41 + { 42 + try { 43 + $export = $this->downloadRepo($did, $since); 44 + 45 + if (! $export->saveTo($path)) { 46 + return ExportResult::failed("Failed to write to file: {$path}"); 47 + } 48 + 49 + return ExportResult::success($path, $export->size); 50 + } catch (Throwable $e) { 51 + return ExportResult::failed($e->getMessage()); 52 + } 53 + } 54 + 55 + /** 56 + * Export and import records from a repository. 57 + * 58 + * This downloads the repository and imports records using the normal import pipeline. 59 + * It's useful for bulk importing all records from a user. 60 + * 61 + * @param array<string>|null $collections Specific collections to import (null = all registered) 62 + */ 63 + public function exportAndImport( 64 + string $did, 65 + ?array $collections = null, 66 + ?callable $onProgress = null 67 + ): ExportResult { 68 + try { 69 + // Use the import service to import the user's records 70 + $result = $this->importService->importUser($did, $collections, $onProgress); 71 + 72 + if ($result->isFailed()) { 73 + return ExportResult::failed($result->error ?? 'Import failed'); 74 + } 75 + 76 + return ExportResult::success( 77 + path: "imported:{$did}", 78 + size: $result->recordsSynced 79 + ); 80 + } catch (Throwable $e) { 81 + return ExportResult::failed($e->getMessage()); 82 + } 83 + } 84 + 85 + /** 86 + * List available blobs for a repository. 87 + * 88 + * @return Generator<string> Yields blob CIDs 89 + */ 90 + public function listBlobs(string $did, ?string $since = null): Generator 91 + { 92 + $cursor = null; 93 + 94 + do { 95 + $response = Atp::atproto->sync->listBlobs( 96 + did: $did, 97 + since: $since, 98 + limit: 500, 99 + cursor: $cursor, 100 + ); 101 + 102 + foreach ($response->cids as $cid) { 103 + yield $cid; 104 + } 105 + 106 + $cursor = $response->cursor; 107 + } while ($cursor !== null); 108 + } 109 + 110 + /** 111 + * Download a specific blob. 112 + */ 113 + public function downloadBlob(string $did, string $cid): string 114 + { 115 + $response = Atp::atproto->sync->getBlob($did, $cid); 116 + 117 + return $response->body(); 118 + } 119 + 120 + /** 121 + * Get the latest commit for a repository. 122 + */ 123 + public function getLatestCommit(string $did): array 124 + { 125 + $commit = Atp::atproto->sync->getLatestCommit($did); 126 + 127 + return [ 128 + 'cid' => $commit->cid, 129 + 'rev' => $commit->rev, 130 + ]; 131 + } 132 + 133 + /** 134 + * Get the hosting status for a repository. 135 + */ 136 + public function getRepoStatus(string $did): array 137 + { 138 + $status = Atp::atproto->sync->getRepoStatus($did); 139 + 140 + return $status->toArray(); 141 + } 142 + }
+40
src/Export/RepoExport.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Export; 4 + 5 + /** 6 + * Value object representing an exported repository as CAR data. 7 + */ 8 + readonly class RepoExport 9 + { 10 + public function __construct( 11 + public string $did, 12 + public string $carData, 13 + public int $size, 14 + ) {} 15 + 16 + /** 17 + * Save the CAR data to a file. 18 + */ 19 + public function saveTo(string $path): bool 20 + { 21 + return file_put_contents($path, $this->carData) !== false; 22 + } 23 + 24 + /** 25 + * Get the size in human-readable format. 26 + */ 27 + public function humanSize(): string 28 + { 29 + $units = ['B', 'KB', 'MB', 'GB']; 30 + $size = $this->size; 31 + $unit = 0; 32 + 33 + while ($size >= 1024 && $unit < count($units) - 1) { 34 + $size /= 1024; 35 + $unit++; 36 + } 37 + 38 + return round($size, 2).' '.$units[$unit]; 39 + } 40 + }