Maintain local ⭤ remote in sync with automatic AT Protocol parity for Laravel (alpha & unstable)
at dev 343 lines 8.1 kB view raw view rendered
1# atp-client Integration 2 3Parity integrates with atp-client to fetch records from the AT Protocol network and convert them to Eloquent models. The `RecordHelper` class provides a simple interface for these operations. 4 5## RecordHelper 6 7The `RecordHelper` is registered as a singleton and available via the container: 8 9```php 10use SocialDept\AtpParity\Support\RecordHelper; 11 12$helper = app(RecordHelper::class); 13``` 14 15### How It Works 16 17When you provide an AT Protocol URI, RecordHelper: 18 191. Parses the URI to extract the DID, collection, and rkey 202. Resolves the DID to find the user's PDS endpoint (via atp-resolver) 213. Creates a public client for that PDS 224. Fetches the record 235. Converts it using the registered mapper 24 25This means it works with any AT Protocol server, not just Bluesky. 26 27## Fetching Records 28 29### `fetch(string $uri, ?string $recordClass = null): mixed` 30 31Fetches a record and returns it as a typed DTO. 32 33```php 34use SocialDept\AtpParity\Support\RecordHelper; 35use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post; 36 37$helper = app(RecordHelper::class); 38 39// Auto-detect type from registered mapper 40$record = $helper->fetch('at://did:plc:abc123/app.bsky.feed.post/xyz789'); 41 42// Or specify the class explicitly 43$record = $helper->fetch( 44 'at://did:plc:abc123/app.bsky.feed.post/xyz789', 45 Post::class 46); 47 48// Access typed properties 49echo $record->text; 50echo $record->createdAt; 51``` 52 53### `fetchAsModel(string $uri): ?Model` 54 55Fetches a record and converts it to an Eloquent model (unsaved). 56 57```php 58$post = $helper->fetchAsModel('at://did:plc:abc123/app.bsky.feed.post/xyz789'); 59 60if ($post) { 61 echo $post->content; 62 echo $post->atp_uri; 63 echo $post->atp_cid; 64 65 // Save if you want to persist it 66 $post->save(); 67} 68``` 69 70Returns `null` if no mapper is registered for the collection. 71 72### `sync(string $uri): ?Model` 73 74Fetches a record and upserts it to the database. 75 76```php 77// Creates or updates the model 78$post = $helper->sync('at://did:plc:abc123/app.bsky.feed.post/xyz789'); 79 80// Model is saved automatically 81echo $post->id; 82echo $post->content; 83``` 84 85This is the most common method for syncing remote records to your database. 86 87## Working with Responses 88 89### `hydrateRecord(GetRecordResponse $response, ?string $recordClass = null): mixed` 90 91If you already have a `GetRecordResponse` from atp-client, convert it to a typed DTO: 92 93```php 94use SocialDept\AtpClient\Facades\Atp; 95use SocialDept\AtpParity\Support\RecordHelper; 96 97$helper = app(RecordHelper::class); 98 99// Using atp-client directly 100$client = Atp::public(); 101$response = $client->atproto->repo->getRecord( 102 'did:plc:abc123', 103 'app.bsky.feed.post', 104 'xyz789' 105); 106 107// Convert to typed DTO 108$record = $helper->hydrateRecord($response); 109``` 110 111## Practical Examples 112 113### Syncing a Single Post 114 115```php 116$helper = app(RecordHelper::class); 117 118$uri = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3k2yihcrp6f2c'; 119$post = $helper->sync($uri); 120 121echo "Synced: {$post->content}"; 122``` 123 124### Syncing Multiple Posts 125 126```php 127$helper = app(RecordHelper::class); 128 129$uris = [ 130 'at://did:plc:abc/app.bsky.feed.post/123', 131 'at://did:plc:def/app.bsky.feed.post/456', 132 'at://did:plc:ghi/app.bsky.feed.post/789', 133]; 134 135foreach ($uris as $uri) { 136 try { 137 $post = $helper->sync($uri); 138 echo "Synced: {$post->id}\n"; 139 } catch (\Exception $e) { 140 echo "Failed to sync {$uri}: {$e->getMessage()}\n"; 141 } 142} 143``` 144 145### Fetching for Preview (Without Saving) 146 147```php 148$helper = app(RecordHelper::class); 149 150// Get model without saving 151$post = $helper->fetchAsModel('at://did:plc:xxx/app.bsky.feed.post/abc'); 152 153if ($post) { 154 return view('posts.preview', ['post' => $post]); 155} 156 157return abort(404); 158``` 159 160### Checking if Record Exists Locally 161 162```php 163use App\Models\Post; 164use SocialDept\AtpParity\Support\RecordHelper; 165 166$uri = 'at://did:plc:xxx/app.bsky.feed.post/abc'; 167 168// Check local database first 169$post = Post::whereAtpUri($uri)->first(); 170 171if (!$post) { 172 // Not in database, fetch from network 173 $helper = app(RecordHelper::class); 174 $post = $helper->sync($uri); 175} 176 177return $post; 178``` 179 180### Building a Post Importer 181 182```php 183namespace App\Services; 184 185use SocialDept\AtpParity\Support\RecordHelper; 186use SocialDept\AtpClient\Facades\Atp; 187 188class PostImporter 189{ 190 public function __construct( 191 protected RecordHelper $helper 192 ) {} 193 194 /** 195 * Import all posts from a user. 196 */ 197 public function importUserPosts(string $did, int $limit = 100): array 198 { 199 $imported = []; 200 $client = Atp::public(); 201 $cursor = null; 202 203 do { 204 $response = $client->atproto->repo->listRecords( 205 repo: $did, 206 collection: 'app.bsky.feed.post', 207 limit: min($limit - count($imported), 100), 208 cursor: $cursor 209 ); 210 211 foreach ($response->records as $record) { 212 $post = $this->helper->sync($record->uri); 213 $imported[] = $post; 214 215 if (count($imported) >= $limit) { 216 break 2; 217 } 218 } 219 220 $cursor = $response->cursor; 221 } while ($cursor && count($imported) < $limit); 222 223 return $imported; 224 } 225} 226``` 227 228## Error Handling 229 230RecordHelper returns `null` for various failure conditions: 231 232```php 233$helper = app(RecordHelper::class); 234 235// Invalid URI format 236$result = $helper->fetch('not-a-valid-uri'); 237// Returns null 238 239// No mapper registered for collection 240$result = $helper->fetchAsModel('at://did:plc:xxx/some.unknown.collection/abc'); 241// Returns null 242 243// PDS resolution failed 244$result = $helper->fetch('at://did:plc:invalid/app.bsky.feed.post/abc'); 245// Returns null (or throws exception depending on resolver config) 246``` 247 248For more control, catch exceptions: 249 250```php 251use SocialDept\AtpResolver\Exceptions\DidResolutionException; 252 253try { 254 $post = $helper->sync($uri); 255} catch (DidResolutionException $e) { 256 // DID could not be resolved 257 Log::warning("Could not resolve DID for {$uri}"); 258} catch (\Exception $e) { 259 // Network error, invalid response, etc. 260 Log::error("Failed to sync {$uri}: {$e->getMessage()}"); 261} 262``` 263 264## Performance Considerations 265 266### PDS Client Caching 267 268RecordHelper caches public clients by PDS endpoint: 269 270```php 271// First request to this PDS - creates client 272$helper->sync('at://did:plc:abc/app.bsky.feed.post/1'); 273 274// Same PDS - reuses cached client 275$helper->sync('at://did:plc:abc/app.bsky.feed.post/2'); 276 277// Different PDS - creates new client 278$helper->sync('at://did:plc:xyz/app.bsky.feed.post/1'); 279``` 280 281### DID Resolution Caching 282 283atp-resolver caches DID documents and PDS endpoints. Default TTL is 1 hour. 284 285### Batch Operations 286 287For bulk imports, consider using atp-client's `listRecords` directly and then batch-processing: 288 289```php 290use SocialDept\AtpClient\Facades\Atp; 291use SocialDept\AtpParity\MapperRegistry; 292 293$client = Atp::public($pdsEndpoint); 294$registry = app(MapperRegistry::class); 295$mapper = $registry->forLexicon('app.bsky.feed.post'); 296 297$response = $client->atproto->repo->listRecords( 298 repo: $did, 299 collection: 'app.bsky.feed.post', 300 limit: 100 301); 302 303foreach ($response->records as $record) { 304 $recordClass = $mapper->recordClass(); 305 $dto = $recordClass::fromArray($record->value); 306 307 $mapper->upsert($dto, [ 308 'uri' => $record->uri, 309 'cid' => $record->cid, 310 ]); 311} 312``` 313 314## Using with Authenticated Client 315 316While RecordHelper uses public clients, you can also use authenticated clients for records that require auth: 317 318```php 319use SocialDept\AtpClient\Facades\Atp; 320use SocialDept\AtpParity\MapperRegistry; 321 322// Authenticated client 323$client = Atp::as('user.bsky.social'); 324 325// Fetch a record that requires auth 326$response = $client->atproto->repo->getRecord( 327 repo: $client->session()->did(), 328 collection: 'app.bsky.feed.post', 329 rkey: 'abc123' 330); 331 332// Convert using mapper 333$registry = app(MapperRegistry::class); 334$mapper = $registry->forLexicon('app.bsky.feed.post'); 335 336$recordClass = $mapper->recordClass(); 337$record = $recordClass::fromArray($response->value); 338 339$model = $mapper->upsert($record, [ 340 'uri' => $response->uri, 341 'cid' => $response->cid, 342]); 343```