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

Merge branch 'refs/heads/dev'

Changed files
+9021 -208
.github
config
database
docs
src
tests
+24
.github/workflows/code-style.yml
··· 1 + name: Code Style 2 + 3 + on: 4 + pull_request: 5 + branches: [ main, dev ] 6 + 7 + jobs: 8 + php-cs-fixer: 9 + runs-on: ubuntu-latest 10 + 11 + steps: 12 + - name: Checkout code 13 + uses: actions/checkout@v4 14 + 15 + - name: Setup PHP 16 + uses: shivammathur/setup-php@v2 17 + with: 18 + php-version: 8.3 19 + extensions: gmp, mbstring, json 20 + coverage: none 21 + tools: php-cs-fixer 22 + 23 + - name: Run PHP CS Fixer 24 + run: php-cs-fixer fix --dry-run --diff --verbose
+30
.github/workflows/tests.yml
··· 1 + name: Tests 2 + 3 + on: 4 + pull_request: 5 + branches: [ main, dev ] 6 + 7 + jobs: 8 + test: 9 + runs-on: ubuntu-latest 10 + 11 + name: Tests (PHP 8.2 - Laravel 12) 12 + 13 + steps: 14 + - name: Checkout code 15 + uses: actions/checkout@v4 16 + 17 + - name: Setup PHP 18 + uses: shivammathur/setup-php@v2 19 + with: 20 + php-version: 8.2 21 + extensions: gmp, mbstring, json 22 + coverage: none 23 + 24 + - name: Install dependencies 25 + run: | 26 + composer require "laravel/framework:^12.0" "orchestra/testbench:^10.0" --no-interaction --no-update 27 + composer update --prefer-stable --prefer-dist --no-interaction 28 + 29 + - name: Execute tests 30 + run: vendor/bin/phpunit
+35
.php-cs-fixer.php
··· 1 + <?php 2 + 3 + use PhpCsFixer\Config; 4 + use PhpCsFixer\Finder; 5 + 6 + $finder = Finder::create() 7 + ->in(__DIR__ . '/src') 8 + ->in(__DIR__ . '/tests') 9 + ->name('*.php') 10 + ->notName('*.blade.php') 11 + ->ignoreDotFiles(true) 12 + ->ignoreVCS(true); 13 + 14 + return (new Config()) 15 + ->setRules([ 16 + '@PSR12' => true, 17 + 'array_syntax' => ['syntax' => 'short'], 18 + 'ordered_imports' => ['sort_algorithm' => 'alpha'], 19 + 'no_unused_imports' => true, 20 + 'not_operator_with_successor_space' => true, 21 + 'trailing_comma_in_multiline' => true, 22 + 'phpdoc_scalar' => true, 23 + 'unary_operator_spaces' => true, 24 + 'binary_operator_spaces' => true, 25 + 'blank_line_before_statement' => [ 26 + 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 27 + ], 28 + 'phpdoc_single_line_var_spacing' => true, 29 + 'phpdoc_var_without_name' => true, 30 + 'method_argument_space' => [ 31 + 'on_multiline' => 'ensure_fully_multiline', 32 + 'keep_multiple_spaces_after_comma' => true, 33 + ], 34 + ]) 35 + ->setFinder($finder);
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Social Dept 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+426
README.md
··· 1 + [![Parity Header](./header.png)](https://github.com/socialdept/atp-parity) 2 + 3 + <h3 align="center"> 4 + Bidirectional mapping between AT Protocol records and Laravel Eloquent models. 5 + </h3> 6 + 7 + <p align="center"> 8 + <br> 9 + <a href="https://packagist.org/packages/socialdept/atp-parity" title="Latest Version on Packagist"><img src="https://img.shields.io/packagist/v/socialdept/atp-parity.svg?style=flat-square"></a> 10 + <a href="https://packagist.org/packages/socialdept/atp-parity" title="Total Downloads"><img src="https://img.shields.io/packagist/dt/socialdept/atp-parity.svg?style=flat-square"></a> 11 + <a href="https://github.com/socialdept/atp-parity/actions/workflows/tests.yml" title="GitHub Tests Action Status"><img src="https://img.shields.io/github/actions/workflow/status/socialdept/atp-parity/tests.yml?branch=main&label=tests&style=flat-square"></a> 12 + <a href="LICENSE" title="Software License"><img src="https://img.shields.io/github/license/socialdept/atp-parity?style=flat-square"></a> 13 + </p> 14 + 15 + --- 16 + 17 + ## What is Parity? 18 + 19 + **Parity** is a Laravel package that bridges your Eloquent models with AT Protocol records. It provides bidirectional mapping, automatic firehose synchronization, and type-safe transformations between your database and the decentralized social web. 20 + 21 + Think of it as Laravel's model casts, but for AT Protocol records. 22 + 23 + ## Why use Parity? 24 + 25 + - **Laravel-style code** - Familiar patterns you already know 26 + - **Bidirectional mapping** - Transform records to models and back 27 + - **Firehose sync** - Automatically sync network events to your database 28 + - **Type-safe DTOs** - Full integration with atp-schema generated types 29 + - **Model traits** - Add AT Protocol awareness to any Eloquent model 30 + - **Flexible mappers** - Define custom transformations for your domain 31 + 32 + ## Quick Example 33 + 34 + ```php 35 + use SocialDept\AtpParity\RecordMapper; 36 + use SocialDept\AtpSchema\Data\Data; 37 + use Illuminate\Database\Eloquent\Model; 38 + 39 + class PostMapper extends RecordMapper 40 + { 41 + public function recordClass(): string 42 + { 43 + return \SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post::class; 44 + } 45 + 46 + public function modelClass(): string 47 + { 48 + return \App\Models\Post::class; 49 + } 50 + 51 + protected function recordToAttributes(Data $record): array 52 + { 53 + return [ 54 + 'content' => $record->text, 55 + 'published_at' => $record->createdAt, 56 + ]; 57 + } 58 + 59 + protected function modelToRecordData(Model $model): array 60 + { 61 + return [ 62 + 'text' => $model->content, 63 + 'createdAt' => $model->published_at->toIso8601String(), 64 + ]; 65 + } 66 + } 67 + ``` 68 + 69 + ## Installation 70 + 71 + ```bash 72 + composer require socialdept/atp-parity 73 + ``` 74 + 75 + Optionally publish the configuration: 76 + 77 + ```bash 78 + php artisan vendor:publish --tag=parity-config 79 + ``` 80 + 81 + ## Getting Started 82 + 83 + Once installed, you're three steps away from syncing AT Protocol records: 84 + 85 + ### 1. Create a Mapper 86 + 87 + Define how your record maps to your model: 88 + 89 + ```php 90 + class PostMapper extends RecordMapper 91 + { 92 + public function recordClass(): string 93 + { 94 + return Post::class; // Your atp-schema DTO or custom Record 95 + } 96 + 97 + public function modelClass(): string 98 + { 99 + return \App\Models\Post::class; 100 + } 101 + 102 + protected function recordToAttributes(Data $record): array 103 + { 104 + return ['content' => $record->text]; 105 + } 106 + 107 + protected function modelToRecordData(Model $model): array 108 + { 109 + return ['text' => $model->content]; 110 + } 111 + } 112 + ``` 113 + 114 + ### 2. Register Your Mapper 115 + 116 + ```php 117 + // config/parity.php 118 + return [ 119 + 'mappers' => [ 120 + App\AtpMappers\PostMapper::class, 121 + ], 122 + ]; 123 + ``` 124 + 125 + ### 3. Add the Trait to Your Model 126 + 127 + ```php 128 + use SocialDept\AtpParity\Concerns\HasAtpRecord; 129 + 130 + class Post extends Model 131 + { 132 + use HasAtpRecord; 133 + } 134 + ``` 135 + 136 + Your model can now convert to/from AT Protocol records and query by URI. 137 + 138 + ## What can you build? 139 + 140 + - **Data mirrors** - Keep local copies of AT Protocol data 141 + - **AppViews** - Build custom applications with synced data 142 + - **Analytics platforms** - Store and analyze network activity 143 + - **Content aggregators** - Collect and organize posts locally 144 + - **Moderation tools** - Track and manage content in your database 145 + - **Hybrid applications** - Combine local and federated data 146 + 147 + ## Ecosystem Integration 148 + 149 + Parity is designed to work seamlessly with the other atp-* packages: 150 + 151 + | Package | Integration | 152 + |---------|-------------| 153 + | **atp-schema** | Records extend `Data`, use generated DTOs directly | 154 + | **atp-client** | `RecordHelper` for fetching and hydrating records | 155 + | **atp-signals** | `ParitySignal` for automatic firehose sync | 156 + 157 + ### Using with atp-schema 158 + 159 + Use generated schema classes directly with `SchemaMapper`: 160 + 161 + ```php 162 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post; 163 + use SocialDept\AtpParity\Support\SchemaMapper; 164 + 165 + $mapper = new SchemaMapper( 166 + schemaClass: Post::class, 167 + modelClass: \App\Models\Post::class, 168 + toAttributes: fn(Post $p) => [ 169 + 'content' => $p->text, 170 + 'published_at' => $p->createdAt, 171 + ], 172 + toRecordData: fn($m) => [ 173 + 'text' => $m->content, 174 + 'createdAt' => $m->published_at->toIso8601String(), 175 + ], 176 + ); 177 + 178 + $registry->register($mapper); 179 + ``` 180 + 181 + ### Using with atp-client 182 + 183 + Fetch records by URI and convert directly to models: 184 + 185 + ```php 186 + use SocialDept\AtpParity\Support\RecordHelper; 187 + 188 + $helper = app(RecordHelper::class); 189 + 190 + // Fetch as typed DTO 191 + $record = $helper->fetch('at://did:plc:xxx/app.bsky.feed.post/abc123'); 192 + 193 + // Fetch and convert to model (unsaved) 194 + $post = $helper->fetchAsModel('at://did:plc:xxx/app.bsky.feed.post/abc123'); 195 + 196 + // Fetch and sync to database (upsert) 197 + $post = $helper->sync('at://did:plc:xxx/app.bsky.feed.post/abc123'); 198 + ``` 199 + 200 + The helper automatically resolves the DID to find the correct PDS endpoint, so it works with any AT Protocol server - not just Bluesky. 201 + 202 + ### Using with atp-signals 203 + 204 + Enable automatic firehose synchronization by registering the `ParitySignal`: 205 + 206 + ```php 207 + // config/signal.php 208 + return [ 209 + 'signals' => [ 210 + \SocialDept\AtpParity\Signals\ParitySignal::class, 211 + ], 212 + ]; 213 + ``` 214 + 215 + Run `php artisan signal:consume` and your models will automatically sync with matching firehose events. 216 + 217 + ### Importing Historical Data 218 + 219 + For existing records created before you started consuming the firehose: 220 + 221 + ```bash 222 + # Import a user's records 223 + php artisan parity:import did:plc:z72i7hdynmk6r22z27h6tvur 224 + 225 + # Check import status 226 + php artisan parity:import-status 227 + ``` 228 + 229 + Or programmatically: 230 + 231 + ```php 232 + use SocialDept\AtpParity\Import\ImportService; 233 + 234 + $service = app(ImportService::class); 235 + $result = $service->importUser('did:plc:z72i7hdynmk6r22z27h6tvur'); 236 + 237 + echo "Synced {$result->recordsSynced} records"; 238 + ``` 239 + 240 + ## Documentation 241 + 242 + For detailed documentation on specific topics: 243 + 244 + - [Record Mappers](docs/mappers.md) - Creating and using mappers 245 + - [Model Traits](docs/traits.md) - HasAtpRecord and SyncsWithAtp 246 + - [atp-schema Integration](docs/atp-schema-integration.md) - Using generated DTOs 247 + - [atp-client Integration](docs/atp-client-integration.md) - RecordHelper and fetching 248 + - [atp-signals Integration](docs/atp-signals-integration.md) - ParitySignal and firehose sync 249 + - [Importing](docs/importing.md) - Syncing historical data 250 + 251 + ## Model Traits 252 + 253 + ### HasAtpRecord 254 + 255 + Add AT Protocol awareness to your models: 256 + 257 + ```php 258 + use SocialDept\AtpParity\Concerns\HasAtpRecord; 259 + 260 + class Post extends Model 261 + { 262 + use HasAtpRecord; 263 + 264 + protected $fillable = ['content', 'atp_uri', 'atp_cid']; 265 + } 266 + ``` 267 + 268 + Available methods: 269 + 270 + ```php 271 + // Get AT Protocol metadata 272 + $post->getAtpUri(); // at://did:plc:xxx/app.bsky.feed.post/rkey 273 + $post->getAtpCid(); // bafyre... 274 + $post->getAtpDid(); // did:plc:xxx (extracted from URI) 275 + $post->getAtpCollection(); // app.bsky.feed.post (extracted from URI) 276 + $post->getAtpRkey(); // rkey (extracted from URI) 277 + 278 + // Check sync status 279 + $post->hasAtpRecord(); // true if synced 280 + 281 + // Convert to record DTO 282 + $record = $post->toAtpRecord(); 283 + 284 + // Query scopes 285 + Post::withAtpRecord()->get(); // Only synced posts 286 + Post::withoutAtpRecord()->get(); // Only unsynced posts 287 + Post::whereAtpUri($uri)->first(); // Find by URI 288 + ``` 289 + 290 + ### SyncsWithAtp 291 + 292 + Extended trait for bidirectional sync tracking: 293 + 294 + ```php 295 + use SocialDept\AtpParity\Concerns\SyncsWithAtp; 296 + 297 + class Post extends Model 298 + { 299 + use SyncsWithAtp; 300 + } 301 + ``` 302 + 303 + Additional methods: 304 + 305 + ```php 306 + // Track sync status 307 + $post->getAtpSyncedAt(); // Last sync timestamp 308 + $post->hasLocalChanges(); // True if updated since last sync 309 + 310 + // Mark as synced 311 + $post->markAsSynced($uri, $cid); 312 + 313 + // Update from remote 314 + $post->updateFromRecord($record, $uri, $cid); 315 + ``` 316 + 317 + ## Database Migration 318 + 319 + Add AT Protocol columns to your models: 320 + 321 + ```php 322 + Schema::table('posts', function (Blueprint $table) { 323 + $table->string('atp_uri')->nullable()->unique(); 324 + $table->string('atp_cid')->nullable(); 325 + $table->timestamp('atp_synced_at')->nullable(); // For SyncsWithAtp 326 + }); 327 + ``` 328 + 329 + ## Configuration 330 + 331 + ```php 332 + // config/parity.php 333 + return [ 334 + // Registered mappers 335 + 'mappers' => [ 336 + App\AtpMappers\PostMapper::class, 337 + App\AtpMappers\ProfileMapper::class, 338 + ], 339 + 340 + // Column names for AT Protocol metadata 341 + 'columns' => [ 342 + 'uri' => 'atp_uri', 343 + 'cid' => 'atp_cid', 344 + ], 345 + ]; 346 + ``` 347 + 348 + ## Creating Custom Records 349 + 350 + Extend the `Record` base class for custom AT Protocol records: 351 + 352 + ```php 353 + use SocialDept\AtpParity\Data\Record; 354 + use Carbon\Carbon; 355 + 356 + class PostRecord extends Record 357 + { 358 + public function __construct( 359 + public readonly string $text, 360 + public readonly Carbon $createdAt, 361 + public readonly ?array $facets = null, 362 + ) {} 363 + 364 + public static function getLexicon(): string 365 + { 366 + return 'app.bsky.feed.post'; 367 + } 368 + 369 + public static function fromArray(array $data): static 370 + { 371 + return new static( 372 + text: $data['text'], 373 + createdAt: Carbon::parse($data['createdAt']), 374 + facets: $data['facets'] ?? null, 375 + ); 376 + } 377 + } 378 + ``` 379 + 380 + The `Record` class extends `atp-schema`'s `Data` and implements `atp-client`'s `Recordable` interface, ensuring full compatibility with the ecosystem. 381 + 382 + ## Requirements 383 + 384 + - PHP 8.2+ 385 + - Laravel 10, 11, or 12 386 + - [socialdept/atp-schema](https://github.com/socialdept/atp-schema) ^0.3 387 + - [socialdept/atp-client](https://github.com/socialdept/atp-client) ^0.0 388 + - [socialdept/atp-resolver](https://github.com/socialdept/atp-resolver) ^1.1 389 + - [socialdept/atp-signals](https://github.com/socialdept/atp-signals) ^1.1 390 + 391 + ## Testing 392 + 393 + ```bash 394 + composer test 395 + ``` 396 + 397 + ## Resources 398 + 399 + - [AT Protocol Documentation](https://atproto.com/) 400 + - [Bluesky API Docs](https://docs.bsky.app/) 401 + - [atp-schema](https://github.com/socialdept/atp-schema) - Generated AT Protocol DTOs 402 + - [atp-client](https://github.com/socialdept/atp-client) - AT Protocol HTTP client 403 + - [atp-signals](https://github.com/socialdept/atp-signals) - Firehose event consumer 404 + 405 + ## Support & Contributing 406 + 407 + Found a bug or have a feature request? [Open an issue](https://github.com/socialdept/atp-parity/issues). 408 + 409 + Want to contribute? Check out the [contribution guidelines](contributing.md). 410 + 411 + ## Changelog 412 + 413 + Please see [changelog](changelog.md) for recent changes. 414 + 415 + ## Credits 416 + 417 + - [Miguel Batres](https://batres.co) - founder & lead maintainer 418 + - [All contributors](https://github.com/socialdept/atp-parity/graphs/contributors) 419 + 420 + ## License 421 + 422 + Parity is open-source software licensed under the [MIT license](license.md). 423 + 424 + --- 425 + 426 + **Built for the Federation** - By Social Dept.
-8
changelog.md
··· 1 - # Changelog 2 - 3 - All notable changes to `AtpReplicator` will be documented in this file. 4 - 5 - ## Version 1.0 6 - 7 - ### Added 8 - - Everything
+15 -9
composer.json
··· 1 1 { 2 2 "name": "socialdept/atp-parity", 3 - "description": ":package_description", 3 + "description": "AT Protocol record mapping and sync for Laravel Eloquent models", 4 4 "license": "MIT", 5 5 "authors": [ 6 6 { ··· 10 10 } 11 11 ], 12 12 "homepage": "https://github.com/socialdept/atp-parity", 13 - "keywords": ["Laravel", "AtpReplicator"], 13 + "keywords": ["Laravel", "AT Protocol", "Bluesky", "ATP", "Parity", "Sync"], 14 14 "require": { 15 - "illuminate/support": "~9" 15 + "php": "^8.2", 16 + "illuminate/support": "^10.0|^11.0|^12.0", 17 + "illuminate/database": "^10.0|^11.0|^12.0", 18 + "socialdept/atp-schema": "^0.3", 19 + "socialdept/atp-client": "^0.0", 20 + "socialdept/atp-resolver": "^1.1", 21 + "socialdept/atp-signals": "^1.1" 16 22 }, 17 23 "require-dev": { 18 - "../../../vendor/phpunit/phpunit": "~9.0", 19 - "orchestra/testbench": "~7" 24 + "phpunit/phpunit": "^10.0|^11.0", 25 + "orchestra/testbench": "^8.0|^9.0|^10.0" 20 26 }, 21 27 "autoload": { 22 28 "psr-4": { 23 - "SocialDept\\AtpReplicator\\": "src/" 29 + "SocialDept\\AtpParity\\": "src/" 24 30 } 25 31 }, 26 32 "autoload-dev": { 27 33 "psr-4": { 28 - "SocialDept\\AtpReplicator\\Tests\\": "tests" 34 + "SocialDept\\AtpParity\\Tests\\": "tests" 29 35 } 30 36 }, 31 37 "extra": { 32 38 "laravel": { 33 39 "providers": [ 34 - "SocialDept\\AtpReplicator\\AtpReplicatorServiceProvider" 40 + "SocialDept\\AtpParity\\ParityServiceProvider" 35 41 ], 36 42 "aliases": { 37 - "AtpReplicator": "SocialDept\\AtpReplicator\\Facades\\AtpReplicator" 43 + "Parity": "SocialDept\\AtpParity\\Facades\\Parity" 38 44 } 39 45 } 40 46 }
-5
config/atp-replicator.php
··· 1 - <?php 2 - 3 - return [ 4 - // 5 - ];
+105
config/parity.php
··· 1 + <?php 2 + 3 + return [ 4 + /* 5 + |-------------------------------------------------------------------------- 6 + | Record Mappers 7 + |-------------------------------------------------------------------------- 8 + | 9 + | List of RecordMapper classes to automatically register. Each mapper 10 + | handles bidirectional conversion between an AT Protocol record DTO 11 + | and an Eloquent model. 12 + | 13 + */ 14 + 'mappers' => [ 15 + // App\AtpMappers\PostMapper::class, 16 + // App\AtpMappers\ProfileMapper::class, 17 + ], 18 + 19 + /* 20 + |-------------------------------------------------------------------------- 21 + | AT Protocol Metadata Columns 22 + |-------------------------------------------------------------------------- 23 + | 24 + | The column names used to store AT Protocol metadata on models. 25 + | 26 + */ 27 + 'columns' => [ 28 + 'uri' => 'atp_uri', 29 + 'cid' => 'atp_cid', 30 + ], 31 + 32 + /* 33 + |-------------------------------------------------------------------------- 34 + | Import Configuration 35 + |-------------------------------------------------------------------------- 36 + | 37 + | Settings for importing historical AT Protocol records to your database. 38 + | 39 + */ 40 + 'import' => [ 41 + // Records per page when listing from PDS 42 + 'page_size' => 100, 43 + 44 + // Delay between pages in milliseconds (rate limiting) 45 + 'page_delay' => 100, 46 + 47 + // Queue name for import jobs 48 + 'queue' => 'default', 49 + 50 + // Database table for storing import state 51 + 'state_table' => 'parity_import_states', 52 + ], 53 + 54 + /* 55 + |-------------------------------------------------------------------------- 56 + | Sync Filtering 57 + |-------------------------------------------------------------------------- 58 + | 59 + | Control which firehose events get synced to your database. 60 + | 61 + */ 62 + 'sync' => [ 63 + // Only sync records from these DIDs (null = all DIDs) 64 + 'dids' => null, 65 + 66 + // Only sync these operations: 'create', 'update', 'delete' (null = all) 67 + 'operations' => null, 68 + 69 + // Custom filter callback: function(SignalEvent $event): bool 70 + // Return true to sync the event, false to skip it 71 + 'filter' => null, 72 + ], 73 + 74 + /* 75 + |-------------------------------------------------------------------------- 76 + | Conflict Resolution 77 + |-------------------------------------------------------------------------- 78 + | 79 + | Strategy for handling conflicts between local and remote changes. 80 + | 81 + */ 82 + 'conflicts' => [ 83 + // Strategy: 'remote', 'local', 'newest', 'manual' 84 + 'strategy' => env('PARITY_CONFLICT_STRATEGY', 'remote'), 85 + 86 + // Database table for pending conflicts (manual resolution) 87 + 'table' => 'parity_conflicts', 88 + 89 + // Notifiable class or callback for conflict notifications 90 + 'notify' => null, 91 + ], 92 + 93 + /* 94 + |-------------------------------------------------------------------------- 95 + | Collection Discovery 96 + |-------------------------------------------------------------------------- 97 + | 98 + | Settings for discovering users with records in specific collections. 99 + | 100 + */ 101 + 'discovery' => [ 102 + // Relay URL for discovery queries 103 + 'relay' => env('ATP_RELAY_URL', 'https://bsky.network'), 104 + ], 105 + ];
+1 -1
contributing.md CONTRIBUTING.md
··· 2 2 3 3 Contributions are welcome and will be fully credited. 4 4 5 - Contributions are accepted via Pull Requests on [Github](https://github.com/socialdept/atp-parity). 5 + Contributions are accepted via Pull Requests on [Github](https://github.com/social-dept/beacon). 6 6 7 7 # Things you could do 8 8 If you want to contribute but do not know where to start, this list provides some starting points.
+37
database/migrations/create_parity_conflicts_table.php
··· 1 + <?php 2 + 3 + use Illuminate\Database\Migrations\Migration; 4 + use Illuminate\Database\Schema\Blueprint; 5 + use Illuminate\Support\Facades\Schema; 6 + 7 + return new class extends Migration 8 + { 9 + public function up(): void 10 + { 11 + $table = config('parity.conflicts.table', 'parity_conflicts'); 12 + 13 + Schema::create($table, function (Blueprint $table) { 14 + $table->id(); 15 + $table->string('model_type'); 16 + $table->unsignedBigInteger('model_id'); 17 + $table->string('uri')->nullable(); 18 + $table->json('local_data'); 19 + $table->json('remote_data'); 20 + $table->string('status')->default('pending'); 21 + $table->string('resolution')->nullable(); 22 + $table->timestamp('resolved_at')->nullable(); 23 + $table->timestamps(); 24 + 25 + $table->index(['model_type', 'model_id']); 26 + $table->index('status'); 27 + $table->index('uri'); 28 + }); 29 + } 30 + 31 + public function down(): void 32 + { 33 + $table = config('parity.conflicts.table', 'parity_conflicts'); 34 + 35 + Schema::dropIfExists($table); 36 + } 37 + };
+38
database/migrations/create_parity_import_states_table.php
··· 1 + <?php 2 + 3 + use Illuminate\Database\Migrations\Migration; 4 + use Illuminate\Database\Schema\Blueprint; 5 + use Illuminate\Support\Facades\Schema; 6 + 7 + return new class extends Migration 8 + { 9 + public function up(): void 10 + { 11 + $table = config('parity.import.state_table', 'parity_import_states'); 12 + 13 + Schema::create($table, function (Blueprint $table) { 14 + $table->id(); 15 + $table->string('did'); 16 + $table->string('collection'); 17 + $table->string('status')->default('pending'); 18 + $table->string('cursor')->nullable(); 19 + $table->unsignedInteger('records_synced')->default(0); 20 + $table->unsignedInteger('records_skipped')->default(0); 21 + $table->unsignedInteger('records_failed')->default(0); 22 + $table->timestamp('started_at')->nullable(); 23 + $table->timestamp('completed_at')->nullable(); 24 + $table->text('error')->nullable(); 25 + $table->timestamps(); 26 + 27 + $table->unique(['did', 'collection']); 28 + $table->index('status'); 29 + }); 30 + } 31 + 32 + public function down(): void 33 + { 34 + $table = config('parity.import.state_table', 'parity_import_states'); 35 + 36 + Schema::dropIfExists($table); 37 + } 38 + };
+343
docs/atp-client-integration.md
··· 1 + # atp-client Integration 2 + 3 + Parity 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 + 7 + The `RecordHelper` is registered as a singleton and available via the container: 8 + 9 + ```php 10 + use SocialDept\AtpParity\Support\RecordHelper; 11 + 12 + $helper = app(RecordHelper::class); 13 + ``` 14 + 15 + ### How It Works 16 + 17 + When you provide an AT Protocol URI, RecordHelper: 18 + 19 + 1. Parses the URI to extract the DID, collection, and rkey 20 + 2. Resolves the DID to find the user's PDS endpoint (via atp-resolver) 21 + 3. Creates a public client for that PDS 22 + 4. Fetches the record 23 + 5. Converts it using the registered mapper 24 + 25 + This 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 + 31 + Fetches a record and returns it as a typed DTO. 32 + 33 + ```php 34 + use SocialDept\AtpParity\Support\RecordHelper; 35 + use 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 49 + echo $record->text; 50 + echo $record->createdAt; 51 + ``` 52 + 53 + ### `fetchAsModel(string $uri): ?Model` 54 + 55 + Fetches 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 + 60 + if ($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 + 70 + Returns `null` if no mapper is registered for the collection. 71 + 72 + ### `sync(string $uri): ?Model` 73 + 74 + Fetches 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 81 + echo $post->id; 82 + echo $post->content; 83 + ``` 84 + 85 + This 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 + 91 + If you already have a `GetRecordResponse` from atp-client, convert it to a typed DTO: 92 + 93 + ```php 94 + use SocialDept\AtpClient\Facades\Atp; 95 + use 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 + 121 + echo "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 + 135 + foreach ($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 + 153 + if ($post) { 154 + return view('posts.preview', ['post' => $post]); 155 + } 156 + 157 + return abort(404); 158 + ``` 159 + 160 + ### Checking if Record Exists Locally 161 + 162 + ```php 163 + use App\Models\Post; 164 + use 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 + 171 + if (!$post) { 172 + // Not in database, fetch from network 173 + $helper = app(RecordHelper::class); 174 + $post = $helper->sync($uri); 175 + } 176 + 177 + return $post; 178 + ``` 179 + 180 + ### Building a Post Importer 181 + 182 + ```php 183 + namespace App\Services; 184 + 185 + use SocialDept\AtpParity\Support\RecordHelper; 186 + use SocialDept\AtpClient\Facades\Atp; 187 + 188 + class 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 + 230 + RecordHelper 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 + 248 + For more control, catch exceptions: 249 + 250 + ```php 251 + use SocialDept\AtpResolver\Exceptions\DidResolutionException; 252 + 253 + try { 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 + 268 + RecordHelper 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 + 283 + atp-resolver caches DID documents and PDS endpoints. Default TTL is 1 hour. 284 + 285 + ### Batch Operations 286 + 287 + For bulk imports, consider using atp-client's `listRecords` directly and then batch-processing: 288 + 289 + ```php 290 + use SocialDept\AtpClient\Facades\Atp; 291 + use 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 + 303 + foreach ($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 + 316 + While RecordHelper uses public clients, you can also use authenticated clients for records that require auth: 317 + 318 + ```php 319 + use SocialDept\AtpClient\Facades\Atp; 320 + use 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 + ```
+355
docs/atp-schema-integration.md
··· 1 + # atp-schema Integration 2 + 3 + Parity is built on top of atp-schema, using its `Data` base class for all record DTOs. This provides type safety, validation, and compatibility with the AT Protocol ecosystem. 4 + 5 + ## How It Works 6 + 7 + The `SocialDept\AtpParity\Data\Record` class extends `SocialDept\AtpSchema\Data\Data`: 8 + 9 + ```php 10 + namespace SocialDept\AtpParity\Data; 11 + 12 + use SocialDept\AtpClient\Contracts\Recordable; 13 + use SocialDept\AtpSchema\Data\Data; 14 + 15 + abstract class Record extends Data implements Recordable 16 + { 17 + public function getType(): string 18 + { 19 + return static::getLexicon(); 20 + } 21 + } 22 + ``` 23 + 24 + This means all Parity records inherit: 25 + 26 + - `getLexicon()` - Returns the lexicon NSID 27 + - `fromArray()` - Creates instance from array data 28 + - `toArray()` - Converts to array 29 + - `toRecord()` - Converts to record format for API calls 30 + - Type validation and casting 31 + 32 + ## Using Generated Schema Classes 33 + 34 + atp-schema generates PHP classes for all AT Protocol lexicons. Use them directly with Parity: 35 + 36 + ```php 37 + use SocialDept\AtpParity\Support\SchemaMapper; 38 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post; 39 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Like; 40 + use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Follow; 41 + 42 + // Post mapper 43 + $postMapper = new SchemaMapper( 44 + schemaClass: Post::class, 45 + modelClass: \App\Models\Post::class, 46 + toAttributes: fn(Post $post) => [ 47 + 'content' => $post->text, 48 + 'published_at' => $post->createdAt, 49 + 'langs' => $post->langs, 50 + 'reply_parent' => $post->reply?->parent->uri, 51 + 'reply_root' => $post->reply?->root->uri, 52 + ], 53 + toRecordData: fn($model) => [ 54 + 'text' => $model->content, 55 + 'createdAt' => $model->published_at->toIso8601String(), 56 + 'langs' => $model->langs ?? ['en'], 57 + ], 58 + ); 59 + 60 + // Like mapper 61 + $likeMapper = new SchemaMapper( 62 + schemaClass: Like::class, 63 + modelClass: \App\Models\Like::class, 64 + toAttributes: fn(Like $like) => [ 65 + 'subject_uri' => $like->subject->uri, 66 + 'subject_cid' => $like->subject->cid, 67 + 'liked_at' => $like->createdAt, 68 + ], 69 + toRecordData: fn($model) => [ 70 + 'subject' => [ 71 + 'uri' => $model->subject_uri, 72 + 'cid' => $model->subject_cid, 73 + ], 74 + 'createdAt' => $model->liked_at->toIso8601String(), 75 + ], 76 + ); 77 + 78 + // Follow mapper 79 + $followMapper = new SchemaMapper( 80 + schemaClass: Follow::class, 81 + modelClass: \App\Models\Follow::class, 82 + toAttributes: fn(Follow $follow) => [ 83 + 'subject_did' => $follow->subject, 84 + 'followed_at' => $follow->createdAt, 85 + ], 86 + toRecordData: fn($model) => [ 87 + 'subject' => $model->subject_did, 88 + 'createdAt' => $model->followed_at->toIso8601String(), 89 + ], 90 + ); 91 + ``` 92 + 93 + ## Creating Custom Records 94 + 95 + For custom lexicons or when you need more control, extend the `Record` class: 96 + 97 + ```php 98 + <?php 99 + 100 + namespace App\AtpRecords; 101 + 102 + use Carbon\Carbon; 103 + use SocialDept\AtpParity\Data\Record; 104 + 105 + class CustomPost extends Record 106 + { 107 + public function __construct( 108 + public readonly string $text, 109 + public readonly Carbon $createdAt, 110 + public readonly ?array $facets = null, 111 + public readonly ?array $embed = null, 112 + public readonly ?array $langs = null, 113 + ) {} 114 + 115 + public static function getLexicon(): string 116 + { 117 + return 'app.bsky.feed.post'; 118 + } 119 + 120 + public static function fromArray(array $data): static 121 + { 122 + return new static( 123 + text: $data['text'], 124 + createdAt: Carbon::parse($data['createdAt']), 125 + facets: $data['facets'] ?? null, 126 + embed: $data['embed'] ?? null, 127 + langs: $data['langs'] ?? null, 128 + ); 129 + } 130 + 131 + public function toArray(): array 132 + { 133 + return array_filter([ 134 + '$type' => static::getLexicon(), 135 + 'text' => $this->text, 136 + 'createdAt' => $this->createdAt->toIso8601String(), 137 + 'facets' => $this->facets, 138 + 'embed' => $this->embed, 139 + 'langs' => $this->langs, 140 + ], fn($v) => $v !== null); 141 + } 142 + } 143 + ``` 144 + 145 + ## Custom Lexicons (AppView) 146 + 147 + Building a custom AT Protocol application? Define your own lexicons: 148 + 149 + ```php 150 + <?php 151 + 152 + namespace App\AtpRecords; 153 + 154 + use Carbon\Carbon; 155 + use SocialDept\AtpParity\Data\Record; 156 + 157 + class Article extends Record 158 + { 159 + public function __construct( 160 + public readonly string $title, 161 + public readonly string $body, 162 + public readonly Carbon $publishedAt, 163 + public readonly ?array $tags = null, 164 + public readonly ?string $coverImage = null, 165 + ) {} 166 + 167 + public static function getLexicon(): string 168 + { 169 + return 'com.myapp.blog.article'; // Your custom NSID 170 + } 171 + 172 + public static function fromArray(array $data): static 173 + { 174 + return new static( 175 + title: $data['title'], 176 + body: $data['body'], 177 + publishedAt: Carbon::parse($data['publishedAt']), 178 + tags: $data['tags'] ?? null, 179 + coverImage: $data['coverImage'] ?? null, 180 + ); 181 + } 182 + 183 + public function toArray(): array 184 + { 185 + return array_filter([ 186 + '$type' => static::getLexicon(), 187 + 'title' => $this->title, 188 + 'body' => $this->body, 189 + 'publishedAt' => $this->publishedAt->toIso8601String(), 190 + 'tags' => $this->tags, 191 + 'coverImage' => $this->coverImage, 192 + ], fn($v) => $v !== null); 193 + } 194 + } 195 + ``` 196 + 197 + ## Working with Embedded Types 198 + 199 + atp-schema generates classes for embedded types. Use them in your mappings: 200 + 201 + ```php 202 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post; 203 + use SocialDept\AtpSchema\Generated\App\Bsky\Embed\Images; 204 + use SocialDept\AtpSchema\Generated\App\Bsky\Embed\External; 205 + use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\StrongRef; 206 + 207 + $mapper = new SchemaMapper( 208 + schemaClass: Post::class, 209 + modelClass: \App\Models\Post::class, 210 + toAttributes: fn(Post $post) => [ 211 + 'content' => $post->text, 212 + 'published_at' => $post->createdAt, 213 + 'has_images' => $post->embed instanceof Images, 214 + 'has_link' => $post->embed instanceof External, 215 + 'embed_data' => $post->embed?->toArray(), 216 + ], 217 + toRecordData: fn($model) => [ 218 + 'text' => $model->content, 219 + 'createdAt' => $model->published_at->toIso8601String(), 220 + ], 221 + ); 222 + ``` 223 + 224 + ## Handling Union Types 225 + 226 + AT Protocol uses union types for fields like `embed`. atp-schema handles these via discriminated unions: 227 + 228 + ```php 229 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post; 230 + use SocialDept\AtpSchema\Generated\App\Bsky\Embed\Images; 231 + use SocialDept\AtpSchema\Generated\App\Bsky\Embed\External; 232 + use SocialDept\AtpSchema\Generated\App\Bsky\Embed\Record; 233 + use SocialDept\AtpSchema\Generated\App\Bsky\Embed\RecordWithMedia; 234 + 235 + $toAttributes = function(Post $post): array { 236 + $attributes = [ 237 + 'content' => $post->text, 238 + 'published_at' => $post->createdAt, 239 + ]; 240 + 241 + // Handle embed union type 242 + if ($post->embed) { 243 + match (true) { 244 + $post->embed instanceof Images => $attributes['embed_type'] = 'images', 245 + $post->embed instanceof External => $attributes['embed_type'] = 'external', 246 + $post->embed instanceof Record => $attributes['embed_type'] = 'quote', 247 + $post->embed instanceof RecordWithMedia => $attributes['embed_type'] = 'quote_media', 248 + default => $attributes['embed_type'] = 'unknown', 249 + }; 250 + $attributes['embed_data'] = $post->embed->toArray(); 251 + } 252 + 253 + return $attributes; 254 + }; 255 + ``` 256 + 257 + ## Reply Threading 258 + 259 + Posts can be replies to other posts: 260 + 261 + ```php 262 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post; 263 + 264 + $toAttributes = function(Post $post): array { 265 + $attributes = [ 266 + 'content' => $post->text, 267 + 'published_at' => $post->createdAt, 268 + 'is_reply' => $post->reply !== null, 269 + ]; 270 + 271 + if ($post->reply) { 272 + // Parent is the immediate post being replied to 273 + $attributes['reply_parent_uri'] = $post->reply->parent->uri; 274 + $attributes['reply_parent_cid'] = $post->reply->parent->cid; 275 + 276 + // Root is the top of the thread 277 + $attributes['reply_root_uri'] = $post->reply->root->uri; 278 + $attributes['reply_root_cid'] = $post->reply->root->cid; 279 + } 280 + 281 + return $attributes; 282 + }; 283 + ``` 284 + 285 + ## Facets (Rich Text) 286 + 287 + Posts with mentions, links, and hashtags use facets: 288 + 289 + ```php 290 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post; 291 + use SocialDept\AtpSchema\Generated\App\Bsky\Richtext\Facet; 292 + 293 + $toAttributes = function(Post $post): array { 294 + $attributes = [ 295 + 'content' => $post->text, 296 + 'published_at' => $post->createdAt, 297 + ]; 298 + 299 + // Extract mentions, links, and tags from facets 300 + $mentions = []; 301 + $links = []; 302 + $tags = []; 303 + 304 + foreach ($post->facets ?? [] as $facet) { 305 + foreach ($facet->features as $feature) { 306 + $type = $feature->getType(); 307 + match ($type) { 308 + 'app.bsky.richtext.facet#mention' => $mentions[] = $feature->did, 309 + 'app.bsky.richtext.facet#link' => $links[] = $feature->uri, 310 + 'app.bsky.richtext.facet#tag' => $tags[] = $feature->tag, 311 + default => null, 312 + }; 313 + } 314 + } 315 + 316 + $attributes['mentions'] = $mentions; 317 + $attributes['links'] = $links; 318 + $attributes['tags'] = $tags; 319 + $attributes['facets'] = $post->facets; // Store raw for reconstruction 320 + 321 + return $attributes; 322 + }; 323 + ``` 324 + 325 + ## Type Safety Benefits 326 + 327 + Using atp-schema classes provides: 328 + 329 + 1. **IDE Autocompletion** - Full property and method suggestions 330 + 2. **Type Checking** - Static analysis catches errors 331 + 3. **Validation** - Data is validated on construction 332 + 4. **Documentation** - Generated classes include docblocks 333 + 334 + ```php 335 + // IDE knows $post->text is string, $post->createdAt is string, etc. 336 + $toAttributes = function(Post $post): array { 337 + return [ 338 + 'content' => $post->text, // string 339 + 'published_at' => $post->createdAt, // string (ISO 8601) 340 + 'langs' => $post->langs, // ?array 341 + 'facets' => $post->facets, // ?array 342 + ]; 343 + }; 344 + ``` 345 + 346 + ## Regenerating Schema Classes 347 + 348 + When the AT Protocol schema updates, regenerate the classes: 349 + 350 + ```bash 351 + # In the atp-schema package 352 + php artisan atp:generate 353 + ``` 354 + 355 + Your mappers will automatically work with the updated types.
+491
docs/atp-signals-integration.md
··· 1 + # atp-signals Integration 2 + 3 + Parity integrates with atp-signals to automatically sync firehose events to your Eloquent models in real-time. The `ParitySignal` class handles create, update, and delete operations for all registered mappers. 4 + 5 + ## ParitySignal 6 + 7 + The `ParitySignal` is a pre-built signal that listens for commit events and syncs them to your database using your registered mappers. 8 + 9 + ### How It Works 10 + 11 + 1. ParitySignal listens for `commit` events on the firehose 12 + 2. It filters for collections that have registered mappers 13 + 3. For each matching event: 14 + - **Create/Update**: Upserts the record to your database 15 + - **Delete**: Removes the record from your database 16 + 17 + ### Setup 18 + 19 + Register the signal in your atp-signals config: 20 + 21 + ```php 22 + // config/signal.php 23 + return [ 24 + 'signals' => [ 25 + \SocialDept\AtpParity\Signals\ParitySignal::class, 26 + ], 27 + ]; 28 + ``` 29 + 30 + Then start consuming: 31 + 32 + ```bash 33 + php artisan signal:consume 34 + ``` 35 + 36 + That's it. Your models will automatically sync with the firehose. 37 + 38 + ## What Gets Synced 39 + 40 + ParitySignal only syncs collections that have registered mappers: 41 + 42 + ```php 43 + // config/parity.php 44 + return [ 45 + 'mappers' => [ 46 + App\AtpMappers\PostMapper::class, // app.bsky.feed.post 47 + App\AtpMappers\LikeMapper::class, // app.bsky.feed.like 48 + App\AtpMappers\FollowMapper::class, // app.bsky.graph.follow 49 + ], 50 + ]; 51 + ``` 52 + 53 + With this config, ParitySignal will sync posts, likes, and follows. All other collections are ignored. 54 + 55 + ## Event Flow 56 + 57 + ``` 58 + Firehose Event 59 + 60 + ParitySignal.handle() 61 + 62 + Check: Is collection registered? 63 + 64 + Yes → Get mapper for collection 65 + 66 + Create DTO from event record 67 + 68 + Call mapper.upsert() or mapper.deleteByUri() 69 + 70 + Model saved to database 71 + ``` 72 + 73 + ## Example: Syncing Posts 74 + 75 + ### 1. Create the Model 76 + 77 + ```php 78 + // app/Models/Post.php 79 + namespace App\Models; 80 + 81 + use Illuminate\Database\Eloquent\Model; 82 + use SocialDept\AtpParity\Concerns\SyncsWithAtp; 83 + 84 + class Post extends Model 85 + { 86 + use SyncsWithAtp; 87 + 88 + protected $fillable = [ 89 + 'content', 90 + 'author_did', 91 + 'published_at', 92 + 'atp_uri', 93 + 'atp_cid', 94 + 'atp_synced_at', 95 + ]; 96 + 97 + protected $casts = [ 98 + 'published_at' => 'datetime', 99 + 'atp_synced_at' => 'datetime', 100 + ]; 101 + } 102 + ``` 103 + 104 + ### 2. Create the Migration 105 + 106 + ```php 107 + Schema::create('posts', function (Blueprint $table) { 108 + $table->id(); 109 + $table->text('content'); 110 + $table->string('author_did'); 111 + $table->timestamp('published_at'); 112 + $table->string('atp_uri')->unique(); 113 + $table->string('atp_cid'); 114 + $table->timestamp('atp_synced_at')->nullable(); 115 + $table->timestamps(); 116 + }); 117 + ``` 118 + 119 + ### 3. Create the Mapper 120 + 121 + ```php 122 + // app/AtpMappers/PostMapper.php 123 + namespace App\AtpMappers; 124 + 125 + use App\Models\Post; 126 + use Illuminate\Database\Eloquent\Model; 127 + use SocialDept\AtpParity\RecordMapper; 128 + use SocialDept\AtpSchema\Data\Data; 129 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post as PostRecord; 130 + 131 + class PostMapper extends RecordMapper 132 + { 133 + public function recordClass(): string 134 + { 135 + return PostRecord::class; 136 + } 137 + 138 + public function modelClass(): string 139 + { 140 + return Post::class; 141 + } 142 + 143 + protected function recordToAttributes(Data $record): array 144 + { 145 + return [ 146 + 'content' => $record->text, 147 + 'published_at' => $record->createdAt, 148 + ]; 149 + } 150 + 151 + protected function modelToRecordData(Model $model): array 152 + { 153 + return [ 154 + 'text' => $model->content, 155 + 'createdAt' => $model->published_at->toIso8601String(), 156 + ]; 157 + } 158 + } 159 + ``` 160 + 161 + ### 4. Register Everything 162 + 163 + ```php 164 + // config/parity.php 165 + return [ 166 + 'mappers' => [ 167 + App\AtpMappers\PostMapper::class, 168 + ], 169 + ]; 170 + ``` 171 + 172 + ```php 173 + // config/signal.php 174 + return [ 175 + 'signals' => [ 176 + \SocialDept\AtpParity\Signals\ParitySignal::class, 177 + ], 178 + ]; 179 + ``` 180 + 181 + ### 5. Start Syncing 182 + 183 + ```bash 184 + php artisan signal:consume 185 + ``` 186 + 187 + Every new post on the AT Protocol network will now be saved to your `posts` table. 188 + 189 + ## Filtering by User 190 + 191 + To only sync records from specific users, create a custom signal: 192 + 193 + ```php 194 + namespace App\Signals; 195 + 196 + use SocialDept\AtpParity\Signals\ParitySignal; 197 + use SocialDept\AtpSignals\Events\SignalEvent; 198 + 199 + class FilteredParitySignal extends ParitySignal 200 + { 201 + /** 202 + * DIDs to sync. 203 + */ 204 + protected array $allowedDids = [ 205 + 'did:plc:abc123', 206 + 'did:plc:def456', 207 + ]; 208 + 209 + public function handle(SignalEvent $event): void 210 + { 211 + // Only process events from allowed DIDs 212 + if (!in_array($event->did, $this->allowedDids)) { 213 + return; 214 + } 215 + 216 + parent::handle($event); 217 + } 218 + } 219 + ``` 220 + 221 + Register your custom signal instead: 222 + 223 + ```php 224 + // config/signal.php 225 + return [ 226 + 'signals' => [ 227 + App\Signals\FilteredParitySignal::class, 228 + ], 229 + ]; 230 + ``` 231 + 232 + ## Filtering by Collection 233 + 234 + To only sync specific collections (even if more mappers are registered): 235 + 236 + ```php 237 + namespace App\Signals; 238 + 239 + use SocialDept\AtpParity\Signals\ParitySignal; 240 + 241 + class PostsOnlySignal extends ParitySignal 242 + { 243 + public function collections(): ?array 244 + { 245 + // Only sync posts, ignore other registered mappers 246 + return ['app.bsky.feed.post']; 247 + } 248 + } 249 + ``` 250 + 251 + ## Custom Processing 252 + 253 + Add custom logic before or after syncing: 254 + 255 + ```php 256 + namespace App\Signals; 257 + 258 + use SocialDept\AtpParity\Contracts\RecordMapper; 259 + use SocialDept\AtpParity\Signals\ParitySignal; 260 + use SocialDept\AtpSignals\Events\SignalEvent; 261 + 262 + class CustomParitySignal extends ParitySignal 263 + { 264 + protected function handleUpsert(SignalEvent $event, RecordMapper $mapper): void 265 + { 266 + // Pre-processing 267 + logger()->info('Syncing record', [ 268 + 'did' => $event->did, 269 + 'collection' => $event->commit->collection, 270 + 'rkey' => $event->commit->rkey, 271 + ]); 272 + 273 + // Call parent to do the actual sync 274 + parent::handleUpsert($event, $mapper); 275 + 276 + // Post-processing 277 + // e.g., dispatch a job, send notification, etc. 278 + } 279 + 280 + protected function handleDelete(SignalEvent $event, RecordMapper $mapper): void 281 + { 282 + logger()->info('Deleting record', [ 283 + 'uri' => $this->buildUri($event->did, $event->commit->collection, $event->commit->rkey), 284 + ]); 285 + 286 + parent::handleDelete($event, $mapper); 287 + } 288 + } 289 + ``` 290 + 291 + ## Queue Integration 292 + 293 + For high-volume processing, enable queue mode: 294 + 295 + ```php 296 + namespace App\Signals; 297 + 298 + use SocialDept\AtpParity\Signals\ParitySignal; 299 + 300 + class QueuedParitySignal extends ParitySignal 301 + { 302 + public function shouldQueue(): bool 303 + { 304 + return true; 305 + } 306 + 307 + public function queue(): string 308 + { 309 + return 'parity-sync'; 310 + } 311 + } 312 + ``` 313 + 314 + Then run a dedicated queue worker: 315 + 316 + ```bash 317 + php artisan queue:work --queue=parity-sync 318 + ``` 319 + 320 + ## Multiple Signals 321 + 322 + You can run ParitySignal alongside other signals: 323 + 324 + ```php 325 + // config/signal.php 326 + return [ 327 + 'signals' => [ 328 + // Sync to database 329 + \SocialDept\AtpParity\Signals\ParitySignal::class, 330 + 331 + // Your custom analytics signal 332 + App\Signals\AnalyticsSignal::class, 333 + 334 + // Your moderation signal 335 + App\Signals\ModerationSignal::class, 336 + ], 337 + ]; 338 + ``` 339 + 340 + ## Handling High Volume 341 + 342 + The AT Protocol firehose processes thousands of events per second. For production: 343 + 344 + ### 1. Use Jetstream Mode 345 + 346 + Jetstream filters server-side, reducing bandwidth: 347 + 348 + ```php 349 + // config/signal.php 350 + return [ 351 + 'mode' => 'jetstream', // More efficient than firehose 352 + 353 + 'jetstream' => [ 354 + 'collections' => [ 355 + 'app.bsky.feed.post', 356 + 'app.bsky.feed.like', 357 + ], 358 + ], 359 + ]; 360 + ``` 361 + 362 + ### 2. Enable Queues 363 + 364 + Process events asynchronously: 365 + 366 + ```php 367 + class QueuedParitySignal extends ParitySignal 368 + { 369 + public function shouldQueue(): bool 370 + { 371 + return true; 372 + } 373 + } 374 + ``` 375 + 376 + ### 3. Use Database Transactions 377 + 378 + Batch inserts for better performance: 379 + 380 + ```php 381 + namespace App\Signals; 382 + 383 + use Illuminate\Support\Facades\DB; 384 + use SocialDept\AtpParity\Signals\ParitySignal; 385 + use SocialDept\AtpSignals\Events\SignalEvent; 386 + 387 + class BatchedParitySignal extends ParitySignal 388 + { 389 + protected array $buffer = []; 390 + protected int $batchSize = 100; 391 + 392 + public function handle(SignalEvent $event): void 393 + { 394 + $this->buffer[] = $event; 395 + 396 + if (count($this->buffer) >= $this->batchSize) { 397 + $this->flush(); 398 + } 399 + } 400 + 401 + protected function flush(): void 402 + { 403 + DB::transaction(function () { 404 + foreach ($this->buffer as $event) { 405 + parent::handle($event); 406 + } 407 + }); 408 + 409 + $this->buffer = []; 410 + } 411 + } 412 + ``` 413 + 414 + ### 4. Monitor Performance 415 + 416 + Log sync statistics: 417 + 418 + ```php 419 + namespace App\Signals; 420 + 421 + use SocialDept\AtpParity\Signals\ParitySignal; 422 + use SocialDept\AtpSignals\Events\SignalEvent; 423 + 424 + class MonitoredParitySignal extends ParitySignal 425 + { 426 + protected int $processed = 0; 427 + protected float $startTime; 428 + 429 + public function handle(SignalEvent $event): void 430 + { 431 + $this->startTime ??= microtime(true); 432 + 433 + parent::handle($event); 434 + 435 + $this->processed++; 436 + 437 + if ($this->processed % 1000 === 0) { 438 + $elapsed = microtime(true) - $this->startTime; 439 + $rate = $this->processed / $elapsed; 440 + 441 + logger()->info("Parity sync stats", [ 442 + 'processed' => $this->processed, 443 + 'elapsed' => round($elapsed, 2), 444 + 'rate' => round($rate, 2) . '/sec', 445 + ]); 446 + } 447 + } 448 + } 449 + ``` 450 + 451 + ## Cursor Management 452 + 453 + atp-signals handles cursor persistence automatically. If the consumer restarts, it resumes from where it left off. 454 + 455 + To reset and start fresh: 456 + 457 + ```bash 458 + php artisan signal:consume --reset 459 + ``` 460 + 461 + ## Testing 462 + 463 + Test your sync setup without connecting to the firehose: 464 + 465 + ```php 466 + use App\AtpMappers\PostMapper; 467 + use SocialDept\AtpParity\MapperRegistry; 468 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post; 469 + 470 + // Create a test record 471 + $record = Post::fromArray([ 472 + 'text' => 'Test post content', 473 + 'createdAt' => now()->toIso8601String(), 474 + ]); 475 + 476 + // Get the mapper 477 + $registry = app(MapperRegistry::class); 478 + $mapper = $registry->forLexicon('app.bsky.feed.post'); 479 + 480 + // Simulate a sync 481 + $model = $mapper->upsert($record, [ 482 + 'uri' => 'at://did:plc:test/app.bsky.feed.post/test123', 483 + 'cid' => 'bafyretest...', 484 + ]); 485 + 486 + // Assert 487 + $this->assertDatabaseHas('posts', [ 488 + 'content' => 'Test post content', 489 + 'atp_uri' => 'at://did:plc:test/app.bsky.feed.post/test123', 490 + ]); 491 + ```
+359
docs/importing.md
··· 1 + # Importing Records 2 + 3 + Parity includes a comprehensive import system that enables you to sync historical AT Protocol data to your Eloquent models. This complements the real-time sync provided by [ParitySignal](atp-signals-integration.md). 4 + 5 + ## The Cold Start Problem 6 + 7 + When you start consuming the AT Protocol firehose with ParitySignal, you only receive events from that point forward. Any records created before you started listening are not captured. 8 + 9 + Importing solves this "cold start" problem by fetching existing records from user repositories via the `com.atproto.repo.listRecords` API. 10 + 11 + ## Quick Start 12 + 13 + ### 1. Run the Migration 14 + 15 + Publish and run the migration to create the import state tracking table: 16 + 17 + ```bash 18 + php artisan vendor:publish --tag=parity-migrations 19 + php artisan migrate 20 + ``` 21 + 22 + ### 2. Import a User 23 + 24 + ```bash 25 + # Import all registered collections for a user 26 + php artisan parity:import did:plc:z72i7hdynmk6r22z27h6tvur 27 + 28 + # Import a specific collection 29 + php artisan parity:import did:plc:z72i7hdynmk6r22z27h6tvur --collection=app.bsky.feed.post 30 + 31 + # Show progress 32 + php artisan parity:import did:plc:z72i7hdynmk6r22z27h6tvur --progress 33 + ``` 34 + 35 + ### 3. Check Status 36 + 37 + ```bash 38 + # Show all import status 39 + php artisan parity:import-status 40 + 41 + # Show status for a specific user 42 + php artisan parity:import-status did:plc:z72i7hdynmk6r22z27h6tvur 43 + 44 + # Show only incomplete imports 45 + php artisan parity:import-status --pending 46 + ``` 47 + 48 + ## Programmatic Usage 49 + 50 + ### ImportService 51 + 52 + The `ImportService` is the main orchestration class: 53 + 54 + ```php 55 + use SocialDept\AtpParity\Import\ImportService; 56 + 57 + $service = app(ImportService::class); 58 + 59 + // Import all registered collections for a user 60 + $result = $service->importUser('did:plc:z72i7hdynmk6r22z27h6tvur'); 61 + 62 + echo "Synced {$result->recordsSynced} records"; 63 + 64 + // Import a specific collection 65 + $result = $service->importUserCollection( 66 + 'did:plc:z72i7hdynmk6r22z27h6tvur', 67 + 'app.bsky.feed.post' 68 + ); 69 + 70 + // With progress callback 71 + $result = $service->importUser('did:plc:z72i7hdynmk6r22z27h6tvur', null, function ($progress) { 72 + echo "Synced {$progress->recordsSynced} records from {$progress->collection}\n"; 73 + }); 74 + ``` 75 + 76 + ### ImportResult 77 + 78 + The `ImportResult` value object provides information about the import operation: 79 + 80 + ```php 81 + $result = $service->importUser($did); 82 + 83 + $result->recordsSynced; // Number of records successfully synced 84 + $result->recordsSkipped; // Number of records skipped 85 + $result->recordsFailed; // Number of records that failed to sync 86 + $result->completed; // Whether the import completed fully 87 + $result->cursor; // Cursor for resuming (if incomplete) 88 + $result->error; // Error message (if failed) 89 + 90 + $result->isSuccess(); // True if completed without errors 91 + $result->isPartial(); // True if some records were synced before failure 92 + $result->isFailed(); // True if an error occurred 93 + ``` 94 + 95 + ### Checking Status 96 + 97 + ```php 98 + // Check if a collection has been imported 99 + if ($service->isImported($did, 'app.bsky.feed.post')) { 100 + echo "Already imported!"; 101 + } 102 + 103 + // Get detailed status 104 + $state = $service->getStatus($did, 'app.bsky.feed.post'); 105 + 106 + if ($state) { 107 + echo "Status: {$state->status}"; 108 + echo "Records synced: {$state->records_synced}"; 109 + } 110 + 111 + // Get all statuses for a user 112 + $states = $service->getStatusForUser($did); 113 + ``` 114 + 115 + ### Resuming Interrupted Imports 116 + 117 + If an import is interrupted (network error, timeout, etc.), you can resume it: 118 + 119 + ```php 120 + // Resume a specific import 121 + $state = $service->getStatus($did, $collection); 122 + if ($state && $state->canResume()) { 123 + $result = $service->resume($state); 124 + } 125 + 126 + // Resume all interrupted imports 127 + $results = $service->resumeAll(); 128 + ``` 129 + 130 + ### Resetting Import State 131 + 132 + To re-import a user or collection: 133 + 134 + ```php 135 + // Reset a specific collection 136 + $service->reset($did, 'app.bsky.feed.post'); 137 + 138 + // Reset all collections for a user 139 + $service->resetUser($did); 140 + ``` 141 + 142 + ## Queue Integration 143 + 144 + For large-scale importing, use the queue system: 145 + 146 + ### Command Line 147 + 148 + ```bash 149 + # Queue an import job instead of running synchronously 150 + php artisan parity:import did:plc:z72i7hdynmk6r22z27h6tvur --queue 151 + 152 + # Queue imports for a list of DIDs 153 + php artisan parity:import --file=dids.txt --queue 154 + ``` 155 + 156 + ### Programmatic 157 + 158 + ```php 159 + use SocialDept\AtpParity\Jobs\ImportUserJob; 160 + 161 + // Dispatch a single user import 162 + ImportUserJob::dispatch('did:plc:z72i7hdynmk6r22z27h6tvur'); 163 + 164 + // Dispatch for a specific collection 165 + ImportUserJob::dispatch('did:plc:z72i7hdynmk6r22z27h6tvur', 'app.bsky.feed.post'); 166 + ``` 167 + 168 + ## Events 169 + 170 + Parity dispatches events during importing that you can listen to: 171 + 172 + ### ImportStarted 173 + 174 + Fired when an import operation begins: 175 + 176 + ```php 177 + use SocialDept\AtpParity\Events\ImportStarted; 178 + 179 + Event::listen(ImportStarted::class, function (ImportStarted $event) { 180 + Log::info("Starting import", [ 181 + 'did' => $event->did, 182 + 'collection' => $event->collection, 183 + ]); 184 + }); 185 + ``` 186 + 187 + ### ImportProgress 188 + 189 + Fired after each page of records is processed: 190 + 191 + ```php 192 + use SocialDept\AtpParity\Events\ImportProgress; 193 + 194 + Event::listen(ImportProgress::class, function (ImportProgress $event) { 195 + Log::info("Import progress", [ 196 + 'did' => $event->did, 197 + 'collection' => $event->collection, 198 + 'records_synced' => $event->recordsSynced, 199 + ]); 200 + }); 201 + ``` 202 + 203 + ### ImportCompleted 204 + 205 + Fired when an import operation completes successfully: 206 + 207 + ```php 208 + use SocialDept\AtpParity\Events\ImportCompleted; 209 + 210 + Event::listen(ImportCompleted::class, function (ImportCompleted $event) { 211 + $result = $event->result; 212 + 213 + Log::info("Import completed", [ 214 + 'did' => $result->did, 215 + 'collection' => $result->collection, 216 + 'records_synced' => $result->recordsSynced, 217 + ]); 218 + }); 219 + ``` 220 + 221 + ### ImportFailed 222 + 223 + Fired when an import operation fails: 224 + 225 + ```php 226 + use SocialDept\AtpParity\Events\ImportFailed; 227 + 228 + Event::listen(ImportFailed::class, function (ImportFailed $event) { 229 + Log::error("Import failed", [ 230 + 'did' => $event->did, 231 + 'collection' => $event->collection, 232 + 'error' => $event->error, 233 + ]); 234 + }); 235 + ``` 236 + 237 + ## Configuration 238 + 239 + Configure importing in `config/parity.php`: 240 + 241 + ```php 242 + 'import' => [ 243 + // Records per page when listing from PDS (max 100) 244 + 'page_size' => 100, 245 + 246 + // Delay between pages in milliseconds (rate limiting) 247 + 'page_delay' => 100, 248 + 249 + // Queue name for import jobs 250 + 'queue' => 'parity-import', 251 + 252 + // Database table for storing import state 253 + 'state_table' => 'parity_import_states', 254 + ], 255 + ``` 256 + 257 + ## Batch Importing from File 258 + 259 + Create a file with DIDs (one per line): 260 + 261 + ```text 262 + did:plc:z72i7hdynmk6r22z27h6tvur 263 + did:plc:ewvi7nxzyoun6zhxrhs64oiz 264 + did:plc:ragtjsm2j2vknwkz3zp4oxrd 265 + ``` 266 + 267 + Then run: 268 + 269 + ```bash 270 + # Synchronous (one at a time) 271 + php artisan parity:import --file=dids.txt --progress 272 + 273 + # Queued (parallel via workers) 274 + php artisan parity:import --file=dids.txt --queue 275 + ``` 276 + 277 + ## Coordinating with ParitySignal 278 + 279 + For a complete sync solution, combine importing with real-time firehose sync: 280 + 281 + 1. **Start the firehose consumer** - Begin receiving live events 282 + 2. **Import historical data** - Fetch existing records 283 + 3. **Continue firehose sync** - New events are handled automatically 284 + 285 + This ensures no gaps in your data. Records that arrive via firehose while importing will be properly deduplicated by the mapper's `upsert()` method (which uses the AT Protocol URI as the unique key). 286 + 287 + ```php 288 + // Example: Import a user then subscribe to their updates 289 + $service->importUser($did); 290 + 291 + // The firehose consumer (ParitySignal) handles updates automatically 292 + // as long as it's running with signal:consume 293 + ``` 294 + 295 + ## Best Practices 296 + 297 + ### Rate Limiting 298 + 299 + The `page_delay` config option helps prevent overwhelming PDS servers. For bulk importing, consider: 300 + 301 + - Using queued jobs to spread load over time 302 + - Increasing the delay between pages 303 + - Running during off-peak hours 304 + 305 + ### Error Handling 306 + 307 + Imports can fail due to: 308 + - Network errors 309 + - PDS rate limiting 310 + - Invalid records 311 + 312 + The system automatically tracks progress via cursor, allowing you to resume failed imports: 313 + 314 + ```bash 315 + # Check for failed imports 316 + php artisan parity:import-status --failed 317 + 318 + # Resume all failed/interrupted imports 319 + php artisan parity:import --resume 320 + ``` 321 + 322 + ### Monitoring 323 + 324 + Use the events to build monitoring: 325 + 326 + ```php 327 + // Track import metrics 328 + Event::listen(ImportCompleted::class, function (ImportCompleted $event) { 329 + Metrics::increment('parity.import.completed'); 330 + Metrics::gauge('parity.import.records', $event->result->recordsSynced); 331 + }); 332 + 333 + Event::listen(ImportFailed::class, function (ImportFailed $event) { 334 + Metrics::increment('parity.import.failed'); 335 + Alert::send("Import failed for {$event->did}: {$event->error}"); 336 + }); 337 + ``` 338 + 339 + ## Database Schema 340 + 341 + The import state table stores progress: 342 + 343 + | Column | Type | Description | 344 + |--------|------|-------------| 345 + | id | bigint | Primary key | 346 + | did | string | The DID being imported | 347 + | collection | string | The collection NSID | 348 + | status | string | pending, in_progress, completed, failed | 349 + | cursor | string | Pagination cursor for resuming | 350 + | records_synced | int | Count of successfully synced records | 351 + | records_skipped | int | Count of skipped records | 352 + | records_failed | int | Count of failed records | 353 + | started_at | timestamp | When import started | 354 + | completed_at | timestamp | When import completed | 355 + | error | text | Error message if failed | 356 + | created_at | timestamp | | 357 + | updated_at | timestamp | | 358 + 359 + The combination of `did` and `collection` is unique.
+375
docs/mappers.md
··· 1 + # Record Mappers 2 + 3 + Mappers are the core of atp-parity. They define bidirectional transformations between AT Protocol record DTOs and Eloquent models. 4 + 5 + ## Creating a Mapper 6 + 7 + Extend the `RecordMapper` abstract class and implement the required methods: 8 + 9 + ```php 10 + <?php 11 + 12 + namespace App\AtpMappers; 13 + 14 + use App\Models\Post; 15 + use Illuminate\Database\Eloquent\Model; 16 + use SocialDept\AtpParity\RecordMapper; 17 + use SocialDept\AtpSchema\Data\Data; 18 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post as PostRecord; 19 + 20 + /** 21 + * @extends RecordMapper<PostRecord, Post> 22 + */ 23 + class PostMapper extends RecordMapper 24 + { 25 + /** 26 + * The AT Protocol record class this mapper handles. 27 + */ 28 + public function recordClass(): string 29 + { 30 + return PostRecord::class; 31 + } 32 + 33 + /** 34 + * The Eloquent model class this mapper handles. 35 + */ 36 + public function modelClass(): string 37 + { 38 + return Post::class; 39 + } 40 + 41 + /** 42 + * Transform a record DTO into model attributes. 43 + */ 44 + protected function recordToAttributes(Data $record): array 45 + { 46 + /** @var PostRecord $record */ 47 + return [ 48 + 'content' => $record->text, 49 + 'published_at' => $record->createdAt, 50 + 'langs' => $record->langs, 51 + 'facets' => $record->facets, 52 + ]; 53 + } 54 + 55 + /** 56 + * Transform a model into record data for creating/updating. 57 + */ 58 + protected function modelToRecordData(Model $model): array 59 + { 60 + /** @var Post $model */ 61 + return [ 62 + 'text' => $model->content, 63 + 'createdAt' => $model->published_at->toIso8601String(), 64 + 'langs' => $model->langs ?? ['en'], 65 + ]; 66 + } 67 + } 68 + ``` 69 + 70 + ## Required Methods 71 + 72 + ### `recordClass(): string` 73 + 74 + Returns the fully qualified class name of the AT Protocol record DTO. This can be: 75 + 76 + - A generated class from atp-schema (e.g., `SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post`) 77 + - A custom class extending `SocialDept\AtpParity\Data\Record` 78 + 79 + ### `modelClass(): string` 80 + 81 + Returns the fully qualified class name of the Eloquent model. 82 + 83 + ### `recordToAttributes(Data $record): array` 84 + 85 + Transforms an AT Protocol record into an array of Eloquent model attributes. This is used when: 86 + 87 + - Creating a new model from a remote record 88 + - Updating an existing model from a remote record 89 + 90 + ### `modelToRecordData(Model $model): array` 91 + 92 + Transforms an Eloquent model into an array suitable for creating an AT Protocol record. This is used when: 93 + 94 + - Publishing a local model to the AT Protocol network 95 + - Comparing local and remote state 96 + 97 + ## Inherited Methods 98 + 99 + The abstract `RecordMapper` class provides these methods: 100 + 101 + ### `lexicon(): string` 102 + 103 + Returns the lexicon NSID (e.g., `app.bsky.feed.post`). Automatically derived from the record class's `getLexicon()` method. 104 + 105 + ### `toModel(Data $record, array $meta = []): Model` 106 + 107 + Creates a new (unsaved) model instance from a record DTO. 108 + 109 + ```php 110 + $record = PostRecord::fromArray($data); 111 + $model = $mapper->toModel($record, [ 112 + 'uri' => 'at://did:plc:xxx/app.bsky.feed.post/abc123', 113 + 'cid' => 'bafyre...', 114 + ]); 115 + ``` 116 + 117 + ### `toRecord(Model $model): Data` 118 + 119 + Converts a model back to a record DTO. 120 + 121 + ```php 122 + $record = $mapper->toRecord($post); 123 + // Use $record->toArray() to get data for API calls 124 + ``` 125 + 126 + ### `updateModel(Model $model, Data $record, array $meta = []): Model` 127 + 128 + Updates an existing model with data from a record. Does not save the model. 129 + 130 + ```php 131 + $mapper->updateModel($existingPost, $record, ['cid' => $newCid]); 132 + $existingPost->save(); 133 + ``` 134 + 135 + ### `findByUri(string $uri): ?Model` 136 + 137 + Finds a model by its AT Protocol URI. 138 + 139 + ```php 140 + $post = $mapper->findByUri('at://did:plc:xxx/app.bsky.feed.post/abc123'); 141 + ``` 142 + 143 + ### `upsert(Data $record, array $meta = []): Model` 144 + 145 + Creates or updates a model based on the URI. This is the primary method used for syncing. 146 + 147 + ```php 148 + $post = $mapper->upsert($record, [ 149 + 'uri' => $uri, 150 + 'cid' => $cid, 151 + ]); 152 + ``` 153 + 154 + ### `deleteByUri(string $uri): bool` 155 + 156 + Deletes a model by its AT Protocol URI. 157 + 158 + ```php 159 + $deleted = $mapper->deleteByUri('at://did:plc:xxx/app.bsky.feed.post/abc123'); 160 + ``` 161 + 162 + ## Meta Fields 163 + 164 + The `$meta` array passed to `toModel`, `updateModel`, and `upsert` can contain: 165 + 166 + | Key | Description | 167 + |-----|-------------| 168 + | `uri` | The AT Protocol URI (e.g., `at://did:plc:xxx/app.bsky.feed.post/abc123`) | 169 + | `cid` | The content identifier hash | 170 + 171 + These are automatically mapped to your configured column names (default: `atp_uri`, `atp_cid`). 172 + 173 + ## Customizing Column Names 174 + 175 + Override the column methods to use different database columns: 176 + 177 + ```php 178 + class PostMapper extends RecordMapper 179 + { 180 + protected function uriColumn(): string 181 + { 182 + return 'at_uri'; // Instead of default 'atp_uri' 183 + } 184 + 185 + protected function cidColumn(): string 186 + { 187 + return 'at_cid'; // Instead of default 'atp_cid' 188 + } 189 + 190 + // ... other methods 191 + } 192 + ``` 193 + 194 + Or configure globally in `config/parity.php`: 195 + 196 + ```php 197 + 'columns' => [ 198 + 'uri' => 'at_uri', 199 + 'cid' => 'at_cid', 200 + ], 201 + ``` 202 + 203 + ## Registering Mappers 204 + 205 + ### Via Configuration 206 + 207 + Add your mapper classes to `config/parity.php`: 208 + 209 + ```php 210 + return [ 211 + 'mappers' => [ 212 + App\AtpMappers\PostMapper::class, 213 + App\AtpMappers\ProfileMapper::class, 214 + App\AtpMappers\LikeMapper::class, 215 + ], 216 + ]; 217 + ``` 218 + 219 + ### Programmatically 220 + 221 + Register mappers at runtime via the `MapperRegistry`: 222 + 223 + ```php 224 + use SocialDept\AtpParity\MapperRegistry; 225 + 226 + $registry = app(MapperRegistry::class); 227 + $registry->register(new PostMapper()); 228 + ``` 229 + 230 + ## Using the Registry 231 + 232 + The `MapperRegistry` provides lookup methods: 233 + 234 + ```php 235 + use SocialDept\AtpParity\MapperRegistry; 236 + 237 + $registry = app(MapperRegistry::class); 238 + 239 + // Find mapper by record class 240 + $mapper = $registry->forRecord(PostRecord::class); 241 + 242 + // Find mapper by model class 243 + $mapper = $registry->forModel(Post::class); 244 + 245 + // Find mapper by lexicon NSID 246 + $mapper = $registry->forLexicon('app.bsky.feed.post'); 247 + 248 + // Get all registered lexicons 249 + $lexicons = $registry->lexicons(); 250 + // ['app.bsky.feed.post', 'app.bsky.actor.profile', ...] 251 + ``` 252 + 253 + ## SchemaMapper for Quick Setup 254 + 255 + For simple mappings, use `SchemaMapper` instead of creating a full class: 256 + 257 + ```php 258 + use SocialDept\AtpParity\Support\SchemaMapper; 259 + use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Like; 260 + 261 + $mapper = new SchemaMapper( 262 + schemaClass: Like::class, 263 + modelClass: \App\Models\Like::class, 264 + toAttributes: fn(Like $like) => [ 265 + 'subject_uri' => $like->subject->uri, 266 + 'subject_cid' => $like->subject->cid, 267 + 'liked_at' => $like->createdAt, 268 + ], 269 + toRecordData: fn($model) => [ 270 + 'subject' => [ 271 + 'uri' => $model->subject_uri, 272 + 'cid' => $model->subject_cid, 273 + ], 274 + 'createdAt' => $model->liked_at->toIso8601String(), 275 + ], 276 + ); 277 + 278 + $registry->register($mapper); 279 + ``` 280 + 281 + ## Handling Complex Records 282 + 283 + ### Embedded Objects 284 + 285 + AT Protocol records often contain embedded objects. Handle them in your mapping: 286 + 287 + ```php 288 + protected function recordToAttributes(Data $record): array 289 + { 290 + /** @var PostRecord $record */ 291 + $attributes = [ 292 + 'content' => $record->text, 293 + 'published_at' => $record->createdAt, 294 + ]; 295 + 296 + // Handle reply reference 297 + if ($record->reply) { 298 + $attributes['reply_to_uri'] = $record->reply->parent->uri; 299 + $attributes['thread_root_uri'] = $record->reply->root->uri; 300 + } 301 + 302 + // Handle embed 303 + if ($record->embed) { 304 + $attributes['embed_type'] = $record->embed->getType(); 305 + $attributes['embed_data'] = $record->embed->toArray(); 306 + } 307 + 308 + return $attributes; 309 + } 310 + ``` 311 + 312 + ### Facets (Rich Text) 313 + 314 + Posts with mentions, links, and hashtags have facets: 315 + 316 + ```php 317 + protected function recordToAttributes(Data $record): array 318 + { 319 + /** @var PostRecord $record */ 320 + return [ 321 + 'content' => $record->text, 322 + 'facets' => $record->facets, // Store as JSON 323 + 'published_at' => $record->createdAt, 324 + ]; 325 + } 326 + 327 + protected function modelToRecordData(Model $model): array 328 + { 329 + /** @var Post $model */ 330 + return [ 331 + 'text' => $model->content, 332 + 'facets' => $model->facets, // Restore from JSON 333 + 'createdAt' => $model->published_at->toIso8601String(), 334 + ]; 335 + } 336 + ``` 337 + 338 + ## Multiple Mappers per Lexicon 339 + 340 + You can register multiple mappers for different model types: 341 + 342 + ```php 343 + // Map posts to different models based on criteria 344 + class UserPostMapper extends RecordMapper 345 + { 346 + public function recordClass(): string 347 + { 348 + return PostRecord::class; 349 + } 350 + 351 + public function modelClass(): string 352 + { 353 + return UserPost::class; 354 + } 355 + 356 + // ... mapping logic for user's own posts 357 + } 358 + 359 + class FeedPostMapper extends RecordMapper 360 + { 361 + public function recordClass(): string 362 + { 363 + return PostRecord::class; 364 + } 365 + 366 + public function modelClass(): string 367 + { 368 + return FeedPost::class; 369 + } 370 + 371 + // ... mapping logic for feed posts 372 + } 373 + ``` 374 + 375 + Note: The registry will return the first registered mapper for a given lexicon. Use explicit mapper instances when you need specific behavior.
+363
docs/traits.md
··· 1 + # Model Traits 2 + 3 + Parity provides two traits to add AT Protocol awareness to your Eloquent models. 4 + 5 + ## HasAtpRecord 6 + 7 + The base trait for models that store AT Protocol record references. 8 + 9 + ### Setup 10 + 11 + ```php 12 + <?php 13 + 14 + namespace App\Models; 15 + 16 + use Illuminate\Database\Eloquent\Model; 17 + use SocialDept\AtpParity\Concerns\HasAtpRecord; 18 + 19 + class Post extends Model 20 + { 21 + use HasAtpRecord; 22 + 23 + protected $fillable = [ 24 + 'content', 25 + 'published_at', 26 + 'atp_uri', 27 + 'atp_cid', 28 + ]; 29 + } 30 + ``` 31 + 32 + ### Database Migration 33 + 34 + ```php 35 + Schema::create('posts', function (Blueprint $table) { 36 + $table->id(); 37 + $table->text('content'); 38 + $table->timestamp('published_at'); 39 + $table->string('atp_uri')->nullable()->unique(); 40 + $table->string('atp_cid')->nullable(); 41 + $table->timestamps(); 42 + }); 43 + ``` 44 + 45 + ### Available Methods 46 + 47 + #### `getAtpUri(): ?string` 48 + 49 + Returns the stored AT Protocol URI. 50 + 51 + ```php 52 + $post->getAtpUri(); 53 + // "at://did:plc:abc123/app.bsky.feed.post/xyz789" 54 + ``` 55 + 56 + #### `getAtpCid(): ?string` 57 + 58 + Returns the stored content identifier. 59 + 60 + ```php 61 + $post->getAtpCid(); 62 + // "bafyreib2rxk3rjnlvzj..." 63 + ``` 64 + 65 + #### `getAtpDid(): ?string` 66 + 67 + Extracts the DID from the URI. 68 + 69 + ```php 70 + $post->getAtpDid(); 71 + // "did:plc:abc123" 72 + ``` 73 + 74 + #### `getAtpCollection(): ?string` 75 + 76 + Extracts the collection (lexicon NSID) from the URI. 77 + 78 + ```php 79 + $post->getAtpCollection(); 80 + // "app.bsky.feed.post" 81 + ``` 82 + 83 + #### `getAtpRkey(): ?string` 84 + 85 + Extracts the record key from the URI. 86 + 87 + ```php 88 + $post->getAtpRkey(); 89 + // "xyz789" 90 + ``` 91 + 92 + #### `hasAtpRecord(): bool` 93 + 94 + Checks if the model has been synced to AT Protocol. 95 + 96 + ```php 97 + if ($post->hasAtpRecord()) { 98 + // Model exists on AT Protocol 99 + } 100 + ``` 101 + 102 + #### `getAtpMapper(): ?RecordMapper` 103 + 104 + Gets the registered mapper for this model class. 105 + 106 + ```php 107 + $mapper = $post->getAtpMapper(); 108 + ``` 109 + 110 + #### `toAtpRecord(): ?Data` 111 + 112 + Converts the model to an AT Protocol record DTO. 113 + 114 + ```php 115 + $record = $post->toAtpRecord(); 116 + $data = $record->toArray(); // Ready for API calls 117 + ``` 118 + 119 + ### Query Scopes 120 + 121 + #### `scopeWithAtpRecord($query)` 122 + 123 + Query only models that have been synced. 124 + 125 + ```php 126 + $syncedPosts = Post::withAtpRecord()->get(); 127 + ``` 128 + 129 + #### `scopeWithoutAtpRecord($query)` 130 + 131 + Query only models that have NOT been synced. 132 + 133 + ```php 134 + $localOnlyPosts = Post::withoutAtpRecord()->get(); 135 + ``` 136 + 137 + #### `scopeWhereAtpUri($query, string $uri)` 138 + 139 + Find a model by its AT Protocol URI. 140 + 141 + ```php 142 + $post = Post::whereAtpUri('at://did:plc:xxx/app.bsky.feed.post/abc')->first(); 143 + ``` 144 + 145 + ## SyncsWithAtp 146 + 147 + Extended trait for bidirectional synchronization tracking. Includes all `HasAtpRecord` functionality plus sync timestamps and conflict detection. 148 + 149 + ### Setup 150 + 151 + ```php 152 + <?php 153 + 154 + namespace App\Models; 155 + 156 + use Illuminate\Database\Eloquent\Model; 157 + use SocialDept\AtpParity\Concerns\SyncsWithAtp; 158 + 159 + class Post extends Model 160 + { 161 + use SyncsWithAtp; 162 + 163 + protected $fillable = [ 164 + 'content', 165 + 'published_at', 166 + 'atp_uri', 167 + 'atp_cid', 168 + 'atp_synced_at', 169 + ]; 170 + 171 + protected $casts = [ 172 + 'published_at' => 'datetime', 173 + 'atp_synced_at' => 'datetime', 174 + ]; 175 + } 176 + ``` 177 + 178 + ### Database Migration 179 + 180 + ```php 181 + Schema::create('posts', function (Blueprint $table) { 182 + $table->id(); 183 + $table->text('content'); 184 + $table->timestamp('published_at'); 185 + $table->string('atp_uri')->nullable()->unique(); 186 + $table->string('atp_cid')->nullable(); 187 + $table->timestamp('atp_synced_at')->nullable(); 188 + $table->timestamps(); 189 + }); 190 + ``` 191 + 192 + ### Additional Methods 193 + 194 + #### `getAtpSyncedAtColumn(): string` 195 + 196 + Returns the column name for the sync timestamp. Override to customize. 197 + 198 + ```php 199 + public function getAtpSyncedAtColumn(): string 200 + { 201 + return 'last_synced_at'; // Default: 'atp_synced_at' 202 + } 203 + ``` 204 + 205 + #### `getAtpSyncedAt(): ?DateTimeInterface` 206 + 207 + Returns when the model was last synced. 208 + 209 + ```php 210 + $syncedAt = $post->getAtpSyncedAt(); 211 + // Carbon instance or null 212 + ``` 213 + 214 + #### `markAsSynced(string $uri, string $cid): void` 215 + 216 + Marks the model as synced with the given metadata. Does not save. 217 + 218 + ```php 219 + $post->markAsSynced($uri, $cid); 220 + $post->save(); 221 + ``` 222 + 223 + #### `hasLocalChanges(): bool` 224 + 225 + Checks if the model has been modified since the last sync. 226 + 227 + ```php 228 + if ($post->hasLocalChanges()) { 229 + // Local changes exist that haven't been pushed 230 + } 231 + ``` 232 + 233 + This compares `updated_at` with `atp_synced_at`. 234 + 235 + #### `updateFromRecord(Data $record, string $uri, string $cid): void` 236 + 237 + Updates the model from a remote record. Does not save. 238 + 239 + ```php 240 + $post->updateFromRecord($record, $uri, $cid); 241 + $post->save(); 242 + ``` 243 + 244 + ## Practical Examples 245 + 246 + ### Checking Sync Status 247 + 248 + ```php 249 + $post = Post::find(1); 250 + 251 + if (!$post->hasAtpRecord()) { 252 + echo "Not yet published to AT Protocol"; 253 + } elseif ($post->hasLocalChanges()) { 254 + echo "Has unpushed local changes"; 255 + } else { 256 + echo "In sync with AT Protocol"; 257 + } 258 + ``` 259 + 260 + ### Finding Related Records 261 + 262 + ```php 263 + // Get all posts from the same author 264 + $authorDid = $post->getAtpDid(); 265 + $authorPosts = Post::withAtpRecord() 266 + ->get() 267 + ->filter(fn($p) => $p->getAtpDid() === $authorDid); 268 + ``` 269 + 270 + ### Building an AT Protocol URL 271 + 272 + ```php 273 + $post = Post::find(1); 274 + 275 + if ($post->hasAtpRecord()) { 276 + $bskyUrl = sprintf( 277 + 'https://bsky.app/profile/%s/post/%s', 278 + $post->getAtpDid(), 279 + $post->getAtpRkey() 280 + ); 281 + } 282 + ``` 283 + 284 + ### Sync Status Dashboard 285 + 286 + ```php 287 + // Get sync statistics 288 + $stats = [ 289 + 'total' => Post::count(), 290 + 'synced' => Post::withAtpRecord()->count(), 291 + 'pending' => Post::withoutAtpRecord()->count(), 292 + 'with_changes' => Post::withAtpRecord() 293 + ->get() 294 + ->filter(fn($p) => $p->hasLocalChanges()) 295 + ->count(), 296 + ]; 297 + ``` 298 + 299 + ## Custom Column Names 300 + 301 + Both traits respect the global column configuration: 302 + 303 + ```php 304 + // config/parity.php 305 + return [ 306 + 'columns' => [ 307 + 'uri' => 'at_protocol_uri', 308 + 'cid' => 'at_protocol_cid', 309 + ], 310 + ]; 311 + ``` 312 + 313 + For the sync timestamp column, override the method in your model: 314 + 315 + ```php 316 + class Post extends Model 317 + { 318 + use SyncsWithAtp; 319 + 320 + public function getAtpSyncedAtColumn(): string 321 + { 322 + return 'last_synced_at'; 323 + } 324 + } 325 + ``` 326 + 327 + ## Event Hooks 328 + 329 + The `SyncsWithAtp` trait includes a boot method you can extend: 330 + 331 + ```php 332 + class Post extends Model 333 + { 334 + use SyncsWithAtp; 335 + 336 + protected static function bootSyncsWithAtp(): void 337 + { 338 + parent::bootSyncsWithAtp(); 339 + 340 + static::updating(function ($model) { 341 + // Custom logic before updates 342 + }); 343 + } 344 + } 345 + ``` 346 + 347 + ## Combining with Other Traits 348 + 349 + The traits work alongside other Eloquent features: 350 + 351 + ```php 352 + use Illuminate\Database\Eloquent\Model; 353 + use Illuminate\Database\Eloquent\SoftDeletes; 354 + use SocialDept\AtpParity\Concerns\SyncsWithAtp; 355 + 356 + class Post extends Model 357 + { 358 + use SoftDeletes; 359 + use SyncsWithAtp; 360 + 361 + // Both traits work together 362 + } 363 + ```
header.png

This is a binary file and will not be displayed.

-5
license.md
··· 1 - # The license 2 - 3 - Copyright (c) Author Name <author@email.com> 4 - 5 - ...Add your license text here...
+19 -15
phpunit.xml
··· 1 1 <?xml version="1.0" encoding="UTF-8"?> 2 - <phpunit bootstrap="vendor/autoload.php" 3 - backupGlobals="false" 4 - backupStaticAttributes="false" 2 + <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 3 + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" 4 + bootstrap="vendor/autoload.php" 5 5 colors="true" 6 - verbose="true" 7 - convertErrorsToExceptions="true" 8 - convertNoticesToExceptions="true" 9 - convertWarningsToExceptions="true" 10 - processIsolation="false" 11 - stopOnFailure="false"> 6 + cacheDirectory=".phpunit.cache"> 12 7 <testsuites> 13 - <testsuite name="Package"> 14 - <directory suffix=".php">./tests/</directory> 8 + <testsuite name="Parity Test Suite"> 9 + <directory suffix="Test.php">./tests/</directory> 10 + <exclude>./tests/Fixtures/</exclude> 15 11 </testsuite> 16 12 </testsuites> 17 - <filter> 18 - <whitelist> 13 + <source> 14 + <include> 19 15 <directory>src/</directory> 20 - </whitelist> 21 - </filter> 16 + </include> 17 + </source> 18 + <php> 19 + <env name="APP_ENV" value="testing"/> 20 + <env name="CACHE_DRIVER" value="array"/> 21 + <env name="SESSION_DRIVER" value="array"/> 22 + <env name="QUEUE_CONNECTION" value="sync"/> 23 + <env name="DB_CONNECTION" value="sqlite"/> 24 + <env name="DB_DATABASE" value=":memory:"/> 25 + </php> 22 26 </phpunit>
-57
readme.md
··· 1 - # AtpReplicator 2 - 3 - [![Latest Version on Packagist][ico-version]][link-packagist] 4 - [![Total Downloads][ico-downloads]][link-downloads] 5 - [![Build Status][ico-travis]][link-travis] 6 - [![StyleCI][ico-styleci]][link-styleci] 7 - 8 - This is where your description should go. Take a look at [contributing.md](contributing.md) to see a to do list. 9 - 10 - ## Installation 11 - 12 - Via Composer 13 - 14 - ```bash 15 - composer require socialdept/atp-parity 16 - ``` 17 - 18 - ## Usage 19 - 20 - ## Change log 21 - 22 - Please see the [changelog](changelog.md) for more information on what has changed recently. 23 - 24 - ## Testing 25 - 26 - ```bash 27 - composer test 28 - ``` 29 - 30 - ## Contributing 31 - 32 - Please see [contributing.md](contributing.md) for details and a todolist. 33 - 34 - ## Security 35 - 36 - If you discover any security related issues, please email author@email.com instead of using the issue tracker. 37 - 38 - ## Credits 39 - 40 - - [Author Name][link-author] 41 - - [All Contributors][link-contributors] 42 - 43 - ## License 44 - 45 - MIT. Please see the [license file](license.md) for more information. 46 - 47 - [ico-version]: https://img.shields.io/packagist/v/socialdept/atp-parity.svg?style=flat-square 48 - [ico-downloads]: https://img.shields.io/packagist/dt/socialdept/atp-parity.svg?style=flat-square 49 - [ico-travis]: https://img.shields.io/travis/socialdept/atp-parity/master.svg?style=flat-square 50 - [ico-styleci]: https://styleci.io/repos/12345678/shield 51 - 52 - [link-packagist]: https://packagist.org/packages/socialdept/atp-parity 53 - [link-downloads]: https://packagist.org/packages/socialdept/atp-parity 54 - [link-travis]: https://travis-ci.org/socialdept/atp-parity 55 - [link-styleci]: https://styleci.io/repos/12345678 56 - [link-author]: https://github.com/social-dept 57 - [link-contributors]: ../../contributors
-8
src/AtpReplicator.php
··· 1 - <?php 2 - 3 - namespace SocialDept\AtpReplicator; 4 - 5 - class AtpReplicator 6 - { 7 - // Build wonderful things 8 - }
-82
src/AtpReplicatorServiceProvider.php
··· 1 - <?php 2 - 3 - namespace SocialDept\AtpReplicator; 4 - 5 - use Illuminate\Support\ServiceProvider; 6 - 7 - class AtpReplicatorServiceProvider extends ServiceProvider 8 - { 9 - /** 10 - * Perform post-registration booting of services. 11 - * 12 - * @return void 13 - */ 14 - public function boot(): void 15 - { 16 - // $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'social-dept'); 17 - // $this->loadViewsFrom(__DIR__.'/../resources/views', 'social-dept'); 18 - // $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 19 - // $this->loadRoutesFrom(__DIR__.'/routes.php'); 20 - 21 - // Publishing is only necessary when using the CLI. 22 - if ($this->app->runningInConsole()) { 23 - $this->bootForConsole(); 24 - } 25 - } 26 - 27 - /** 28 - * Register any package services. 29 - * 30 - * @return void 31 - */ 32 - public function register(): void 33 - { 34 - $this->mergeConfigFrom(__DIR__.'/../config/atp-replicator.php', 'atp-replicator'); 35 - 36 - // Register the service the package provides. 37 - $this->app->singleton('atp-replicator', function ($app) { 38 - return new AtpReplicator; 39 - }); 40 - } 41 - 42 - /** 43 - * Get the services provided by the provider. 44 - * 45 - * @return array 46 - */ 47 - public function provides() 48 - { 49 - return ['atp-replicator']; 50 - } 51 - 52 - /** 53 - * Console-specific booting. 54 - * 55 - * @return void 56 - */ 57 - protected function bootForConsole(): void 58 - { 59 - // Publishing the configuration file. 60 - $this->publishes([ 61 - __DIR__.'/../config/atp-replicator.php' => config_path('atp-replicator.php'), 62 - ], 'atp-replicator.config'); 63 - 64 - // Publishing the views. 65 - /*$this->publishes([ 66 - __DIR__.'/../resources/views' => base_path('resources/views/vendor/social-dept'), 67 - ], 'atp-replicator.views');*/ 68 - 69 - // Publishing assets. 70 - /*$this->publishes([ 71 - __DIR__.'/../resources/assets' => public_path('vendor/social-dept'), 72 - ], 'atp-replicator.assets');*/ 73 - 74 - // Publishing the translation files. 75 - /*$this->publishes([ 76 - __DIR__.'/../resources/lang' => resource_path('lang/vendor/social-dept'), 77 - ], 'atp-replicator.lang');*/ 78 - 79 - // Registering package commands. 80 - // $this->commands([]); 81 - } 82 - }
+122
src/Commands/DiscoverCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Commands; 4 + 5 + use Illuminate\Console\Command; 6 + use SocialDept\AtpParity\Discovery\DiscoveryService; 7 + 8 + use function Laravel\Prompts\error; 9 + use function Laravel\Prompts\info; 10 + use function Laravel\Prompts\note; 11 + 12 + class DiscoverCommand extends Command 13 + { 14 + protected $signature = 'parity:discover 15 + {collection : The collection NSID to discover (e.g., app.bsky.feed.post)} 16 + {--limit= : Maximum number of DIDs to discover} 17 + {--import : Import records for discovered DIDs} 18 + {--output= : Output DIDs to file (one per line)} 19 + {--count : Only count DIDs without listing them}'; 20 + 21 + protected $description = 'Discover DIDs with records in a specific collection'; 22 + 23 + public function handle(DiscoveryService $service): int 24 + { 25 + $collection = $this->argument('collection'); 26 + $limit = $this->option('limit') ? (int) $this->option('limit') : null; 27 + 28 + if ($this->option('count')) { 29 + return $this->handleCount($service, $collection); 30 + } 31 + 32 + if ($this->option('import')) { 33 + return $this->handleDiscoverAndImport($service, $collection, $limit); 34 + } 35 + 36 + return $this->handleDiscover($service, $collection, $limit); 37 + } 38 + 39 + protected function handleCount(DiscoveryService $service, string $collection): int 40 + { 41 + info("Counting DIDs with records in {$collection}..."); 42 + 43 + $count = $service->count($collection); 44 + 45 + info("Found {$count} DIDs"); 46 + 47 + return self::SUCCESS; 48 + } 49 + 50 + protected function handleDiscover(DiscoveryService $service, string $collection, ?int $limit): int 51 + { 52 + $limitDisplay = $limit ? " (limit: {$limit})" : ''; 53 + info("Discovering DIDs with records in {$collection}{$limitDisplay}..."); 54 + 55 + $result = $service->discover($collection, $limit); 56 + 57 + if ($result->isFailed()) { 58 + error("Discovery failed: {$result->error}"); 59 + 60 + return self::FAILURE; 61 + } 62 + 63 + if ($result->total === 0) { 64 + note('No DIDs found'); 65 + 66 + return self::SUCCESS; 67 + } 68 + 69 + // Output to file if requested 70 + if ($output = $this->option('output')) { 71 + file_put_contents($output, implode("\n", $result->dids)."\n"); 72 + info("Found {$result->total} DIDs, written to {$output}"); 73 + 74 + if ($result->isIncomplete()) { 75 + note('Results may be incomplete due to limit'); 76 + } 77 + 78 + return self::SUCCESS; 79 + } 80 + 81 + // Output to console 82 + foreach ($result->dids as $did) { 83 + $this->line($did); 84 + } 85 + 86 + info("Found {$result->total} DIDs"); 87 + 88 + if ($result->isIncomplete()) { 89 + note('Results may be incomplete due to limit'); 90 + } 91 + 92 + return self::SUCCESS; 93 + } 94 + 95 + protected function handleDiscoverAndImport(DiscoveryService $service, string $collection, ?int $limit): int 96 + { 97 + $limitDisplay = $limit ? " (limit: {$limit})" : ''; 98 + info("Discovering and importing DIDs with records in {$collection}{$limitDisplay}..."); 99 + 100 + $result = $service->discoverAndImport( 101 + $collection, 102 + $limit, 103 + function (string $did, int $count) { 104 + note("[{$count}] Importing {$did}"); 105 + } 106 + ); 107 + 108 + if ($result->isFailed()) { 109 + error("Discovery failed: {$result->error}"); 110 + 111 + return self::FAILURE; 112 + } 113 + 114 + info("Imported records for {$result->total} DIDs"); 115 + 116 + if ($result->isIncomplete()) { 117 + note('Results may be incomplete due to limit'); 118 + } 119 + 120 + return self::SUCCESS; 121 + } 122 + }
+135
src/Commands/ExportCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Commands; 4 + 5 + use Illuminate\Console\Command; 6 + use SocialDept\AtpParity\Events\ImportProgress; 7 + use SocialDept\AtpParity\Export\ExportService; 8 + 9 + use function Laravel\Prompts\error; 10 + use function Laravel\Prompts\info; 11 + use function Laravel\Prompts\note; 12 + 13 + class ExportCommand extends Command 14 + { 15 + protected $signature = 'parity:export 16 + {did : The DID to export} 17 + {--output= : Output CAR file path} 18 + {--import : Import records to database instead of saving CAR file} 19 + {--collection=* : Specific collections to import (with --import)} 20 + {--since= : Only export changes since this revision} 21 + {--status : Show repository status instead of exporting}'; 22 + 23 + protected $description = 'Export an AT Protocol repository as CAR file or import to database'; 24 + 25 + public function handle(ExportService $service): int 26 + { 27 + $did = $this->argument('did'); 28 + 29 + if (! str_starts_with($did, 'did:')) { 30 + error("Invalid DID format: {$did}"); 31 + 32 + return self::FAILURE; 33 + } 34 + 35 + if ($this->option('status')) { 36 + return $this->handleStatus($service, $did); 37 + } 38 + 39 + if ($this->option('import')) { 40 + return $this->handleImport($service, $did); 41 + } 42 + 43 + return $this->handleExport($service, $did); 44 + } 45 + 46 + protected function handleStatus(ExportService $service, string $did): int 47 + { 48 + info("Getting repository status for {$did}..."); 49 + 50 + try { 51 + $commit = $service->getLatestCommit($did); 52 + $status = $service->getRepoStatus($did); 53 + 54 + $this->table(['Property', 'Value'], [ 55 + ['DID', $did], 56 + ['Latest CID', $commit['cid'] ?? 'N/A'], 57 + ['Latest Rev', $commit['rev'] ?? 'N/A'], 58 + ['Active', ($status['active'] ?? false) ? 'Yes' : 'No'], 59 + ['Status', $status['status'] ?? 'N/A'], 60 + ]); 61 + 62 + return self::SUCCESS; 63 + } catch (\Throwable $e) { 64 + error("Failed to get status: {$e->getMessage()}"); 65 + 66 + return self::FAILURE; 67 + } 68 + } 69 + 70 + protected function handleExport(ExportService $service, string $did): int 71 + { 72 + $output = $this->option('output') ?? "{$did}.car"; 73 + $since = $this->option('since'); 74 + 75 + // Sanitize filename if using DID as filename 76 + $output = str_replace([':', '/'], ['_', '_'], $output); 77 + 78 + info("Exporting repository {$did} to {$output}..."); 79 + 80 + $result = $service->exportToFile($did, $output, $since); 81 + 82 + if ($result->isFailed()) { 83 + error("Export failed: {$result->error}"); 84 + 85 + return self::FAILURE; 86 + } 87 + 88 + $size = $this->formatBytes($result->size); 89 + info("Exported {$size} to {$output}"); 90 + 91 + return self::SUCCESS; 92 + } 93 + 94 + protected function handleImport(ExportService $service, string $did): int 95 + { 96 + $collections = $this->option('collection') ?: null; 97 + $collectionDisplay = $collections ? implode(', ', $collections) : 'all registered'; 98 + 99 + info("Exporting and importing {$did} ({$collectionDisplay})..."); 100 + 101 + $result = $service->exportAndImport( 102 + $did, 103 + $collections, 104 + function (ImportProgress $progress) { 105 + $this->output->write("\r"); 106 + $this->output->write(" [{$progress->collection}] {$progress->recordsSynced} records synced"); 107 + } 108 + ); 109 + 110 + $this->output->write("\n"); 111 + 112 + if ($result->isFailed()) { 113 + error("Import failed: {$result->error}"); 114 + 115 + return self::FAILURE; 116 + } 117 + 118 + info("Imported {$result->size} records"); 119 + 120 + return self::SUCCESS; 121 + } 122 + 123 + protected function formatBytes(int $bytes): string 124 + { 125 + $units = ['B', 'KB', 'MB', 'GB']; 126 + $unit = 0; 127 + 128 + while ($bytes >= 1024 && $unit < count($units) - 1) { 129 + $bytes /= 1024; 130 + $unit++; 131 + } 132 + 133 + return round($bytes, 2).' '.$units[$unit]; 134 + } 135 + }
+190
src/Commands/ImportCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Commands; 4 + 5 + use Illuminate\Console\Command; 6 + use SocialDept\AtpParity\Events\ImportProgress; 7 + use SocialDept\AtpParity\Import\ImportService; 8 + use SocialDept\AtpParity\Jobs\ImportUserJob; 9 + use SocialDept\AtpParity\MapperRegistry; 10 + 11 + use function Laravel\Prompts\error; 12 + use function Laravel\Prompts\info; 13 + use function Laravel\Prompts\note; 14 + use function Laravel\Prompts\warning; 15 + 16 + class ImportCommand extends Command 17 + { 18 + protected $signature = 'parity:import 19 + {did? : The DID to import} 20 + {--collection= : Specific collection to import} 21 + {--file= : File containing DIDs to import (one per line)} 22 + {--resume : Resume all interrupted imports} 23 + {--queue : Queue the import job instead of running synchronously} 24 + {--progress : Show progress output}'; 25 + 26 + protected $description = 'Import AT Protocol records for a user or from a file of DIDs'; 27 + 28 + public function handle(ImportService $service, MapperRegistry $registry): int 29 + { 30 + if ($this->option('resume')) { 31 + return $this->handleResume($service); 32 + } 33 + 34 + $did = $this->argument('did'); 35 + $file = $this->option('file'); 36 + 37 + if (! $did && ! $file) { 38 + error('Please provide a DID or use --file to specify a file of DIDs'); 39 + 40 + return self::FAILURE; 41 + } 42 + 43 + if ($file) { 44 + return $this->handleFile($file, $service); 45 + } 46 + 47 + return $this->importDid($did, $service, $registry); 48 + } 49 + 50 + protected function handleResume(ImportService $service): int 51 + { 52 + info('Resuming interrupted imports...'); 53 + 54 + $results = $service->resumeAll($this->getProgressCallback()); 55 + 56 + if (empty($results)) { 57 + note('No interrupted imports found'); 58 + 59 + return self::SUCCESS; 60 + } 61 + 62 + $success = 0; 63 + $failed = 0; 64 + 65 + foreach ($results as $result) { 66 + if ($result->isSuccess()) { 67 + $success++; 68 + } else { 69 + $failed++; 70 + } 71 + } 72 + 73 + info("Resumed {$success} imports successfully"); 74 + 75 + if ($failed > 0) { 76 + warning("{$failed} imports failed"); 77 + } 78 + 79 + return $failed > 0 ? self::FAILURE : self::SUCCESS; 80 + } 81 + 82 + protected function handleFile(string $file, ImportService $service): int 83 + { 84 + if (! file_exists($file)) { 85 + error("File not found: {$file}"); 86 + 87 + return self::FAILURE; 88 + } 89 + 90 + $dids = array_filter(array_map('trim', file($file))); 91 + $total = count($dids); 92 + $success = 0; 93 + $failed = 0; 94 + 95 + info("Importing {$total} DIDs from {$file}"); 96 + 97 + foreach ($dids as $index => $did) { 98 + if (! str_starts_with($did, 'did:')) { 99 + warning("Skipping invalid DID: {$did}"); 100 + 101 + continue; 102 + } 103 + 104 + $current = $index + 1; 105 + note("[{$current}/{$total}] Importing {$did}"); 106 + 107 + if ($this->option('queue')) { 108 + ImportUserJob::dispatch($did, $this->option('collection')); 109 + $success++; 110 + } else { 111 + $result = $service->importUser($did, $this->getCollections(), $this->getProgressCallback()); 112 + 113 + if ($result->isSuccess()) { 114 + $success++; 115 + } else { 116 + $failed++; 117 + warning("Failed: {$result->error}"); 118 + } 119 + } 120 + } 121 + 122 + info("Completed: {$success} successful, {$failed} failed"); 123 + 124 + return $failed > 0 ? self::FAILURE : self::SUCCESS; 125 + } 126 + 127 + protected function importDid(string $did, ImportService $service, MapperRegistry $registry): int 128 + { 129 + if (! str_starts_with($did, 'did:')) { 130 + error("Invalid DID format: {$did}"); 131 + 132 + return self::FAILURE; 133 + } 134 + 135 + $collections = $this->getCollections(); 136 + $collectionDisplay = $collections ? implode(', ', $collections) : 'all registered'; 137 + 138 + info("Importing {$did} ({$collectionDisplay})"); 139 + 140 + if ($this->option('queue')) { 141 + ImportUserJob::dispatch($did, $this->option('collection')); 142 + note('Import job queued'); 143 + 144 + return self::SUCCESS; 145 + } 146 + 147 + $result = $service->importUser($did, $collections, $this->getProgressCallback()); 148 + 149 + if ($result->isSuccess()) { 150 + info("Import completed: {$result->recordsSynced} records synced"); 151 + 152 + if ($result->recordsSkipped > 0) { 153 + note("{$result->recordsSkipped} records skipped"); 154 + } 155 + 156 + if ($result->recordsFailed > 0) { 157 + warning("{$result->recordsFailed} records failed"); 158 + } 159 + 160 + return self::SUCCESS; 161 + } 162 + 163 + error("Import failed: {$result->error}"); 164 + 165 + if ($result->recordsSynced > 0) { 166 + note("Partial progress: {$result->recordsSynced} records synced before failure"); 167 + } 168 + 169 + return self::FAILURE; 170 + } 171 + 172 + protected function getCollections(): ?array 173 + { 174 + $collection = $this->option('collection'); 175 + 176 + return $collection ? [$collection] : null; 177 + } 178 + 179 + protected function getProgressCallback(): ?callable 180 + { 181 + if (! $this->option('progress')) { 182 + return null; 183 + } 184 + 185 + return function (ImportProgress $progress) { 186 + $this->output->write("\r"); 187 + $this->output->write(" [{$progress->collection}] {$progress->recordsSynced} records synced"); 188 + }; 189 + } 190 + }
+143
src/Commands/ImportStatusCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Commands; 4 + 5 + use Illuminate\Console\Command; 6 + use SocialDept\AtpParity\Import\ImportState; 7 + 8 + use function Laravel\Prompts\info; 9 + use function Laravel\Prompts\note; 10 + use function Laravel\Prompts\table; 11 + use function Laravel\Prompts\warning; 12 + 13 + class ImportStatusCommand extends Command 14 + { 15 + protected $signature = 'parity:import-status 16 + {did? : Show status for specific DID} 17 + {--pending : Show only pending/incomplete imports} 18 + {--failed : Show only failed imports} 19 + {--completed : Show only completed imports}'; 20 + 21 + protected $description = 'Show import status'; 22 + 23 + public function handle(): int 24 + { 25 + $did = $this->argument('did'); 26 + 27 + if ($did) { 28 + return $this->showDidStatus($did); 29 + } 30 + 31 + return $this->showAllStatus(); 32 + } 33 + 34 + protected function showDidStatus(string $did): int 35 + { 36 + $states = ImportState::where('did', $did)->get(); 37 + 38 + if ($states->isEmpty()) { 39 + note("No import records found for {$did}"); 40 + 41 + return self::SUCCESS; 42 + } 43 + 44 + info("Import status for {$did}"); 45 + 46 + table( 47 + headers: ['Collection', 'Status', 'Synced', 'Skipped', 'Failed', 'Started', 'Completed'], 48 + rows: $states->map(fn (ImportState $state) => [ 49 + $state->collection, 50 + $this->formatStatus($state->status), 51 + $state->records_synced, 52 + $state->records_skipped, 53 + $state->records_failed, 54 + $state->started_at?->diffForHumans() ?? '-', 55 + $state->completed_at?->diffForHumans() ?? '-', 56 + ])->toArray() 57 + ); 58 + 59 + return self::SUCCESS; 60 + } 61 + 62 + protected function showAllStatus(): int 63 + { 64 + $query = ImportState::query(); 65 + 66 + if ($this->option('pending')) { 67 + $query->incomplete(); 68 + } elseif ($this->option('failed')) { 69 + $query->failed(); 70 + } elseif ($this->option('completed')) { 71 + $query->completed(); 72 + } 73 + 74 + $states = $query->orderByDesc('updated_at')->limit(100)->get(); 75 + 76 + if ($states->isEmpty()) { 77 + note('No import records found'); 78 + 79 + return self::SUCCESS; 80 + } 81 + 82 + $this->displaySummary(); 83 + 84 + table( 85 + headers: ['DID', 'Collection', 'Status', 'Synced', 'Updated'], 86 + rows: $states->map(fn (ImportState $state) => [ 87 + $this->truncateDid($state->did), 88 + $state->collection, 89 + $this->formatStatus($state->status), 90 + $state->records_synced, 91 + $state->updated_at->diffForHumans(), 92 + ])->toArray() 93 + ); 94 + 95 + if ($states->count() >= 100) { 96 + note('Showing first 100 results. Use --pending, --failed, or --completed to filter.'); 97 + } 98 + 99 + return self::SUCCESS; 100 + } 101 + 102 + protected function displaySummary(): void 103 + { 104 + $counts = ImportState::query() 105 + ->selectRaw('status, count(*) as count') 106 + ->groupBy('status') 107 + ->pluck('count', 'status'); 108 + 109 + $pending = $counts->get('pending', 0); 110 + $inProgress = $counts->get('in_progress', 0); 111 + $completed = $counts->get('completed', 0); 112 + $failed = $counts->get('failed', 0); 113 + 114 + info("Import Status Summary"); 115 + note("Pending: {$pending} | In Progress: {$inProgress} | Completed: {$completed} | Failed: {$failed}"); 116 + 117 + if ($failed > 0) { 118 + warning("Use 'php artisan parity:import --resume' to retry failed imports"); 119 + } 120 + 121 + $this->newLine(); 122 + } 123 + 124 + protected function formatStatus(string $status): string 125 + { 126 + return match ($status) { 127 + ImportState::STATUS_PENDING => 'pending', 128 + ImportState::STATUS_IN_PROGRESS => 'running', 129 + ImportState::STATUS_COMPLETED => 'done', 130 + ImportState::STATUS_FAILED => 'FAILED', 131 + default => $status, 132 + }; 133 + } 134 + 135 + protected function truncateDid(string $did): string 136 + { 137 + if (strlen($did) <= 30) { 138 + return $did; 139 + } 140 + 141 + return substr($did, 0, 15).'...'.substr($did, -12); 142 + } 143 + }
+90
src/Concerns/AutoPublish.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Concerns; 4 + 5 + use SocialDept\AtpParity\Publish\PublishService; 6 + 7 + /** 8 + * Trait for Eloquent models that automatically publish to AT Protocol. 9 + * 10 + * This trait sets up model observers to automatically publish, update, 11 + * and unpublish records when the model is created, updated, or deleted. 12 + * 13 + * Override shouldAutoPublish() and shouldAutoUnpublish() to customize 14 + * the conditions under which auto-publishing occurs. 15 + * 16 + * @mixin \Illuminate\Database\Eloquent\Model 17 + */ 18 + trait AutoPublish 19 + { 20 + use PublishesRecords; 21 + 22 + /** 23 + * Boot the AutoPublish trait. 24 + */ 25 + public static function bootAutoPublish(): void 26 + { 27 + static::created(function ($model) { 28 + if ($model->shouldAutoPublish()) { 29 + app(PublishService::class)->publish($model); 30 + } 31 + }); 32 + 33 + static::updated(function ($model) { 34 + if ($model->isPublished() && $model->shouldAutoPublish()) { 35 + app(PublishService::class)->update($model); 36 + } 37 + }); 38 + 39 + static::deleted(function ($model) { 40 + if ($model->isPublished() && $model->shouldAutoUnpublish()) { 41 + app(PublishService::class)->delete($model); 42 + } 43 + }); 44 + } 45 + 46 + /** 47 + * Determine if the model should be auto-published. 48 + * 49 + * Override this method to add custom conditions. 50 + */ 51 + public function shouldAutoPublish(): bool 52 + { 53 + return true; 54 + } 55 + 56 + /** 57 + * Determine if the model should be auto-unpublished when deleted. 58 + * 59 + * Override this method to add custom conditions. 60 + */ 61 + public function shouldAutoUnpublish(): bool 62 + { 63 + return true; 64 + } 65 + 66 + /** 67 + * Get the DID to use for auto-publishing. 68 + * 69 + * Override this method to customize DID resolution. 70 + */ 71 + public function getAutoPublishDid(): ?string 72 + { 73 + // Check for did column 74 + if (isset($this->did)) { 75 + return $this->did; 76 + } 77 + 78 + // Check for user relationship with did 79 + if (method_exists($this, 'user') && $this->user?->did) { 80 + return $this->user->did; 81 + } 82 + 83 + // Check for author relationship with did 84 + if (method_exists($this, 'author') && $this->author?->did) { 85 + return $this->author->did; 86 + } 87 + 88 + return null; 89 + } 90 + }
+152
src/Concerns/HasAtpRecord.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Concerns; 4 + 5 + use SocialDept\AtpParity\Contracts\RecordMapper; 6 + use SocialDept\AtpParity\MapperRegistry; 7 + use SocialDept\AtpSchema\Data\Data; 8 + 9 + /** 10 + * Trait for Eloquent models that map to AT Protocol records. 11 + * 12 + * @mixin \Illuminate\Database\Eloquent\Model 13 + */ 14 + trait HasAtpRecord 15 + { 16 + /** 17 + * Get the AT Protocol URI for this model. 18 + */ 19 + public function getAtpUri(): ?string 20 + { 21 + $column = config('parity.columns.uri', 'atp_uri'); 22 + 23 + return $this->getAttribute($column); 24 + } 25 + 26 + /** 27 + * Get the AT Protocol CID for this model. 28 + */ 29 + public function getAtpCid(): ?string 30 + { 31 + $column = config('parity.columns.cid', 'atp_cid'); 32 + 33 + return $this->getAttribute($column); 34 + } 35 + 36 + /** 37 + * Get the DID from the AT Protocol URI. 38 + */ 39 + public function getAtpDid(): ?string 40 + { 41 + $uri = $this->getAtpUri(); 42 + 43 + if (! $uri) { 44 + return null; 45 + } 46 + 47 + // at://did:plc:xxx/app.bsky.feed.post/rkey 48 + if (preg_match('#^at://([^/]+)/#', $uri, $matches)) { 49 + return $matches[1]; 50 + } 51 + 52 + return null; 53 + } 54 + 55 + /** 56 + * Get the collection (lexicon NSID) from the AT Protocol URI. 57 + */ 58 + public function getAtpCollection(): ?string 59 + { 60 + $uri = $this->getAtpUri(); 61 + 62 + if (! $uri) { 63 + return null; 64 + } 65 + 66 + // at://did:plc:xxx/app.bsky.feed.post/rkey 67 + if (preg_match('#^at://[^/]+/([^/]+)/#', $uri, $matches)) { 68 + return $matches[1]; 69 + } 70 + 71 + return null; 72 + } 73 + 74 + /** 75 + * Get the rkey from the AT Protocol URI. 76 + */ 77 + public function getAtpRkey(): ?string 78 + { 79 + $uri = $this->getAtpUri(); 80 + 81 + if (! $uri) { 82 + return null; 83 + } 84 + 85 + // at://did:plc:xxx/app.bsky.feed.post/rkey 86 + if (preg_match('#^at://[^/]+/[^/]+/([^/]+)$#', $uri, $matches)) { 87 + return $matches[1]; 88 + } 89 + 90 + return null; 91 + } 92 + 93 + /** 94 + * Check if this model has been synced to AT Protocol. 95 + */ 96 + public function hasAtpRecord(): bool 97 + { 98 + return $this->getAtpUri() !== null; 99 + } 100 + 101 + /** 102 + * Get the mapper for this model. 103 + */ 104 + public function getAtpMapper(): ?RecordMapper 105 + { 106 + return app(MapperRegistry::class)->forModel(static::class); 107 + } 108 + 109 + /** 110 + * Convert this model to an AT Protocol record DTO. 111 + */ 112 + public function toAtpRecord(): ?Data 113 + { 114 + $mapper = $this->getAtpMapper(); 115 + 116 + if (! $mapper) { 117 + return null; 118 + } 119 + 120 + return $mapper->toRecord($this); 121 + } 122 + 123 + /** 124 + * Scope to query models that have been synced to AT Protocol. 125 + */ 126 + public function scopeWithAtpRecord($query) 127 + { 128 + $column = config('parity.columns.uri', 'atp_uri'); 129 + 130 + return $query->whereNotNull($column); 131 + } 132 + 133 + /** 134 + * Scope to query models that have not been synced to AT Protocol. 135 + */ 136 + public function scopeWithoutAtpRecord($query) 137 + { 138 + $column = config('parity.columns.uri', 'atp_uri'); 139 + 140 + return $query->whereNull($column); 141 + } 142 + 143 + /** 144 + * Scope to find by AT Protocol URI. 145 + */ 146 + public function scopeWhereAtpUri($query, string $uri) 147 + { 148 + $column = config('parity.columns.uri', 'atp_uri'); 149 + 150 + return $query->where($column, $uri); 151 + } 152 + }
+127
src/Concerns/HasAtpRelationships.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Concerns; 4 + 5 + use Illuminate\Database\Eloquent\Relations\BelongsTo; 6 + use Illuminate\Database\Eloquent\Relations\HasMany; 7 + 8 + /** 9 + * Trait for Eloquent models with AT Protocol relationships. 10 + * 11 + * Provides helpers for defining relationships based on AT Protocol URI references. 12 + * Common relationship patterns: 13 + * 14 + * - reply.parent -> parent_uri column 15 + * - reply.root -> root_uri column 16 + * - embed.record (quote) -> quoted_uri column 17 + * - like.subject -> subject_uri column 18 + * - follow.subject -> subject_did column 19 + * - repost.subject -> subject_uri column 20 + * 21 + * @mixin \Illuminate\Database\Eloquent\Model 22 + */ 23 + trait HasAtpRelationships 24 + { 25 + /** 26 + * Define an AT Protocol relationship via URI reference. 27 + * 28 + * This creates a BelongsTo relationship where the foreign key is an AT Protocol URI 29 + * stored in the specified column, matched against the related model's atp_uri column. 30 + * 31 + * Example: 32 + * ```php 33 + * public function parent(): BelongsTo 34 + * { 35 + * return $this->atpBelongsTo(Post::class, 'parent_uri'); 36 + * } 37 + * ``` 38 + * 39 + * @param class-string<\Illuminate\Database\Eloquent\Model> $related 40 + */ 41 + public function atpBelongsTo(string $related, string $uriColumn, ?string $ownerKey = null): BelongsTo 42 + { 43 + $ownerKey = $ownerKey ?? config('parity.columns.uri', 'atp_uri'); 44 + 45 + // Create a custom BelongsTo that uses URI matching 46 + return $this->belongsTo($related, $uriColumn, $ownerKey); 47 + } 48 + 49 + /** 50 + * Define an inverse AT Protocol relationship via URI reference. 51 + * 52 + * This creates a HasMany relationship where related models have a column 53 + * containing this model's AT Protocol URI. 54 + * 55 + * Example: 56 + * ```php 57 + * public function replies(): HasMany 58 + * { 59 + * return $this->atpHasMany(Post::class, 'parent_uri'); 60 + * } 61 + * ``` 62 + * 63 + * @param class-string<\Illuminate\Database\Eloquent\Model> $related 64 + */ 65 + public function atpHasMany(string $related, string $foreignKey, ?string $localKey = null): HasMany 66 + { 67 + $localKey = $localKey ?? config('parity.columns.uri', 'atp_uri'); 68 + 69 + return $this->hasMany($related, $foreignKey, $localKey); 70 + } 71 + 72 + /** 73 + * Define an AT Protocol relationship via DID reference. 74 + * 75 + * This creates a BelongsTo relationship where the foreign key is a DID 76 + * stored in the specified column, matched against a did column on the related model. 77 + * 78 + * Example: 79 + * ```php 80 + * public function subject(): BelongsTo 81 + * { 82 + * return $this->atpBelongsToByDid(User::class, 'subject_did'); 83 + * } 84 + * ``` 85 + * 86 + * @param class-string<\Illuminate\Database\Eloquent\Model> $related 87 + */ 88 + public function atpBelongsToByDid(string $related, string $didColumn, string $ownerKey = 'did'): BelongsTo 89 + { 90 + return $this->belongsTo($related, $didColumn, $ownerKey); 91 + } 92 + 93 + /** 94 + * Define an inverse AT Protocol relationship via DID reference. 95 + * 96 + * Example: 97 + * ```php 98 + * public function followers(): HasMany 99 + * { 100 + * return $this->atpHasManyByDid(Follow::class, 'subject_did'); 101 + * } 102 + * ``` 103 + * 104 + * @param class-string<\Illuminate\Database\Eloquent\Model> $related 105 + */ 106 + public function atpHasManyByDid(string $related, string $foreignKey, string $localKey = 'did'): HasMany 107 + { 108 + return $this->hasMany($related, $foreignKey, $localKey); 109 + } 110 + 111 + /** 112 + * Get a related model by AT Protocol URI. 113 + * 114 + * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass 115 + * @return \Illuminate\Database\Eloquent\Model|null 116 + */ 117 + public function findByAtpUri(string $modelClass, ?string $uri) 118 + { 119 + if (! $uri) { 120 + return null; 121 + } 122 + 123 + $column = config('parity.columns.uri', 'atp_uri'); 124 + 125 + return $modelClass::where($column, $uri)->first(); 126 + } 127 + }
+59
src/Concerns/PublishesRecords.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Concerns; 4 + 5 + use SocialDept\AtpParity\Publish\PublishResult; 6 + use SocialDept\AtpParity\Publish\PublishService; 7 + 8 + /** 9 + * Trait for Eloquent models that can be manually published to AT Protocol. 10 + * 11 + * @mixin \Illuminate\Database\Eloquent\Model 12 + */ 13 + trait PublishesRecords 14 + { 15 + use HasAtpRecord; 16 + 17 + /** 18 + * Publish this model to AT Protocol. 19 + * 20 + * If the model has a DID association (via did column or relationship), 21 + * it will be used. Otherwise, use publishAs() to specify the DID. 22 + */ 23 + public function publish(): PublishResult 24 + { 25 + return app(PublishService::class)->publish($this); 26 + } 27 + 28 + /** 29 + * Publish this model as a specific user. 30 + */ 31 + public function publishAs(string $did): PublishResult 32 + { 33 + return app(PublishService::class)->publishAs($did, $this); 34 + } 35 + 36 + /** 37 + * Update the published record on AT Protocol. 38 + */ 39 + public function republish(): PublishResult 40 + { 41 + return app(PublishService::class)->update($this); 42 + } 43 + 44 + /** 45 + * Delete the record from AT Protocol. 46 + */ 47 + public function unpublish(): bool 48 + { 49 + return app(PublishService::class)->delete($this); 50 + } 51 + 52 + /** 53 + * Check if this model has been published to AT Protocol. 54 + */ 55 + public function isPublished(): bool 56 + { 57 + return $this->hasAtpRecord(); 58 + } 59 + }
+96
src/Concerns/SyncsWithAtp.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Concerns; 4 + 5 + use SocialDept\AtpSchema\Data\Data; 6 + 7 + /** 8 + * Trait for models that sync bidirectionally with AT Protocol. 9 + * 10 + * Extends HasAtpRecord with additional sync tracking and conflict handling. 11 + * 12 + * @mixin \Illuminate\Database\Eloquent\Model 13 + */ 14 + trait SyncsWithAtp 15 + { 16 + use HasAtpRecord; 17 + 18 + /** 19 + * Get the column name for tracking the last sync timestamp. 20 + */ 21 + public function getAtpSyncedAtColumn(): string 22 + { 23 + return 'atp_synced_at'; 24 + } 25 + 26 + /** 27 + * Get the timestamp of the last sync. 28 + */ 29 + public function getAtpSyncedAt(): ?\DateTimeInterface 30 + { 31 + $column = $this->getAtpSyncedAtColumn(); 32 + 33 + return $this->getAttribute($column); 34 + } 35 + 36 + /** 37 + * Mark the model as synced with the given metadata. 38 + */ 39 + public function markAsSynced(string $uri, string $cid): void 40 + { 41 + $uriColumn = config('parity.columns.uri', 'atp_uri'); 42 + $cidColumn = config('parity.columns.cid', 'atp_cid'); 43 + $syncColumn = $this->getAtpSyncedAtColumn(); 44 + 45 + $this->setAttribute($uriColumn, $uri); 46 + $this->setAttribute($cidColumn, $cid); 47 + $this->setAttribute($syncColumn, now()); 48 + } 49 + 50 + /** 51 + * Check if the model has local changes since last sync. 52 + */ 53 + public function hasLocalChanges(): bool 54 + { 55 + $syncedAt = $this->getAtpSyncedAt(); 56 + 57 + if (! $syncedAt) { 58 + return true; 59 + } 60 + 61 + $updatedAt = $this->getAttribute('updated_at'); 62 + 63 + if (! $updatedAt) { 64 + return false; 65 + } 66 + 67 + return $updatedAt > $syncedAt; 68 + } 69 + 70 + /** 71 + * Update the model from a remote record. 72 + */ 73 + public function updateFromRecord(Data $record, string $uri, string $cid): void 74 + { 75 + $mapper = $this->getAtpMapper(); 76 + 77 + if (! $mapper) { 78 + return; 79 + } 80 + 81 + $mapper->updateModel($this, $record, [ 82 + 'uri' => $uri, 83 + 'cid' => $cid, 84 + ]); 85 + 86 + $this->setAttribute($this->getAtpSyncedAtColumn(), now()); 87 + } 88 + 89 + /** 90 + * Boot the trait. 91 + */ 92 + public static function bootSyncsWithAtp(): void 93 + { 94 + // Hook into model events if needed 95 + } 96 + }
+82
src/Contracts/RecordMapper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Contracts; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use SocialDept\AtpSchema\Data\Data; 7 + 8 + /** 9 + * Contract for bidirectional mapping between Record DTOs and Eloquent models. 10 + * 11 + * @template TRecord of Data 12 + * @template TModel of Model 13 + */ 14 + interface RecordMapper 15 + { 16 + /** 17 + * Get the Record class this mapper handles. 18 + * 19 + * @return class-string<TRecord> 20 + */ 21 + public function recordClass(): string; 22 + 23 + /** 24 + * Get the Model class this mapper handles. 25 + * 26 + * @return class-string<TModel> 27 + */ 28 + public function modelClass(): string; 29 + 30 + /** 31 + * Get the lexicon NSID this mapper handles. 32 + */ 33 + public function lexicon(): string; 34 + 35 + /** 36 + * Convert a Record DTO to an Eloquent Model. 37 + * 38 + * @param TRecord $record 39 + * @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta AT Protocol metadata 40 + * @return TModel 41 + */ 42 + public function toModel(Data $record, array $meta = []): Model; 43 + 44 + /** 45 + * Convert an Eloquent Model to a Record DTO. 46 + * 47 + * @param TModel $model 48 + * @return TRecord 49 + */ 50 + public function toRecord(Model $model): Data; 51 + 52 + /** 53 + * Update an existing model with data from a record. 54 + * 55 + * @param TModel $model 56 + * @param TRecord $record 57 + * @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta 58 + * @return TModel 59 + */ 60 + public function updateModel(Model $model, Data $record, array $meta = []): Model; 61 + 62 + /** 63 + * Find or create model from record. 64 + * 65 + * @param TRecord $record 66 + * @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta 67 + * @return TModel 68 + */ 69 + public function upsert(Data $record, array $meta = []): Model; 70 + 71 + /** 72 + * Find model by AT Protocol URI. 73 + * 74 + * @return TModel|null 75 + */ 76 + public function findByUri(string $uri): ?Model; 77 + 78 + /** 79 + * Delete model by AT Protocol URI. 80 + */ 81 + public function deleteByUri(string $uri): bool; 82 + }
+25
src/Data/Record.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Data; 4 + 5 + use SocialDept\AtpClient\Contracts\Recordable; 6 + use SocialDept\AtpSchema\Data\Data; 7 + 8 + /** 9 + * Base class for custom AT Protocol records. 10 + * 11 + * Extends atp-schema's Data for full compatibility with the ecosystem, 12 + * including union type support, validation, equality, and hashing. 13 + * 14 + * Implements Recordable for seamless atp-client integration. 15 + */ 16 + abstract class Record extends Data implements Recordable 17 + { 18 + /** 19 + * Get the record type (alias for getLexicon for Recordable interface). 20 + */ 21 + public function getType(): string 22 + { 23 + return static::getLexicon(); 24 + } 25 + }
+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 + }
+23
src/Events/ConflictDetected.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Events; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use Illuminate\Foundation\Events\Dispatchable; 7 + use SocialDept\AtpParity\Sync\PendingConflict; 8 + use SocialDept\AtpSchema\Data\Data; 9 + 10 + /** 11 + * Dispatched when a conflict is detected that requires manual resolution. 12 + */ 13 + class ConflictDetected 14 + { 15 + use Dispatchable; 16 + 17 + public function __construct( 18 + public readonly Model $model, 19 + public readonly Data $record, 20 + public readonly array $meta, 21 + public readonly PendingConflict $conflict, 22 + ) {} 23 + }
+15
src/Events/ImportCompleted.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Events; 4 + 5 + use Illuminate\Foundation\Events\Dispatchable; 6 + use SocialDept\AtpParity\Import\ImportResult; 7 + 8 + class ImportCompleted 9 + { 10 + use Dispatchable; 11 + 12 + public function __construct( 13 + public readonly ImportResult $result, 14 + ) {} 15 + }
+16
src/Events/ImportFailed.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Events; 4 + 5 + use Illuminate\Foundation\Events\Dispatchable; 6 + 7 + class ImportFailed 8 + { 9 + use Dispatchable; 10 + 11 + public function __construct( 12 + public readonly string $did, 13 + public readonly string $collection, 14 + public readonly string $error, 15 + ) {} 16 + }
+17
src/Events/ImportProgress.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Events; 4 + 5 + use Illuminate\Foundation\Events\Dispatchable; 6 + 7 + class ImportProgress 8 + { 9 + use Dispatchable; 10 + 11 + public function __construct( 12 + public readonly string $did, 13 + public readonly string $collection, 14 + public readonly int $recordsSynced, 15 + public readonly ?string $cursor = null, 16 + ) {} 17 + }
+15
src/Events/ImportStarted.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Events; 4 + 5 + use Illuminate\Foundation\Events\Dispatchable; 6 + 7 + class ImportStarted 8 + { 9 + use Dispatchable; 10 + 11 + public function __construct( 12 + public readonly string $did, 13 + public readonly string $collection, 14 + ) {} 15 + }
+20
src/Events/RecordPublished.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Events; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use Illuminate\Foundation\Events\Dispatchable; 7 + 8 + /** 9 + * Dispatched when a model is published to AT Protocol. 10 + */ 11 + class RecordPublished 12 + { 13 + use Dispatchable; 14 + 15 + public function __construct( 16 + public readonly Model $model, 17 + public readonly string $uri, 18 + public readonly string $cid, 19 + ) {} 20 + }
+19
src/Events/RecordUnpublished.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Events; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use Illuminate\Foundation\Events\Dispatchable; 7 + 8 + /** 9 + * Dispatched when a model is unpublished from AT Protocol. 10 + */ 11 + class RecordUnpublished 12 + { 13 + use Dispatchable; 14 + 15 + public function __construct( 16 + public readonly Model $model, 17 + public readonly string $uri, 18 + ) {} 19 + }
+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 + }
-18
src/Facades/AtpReplicator.php
··· 1 - <?php 2 - 3 - namespace SocialDept\AtpReplicator\Facades; 4 - 5 - use Illuminate\Support\Facades\Facade; 6 - 7 - class AtpReplicator extends Facade 8 - { 9 - /** 10 - * Get the registered name of the component. 11 - * 12 - * @return string 13 - */ 14 - protected static function getFacadeAccessor(): string 15 - { 16 - return 'atp-replicator'; 17 - } 18 - }
+23
src/Facades/Parity.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Facades; 4 + 5 + use Illuminate\Support\Facades\Facade; 6 + use SocialDept\AtpParity\Contracts\RecordMapper; 7 + use SocialDept\AtpParity\MapperRegistry; 8 + 9 + /** 10 + * @method static void register(RecordMapper $mapper) 11 + * @method static RecordMapper|null forRecord(string $recordClass) 12 + * @method static RecordMapper|null forModel(string $modelClass) 13 + * @method static RecordMapper|null forLexicon(string $nsid) 14 + * 15 + * @see MapperRegistry 16 + */ 17 + class Parity extends Facade 18 + { 19 + protected static function getFacadeAccessor(): string 20 + { 21 + return 'parity'; 22 + } 23 + }
+138
src/Import/ImportResult.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Import; 4 + 5 + /** 6 + * Immutable value object representing the result of an import operation. 7 + */ 8 + readonly class ImportResult 9 + { 10 + public function __construct( 11 + public string $did, 12 + public string $collection, 13 + public int $recordsSynced, 14 + public int $recordsSkipped, 15 + public int $recordsFailed, 16 + public bool $completed, 17 + public ?string $cursor = null, 18 + public ?string $error = null, 19 + ) {} 20 + 21 + /** 22 + * Check if the import completed successfully. 23 + */ 24 + public function isSuccess(): bool 25 + { 26 + return $this->completed && $this->error === null; 27 + } 28 + 29 + /** 30 + * Check if the import was partially completed. 31 + */ 32 + public function isPartial(): bool 33 + { 34 + return ! $this->completed && $this->recordsSynced > 0; 35 + } 36 + 37 + /** 38 + * Check if the import failed. 39 + */ 40 + public function isFailed(): bool 41 + { 42 + return $this->error !== null; 43 + } 44 + 45 + /** 46 + * Get total records processed. 47 + */ 48 + public function totalProcessed(): int 49 + { 50 + return $this->recordsSynced + $this->recordsSkipped + $this->recordsFailed; 51 + } 52 + 53 + /** 54 + * Create a successful result. 55 + */ 56 + public static function success(string $did, string $collection, int $synced, int $skipped = 0, int $failed = 0): self 57 + { 58 + return new self( 59 + did: $did, 60 + collection: $collection, 61 + recordsSynced: $synced, 62 + recordsSkipped: $skipped, 63 + recordsFailed: $failed, 64 + completed: true, 65 + ); 66 + } 67 + 68 + /** 69 + * Create a partial result (incomplete). 70 + */ 71 + public static function partial(string $did, string $collection, int $synced, string $cursor, int $skipped = 0, int $failed = 0): self 72 + { 73 + return new self( 74 + did: $did, 75 + collection: $collection, 76 + recordsSynced: $synced, 77 + recordsSkipped: $skipped, 78 + recordsFailed: $failed, 79 + completed: false, 80 + cursor: $cursor, 81 + ); 82 + } 83 + 84 + /** 85 + * Create a failed result. 86 + */ 87 + public static function failed(string $did, string $collection, string $error, int $synced = 0, int $skipped = 0, int $failed = 0, ?string $cursor = null): self 88 + { 89 + return new self( 90 + did: $did, 91 + collection: $collection, 92 + recordsSynced: $synced, 93 + recordsSkipped: $skipped, 94 + recordsFailed: $failed, 95 + completed: false, 96 + cursor: $cursor, 97 + error: $error, 98 + ); 99 + } 100 + 101 + /** 102 + * Merge multiple results for the same DID into one aggregate result. 103 + * 104 + * @param ImportResult[] $results 105 + */ 106 + public static function aggregate(string $did, array $results): self 107 + { 108 + $synced = 0; 109 + $skipped = 0; 110 + $failed = 0; 111 + $errors = []; 112 + $allCompleted = true; 113 + 114 + foreach ($results as $result) { 115 + $synced += $result->recordsSynced; 116 + $skipped += $result->recordsSkipped; 117 + $failed += $result->recordsFailed; 118 + 119 + if (! $result->completed) { 120 + $allCompleted = false; 121 + } 122 + 123 + if ($result->error) { 124 + $errors[] = "{$result->collection}: {$result->error}"; 125 + } 126 + } 127 + 128 + return new self( 129 + did: $did, 130 + collection: '*', 131 + recordsSynced: $synced, 132 + recordsSkipped: $skipped, 133 + recordsFailed: $failed, 134 + completed: $allCompleted, 135 + error: $errors ? implode('; ', $errors) : null, 136 + ); 137 + } 138 + }
+253
src/Import/ImportService.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Import; 4 + 5 + use SocialDept\AtpClient\AtpClient; 6 + use SocialDept\AtpClient\Facades\Atp; 7 + use SocialDept\AtpParity\Events\ImportCompleted; 8 + use SocialDept\AtpParity\Events\ImportFailed; 9 + use SocialDept\AtpParity\Events\ImportProgress; 10 + use SocialDept\AtpParity\Events\ImportStarted; 11 + use SocialDept\AtpParity\MapperRegistry; 12 + use SocialDept\AtpResolver\Facades\Resolver; 13 + use Throwable; 14 + 15 + /** 16 + * Orchestrates importing of AT Protocol records to Eloquent models. 17 + * 18 + * Supports importing individual users, specific collections, or entire 19 + * networks through cursor-based pagination with progress tracking. 20 + */ 21 + class ImportService 22 + { 23 + /** 24 + * Cache of clients by PDS endpoint. 25 + * 26 + * @var array<string, AtpClient> 27 + */ 28 + protected array $clients = []; 29 + 30 + public function __construct( 31 + protected MapperRegistry $registry 32 + ) {} 33 + 34 + /** 35 + * Import all records for a user in registered collections. 36 + * 37 + * @param array<string>|null $collections Specific collections to import, or null for all registered 38 + */ 39 + public function importUser(string $did, ?array $collections = null, ?callable $onProgress = null): ImportResult 40 + { 41 + $collections = $collections ?? $this->registry->lexicons(); 42 + $results = []; 43 + 44 + foreach ($collections as $collection) { 45 + if (! $this->registry->hasLexicon($collection)) { 46 + continue; 47 + } 48 + 49 + $results[] = $this->importUserCollection($did, $collection, $onProgress); 50 + } 51 + 52 + return ImportResult::aggregate($did, $results); 53 + } 54 + 55 + /** 56 + * Import a specific collection for a user. 57 + */ 58 + public function importUserCollection(string $did, string $collection, ?callable $onProgress = null): ImportResult 59 + { 60 + $mapper = $this->registry->forLexicon($collection); 61 + 62 + if (! $mapper) { 63 + return ImportResult::failed($did, $collection, "No mapper registered for collection: {$collection}"); 64 + } 65 + 66 + $state = ImportState::findOrCreateFor($did, $collection); 67 + 68 + if ($state->isComplete()) { 69 + return $state->toResult(); 70 + } 71 + 72 + $pdsEndpoint = $this->resolvePds($did); 73 + 74 + if (! $pdsEndpoint) { 75 + $error = "Could not resolve PDS endpoint for DID: {$did}"; 76 + $state->markFailed($error); 77 + event(new ImportFailed($did, $collection, $error)); 78 + 79 + return ImportResult::failed($did, $collection, $error); 80 + } 81 + 82 + $state->markStarted(); 83 + event(new ImportStarted($did, $collection)); 84 + 85 + $client = $this->clientFor($pdsEndpoint); 86 + $cursor = $state->cursor; 87 + $pageSize = config('parity.import.page_size', 100); 88 + $pageDelay = config('parity.import.page_delay', 100); 89 + $recordClass = $mapper->recordClass(); 90 + 91 + try { 92 + do { 93 + $response = $client->atproto->repo->listRecords( 94 + repo: $did, 95 + collection: $collection, 96 + limit: $pageSize, 97 + cursor: $cursor 98 + ); 99 + 100 + $synced = 0; 101 + $skipped = 0; 102 + $failed = 0; 103 + 104 + foreach ($response->records as $item) { 105 + try { 106 + $record = $recordClass::fromArray($item['value']); 107 + 108 + $mapper->upsert($record, [ 109 + 'uri' => $item['uri'], 110 + 'cid' => $item['cid'], 111 + ]); 112 + 113 + $synced++; 114 + } catch (Throwable $e) { 115 + $failed++; 116 + } 117 + } 118 + 119 + $cursor = $response->cursor; 120 + $state->updateProgress($synced, $skipped, $failed, $cursor); 121 + 122 + if ($onProgress) { 123 + $onProgress(new ImportProgress( 124 + did: $did, 125 + collection: $collection, 126 + recordsSynced: $state->records_synced, 127 + cursor: $cursor 128 + )); 129 + } 130 + 131 + event(new ImportProgress($did, $collection, $state->records_synced, $cursor)); 132 + 133 + if ($cursor && $pageDelay > 0) { 134 + usleep($pageDelay * 1000); 135 + } 136 + } while ($cursor); 137 + 138 + $state->markCompleted(); 139 + $result = $state->toResult(); 140 + event(new ImportCompleted($result)); 141 + 142 + return $result; 143 + } catch (Throwable $e) { 144 + $error = $e->getMessage(); 145 + $state->markFailed($error); 146 + event(new ImportFailed($did, $collection, $error)); 147 + 148 + return ImportResult::failed( 149 + did: $did, 150 + collection: $collection, 151 + error: $error, 152 + synced: $state->records_synced, 153 + skipped: $state->records_skipped, 154 + failed: $state->records_failed, 155 + cursor: $state->cursor 156 + ); 157 + } 158 + } 159 + 160 + /** 161 + * Resume an interrupted import from cursor. 162 + */ 163 + public function resume(ImportState $state, ?callable $onProgress = null): ImportResult 164 + { 165 + if (! $state->canResume()) { 166 + return $state->toResult(); 167 + } 168 + 169 + $state->update(['status' => ImportState::STATUS_PENDING]); 170 + 171 + return $this->importUserCollection($state->did, $state->collection, $onProgress); 172 + } 173 + 174 + /** 175 + * Resume all interrupted imports. 176 + * 177 + * @return array<ImportResult> 178 + */ 179 + public function resumeAll(?callable $onProgress = null): array 180 + { 181 + $results = []; 182 + 183 + ImportState::resumable()->each(function (ImportState $state) use (&$results, $onProgress) { 184 + $results[] = $this->resume($state, $onProgress); 185 + }); 186 + 187 + return $results; 188 + } 189 + 190 + /** 191 + * Get import status for a DID/collection. 192 + */ 193 + public function getStatus(string $did, string $collection): ?ImportState 194 + { 195 + return ImportState::where('did', $did) 196 + ->where('collection', $collection) 197 + ->first(); 198 + } 199 + 200 + /** 201 + * Get all import states for a DID. 202 + * 203 + * @return \Illuminate\Database\Eloquent\Collection<int, ImportState> 204 + */ 205 + public function getStatusForUser(string $did): \Illuminate\Database\Eloquent\Collection 206 + { 207 + return ImportState::where('did', $did)->get(); 208 + } 209 + 210 + /** 211 + * Check if a user's collection has been imported. 212 + */ 213 + public function isImported(string $did, string $collection): bool 214 + { 215 + $state = $this->getStatus($did, $collection); 216 + 217 + return $state && $state->isComplete(); 218 + } 219 + 220 + /** 221 + * Reset an import state to allow re-importing. 222 + */ 223 + public function reset(string $did, string $collection): void 224 + { 225 + ImportState::where('did', $did) 226 + ->where('collection', $collection) 227 + ->delete(); 228 + } 229 + 230 + /** 231 + * Reset all import states for a user. 232 + */ 233 + public function resetUser(string $did): void 234 + { 235 + ImportState::where('did', $did)->delete(); 236 + } 237 + 238 + /** 239 + * Get or create a client for a PDS endpoint. 240 + */ 241 + protected function clientFor(string $pdsEndpoint): AtpClient 242 + { 243 + return $this->clients[$pdsEndpoint] ??= Atp::public($pdsEndpoint); 244 + } 245 + 246 + /** 247 + * Resolve the PDS endpoint for a DID. 248 + */ 249 + protected function resolvePds(string $did): ?string 250 + { 251 + return Resolver::resolvePds($did); 252 + } 253 + }
+231
src/Import/ImportState.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Import; 4 + 5 + use Illuminate\Database\Eloquent\Builder; 6 + use Illuminate\Database\Eloquent\Model; 7 + 8 + /** 9 + * Tracks import progress for a DID/collection pair. 10 + * 11 + * @property int $id 12 + * @property string $did 13 + * @property string $collection 14 + * @property string $status 15 + * @property string|null $cursor 16 + * @property int $records_synced 17 + * @property int $records_skipped 18 + * @property int $records_failed 19 + * @property \Carbon\Carbon|null $started_at 20 + * @property \Carbon\Carbon|null $completed_at 21 + * @property string|null $error 22 + * @property \Carbon\Carbon $created_at 23 + * @property \Carbon\Carbon $updated_at 24 + */ 25 + class ImportState extends Model 26 + { 27 + public const STATUS_PENDING = 'pending'; 28 + 29 + public const STATUS_IN_PROGRESS = 'in_progress'; 30 + 31 + public const STATUS_COMPLETED = 'completed'; 32 + 33 + public const STATUS_FAILED = 'failed'; 34 + 35 + protected $fillable = [ 36 + 'did', 37 + 'collection', 38 + 'status', 39 + 'cursor', 40 + 'records_synced', 41 + 'records_skipped', 42 + 'records_failed', 43 + 'started_at', 44 + 'completed_at', 45 + 'error', 46 + ]; 47 + 48 + protected $casts = [ 49 + 'records_synced' => 'integer', 50 + 'records_skipped' => 'integer', 51 + 'records_failed' => 'integer', 52 + 'started_at' => 'datetime', 53 + 'completed_at' => 'datetime', 54 + ]; 55 + 56 + public function getTable(): string 57 + { 58 + return config('parity.import.state_table', 'parity_import_states'); 59 + } 60 + 61 + /** 62 + * Start the import process for this state. 63 + */ 64 + public function markStarted(): self 65 + { 66 + $this->update([ 67 + 'status' => self::STATUS_IN_PROGRESS, 68 + 'started_at' => now(), 69 + 'error' => null, 70 + ]); 71 + 72 + return $this; 73 + } 74 + 75 + /** 76 + * Mark the import as completed. 77 + */ 78 + public function markCompleted(): self 79 + { 80 + $this->update([ 81 + 'status' => self::STATUS_COMPLETED, 82 + 'completed_at' => now(), 83 + 'cursor' => null, 84 + ]); 85 + 86 + return $this; 87 + } 88 + 89 + /** 90 + * Mark the import as failed. 91 + */ 92 + public function markFailed(string $error): self 93 + { 94 + $this->update([ 95 + 'status' => self::STATUS_FAILED, 96 + 'error' => $error, 97 + ]); 98 + 99 + return $this; 100 + } 101 + 102 + /** 103 + * Update progress during import. 104 + */ 105 + public function updateProgress(int $synced, int $skipped = 0, int $failed = 0, ?string $cursor = null): self 106 + { 107 + $this->increment('records_synced', $synced); 108 + 109 + if ($skipped > 0) { 110 + $this->increment('records_skipped', $skipped); 111 + } 112 + 113 + if ($failed > 0) { 114 + $this->increment('records_failed', $failed); 115 + } 116 + 117 + if ($cursor !== null) { 118 + $this->update(['cursor' => $cursor]); 119 + } 120 + 121 + return $this; 122 + } 123 + 124 + /** 125 + * Check if this import can be resumed. 126 + */ 127 + public function canResume(): bool 128 + { 129 + return $this->status === self::STATUS_IN_PROGRESS 130 + || $this->status === self::STATUS_FAILED; 131 + } 132 + 133 + /** 134 + * Check if this import is complete. 135 + */ 136 + public function isComplete(): bool 137 + { 138 + return $this->status === self::STATUS_COMPLETED; 139 + } 140 + 141 + /** 142 + * Check if this import is currently running. 143 + */ 144 + public function isRunning(): bool 145 + { 146 + return $this->status === self::STATUS_IN_PROGRESS; 147 + } 148 + 149 + /** 150 + * Scope to pending imports. 151 + */ 152 + public function scopePending(Builder $query): Builder 153 + { 154 + return $query->where('status', self::STATUS_PENDING); 155 + } 156 + 157 + /** 158 + * Scope to in-progress imports. 159 + */ 160 + public function scopeInProgress(Builder $query): Builder 161 + { 162 + return $query->where('status', self::STATUS_IN_PROGRESS); 163 + } 164 + 165 + /** 166 + * Scope to completed imports. 167 + */ 168 + public function scopeCompleted(Builder $query): Builder 169 + { 170 + return $query->where('status', self::STATUS_COMPLETED); 171 + } 172 + 173 + /** 174 + * Scope to failed imports. 175 + */ 176 + public function scopeFailed(Builder $query): Builder 177 + { 178 + return $query->where('status', self::STATUS_FAILED); 179 + } 180 + 181 + /** 182 + * Scope to incomplete imports (pending, in_progress, or failed). 183 + */ 184 + public function scopeIncomplete(Builder $query): Builder 185 + { 186 + return $query->whereIn('status', [ 187 + self::STATUS_PENDING, 188 + self::STATUS_IN_PROGRESS, 189 + self::STATUS_FAILED, 190 + ]); 191 + } 192 + 193 + /** 194 + * Scope to resumable imports (in_progress or failed with cursor). 195 + */ 196 + public function scopeResumable(Builder $query): Builder 197 + { 198 + return $query->whereIn('status', [ 199 + self::STATUS_IN_PROGRESS, 200 + self::STATUS_FAILED, 201 + ]); 202 + } 203 + 204 + /** 205 + * Find or create an import state for a DID/collection pair. 206 + */ 207 + public static function findOrCreateFor(string $did, string $collection): self 208 + { 209 + return static::firstOrCreate( 210 + ['did' => $did, 'collection' => $collection], 211 + ['status' => self::STATUS_PENDING] 212 + ); 213 + } 214 + 215 + /** 216 + * Convert to ImportResult. 217 + */ 218 + public function toResult(): ImportResult 219 + { 220 + return new ImportResult( 221 + did: $this->did, 222 + collection: $this->collection, 223 + recordsSynced: $this->records_synced, 224 + recordsSkipped: $this->records_skipped, 225 + recordsFailed: $this->records_failed, 226 + completed: $this->isComplete(), 227 + cursor: $this->cursor, 228 + error: $this->error, 229 + ); 230 + } 231 + }
+57
src/Jobs/ImportUserJob.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Jobs; 4 + 5 + use Illuminate\Bus\Queueable; 6 + use Illuminate\Contracts\Queue\ShouldQueue; 7 + use Illuminate\Foundation\Bus\Dispatchable; 8 + use Illuminate\Queue\InteractsWithQueue; 9 + use Illuminate\Queue\SerializesModels; 10 + use SocialDept\AtpParity\Import\ImportService; 11 + 12 + class ImportUserJob implements ShouldQueue 13 + { 14 + use Dispatchable; 15 + use InteractsWithQueue; 16 + use Queueable; 17 + use SerializesModels; 18 + 19 + /** 20 + * The number of times the job may be attempted. 21 + */ 22 + public int $tries = 3; 23 + 24 + /** 25 + * The number of seconds to wait before retrying. 26 + */ 27 + public int $backoff = 60; 28 + 29 + public function __construct( 30 + public string $did, 31 + public ?string $collection = null, 32 + ) { 33 + $this->onQueue(config('parity.import.queue', 'default')); 34 + } 35 + 36 + public function handle(ImportService $service): void 37 + { 38 + $collections = $this->collection ? [$this->collection] : null; 39 + $service->importUser($this->did, $collections); 40 + } 41 + 42 + /** 43 + * Get the tags that should be assigned to the job. 44 + * 45 + * @return array<string> 46 + */ 47 + public function tags(): array 48 + { 49 + $tags = ['parity-import', "did:{$this->did}"]; 50 + 51 + if ($this->collection) { 52 + $tags[] = "collection:{$this->collection}"; 53 + } 54 + 55 + return $tags; 56 + } 57 + }
+93
src/MapperRegistry.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use SocialDept\AtpParity\Contracts\RecordMapper; 7 + use SocialDept\AtpSchema\Data\Data; 8 + 9 + /** 10 + * Registry for RecordMapper instances. 11 + * 12 + * Allows looking up mappers by Record class, Model class, or lexicon NSID. 13 + */ 14 + class MapperRegistry 15 + { 16 + /** @var array<class-string<Data>, RecordMapper> */ 17 + protected array $byRecord = []; 18 + 19 + /** @var array<class-string<Model>, RecordMapper> */ 20 + protected array $byModel = []; 21 + 22 + /** @var array<string, RecordMapper> Keyed by NSID */ 23 + protected array $byLexicon = []; 24 + 25 + /** 26 + * Register a mapper. 27 + */ 28 + public function register(RecordMapper $mapper): void 29 + { 30 + $recordClass = $mapper->recordClass(); 31 + $modelClass = $mapper->modelClass(); 32 + 33 + $this->byRecord[$recordClass] = $mapper; 34 + $this->byModel[$modelClass] = $mapper; 35 + $this->byLexicon[$mapper->lexicon()] = $mapper; 36 + } 37 + 38 + /** 39 + * Get a mapper by Record class. 40 + * 41 + * @param class-string<Data> $recordClass 42 + */ 43 + public function forRecord(string $recordClass): ?RecordMapper 44 + { 45 + return $this->byRecord[$recordClass] ?? null; 46 + } 47 + 48 + /** 49 + * Get a mapper by Model class. 50 + * 51 + * @param class-string<Model> $modelClass 52 + */ 53 + public function forModel(string $modelClass): ?RecordMapper 54 + { 55 + return $this->byModel[$modelClass] ?? null; 56 + } 57 + 58 + /** 59 + * Get a mapper by lexicon NSID. 60 + */ 61 + public function forLexicon(string $nsid): ?RecordMapper 62 + { 63 + return $this->byLexicon[$nsid] ?? null; 64 + } 65 + 66 + /** 67 + * Check if a mapper exists for the given lexicon. 68 + */ 69 + public function hasLexicon(string $nsid): bool 70 + { 71 + return isset($this->byLexicon[$nsid]); 72 + } 73 + 74 + /** 75 + * Get all registered lexicon NSIDs. 76 + * 77 + * @return array<string> 78 + */ 79 + public function lexicons(): array 80 + { 81 + return array_keys($this->byLexicon); 82 + } 83 + 84 + /** 85 + * Get all registered mappers. 86 + * 87 + * @return array<RecordMapper> 88 + */ 89 + public function all(): array 90 + { 91 + return array_values($this->byLexicon); 92 + } 93 + }
+102
src/ParityServiceProvider.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity; 4 + 5 + use Illuminate\Support\ServiceProvider; 6 + use SocialDept\AtpParity\Commands\DiscoverCommand; 7 + use SocialDept\AtpParity\Commands\ExportCommand; 8 + use SocialDept\AtpParity\Commands\ImportCommand; 9 + use SocialDept\AtpParity\Commands\ImportStatusCommand; 10 + use SocialDept\AtpParity\Discovery\DiscoveryService; 11 + use SocialDept\AtpParity\Export\ExportService; 12 + use SocialDept\AtpParity\Import\ImportService; 13 + use SocialDept\AtpParity\Publish\PublishService; 14 + use SocialDept\AtpParity\Support\RecordHelper; 15 + 16 + class ParityServiceProvider extends ServiceProvider 17 + { 18 + public function boot(): void 19 + { 20 + $this->registerConfiguredMappers(); 21 + 22 + if ($this->app->runningInConsole()) { 23 + $this->bootForConsole(); 24 + } 25 + } 26 + 27 + public function register(): void 28 + { 29 + $this->mergeConfigFrom(__DIR__.'/../config/parity.php', 'parity'); 30 + 31 + $this->app->singleton(MapperRegistry::class); 32 + $this->app->alias(MapperRegistry::class, 'parity'); 33 + 34 + $this->app->singleton(RecordHelper::class, function ($app) { 35 + return new RecordHelper($app->make(MapperRegistry::class)); 36 + }); 37 + 38 + $this->app->singleton(ImportService::class, function ($app) { 39 + return new ImportService($app->make(MapperRegistry::class)); 40 + }); 41 + 42 + $this->app->singleton(PublishService::class, function ($app) { 43 + return new PublishService($app->make(MapperRegistry::class)); 44 + }); 45 + 46 + $this->app->singleton(DiscoveryService::class, function ($app) { 47 + return new DiscoveryService($app->make(ImportService::class)); 48 + }); 49 + 50 + $this->app->singleton(ExportService::class, function ($app) { 51 + return new ExportService( 52 + $app->make(MapperRegistry::class), 53 + $app->make(ImportService::class) 54 + ); 55 + }); 56 + } 57 + 58 + /** 59 + * Register mappers defined in config. 60 + */ 61 + protected function registerConfiguredMappers(): void 62 + { 63 + $registry = $this->app->make(MapperRegistry::class); 64 + 65 + foreach (config('parity.mappers', []) as $mapperClass) { 66 + if (class_exists($mapperClass)) { 67 + $registry->register($this->app->make($mapperClass)); 68 + } 69 + } 70 + } 71 + 72 + protected function bootForConsole(): void 73 + { 74 + $this->publishes([ 75 + __DIR__.'/../config/parity.php' => config_path('parity.php'), 76 + ], 'parity-config'); 77 + 78 + $this->publishes([ 79 + __DIR__.'/../database/migrations' => database_path('migrations'), 80 + ], 'parity-migrations'); 81 + 82 + $this->commands([ 83 + DiscoverCommand::class, 84 + ExportCommand::class, 85 + ImportCommand::class, 86 + ImportStatusCommand::class, 87 + ]); 88 + } 89 + 90 + public function provides(): array 91 + { 92 + return [ 93 + 'parity', 94 + MapperRegistry::class, 95 + RecordHelper::class, 96 + ImportService::class, 97 + PublishService::class, 98 + DiscoveryService::class, 99 + ExportService::class, 100 + ]; 101 + } 102 + }
+55
src/Publish/PublishResult.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Publish; 4 + 5 + /** 6 + * Immutable value object representing the result of a publish operation. 7 + */ 8 + readonly class PublishResult 9 + { 10 + public function __construct( 11 + public bool $success, 12 + public ?string $uri = null, 13 + public ?string $cid = null, 14 + public ?string $error = null, 15 + ) {} 16 + 17 + /** 18 + * Check if the publish operation succeeded. 19 + */ 20 + public function isSuccess(): bool 21 + { 22 + return $this->success; 23 + } 24 + 25 + /** 26 + * Check if the publish 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 $uri, string $cid): self 37 + { 38 + return new self( 39 + success: true, 40 + uri: $uri, 41 + cid: $cid, 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 + }
+243
src/Publish/PublishService.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Publish; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use SocialDept\AtpClient\Facades\Atp; 7 + use SocialDept\AtpParity\Events\RecordPublished; 8 + use SocialDept\AtpParity\Events\RecordUnpublished; 9 + use SocialDept\AtpParity\MapperRegistry; 10 + use Throwable; 11 + 12 + /** 13 + * Service for publishing Eloquent models to AT Protocol. 14 + */ 15 + class PublishService 16 + { 17 + public function __construct( 18 + protected MapperRegistry $registry 19 + ) {} 20 + 21 + /** 22 + * Publish a model as a new record to AT Protocol. 23 + * 24 + * Requires the model to have a DID association (via did column or relationship). 25 + */ 26 + public function publish(Model $model): PublishResult 27 + { 28 + $did = $this->getDidFromModel($model); 29 + 30 + if (! $did) { 31 + return PublishResult::failed('No DID associated with model. Use publishAs() to specify a DID.'); 32 + } 33 + 34 + return $this->publishAs($did, $model); 35 + } 36 + 37 + /** 38 + * Publish a model as a specific user. 39 + */ 40 + public function publishAs(string $did, Model $model): PublishResult 41 + { 42 + $mapper = $this->registry->forModel(get_class($model)); 43 + 44 + if (! $mapper) { 45 + return PublishResult::failed('No mapper registered for model: '.get_class($model)); 46 + } 47 + 48 + // Check if already published 49 + $existingUri = $this->getModelUri($model); 50 + if ($existingUri) { 51 + return $this->update($model); 52 + } 53 + 54 + try { 55 + $record = $mapper->toRecord($model); 56 + $collection = $mapper->lexicon(); 57 + 58 + $client = Atp::as($did); 59 + $response = $client->atproto->repo->createRecord( 60 + repo: $did, 61 + collection: $collection, 62 + record: $record->toArray(), 63 + ); 64 + 65 + // Update model with ATP metadata 66 + $this->updateModelMeta($model, $response->uri, $response->cid); 67 + 68 + event(new RecordPublished($model, $response->uri, $response->cid)); 69 + 70 + return PublishResult::success($response->uri, $response->cid); 71 + } catch (Throwable $e) { 72 + return PublishResult::failed($e->getMessage()); 73 + } 74 + } 75 + 76 + /** 77 + * Update an existing published record. 78 + */ 79 + public function update(Model $model): PublishResult 80 + { 81 + $uri = $this->getModelUri($model); 82 + 83 + if (! $uri) { 84 + return PublishResult::failed('Model has not been published yet. Use publish() first.'); 85 + } 86 + 87 + $mapper = $this->registry->forModel(get_class($model)); 88 + 89 + if (! $mapper) { 90 + return PublishResult::failed('No mapper registered for model: '.get_class($model)); 91 + } 92 + 93 + $parts = $this->parseUri($uri); 94 + 95 + if (! $parts) { 96 + return PublishResult::failed('Invalid AT Protocol URI: '.$uri); 97 + } 98 + 99 + try { 100 + $record = $mapper->toRecord($model); 101 + 102 + $client = Atp::as($parts['did']); 103 + $response = $client->atproto->repo->putRecord( 104 + repo: $parts['did'], 105 + collection: $parts['collection'], 106 + rkey: $parts['rkey'], 107 + record: $record->toArray(), 108 + ); 109 + 110 + // Update model with new CID 111 + $this->updateModelMeta($model, $response->uri, $response->cid); 112 + 113 + event(new RecordPublished($model, $response->uri, $response->cid)); 114 + 115 + return PublishResult::success($response->uri, $response->cid); 116 + } catch (Throwable $e) { 117 + return PublishResult::failed($e->getMessage()); 118 + } 119 + } 120 + 121 + /** 122 + * Delete a published record from AT Protocol. 123 + */ 124 + public function delete(Model $model): bool 125 + { 126 + $uri = $this->getModelUri($model); 127 + 128 + if (! $uri) { 129 + return false; 130 + } 131 + 132 + $parts = $this->parseUri($uri); 133 + 134 + if (! $parts) { 135 + return false; 136 + } 137 + 138 + try { 139 + $client = Atp::as($parts['did']); 140 + $client->atproto->repo->deleteRecord( 141 + repo: $parts['did'], 142 + collection: $parts['collection'], 143 + rkey: $parts['rkey'], 144 + ); 145 + 146 + // Clear ATP metadata from model 147 + $this->clearModelMeta($model); 148 + 149 + event(new RecordUnpublished($model, $uri)); 150 + 151 + return true; 152 + } catch (Throwable $e) { 153 + return false; 154 + } 155 + } 156 + 157 + /** 158 + * Get the DID from a model. 159 + * 160 + * Override this method or set a did column/relationship on your model. 161 + */ 162 + protected function getDidFromModel(Model $model): ?string 163 + { 164 + // Check for did column 165 + if (isset($model->did)) { 166 + return $model->did; 167 + } 168 + 169 + // Check for user relationship with did 170 + if (method_exists($model, 'user') && $model->user?->did) { 171 + return $model->user->did; 172 + } 173 + 174 + // Check for author relationship with did 175 + if (method_exists($model, 'author') && $model->author?->did) { 176 + return $model->author->did; 177 + } 178 + 179 + // Try extracting from existing URI 180 + $uri = $this->getModelUri($model); 181 + if ($uri) { 182 + $parts = $this->parseUri($uri); 183 + 184 + return $parts['did'] ?? null; 185 + } 186 + 187 + return null; 188 + } 189 + 190 + /** 191 + * Get the AT Protocol URI from a model. 192 + */ 193 + protected function getModelUri(Model $model): ?string 194 + { 195 + $column = config('parity.columns.uri', 'atp_uri'); 196 + 197 + return $model->{$column}; 198 + } 199 + 200 + /** 201 + * Update model with AT Protocol metadata. 202 + */ 203 + protected function updateModelMeta(Model $model, string $uri, string $cid): void 204 + { 205 + $uriColumn = config('parity.columns.uri', 'atp_uri'); 206 + $cidColumn = config('parity.columns.cid', 'atp_cid'); 207 + 208 + $model->{$uriColumn} = $uri; 209 + $model->{$cidColumn} = $cid; 210 + $model->save(); 211 + } 212 + 213 + /** 214 + * Clear AT Protocol metadata from model. 215 + */ 216 + protected function clearModelMeta(Model $model): void 217 + { 218 + $uriColumn = config('parity.columns.uri', 'atp_uri'); 219 + $cidColumn = config('parity.columns.cid', 'atp_cid'); 220 + 221 + $model->{$uriColumn} = null; 222 + $model->{$cidColumn} = null; 223 + $model->save(); 224 + } 225 + 226 + /** 227 + * Parse an AT Protocol URI into its components. 228 + * 229 + * @return array{did: string, collection: string, rkey: string}|null 230 + */ 231 + protected function parseUri(string $uri): ?array 232 + { 233 + if (! preg_match('#^at://([^/]+)/([^/]+)/([^/]+)$#', $uri, $matches)) { 234 + return null; 235 + } 236 + 237 + return [ 238 + 'did' => $matches[1], 239 + 'collection' => $matches[2], 240 + 'rkey' => $matches[3], 241 + ]; 242 + } 243 + }
+154
src/RecordMapper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use SocialDept\AtpParity\Contracts\RecordMapper as RecordMapperContract; 7 + use SocialDept\AtpSchema\Data\Data; 8 + 9 + /** 10 + * Abstract base class for bidirectional Record <-> Model mapping. 11 + * 12 + * @template TRecord of Data 13 + * @template TModel of Model 14 + * 15 + * @implements RecordMapperContract<TRecord, TModel> 16 + */ 17 + abstract class RecordMapper implements RecordMapperContract 18 + { 19 + /** 20 + * Get the Record class this mapper handles. 21 + * 22 + * @return class-string<TRecord> 23 + */ 24 + abstract public function recordClass(): string; 25 + 26 + /** 27 + * Get the Model class this mapper handles. 28 + * 29 + * @return class-string<TModel> 30 + */ 31 + abstract public function modelClass(): string; 32 + 33 + /** 34 + * Map record properties to model attributes. 35 + * 36 + * @param TRecord $record 37 + * @return array<string, mixed> 38 + */ 39 + abstract protected function recordToAttributes(Data $record): array; 40 + 41 + /** 42 + * Map model attributes to record properties. 43 + * 44 + * @param TModel $model 45 + * @return array<string, mixed> 46 + */ 47 + abstract protected function modelToRecordData(Model $model): array; 48 + 49 + /** 50 + * Get the lexicon NSID this mapper handles. 51 + */ 52 + public function lexicon(): string 53 + { 54 + $recordClass = $this->recordClass(); 55 + 56 + return $recordClass::getLexicon(); 57 + } 58 + 59 + /** 60 + * Get the column name for storing the AT Protocol URI. 61 + */ 62 + protected function uriColumn(): string 63 + { 64 + return config('parity.columns.uri', 'atp_uri'); 65 + } 66 + 67 + /** 68 + * Get the column name for storing the AT Protocol CID. 69 + */ 70 + protected function cidColumn(): string 71 + { 72 + return config('parity.columns.cid', 'atp_cid'); 73 + } 74 + 75 + public function toModel(Data $record, array $meta = []): Model 76 + { 77 + $modelClass = $this->modelClass(); 78 + $attributes = $this->recordToAttributes($record); 79 + $attributes = $this->applyMeta($attributes, $meta); 80 + 81 + return new $modelClass($attributes); 82 + } 83 + 84 + public function toRecord(Model $model): Data 85 + { 86 + $recordClass = $this->recordClass(); 87 + 88 + return $recordClass::fromArray($this->modelToRecordData($model)); 89 + } 90 + 91 + public function updateModel(Model $model, Data $record, array $meta = []): Model 92 + { 93 + $attributes = $this->recordToAttributes($record); 94 + $attributes = $this->applyMeta($attributes, $meta); 95 + $model->fill($attributes); 96 + 97 + return $model; 98 + } 99 + 100 + public function findByUri(string $uri): ?Model 101 + { 102 + $modelClass = $this->modelClass(); 103 + 104 + return $modelClass::where($this->uriColumn(), $uri)->first(); 105 + } 106 + 107 + public function upsert(Data $record, array $meta = []): Model 108 + { 109 + $uri = $meta['uri'] ?? null; 110 + 111 + if ($uri) { 112 + $existing = $this->findByUri($uri); 113 + 114 + if ($existing) { 115 + $this->updateModel($existing, $record, $meta); 116 + $existing->save(); 117 + 118 + return $existing; 119 + } 120 + } 121 + 122 + $model = $this->toModel($record, $meta); 123 + $model->save(); 124 + 125 + return $model; 126 + } 127 + 128 + public function deleteByUri(string $uri): bool 129 + { 130 + $model = $this->findByUri($uri); 131 + 132 + if ($model) { 133 + return (bool) $model->delete(); 134 + } 135 + 136 + return false; 137 + } 138 + 139 + /** 140 + * Apply AT Protocol metadata to attributes. 141 + */ 142 + protected function applyMeta(array $attributes, array $meta): array 143 + { 144 + if (isset($meta['uri'])) { 145 + $attributes[$this->uriColumn()] = $meta['uri']; 146 + } 147 + 148 + if (isset($meta['cid'])) { 149 + $attributes[$this->cidColumn()] = $meta['cid']; 150 + } 151 + 152 + return $attributes; 153 + } 154 + }
+234
src/Signals/ParitySignal.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Signals; 4 + 5 + use SocialDept\AtpParity\Contracts\RecordMapper; 6 + use SocialDept\AtpParity\MapperRegistry; 7 + use SocialDept\AtpParity\Sync\ConflictDetector; 8 + use SocialDept\AtpParity\Sync\ConflictResolver; 9 + use SocialDept\AtpParity\Sync\ConflictStrategy; 10 + use SocialDept\AtpSignals\Events\SignalEvent; 11 + use SocialDept\AtpSignals\Signals\Signal; 12 + 13 + /** 14 + * Signal that automatically syncs firehose events to Eloquent models. 15 + * 16 + * This signal listens for commit events on collections that have registered 17 + * mappers and automatically creates, updates, or deletes the corresponding 18 + * Eloquent models. 19 + * 20 + * Supports selective sync via configuration or by extending this class: 21 + * - Filter by DID: config('parity.sync.dids') or override dids() 22 + * - Filter by operation: config('parity.sync.operations') or override operations() 23 + * - Custom filter: config('parity.sync.filter') or override shouldSync() 24 + * 25 + * Supports conflict resolution via configuration: 26 + * - Strategy: config('parity.conflicts.strategy') - 'remote', 'local', 'newest', 'manual' 27 + * 28 + * To use this signal, register it in your atp-signals config: 29 + * 30 + * // config/signal.php 31 + * return [ 32 + * 'signals' => [ 33 + * \SocialDept\AtpParity\Signals\ParitySignal::class, 34 + * ], 35 + * ]; 36 + */ 37 + class ParitySignal extends Signal 38 + { 39 + protected ConflictDetector $conflictDetector; 40 + 41 + protected ConflictResolver $conflictResolver; 42 + 43 + public function __construct( 44 + protected MapperRegistry $registry 45 + ) { 46 + $this->conflictDetector = new ConflictDetector; 47 + $this->conflictResolver = new ConflictResolver; 48 + } 49 + 50 + /** 51 + * Listen for commit events only. 52 + */ 53 + public function eventTypes(): array 54 + { 55 + return ['commit']; 56 + } 57 + 58 + /** 59 + * Only listen for collections that have registered mappers. 60 + */ 61 + public function collections(): ?array 62 + { 63 + $lexicons = $this->registry->lexicons(); 64 + 65 + // Return null if no mappers registered (don't match anything) 66 + return empty($lexicons) ? ['__none__'] : $lexicons; 67 + } 68 + 69 + /** 70 + * Get the DIDs to sync (null = all DIDs). 71 + * 72 + * Override this method for custom DID filtering logic. 73 + */ 74 + public function dids(): ?array 75 + { 76 + return config('parity.sync.dids'); 77 + } 78 + 79 + /** 80 + * Get the operations to sync (null = all operations). 81 + * 82 + * Possible values: 'create', 'update', 'delete' 83 + * Override this method for custom operation filtering. 84 + */ 85 + public function operations(): ?array 86 + { 87 + return config('parity.sync.operations'); 88 + } 89 + 90 + /** 91 + * Determine if the event should be synced. 92 + * 93 + * Override this method for custom filtering logic. 94 + */ 95 + public function shouldSync(SignalEvent $event): bool 96 + { 97 + // Check custom filter callback from config 98 + $filter = config('parity.sync.filter'); 99 + if ($filter && is_callable($filter)) { 100 + return $filter($event); 101 + } 102 + 103 + return true; 104 + } 105 + 106 + /** 107 + * Handle the firehose event. 108 + */ 109 + public function handle(SignalEvent $event): void 110 + { 111 + if (! $event->commit) { 112 + return; 113 + } 114 + 115 + // Apply DID filter 116 + $dids = $this->dids(); 117 + if ($dids !== null && ! in_array($event->did, $dids)) { 118 + return; 119 + } 120 + 121 + $commit = $event->commit; 122 + 123 + // Apply operation filter 124 + $operations = $this->operations(); 125 + if ($operations !== null) { 126 + $operation = $this->getOperationType($commit); 127 + if (! in_array($operation, $operations)) { 128 + return; 129 + } 130 + } 131 + 132 + // Apply custom filter 133 + if (! $this->shouldSync($event)) { 134 + return; 135 + } 136 + 137 + $mapper = $this->registry->forLexicon($commit->collection); 138 + 139 + if (! $mapper) { 140 + return; 141 + } 142 + 143 + if ($commit->isCreate() || $commit->isUpdate()) { 144 + $this->handleUpsert($event, $mapper); 145 + } elseif ($commit->isDelete()) { 146 + $this->handleDelete($event, $mapper); 147 + } 148 + } 149 + 150 + /** 151 + * Get the operation type from a commit. 152 + */ 153 + protected function getOperationType(object $commit): string 154 + { 155 + if ($commit->isCreate()) { 156 + return 'create'; 157 + } 158 + 159 + if ($commit->isUpdate()) { 160 + return 'update'; 161 + } 162 + 163 + if ($commit->isDelete()) { 164 + return 'delete'; 165 + } 166 + 167 + return 'unknown'; 168 + } 169 + 170 + /** 171 + * Handle create or update operations. 172 + */ 173 + protected function handleUpsert(SignalEvent $event, RecordMapper $mapper): void 174 + { 175 + $commit = $event->commit; 176 + 177 + if (! $commit->record) { 178 + return; 179 + } 180 + 181 + $recordClass = $mapper->recordClass(); 182 + $record = $recordClass::fromArray((array) $commit->record); 183 + 184 + $uri = $this->buildUri($event->did, $commit->collection, $commit->rkey); 185 + $meta = [ 186 + 'uri' => $uri, 187 + 'cid' => $commit->cid, 188 + ]; 189 + 190 + // Check for existing model and potential conflict 191 + $existing = $mapper->findByUri($uri); 192 + 193 + if ($existing && $this->conflictDetector->hasConflict($existing, $record, $commit->cid)) { 194 + $strategy = ConflictStrategy::fromConfig(); 195 + $resolution = $this->conflictResolver->resolve( 196 + $existing, 197 + $record, 198 + $meta, 199 + $mapper, 200 + $strategy 201 + ); 202 + 203 + // If conflict is pending manual resolution, don't apply changes 204 + if (! $resolution->isResolved()) { 205 + return; 206 + } 207 + 208 + // Conflict was resolved, model already updated if needed 209 + return; 210 + } 211 + 212 + // No conflict, proceed with normal upsert 213 + $mapper->upsert($record, $meta); 214 + } 215 + 216 + /** 217 + * Handle delete operations. 218 + */ 219 + protected function handleDelete(SignalEvent $event, RecordMapper $mapper): void 220 + { 221 + $commit = $event->commit; 222 + $uri = $this->buildUri($event->did, $commit->collection, $commit->rkey); 223 + 224 + $mapper->deleteByUri($uri); 225 + } 226 + 227 + /** 228 + * Build an AT Protocol URI. 229 + */ 230 + protected function buildUri(string $did, string $collection, string $rkey): string 231 + { 232 + return "at://{$did}/{$collection}/{$rkey}"; 233 + } 234 + }
+220
src/Support/RecordHelper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Support; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use SocialDept\AtpClient\AtpClient; 7 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\GetRecordResponse; 8 + use SocialDept\AtpClient\Facades\Atp; 9 + use SocialDept\AtpParity\MapperRegistry; 10 + use SocialDept\AtpResolver\Facades\Resolver; 11 + use SocialDept\AtpSchema\Data\Data; 12 + 13 + /** 14 + * Helper for integrating atp-parity with atp-client. 15 + * 16 + * Provides convenient methods for fetching records from the ATP network 17 + * and converting them to typed DTOs or Eloquent models. 18 + */ 19 + class RecordHelper 20 + { 21 + /** 22 + * Cache of clients by PDS endpoint. 23 + * 24 + * @var array<string, AtpClient> 25 + */ 26 + protected array $clients = []; 27 + 28 + public function __construct( 29 + protected MapperRegistry $registry 30 + ) {} 31 + 32 + /** 33 + * Get or create a client for a PDS endpoint. 34 + */ 35 + protected function clientFor(string $pdsEndpoint): AtpClient 36 + { 37 + return $this->clients[$pdsEndpoint] ??= Atp::public($pdsEndpoint); 38 + } 39 + 40 + /** 41 + * Resolve the PDS endpoint for a DID or handle. 42 + */ 43 + protected function resolvePds(string $actor): ?string 44 + { 45 + return Resolver::resolvePds($actor); 46 + } 47 + 48 + /** 49 + * Convert a GetRecordResponse to a typed record DTO. 50 + * 51 + * @template T of Data 52 + * 53 + * @param class-string<T>|null $recordClass Explicit record class, or null to auto-detect from mapper 54 + * @return T|array The typed record, or raw array if no mapper found and no class specified 55 + */ 56 + public function hydrateRecord(GetRecordResponse $response, ?string $recordClass = null): mixed 57 + { 58 + if ($recordClass) { 59 + return $recordClass::fromArray($response->value); 60 + } 61 + 62 + $collection = $this->extractCollection($response->uri); 63 + $mapper = $this->registry->forLexicon($collection); 64 + 65 + if (! $mapper) { 66 + return $response->value; 67 + } 68 + 69 + $recordClass = $mapper->recordClass(); 70 + 71 + return $recordClass::fromArray($response->value); 72 + } 73 + 74 + /** 75 + * Fetch a record from the ATP network by URI and return as typed DTO. 76 + * 77 + * @template T of Data 78 + * 79 + * @param class-string<T>|null $recordClass 80 + * @return T|array|null 81 + */ 82 + public function fetch(string $uri, ?string $recordClass = null): mixed 83 + { 84 + $parts = $this->parseUri($uri); 85 + 86 + if (! $parts) { 87 + return null; 88 + } 89 + 90 + $pdsEndpoint = $this->resolvePds($parts['repo']); 91 + 92 + if (! $pdsEndpoint) { 93 + return null; 94 + } 95 + 96 + $response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord( 97 + $parts['repo'], 98 + $parts['collection'], 99 + $parts['rkey'] 100 + ); 101 + 102 + return $this->hydrateRecord($response, $recordClass); 103 + } 104 + 105 + /** 106 + * Fetch a record by URI and convert directly to an Eloquent model. 107 + * 108 + * @template TModel of Model 109 + * 110 + * @return TModel|null 111 + */ 112 + public function fetchAsModel(string $uri): ?Model 113 + { 114 + $parts = $this->parseUri($uri); 115 + 116 + if (! $parts) { 117 + return null; 118 + } 119 + 120 + $mapper = $this->registry->forLexicon($parts['collection']); 121 + 122 + if (! $mapper) { 123 + return null; 124 + } 125 + 126 + $pdsEndpoint = $this->resolvePds($parts['repo']); 127 + 128 + if (! $pdsEndpoint) { 129 + return null; 130 + } 131 + 132 + $response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord( 133 + $parts['repo'], 134 + $parts['collection'], 135 + $parts['rkey'] 136 + ); 137 + 138 + $recordClass = $mapper->recordClass(); 139 + $record = $recordClass::fromArray($response->value); 140 + 141 + return $mapper->toModel($record, [ 142 + 'uri' => $response->uri, 143 + 'cid' => $response->cid, 144 + ]); 145 + } 146 + 147 + /** 148 + * Fetch a record by URI and upsert to the database. 149 + * 150 + * @template TModel of Model 151 + * 152 + * @return TModel|null 153 + */ 154 + public function sync(string $uri): ?Model 155 + { 156 + $parts = $this->parseUri($uri); 157 + 158 + if (! $parts) { 159 + return null; 160 + } 161 + 162 + $mapper = $this->registry->forLexicon($parts['collection']); 163 + 164 + if (! $mapper) { 165 + return null; 166 + } 167 + 168 + $pdsEndpoint = $this->resolvePds($parts['repo']); 169 + 170 + if (! $pdsEndpoint) { 171 + return null; 172 + } 173 + 174 + $response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord( 175 + $parts['repo'], 176 + $parts['collection'], 177 + $parts['rkey'] 178 + ); 179 + 180 + $recordClass = $mapper->recordClass(); 181 + $record = $recordClass::fromArray($response->value); 182 + 183 + return $mapper->upsert($record, [ 184 + 'uri' => $response->uri, 185 + 'cid' => $response->cid, 186 + ]); 187 + } 188 + 189 + /** 190 + * Parse an AT Protocol URI into its components. 191 + * 192 + * @return array{repo: string, collection: string, rkey: string}|null 193 + */ 194 + protected function parseUri(string $uri): ?array 195 + { 196 + // at://did:plc:xxx/app.bsky.feed.post/rkey 197 + if (! preg_match('#^at://([^/]+)/([^/]+)/([^/]+)$#', $uri, $matches)) { 198 + return null; 199 + } 200 + 201 + return [ 202 + 'repo' => $matches[1], 203 + 'collection' => $matches[2], 204 + 'rkey' => $matches[3], 205 + ]; 206 + } 207 + 208 + /** 209 + * Extract collection from AT Protocol URI. 210 + */ 211 + protected function extractCollection(string $uri): string 212 + { 213 + // at://did:plc:xxx/app.bsky.feed.post/rkey 214 + if (preg_match('#^at://[^/]+/([^/]+)/#', $uri, $matches)) { 215 + return $matches[1]; 216 + } 217 + 218 + return ''; 219 + } 220 + }
+75
src/Support/SchemaMapper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Support; 4 + 5 + use Closure; 6 + use Illuminate\Database\Eloquent\Model; 7 + use SocialDept\AtpParity\RecordMapper; 8 + use SocialDept\AtpSchema\Data\Data; 9 + 10 + /** 11 + * Adapter for using atp-schema generated DTOs as record types. 12 + * 13 + * This allows you to use the auto-generated schema classes directly 14 + * without creating custom Record classes. 15 + * 16 + * Example: 17 + * 18 + * use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post; 19 + * use App\Models\Post as PostModel; 20 + * 21 + * $mapper = new SchemaMapper( 22 + * schemaClass: Post::class, 23 + * modelClass: PostModel::class, 24 + * toAttributes: fn(Post $p) => [ 25 + * 'content' => $p->text, 26 + * 'published_at' => $p->createdAt, 27 + * ], 28 + * toRecordData: fn(PostModel $m) => [ 29 + * 'text' => $m->content, 30 + * 'createdAt' => $m->published_at->toIso8601String(), 31 + * ], 32 + * ); 33 + * 34 + * $registry->register($mapper); 35 + * 36 + * @template TSchema of Data 37 + * @template TModel of Model 38 + * 39 + * @extends RecordMapper<TSchema, TModel> 40 + */ 41 + class SchemaMapper extends RecordMapper 42 + { 43 + /** 44 + * @param class-string<TSchema> $schemaClass The atp-schema generated class 45 + * @param class-string<TModel> $modelClass The Eloquent model class 46 + * @param Closure(TSchema): array $toAttributes Convert schema to model attributes 47 + * @param Closure(TModel): array $toRecordData Convert model to record data 48 + */ 49 + public function __construct( 50 + protected string $schemaClass, 51 + protected string $modelClass, 52 + protected Closure $toAttributes, 53 + protected Closure $toRecordData, 54 + ) {} 55 + 56 + public function recordClass(): string 57 + { 58 + return $this->schemaClass; 59 + } 60 + 61 + public function modelClass(): string 62 + { 63 + return $this->modelClass; 64 + } 65 + 66 + protected function recordToAttributes(Data $record): array 67 + { 68 + return ($this->toAttributes)($record); 69 + } 70 + 71 + protected function modelToRecordData(Model $model): array 72 + { 73 + return ($this->toRecordData)($model); 74 + } 75 + }
+77
src/Sync/ConflictDetector.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Sync; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use SocialDept\AtpParity\Concerns\SyncsWithAtp; 7 + use SocialDept\AtpSchema\Data\Data; 8 + 9 + /** 10 + * Detects conflicts between local and remote record versions. 11 + */ 12 + class ConflictDetector 13 + { 14 + /** 15 + * Check if there's a conflict between local model and remote record. 16 + */ 17 + public function hasConflict(Model $model, Data $record, string $cid): bool 18 + { 19 + // No conflict if model doesn't have local changes 20 + if (! $this->modelHasLocalChanges($model)) { 21 + return false; 22 + } 23 + 24 + // No conflict if CID matches (same version) 25 + if ($this->getCid($model) === $cid) { 26 + return false; 27 + } 28 + 29 + return true; 30 + } 31 + 32 + /** 33 + * Check if the model has local changes since last sync. 34 + */ 35 + protected function modelHasLocalChanges(Model $model): bool 36 + { 37 + // Use trait method if available 38 + if ($this->usesTrait($model, SyncsWithAtp::class)) { 39 + return $model->hasLocalChanges(); 40 + } 41 + 42 + // Fallback: compare updated_at with a sync timestamp if available 43 + $syncedAt = $model->getAttribute('atp_synced_at'); 44 + 45 + if (! $syncedAt) { 46 + return true; 47 + } 48 + 49 + $updatedAt = $model->getAttribute('updated_at'); 50 + 51 + if (! $updatedAt) { 52 + return false; 53 + } 54 + 55 + return $updatedAt > $syncedAt; 56 + } 57 + 58 + /** 59 + * Get the CID from a model. 60 + */ 61 + protected function getCid(Model $model): ?string 62 + { 63 + $column = config('parity.columns.cid', 'atp_cid'); 64 + 65 + return $model->getAttribute($column); 66 + } 67 + 68 + /** 69 + * Check if a model uses a specific trait. 70 + * 71 + * @param class-string $trait 72 + */ 73 + protected function usesTrait(Model $model, string $trait): bool 74 + { 75 + return in_array($trait, class_uses_recursive($model)); 76 + } 77 + }
+70
src/Sync/ConflictResolution.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Sync; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + 7 + /** 8 + * Value object representing the result of conflict resolution. 9 + */ 10 + readonly class ConflictResolution 11 + { 12 + public function __construct( 13 + public bool $resolved, 14 + public string $winner, 15 + public ?Model $model = null, 16 + public ?PendingConflict $pending = null, 17 + ) {} 18 + 19 + /** 20 + * Check if the conflict was resolved. 21 + */ 22 + public function isResolved(): bool 23 + { 24 + return $this->resolved; 25 + } 26 + 27 + /** 28 + * Check if the conflict requires manual resolution. 29 + */ 30 + public function isPending(): bool 31 + { 32 + return ! $this->resolved && $this->pending !== null; 33 + } 34 + 35 + /** 36 + * Create resolution where remote wins. 37 + */ 38 + public static function remoteWins(Model $model): self 39 + { 40 + return new self( 41 + resolved: true, 42 + winner: 'remote', 43 + model: $model, 44 + ); 45 + } 46 + 47 + /** 48 + * Create resolution where local wins. 49 + */ 50 + public static function localWins(Model $model): self 51 + { 52 + return new self( 53 + resolved: true, 54 + winner: 'local', 55 + model: $model, 56 + ); 57 + } 58 + 59 + /** 60 + * Create pending resolution for manual review. 61 + */ 62 + public static function pending(PendingConflict $conflict): self 63 + { 64 + return new self( 65 + resolved: false, 66 + winner: 'manual', 67 + pending: $conflict, 68 + ); 69 + } 70 + }
+118
src/Sync/ConflictResolver.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Sync; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use SocialDept\AtpParity\Contracts\RecordMapper; 7 + use SocialDept\AtpParity\Events\ConflictDetected; 8 + use SocialDept\AtpSchema\Data\Data; 9 + 10 + /** 11 + * Resolves conflicts between local and remote record versions. 12 + */ 13 + class ConflictResolver 14 + { 15 + /** 16 + * Resolve a conflict according to the specified strategy. 17 + */ 18 + public function resolve( 19 + Model $model, 20 + Data $record, 21 + array $meta, 22 + RecordMapper $mapper, 23 + ConflictStrategy $strategy 24 + ): ConflictResolution { 25 + return match ($strategy) { 26 + ConflictStrategy::RemoteWins => $this->applyRemote($model, $record, $meta, $mapper), 27 + ConflictStrategy::LocalWins => $this->keepLocal($model), 28 + ConflictStrategy::NewestWins => $this->compareAndApply($model, $record, $meta, $mapper), 29 + ConflictStrategy::Manual => $this->flagForReview($model, $record, $meta, $mapper), 30 + }; 31 + } 32 + 33 + /** 34 + * Apply the remote version, overwriting local changes. 35 + */ 36 + protected function applyRemote( 37 + Model $model, 38 + Data $record, 39 + array $meta, 40 + RecordMapper $mapper 41 + ): ConflictResolution { 42 + $mapper->updateModel($model, $record, $meta); 43 + $model->save(); 44 + 45 + return ConflictResolution::remoteWins($model); 46 + } 47 + 48 + /** 49 + * Keep the local version, ignoring remote changes. 50 + */ 51 + protected function keepLocal(Model $model): ConflictResolution 52 + { 53 + return ConflictResolution::localWins($model); 54 + } 55 + 56 + /** 57 + * Compare timestamps and apply the newest version. 58 + */ 59 + protected function compareAndApply( 60 + Model $model, 61 + Data $record, 62 + array $meta, 63 + RecordMapper $mapper 64 + ): ConflictResolution { 65 + $localUpdatedAt = $model->getAttribute('updated_at'); 66 + 67 + // Try to get remote timestamp from record 68 + $remoteCreatedAt = $record->createdAt ?? null; 69 + 70 + // If we can't compare, default to remote wins 71 + if (! $localUpdatedAt || ! $remoteCreatedAt) { 72 + return $this->applyRemote($model, $record, $meta, $mapper); 73 + } 74 + 75 + // Compare timestamps 76 + if ($localUpdatedAt > $remoteCreatedAt) { 77 + return $this->keepLocal($model); 78 + } 79 + 80 + return $this->applyRemote($model, $record, $meta, $mapper); 81 + } 82 + 83 + /** 84 + * Flag the conflict for manual review. 85 + */ 86 + protected function flagForReview( 87 + Model $model, 88 + Data $record, 89 + array $meta, 90 + RecordMapper $mapper 91 + ): ConflictResolution { 92 + // Create a pending conflict record 93 + $conflict = PendingConflict::create([ 94 + 'model_type' => get_class($model), 95 + 'model_id' => $model->getKey(), 96 + 'uri' => $meta['uri'] ?? null, 97 + 'local_data' => $model->toArray(), 98 + 'remote_data' => $this->buildRemoteData($record, $meta, $mapper), 99 + 'status' => 'pending', 100 + ]); 101 + 102 + // Dispatch event for notification 103 + event(new ConflictDetected($model, $record, $meta, $conflict)); 104 + 105 + return ConflictResolution::pending($conflict); 106 + } 107 + 108 + /** 109 + * Build the remote data array for storage. 110 + */ 111 + protected function buildRemoteData(Data $record, array $meta, RecordMapper $mapper): array 112 + { 113 + // Create a temporary model with the remote data 114 + $tempModel = $mapper->toModel($record, $meta); 115 + 116 + return $tempModel->toArray(); 117 + } 118 + }
+42
src/Sync/ConflictStrategy.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Sync; 4 + 5 + /** 6 + * Strategy for resolving conflicts between local and remote changes. 7 + */ 8 + enum ConflictStrategy: string 9 + { 10 + /** 11 + * Remote (AT Protocol) is source of truth. 12 + * Local changes are overwritten. 13 + */ 14 + case RemoteWins = 'remote'; 15 + 16 + /** 17 + * Local database is source of truth. 18 + * Remote changes are ignored. 19 + */ 20 + case LocalWins = 'local'; 21 + 22 + /** 23 + * Compare timestamps and use the newest version. 24 + */ 25 + case NewestWins = 'newest'; 26 + 27 + /** 28 + * Flag conflict for manual review. 29 + * Neither version is applied automatically. 30 + */ 31 + case Manual = 'manual'; 32 + 33 + /** 34 + * Create from config value. 35 + */ 36 + public static function fromConfig(): self 37 + { 38 + $strategy = config('parity.conflicts.strategy', 'remote'); 39 + 40 + return self::tryFrom($strategy) ?? self::RemoteWins; 41 + } 42 + }
+127
src/Sync/PendingConflict.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Sync; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use Illuminate\Database\Eloquent\Relations\MorphTo; 7 + 8 + /** 9 + * Model for storing pending conflicts requiring manual resolution. 10 + */ 11 + class PendingConflict extends Model 12 + { 13 + protected $guarded = []; 14 + 15 + protected $casts = [ 16 + 'local_data' => 'array', 17 + 'remote_data' => 'array', 18 + 'resolved_at' => 'datetime', 19 + ]; 20 + 21 + /** 22 + * Get the table name from config. 23 + */ 24 + public function getTable(): string 25 + { 26 + return config('parity.conflicts.table', 'parity_conflicts'); 27 + } 28 + 29 + /** 30 + * Get the related model. 31 + */ 32 + public function model(): MorphTo 33 + { 34 + return $this->morphTo(); 35 + } 36 + 37 + /** 38 + * Check if this conflict is pending. 39 + */ 40 + public function isPending(): bool 41 + { 42 + return $this->status === 'pending'; 43 + } 44 + 45 + /** 46 + * Check if this conflict has been resolved. 47 + */ 48 + public function isResolved(): bool 49 + { 50 + return $this->status === 'resolved'; 51 + } 52 + 53 + /** 54 + * Check if this conflict was dismissed. 55 + */ 56 + public function isDismissed(): bool 57 + { 58 + return $this->status === 'dismissed'; 59 + } 60 + 61 + /** 62 + * Resolve the conflict with the local version. 63 + */ 64 + public function resolveWithLocal(): void 65 + { 66 + $this->update([ 67 + 'status' => 'resolved', 68 + 'resolution' => 'local', 69 + 'resolved_at' => now(), 70 + ]); 71 + } 72 + 73 + /** 74 + * Resolve the conflict with the remote version. 75 + */ 76 + public function resolveWithRemote(): void 77 + { 78 + $model = $this->model; 79 + 80 + if ($model) { 81 + $model->fill($this->remote_data); 82 + $model->save(); 83 + } 84 + 85 + $this->update([ 86 + 'status' => 'resolved', 87 + 'resolution' => 'remote', 88 + 'resolved_at' => now(), 89 + ]); 90 + } 91 + 92 + /** 93 + * Dismiss this conflict without resolving. 94 + */ 95 + public function dismiss(): void 96 + { 97 + $this->update([ 98 + 'status' => 'dismissed', 99 + 'resolved_at' => now(), 100 + ]); 101 + } 102 + 103 + /** 104 + * Scope to pending conflicts. 105 + */ 106 + public function scopePending($query) 107 + { 108 + return $query->where('status', 'pending'); 109 + } 110 + 111 + /** 112 + * Scope to resolved conflicts. 113 + */ 114 + public function scopeResolved($query) 115 + { 116 + return $query->where('status', 'resolved'); 117 + } 118 + 119 + /** 120 + * Scope to conflicts for a specific model. 121 + */ 122 + public function scopeForModel($query, Model $model) 123 + { 124 + return $query->where('model_type', get_class($model)) 125 + ->where('model_id', $model->getKey()); 126 + } 127 + }
+14
tests/Fixtures/SyncableMapper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Fixtures; 4 + 5 + /** 6 + * Mapper for SyncableModel (extends TestMapper with different model class). 7 + */ 8 + class SyncableMapper extends TestMapper 9 + { 10 + public function modelClass(): string 11 + { 12 + return SyncableModel::class; 13 + } 14 + }
+19
tests/Fixtures/SyncableModel.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Fixtures; 4 + 5 + use SocialDept\AtpParity\Concerns\SyncsWithAtp; 6 + 7 + /** 8 + * Test model with SyncsWithAtp trait for unit testing. 9 + * 10 + * Extends TestModel so it gets the same mapper from the registry. 11 + */ 12 + class SyncableModel extends TestModel 13 + { 14 + use SyncsWithAtp; 15 + 16 + protected $casts = [ 17 + 'atp_synced_at' => 'datetime', 18 + ]; 19 + }
+38
tests/Fixtures/TestMapper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Fixtures; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use SocialDept\AtpParity\RecordMapper; 7 + use SocialDept\AtpSchema\Data\Data; 8 + 9 + /** 10 + * Test mapper for unit testing. 11 + */ 12 + class TestMapper extends RecordMapper 13 + { 14 + public function recordClass(): string 15 + { 16 + return TestRecord::class; 17 + } 18 + 19 + public function modelClass(): string 20 + { 21 + return TestModel::class; 22 + } 23 + 24 + protected function recordToAttributes(Data $record): array 25 + { 26 + return [ 27 + 'content' => $record->text, 28 + ]; 29 + } 30 + 31 + protected function modelToRecordData(Model $model): array 32 + { 33 + return [ 34 + 'text' => $model->content, 35 + 'createdAt' => $model->created_at?->toIso8601String(), 36 + ]; 37 + } 38 + }
+22
tests/Fixtures/TestModel.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Fixtures; 4 + 5 + use Illuminate\Database\Eloquent\Model; 6 + use SocialDept\AtpParity\Concerns\HasAtpRecord; 7 + 8 + /** 9 + * Test model for unit testing. 10 + */ 11 + class TestModel extends Model 12 + { 13 + use HasAtpRecord; 14 + 15 + protected $table = 'test_models'; 16 + 17 + protected $guarded = []; 18 + 19 + protected $casts = [ 20 + 'atp_synced_at' => 'datetime', 21 + ]; 22 + }
+37
tests/Fixtures/TestRecord.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Fixtures; 4 + 5 + use SocialDept\AtpSchema\Data\Data; 6 + 7 + /** 8 + * Test record for unit testing. 9 + */ 10 + class TestRecord extends Data 11 + { 12 + public function __construct( 13 + public readonly string $text, 14 + public readonly ?string $createdAt = null, 15 + ) {} 16 + 17 + public static function getLexicon(): string 18 + { 19 + return 'app.test.record'; 20 + } 21 + 22 + public static function fromArray(array $data): static 23 + { 24 + return new static( 25 + text: $data['text'] ?? '', 26 + createdAt: $data['createdAt'] ?? null, 27 + ); 28 + } 29 + 30 + public function toArray(): array 31 + { 32 + return array_filter([ 33 + 'text' => $this->text, 34 + 'createdAt' => $this->createdAt, 35 + ], fn ($v) => $v !== null); 36 + } 37 + }
+85
tests/TestCase.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests; 4 + 5 + use Illuminate\Database\Schema\Blueprint; 6 + use Illuminate\Support\Facades\Schema; 7 + use Orchestra\Testbench\TestCase as Orchestra; 8 + use SocialDept\AtpClient\AtpClientServiceProvider; 9 + use SocialDept\AtpParity\ParityServiceProvider; 10 + use SocialDept\AtpResolver\ResolverServiceProvider; 11 + use SocialDept\AtpSignals\SignalServiceProvider; 12 + 13 + abstract class TestCase extends Orchestra 14 + { 15 + protected function setUp(): void 16 + { 17 + parent::setUp(); 18 + 19 + $this->setUpDatabase(); 20 + } 21 + 22 + protected function getPackageProviders($app): array 23 + { 24 + return [ 25 + ResolverServiceProvider::class, 26 + AtpClientServiceProvider::class, 27 + SignalServiceProvider::class, 28 + ParityServiceProvider::class, 29 + ]; 30 + } 31 + 32 + protected function getEnvironmentSetUp($app): void 33 + { 34 + $app['config']->set('database.default', 'testing'); 35 + $app['config']->set('database.connections.testing', [ 36 + 'driver' => 'sqlite', 37 + 'database' => ':memory:', 38 + 'prefix' => '', 39 + ]); 40 + 41 + $app['config']->set('parity.columns.uri', 'atp_uri'); 42 + $app['config']->set('parity.columns.cid', 'atp_cid'); 43 + } 44 + 45 + protected function setUpDatabase(): void 46 + { 47 + Schema::create('test_models', function (Blueprint $table) { 48 + $table->id(); 49 + $table->string('content')->nullable(); 50 + $table->string('did')->nullable(); 51 + $table->string('atp_uri')->nullable()->unique(); 52 + $table->string('atp_cid')->nullable(); 53 + $table->timestamp('atp_synced_at')->nullable(); 54 + $table->timestamps(); 55 + }); 56 + 57 + Schema::create('parity_import_states', function (Blueprint $table) { 58 + $table->id(); 59 + $table->string('did'); 60 + $table->string('collection'); 61 + $table->string('status')->default('pending'); 62 + $table->integer('records_synced')->default(0); 63 + $table->integer('records_skipped')->default(0); 64 + $table->integer('records_failed')->default(0); 65 + $table->string('cursor')->nullable(); 66 + $table->text('error')->nullable(); 67 + $table->timestamp('started_at')->nullable(); 68 + $table->timestamp('completed_at')->nullable(); 69 + $table->timestamps(); 70 + 71 + $table->unique(['did', 'collection']); 72 + }); 73 + 74 + Schema::create('parity_conflicts', function (Blueprint $table) { 75 + $table->id(); 76 + $table->morphs('model'); 77 + $table->string('uri'); 78 + $table->string('remote_cid'); 79 + $table->json('remote_data'); 80 + $table->string('status')->default('pending'); 81 + $table->timestamp('resolved_at')->nullable(); 82 + $table->timestamps(); 83 + }); 84 + } 85 + }
+191
tests/Unit/Concerns/HasAtpRecordTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Concerns; 4 + 5 + use SocialDept\AtpParity\MapperRegistry; 6 + use SocialDept\AtpParity\Tests\Fixtures\TestMapper; 7 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 8 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 9 + use SocialDept\AtpParity\Tests\TestCase; 10 + 11 + class HasAtpRecordTest extends TestCase 12 + { 13 + public function test_get_atp_uri_returns_uri_from_column(): void 14 + { 15 + $model = new TestModel(['atp_uri' => 'at://did:plc:test/app.test.record/abc123']); 16 + 17 + $this->assertSame('at://did:plc:test/app.test.record/abc123', $model->getAtpUri()); 18 + } 19 + 20 + public function test_get_atp_uri_returns_null_when_not_set(): void 21 + { 22 + $model = new TestModel(); 23 + 24 + $this->assertNull($model->getAtpUri()); 25 + } 26 + 27 + public function test_get_atp_cid_returns_cid_from_column(): void 28 + { 29 + $model = new TestModel(['atp_cid' => 'bafyreiabc123']); 30 + 31 + $this->assertSame('bafyreiabc123', $model->getAtpCid()); 32 + } 33 + 34 + public function test_get_atp_cid_returns_null_when_not_set(): void 35 + { 36 + $model = new TestModel(); 37 + 38 + $this->assertNull($model->getAtpCid()); 39 + } 40 + 41 + public function test_get_atp_did_extracts_did_from_uri(): void 42 + { 43 + $model = new TestModel(['atp_uri' => 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/abc123']); 44 + 45 + $this->assertSame('did:plc:z72i7hdynmk6r22z27h6tvur', $model->getAtpDid()); 46 + } 47 + 48 + public function test_get_atp_did_returns_null_when_no_uri(): void 49 + { 50 + $model = new TestModel(); 51 + 52 + $this->assertNull($model->getAtpDid()); 53 + } 54 + 55 + public function test_get_atp_did_returns_null_for_malformed_uri(): void 56 + { 57 + $model = new TestModel(['atp_uri' => 'invalid-uri']); 58 + 59 + $this->assertNull($model->getAtpDid()); 60 + } 61 + 62 + public function test_get_atp_collection_extracts_collection_from_uri(): void 63 + { 64 + $model = new TestModel(['atp_uri' => 'at://did:plc:test/app.bsky.feed.post/abc123']); 65 + 66 + $this->assertSame('app.bsky.feed.post', $model->getAtpCollection()); 67 + } 68 + 69 + public function test_get_atp_collection_returns_null_when_no_uri(): void 70 + { 71 + $model = new TestModel(); 72 + 73 + $this->assertNull($model->getAtpCollection()); 74 + } 75 + 76 + public function test_get_atp_rkey_extracts_rkey_from_uri(): void 77 + { 78 + $model = new TestModel(['atp_uri' => 'at://did:plc:test/app.bsky.feed.post/3kj2h4k5j']); 79 + 80 + $this->assertSame('3kj2h4k5j', $model->getAtpRkey()); 81 + } 82 + 83 + public function test_get_atp_rkey_returns_null_when_no_uri(): void 84 + { 85 + $model = new TestModel(); 86 + 87 + $this->assertNull($model->getAtpRkey()); 88 + } 89 + 90 + public function test_has_atp_record_returns_true_when_uri_set(): void 91 + { 92 + $model = new TestModel(['atp_uri' => 'at://did/col/rkey']); 93 + 94 + $this->assertTrue($model->hasAtpRecord()); 95 + } 96 + 97 + public function test_has_atp_record_returns_false_when_no_uri(): void 98 + { 99 + $model = new TestModel(); 100 + 101 + $this->assertFalse($model->hasAtpRecord()); 102 + } 103 + 104 + public function test_get_atp_mapper_returns_mapper_when_registered(): void 105 + { 106 + $registry = app(MapperRegistry::class); 107 + $registry->register(new TestMapper()); 108 + 109 + $model = new TestModel(); 110 + $mapper = $model->getAtpMapper(); 111 + 112 + $this->assertInstanceOf(TestMapper::class, $mapper); 113 + } 114 + 115 + public function test_get_atp_mapper_returns_null_when_not_registered(): void 116 + { 117 + // Fresh registry without any mappers 118 + $this->app->forgetInstance(MapperRegistry::class); 119 + $this->app->singleton(MapperRegistry::class); 120 + 121 + $model = new TestModel(); 122 + 123 + $this->assertNull($model->getAtpMapper()); 124 + } 125 + 126 + public function test_to_atp_record_converts_model_to_record(): void 127 + { 128 + $registry = app(MapperRegistry::class); 129 + $registry->register(new TestMapper()); 130 + 131 + $model = new TestModel(['content' => 'Hello world']); 132 + $record = $model->toAtpRecord(); 133 + 134 + $this->assertInstanceOf(TestRecord::class, $record); 135 + $this->assertSame('Hello world', $record->text); 136 + } 137 + 138 + public function test_to_atp_record_returns_null_when_no_mapper(): void 139 + { 140 + $this->app->forgetInstance(MapperRegistry::class); 141 + $this->app->singleton(MapperRegistry::class); 142 + 143 + $model = new TestModel(['content' => 'Hello']); 144 + 145 + $this->assertNull($model->toAtpRecord()); 146 + } 147 + 148 + public function test_scope_with_atp_record_filters_synced_models(): void 149 + { 150 + TestModel::create(['content' => 'Synced', 'atp_uri' => 'at://did/col/rkey1']); 151 + TestModel::create(['content' => 'Not synced']); 152 + TestModel::create(['content' => 'Also synced', 'atp_uri' => 'at://did/col/rkey2']); 153 + 154 + $synced = TestModel::withAtpRecord()->get(); 155 + 156 + $this->assertCount(2, $synced); 157 + $this->assertTrue($synced->every(fn ($m) => $m->atp_uri !== null)); 158 + } 159 + 160 + public function test_scope_without_atp_record_filters_unsynced_models(): void 161 + { 162 + TestModel::create(['content' => 'Synced', 'atp_uri' => 'at://did/col/rkey']); 163 + TestModel::create(['content' => 'Not synced 1']); 164 + TestModel::create(['content' => 'Not synced 2']); 165 + 166 + $unsynced = TestModel::withoutAtpRecord()->get(); 167 + 168 + $this->assertCount(2, $unsynced); 169 + $this->assertTrue($unsynced->every(fn ($m) => $m->atp_uri === null)); 170 + } 171 + 172 + public function test_scope_where_atp_uri_finds_by_uri(): void 173 + { 174 + TestModel::create(['content' => 'Target', 'atp_uri' => 'at://did/col/target']); 175 + TestModel::create(['content' => 'Other', 'atp_uri' => 'at://did/col/other']); 176 + 177 + $found = TestModel::whereAtpUri('at://did/col/target')->first(); 178 + 179 + $this->assertNotNull($found); 180 + $this->assertSame('Target', $found->content); 181 + } 182 + 183 + public function test_scope_where_atp_uri_returns_null_when_not_found(): void 184 + { 185 + TestModel::create(['content' => 'Some', 'atp_uri' => 'at://did/col/some']); 186 + 187 + $found = TestModel::whereAtpUri('at://did/col/nonexistent')->first(); 188 + 189 + $this->assertNull($found); 190 + } 191 + }
+128
tests/Unit/Concerns/SyncsWithAtpTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Concerns; 4 + 5 + use Carbon\Carbon; 6 + use SocialDept\AtpParity\MapperRegistry; 7 + use SocialDept\AtpParity\Tests\Fixtures\SyncableMapper; 8 + use SocialDept\AtpParity\Tests\Fixtures\SyncableModel; 9 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 10 + use SocialDept\AtpParity\Tests\TestCase; 11 + 12 + class SyncsWithAtpTest extends TestCase 13 + { 14 + public function test_get_atp_synced_at_column_returns_default(): void 15 + { 16 + $model = new SyncableModel(); 17 + 18 + $this->assertSame('atp_synced_at', $model->getAtpSyncedAtColumn()); 19 + } 20 + 21 + public function test_get_atp_synced_at_returns_timestamp(): void 22 + { 23 + $now = Carbon::now(); 24 + $model = new SyncableModel(['atp_synced_at' => $now]); 25 + 26 + $this->assertEquals($now->toDateTimeString(), $model->getAtpSyncedAt()->toDateTimeString()); 27 + } 28 + 29 + public function test_get_atp_synced_at_returns_null_when_not_set(): void 30 + { 31 + $model = new SyncableModel(); 32 + 33 + $this->assertNull($model->getAtpSyncedAt()); 34 + } 35 + 36 + public function test_mark_as_synced_sets_all_attributes(): void 37 + { 38 + Carbon::setTestNow('2024-01-15 12:00:00'); 39 + 40 + $model = new SyncableModel(); 41 + $model->markAsSynced('at://did/col/rkey', 'cid123'); 42 + 43 + $this->assertSame('at://did/col/rkey', $model->atp_uri); 44 + $this->assertSame('cid123', $model->atp_cid); 45 + $this->assertSame('2024-01-15 12:00:00', $model->atp_synced_at->toDateTimeString()); 46 + 47 + Carbon::setTestNow(); 48 + } 49 + 50 + public function test_has_local_changes_returns_true_when_never_synced(): void 51 + { 52 + $model = new SyncableModel(); 53 + 54 + $this->assertTrue($model->hasLocalChanges()); 55 + } 56 + 57 + public function test_has_local_changes_returns_false_when_no_updated_at(): void 58 + { 59 + $model = new SyncableModel([ 60 + 'atp_synced_at' => Carbon::now(), 61 + ]); 62 + 63 + $this->assertFalse($model->hasLocalChanges()); 64 + } 65 + 66 + public function test_has_local_changes_returns_true_when_updated_after_sync(): void 67 + { 68 + $model = new SyncableModel([ 69 + 'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'), 70 + 'updated_at' => Carbon::parse('2024-01-15 13:00:00'), 71 + ]); 72 + 73 + $this->assertTrue($model->hasLocalChanges()); 74 + } 75 + 76 + public function test_has_local_changes_returns_false_when_synced_after_update(): void 77 + { 78 + $model = new SyncableModel([ 79 + 'updated_at' => Carbon::parse('2024-01-15 12:00:00'), 80 + 'atp_synced_at' => Carbon::parse('2024-01-15 13:00:00'), 81 + ]); 82 + 83 + $this->assertFalse($model->hasLocalChanges()); 84 + } 85 + 86 + public function test_update_from_record_updates_model_and_sync_timestamp(): void 87 + { 88 + Carbon::setTestNow('2024-01-15 14:00:00'); 89 + 90 + $registry = app(MapperRegistry::class); 91 + $registry->register(new SyncableMapper()); 92 + 93 + $model = new SyncableModel(['content' => 'Original']); 94 + $record = new TestRecord(text: 'From remote'); 95 + 96 + $model->updateFromRecord($record, 'at://did/col/rkey', 'newcid'); 97 + 98 + $this->assertSame('From remote', $model->content); 99 + $this->assertSame('at://did/col/rkey', $model->atp_uri); 100 + $this->assertSame('newcid', $model->atp_cid); 101 + $this->assertSame('2024-01-15 14:00:00', $model->atp_synced_at->toDateTimeString()); 102 + 103 + Carbon::setTestNow(); 104 + } 105 + 106 + public function test_update_from_record_does_nothing_without_mapper(): void 107 + { 108 + $this->app->forgetInstance(MapperRegistry::class); 109 + $this->app->singleton(MapperRegistry::class); 110 + 111 + $model = new SyncableModel(['content' => 'Original']); 112 + $record = new TestRecord(text: 'From remote'); 113 + 114 + $model->updateFromRecord($record, 'at://did/col/rkey', 'cid'); 115 + 116 + $this->assertSame('Original', $model->content); 117 + } 118 + 119 + public function test_inherits_has_atp_record_methods(): void 120 + { 121 + $model = new SyncableModel(['atp_uri' => 'at://did:plc:test/app.test.record/rkey']); 122 + 123 + $this->assertTrue($model->hasAtpRecord()); 124 + $this->assertSame('did:plc:test', $model->getAtpDid()); 125 + $this->assertSame('app.test.record', $model->getAtpCollection()); 126 + $this->assertSame('rkey', $model->getAtpRkey()); 127 + } 128 + }
+139
tests/Unit/Import/ImportResultTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Import; 4 + 5 + use SocialDept\AtpParity\Import\ImportResult; 6 + use SocialDept\AtpParity\Tests\TestCase; 7 + 8 + class ImportResultTest extends TestCase 9 + { 10 + public function test_success_creates_completed_result(): void 11 + { 12 + $result = ImportResult::success( 13 + did: 'did:plc:test123', 14 + collection: 'app.bsky.feed.post', 15 + synced: 50, 16 + skipped: 5, 17 + failed: 2 18 + ); 19 + 20 + $this->assertTrue($result->isSuccess()); 21 + $this->assertFalse($result->isPartial()); 22 + $this->assertFalse($result->isFailed()); 23 + $this->assertTrue($result->completed); 24 + $this->assertNull($result->error); 25 + $this->assertSame(50, $result->recordsSynced); 26 + $this->assertSame(5, $result->recordsSkipped); 27 + $this->assertSame(2, $result->recordsFailed); 28 + } 29 + 30 + public function test_partial_creates_incomplete_result(): void 31 + { 32 + $result = ImportResult::partial( 33 + did: 'did:plc:test123', 34 + collection: 'app.bsky.feed.post', 35 + synced: 100, 36 + cursor: 'abc123' 37 + ); 38 + 39 + $this->assertFalse($result->isSuccess()); 40 + $this->assertTrue($result->isPartial()); 41 + $this->assertFalse($result->isFailed()); 42 + $this->assertFalse($result->completed); 43 + $this->assertSame('abc123', $result->cursor); 44 + $this->assertNull($result->error); 45 + } 46 + 47 + public function test_failed_creates_error_result(): void 48 + { 49 + $result = ImportResult::failed( 50 + did: 'did:plc:test123', 51 + collection: 'app.bsky.feed.post', 52 + error: 'Connection failed' 53 + ); 54 + 55 + $this->assertFalse($result->isSuccess()); 56 + $this->assertFalse($result->isPartial()); // no records synced 57 + $this->assertTrue($result->isFailed()); 58 + $this->assertSame('Connection failed', $result->error); 59 + } 60 + 61 + public function test_failed_with_partial_progress(): void 62 + { 63 + $result = ImportResult::failed( 64 + did: 'did:plc:test123', 65 + collection: 'app.bsky.feed.post', 66 + error: 'Connection lost', 67 + synced: 50, 68 + cursor: 'xyz789' 69 + ); 70 + 71 + $this->assertTrue($result->isFailed()); 72 + $this->assertTrue($result->isPartial()); // has synced records 73 + $this->assertSame(50, $result->recordsSynced); 74 + $this->assertSame('xyz789', $result->cursor); 75 + } 76 + 77 + public function test_total_processed_sums_all_records(): void 78 + { 79 + $result = ImportResult::success( 80 + did: 'did:plc:test123', 81 + collection: 'app.bsky.feed.post', 82 + synced: 50, 83 + skipped: 10, 84 + failed: 5 85 + ); 86 + 87 + $this->assertSame(65, $result->totalProcessed()); 88 + } 89 + 90 + public function test_aggregate_combines_multiple_results(): void 91 + { 92 + $results = [ 93 + ImportResult::success('did:plc:test', 'app.bsky.feed.post', synced: 50), 94 + ImportResult::success('did:plc:test', 'app.bsky.feed.like', synced: 100, failed: 5), 95 + ]; 96 + 97 + $aggregate = ImportResult::aggregate('did:plc:test', $results); 98 + 99 + $this->assertTrue($aggregate->isSuccess()); 100 + $this->assertSame('*', $aggregate->collection); 101 + $this->assertSame(150, $aggregate->recordsSynced); 102 + $this->assertSame(5, $aggregate->recordsFailed); 103 + $this->assertNull($aggregate->error); 104 + } 105 + 106 + public function test_aggregate_marks_incomplete_when_any_incomplete(): void 107 + { 108 + $results = [ 109 + ImportResult::success('did:plc:test', 'app.bsky.feed.post', synced: 50), 110 + ImportResult::partial('did:plc:test', 'app.bsky.feed.like', synced: 100, cursor: 'abc'), 111 + ]; 112 + 113 + $aggregate = ImportResult::aggregate('did:plc:test', $results); 114 + 115 + $this->assertFalse($aggregate->completed); 116 + } 117 + 118 + public function test_aggregate_combines_errors(): void 119 + { 120 + $results = [ 121 + ImportResult::failed('did:plc:test', 'app.bsky.feed.post', error: 'Error 1'), 122 + ImportResult::failed('did:plc:test', 'app.bsky.feed.like', error: 'Error 2'), 123 + ]; 124 + 125 + $aggregate = ImportResult::aggregate('did:plc:test', $results); 126 + 127 + $this->assertTrue($aggregate->isFailed()); 128 + $this->assertStringContainsString('app.bsky.feed.post: Error 1', $aggregate->error); 129 + $this->assertStringContainsString('app.bsky.feed.like: Error 2', $aggregate->error); 130 + } 131 + 132 + public function test_aggregate_with_empty_array(): void 133 + { 134 + $aggregate = ImportResult::aggregate('did:plc:test', []); 135 + 136 + $this->assertTrue($aggregate->completed); 137 + $this->assertSame(0, $aggregate->recordsSynced); 138 + } 139 + }
+169
tests/Unit/Import/ImportServiceTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Import; 4 + 5 + use Illuminate\Support\Facades\Event; 6 + use Mockery; 7 + use SocialDept\AtpParity\Events\ImportCompleted; 8 + use SocialDept\AtpParity\Events\ImportFailed; 9 + use SocialDept\AtpParity\Events\ImportProgress; 10 + use SocialDept\AtpParity\Events\ImportStarted; 11 + use SocialDept\AtpParity\Import\ImportService; 12 + use SocialDept\AtpParity\Import\ImportState; 13 + use SocialDept\AtpParity\MapperRegistry; 14 + use SocialDept\AtpParity\Tests\Fixtures\TestMapper; 15 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 16 + use SocialDept\AtpParity\Tests\TestCase; 17 + use SocialDept\AtpResolver\Facades\Resolver; 18 + 19 + class ImportServiceTest extends TestCase 20 + { 21 + private ImportService $service; 22 + 23 + private MapperRegistry $registry; 24 + 25 + protected function setUp(): void 26 + { 27 + parent::setUp(); 28 + 29 + $this->registry = new MapperRegistry(); 30 + $this->registry->register(new TestMapper()); 31 + 32 + $this->service = new ImportService($this->registry); 33 + 34 + Event::fake(); 35 + } 36 + 37 + public function test_import_user_collection_returns_failed_when_no_mapper(): void 38 + { 39 + $this->app->forgetInstance(MapperRegistry::class); 40 + $emptyRegistry = new MapperRegistry(); 41 + $service = new ImportService($emptyRegistry); 42 + 43 + $result = $service->importUserCollection('did:plc:test', 'unknown.collection'); 44 + 45 + $this->assertTrue($result->isFailed()); 46 + $this->assertStringContainsString('No mapper registered', $result->error); 47 + } 48 + 49 + public function test_import_user_collection_fails_when_pds_not_resolved(): void 50 + { 51 + Resolver::shouldReceive('resolvePds') 52 + ->with('did:plc:test') 53 + ->andReturnNull(); 54 + 55 + $result = $this->service->importUserCollection('did:plc:test', 'app.test.record'); 56 + 57 + $this->assertTrue($result->isFailed()); 58 + $this->assertStringContainsString('Could not resolve PDS', $result->error); 59 + Event::assertDispatched(ImportFailed::class); 60 + } 61 + 62 + /** 63 + * @group integration 64 + */ 65 + public function test_import_user_collection_imports_records(): void 66 + { 67 + $this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking'); 68 + } 69 + 70 + /** 71 + * @group integration 72 + */ 73 + public function test_import_dispatches_events(): void 74 + { 75 + $this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking'); 76 + } 77 + 78 + /** 79 + * @group integration 80 + */ 81 + public function test_import_calls_progress_callback(): void 82 + { 83 + $this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking'); 84 + } 85 + 86 + /** 87 + * @group integration 88 + */ 89 + public function test_import_user_imports_multiple_collections(): void 90 + { 91 + $this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking'); 92 + } 93 + 94 + public function test_get_status_returns_import_state(): void 95 + { 96 + ImportState::create([ 97 + 'did' => 'did:plc:test', 98 + 'collection' => 'app.test.record', 99 + 'status' => 'completed', 100 + 'records_synced' => 50, 101 + ]); 102 + 103 + $state = $this->service->getStatus('did:plc:test', 'app.test.record'); 104 + 105 + $this->assertNotNull($state); 106 + $this->assertSame('completed', $state->status); 107 + $this->assertSame(50, $state->records_synced); 108 + } 109 + 110 + public function test_get_status_returns_null_when_not_found(): void 111 + { 112 + $state = $this->service->getStatus('did:plc:unknown', 'unknown'); 113 + 114 + $this->assertNull($state); 115 + } 116 + 117 + public function test_is_imported_returns_true_when_completed(): void 118 + { 119 + ImportState::create([ 120 + 'did' => 'did:plc:test', 121 + 'collection' => 'app.test.record', 122 + 'status' => 'completed', 123 + ]); 124 + 125 + $this->assertTrue($this->service->isImported('did:plc:test', 'app.test.record')); 126 + } 127 + 128 + public function test_is_imported_returns_false_when_not_started(): void 129 + { 130 + $this->assertFalse($this->service->isImported('did:plc:test', 'app.test.record')); 131 + } 132 + 133 + public function test_reset_deletes_import_state(): void 134 + { 135 + ImportState::create([ 136 + 'did' => 'did:plc:test', 137 + 'collection' => 'app.test.record', 138 + 'status' => 'completed', 139 + ]); 140 + 141 + $this->service->reset('did:plc:test', 'app.test.record'); 142 + 143 + $this->assertNull($this->service->getStatus('did:plc:test', 'app.test.record')); 144 + } 145 + 146 + public function test_import_skips_already_completed(): void 147 + { 148 + ImportState::create([ 149 + 'did' => 'did:plc:test', 150 + 'collection' => 'app.test.record', 151 + 'status' => 'completed', 152 + 'records_synced' => 100, 153 + ]); 154 + 155 + // No mocking needed - should return cached result 156 + $result = $this->service->importUserCollection('did:plc:test', 'app.test.record'); 157 + 158 + $this->assertTrue($result->completed); 159 + $this->assertSame(100, $result->recordsSynced); 160 + } 161 + 162 + /** 163 + * @group integration 164 + */ 165 + public function test_import_handles_record_failures_gracefully(): void 166 + { 167 + $this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking'); 168 + } 169 + }
+107
tests/Unit/MapperRegistryTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit; 4 + 5 + use SocialDept\AtpParity\MapperRegistry; 6 + use SocialDept\AtpParity\Tests\Fixtures\TestMapper; 7 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 8 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 9 + use SocialDept\AtpParity\Tests\TestCase; 10 + 11 + class MapperRegistryTest extends TestCase 12 + { 13 + private MapperRegistry $registry; 14 + 15 + protected function setUp(): void 16 + { 17 + parent::setUp(); 18 + $this->registry = new MapperRegistry(); 19 + } 20 + 21 + public function test_register_adds_mapper_to_all_indices(): void 22 + { 23 + $mapper = new TestMapper(); 24 + 25 + $this->registry->register($mapper); 26 + 27 + $this->assertSame($mapper, $this->registry->forRecord(TestRecord::class)); 28 + $this->assertSame($mapper, $this->registry->forModel(TestModel::class)); 29 + $this->assertSame($mapper, $this->registry->forLexicon('app.test.record')); 30 + } 31 + 32 + public function test_for_record_returns_null_for_unregistered_class(): void 33 + { 34 + $result = $this->registry->forRecord('NonExistent\\Record'); 35 + 36 + $this->assertNull($result); 37 + } 38 + 39 + public function test_for_model_returns_null_for_unregistered_class(): void 40 + { 41 + $result = $this->registry->forModel('NonExistent\\Model'); 42 + 43 + $this->assertNull($result); 44 + } 45 + 46 + public function test_for_lexicon_returns_null_for_unregistered_nsid(): void 47 + { 48 + $result = $this->registry->forLexicon('app.unknown.record'); 49 + 50 + $this->assertNull($result); 51 + } 52 + 53 + public function test_has_lexicon_returns_true_when_registered(): void 54 + { 55 + $this->registry->register(new TestMapper()); 56 + 57 + $this->assertTrue($this->registry->hasLexicon('app.test.record')); 58 + } 59 + 60 + public function test_has_lexicon_returns_false_when_not_registered(): void 61 + { 62 + $this->assertFalse($this->registry->hasLexicon('app.unknown.record')); 63 + } 64 + 65 + public function test_lexicons_returns_all_registered_nsids(): void 66 + { 67 + $this->registry->register(new TestMapper()); 68 + 69 + $lexicons = $this->registry->lexicons(); 70 + 71 + $this->assertContains('app.test.record', $lexicons); 72 + $this->assertCount(1, $lexicons); 73 + } 74 + 75 + public function test_lexicons_returns_empty_array_when_no_mappers_registered(): void 76 + { 77 + $this->assertEmpty($this->registry->lexicons()); 78 + } 79 + 80 + public function test_all_returns_all_registered_mappers(): void 81 + { 82 + $mapper = new TestMapper(); 83 + $this->registry->register($mapper); 84 + 85 + $all = $this->registry->all(); 86 + 87 + $this->assertCount(1, $all); 88 + $this->assertSame($mapper, $all[0]); 89 + } 90 + 91 + public function test_all_returns_empty_array_when_no_mappers_registered(): void 92 + { 93 + $this->assertEmpty($this->registry->all()); 94 + } 95 + 96 + public function test_registering_same_mapper_twice_overwrites(): void 97 + { 98 + $mapper1 = new TestMapper(); 99 + $mapper2 = new TestMapper(); 100 + 101 + $this->registry->register($mapper1); 102 + $this->registry->register($mapper2); 103 + 104 + $this->assertSame($mapper2, $this->registry->forRecord(TestRecord::class)); 105 + $this->assertCount(1, $this->registry->all()); 106 + } 107 + }
+69
tests/Unit/Publish/PublishResultTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Publish; 4 + 5 + use SocialDept\AtpParity\Publish\PublishResult; 6 + use SocialDept\AtpParity\Tests\TestCase; 7 + 8 + class PublishResultTest extends TestCase 9 + { 10 + public function test_success_creates_successful_result(): void 11 + { 12 + $result = PublishResult::success( 13 + uri: 'at://did:plc:test/app.bsky.feed.post/abc123', 14 + cid: 'bafyreiabc123' 15 + ); 16 + 17 + $this->assertTrue($result->isSuccess()); 18 + $this->assertFalse($result->isFailed()); 19 + $this->assertSame('at://did:plc:test/app.bsky.feed.post/abc123', $result->uri); 20 + $this->assertSame('bafyreiabc123', $result->cid); 21 + $this->assertNull($result->error); 22 + } 23 + 24 + public function test_failed_creates_failed_result(): void 25 + { 26 + $result = PublishResult::failed('Authentication required'); 27 + 28 + $this->assertFalse($result->isSuccess()); 29 + $this->assertTrue($result->isFailed()); 30 + $this->assertNull($result->uri); 31 + $this->assertNull($result->cid); 32 + $this->assertSame('Authentication required', $result->error); 33 + } 34 + 35 + public function test_is_success_returns_correct_boolean(): void 36 + { 37 + $success = new PublishResult(success: true); 38 + $failure = new PublishResult(success: false); 39 + 40 + $this->assertTrue($success->isSuccess()); 41 + $this->assertFalse($failure->isSuccess()); 42 + } 43 + 44 + public function test_is_failed_returns_correct_boolean(): void 45 + { 46 + $success = new PublishResult(success: true); 47 + $failure = new PublishResult(success: false); 48 + 49 + $this->assertFalse($success->isFailed()); 50 + $this->assertTrue($failure->isFailed()); 51 + } 52 + 53 + public function test_success_result_properties_are_accessible(): void 54 + { 55 + $result = PublishResult::success('at://did/col/rkey', 'cid123'); 56 + 57 + $this->assertTrue($result->success); 58 + $this->assertSame('at://did/col/rkey', $result->uri); 59 + $this->assertSame('cid123', $result->cid); 60 + } 61 + 62 + public function test_failed_result_error_is_accessible(): void 63 + { 64 + $result = PublishResult::failed('Something went wrong'); 65 + 66 + $this->assertFalse($result->success); 67 + $this->assertSame('Something went wrong', $result->error); 68 + } 69 + }
+299
tests/Unit/Publish/PublishServiceTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Publish; 4 + 5 + use Illuminate\Support\Facades\Event; 6 + use Mockery; 7 + use SocialDept\AtpParity\Events\RecordPublished; 8 + use SocialDept\AtpParity\Events\RecordUnpublished; 9 + use SocialDept\AtpParity\MapperRegistry; 10 + use SocialDept\AtpParity\Publish\PublishService; 11 + use SocialDept\AtpParity\Tests\Fixtures\TestMapper; 12 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 13 + use SocialDept\AtpParity\Tests\TestCase; 14 + 15 + class PublishServiceTest extends TestCase 16 + { 17 + private PublishService $service; 18 + 19 + private MapperRegistry $registry; 20 + 21 + protected function setUp(): void 22 + { 23 + parent::setUp(); 24 + 25 + $this->registry = new MapperRegistry(); 26 + $this->registry->register(new TestMapper()); 27 + 28 + $this->service = new PublishService($this->registry); 29 + 30 + Event::fake(); 31 + } 32 + 33 + public function test_publish_fails_when_no_did_available(): void 34 + { 35 + $model = new TestModel(['content' => 'Test']); 36 + 37 + $result = $this->service->publish($model); 38 + 39 + $this->assertTrue($result->isFailed()); 40 + $this->assertStringContainsString('No DID', $result->error); 41 + } 42 + 43 + public function test_publish_uses_did_from_model_column(): void 44 + { 45 + $model = new TestModel([ 46 + 'content' => 'Test', 47 + 'did' => 'did:plc:test123', 48 + ]); 49 + 50 + $this->mockAtpClient('did:plc:test123', 'at://did:plc:test123/app.test.record/abc', 'cid123'); 51 + 52 + $result = $this->service->publish($model); 53 + 54 + $this->assertTrue($result->isSuccess()); 55 + $this->assertSame('at://did:plc:test123/app.test.record/abc', $result->uri); 56 + } 57 + 58 + public function test_publish_as_creates_record_with_specified_did(): void 59 + { 60 + $model = new TestModel(['content' => 'Hello world']); 61 + 62 + $this->mockAtpClient('did:plc:specified', 'at://did:plc:specified/app.test.record/xyz', 'newcid'); 63 + 64 + $result = $this->service->publishAs('did:plc:specified', $model); 65 + 66 + $this->assertTrue($result->isSuccess()); 67 + $this->assertSame('at://did:plc:specified/app.test.record/xyz', $result->uri); 68 + $this->assertSame('newcid', $result->cid); 69 + } 70 + 71 + public function test_publish_as_updates_model_metadata(): void 72 + { 73 + $model = TestModel::create(['content' => 'Test']); 74 + 75 + $this->mockAtpClient('did:plc:test', 'at://did:plc:test/app.test.record/rkey', 'cid'); 76 + 77 + $this->service->publishAs('did:plc:test', $model); 78 + 79 + $model->refresh(); 80 + $this->assertSame('at://did:plc:test/app.test.record/rkey', $model->atp_uri); 81 + $this->assertSame('cid', $model->atp_cid); 82 + } 83 + 84 + public function test_publish_dispatches_record_published_event(): void 85 + { 86 + $model = new TestModel(['content' => 'Test', 'did' => 'did:plc:test']); 87 + 88 + $this->mockAtpClient('did:plc:test', 'at://did/col/rkey', 'cid'); 89 + 90 + $this->service->publish($model); 91 + 92 + Event::assertDispatched(RecordPublished::class); 93 + } 94 + 95 + public function test_publish_redirects_to_update_when_already_published(): void 96 + { 97 + $model = TestModel::create([ 98 + 'content' => 'Existing', 99 + 'atp_uri' => 'at://did:plc:test/app.test.record/existing', 100 + 'atp_cid' => 'oldcid', 101 + ]); 102 + 103 + $this->mockAtpClientForUpdate('did:plc:test', 'at://did:plc:test/app.test.record/existing', 'newcid'); 104 + 105 + $result = $this->service->publishAs('did:plc:test', $model); 106 + 107 + $this->assertTrue($result->isSuccess()); 108 + } 109 + 110 + public function test_update_fails_when_not_published(): void 111 + { 112 + $model = new TestModel(['content' => 'Not published']); 113 + 114 + $result = $this->service->update($model); 115 + 116 + $this->assertTrue($result->isFailed()); 117 + $this->assertStringContainsString('not been published', $result->error); 118 + } 119 + 120 + public function test_update_calls_put_record(): void 121 + { 122 + $model = TestModel::create([ 123 + 'content' => 'Updated content', 124 + 'atp_uri' => 'at://did:plc:test/app.test.record/rkey123', 125 + 'atp_cid' => 'oldcid', 126 + ]); 127 + 128 + $this->mockAtpClientForUpdate('did:plc:test', 'at://did:plc:test/app.test.record/rkey123', 'updatedcid'); 129 + 130 + $result = $this->service->update($model); 131 + 132 + $this->assertTrue($result->isSuccess()); 133 + $this->assertSame('updatedcid', $result->cid); 134 + } 135 + 136 + public function test_delete_removes_record_and_clears_metadata(): void 137 + { 138 + $model = TestModel::create([ 139 + 'content' => 'To delete', 140 + 'atp_uri' => 'at://did:plc:test/app.test.record/todelete', 141 + 'atp_cid' => 'cid', 142 + ]); 143 + 144 + $this->mockAtpClientForDelete('did:plc:test'); 145 + 146 + $result = $this->service->delete($model); 147 + 148 + $this->assertTrue($result); 149 + 150 + $model->refresh(); 151 + $this->assertNull($model->atp_uri); 152 + $this->assertNull($model->atp_cid); 153 + } 154 + 155 + public function test_delete_dispatches_record_unpublished_event(): void 156 + { 157 + $model = TestModel::create([ 158 + 'content' => 'To delete', 159 + 'atp_uri' => 'at://did:plc:test/app.test.record/xyz', 160 + 'atp_cid' => 'cid', 161 + ]); 162 + 163 + $this->mockAtpClientForDelete('did:plc:test'); 164 + 165 + $this->service->delete($model); 166 + 167 + Event::assertDispatched(RecordUnpublished::class); 168 + } 169 + 170 + public function test_delete_returns_false_when_not_published(): void 171 + { 172 + $model = new TestModel(['content' => 'Not published']); 173 + 174 + $result = $this->service->delete($model); 175 + 176 + $this->assertFalse($result); 177 + } 178 + 179 + public function test_publish_handles_exception_gracefully(): void 180 + { 181 + $model = new TestModel(['content' => 'Test', 'did' => 'did:plc:test']); 182 + 183 + $this->mockAtpClientWithException('did:plc:test', 'API error occurred'); 184 + 185 + $result = $this->service->publish($model); 186 + 187 + $this->assertTrue($result->isFailed()); 188 + $this->assertSame('API error occurred', $result->error); 189 + } 190 + 191 + public function test_update_fails_for_invalid_uri(): void 192 + { 193 + $model = new TestModel([ 194 + 'content' => 'Test', 195 + 'atp_uri' => 'invalid-uri-format', 196 + ]); 197 + 198 + $result = $this->service->update($model); 199 + 200 + $this->assertTrue($result->isFailed()); 201 + $this->assertStringContainsString('Invalid AT Protocol URI', $result->error); 202 + } 203 + 204 + /** 205 + * Mock AtpClient for create operations. 206 + */ 207 + protected function mockAtpClient(string $did, string $returnUri, string $returnCid): void 208 + { 209 + $response = new \stdClass(); 210 + $response->uri = $returnUri; 211 + $response->cid = $returnCid; 212 + 213 + $repoClient = Mockery::mock(); 214 + $repoClient->shouldReceive('createRecord') 215 + ->andReturn($response); 216 + 217 + // Create client mock with property chain (no typed properties) 218 + $atprotoClient = Mockery::mock(); 219 + $atprotoClient->repo = $repoClient; 220 + 221 + $atpClient = Mockery::mock(); 222 + $atpClient->atproto = $atprotoClient; 223 + 224 + // Bind a manager mock to the container 225 + $manager = Mockery::mock(); 226 + $manager->shouldReceive('as') 227 + ->with($did) 228 + ->andReturn($atpClient); 229 + 230 + $this->app->instance('atp-client', $manager); 231 + } 232 + 233 + /** 234 + * Mock AtpClient for update operations. 235 + */ 236 + protected function mockAtpClientForUpdate(string $did, string $returnUri, string $returnCid): void 237 + { 238 + $response = new \stdClass(); 239 + $response->uri = $returnUri; 240 + $response->cid = $returnCid; 241 + 242 + $repoClient = Mockery::mock(); 243 + $repoClient->shouldReceive('putRecord') 244 + ->andReturn($response); 245 + 246 + // Create client mock with property chain (no typed properties) 247 + $atprotoClient = Mockery::mock(); 248 + $atprotoClient->repo = $repoClient; 249 + 250 + $atpClient = Mockery::mock(); 251 + $atpClient->atproto = $atprotoClient; 252 + 253 + // Bind a manager mock to the container 254 + $manager = Mockery::mock(); 255 + $manager->shouldReceive('as') 256 + ->with($did) 257 + ->andReturn($atpClient); 258 + 259 + $this->app->instance('atp-client', $manager); 260 + } 261 + 262 + /** 263 + * Mock AtpClient for delete operations. 264 + */ 265 + protected function mockAtpClientForDelete(string $did): void 266 + { 267 + $repoClient = Mockery::mock(); 268 + $repoClient->shouldReceive('deleteRecord') 269 + ->andReturnNull(); 270 + 271 + // Create client mock with property chain (no typed properties) 272 + $atprotoClient = Mockery::mock(); 273 + $atprotoClient->repo = $repoClient; 274 + 275 + $atpClient = Mockery::mock(); 276 + $atpClient->atproto = $atprotoClient; 277 + 278 + // Bind a manager mock to the container 279 + $manager = Mockery::mock(); 280 + $manager->shouldReceive('as') 281 + ->with($did) 282 + ->andReturn($atpClient); 283 + 284 + $this->app->instance('atp-client', $manager); 285 + } 286 + 287 + /** 288 + * Mock AtpClient to throw exception. 289 + */ 290 + protected function mockAtpClientWithException(string $did, string $message): void 291 + { 292 + $manager = Mockery::mock(); 293 + $manager->shouldReceive('as') 294 + ->with($did) 295 + ->andThrow(new \Exception($message)); 296 + 297 + $this->app->instance('atp-client', $manager); 298 + } 299 + }
+184
tests/Unit/RecordMapperTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit; 4 + 5 + use SocialDept\AtpParity\Tests\Fixtures\TestMapper; 6 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 7 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 8 + use SocialDept\AtpParity\Tests\TestCase; 9 + 10 + class RecordMapperTest extends TestCase 11 + { 12 + private TestMapper $mapper; 13 + 14 + protected function setUp(): void 15 + { 16 + parent::setUp(); 17 + $this->mapper = new TestMapper(); 18 + } 19 + 20 + public function test_record_class_returns_correct_class(): void 21 + { 22 + $this->assertSame(TestRecord::class, $this->mapper->recordClass()); 23 + } 24 + 25 + public function test_model_class_returns_correct_class(): void 26 + { 27 + $this->assertSame(TestModel::class, $this->mapper->modelClass()); 28 + } 29 + 30 + public function test_lexicon_returns_record_lexicon(): void 31 + { 32 + $this->assertSame('app.test.record', $this->mapper->lexicon()); 33 + } 34 + 35 + public function test_to_model_creates_model_with_attributes(): void 36 + { 37 + $record = new TestRecord(text: 'Hello world'); 38 + 39 + $model = $this->mapper->toModel($record); 40 + 41 + $this->assertInstanceOf(TestModel::class, $model); 42 + $this->assertSame('Hello world', $model->content); 43 + $this->assertFalse($model->exists); 44 + } 45 + 46 + public function test_to_model_applies_meta(): void 47 + { 48 + $record = new TestRecord(text: 'Test'); 49 + $meta = [ 50 + 'uri' => 'at://did:plc:test/app.test.record/abc123', 51 + 'cid' => 'bafyreiabc', 52 + ]; 53 + 54 + $model = $this->mapper->toModel($record, $meta); 55 + 56 + $this->assertSame('at://did:plc:test/app.test.record/abc123', $model->atp_uri); 57 + $this->assertSame('bafyreiabc', $model->atp_cid); 58 + } 59 + 60 + public function test_to_record_converts_model_to_record(): void 61 + { 62 + $model = new TestModel(['content' => 'Test content']); 63 + 64 + $record = $this->mapper->toRecord($model); 65 + 66 + $this->assertInstanceOf(TestRecord::class, $record); 67 + $this->assertSame('Test content', $record->text); 68 + } 69 + 70 + public function test_update_model_fills_model_without_saving(): void 71 + { 72 + $model = new TestModel(['content' => 'Original']); 73 + $model->save(); 74 + $originalUpdatedAt = $model->updated_at; 75 + 76 + $record = new TestRecord(text: 'Updated'); 77 + 78 + $result = $this->mapper->updateModel($model, $record); 79 + 80 + $this->assertSame($model, $result); 81 + $this->assertSame('Updated', $model->content); 82 + // Model is filled but not saved 83 + $this->assertTrue($model->isDirty('content')); 84 + } 85 + 86 + public function test_update_model_applies_meta(): void 87 + { 88 + $model = new TestModel(['content' => 'Original']); 89 + $record = new TestRecord(text: 'Updated'); 90 + $meta = ['uri' => 'at://test/col/rkey', 'cid' => 'cid123']; 91 + 92 + $this->mapper->updateModel($model, $record, $meta); 93 + 94 + $this->assertSame('at://test/col/rkey', $model->atp_uri); 95 + $this->assertSame('cid123', $model->atp_cid); 96 + } 97 + 98 + public function test_find_by_uri_returns_model_when_exists(): void 99 + { 100 + $model = TestModel::create([ 101 + 'content' => 'Test', 102 + 'atp_uri' => 'at://did:plc:test/app.test.record/abc', 103 + ]); 104 + 105 + $found = $this->mapper->findByUri('at://did:plc:test/app.test.record/abc'); 106 + 107 + $this->assertNotNull($found); 108 + $this->assertSame($model->id, $found->id); 109 + } 110 + 111 + public function test_find_by_uri_returns_null_when_not_exists(): void 112 + { 113 + $found = $this->mapper->findByUri('at://nonexistent/col/rkey'); 114 + 115 + $this->assertNull($found); 116 + } 117 + 118 + public function test_upsert_creates_new_model_when_uri_not_found(): void 119 + { 120 + $record = new TestRecord(text: 'New record'); 121 + $meta = [ 122 + 'uri' => 'at://did:plc:test/app.test.record/new123', 123 + 'cid' => 'bafyrei123', 124 + ]; 125 + 126 + $model = $this->mapper->upsert($record, $meta); 127 + 128 + $this->assertTrue($model->exists); 129 + $this->assertSame('New record', $model->content); 130 + $this->assertSame('at://did:plc:test/app.test.record/new123', $model->atp_uri); 131 + } 132 + 133 + public function test_upsert_updates_existing_model_when_uri_found(): void 134 + { 135 + $existing = TestModel::create([ 136 + 'content' => 'Original', 137 + 'atp_uri' => 'at://did:plc:test/app.test.record/exists', 138 + 'atp_cid' => 'old_cid', 139 + ]); 140 + 141 + $record = new TestRecord(text: 'Updated content'); 142 + $meta = [ 143 + 'uri' => 'at://did:plc:test/app.test.record/exists', 144 + 'cid' => 'new_cid', 145 + ]; 146 + 147 + $model = $this->mapper->upsert($record, $meta); 148 + 149 + $this->assertSame($existing->id, $model->id); 150 + $this->assertSame('Updated content', $model->content); 151 + $this->assertSame('new_cid', $model->atp_cid); 152 + } 153 + 154 + public function test_upsert_without_uri_creates_new_model(): void 155 + { 156 + $record = new TestRecord(text: 'No URI'); 157 + 158 + $model = $this->mapper->upsert($record, []); 159 + 160 + $this->assertTrue($model->exists); 161 + $this->assertSame('No URI', $model->content); 162 + $this->assertNull($model->atp_uri); 163 + } 164 + 165 + public function test_delete_by_uri_deletes_model_when_exists(): void 166 + { 167 + TestModel::create([ 168 + 'content' => 'To delete', 169 + 'atp_uri' => 'at://did:plc:test/app.test.record/todelete', 170 + ]); 171 + 172 + $result = $this->mapper->deleteByUri('at://did:plc:test/app.test.record/todelete'); 173 + 174 + $this->assertTrue($result); 175 + $this->assertNull($this->mapper->findByUri('at://did:plc:test/app.test.record/todelete')); 176 + } 177 + 178 + public function test_delete_by_uri_returns_false_when_not_exists(): void 179 + { 180 + $result = $this->mapper->deleteByUri('at://nonexistent/col/rkey'); 181 + 182 + $this->assertFalse($result); 183 + } 184 + }
+137
tests/Unit/SchemaMapperTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit; 4 + 5 + use SocialDept\AtpParity\Support\SchemaMapper; 6 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 7 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 8 + use SocialDept\AtpParity\Tests\TestCase; 9 + 10 + class SchemaMapperTest extends TestCase 11 + { 12 + public function test_record_class_returns_schema_class(): void 13 + { 14 + $mapper = new SchemaMapper( 15 + schemaClass: TestRecord::class, 16 + modelClass: TestModel::class, 17 + toAttributes: fn () => [], 18 + toRecordData: fn () => [], 19 + ); 20 + 21 + $this->assertSame(TestRecord::class, $mapper->recordClass()); 22 + } 23 + 24 + public function test_model_class_returns_model_class(): void 25 + { 26 + $mapper = new SchemaMapper( 27 + schemaClass: TestRecord::class, 28 + modelClass: TestModel::class, 29 + toAttributes: fn () => [], 30 + toRecordData: fn () => [], 31 + ); 32 + 33 + $this->assertSame(TestModel::class, $mapper->modelClass()); 34 + } 35 + 36 + public function test_lexicon_returns_schema_lexicon(): void 37 + { 38 + $mapper = new SchemaMapper( 39 + schemaClass: TestRecord::class, 40 + modelClass: TestModel::class, 41 + toAttributes: fn () => [], 42 + toRecordData: fn () => [], 43 + ); 44 + 45 + $this->assertSame('app.test.record', $mapper->lexicon()); 46 + } 47 + 48 + public function test_to_model_invokes_to_attributes_closure(): void 49 + { 50 + $closureCalled = false; 51 + 52 + $mapper = new SchemaMapper( 53 + schemaClass: TestRecord::class, 54 + modelClass: TestModel::class, 55 + toAttributes: function (TestRecord $record) use (&$closureCalled) { 56 + $closureCalled = true; 57 + 58 + return ['content' => $record->text.'_transformed']; 59 + }, 60 + toRecordData: fn () => [], 61 + ); 62 + 63 + $record = new TestRecord(text: 'original'); 64 + $model = $mapper->toModel($record); 65 + 66 + $this->assertTrue($closureCalled); 67 + $this->assertSame('original_transformed', $model->content); 68 + } 69 + 70 + public function test_to_record_invokes_to_record_data_closure(): void 71 + { 72 + $closureCalled = false; 73 + 74 + $mapper = new SchemaMapper( 75 + schemaClass: TestRecord::class, 76 + modelClass: TestModel::class, 77 + toAttributes: fn () => [], 78 + toRecordData: function (TestModel $model) use (&$closureCalled) { 79 + $closureCalled = true; 80 + 81 + return ['text' => strtoupper($model->content)]; 82 + }, 83 + ); 84 + 85 + $model = new TestModel(['content' => 'hello']); 86 + $record = $mapper->toRecord($model); 87 + 88 + $this->assertTrue($closureCalled); 89 + $this->assertSame('HELLO', $record->text); 90 + } 91 + 92 + public function test_closures_receive_correct_types(): void 93 + { 94 + $receivedRecordType = null; 95 + $receivedModelType = null; 96 + 97 + $mapper = new SchemaMapper( 98 + schemaClass: TestRecord::class, 99 + modelClass: TestModel::class, 100 + toAttributes: function ($record) use (&$receivedRecordType) { 101 + $receivedRecordType = get_class($record); 102 + 103 + return ['content' => $record->text]; 104 + }, 105 + toRecordData: function ($model) use (&$receivedModelType) { 106 + $receivedModelType = get_class($model); 107 + 108 + return ['text' => $model->content]; 109 + }, 110 + ); 111 + 112 + $mapper->toModel(new TestRecord(text: 'test')); 113 + $mapper->toRecord(new TestModel(['content' => 'test'])); 114 + 115 + $this->assertSame(TestRecord::class, $receivedRecordType); 116 + $this->assertSame(TestModel::class, $receivedModelType); 117 + } 118 + 119 + public function test_upsert_works_with_schema_mapper(): void 120 + { 121 + $mapper = new SchemaMapper( 122 + schemaClass: TestRecord::class, 123 + modelClass: TestModel::class, 124 + toAttributes: fn (TestRecord $r) => ['content' => $r->text], 125 + toRecordData: fn (TestModel $m) => ['text' => $m->content], 126 + ); 127 + 128 + $record = new TestRecord(text: 'schema mapper test'); 129 + $meta = ['uri' => 'at://test/col/rkey', 'cid' => 'cid123']; 130 + 131 + $model = $mapper->upsert($record, $meta); 132 + 133 + $this->assertTrue($model->exists); 134 + $this->assertSame('schema mapper test', $model->content); 135 + $this->assertSame('at://test/col/rkey', $model->atp_uri); 136 + } 137 + }
+118
tests/Unit/Sync/ConflictDetectorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Sync; 4 + 5 + use Carbon\Carbon; 6 + use SocialDept\AtpParity\Sync\ConflictDetector; 7 + use SocialDept\AtpParity\Tests\Fixtures\SyncableModel; 8 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 9 + use SocialDept\AtpParity\Tests\Fixtures\TestRecord; 10 + use SocialDept\AtpParity\Tests\TestCase; 11 + 12 + class ConflictDetectorTest extends TestCase 13 + { 14 + private ConflictDetector $detector; 15 + 16 + protected function setUp(): void 17 + { 18 + parent::setUp(); 19 + $this->detector = new ConflictDetector(); 20 + } 21 + 22 + public function test_no_conflict_when_cid_matches(): void 23 + { 24 + $model = new TestModel([ 25 + 'atp_cid' => 'samecid123', 26 + 'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'), 27 + 'updated_at' => Carbon::parse('2024-01-15 13:00:00'), // local changes 28 + ]); 29 + $record = new TestRecord(text: 'Remote content'); 30 + 31 + $hasConflict = $this->detector->hasConflict($model, $record, 'samecid123'); 32 + 33 + $this->assertFalse($hasConflict); 34 + } 35 + 36 + public function test_no_conflict_when_no_local_changes(): void 37 + { 38 + $model = new TestModel([ 39 + 'atp_cid' => 'oldcid', 40 + 'atp_synced_at' => Carbon::parse('2024-01-15 13:00:00'), 41 + 'updated_at' => Carbon::parse('2024-01-15 12:00:00'), // updated before sync 42 + ]); 43 + $record = new TestRecord(text: 'Remote content'); 44 + 45 + $hasConflict = $this->detector->hasConflict($model, $record, 'newcid'); 46 + 47 + $this->assertFalse($hasConflict); 48 + } 49 + 50 + public function test_conflict_when_cid_differs_and_local_changes(): void 51 + { 52 + $model = new TestModel([ 53 + 'atp_cid' => 'oldcid', 54 + 'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'), 55 + 'updated_at' => Carbon::parse('2024-01-15 13:00:00'), // local changes 56 + ]); 57 + $record = new TestRecord(text: 'Remote content'); 58 + 59 + $hasConflict = $this->detector->hasConflict($model, $record, 'newcid'); 60 + 61 + $this->assertTrue($hasConflict); 62 + } 63 + 64 + public function test_conflict_when_never_synced(): void 65 + { 66 + $model = new TestModel([ 67 + 'atp_cid' => 'cid', 68 + // No atp_synced_at means never synced, which implies local changes 69 + ]); 70 + $record = new TestRecord(text: 'Remote'); 71 + 72 + $hasConflict = $this->detector->hasConflict($model, $record, 'differentcid'); 73 + 74 + $this->assertTrue($hasConflict); 75 + } 76 + 77 + public function test_uses_syncs_with_atp_trait_method(): void 78 + { 79 + $model = new SyncableModel([ 80 + 'atp_cid' => 'oldcid', 81 + 'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'), 82 + 'updated_at' => Carbon::parse('2024-01-15 13:00:00'), 83 + ]); 84 + $record = new TestRecord(text: 'Remote'); 85 + 86 + $hasConflict = $this->detector->hasConflict($model, $record, 'newcid'); 87 + 88 + $this->assertTrue($hasConflict); 89 + } 90 + 91 + public function test_no_conflict_when_synced_after_update_with_trait(): void 92 + { 93 + $model = new SyncableModel([ 94 + 'atp_cid' => 'oldcid', 95 + 'updated_at' => Carbon::parse('2024-01-15 12:00:00'), 96 + 'atp_synced_at' => Carbon::parse('2024-01-15 13:00:00'), 97 + ]); 98 + $record = new TestRecord(text: 'Remote'); 99 + 100 + $hasConflict = $this->detector->hasConflict($model, $record, 'newcid'); 101 + 102 + $this->assertFalse($hasConflict); 103 + } 104 + 105 + public function test_no_conflict_without_updated_at(): void 106 + { 107 + $model = new TestModel([ 108 + 'atp_cid' => 'cid', 109 + 'atp_synced_at' => Carbon::now(), 110 + // No updated_at 111 + ]); 112 + $record = new TestRecord(text: 'Remote'); 113 + 114 + $hasConflict = $this->detector->hasConflict($model, $record, 'newcid'); 115 + 116 + $this->assertFalse($hasConflict); 117 + } 118 + }
+66
tests/Unit/Sync/ConflictResolutionTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Sync; 4 + 5 + use SocialDept\AtpParity\Sync\ConflictResolution; 6 + use SocialDept\AtpParity\Sync\PendingConflict; 7 + use SocialDept\AtpParity\Tests\Fixtures\TestModel; 8 + use SocialDept\AtpParity\Tests\TestCase; 9 + 10 + class ConflictResolutionTest extends TestCase 11 + { 12 + public function test_remote_wins_creates_resolved_resolution(): void 13 + { 14 + $model = new TestModel(); 15 + 16 + $resolution = ConflictResolution::remoteWins($model); 17 + 18 + $this->assertTrue($resolution->isResolved()); 19 + $this->assertFalse($resolution->isPending()); 20 + $this->assertSame('remote', $resolution->winner); 21 + $this->assertSame($model, $resolution->model); 22 + $this->assertNull($resolution->pending); 23 + } 24 + 25 + public function test_local_wins_creates_resolved_resolution(): void 26 + { 27 + $model = new TestModel(); 28 + 29 + $resolution = ConflictResolution::localWins($model); 30 + 31 + $this->assertTrue($resolution->isResolved()); 32 + $this->assertFalse($resolution->isPending()); 33 + $this->assertSame('local', $resolution->winner); 34 + $this->assertSame($model, $resolution->model); 35 + $this->assertNull($resolution->pending); 36 + } 37 + 38 + public function test_pending_creates_unresolved_resolution(): void 39 + { 40 + $pending = new PendingConflict(); 41 + 42 + $resolution = ConflictResolution::pending($pending); 43 + 44 + $this->assertFalse($resolution->isResolved()); 45 + $this->assertTrue($resolution->isPending()); 46 + $this->assertSame('manual', $resolution->winner); 47 + $this->assertNull($resolution->model); 48 + $this->assertSame($pending, $resolution->pending); 49 + } 50 + 51 + public function test_is_resolved_returns_correct_boolean(): void 52 + { 53 + $resolved = new ConflictResolution(resolved: true, winner: 'remote'); 54 + $unresolved = new ConflictResolution(resolved: false, winner: 'manual'); 55 + 56 + $this->assertTrue($resolved->isResolved()); 57 + $this->assertFalse($unresolved->isResolved()); 58 + } 59 + 60 + public function test_is_pending_returns_false_when_no_pending_conflict(): void 61 + { 62 + $resolution = new ConflictResolution(resolved: false, winner: 'manual'); 63 + 64 + $this->assertFalse($resolution->isPending()); 65 + } 66 + }
+83
tests/Unit/Sync/ConflictStrategyTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpParity\Tests\Unit\Sync; 4 + 5 + use SocialDept\AtpParity\Sync\ConflictStrategy; 6 + use SocialDept\AtpParity\Tests\TestCase; 7 + 8 + class ConflictStrategyTest extends TestCase 9 + { 10 + public function test_remote_wins_has_correct_value(): void 11 + { 12 + $this->assertSame('remote', ConflictStrategy::RemoteWins->value); 13 + } 14 + 15 + public function test_local_wins_has_correct_value(): void 16 + { 17 + $this->assertSame('local', ConflictStrategy::LocalWins->value); 18 + } 19 + 20 + public function test_newest_wins_has_correct_value(): void 21 + { 22 + $this->assertSame('newest', ConflictStrategy::NewestWins->value); 23 + } 24 + 25 + public function test_manual_has_correct_value(): void 26 + { 27 + $this->assertSame('manual', ConflictStrategy::Manual->value); 28 + } 29 + 30 + public function test_from_config_returns_remote_wins_by_default(): void 31 + { 32 + config()->set('parity.conflicts.strategy', 'remote'); 33 + 34 + $strategy = ConflictStrategy::fromConfig(); 35 + 36 + $this->assertSame(ConflictStrategy::RemoteWins, $strategy); 37 + } 38 + 39 + public function test_from_config_returns_local_wins(): void 40 + { 41 + config()->set('parity.conflicts.strategy', 'local'); 42 + 43 + $strategy = ConflictStrategy::fromConfig(); 44 + 45 + $this->assertSame(ConflictStrategy::LocalWins, $strategy); 46 + } 47 + 48 + public function test_from_config_returns_newest_wins(): void 49 + { 50 + config()->set('parity.conflicts.strategy', 'newest'); 51 + 52 + $strategy = ConflictStrategy::fromConfig(); 53 + 54 + $this->assertSame(ConflictStrategy::NewestWins, $strategy); 55 + } 56 + 57 + public function test_from_config_returns_manual(): void 58 + { 59 + config()->set('parity.conflicts.strategy', 'manual'); 60 + 61 + $strategy = ConflictStrategy::fromConfig(); 62 + 63 + $this->assertSame(ConflictStrategy::Manual, $strategy); 64 + } 65 + 66 + public function test_from_config_defaults_to_remote_wins_for_invalid_value(): void 67 + { 68 + config()->set('parity.conflicts.strategy', 'invalid'); 69 + 70 + $strategy = ConflictStrategy::fromConfig(); 71 + 72 + $this->assertSame(ConflictStrategy::RemoteWins, $strategy); 73 + } 74 + 75 + public function test_from_config_defaults_to_remote_wins_when_not_set(): void 76 + { 77 + config()->set('parity.conflicts.strategy', null); 78 + 79 + $strategy = ConflictStrategy::fromConfig(); 80 + 81 + $this->assertSame(ConflictStrategy::RemoteWins, $strategy); 82 + } 83 + }