···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.
···1+[](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
···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
···23Contributions are welcome and will be fully credited.
45-Contributions are accepted via Pull Requests on [Github](https://github.com/socialdept/atp-parity).
67# Things you could do
8If you want to contribute but do not know where to start, this list provides some starting points.
···23Contributions are welcome and will be fully credited.
45+Contributions are accepted via Pull Requests on [Github](https://github.com/social-dept/beacon).
67# Things you could do
8If you want to contribute but do not know where to start, this list provides some starting points.
···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+```
···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.
···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.
···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...
···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
···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+}
···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+}
···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+}
···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
···0000000000000000000000000
···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+}
···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
···00000000000000000000000
···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
···000000000000000
···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
···0000000000000000
···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
···00000000000000000
···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
···000000000000000
···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
···00000000000000000000
···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
···0000000000000000000
···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+}
···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+}
···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+}
···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
···00000000000000
···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
···0000000000000000000
···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+}