···11+MIT License
22+33+Copyright (c) 2025 Social Dept
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+426
README.md
···11+[](https://github.com/socialdept/atp-parity)
22+33+<h3 align="center">
44+ Bidirectional mapping between AT Protocol records and Laravel Eloquent models.
55+</h3>
66+77+<p align="center">
88+ <br>
99+ <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>
1010+ <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>
1111+ <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>
1212+ <a href="LICENSE" title="Software License"><img src="https://img.shields.io/github/license/socialdept/atp-parity?style=flat-square"></a>
1313+</p>
1414+1515+---
1616+1717+## What is Parity?
1818+1919+**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.
2020+2121+Think of it as Laravel's model casts, but for AT Protocol records.
2222+2323+## Why use Parity?
2424+2525+- **Laravel-style code** - Familiar patterns you already know
2626+- **Bidirectional mapping** - Transform records to models and back
2727+- **Firehose sync** - Automatically sync network events to your database
2828+- **Type-safe DTOs** - Full integration with atp-schema generated types
2929+- **Model traits** - Add AT Protocol awareness to any Eloquent model
3030+- **Flexible mappers** - Define custom transformations for your domain
3131+3232+## Quick Example
3333+3434+```php
3535+use SocialDept\AtpParity\RecordMapper;
3636+use SocialDept\AtpSchema\Data\Data;
3737+use Illuminate\Database\Eloquent\Model;
3838+3939+class PostMapper extends RecordMapper
4040+{
4141+ public function recordClass(): string
4242+ {
4343+ return \SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post::class;
4444+ }
4545+4646+ public function modelClass(): string
4747+ {
4848+ return \App\Models\Post::class;
4949+ }
5050+5151+ protected function recordToAttributes(Data $record): array
5252+ {
5353+ return [
5454+ 'content' => $record->text,
5555+ 'published_at' => $record->createdAt,
5656+ ];
5757+ }
5858+5959+ protected function modelToRecordData(Model $model): array
6060+ {
6161+ return [
6262+ 'text' => $model->content,
6363+ 'createdAt' => $model->published_at->toIso8601String(),
6464+ ];
6565+ }
6666+}
6767+```
6868+6969+## Installation
7070+7171+```bash
7272+composer require socialdept/atp-parity
7373+```
7474+7575+Optionally publish the configuration:
7676+7777+```bash
7878+php artisan vendor:publish --tag=parity-config
7979+```
8080+8181+## Getting Started
8282+8383+Once installed, you're three steps away from syncing AT Protocol records:
8484+8585+### 1. Create a Mapper
8686+8787+Define how your record maps to your model:
8888+8989+```php
9090+class PostMapper extends RecordMapper
9191+{
9292+ public function recordClass(): string
9393+ {
9494+ return Post::class; // Your atp-schema DTO or custom Record
9595+ }
9696+9797+ public function modelClass(): string
9898+ {
9999+ return \App\Models\Post::class;
100100+ }
101101+102102+ protected function recordToAttributes(Data $record): array
103103+ {
104104+ return ['content' => $record->text];
105105+ }
106106+107107+ protected function modelToRecordData(Model $model): array
108108+ {
109109+ return ['text' => $model->content];
110110+ }
111111+}
112112+```
113113+114114+### 2. Register Your Mapper
115115+116116+```php
117117+// config/parity.php
118118+return [
119119+ 'mappers' => [
120120+ App\AtpMappers\PostMapper::class,
121121+ ],
122122+];
123123+```
124124+125125+### 3. Add the Trait to Your Model
126126+127127+```php
128128+use SocialDept\AtpParity\Concerns\HasAtpRecord;
129129+130130+class Post extends Model
131131+{
132132+ use HasAtpRecord;
133133+}
134134+```
135135+136136+Your model can now convert to/from AT Protocol records and query by URI.
137137+138138+## What can you build?
139139+140140+- **Data mirrors** - Keep local copies of AT Protocol data
141141+- **AppViews** - Build custom applications with synced data
142142+- **Analytics platforms** - Store and analyze network activity
143143+- **Content aggregators** - Collect and organize posts locally
144144+- **Moderation tools** - Track and manage content in your database
145145+- **Hybrid applications** - Combine local and federated data
146146+147147+## Ecosystem Integration
148148+149149+Parity is designed to work seamlessly with the other atp-* packages:
150150+151151+| Package | Integration |
152152+|---------|-------------|
153153+| **atp-schema** | Records extend `Data`, use generated DTOs directly |
154154+| **atp-client** | `RecordHelper` for fetching and hydrating records |
155155+| **atp-signals** | `ParitySignal` for automatic firehose sync |
156156+157157+### Using with atp-schema
158158+159159+Use generated schema classes directly with `SchemaMapper`:
160160+161161+```php
162162+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
163163+use SocialDept\AtpParity\Support\SchemaMapper;
164164+165165+$mapper = new SchemaMapper(
166166+ schemaClass: Post::class,
167167+ modelClass: \App\Models\Post::class,
168168+ toAttributes: fn(Post $p) => [
169169+ 'content' => $p->text,
170170+ 'published_at' => $p->createdAt,
171171+ ],
172172+ toRecordData: fn($m) => [
173173+ 'text' => $m->content,
174174+ 'createdAt' => $m->published_at->toIso8601String(),
175175+ ],
176176+);
177177+178178+$registry->register($mapper);
179179+```
180180+181181+### Using with atp-client
182182+183183+Fetch records by URI and convert directly to models:
184184+185185+```php
186186+use SocialDept\AtpParity\Support\RecordHelper;
187187+188188+$helper = app(RecordHelper::class);
189189+190190+// Fetch as typed DTO
191191+$record = $helper->fetch('at://did:plc:xxx/app.bsky.feed.post/abc123');
192192+193193+// Fetch and convert to model (unsaved)
194194+$post = $helper->fetchAsModel('at://did:plc:xxx/app.bsky.feed.post/abc123');
195195+196196+// Fetch and sync to database (upsert)
197197+$post = $helper->sync('at://did:plc:xxx/app.bsky.feed.post/abc123');
198198+```
199199+200200+The helper automatically resolves the DID to find the correct PDS endpoint, so it works with any AT Protocol server - not just Bluesky.
201201+202202+### Using with atp-signals
203203+204204+Enable automatic firehose synchronization by registering the `ParitySignal`:
205205+206206+```php
207207+// config/signal.php
208208+return [
209209+ 'signals' => [
210210+ \SocialDept\AtpParity\Signals\ParitySignal::class,
211211+ ],
212212+];
213213+```
214214+215215+Run `php artisan signal:consume` and your models will automatically sync with matching firehose events.
216216+217217+### Importing Historical Data
218218+219219+For existing records created before you started consuming the firehose:
220220+221221+```bash
222222+# Import a user's records
223223+php artisan parity:import did:plc:z72i7hdynmk6r22z27h6tvur
224224+225225+# Check import status
226226+php artisan parity:import-status
227227+```
228228+229229+Or programmatically:
230230+231231+```php
232232+use SocialDept\AtpParity\Import\ImportService;
233233+234234+$service = app(ImportService::class);
235235+$result = $service->importUser('did:plc:z72i7hdynmk6r22z27h6tvur');
236236+237237+echo "Synced {$result->recordsSynced} records";
238238+```
239239+240240+## Documentation
241241+242242+For detailed documentation on specific topics:
243243+244244+- [Record Mappers](docs/mappers.md) - Creating and using mappers
245245+- [Model Traits](docs/traits.md) - HasAtpRecord and SyncsWithAtp
246246+- [atp-schema Integration](docs/atp-schema-integration.md) - Using generated DTOs
247247+- [atp-client Integration](docs/atp-client-integration.md) - RecordHelper and fetching
248248+- [atp-signals Integration](docs/atp-signals-integration.md) - ParitySignal and firehose sync
249249+- [Importing](docs/importing.md) - Syncing historical data
250250+251251+## Model Traits
252252+253253+### HasAtpRecord
254254+255255+Add AT Protocol awareness to your models:
256256+257257+```php
258258+use SocialDept\AtpParity\Concerns\HasAtpRecord;
259259+260260+class Post extends Model
261261+{
262262+ use HasAtpRecord;
263263+264264+ protected $fillable = ['content', 'atp_uri', 'atp_cid'];
265265+}
266266+```
267267+268268+Available methods:
269269+270270+```php
271271+// Get AT Protocol metadata
272272+$post->getAtpUri(); // at://did:plc:xxx/app.bsky.feed.post/rkey
273273+$post->getAtpCid(); // bafyre...
274274+$post->getAtpDid(); // did:plc:xxx (extracted from URI)
275275+$post->getAtpCollection(); // app.bsky.feed.post (extracted from URI)
276276+$post->getAtpRkey(); // rkey (extracted from URI)
277277+278278+// Check sync status
279279+$post->hasAtpRecord(); // true if synced
280280+281281+// Convert to record DTO
282282+$record = $post->toAtpRecord();
283283+284284+// Query scopes
285285+Post::withAtpRecord()->get(); // Only synced posts
286286+Post::withoutAtpRecord()->get(); // Only unsynced posts
287287+Post::whereAtpUri($uri)->first(); // Find by URI
288288+```
289289+290290+### SyncsWithAtp
291291+292292+Extended trait for bidirectional sync tracking:
293293+294294+```php
295295+use SocialDept\AtpParity\Concerns\SyncsWithAtp;
296296+297297+class Post extends Model
298298+{
299299+ use SyncsWithAtp;
300300+}
301301+```
302302+303303+Additional methods:
304304+305305+```php
306306+// Track sync status
307307+$post->getAtpSyncedAt(); // Last sync timestamp
308308+$post->hasLocalChanges(); // True if updated since last sync
309309+310310+// Mark as synced
311311+$post->markAsSynced($uri, $cid);
312312+313313+// Update from remote
314314+$post->updateFromRecord($record, $uri, $cid);
315315+```
316316+317317+## Database Migration
318318+319319+Add AT Protocol columns to your models:
320320+321321+```php
322322+Schema::table('posts', function (Blueprint $table) {
323323+ $table->string('atp_uri')->nullable()->unique();
324324+ $table->string('atp_cid')->nullable();
325325+ $table->timestamp('atp_synced_at')->nullable(); // For SyncsWithAtp
326326+});
327327+```
328328+329329+## Configuration
330330+331331+```php
332332+// config/parity.php
333333+return [
334334+ // Registered mappers
335335+ 'mappers' => [
336336+ App\AtpMappers\PostMapper::class,
337337+ App\AtpMappers\ProfileMapper::class,
338338+ ],
339339+340340+ // Column names for AT Protocol metadata
341341+ 'columns' => [
342342+ 'uri' => 'atp_uri',
343343+ 'cid' => 'atp_cid',
344344+ ],
345345+];
346346+```
347347+348348+## Creating Custom Records
349349+350350+Extend the `Record` base class for custom AT Protocol records:
351351+352352+```php
353353+use SocialDept\AtpParity\Data\Record;
354354+use Carbon\Carbon;
355355+356356+class PostRecord extends Record
357357+{
358358+ public function __construct(
359359+ public readonly string $text,
360360+ public readonly Carbon $createdAt,
361361+ public readonly ?array $facets = null,
362362+ ) {}
363363+364364+ public static function getLexicon(): string
365365+ {
366366+ return 'app.bsky.feed.post';
367367+ }
368368+369369+ public static function fromArray(array $data): static
370370+ {
371371+ return new static(
372372+ text: $data['text'],
373373+ createdAt: Carbon::parse($data['createdAt']),
374374+ facets: $data['facets'] ?? null,
375375+ );
376376+ }
377377+}
378378+```
379379+380380+The `Record` class extends `atp-schema`'s `Data` and implements `atp-client`'s `Recordable` interface, ensuring full compatibility with the ecosystem.
381381+382382+## Requirements
383383+384384+- PHP 8.2+
385385+- Laravel 10, 11, or 12
386386+- [socialdept/atp-schema](https://github.com/socialdept/atp-schema) ^0.3
387387+- [socialdept/atp-client](https://github.com/socialdept/atp-client) ^0.0
388388+- [socialdept/atp-resolver](https://github.com/socialdept/atp-resolver) ^1.1
389389+- [socialdept/atp-signals](https://github.com/socialdept/atp-signals) ^1.1
390390+391391+## Testing
392392+393393+```bash
394394+composer test
395395+```
396396+397397+## Resources
398398+399399+- [AT Protocol Documentation](https://atproto.com/)
400400+- [Bluesky API Docs](https://docs.bsky.app/)
401401+- [atp-schema](https://github.com/socialdept/atp-schema) - Generated AT Protocol DTOs
402402+- [atp-client](https://github.com/socialdept/atp-client) - AT Protocol HTTP client
403403+- [atp-signals](https://github.com/socialdept/atp-signals) - Firehose event consumer
404404+405405+## Support & Contributing
406406+407407+Found a bug or have a feature request? [Open an issue](https://github.com/socialdept/atp-parity/issues).
408408+409409+Want to contribute? Check out the [contribution guidelines](contributing.md).
410410+411411+## Changelog
412412+413413+Please see [changelog](changelog.md) for recent changes.
414414+415415+## Credits
416416+417417+- [Miguel Batres](https://batres.co) - founder & lead maintainer
418418+- [All contributors](https://github.com/socialdept/atp-parity/graphs/contributors)
419419+420420+## License
421421+422422+Parity is open-source software licensed under the [MIT license](license.md).
423423+424424+---
425425+426426+**Built for the Federation** - By Social Dept.
-8
changelog.md
···11-# Changelog
22-33-All notable changes to `AtpReplicator` will be documented in this file.
44-55-## Version 1.0
66-77-### Added
88-- Everything
···11+<?php
22+33+return [
44+ /*
55+ |--------------------------------------------------------------------------
66+ | Record Mappers
77+ |--------------------------------------------------------------------------
88+ |
99+ | List of RecordMapper classes to automatically register. Each mapper
1010+ | handles bidirectional conversion between an AT Protocol record DTO
1111+ | and an Eloquent model.
1212+ |
1313+ */
1414+ 'mappers' => [
1515+ // App\AtpMappers\PostMapper::class,
1616+ // App\AtpMappers\ProfileMapper::class,
1717+ ],
1818+1919+ /*
2020+ |--------------------------------------------------------------------------
2121+ | AT Protocol Metadata Columns
2222+ |--------------------------------------------------------------------------
2323+ |
2424+ | The column names used to store AT Protocol metadata on models.
2525+ |
2626+ */
2727+ 'columns' => [
2828+ 'uri' => 'atp_uri',
2929+ 'cid' => 'atp_cid',
3030+ ],
3131+3232+ /*
3333+ |--------------------------------------------------------------------------
3434+ | Import Configuration
3535+ |--------------------------------------------------------------------------
3636+ |
3737+ | Settings for importing historical AT Protocol records to your database.
3838+ |
3939+ */
4040+ 'import' => [
4141+ // Records per page when listing from PDS
4242+ 'page_size' => 100,
4343+4444+ // Delay between pages in milliseconds (rate limiting)
4545+ 'page_delay' => 100,
4646+4747+ // Queue name for import jobs
4848+ 'queue' => 'default',
4949+5050+ // Database table for storing import state
5151+ 'state_table' => 'parity_import_states',
5252+ ],
5353+5454+ /*
5555+ |--------------------------------------------------------------------------
5656+ | Sync Filtering
5757+ |--------------------------------------------------------------------------
5858+ |
5959+ | Control which firehose events get synced to your database.
6060+ |
6161+ */
6262+ 'sync' => [
6363+ // Only sync records from these DIDs (null = all DIDs)
6464+ 'dids' => null,
6565+6666+ // Only sync these operations: 'create', 'update', 'delete' (null = all)
6767+ 'operations' => null,
6868+6969+ // Custom filter callback: function(SignalEvent $event): bool
7070+ // Return true to sync the event, false to skip it
7171+ 'filter' => null,
7272+ ],
7373+7474+ /*
7575+ |--------------------------------------------------------------------------
7676+ | Conflict Resolution
7777+ |--------------------------------------------------------------------------
7878+ |
7979+ | Strategy for handling conflicts between local and remote changes.
8080+ |
8181+ */
8282+ 'conflicts' => [
8383+ // Strategy: 'remote', 'local', 'newest', 'manual'
8484+ 'strategy' => env('PARITY_CONFLICT_STRATEGY', 'remote'),
8585+8686+ // Database table for pending conflicts (manual resolution)
8787+ 'table' => 'parity_conflicts',
8888+8989+ // Notifiable class or callback for conflict notifications
9090+ 'notify' => null,
9191+ ],
9292+9393+ /*
9494+ |--------------------------------------------------------------------------
9595+ | Collection Discovery
9696+ |--------------------------------------------------------------------------
9797+ |
9898+ | Settings for discovering users with records in specific collections.
9999+ |
100100+ */
101101+ 'discovery' => [
102102+ // Relay URL for discovery queries
103103+ 'relay' => env('ATP_RELAY_URL', 'https://bsky.network'),
104104+ ],
105105+];
+1-1
contributing.md
CONTRIBUTING.md
···2233Contributions are welcome and will be fully credited.
4455-Contributions are accepted via Pull Requests on [Github](https://github.com/socialdept/atp-parity).
55+Contributions are accepted via Pull Requests on [Github](https://github.com/social-dept/beacon).
6677# Things you could do
88If you want to contribute but do not know where to start, this list provides some starting points.
···11+# atp-client Integration
22+33+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.
44+55+## RecordHelper
66+77+The `RecordHelper` is registered as a singleton and available via the container:
88+99+```php
1010+use SocialDept\AtpParity\Support\RecordHelper;
1111+1212+$helper = app(RecordHelper::class);
1313+```
1414+1515+### How It Works
1616+1717+When you provide an AT Protocol URI, RecordHelper:
1818+1919+1. Parses the URI to extract the DID, collection, and rkey
2020+2. Resolves the DID to find the user's PDS endpoint (via atp-resolver)
2121+3. Creates a public client for that PDS
2222+4. Fetches the record
2323+5. Converts it using the registered mapper
2424+2525+This means it works with any AT Protocol server, not just Bluesky.
2626+2727+## Fetching Records
2828+2929+### `fetch(string $uri, ?string $recordClass = null): mixed`
3030+3131+Fetches a record and returns it as a typed DTO.
3232+3333+```php
3434+use SocialDept\AtpParity\Support\RecordHelper;
3535+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
3636+3737+$helper = app(RecordHelper::class);
3838+3939+// Auto-detect type from registered mapper
4040+$record = $helper->fetch('at://did:plc:abc123/app.bsky.feed.post/xyz789');
4141+4242+// Or specify the class explicitly
4343+$record = $helper->fetch(
4444+ 'at://did:plc:abc123/app.bsky.feed.post/xyz789',
4545+ Post::class
4646+);
4747+4848+// Access typed properties
4949+echo $record->text;
5050+echo $record->createdAt;
5151+```
5252+5353+### `fetchAsModel(string $uri): ?Model`
5454+5555+Fetches a record and converts it to an Eloquent model (unsaved).
5656+5757+```php
5858+$post = $helper->fetchAsModel('at://did:plc:abc123/app.bsky.feed.post/xyz789');
5959+6060+if ($post) {
6161+ echo $post->content;
6262+ echo $post->atp_uri;
6363+ echo $post->atp_cid;
6464+6565+ // Save if you want to persist it
6666+ $post->save();
6767+}
6868+```
6969+7070+Returns `null` if no mapper is registered for the collection.
7171+7272+### `sync(string $uri): ?Model`
7373+7474+Fetches a record and upserts it to the database.
7575+7676+```php
7777+// Creates or updates the model
7878+$post = $helper->sync('at://did:plc:abc123/app.bsky.feed.post/xyz789');
7979+8080+// Model is saved automatically
8181+echo $post->id;
8282+echo $post->content;
8383+```
8484+8585+This is the most common method for syncing remote records to your database.
8686+8787+## Working with Responses
8888+8989+### `hydrateRecord(GetRecordResponse $response, ?string $recordClass = null): mixed`
9090+9191+If you already have a `GetRecordResponse` from atp-client, convert it to a typed DTO:
9292+9393+```php
9494+use SocialDept\AtpClient\Facades\Atp;
9595+use SocialDept\AtpParity\Support\RecordHelper;
9696+9797+$helper = app(RecordHelper::class);
9898+9999+// Using atp-client directly
100100+$client = Atp::public();
101101+$response = $client->atproto->repo->getRecord(
102102+ 'did:plc:abc123',
103103+ 'app.bsky.feed.post',
104104+ 'xyz789'
105105+);
106106+107107+// Convert to typed DTO
108108+$record = $helper->hydrateRecord($response);
109109+```
110110+111111+## Practical Examples
112112+113113+### Syncing a Single Post
114114+115115+```php
116116+$helper = app(RecordHelper::class);
117117+118118+$uri = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3k2yihcrp6f2c';
119119+$post = $helper->sync($uri);
120120+121121+echo "Synced: {$post->content}";
122122+```
123123+124124+### Syncing Multiple Posts
125125+126126+```php
127127+$helper = app(RecordHelper::class);
128128+129129+$uris = [
130130+ 'at://did:plc:abc/app.bsky.feed.post/123',
131131+ 'at://did:plc:def/app.bsky.feed.post/456',
132132+ 'at://did:plc:ghi/app.bsky.feed.post/789',
133133+];
134134+135135+foreach ($uris as $uri) {
136136+ try {
137137+ $post = $helper->sync($uri);
138138+ echo "Synced: {$post->id}\n";
139139+ } catch (\Exception $e) {
140140+ echo "Failed to sync {$uri}: {$e->getMessage()}\n";
141141+ }
142142+}
143143+```
144144+145145+### Fetching for Preview (Without Saving)
146146+147147+```php
148148+$helper = app(RecordHelper::class);
149149+150150+// Get model without saving
151151+$post = $helper->fetchAsModel('at://did:plc:xxx/app.bsky.feed.post/abc');
152152+153153+if ($post) {
154154+ return view('posts.preview', ['post' => $post]);
155155+}
156156+157157+return abort(404);
158158+```
159159+160160+### Checking if Record Exists Locally
161161+162162+```php
163163+use App\Models\Post;
164164+use SocialDept\AtpParity\Support\RecordHelper;
165165+166166+$uri = 'at://did:plc:xxx/app.bsky.feed.post/abc';
167167+168168+// Check local database first
169169+$post = Post::whereAtpUri($uri)->first();
170170+171171+if (!$post) {
172172+ // Not in database, fetch from network
173173+ $helper = app(RecordHelper::class);
174174+ $post = $helper->sync($uri);
175175+}
176176+177177+return $post;
178178+```
179179+180180+### Building a Post Importer
181181+182182+```php
183183+namespace App\Services;
184184+185185+use SocialDept\AtpParity\Support\RecordHelper;
186186+use SocialDept\AtpClient\Facades\Atp;
187187+188188+class PostImporter
189189+{
190190+ public function __construct(
191191+ protected RecordHelper $helper
192192+ ) {}
193193+194194+ /**
195195+ * Import all posts from a user.
196196+ */
197197+ public function importUserPosts(string $did, int $limit = 100): array
198198+ {
199199+ $imported = [];
200200+ $client = Atp::public();
201201+ $cursor = null;
202202+203203+ do {
204204+ $response = $client->atproto->repo->listRecords(
205205+ repo: $did,
206206+ collection: 'app.bsky.feed.post',
207207+ limit: min($limit - count($imported), 100),
208208+ cursor: $cursor
209209+ );
210210+211211+ foreach ($response->records as $record) {
212212+ $post = $this->helper->sync($record->uri);
213213+ $imported[] = $post;
214214+215215+ if (count($imported) >= $limit) {
216216+ break 2;
217217+ }
218218+ }
219219+220220+ $cursor = $response->cursor;
221221+ } while ($cursor && count($imported) < $limit);
222222+223223+ return $imported;
224224+ }
225225+}
226226+```
227227+228228+## Error Handling
229229+230230+RecordHelper returns `null` for various failure conditions:
231231+232232+```php
233233+$helper = app(RecordHelper::class);
234234+235235+// Invalid URI format
236236+$result = $helper->fetch('not-a-valid-uri');
237237+// Returns null
238238+239239+// No mapper registered for collection
240240+$result = $helper->fetchAsModel('at://did:plc:xxx/some.unknown.collection/abc');
241241+// Returns null
242242+243243+// PDS resolution failed
244244+$result = $helper->fetch('at://did:plc:invalid/app.bsky.feed.post/abc');
245245+// Returns null (or throws exception depending on resolver config)
246246+```
247247+248248+For more control, catch exceptions:
249249+250250+```php
251251+use SocialDept\AtpResolver\Exceptions\DidResolutionException;
252252+253253+try {
254254+ $post = $helper->sync($uri);
255255+} catch (DidResolutionException $e) {
256256+ // DID could not be resolved
257257+ Log::warning("Could not resolve DID for {$uri}");
258258+} catch (\Exception $e) {
259259+ // Network error, invalid response, etc.
260260+ Log::error("Failed to sync {$uri}: {$e->getMessage()}");
261261+}
262262+```
263263+264264+## Performance Considerations
265265+266266+### PDS Client Caching
267267+268268+RecordHelper caches public clients by PDS endpoint:
269269+270270+```php
271271+// First request to this PDS - creates client
272272+$helper->sync('at://did:plc:abc/app.bsky.feed.post/1');
273273+274274+// Same PDS - reuses cached client
275275+$helper->sync('at://did:plc:abc/app.bsky.feed.post/2');
276276+277277+// Different PDS - creates new client
278278+$helper->sync('at://did:plc:xyz/app.bsky.feed.post/1');
279279+```
280280+281281+### DID Resolution Caching
282282+283283+atp-resolver caches DID documents and PDS endpoints. Default TTL is 1 hour.
284284+285285+### Batch Operations
286286+287287+For bulk imports, consider using atp-client's `listRecords` directly and then batch-processing:
288288+289289+```php
290290+use SocialDept\AtpClient\Facades\Atp;
291291+use SocialDept\AtpParity\MapperRegistry;
292292+293293+$client = Atp::public($pdsEndpoint);
294294+$registry = app(MapperRegistry::class);
295295+$mapper = $registry->forLexicon('app.bsky.feed.post');
296296+297297+$response = $client->atproto->repo->listRecords(
298298+ repo: $did,
299299+ collection: 'app.bsky.feed.post',
300300+ limit: 100
301301+);
302302+303303+foreach ($response->records as $record) {
304304+ $recordClass = $mapper->recordClass();
305305+ $dto = $recordClass::fromArray($record->value);
306306+307307+ $mapper->upsert($dto, [
308308+ 'uri' => $record->uri,
309309+ 'cid' => $record->cid,
310310+ ]);
311311+}
312312+```
313313+314314+## Using with Authenticated Client
315315+316316+While RecordHelper uses public clients, you can also use authenticated clients for records that require auth:
317317+318318+```php
319319+use SocialDept\AtpClient\Facades\Atp;
320320+use SocialDept\AtpParity\MapperRegistry;
321321+322322+// Authenticated client
323323+$client = Atp::as('user.bsky.social');
324324+325325+// Fetch a record that requires auth
326326+$response = $client->atproto->repo->getRecord(
327327+ repo: $client->session()->did(),
328328+ collection: 'app.bsky.feed.post',
329329+ rkey: 'abc123'
330330+);
331331+332332+// Convert using mapper
333333+$registry = app(MapperRegistry::class);
334334+$mapper = $registry->forLexicon('app.bsky.feed.post');
335335+336336+$recordClass = $mapper->recordClass();
337337+$record = $recordClass::fromArray($response->value);
338338+339339+$model = $mapper->upsert($record, [
340340+ 'uri' => $response->uri,
341341+ 'cid' => $response->cid,
342342+]);
343343+```
+355
docs/atp-schema-integration.md
···11+# atp-schema Integration
22+33+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.
44+55+## How It Works
66+77+The `SocialDept\AtpParity\Data\Record` class extends `SocialDept\AtpSchema\Data\Data`:
88+99+```php
1010+namespace SocialDept\AtpParity\Data;
1111+1212+use SocialDept\AtpClient\Contracts\Recordable;
1313+use SocialDept\AtpSchema\Data\Data;
1414+1515+abstract class Record extends Data implements Recordable
1616+{
1717+ public function getType(): string
1818+ {
1919+ return static::getLexicon();
2020+ }
2121+}
2222+```
2323+2424+This means all Parity records inherit:
2525+2626+- `getLexicon()` - Returns the lexicon NSID
2727+- `fromArray()` - Creates instance from array data
2828+- `toArray()` - Converts to array
2929+- `toRecord()` - Converts to record format for API calls
3030+- Type validation and casting
3131+3232+## Using Generated Schema Classes
3333+3434+atp-schema generates PHP classes for all AT Protocol lexicons. Use them directly with Parity:
3535+3636+```php
3737+use SocialDept\AtpParity\Support\SchemaMapper;
3838+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
3939+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Like;
4040+use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Follow;
4141+4242+// Post mapper
4343+$postMapper = new SchemaMapper(
4444+ schemaClass: Post::class,
4545+ modelClass: \App\Models\Post::class,
4646+ toAttributes: fn(Post $post) => [
4747+ 'content' => $post->text,
4848+ 'published_at' => $post->createdAt,
4949+ 'langs' => $post->langs,
5050+ 'reply_parent' => $post->reply?->parent->uri,
5151+ 'reply_root' => $post->reply?->root->uri,
5252+ ],
5353+ toRecordData: fn($model) => [
5454+ 'text' => $model->content,
5555+ 'createdAt' => $model->published_at->toIso8601String(),
5656+ 'langs' => $model->langs ?? ['en'],
5757+ ],
5858+);
5959+6060+// Like mapper
6161+$likeMapper = new SchemaMapper(
6262+ schemaClass: Like::class,
6363+ modelClass: \App\Models\Like::class,
6464+ toAttributes: fn(Like $like) => [
6565+ 'subject_uri' => $like->subject->uri,
6666+ 'subject_cid' => $like->subject->cid,
6767+ 'liked_at' => $like->createdAt,
6868+ ],
6969+ toRecordData: fn($model) => [
7070+ 'subject' => [
7171+ 'uri' => $model->subject_uri,
7272+ 'cid' => $model->subject_cid,
7373+ ],
7474+ 'createdAt' => $model->liked_at->toIso8601String(),
7575+ ],
7676+);
7777+7878+// Follow mapper
7979+$followMapper = new SchemaMapper(
8080+ schemaClass: Follow::class,
8181+ modelClass: \App\Models\Follow::class,
8282+ toAttributes: fn(Follow $follow) => [
8383+ 'subject_did' => $follow->subject,
8484+ 'followed_at' => $follow->createdAt,
8585+ ],
8686+ toRecordData: fn($model) => [
8787+ 'subject' => $model->subject_did,
8888+ 'createdAt' => $model->followed_at->toIso8601String(),
8989+ ],
9090+);
9191+```
9292+9393+## Creating Custom Records
9494+9595+For custom lexicons or when you need more control, extend the `Record` class:
9696+9797+```php
9898+<?php
9999+100100+namespace App\AtpRecords;
101101+102102+use Carbon\Carbon;
103103+use SocialDept\AtpParity\Data\Record;
104104+105105+class CustomPost extends Record
106106+{
107107+ public function __construct(
108108+ public readonly string $text,
109109+ public readonly Carbon $createdAt,
110110+ public readonly ?array $facets = null,
111111+ public readonly ?array $embed = null,
112112+ public readonly ?array $langs = null,
113113+ ) {}
114114+115115+ public static function getLexicon(): string
116116+ {
117117+ return 'app.bsky.feed.post';
118118+ }
119119+120120+ public static function fromArray(array $data): static
121121+ {
122122+ return new static(
123123+ text: $data['text'],
124124+ createdAt: Carbon::parse($data['createdAt']),
125125+ facets: $data['facets'] ?? null,
126126+ embed: $data['embed'] ?? null,
127127+ langs: $data['langs'] ?? null,
128128+ );
129129+ }
130130+131131+ public function toArray(): array
132132+ {
133133+ return array_filter([
134134+ '$type' => static::getLexicon(),
135135+ 'text' => $this->text,
136136+ 'createdAt' => $this->createdAt->toIso8601String(),
137137+ 'facets' => $this->facets,
138138+ 'embed' => $this->embed,
139139+ 'langs' => $this->langs,
140140+ ], fn($v) => $v !== null);
141141+ }
142142+}
143143+```
144144+145145+## Custom Lexicons (AppView)
146146+147147+Building a custom AT Protocol application? Define your own lexicons:
148148+149149+```php
150150+<?php
151151+152152+namespace App\AtpRecords;
153153+154154+use Carbon\Carbon;
155155+use SocialDept\AtpParity\Data\Record;
156156+157157+class Article extends Record
158158+{
159159+ public function __construct(
160160+ public readonly string $title,
161161+ public readonly string $body,
162162+ public readonly Carbon $publishedAt,
163163+ public readonly ?array $tags = null,
164164+ public readonly ?string $coverImage = null,
165165+ ) {}
166166+167167+ public static function getLexicon(): string
168168+ {
169169+ return 'com.myapp.blog.article'; // Your custom NSID
170170+ }
171171+172172+ public static function fromArray(array $data): static
173173+ {
174174+ return new static(
175175+ title: $data['title'],
176176+ body: $data['body'],
177177+ publishedAt: Carbon::parse($data['publishedAt']),
178178+ tags: $data['tags'] ?? null,
179179+ coverImage: $data['coverImage'] ?? null,
180180+ );
181181+ }
182182+183183+ public function toArray(): array
184184+ {
185185+ return array_filter([
186186+ '$type' => static::getLexicon(),
187187+ 'title' => $this->title,
188188+ 'body' => $this->body,
189189+ 'publishedAt' => $this->publishedAt->toIso8601String(),
190190+ 'tags' => $this->tags,
191191+ 'coverImage' => $this->coverImage,
192192+ ], fn($v) => $v !== null);
193193+ }
194194+}
195195+```
196196+197197+## Working with Embedded Types
198198+199199+atp-schema generates classes for embedded types. Use them in your mappings:
200200+201201+```php
202202+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
203203+use SocialDept\AtpSchema\Generated\App\Bsky\Embed\Images;
204204+use SocialDept\AtpSchema\Generated\App\Bsky\Embed\External;
205205+use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\StrongRef;
206206+207207+$mapper = new SchemaMapper(
208208+ schemaClass: Post::class,
209209+ modelClass: \App\Models\Post::class,
210210+ toAttributes: fn(Post $post) => [
211211+ 'content' => $post->text,
212212+ 'published_at' => $post->createdAt,
213213+ 'has_images' => $post->embed instanceof Images,
214214+ 'has_link' => $post->embed instanceof External,
215215+ 'embed_data' => $post->embed?->toArray(),
216216+ ],
217217+ toRecordData: fn($model) => [
218218+ 'text' => $model->content,
219219+ 'createdAt' => $model->published_at->toIso8601String(),
220220+ ],
221221+);
222222+```
223223+224224+## Handling Union Types
225225+226226+AT Protocol uses union types for fields like `embed`. atp-schema handles these via discriminated unions:
227227+228228+```php
229229+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
230230+use SocialDept\AtpSchema\Generated\App\Bsky\Embed\Images;
231231+use SocialDept\AtpSchema\Generated\App\Bsky\Embed\External;
232232+use SocialDept\AtpSchema\Generated\App\Bsky\Embed\Record;
233233+use SocialDept\AtpSchema\Generated\App\Bsky\Embed\RecordWithMedia;
234234+235235+$toAttributes = function(Post $post): array {
236236+ $attributes = [
237237+ 'content' => $post->text,
238238+ 'published_at' => $post->createdAt,
239239+ ];
240240+241241+ // Handle embed union type
242242+ if ($post->embed) {
243243+ match (true) {
244244+ $post->embed instanceof Images => $attributes['embed_type'] = 'images',
245245+ $post->embed instanceof External => $attributes['embed_type'] = 'external',
246246+ $post->embed instanceof Record => $attributes['embed_type'] = 'quote',
247247+ $post->embed instanceof RecordWithMedia => $attributes['embed_type'] = 'quote_media',
248248+ default => $attributes['embed_type'] = 'unknown',
249249+ };
250250+ $attributes['embed_data'] = $post->embed->toArray();
251251+ }
252252+253253+ return $attributes;
254254+};
255255+```
256256+257257+## Reply Threading
258258+259259+Posts can be replies to other posts:
260260+261261+```php
262262+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
263263+264264+$toAttributes = function(Post $post): array {
265265+ $attributes = [
266266+ 'content' => $post->text,
267267+ 'published_at' => $post->createdAt,
268268+ 'is_reply' => $post->reply !== null,
269269+ ];
270270+271271+ if ($post->reply) {
272272+ // Parent is the immediate post being replied to
273273+ $attributes['reply_parent_uri'] = $post->reply->parent->uri;
274274+ $attributes['reply_parent_cid'] = $post->reply->parent->cid;
275275+276276+ // Root is the top of the thread
277277+ $attributes['reply_root_uri'] = $post->reply->root->uri;
278278+ $attributes['reply_root_cid'] = $post->reply->root->cid;
279279+ }
280280+281281+ return $attributes;
282282+};
283283+```
284284+285285+## Facets (Rich Text)
286286+287287+Posts with mentions, links, and hashtags use facets:
288288+289289+```php
290290+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
291291+use SocialDept\AtpSchema\Generated\App\Bsky\Richtext\Facet;
292292+293293+$toAttributes = function(Post $post): array {
294294+ $attributes = [
295295+ 'content' => $post->text,
296296+ 'published_at' => $post->createdAt,
297297+ ];
298298+299299+ // Extract mentions, links, and tags from facets
300300+ $mentions = [];
301301+ $links = [];
302302+ $tags = [];
303303+304304+ foreach ($post->facets ?? [] as $facet) {
305305+ foreach ($facet->features as $feature) {
306306+ $type = $feature->getType();
307307+ match ($type) {
308308+ 'app.bsky.richtext.facet#mention' => $mentions[] = $feature->did,
309309+ 'app.bsky.richtext.facet#link' => $links[] = $feature->uri,
310310+ 'app.bsky.richtext.facet#tag' => $tags[] = $feature->tag,
311311+ default => null,
312312+ };
313313+ }
314314+ }
315315+316316+ $attributes['mentions'] = $mentions;
317317+ $attributes['links'] = $links;
318318+ $attributes['tags'] = $tags;
319319+ $attributes['facets'] = $post->facets; // Store raw for reconstruction
320320+321321+ return $attributes;
322322+};
323323+```
324324+325325+## Type Safety Benefits
326326+327327+Using atp-schema classes provides:
328328+329329+1. **IDE Autocompletion** - Full property and method suggestions
330330+2. **Type Checking** - Static analysis catches errors
331331+3. **Validation** - Data is validated on construction
332332+4. **Documentation** - Generated classes include docblocks
333333+334334+```php
335335+// IDE knows $post->text is string, $post->createdAt is string, etc.
336336+$toAttributes = function(Post $post): array {
337337+ return [
338338+ 'content' => $post->text, // string
339339+ 'published_at' => $post->createdAt, // string (ISO 8601)
340340+ 'langs' => $post->langs, // ?array
341341+ 'facets' => $post->facets, // ?array
342342+ ];
343343+};
344344+```
345345+346346+## Regenerating Schema Classes
347347+348348+When the AT Protocol schema updates, regenerate the classes:
349349+350350+```bash
351351+# In the atp-schema package
352352+php artisan atp:generate
353353+```
354354+355355+Your mappers will automatically work with the updated types.
+491
docs/atp-signals-integration.md
···11+# atp-signals Integration
22+33+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.
44+55+## ParitySignal
66+77+The `ParitySignal` is a pre-built signal that listens for commit events and syncs them to your database using your registered mappers.
88+99+### How It Works
1010+1111+1. ParitySignal listens for `commit` events on the firehose
1212+2. It filters for collections that have registered mappers
1313+3. For each matching event:
1414+ - **Create/Update**: Upserts the record to your database
1515+ - **Delete**: Removes the record from your database
1616+1717+### Setup
1818+1919+Register the signal in your atp-signals config:
2020+2121+```php
2222+// config/signal.php
2323+return [
2424+ 'signals' => [
2525+ \SocialDept\AtpParity\Signals\ParitySignal::class,
2626+ ],
2727+];
2828+```
2929+3030+Then start consuming:
3131+3232+```bash
3333+php artisan signal:consume
3434+```
3535+3636+That's it. Your models will automatically sync with the firehose.
3737+3838+## What Gets Synced
3939+4040+ParitySignal only syncs collections that have registered mappers:
4141+4242+```php
4343+// config/parity.php
4444+return [
4545+ 'mappers' => [
4646+ App\AtpMappers\PostMapper::class, // app.bsky.feed.post
4747+ App\AtpMappers\LikeMapper::class, // app.bsky.feed.like
4848+ App\AtpMappers\FollowMapper::class, // app.bsky.graph.follow
4949+ ],
5050+];
5151+```
5252+5353+With this config, ParitySignal will sync posts, likes, and follows. All other collections are ignored.
5454+5555+## Event Flow
5656+5757+```
5858+Firehose Event
5959+ ↓
6060+ParitySignal.handle()
6161+ ↓
6262+Check: Is collection registered?
6363+ ↓
6464+ Yes → Get mapper for collection
6565+ ↓
6666+Create DTO from event record
6767+ ↓
6868+Call mapper.upsert() or mapper.deleteByUri()
6969+ ↓
7070+Model saved to database
7171+```
7272+7373+## Example: Syncing Posts
7474+7575+### 1. Create the Model
7676+7777+```php
7878+// app/Models/Post.php
7979+namespace App\Models;
8080+8181+use Illuminate\Database\Eloquent\Model;
8282+use SocialDept\AtpParity\Concerns\SyncsWithAtp;
8383+8484+class Post extends Model
8585+{
8686+ use SyncsWithAtp;
8787+8888+ protected $fillable = [
8989+ 'content',
9090+ 'author_did',
9191+ 'published_at',
9292+ 'atp_uri',
9393+ 'atp_cid',
9494+ 'atp_synced_at',
9595+ ];
9696+9797+ protected $casts = [
9898+ 'published_at' => 'datetime',
9999+ 'atp_synced_at' => 'datetime',
100100+ ];
101101+}
102102+```
103103+104104+### 2. Create the Migration
105105+106106+```php
107107+Schema::create('posts', function (Blueprint $table) {
108108+ $table->id();
109109+ $table->text('content');
110110+ $table->string('author_did');
111111+ $table->timestamp('published_at');
112112+ $table->string('atp_uri')->unique();
113113+ $table->string('atp_cid');
114114+ $table->timestamp('atp_synced_at')->nullable();
115115+ $table->timestamps();
116116+});
117117+```
118118+119119+### 3. Create the Mapper
120120+121121+```php
122122+// app/AtpMappers/PostMapper.php
123123+namespace App\AtpMappers;
124124+125125+use App\Models\Post;
126126+use Illuminate\Database\Eloquent\Model;
127127+use SocialDept\AtpParity\RecordMapper;
128128+use SocialDept\AtpSchema\Data\Data;
129129+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post as PostRecord;
130130+131131+class PostMapper extends RecordMapper
132132+{
133133+ public function recordClass(): string
134134+ {
135135+ return PostRecord::class;
136136+ }
137137+138138+ public function modelClass(): string
139139+ {
140140+ return Post::class;
141141+ }
142142+143143+ protected function recordToAttributes(Data $record): array
144144+ {
145145+ return [
146146+ 'content' => $record->text,
147147+ 'published_at' => $record->createdAt,
148148+ ];
149149+ }
150150+151151+ protected function modelToRecordData(Model $model): array
152152+ {
153153+ return [
154154+ 'text' => $model->content,
155155+ 'createdAt' => $model->published_at->toIso8601String(),
156156+ ];
157157+ }
158158+}
159159+```
160160+161161+### 4. Register Everything
162162+163163+```php
164164+// config/parity.php
165165+return [
166166+ 'mappers' => [
167167+ App\AtpMappers\PostMapper::class,
168168+ ],
169169+];
170170+```
171171+172172+```php
173173+// config/signal.php
174174+return [
175175+ 'signals' => [
176176+ \SocialDept\AtpParity\Signals\ParitySignal::class,
177177+ ],
178178+];
179179+```
180180+181181+### 5. Start Syncing
182182+183183+```bash
184184+php artisan signal:consume
185185+```
186186+187187+Every new post on the AT Protocol network will now be saved to your `posts` table.
188188+189189+## Filtering by User
190190+191191+To only sync records from specific users, create a custom signal:
192192+193193+```php
194194+namespace App\Signals;
195195+196196+use SocialDept\AtpParity\Signals\ParitySignal;
197197+use SocialDept\AtpSignals\Events\SignalEvent;
198198+199199+class FilteredParitySignal extends ParitySignal
200200+{
201201+ /**
202202+ * DIDs to sync.
203203+ */
204204+ protected array $allowedDids = [
205205+ 'did:plc:abc123',
206206+ 'did:plc:def456',
207207+ ];
208208+209209+ public function handle(SignalEvent $event): void
210210+ {
211211+ // Only process events from allowed DIDs
212212+ if (!in_array($event->did, $this->allowedDids)) {
213213+ return;
214214+ }
215215+216216+ parent::handle($event);
217217+ }
218218+}
219219+```
220220+221221+Register your custom signal instead:
222222+223223+```php
224224+// config/signal.php
225225+return [
226226+ 'signals' => [
227227+ App\Signals\FilteredParitySignal::class,
228228+ ],
229229+];
230230+```
231231+232232+## Filtering by Collection
233233+234234+To only sync specific collections (even if more mappers are registered):
235235+236236+```php
237237+namespace App\Signals;
238238+239239+use SocialDept\AtpParity\Signals\ParitySignal;
240240+241241+class PostsOnlySignal extends ParitySignal
242242+{
243243+ public function collections(): ?array
244244+ {
245245+ // Only sync posts, ignore other registered mappers
246246+ return ['app.bsky.feed.post'];
247247+ }
248248+}
249249+```
250250+251251+## Custom Processing
252252+253253+Add custom logic before or after syncing:
254254+255255+```php
256256+namespace App\Signals;
257257+258258+use SocialDept\AtpParity\Contracts\RecordMapper;
259259+use SocialDept\AtpParity\Signals\ParitySignal;
260260+use SocialDept\AtpSignals\Events\SignalEvent;
261261+262262+class CustomParitySignal extends ParitySignal
263263+{
264264+ protected function handleUpsert(SignalEvent $event, RecordMapper $mapper): void
265265+ {
266266+ // Pre-processing
267267+ logger()->info('Syncing record', [
268268+ 'did' => $event->did,
269269+ 'collection' => $event->commit->collection,
270270+ 'rkey' => $event->commit->rkey,
271271+ ]);
272272+273273+ // Call parent to do the actual sync
274274+ parent::handleUpsert($event, $mapper);
275275+276276+ // Post-processing
277277+ // e.g., dispatch a job, send notification, etc.
278278+ }
279279+280280+ protected function handleDelete(SignalEvent $event, RecordMapper $mapper): void
281281+ {
282282+ logger()->info('Deleting record', [
283283+ 'uri' => $this->buildUri($event->did, $event->commit->collection, $event->commit->rkey),
284284+ ]);
285285+286286+ parent::handleDelete($event, $mapper);
287287+ }
288288+}
289289+```
290290+291291+## Queue Integration
292292+293293+For high-volume processing, enable queue mode:
294294+295295+```php
296296+namespace App\Signals;
297297+298298+use SocialDept\AtpParity\Signals\ParitySignal;
299299+300300+class QueuedParitySignal extends ParitySignal
301301+{
302302+ public function shouldQueue(): bool
303303+ {
304304+ return true;
305305+ }
306306+307307+ public function queue(): string
308308+ {
309309+ return 'parity-sync';
310310+ }
311311+}
312312+```
313313+314314+Then run a dedicated queue worker:
315315+316316+```bash
317317+php artisan queue:work --queue=parity-sync
318318+```
319319+320320+## Multiple Signals
321321+322322+You can run ParitySignal alongside other signals:
323323+324324+```php
325325+// config/signal.php
326326+return [
327327+ 'signals' => [
328328+ // Sync to database
329329+ \SocialDept\AtpParity\Signals\ParitySignal::class,
330330+331331+ // Your custom analytics signal
332332+ App\Signals\AnalyticsSignal::class,
333333+334334+ // Your moderation signal
335335+ App\Signals\ModerationSignal::class,
336336+ ],
337337+];
338338+```
339339+340340+## Handling High Volume
341341+342342+The AT Protocol firehose processes thousands of events per second. For production:
343343+344344+### 1. Use Jetstream Mode
345345+346346+Jetstream filters server-side, reducing bandwidth:
347347+348348+```php
349349+// config/signal.php
350350+return [
351351+ 'mode' => 'jetstream', // More efficient than firehose
352352+353353+ 'jetstream' => [
354354+ 'collections' => [
355355+ 'app.bsky.feed.post',
356356+ 'app.bsky.feed.like',
357357+ ],
358358+ ],
359359+];
360360+```
361361+362362+### 2. Enable Queues
363363+364364+Process events asynchronously:
365365+366366+```php
367367+class QueuedParitySignal extends ParitySignal
368368+{
369369+ public function shouldQueue(): bool
370370+ {
371371+ return true;
372372+ }
373373+}
374374+```
375375+376376+### 3. Use Database Transactions
377377+378378+Batch inserts for better performance:
379379+380380+```php
381381+namespace App\Signals;
382382+383383+use Illuminate\Support\Facades\DB;
384384+use SocialDept\AtpParity\Signals\ParitySignal;
385385+use SocialDept\AtpSignals\Events\SignalEvent;
386386+387387+class BatchedParitySignal extends ParitySignal
388388+{
389389+ protected array $buffer = [];
390390+ protected int $batchSize = 100;
391391+392392+ public function handle(SignalEvent $event): void
393393+ {
394394+ $this->buffer[] = $event;
395395+396396+ if (count($this->buffer) >= $this->batchSize) {
397397+ $this->flush();
398398+ }
399399+ }
400400+401401+ protected function flush(): void
402402+ {
403403+ DB::transaction(function () {
404404+ foreach ($this->buffer as $event) {
405405+ parent::handle($event);
406406+ }
407407+ });
408408+409409+ $this->buffer = [];
410410+ }
411411+}
412412+```
413413+414414+### 4. Monitor Performance
415415+416416+Log sync statistics:
417417+418418+```php
419419+namespace App\Signals;
420420+421421+use SocialDept\AtpParity\Signals\ParitySignal;
422422+use SocialDept\AtpSignals\Events\SignalEvent;
423423+424424+class MonitoredParitySignal extends ParitySignal
425425+{
426426+ protected int $processed = 0;
427427+ protected float $startTime;
428428+429429+ public function handle(SignalEvent $event): void
430430+ {
431431+ $this->startTime ??= microtime(true);
432432+433433+ parent::handle($event);
434434+435435+ $this->processed++;
436436+437437+ if ($this->processed % 1000 === 0) {
438438+ $elapsed = microtime(true) - $this->startTime;
439439+ $rate = $this->processed / $elapsed;
440440+441441+ logger()->info("Parity sync stats", [
442442+ 'processed' => $this->processed,
443443+ 'elapsed' => round($elapsed, 2),
444444+ 'rate' => round($rate, 2) . '/sec',
445445+ ]);
446446+ }
447447+ }
448448+}
449449+```
450450+451451+## Cursor Management
452452+453453+atp-signals handles cursor persistence automatically. If the consumer restarts, it resumes from where it left off.
454454+455455+To reset and start fresh:
456456+457457+```bash
458458+php artisan signal:consume --reset
459459+```
460460+461461+## Testing
462462+463463+Test your sync setup without connecting to the firehose:
464464+465465+```php
466466+use App\AtpMappers\PostMapper;
467467+use SocialDept\AtpParity\MapperRegistry;
468468+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
469469+470470+// Create a test record
471471+$record = Post::fromArray([
472472+ 'text' => 'Test post content',
473473+ 'createdAt' => now()->toIso8601String(),
474474+]);
475475+476476+// Get the mapper
477477+$registry = app(MapperRegistry::class);
478478+$mapper = $registry->forLexicon('app.bsky.feed.post');
479479+480480+// Simulate a sync
481481+$model = $mapper->upsert($record, [
482482+ 'uri' => 'at://did:plc:test/app.bsky.feed.post/test123',
483483+ 'cid' => 'bafyretest...',
484484+]);
485485+486486+// Assert
487487+$this->assertDatabaseHas('posts', [
488488+ 'content' => 'Test post content',
489489+ 'atp_uri' => 'at://did:plc:test/app.bsky.feed.post/test123',
490490+]);
491491+```
+359
docs/importing.md
···11+# Importing Records
22+33+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).
44+55+## The Cold Start Problem
66+77+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.
88+99+Importing solves this "cold start" problem by fetching existing records from user repositories via the `com.atproto.repo.listRecords` API.
1010+1111+## Quick Start
1212+1313+### 1. Run the Migration
1414+1515+Publish and run the migration to create the import state tracking table:
1616+1717+```bash
1818+php artisan vendor:publish --tag=parity-migrations
1919+php artisan migrate
2020+```
2121+2222+### 2. Import a User
2323+2424+```bash
2525+# Import all registered collections for a user
2626+php artisan parity:import did:plc:z72i7hdynmk6r22z27h6tvur
2727+2828+# Import a specific collection
2929+php artisan parity:import did:plc:z72i7hdynmk6r22z27h6tvur --collection=app.bsky.feed.post
3030+3131+# Show progress
3232+php artisan parity:import did:plc:z72i7hdynmk6r22z27h6tvur --progress
3333+```
3434+3535+### 3. Check Status
3636+3737+```bash
3838+# Show all import status
3939+php artisan parity:import-status
4040+4141+# Show status for a specific user
4242+php artisan parity:import-status did:plc:z72i7hdynmk6r22z27h6tvur
4343+4444+# Show only incomplete imports
4545+php artisan parity:import-status --pending
4646+```
4747+4848+## Programmatic Usage
4949+5050+### ImportService
5151+5252+The `ImportService` is the main orchestration class:
5353+5454+```php
5555+use SocialDept\AtpParity\Import\ImportService;
5656+5757+$service = app(ImportService::class);
5858+5959+// Import all registered collections for a user
6060+$result = $service->importUser('did:plc:z72i7hdynmk6r22z27h6tvur');
6161+6262+echo "Synced {$result->recordsSynced} records";
6363+6464+// Import a specific collection
6565+$result = $service->importUserCollection(
6666+ 'did:plc:z72i7hdynmk6r22z27h6tvur',
6767+ 'app.bsky.feed.post'
6868+);
6969+7070+// With progress callback
7171+$result = $service->importUser('did:plc:z72i7hdynmk6r22z27h6tvur', null, function ($progress) {
7272+ echo "Synced {$progress->recordsSynced} records from {$progress->collection}\n";
7373+});
7474+```
7575+7676+### ImportResult
7777+7878+The `ImportResult` value object provides information about the import operation:
7979+8080+```php
8181+$result = $service->importUser($did);
8282+8383+$result->recordsSynced; // Number of records successfully synced
8484+$result->recordsSkipped; // Number of records skipped
8585+$result->recordsFailed; // Number of records that failed to sync
8686+$result->completed; // Whether the import completed fully
8787+$result->cursor; // Cursor for resuming (if incomplete)
8888+$result->error; // Error message (if failed)
8989+9090+$result->isSuccess(); // True if completed without errors
9191+$result->isPartial(); // True if some records were synced before failure
9292+$result->isFailed(); // True if an error occurred
9393+```
9494+9595+### Checking Status
9696+9797+```php
9898+// Check if a collection has been imported
9999+if ($service->isImported($did, 'app.bsky.feed.post')) {
100100+ echo "Already imported!";
101101+}
102102+103103+// Get detailed status
104104+$state = $service->getStatus($did, 'app.bsky.feed.post');
105105+106106+if ($state) {
107107+ echo "Status: {$state->status}";
108108+ echo "Records synced: {$state->records_synced}";
109109+}
110110+111111+// Get all statuses for a user
112112+$states = $service->getStatusForUser($did);
113113+```
114114+115115+### Resuming Interrupted Imports
116116+117117+If an import is interrupted (network error, timeout, etc.), you can resume it:
118118+119119+```php
120120+// Resume a specific import
121121+$state = $service->getStatus($did, $collection);
122122+if ($state && $state->canResume()) {
123123+ $result = $service->resume($state);
124124+}
125125+126126+// Resume all interrupted imports
127127+$results = $service->resumeAll();
128128+```
129129+130130+### Resetting Import State
131131+132132+To re-import a user or collection:
133133+134134+```php
135135+// Reset a specific collection
136136+$service->reset($did, 'app.bsky.feed.post');
137137+138138+// Reset all collections for a user
139139+$service->resetUser($did);
140140+```
141141+142142+## Queue Integration
143143+144144+For large-scale importing, use the queue system:
145145+146146+### Command Line
147147+148148+```bash
149149+# Queue an import job instead of running synchronously
150150+php artisan parity:import did:plc:z72i7hdynmk6r22z27h6tvur --queue
151151+152152+# Queue imports for a list of DIDs
153153+php artisan parity:import --file=dids.txt --queue
154154+```
155155+156156+### Programmatic
157157+158158+```php
159159+use SocialDept\AtpParity\Jobs\ImportUserJob;
160160+161161+// Dispatch a single user import
162162+ImportUserJob::dispatch('did:plc:z72i7hdynmk6r22z27h6tvur');
163163+164164+// Dispatch for a specific collection
165165+ImportUserJob::dispatch('did:plc:z72i7hdynmk6r22z27h6tvur', 'app.bsky.feed.post');
166166+```
167167+168168+## Events
169169+170170+Parity dispatches events during importing that you can listen to:
171171+172172+### ImportStarted
173173+174174+Fired when an import operation begins:
175175+176176+```php
177177+use SocialDept\AtpParity\Events\ImportStarted;
178178+179179+Event::listen(ImportStarted::class, function (ImportStarted $event) {
180180+ Log::info("Starting import", [
181181+ 'did' => $event->did,
182182+ 'collection' => $event->collection,
183183+ ]);
184184+});
185185+```
186186+187187+### ImportProgress
188188+189189+Fired after each page of records is processed:
190190+191191+```php
192192+use SocialDept\AtpParity\Events\ImportProgress;
193193+194194+Event::listen(ImportProgress::class, function (ImportProgress $event) {
195195+ Log::info("Import progress", [
196196+ 'did' => $event->did,
197197+ 'collection' => $event->collection,
198198+ 'records_synced' => $event->recordsSynced,
199199+ ]);
200200+});
201201+```
202202+203203+### ImportCompleted
204204+205205+Fired when an import operation completes successfully:
206206+207207+```php
208208+use SocialDept\AtpParity\Events\ImportCompleted;
209209+210210+Event::listen(ImportCompleted::class, function (ImportCompleted $event) {
211211+ $result = $event->result;
212212+213213+ Log::info("Import completed", [
214214+ 'did' => $result->did,
215215+ 'collection' => $result->collection,
216216+ 'records_synced' => $result->recordsSynced,
217217+ ]);
218218+});
219219+```
220220+221221+### ImportFailed
222222+223223+Fired when an import operation fails:
224224+225225+```php
226226+use SocialDept\AtpParity\Events\ImportFailed;
227227+228228+Event::listen(ImportFailed::class, function (ImportFailed $event) {
229229+ Log::error("Import failed", [
230230+ 'did' => $event->did,
231231+ 'collection' => $event->collection,
232232+ 'error' => $event->error,
233233+ ]);
234234+});
235235+```
236236+237237+## Configuration
238238+239239+Configure importing in `config/parity.php`:
240240+241241+```php
242242+'import' => [
243243+ // Records per page when listing from PDS (max 100)
244244+ 'page_size' => 100,
245245+246246+ // Delay between pages in milliseconds (rate limiting)
247247+ 'page_delay' => 100,
248248+249249+ // Queue name for import jobs
250250+ 'queue' => 'parity-import',
251251+252252+ // Database table for storing import state
253253+ 'state_table' => 'parity_import_states',
254254+],
255255+```
256256+257257+## Batch Importing from File
258258+259259+Create a file with DIDs (one per line):
260260+261261+```text
262262+did:plc:z72i7hdynmk6r22z27h6tvur
263263+did:plc:ewvi7nxzyoun6zhxrhs64oiz
264264+did:plc:ragtjsm2j2vknwkz3zp4oxrd
265265+```
266266+267267+Then run:
268268+269269+```bash
270270+# Synchronous (one at a time)
271271+php artisan parity:import --file=dids.txt --progress
272272+273273+# Queued (parallel via workers)
274274+php artisan parity:import --file=dids.txt --queue
275275+```
276276+277277+## Coordinating with ParitySignal
278278+279279+For a complete sync solution, combine importing with real-time firehose sync:
280280+281281+1. **Start the firehose consumer** - Begin receiving live events
282282+2. **Import historical data** - Fetch existing records
283283+3. **Continue firehose sync** - New events are handled automatically
284284+285285+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).
286286+287287+```php
288288+// Example: Import a user then subscribe to their updates
289289+$service->importUser($did);
290290+291291+// The firehose consumer (ParitySignal) handles updates automatically
292292+// as long as it's running with signal:consume
293293+```
294294+295295+## Best Practices
296296+297297+### Rate Limiting
298298+299299+The `page_delay` config option helps prevent overwhelming PDS servers. For bulk importing, consider:
300300+301301+- Using queued jobs to spread load over time
302302+- Increasing the delay between pages
303303+- Running during off-peak hours
304304+305305+### Error Handling
306306+307307+Imports can fail due to:
308308+- Network errors
309309+- PDS rate limiting
310310+- Invalid records
311311+312312+The system automatically tracks progress via cursor, allowing you to resume failed imports:
313313+314314+```bash
315315+# Check for failed imports
316316+php artisan parity:import-status --failed
317317+318318+# Resume all failed/interrupted imports
319319+php artisan parity:import --resume
320320+```
321321+322322+### Monitoring
323323+324324+Use the events to build monitoring:
325325+326326+```php
327327+// Track import metrics
328328+Event::listen(ImportCompleted::class, function (ImportCompleted $event) {
329329+ Metrics::increment('parity.import.completed');
330330+ Metrics::gauge('parity.import.records', $event->result->recordsSynced);
331331+});
332332+333333+Event::listen(ImportFailed::class, function (ImportFailed $event) {
334334+ Metrics::increment('parity.import.failed');
335335+ Alert::send("Import failed for {$event->did}: {$event->error}");
336336+});
337337+```
338338+339339+## Database Schema
340340+341341+The import state table stores progress:
342342+343343+| Column | Type | Description |
344344+|--------|------|-------------|
345345+| id | bigint | Primary key |
346346+| did | string | The DID being imported |
347347+| collection | string | The collection NSID |
348348+| status | string | pending, in_progress, completed, failed |
349349+| cursor | string | Pagination cursor for resuming |
350350+| records_synced | int | Count of successfully synced records |
351351+| records_skipped | int | Count of skipped records |
352352+| records_failed | int | Count of failed records |
353353+| started_at | timestamp | When import started |
354354+| completed_at | timestamp | When import completed |
355355+| error | text | Error message if failed |
356356+| created_at | timestamp | |
357357+| updated_at | timestamp | |
358358+359359+The combination of `did` and `collection` is unique.
+375
docs/mappers.md
···11+# Record Mappers
22+33+Mappers are the core of atp-parity. They define bidirectional transformations between AT Protocol record DTOs and Eloquent models.
44+55+## Creating a Mapper
66+77+Extend the `RecordMapper` abstract class and implement the required methods:
88+99+```php
1010+<?php
1111+1212+namespace App\AtpMappers;
1313+1414+use App\Models\Post;
1515+use Illuminate\Database\Eloquent\Model;
1616+use SocialDept\AtpParity\RecordMapper;
1717+use SocialDept\AtpSchema\Data\Data;
1818+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post as PostRecord;
1919+2020+/**
2121+ * @extends RecordMapper<PostRecord, Post>
2222+ */
2323+class PostMapper extends RecordMapper
2424+{
2525+ /**
2626+ * The AT Protocol record class this mapper handles.
2727+ */
2828+ public function recordClass(): string
2929+ {
3030+ return PostRecord::class;
3131+ }
3232+3333+ /**
3434+ * The Eloquent model class this mapper handles.
3535+ */
3636+ public function modelClass(): string
3737+ {
3838+ return Post::class;
3939+ }
4040+4141+ /**
4242+ * Transform a record DTO into model attributes.
4343+ */
4444+ protected function recordToAttributes(Data $record): array
4545+ {
4646+ /** @var PostRecord $record */
4747+ return [
4848+ 'content' => $record->text,
4949+ 'published_at' => $record->createdAt,
5050+ 'langs' => $record->langs,
5151+ 'facets' => $record->facets,
5252+ ];
5353+ }
5454+5555+ /**
5656+ * Transform a model into record data for creating/updating.
5757+ */
5858+ protected function modelToRecordData(Model $model): array
5959+ {
6060+ /** @var Post $model */
6161+ return [
6262+ 'text' => $model->content,
6363+ 'createdAt' => $model->published_at->toIso8601String(),
6464+ 'langs' => $model->langs ?? ['en'],
6565+ ];
6666+ }
6767+}
6868+```
6969+7070+## Required Methods
7171+7272+### `recordClass(): string`
7373+7474+Returns the fully qualified class name of the AT Protocol record DTO. This can be:
7575+7676+- A generated class from atp-schema (e.g., `SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post`)
7777+- A custom class extending `SocialDept\AtpParity\Data\Record`
7878+7979+### `modelClass(): string`
8080+8181+Returns the fully qualified class name of the Eloquent model.
8282+8383+### `recordToAttributes(Data $record): array`
8484+8585+Transforms an AT Protocol record into an array of Eloquent model attributes. This is used when:
8686+8787+- Creating a new model from a remote record
8888+- Updating an existing model from a remote record
8989+9090+### `modelToRecordData(Model $model): array`
9191+9292+Transforms an Eloquent model into an array suitable for creating an AT Protocol record. This is used when:
9393+9494+- Publishing a local model to the AT Protocol network
9595+- Comparing local and remote state
9696+9797+## Inherited Methods
9898+9999+The abstract `RecordMapper` class provides these methods:
100100+101101+### `lexicon(): string`
102102+103103+Returns the lexicon NSID (e.g., `app.bsky.feed.post`). Automatically derived from the record class's `getLexicon()` method.
104104+105105+### `toModel(Data $record, array $meta = []): Model`
106106+107107+Creates a new (unsaved) model instance from a record DTO.
108108+109109+```php
110110+$record = PostRecord::fromArray($data);
111111+$model = $mapper->toModel($record, [
112112+ 'uri' => 'at://did:plc:xxx/app.bsky.feed.post/abc123',
113113+ 'cid' => 'bafyre...',
114114+]);
115115+```
116116+117117+### `toRecord(Model $model): Data`
118118+119119+Converts a model back to a record DTO.
120120+121121+```php
122122+$record = $mapper->toRecord($post);
123123+// Use $record->toArray() to get data for API calls
124124+```
125125+126126+### `updateModel(Model $model, Data $record, array $meta = []): Model`
127127+128128+Updates an existing model with data from a record. Does not save the model.
129129+130130+```php
131131+$mapper->updateModel($existingPost, $record, ['cid' => $newCid]);
132132+$existingPost->save();
133133+```
134134+135135+### `findByUri(string $uri): ?Model`
136136+137137+Finds a model by its AT Protocol URI.
138138+139139+```php
140140+$post = $mapper->findByUri('at://did:plc:xxx/app.bsky.feed.post/abc123');
141141+```
142142+143143+### `upsert(Data $record, array $meta = []): Model`
144144+145145+Creates or updates a model based on the URI. This is the primary method used for syncing.
146146+147147+```php
148148+$post = $mapper->upsert($record, [
149149+ 'uri' => $uri,
150150+ 'cid' => $cid,
151151+]);
152152+```
153153+154154+### `deleteByUri(string $uri): bool`
155155+156156+Deletes a model by its AT Protocol URI.
157157+158158+```php
159159+$deleted = $mapper->deleteByUri('at://did:plc:xxx/app.bsky.feed.post/abc123');
160160+```
161161+162162+## Meta Fields
163163+164164+The `$meta` array passed to `toModel`, `updateModel`, and `upsert` can contain:
165165+166166+| Key | Description |
167167+|-----|-------------|
168168+| `uri` | The AT Protocol URI (e.g., `at://did:plc:xxx/app.bsky.feed.post/abc123`) |
169169+| `cid` | The content identifier hash |
170170+171171+These are automatically mapped to your configured column names (default: `atp_uri`, `atp_cid`).
172172+173173+## Customizing Column Names
174174+175175+Override the column methods to use different database columns:
176176+177177+```php
178178+class PostMapper extends RecordMapper
179179+{
180180+ protected function uriColumn(): string
181181+ {
182182+ return 'at_uri'; // Instead of default 'atp_uri'
183183+ }
184184+185185+ protected function cidColumn(): string
186186+ {
187187+ return 'at_cid'; // Instead of default 'atp_cid'
188188+ }
189189+190190+ // ... other methods
191191+}
192192+```
193193+194194+Or configure globally in `config/parity.php`:
195195+196196+```php
197197+'columns' => [
198198+ 'uri' => 'at_uri',
199199+ 'cid' => 'at_cid',
200200+],
201201+```
202202+203203+## Registering Mappers
204204+205205+### Via Configuration
206206+207207+Add your mapper classes to `config/parity.php`:
208208+209209+```php
210210+return [
211211+ 'mappers' => [
212212+ App\AtpMappers\PostMapper::class,
213213+ App\AtpMappers\ProfileMapper::class,
214214+ App\AtpMappers\LikeMapper::class,
215215+ ],
216216+];
217217+```
218218+219219+### Programmatically
220220+221221+Register mappers at runtime via the `MapperRegistry`:
222222+223223+```php
224224+use SocialDept\AtpParity\MapperRegistry;
225225+226226+$registry = app(MapperRegistry::class);
227227+$registry->register(new PostMapper());
228228+```
229229+230230+## Using the Registry
231231+232232+The `MapperRegistry` provides lookup methods:
233233+234234+```php
235235+use SocialDept\AtpParity\MapperRegistry;
236236+237237+$registry = app(MapperRegistry::class);
238238+239239+// Find mapper by record class
240240+$mapper = $registry->forRecord(PostRecord::class);
241241+242242+// Find mapper by model class
243243+$mapper = $registry->forModel(Post::class);
244244+245245+// Find mapper by lexicon NSID
246246+$mapper = $registry->forLexicon('app.bsky.feed.post');
247247+248248+// Get all registered lexicons
249249+$lexicons = $registry->lexicons();
250250+// ['app.bsky.feed.post', 'app.bsky.actor.profile', ...]
251251+```
252252+253253+## SchemaMapper for Quick Setup
254254+255255+For simple mappings, use `SchemaMapper` instead of creating a full class:
256256+257257+```php
258258+use SocialDept\AtpParity\Support\SchemaMapper;
259259+use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Like;
260260+261261+$mapper = new SchemaMapper(
262262+ schemaClass: Like::class,
263263+ modelClass: \App\Models\Like::class,
264264+ toAttributes: fn(Like $like) => [
265265+ 'subject_uri' => $like->subject->uri,
266266+ 'subject_cid' => $like->subject->cid,
267267+ 'liked_at' => $like->createdAt,
268268+ ],
269269+ toRecordData: fn($model) => [
270270+ 'subject' => [
271271+ 'uri' => $model->subject_uri,
272272+ 'cid' => $model->subject_cid,
273273+ ],
274274+ 'createdAt' => $model->liked_at->toIso8601String(),
275275+ ],
276276+);
277277+278278+$registry->register($mapper);
279279+```
280280+281281+## Handling Complex Records
282282+283283+### Embedded Objects
284284+285285+AT Protocol records often contain embedded objects. Handle them in your mapping:
286286+287287+```php
288288+protected function recordToAttributes(Data $record): array
289289+{
290290+ /** @var PostRecord $record */
291291+ $attributes = [
292292+ 'content' => $record->text,
293293+ 'published_at' => $record->createdAt,
294294+ ];
295295+296296+ // Handle reply reference
297297+ if ($record->reply) {
298298+ $attributes['reply_to_uri'] = $record->reply->parent->uri;
299299+ $attributes['thread_root_uri'] = $record->reply->root->uri;
300300+ }
301301+302302+ // Handle embed
303303+ if ($record->embed) {
304304+ $attributes['embed_type'] = $record->embed->getType();
305305+ $attributes['embed_data'] = $record->embed->toArray();
306306+ }
307307+308308+ return $attributes;
309309+}
310310+```
311311+312312+### Facets (Rich Text)
313313+314314+Posts with mentions, links, and hashtags have facets:
315315+316316+```php
317317+protected function recordToAttributes(Data $record): array
318318+{
319319+ /** @var PostRecord $record */
320320+ return [
321321+ 'content' => $record->text,
322322+ 'facets' => $record->facets, // Store as JSON
323323+ 'published_at' => $record->createdAt,
324324+ ];
325325+}
326326+327327+protected function modelToRecordData(Model $model): array
328328+{
329329+ /** @var Post $model */
330330+ return [
331331+ 'text' => $model->content,
332332+ 'facets' => $model->facets, // Restore from JSON
333333+ 'createdAt' => $model->published_at->toIso8601String(),
334334+ ];
335335+}
336336+```
337337+338338+## Multiple Mappers per Lexicon
339339+340340+You can register multiple mappers for different model types:
341341+342342+```php
343343+// Map posts to different models based on criteria
344344+class UserPostMapper extends RecordMapper
345345+{
346346+ public function recordClass(): string
347347+ {
348348+ return PostRecord::class;
349349+ }
350350+351351+ public function modelClass(): string
352352+ {
353353+ return UserPost::class;
354354+ }
355355+356356+ // ... mapping logic for user's own posts
357357+}
358358+359359+class FeedPostMapper extends RecordMapper
360360+{
361361+ public function recordClass(): string
362362+ {
363363+ return PostRecord::class;
364364+ }
365365+366366+ public function modelClass(): string
367367+ {
368368+ return FeedPost::class;
369369+ }
370370+371371+ // ... mapping logic for feed posts
372372+}
373373+```
374374+375375+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
···11+# Model Traits
22+33+Parity provides two traits to add AT Protocol awareness to your Eloquent models.
44+55+## HasAtpRecord
66+77+The base trait for models that store AT Protocol record references.
88+99+### Setup
1010+1111+```php
1212+<?php
1313+1414+namespace App\Models;
1515+1616+use Illuminate\Database\Eloquent\Model;
1717+use SocialDept\AtpParity\Concerns\HasAtpRecord;
1818+1919+class Post extends Model
2020+{
2121+ use HasAtpRecord;
2222+2323+ protected $fillable = [
2424+ 'content',
2525+ 'published_at',
2626+ 'atp_uri',
2727+ 'atp_cid',
2828+ ];
2929+}
3030+```
3131+3232+### Database Migration
3333+3434+```php
3535+Schema::create('posts', function (Blueprint $table) {
3636+ $table->id();
3737+ $table->text('content');
3838+ $table->timestamp('published_at');
3939+ $table->string('atp_uri')->nullable()->unique();
4040+ $table->string('atp_cid')->nullable();
4141+ $table->timestamps();
4242+});
4343+```
4444+4545+### Available Methods
4646+4747+#### `getAtpUri(): ?string`
4848+4949+Returns the stored AT Protocol URI.
5050+5151+```php
5252+$post->getAtpUri();
5353+// "at://did:plc:abc123/app.bsky.feed.post/xyz789"
5454+```
5555+5656+#### `getAtpCid(): ?string`
5757+5858+Returns the stored content identifier.
5959+6060+```php
6161+$post->getAtpCid();
6262+// "bafyreib2rxk3rjnlvzj..."
6363+```
6464+6565+#### `getAtpDid(): ?string`
6666+6767+Extracts the DID from the URI.
6868+6969+```php
7070+$post->getAtpDid();
7171+// "did:plc:abc123"
7272+```
7373+7474+#### `getAtpCollection(): ?string`
7575+7676+Extracts the collection (lexicon NSID) from the URI.
7777+7878+```php
7979+$post->getAtpCollection();
8080+// "app.bsky.feed.post"
8181+```
8282+8383+#### `getAtpRkey(): ?string`
8484+8585+Extracts the record key from the URI.
8686+8787+```php
8888+$post->getAtpRkey();
8989+// "xyz789"
9090+```
9191+9292+#### `hasAtpRecord(): bool`
9393+9494+Checks if the model has been synced to AT Protocol.
9595+9696+```php
9797+if ($post->hasAtpRecord()) {
9898+ // Model exists on AT Protocol
9999+}
100100+```
101101+102102+#### `getAtpMapper(): ?RecordMapper`
103103+104104+Gets the registered mapper for this model class.
105105+106106+```php
107107+$mapper = $post->getAtpMapper();
108108+```
109109+110110+#### `toAtpRecord(): ?Data`
111111+112112+Converts the model to an AT Protocol record DTO.
113113+114114+```php
115115+$record = $post->toAtpRecord();
116116+$data = $record->toArray(); // Ready for API calls
117117+```
118118+119119+### Query Scopes
120120+121121+#### `scopeWithAtpRecord($query)`
122122+123123+Query only models that have been synced.
124124+125125+```php
126126+$syncedPosts = Post::withAtpRecord()->get();
127127+```
128128+129129+#### `scopeWithoutAtpRecord($query)`
130130+131131+Query only models that have NOT been synced.
132132+133133+```php
134134+$localOnlyPosts = Post::withoutAtpRecord()->get();
135135+```
136136+137137+#### `scopeWhereAtpUri($query, string $uri)`
138138+139139+Find a model by its AT Protocol URI.
140140+141141+```php
142142+$post = Post::whereAtpUri('at://did:plc:xxx/app.bsky.feed.post/abc')->first();
143143+```
144144+145145+## SyncsWithAtp
146146+147147+Extended trait for bidirectional synchronization tracking. Includes all `HasAtpRecord` functionality plus sync timestamps and conflict detection.
148148+149149+### Setup
150150+151151+```php
152152+<?php
153153+154154+namespace App\Models;
155155+156156+use Illuminate\Database\Eloquent\Model;
157157+use SocialDept\AtpParity\Concerns\SyncsWithAtp;
158158+159159+class Post extends Model
160160+{
161161+ use SyncsWithAtp;
162162+163163+ protected $fillable = [
164164+ 'content',
165165+ 'published_at',
166166+ 'atp_uri',
167167+ 'atp_cid',
168168+ 'atp_synced_at',
169169+ ];
170170+171171+ protected $casts = [
172172+ 'published_at' => 'datetime',
173173+ 'atp_synced_at' => 'datetime',
174174+ ];
175175+}
176176+```
177177+178178+### Database Migration
179179+180180+```php
181181+Schema::create('posts', function (Blueprint $table) {
182182+ $table->id();
183183+ $table->text('content');
184184+ $table->timestamp('published_at');
185185+ $table->string('atp_uri')->nullable()->unique();
186186+ $table->string('atp_cid')->nullable();
187187+ $table->timestamp('atp_synced_at')->nullable();
188188+ $table->timestamps();
189189+});
190190+```
191191+192192+### Additional Methods
193193+194194+#### `getAtpSyncedAtColumn(): string`
195195+196196+Returns the column name for the sync timestamp. Override to customize.
197197+198198+```php
199199+public function getAtpSyncedAtColumn(): string
200200+{
201201+ return 'last_synced_at'; // Default: 'atp_synced_at'
202202+}
203203+```
204204+205205+#### `getAtpSyncedAt(): ?DateTimeInterface`
206206+207207+Returns when the model was last synced.
208208+209209+```php
210210+$syncedAt = $post->getAtpSyncedAt();
211211+// Carbon instance or null
212212+```
213213+214214+#### `markAsSynced(string $uri, string $cid): void`
215215+216216+Marks the model as synced with the given metadata. Does not save.
217217+218218+```php
219219+$post->markAsSynced($uri, $cid);
220220+$post->save();
221221+```
222222+223223+#### `hasLocalChanges(): bool`
224224+225225+Checks if the model has been modified since the last sync.
226226+227227+```php
228228+if ($post->hasLocalChanges()) {
229229+ // Local changes exist that haven't been pushed
230230+}
231231+```
232232+233233+This compares `updated_at` with `atp_synced_at`.
234234+235235+#### `updateFromRecord(Data $record, string $uri, string $cid): void`
236236+237237+Updates the model from a remote record. Does not save.
238238+239239+```php
240240+$post->updateFromRecord($record, $uri, $cid);
241241+$post->save();
242242+```
243243+244244+## Practical Examples
245245+246246+### Checking Sync Status
247247+248248+```php
249249+$post = Post::find(1);
250250+251251+if (!$post->hasAtpRecord()) {
252252+ echo "Not yet published to AT Protocol";
253253+} elseif ($post->hasLocalChanges()) {
254254+ echo "Has unpushed local changes";
255255+} else {
256256+ echo "In sync with AT Protocol";
257257+}
258258+```
259259+260260+### Finding Related Records
261261+262262+```php
263263+// Get all posts from the same author
264264+$authorDid = $post->getAtpDid();
265265+$authorPosts = Post::withAtpRecord()
266266+ ->get()
267267+ ->filter(fn($p) => $p->getAtpDid() === $authorDid);
268268+```
269269+270270+### Building an AT Protocol URL
271271+272272+```php
273273+$post = Post::find(1);
274274+275275+if ($post->hasAtpRecord()) {
276276+ $bskyUrl = sprintf(
277277+ 'https://bsky.app/profile/%s/post/%s',
278278+ $post->getAtpDid(),
279279+ $post->getAtpRkey()
280280+ );
281281+}
282282+```
283283+284284+### Sync Status Dashboard
285285+286286+```php
287287+// Get sync statistics
288288+$stats = [
289289+ 'total' => Post::count(),
290290+ 'synced' => Post::withAtpRecord()->count(),
291291+ 'pending' => Post::withoutAtpRecord()->count(),
292292+ 'with_changes' => Post::withAtpRecord()
293293+ ->get()
294294+ ->filter(fn($p) => $p->hasLocalChanges())
295295+ ->count(),
296296+];
297297+```
298298+299299+## Custom Column Names
300300+301301+Both traits respect the global column configuration:
302302+303303+```php
304304+// config/parity.php
305305+return [
306306+ 'columns' => [
307307+ 'uri' => 'at_protocol_uri',
308308+ 'cid' => 'at_protocol_cid',
309309+ ],
310310+];
311311+```
312312+313313+For the sync timestamp column, override the method in your model:
314314+315315+```php
316316+class Post extends Model
317317+{
318318+ use SyncsWithAtp;
319319+320320+ public function getAtpSyncedAtColumn(): string
321321+ {
322322+ return 'last_synced_at';
323323+ }
324324+}
325325+```
326326+327327+## Event Hooks
328328+329329+The `SyncsWithAtp` trait includes a boot method you can extend:
330330+331331+```php
332332+class Post extends Model
333333+{
334334+ use SyncsWithAtp;
335335+336336+ protected static function bootSyncsWithAtp(): void
337337+ {
338338+ parent::bootSyncsWithAtp();
339339+340340+ static::updating(function ($model) {
341341+ // Custom logic before updates
342342+ });
343343+ }
344344+}
345345+```
346346+347347+## Combining with Other Traits
348348+349349+The traits work alongside other Eloquent features:
350350+351351+```php
352352+use Illuminate\Database\Eloquent\Model;
353353+use Illuminate\Database\Eloquent\SoftDeletes;
354354+use SocialDept\AtpParity\Concerns\SyncsWithAtp;
355355+356356+class Post extends Model
357357+{
358358+ use SoftDeletes;
359359+ use SyncsWithAtp;
360360+361361+ // Both traits work together
362362+}
363363+```
header.png
This is a binary file and will not be displayed.
-5
license.md
···11-# The license
22-33-Copyright (c) Author Name <author@email.com>
44-55-...Add your license text here...
···11-# AtpReplicator
22-33-[![Latest Version on Packagist][ico-version]][link-packagist]
44-[![Total Downloads][ico-downloads]][link-downloads]
55-[![Build Status][ico-travis]][link-travis]
66-[![StyleCI][ico-styleci]][link-styleci]
77-88-This is where your description should go. Take a look at [contributing.md](contributing.md) to see a to do list.
99-1010-## Installation
1111-1212-Via Composer
1313-1414-```bash
1515-composer require socialdept/atp-parity
1616-```
1717-1818-## Usage
1919-2020-## Change log
2121-2222-Please see the [changelog](changelog.md) for more information on what has changed recently.
2323-2424-## Testing
2525-2626-```bash
2727-composer test
2828-```
2929-3030-## Contributing
3131-3232-Please see [contributing.md](contributing.md) for details and a todolist.
3333-3434-## Security
3535-3636-If you discover any security related issues, please email author@email.com instead of using the issue tracker.
3737-3838-## Credits
3939-4040-- [Author Name][link-author]
4141-- [All Contributors][link-contributors]
4242-4343-## License
4444-4545-MIT. Please see the [license file](license.md) for more information.
4646-4747-[ico-version]: https://img.shields.io/packagist/v/socialdept/atp-parity.svg?style=flat-square
4848-[ico-downloads]: https://img.shields.io/packagist/dt/socialdept/atp-parity.svg?style=flat-square
4949-[ico-travis]: https://img.shields.io/travis/socialdept/atp-parity/master.svg?style=flat-square
5050-[ico-styleci]: https://styleci.io/repos/12345678/shield
5151-5252-[link-packagist]: https://packagist.org/packages/socialdept/atp-parity
5353-[link-downloads]: https://packagist.org/packages/socialdept/atp-parity
5454-[link-travis]: https://travis-ci.org/socialdept/atp-parity
5555-[link-styleci]: https://styleci.io/repos/12345678
5656-[link-author]: https://github.com/social-dept
5757-[link-contributors]: ../../contributors
···11-<?php
22-33-namespace SocialDept\AtpReplicator;
44-55-use Illuminate\Support\ServiceProvider;
66-77-class AtpReplicatorServiceProvider extends ServiceProvider
88-{
99- /**
1010- * Perform post-registration booting of services.
1111- *
1212- * @return void
1313- */
1414- public function boot(): void
1515- {
1616- // $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'social-dept');
1717- // $this->loadViewsFrom(__DIR__.'/../resources/views', 'social-dept');
1818- // $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
1919- // $this->loadRoutesFrom(__DIR__.'/routes.php');
2020-2121- // Publishing is only necessary when using the CLI.
2222- if ($this->app->runningInConsole()) {
2323- $this->bootForConsole();
2424- }
2525- }
2626-2727- /**
2828- * Register any package services.
2929- *
3030- * @return void
3131- */
3232- public function register(): void
3333- {
3434- $this->mergeConfigFrom(__DIR__.'/../config/atp-replicator.php', 'atp-replicator');
3535-3636- // Register the service the package provides.
3737- $this->app->singleton('atp-replicator', function ($app) {
3838- return new AtpReplicator;
3939- });
4040- }
4141-4242- /**
4343- * Get the services provided by the provider.
4444- *
4545- * @return array
4646- */
4747- public function provides()
4848- {
4949- return ['atp-replicator'];
5050- }
5151-5252- /**
5353- * Console-specific booting.
5454- *
5555- * @return void
5656- */
5757- protected function bootForConsole(): void
5858- {
5959- // Publishing the configuration file.
6060- $this->publishes([
6161- __DIR__.'/../config/atp-replicator.php' => config_path('atp-replicator.php'),
6262- ], 'atp-replicator.config');
6363-6464- // Publishing the views.
6565- /*$this->publishes([
6666- __DIR__.'/../resources/views' => base_path('resources/views/vendor/social-dept'),
6767- ], 'atp-replicator.views');*/
6868-6969- // Publishing assets.
7070- /*$this->publishes([
7171- __DIR__.'/../resources/assets' => public_path('vendor/social-dept'),
7272- ], 'atp-replicator.assets');*/
7373-7474- // Publishing the translation files.
7575- /*$this->publishes([
7676- __DIR__.'/../resources/lang' => resource_path('lang/vendor/social-dept'),
7777- ], 'atp-replicator.lang');*/
7878-7979- // Registering package commands.
8080- // $this->commands([]);
8181- }
8282-}
+122
src/Commands/DiscoverCommand.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Commands;
44+55+use Illuminate\Console\Command;
66+use SocialDept\AtpParity\Discovery\DiscoveryService;
77+88+use function Laravel\Prompts\error;
99+use function Laravel\Prompts\info;
1010+use function Laravel\Prompts\note;
1111+1212+class DiscoverCommand extends Command
1313+{
1414+ protected $signature = 'parity:discover
1515+ {collection : The collection NSID to discover (e.g., app.bsky.feed.post)}
1616+ {--limit= : Maximum number of DIDs to discover}
1717+ {--import : Import records for discovered DIDs}
1818+ {--output= : Output DIDs to file (one per line)}
1919+ {--count : Only count DIDs without listing them}';
2020+2121+ protected $description = 'Discover DIDs with records in a specific collection';
2222+2323+ public function handle(DiscoveryService $service): int
2424+ {
2525+ $collection = $this->argument('collection');
2626+ $limit = $this->option('limit') ? (int) $this->option('limit') : null;
2727+2828+ if ($this->option('count')) {
2929+ return $this->handleCount($service, $collection);
3030+ }
3131+3232+ if ($this->option('import')) {
3333+ return $this->handleDiscoverAndImport($service, $collection, $limit);
3434+ }
3535+3636+ return $this->handleDiscover($service, $collection, $limit);
3737+ }
3838+3939+ protected function handleCount(DiscoveryService $service, string $collection): int
4040+ {
4141+ info("Counting DIDs with records in {$collection}...");
4242+4343+ $count = $service->count($collection);
4444+4545+ info("Found {$count} DIDs");
4646+4747+ return self::SUCCESS;
4848+ }
4949+5050+ protected function handleDiscover(DiscoveryService $service, string $collection, ?int $limit): int
5151+ {
5252+ $limitDisplay = $limit ? " (limit: {$limit})" : '';
5353+ info("Discovering DIDs with records in {$collection}{$limitDisplay}...");
5454+5555+ $result = $service->discover($collection, $limit);
5656+5757+ if ($result->isFailed()) {
5858+ error("Discovery failed: {$result->error}");
5959+6060+ return self::FAILURE;
6161+ }
6262+6363+ if ($result->total === 0) {
6464+ note('No DIDs found');
6565+6666+ return self::SUCCESS;
6767+ }
6868+6969+ // Output to file if requested
7070+ if ($output = $this->option('output')) {
7171+ file_put_contents($output, implode("\n", $result->dids)."\n");
7272+ info("Found {$result->total} DIDs, written to {$output}");
7373+7474+ if ($result->isIncomplete()) {
7575+ note('Results may be incomplete due to limit');
7676+ }
7777+7878+ return self::SUCCESS;
7979+ }
8080+8181+ // Output to console
8282+ foreach ($result->dids as $did) {
8383+ $this->line($did);
8484+ }
8585+8686+ info("Found {$result->total} DIDs");
8787+8888+ if ($result->isIncomplete()) {
8989+ note('Results may be incomplete due to limit');
9090+ }
9191+9292+ return self::SUCCESS;
9393+ }
9494+9595+ protected function handleDiscoverAndImport(DiscoveryService $service, string $collection, ?int $limit): int
9696+ {
9797+ $limitDisplay = $limit ? " (limit: {$limit})" : '';
9898+ info("Discovering and importing DIDs with records in {$collection}{$limitDisplay}...");
9999+100100+ $result = $service->discoverAndImport(
101101+ $collection,
102102+ $limit,
103103+ function (string $did, int $count) {
104104+ note("[{$count}] Importing {$did}");
105105+ }
106106+ );
107107+108108+ if ($result->isFailed()) {
109109+ error("Discovery failed: {$result->error}");
110110+111111+ return self::FAILURE;
112112+ }
113113+114114+ info("Imported records for {$result->total} DIDs");
115115+116116+ if ($result->isIncomplete()) {
117117+ note('Results may be incomplete due to limit');
118118+ }
119119+120120+ return self::SUCCESS;
121121+ }
122122+}
+135
src/Commands/ExportCommand.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Commands;
44+55+use Illuminate\Console\Command;
66+use SocialDept\AtpParity\Events\ImportProgress;
77+use SocialDept\AtpParity\Export\ExportService;
88+99+use function Laravel\Prompts\error;
1010+use function Laravel\Prompts\info;
1111+use function Laravel\Prompts\note;
1212+1313+class ExportCommand extends Command
1414+{
1515+ protected $signature = 'parity:export
1616+ {did : The DID to export}
1717+ {--output= : Output CAR file path}
1818+ {--import : Import records to database instead of saving CAR file}
1919+ {--collection=* : Specific collections to import (with --import)}
2020+ {--since= : Only export changes since this revision}
2121+ {--status : Show repository status instead of exporting}';
2222+2323+ protected $description = 'Export an AT Protocol repository as CAR file or import to database';
2424+2525+ public function handle(ExportService $service): int
2626+ {
2727+ $did = $this->argument('did');
2828+2929+ if (! str_starts_with($did, 'did:')) {
3030+ error("Invalid DID format: {$did}");
3131+3232+ return self::FAILURE;
3333+ }
3434+3535+ if ($this->option('status')) {
3636+ return $this->handleStatus($service, $did);
3737+ }
3838+3939+ if ($this->option('import')) {
4040+ return $this->handleImport($service, $did);
4141+ }
4242+4343+ return $this->handleExport($service, $did);
4444+ }
4545+4646+ protected function handleStatus(ExportService $service, string $did): int
4747+ {
4848+ info("Getting repository status for {$did}...");
4949+5050+ try {
5151+ $commit = $service->getLatestCommit($did);
5252+ $status = $service->getRepoStatus($did);
5353+5454+ $this->table(['Property', 'Value'], [
5555+ ['DID', $did],
5656+ ['Latest CID', $commit['cid'] ?? 'N/A'],
5757+ ['Latest Rev', $commit['rev'] ?? 'N/A'],
5858+ ['Active', ($status['active'] ?? false) ? 'Yes' : 'No'],
5959+ ['Status', $status['status'] ?? 'N/A'],
6060+ ]);
6161+6262+ return self::SUCCESS;
6363+ } catch (\Throwable $e) {
6464+ error("Failed to get status: {$e->getMessage()}");
6565+6666+ return self::FAILURE;
6767+ }
6868+ }
6969+7070+ protected function handleExport(ExportService $service, string $did): int
7171+ {
7272+ $output = $this->option('output') ?? "{$did}.car";
7373+ $since = $this->option('since');
7474+7575+ // Sanitize filename if using DID as filename
7676+ $output = str_replace([':', '/'], ['_', '_'], $output);
7777+7878+ info("Exporting repository {$did} to {$output}...");
7979+8080+ $result = $service->exportToFile($did, $output, $since);
8181+8282+ if ($result->isFailed()) {
8383+ error("Export failed: {$result->error}");
8484+8585+ return self::FAILURE;
8686+ }
8787+8888+ $size = $this->formatBytes($result->size);
8989+ info("Exported {$size} to {$output}");
9090+9191+ return self::SUCCESS;
9292+ }
9393+9494+ protected function handleImport(ExportService $service, string $did): int
9595+ {
9696+ $collections = $this->option('collection') ?: null;
9797+ $collectionDisplay = $collections ? implode(', ', $collections) : 'all registered';
9898+9999+ info("Exporting and importing {$did} ({$collectionDisplay})...");
100100+101101+ $result = $service->exportAndImport(
102102+ $did,
103103+ $collections,
104104+ function (ImportProgress $progress) {
105105+ $this->output->write("\r");
106106+ $this->output->write(" [{$progress->collection}] {$progress->recordsSynced} records synced");
107107+ }
108108+ );
109109+110110+ $this->output->write("\n");
111111+112112+ if ($result->isFailed()) {
113113+ error("Import failed: {$result->error}");
114114+115115+ return self::FAILURE;
116116+ }
117117+118118+ info("Imported {$result->size} records");
119119+120120+ return self::SUCCESS;
121121+ }
122122+123123+ protected function formatBytes(int $bytes): string
124124+ {
125125+ $units = ['B', 'KB', 'MB', 'GB'];
126126+ $unit = 0;
127127+128128+ while ($bytes >= 1024 && $unit < count($units) - 1) {
129129+ $bytes /= 1024;
130130+ $unit++;
131131+ }
132132+133133+ return round($bytes, 2).' '.$units[$unit];
134134+ }
135135+}
+190
src/Commands/ImportCommand.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Commands;
44+55+use Illuminate\Console\Command;
66+use SocialDept\AtpParity\Events\ImportProgress;
77+use SocialDept\AtpParity\Import\ImportService;
88+use SocialDept\AtpParity\Jobs\ImportUserJob;
99+use SocialDept\AtpParity\MapperRegistry;
1010+1111+use function Laravel\Prompts\error;
1212+use function Laravel\Prompts\info;
1313+use function Laravel\Prompts\note;
1414+use function Laravel\Prompts\warning;
1515+1616+class ImportCommand extends Command
1717+{
1818+ protected $signature = 'parity:import
1919+ {did? : The DID to import}
2020+ {--collection= : Specific collection to import}
2121+ {--file= : File containing DIDs to import (one per line)}
2222+ {--resume : Resume all interrupted imports}
2323+ {--queue : Queue the import job instead of running synchronously}
2424+ {--progress : Show progress output}';
2525+2626+ protected $description = 'Import AT Protocol records for a user or from a file of DIDs';
2727+2828+ public function handle(ImportService $service, MapperRegistry $registry): int
2929+ {
3030+ if ($this->option('resume')) {
3131+ return $this->handleResume($service);
3232+ }
3333+3434+ $did = $this->argument('did');
3535+ $file = $this->option('file');
3636+3737+ if (! $did && ! $file) {
3838+ error('Please provide a DID or use --file to specify a file of DIDs');
3939+4040+ return self::FAILURE;
4141+ }
4242+4343+ if ($file) {
4444+ return $this->handleFile($file, $service);
4545+ }
4646+4747+ return $this->importDid($did, $service, $registry);
4848+ }
4949+5050+ protected function handleResume(ImportService $service): int
5151+ {
5252+ info('Resuming interrupted imports...');
5353+5454+ $results = $service->resumeAll($this->getProgressCallback());
5555+5656+ if (empty($results)) {
5757+ note('No interrupted imports found');
5858+5959+ return self::SUCCESS;
6060+ }
6161+6262+ $success = 0;
6363+ $failed = 0;
6464+6565+ foreach ($results as $result) {
6666+ if ($result->isSuccess()) {
6767+ $success++;
6868+ } else {
6969+ $failed++;
7070+ }
7171+ }
7272+7373+ info("Resumed {$success} imports successfully");
7474+7575+ if ($failed > 0) {
7676+ warning("{$failed} imports failed");
7777+ }
7878+7979+ return $failed > 0 ? self::FAILURE : self::SUCCESS;
8080+ }
8181+8282+ protected function handleFile(string $file, ImportService $service): int
8383+ {
8484+ if (! file_exists($file)) {
8585+ error("File not found: {$file}");
8686+8787+ return self::FAILURE;
8888+ }
8989+9090+ $dids = array_filter(array_map('trim', file($file)));
9191+ $total = count($dids);
9292+ $success = 0;
9393+ $failed = 0;
9494+9595+ info("Importing {$total} DIDs from {$file}");
9696+9797+ foreach ($dids as $index => $did) {
9898+ if (! str_starts_with($did, 'did:')) {
9999+ warning("Skipping invalid DID: {$did}");
100100+101101+ continue;
102102+ }
103103+104104+ $current = $index + 1;
105105+ note("[{$current}/{$total}] Importing {$did}");
106106+107107+ if ($this->option('queue')) {
108108+ ImportUserJob::dispatch($did, $this->option('collection'));
109109+ $success++;
110110+ } else {
111111+ $result = $service->importUser($did, $this->getCollections(), $this->getProgressCallback());
112112+113113+ if ($result->isSuccess()) {
114114+ $success++;
115115+ } else {
116116+ $failed++;
117117+ warning("Failed: {$result->error}");
118118+ }
119119+ }
120120+ }
121121+122122+ info("Completed: {$success} successful, {$failed} failed");
123123+124124+ return $failed > 0 ? self::FAILURE : self::SUCCESS;
125125+ }
126126+127127+ protected function importDid(string $did, ImportService $service, MapperRegistry $registry): int
128128+ {
129129+ if (! str_starts_with($did, 'did:')) {
130130+ error("Invalid DID format: {$did}");
131131+132132+ return self::FAILURE;
133133+ }
134134+135135+ $collections = $this->getCollections();
136136+ $collectionDisplay = $collections ? implode(', ', $collections) : 'all registered';
137137+138138+ info("Importing {$did} ({$collectionDisplay})");
139139+140140+ if ($this->option('queue')) {
141141+ ImportUserJob::dispatch($did, $this->option('collection'));
142142+ note('Import job queued');
143143+144144+ return self::SUCCESS;
145145+ }
146146+147147+ $result = $service->importUser($did, $collections, $this->getProgressCallback());
148148+149149+ if ($result->isSuccess()) {
150150+ info("Import completed: {$result->recordsSynced} records synced");
151151+152152+ if ($result->recordsSkipped > 0) {
153153+ note("{$result->recordsSkipped} records skipped");
154154+ }
155155+156156+ if ($result->recordsFailed > 0) {
157157+ warning("{$result->recordsFailed} records failed");
158158+ }
159159+160160+ return self::SUCCESS;
161161+ }
162162+163163+ error("Import failed: {$result->error}");
164164+165165+ if ($result->recordsSynced > 0) {
166166+ note("Partial progress: {$result->recordsSynced} records synced before failure");
167167+ }
168168+169169+ return self::FAILURE;
170170+ }
171171+172172+ protected function getCollections(): ?array
173173+ {
174174+ $collection = $this->option('collection');
175175+176176+ return $collection ? [$collection] : null;
177177+ }
178178+179179+ protected function getProgressCallback(): ?callable
180180+ {
181181+ if (! $this->option('progress')) {
182182+ return null;
183183+ }
184184+185185+ return function (ImportProgress $progress) {
186186+ $this->output->write("\r");
187187+ $this->output->write(" [{$progress->collection}] {$progress->recordsSynced} records synced");
188188+ };
189189+ }
190190+}
+143
src/Commands/ImportStatusCommand.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Commands;
44+55+use Illuminate\Console\Command;
66+use SocialDept\AtpParity\Import\ImportState;
77+88+use function Laravel\Prompts\info;
99+use function Laravel\Prompts\note;
1010+use function Laravel\Prompts\table;
1111+use function Laravel\Prompts\warning;
1212+1313+class ImportStatusCommand extends Command
1414+{
1515+ protected $signature = 'parity:import-status
1616+ {did? : Show status for specific DID}
1717+ {--pending : Show only pending/incomplete imports}
1818+ {--failed : Show only failed imports}
1919+ {--completed : Show only completed imports}';
2020+2121+ protected $description = 'Show import status';
2222+2323+ public function handle(): int
2424+ {
2525+ $did = $this->argument('did');
2626+2727+ if ($did) {
2828+ return $this->showDidStatus($did);
2929+ }
3030+3131+ return $this->showAllStatus();
3232+ }
3333+3434+ protected function showDidStatus(string $did): int
3535+ {
3636+ $states = ImportState::where('did', $did)->get();
3737+3838+ if ($states->isEmpty()) {
3939+ note("No import records found for {$did}");
4040+4141+ return self::SUCCESS;
4242+ }
4343+4444+ info("Import status for {$did}");
4545+4646+ table(
4747+ headers: ['Collection', 'Status', 'Synced', 'Skipped', 'Failed', 'Started', 'Completed'],
4848+ rows: $states->map(fn (ImportState $state) => [
4949+ $state->collection,
5050+ $this->formatStatus($state->status),
5151+ $state->records_synced,
5252+ $state->records_skipped,
5353+ $state->records_failed,
5454+ $state->started_at?->diffForHumans() ?? '-',
5555+ $state->completed_at?->diffForHumans() ?? '-',
5656+ ])->toArray()
5757+ );
5858+5959+ return self::SUCCESS;
6060+ }
6161+6262+ protected function showAllStatus(): int
6363+ {
6464+ $query = ImportState::query();
6565+6666+ if ($this->option('pending')) {
6767+ $query->incomplete();
6868+ } elseif ($this->option('failed')) {
6969+ $query->failed();
7070+ } elseif ($this->option('completed')) {
7171+ $query->completed();
7272+ }
7373+7474+ $states = $query->orderByDesc('updated_at')->limit(100)->get();
7575+7676+ if ($states->isEmpty()) {
7777+ note('No import records found');
7878+7979+ return self::SUCCESS;
8080+ }
8181+8282+ $this->displaySummary();
8383+8484+ table(
8585+ headers: ['DID', 'Collection', 'Status', 'Synced', 'Updated'],
8686+ rows: $states->map(fn (ImportState $state) => [
8787+ $this->truncateDid($state->did),
8888+ $state->collection,
8989+ $this->formatStatus($state->status),
9090+ $state->records_synced,
9191+ $state->updated_at->diffForHumans(),
9292+ ])->toArray()
9393+ );
9494+9595+ if ($states->count() >= 100) {
9696+ note('Showing first 100 results. Use --pending, --failed, or --completed to filter.');
9797+ }
9898+9999+ return self::SUCCESS;
100100+ }
101101+102102+ protected function displaySummary(): void
103103+ {
104104+ $counts = ImportState::query()
105105+ ->selectRaw('status, count(*) as count')
106106+ ->groupBy('status')
107107+ ->pluck('count', 'status');
108108+109109+ $pending = $counts->get('pending', 0);
110110+ $inProgress = $counts->get('in_progress', 0);
111111+ $completed = $counts->get('completed', 0);
112112+ $failed = $counts->get('failed', 0);
113113+114114+ info("Import Status Summary");
115115+ note("Pending: {$pending} | In Progress: {$inProgress} | Completed: {$completed} | Failed: {$failed}");
116116+117117+ if ($failed > 0) {
118118+ warning("Use 'php artisan parity:import --resume' to retry failed imports");
119119+ }
120120+121121+ $this->newLine();
122122+ }
123123+124124+ protected function formatStatus(string $status): string
125125+ {
126126+ return match ($status) {
127127+ ImportState::STATUS_PENDING => 'pending',
128128+ ImportState::STATUS_IN_PROGRESS => 'running',
129129+ ImportState::STATUS_COMPLETED => 'done',
130130+ ImportState::STATUS_FAILED => 'FAILED',
131131+ default => $status,
132132+ };
133133+ }
134134+135135+ protected function truncateDid(string $did): string
136136+ {
137137+ if (strlen($did) <= 30) {
138138+ return $did;
139139+ }
140140+141141+ return substr($did, 0, 15).'...'.substr($did, -12);
142142+ }
143143+}
+90
src/Concerns/AutoPublish.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Concerns;
44+55+use SocialDept\AtpParity\Publish\PublishService;
66+77+/**
88+ * Trait for Eloquent models that automatically publish to AT Protocol.
99+ *
1010+ * This trait sets up model observers to automatically publish, update,
1111+ * and unpublish records when the model is created, updated, or deleted.
1212+ *
1313+ * Override shouldAutoPublish() and shouldAutoUnpublish() to customize
1414+ * the conditions under which auto-publishing occurs.
1515+ *
1616+ * @mixin \Illuminate\Database\Eloquent\Model
1717+ */
1818+trait AutoPublish
1919+{
2020+ use PublishesRecords;
2121+2222+ /**
2323+ * Boot the AutoPublish trait.
2424+ */
2525+ public static function bootAutoPublish(): void
2626+ {
2727+ static::created(function ($model) {
2828+ if ($model->shouldAutoPublish()) {
2929+ app(PublishService::class)->publish($model);
3030+ }
3131+ });
3232+3333+ static::updated(function ($model) {
3434+ if ($model->isPublished() && $model->shouldAutoPublish()) {
3535+ app(PublishService::class)->update($model);
3636+ }
3737+ });
3838+3939+ static::deleted(function ($model) {
4040+ if ($model->isPublished() && $model->shouldAutoUnpublish()) {
4141+ app(PublishService::class)->delete($model);
4242+ }
4343+ });
4444+ }
4545+4646+ /**
4747+ * Determine if the model should be auto-published.
4848+ *
4949+ * Override this method to add custom conditions.
5050+ */
5151+ public function shouldAutoPublish(): bool
5252+ {
5353+ return true;
5454+ }
5555+5656+ /**
5757+ * Determine if the model should be auto-unpublished when deleted.
5858+ *
5959+ * Override this method to add custom conditions.
6060+ */
6161+ public function shouldAutoUnpublish(): bool
6262+ {
6363+ return true;
6464+ }
6565+6666+ /**
6767+ * Get the DID to use for auto-publishing.
6868+ *
6969+ * Override this method to customize DID resolution.
7070+ */
7171+ public function getAutoPublishDid(): ?string
7272+ {
7373+ // Check for did column
7474+ if (isset($this->did)) {
7575+ return $this->did;
7676+ }
7777+7878+ // Check for user relationship with did
7979+ if (method_exists($this, 'user') && $this->user?->did) {
8080+ return $this->user->did;
8181+ }
8282+8383+ // Check for author relationship with did
8484+ if (method_exists($this, 'author') && $this->author?->did) {
8585+ return $this->author->did;
8686+ }
8787+8888+ return null;
8989+ }
9090+}
+152
src/Concerns/HasAtpRecord.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Concerns;
44+55+use SocialDept\AtpParity\Contracts\RecordMapper;
66+use SocialDept\AtpParity\MapperRegistry;
77+use SocialDept\AtpSchema\Data\Data;
88+99+/**
1010+ * Trait for Eloquent models that map to AT Protocol records.
1111+ *
1212+ * @mixin \Illuminate\Database\Eloquent\Model
1313+ */
1414+trait HasAtpRecord
1515+{
1616+ /**
1717+ * Get the AT Protocol URI for this model.
1818+ */
1919+ public function getAtpUri(): ?string
2020+ {
2121+ $column = config('parity.columns.uri', 'atp_uri');
2222+2323+ return $this->getAttribute($column);
2424+ }
2525+2626+ /**
2727+ * Get the AT Protocol CID for this model.
2828+ */
2929+ public function getAtpCid(): ?string
3030+ {
3131+ $column = config('parity.columns.cid', 'atp_cid');
3232+3333+ return $this->getAttribute($column);
3434+ }
3535+3636+ /**
3737+ * Get the DID from the AT Protocol URI.
3838+ */
3939+ public function getAtpDid(): ?string
4040+ {
4141+ $uri = $this->getAtpUri();
4242+4343+ if (! $uri) {
4444+ return null;
4545+ }
4646+4747+ // at://did:plc:xxx/app.bsky.feed.post/rkey
4848+ if (preg_match('#^at://([^/]+)/#', $uri, $matches)) {
4949+ return $matches[1];
5050+ }
5151+5252+ return null;
5353+ }
5454+5555+ /**
5656+ * Get the collection (lexicon NSID) from the AT Protocol URI.
5757+ */
5858+ public function getAtpCollection(): ?string
5959+ {
6060+ $uri = $this->getAtpUri();
6161+6262+ if (! $uri) {
6363+ return null;
6464+ }
6565+6666+ // at://did:plc:xxx/app.bsky.feed.post/rkey
6767+ if (preg_match('#^at://[^/]+/([^/]+)/#', $uri, $matches)) {
6868+ return $matches[1];
6969+ }
7070+7171+ return null;
7272+ }
7373+7474+ /**
7575+ * Get the rkey from the AT Protocol URI.
7676+ */
7777+ public function getAtpRkey(): ?string
7878+ {
7979+ $uri = $this->getAtpUri();
8080+8181+ if (! $uri) {
8282+ return null;
8383+ }
8484+8585+ // at://did:plc:xxx/app.bsky.feed.post/rkey
8686+ if (preg_match('#^at://[^/]+/[^/]+/([^/]+)$#', $uri, $matches)) {
8787+ return $matches[1];
8888+ }
8989+9090+ return null;
9191+ }
9292+9393+ /**
9494+ * Check if this model has been synced to AT Protocol.
9595+ */
9696+ public function hasAtpRecord(): bool
9797+ {
9898+ return $this->getAtpUri() !== null;
9999+ }
100100+101101+ /**
102102+ * Get the mapper for this model.
103103+ */
104104+ public function getAtpMapper(): ?RecordMapper
105105+ {
106106+ return app(MapperRegistry::class)->forModel(static::class);
107107+ }
108108+109109+ /**
110110+ * Convert this model to an AT Protocol record DTO.
111111+ */
112112+ public function toAtpRecord(): ?Data
113113+ {
114114+ $mapper = $this->getAtpMapper();
115115+116116+ if (! $mapper) {
117117+ return null;
118118+ }
119119+120120+ return $mapper->toRecord($this);
121121+ }
122122+123123+ /**
124124+ * Scope to query models that have been synced to AT Protocol.
125125+ */
126126+ public function scopeWithAtpRecord($query)
127127+ {
128128+ $column = config('parity.columns.uri', 'atp_uri');
129129+130130+ return $query->whereNotNull($column);
131131+ }
132132+133133+ /**
134134+ * Scope to query models that have not been synced to AT Protocol.
135135+ */
136136+ public function scopeWithoutAtpRecord($query)
137137+ {
138138+ $column = config('parity.columns.uri', 'atp_uri');
139139+140140+ return $query->whereNull($column);
141141+ }
142142+143143+ /**
144144+ * Scope to find by AT Protocol URI.
145145+ */
146146+ public function scopeWhereAtpUri($query, string $uri)
147147+ {
148148+ $column = config('parity.columns.uri', 'atp_uri');
149149+150150+ return $query->where($column, $uri);
151151+ }
152152+}
+127
src/Concerns/HasAtpRelationships.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Concerns;
44+55+use Illuminate\Database\Eloquent\Relations\BelongsTo;
66+use Illuminate\Database\Eloquent\Relations\HasMany;
77+88+/**
99+ * Trait for Eloquent models with AT Protocol relationships.
1010+ *
1111+ * Provides helpers for defining relationships based on AT Protocol URI references.
1212+ * Common relationship patterns:
1313+ *
1414+ * - reply.parent -> parent_uri column
1515+ * - reply.root -> root_uri column
1616+ * - embed.record (quote) -> quoted_uri column
1717+ * - like.subject -> subject_uri column
1818+ * - follow.subject -> subject_did column
1919+ * - repost.subject -> subject_uri column
2020+ *
2121+ * @mixin \Illuminate\Database\Eloquent\Model
2222+ */
2323+trait HasAtpRelationships
2424+{
2525+ /**
2626+ * Define an AT Protocol relationship via URI reference.
2727+ *
2828+ * This creates a BelongsTo relationship where the foreign key is an AT Protocol URI
2929+ * stored in the specified column, matched against the related model's atp_uri column.
3030+ *
3131+ * Example:
3232+ * ```php
3333+ * public function parent(): BelongsTo
3434+ * {
3535+ * return $this->atpBelongsTo(Post::class, 'parent_uri');
3636+ * }
3737+ * ```
3838+ *
3939+ * @param class-string<\Illuminate\Database\Eloquent\Model> $related
4040+ */
4141+ public function atpBelongsTo(string $related, string $uriColumn, ?string $ownerKey = null): BelongsTo
4242+ {
4343+ $ownerKey = $ownerKey ?? config('parity.columns.uri', 'atp_uri');
4444+4545+ // Create a custom BelongsTo that uses URI matching
4646+ return $this->belongsTo($related, $uriColumn, $ownerKey);
4747+ }
4848+4949+ /**
5050+ * Define an inverse AT Protocol relationship via URI reference.
5151+ *
5252+ * This creates a HasMany relationship where related models have a column
5353+ * containing this model's AT Protocol URI.
5454+ *
5555+ * Example:
5656+ * ```php
5757+ * public function replies(): HasMany
5858+ * {
5959+ * return $this->atpHasMany(Post::class, 'parent_uri');
6060+ * }
6161+ * ```
6262+ *
6363+ * @param class-string<\Illuminate\Database\Eloquent\Model> $related
6464+ */
6565+ public function atpHasMany(string $related, string $foreignKey, ?string $localKey = null): HasMany
6666+ {
6767+ $localKey = $localKey ?? config('parity.columns.uri', 'atp_uri');
6868+6969+ return $this->hasMany($related, $foreignKey, $localKey);
7070+ }
7171+7272+ /**
7373+ * Define an AT Protocol relationship via DID reference.
7474+ *
7575+ * This creates a BelongsTo relationship where the foreign key is a DID
7676+ * stored in the specified column, matched against a did column on the related model.
7777+ *
7878+ * Example:
7979+ * ```php
8080+ * public function subject(): BelongsTo
8181+ * {
8282+ * return $this->atpBelongsToByDid(User::class, 'subject_did');
8383+ * }
8484+ * ```
8585+ *
8686+ * @param class-string<\Illuminate\Database\Eloquent\Model> $related
8787+ */
8888+ public function atpBelongsToByDid(string $related, string $didColumn, string $ownerKey = 'did'): BelongsTo
8989+ {
9090+ return $this->belongsTo($related, $didColumn, $ownerKey);
9191+ }
9292+9393+ /**
9494+ * Define an inverse AT Protocol relationship via DID reference.
9595+ *
9696+ * Example:
9797+ * ```php
9898+ * public function followers(): HasMany
9999+ * {
100100+ * return $this->atpHasManyByDid(Follow::class, 'subject_did');
101101+ * }
102102+ * ```
103103+ *
104104+ * @param class-string<\Illuminate\Database\Eloquent\Model> $related
105105+ */
106106+ public function atpHasManyByDid(string $related, string $foreignKey, string $localKey = 'did'): HasMany
107107+ {
108108+ return $this->hasMany($related, $foreignKey, $localKey);
109109+ }
110110+111111+ /**
112112+ * Get a related model by AT Protocol URI.
113113+ *
114114+ * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass
115115+ * @return \Illuminate\Database\Eloquent\Model|null
116116+ */
117117+ public function findByAtpUri(string $modelClass, ?string $uri)
118118+ {
119119+ if (! $uri) {
120120+ return null;
121121+ }
122122+123123+ $column = config('parity.columns.uri', 'atp_uri');
124124+125125+ return $modelClass::where($column, $uri)->first();
126126+ }
127127+}
+59
src/Concerns/PublishesRecords.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Concerns;
44+55+use SocialDept\AtpParity\Publish\PublishResult;
66+use SocialDept\AtpParity\Publish\PublishService;
77+88+/**
99+ * Trait for Eloquent models that can be manually published to AT Protocol.
1010+ *
1111+ * @mixin \Illuminate\Database\Eloquent\Model
1212+ */
1313+trait PublishesRecords
1414+{
1515+ use HasAtpRecord;
1616+1717+ /**
1818+ * Publish this model to AT Protocol.
1919+ *
2020+ * If the model has a DID association (via did column or relationship),
2121+ * it will be used. Otherwise, use publishAs() to specify the DID.
2222+ */
2323+ public function publish(): PublishResult
2424+ {
2525+ return app(PublishService::class)->publish($this);
2626+ }
2727+2828+ /**
2929+ * Publish this model as a specific user.
3030+ */
3131+ public function publishAs(string $did): PublishResult
3232+ {
3333+ return app(PublishService::class)->publishAs($did, $this);
3434+ }
3535+3636+ /**
3737+ * Update the published record on AT Protocol.
3838+ */
3939+ public function republish(): PublishResult
4040+ {
4141+ return app(PublishService::class)->update($this);
4242+ }
4343+4444+ /**
4545+ * Delete the record from AT Protocol.
4646+ */
4747+ public function unpublish(): bool
4848+ {
4949+ return app(PublishService::class)->delete($this);
5050+ }
5151+5252+ /**
5353+ * Check if this model has been published to AT Protocol.
5454+ */
5555+ public function isPublished(): bool
5656+ {
5757+ return $this->hasAtpRecord();
5858+ }
5959+}
+96
src/Concerns/SyncsWithAtp.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Concerns;
44+55+use SocialDept\AtpSchema\Data\Data;
66+77+/**
88+ * Trait for models that sync bidirectionally with AT Protocol.
99+ *
1010+ * Extends HasAtpRecord with additional sync tracking and conflict handling.
1111+ *
1212+ * @mixin \Illuminate\Database\Eloquent\Model
1313+ */
1414+trait SyncsWithAtp
1515+{
1616+ use HasAtpRecord;
1717+1818+ /**
1919+ * Get the column name for tracking the last sync timestamp.
2020+ */
2121+ public function getAtpSyncedAtColumn(): string
2222+ {
2323+ return 'atp_synced_at';
2424+ }
2525+2626+ /**
2727+ * Get the timestamp of the last sync.
2828+ */
2929+ public function getAtpSyncedAt(): ?\DateTimeInterface
3030+ {
3131+ $column = $this->getAtpSyncedAtColumn();
3232+3333+ return $this->getAttribute($column);
3434+ }
3535+3636+ /**
3737+ * Mark the model as synced with the given metadata.
3838+ */
3939+ public function markAsSynced(string $uri, string $cid): void
4040+ {
4141+ $uriColumn = config('parity.columns.uri', 'atp_uri');
4242+ $cidColumn = config('parity.columns.cid', 'atp_cid');
4343+ $syncColumn = $this->getAtpSyncedAtColumn();
4444+4545+ $this->setAttribute($uriColumn, $uri);
4646+ $this->setAttribute($cidColumn, $cid);
4747+ $this->setAttribute($syncColumn, now());
4848+ }
4949+5050+ /**
5151+ * Check if the model has local changes since last sync.
5252+ */
5353+ public function hasLocalChanges(): bool
5454+ {
5555+ $syncedAt = $this->getAtpSyncedAt();
5656+5757+ if (! $syncedAt) {
5858+ return true;
5959+ }
6060+6161+ $updatedAt = $this->getAttribute('updated_at');
6262+6363+ if (! $updatedAt) {
6464+ return false;
6565+ }
6666+6767+ return $updatedAt > $syncedAt;
6868+ }
6969+7070+ /**
7171+ * Update the model from a remote record.
7272+ */
7373+ public function updateFromRecord(Data $record, string $uri, string $cid): void
7474+ {
7575+ $mapper = $this->getAtpMapper();
7676+7777+ if (! $mapper) {
7878+ return;
7979+ }
8080+8181+ $mapper->updateModel($this, $record, [
8282+ 'uri' => $uri,
8383+ 'cid' => $cid,
8484+ ]);
8585+8686+ $this->setAttribute($this->getAtpSyncedAtColumn(), now());
8787+ }
8888+8989+ /**
9090+ * Boot the trait.
9191+ */
9292+ public static function bootSyncsWithAtp(): void
9393+ {
9494+ // Hook into model events if needed
9595+ }
9696+}
+82
src/Contracts/RecordMapper.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Contracts;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpSchema\Data\Data;
77+88+/**
99+ * Contract for bidirectional mapping between Record DTOs and Eloquent models.
1010+ *
1111+ * @template TRecord of Data
1212+ * @template TModel of Model
1313+ */
1414+interface RecordMapper
1515+{
1616+ /**
1717+ * Get the Record class this mapper handles.
1818+ *
1919+ * @return class-string<TRecord>
2020+ */
2121+ public function recordClass(): string;
2222+2323+ /**
2424+ * Get the Model class this mapper handles.
2525+ *
2626+ * @return class-string<TModel>
2727+ */
2828+ public function modelClass(): string;
2929+3030+ /**
3131+ * Get the lexicon NSID this mapper handles.
3232+ */
3333+ public function lexicon(): string;
3434+3535+ /**
3636+ * Convert a Record DTO to an Eloquent Model.
3737+ *
3838+ * @param TRecord $record
3939+ * @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta AT Protocol metadata
4040+ * @return TModel
4141+ */
4242+ public function toModel(Data $record, array $meta = []): Model;
4343+4444+ /**
4545+ * Convert an Eloquent Model to a Record DTO.
4646+ *
4747+ * @param TModel $model
4848+ * @return TRecord
4949+ */
5050+ public function toRecord(Model $model): Data;
5151+5252+ /**
5353+ * Update an existing model with data from a record.
5454+ *
5555+ * @param TModel $model
5656+ * @param TRecord $record
5757+ * @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta
5858+ * @return TModel
5959+ */
6060+ public function updateModel(Model $model, Data $record, array $meta = []): Model;
6161+6262+ /**
6363+ * Find or create model from record.
6464+ *
6565+ * @param TRecord $record
6666+ * @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta
6767+ * @return TModel
6868+ */
6969+ public function upsert(Data $record, array $meta = []): Model;
7070+7171+ /**
7272+ * Find model by AT Protocol URI.
7373+ *
7474+ * @return TModel|null
7575+ */
7676+ public function findByUri(string $uri): ?Model;
7777+7878+ /**
7979+ * Delete model by AT Protocol URI.
8080+ */
8181+ public function deleteByUri(string $uri): bool;
8282+}
+25
src/Data/Record.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Data;
44+55+use SocialDept\AtpClient\Contracts\Recordable;
66+use SocialDept\AtpSchema\Data\Data;
77+88+/**
99+ * Base class for custom AT Protocol records.
1010+ *
1111+ * Extends atp-schema's Data for full compatibility with the ecosystem,
1212+ * including union type support, validation, equality, and hashing.
1313+ *
1414+ * Implements Recordable for seamless atp-client integration.
1515+ */
1616+abstract class Record extends Data implements Recordable
1717+{
1818+ /**
1919+ * Get the record type (alias for getLexicon for Recordable interface).
2020+ */
2121+ public function getType(): string
2222+ {
2323+ return static::getLexicon();
2424+ }
2525+}
+65
src/Discovery/DiscoveryResult.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Discovery;
44+55+/**
66+ * Immutable value object representing the result of a discovery operation.
77+ */
88+readonly class DiscoveryResult
99+{
1010+ public function __construct(
1111+ public bool $success,
1212+ public array $dids = [],
1313+ public int $total = 0,
1414+ public ?string $error = null,
1515+ public bool $incomplete = false,
1616+ ) {}
1717+1818+ /**
1919+ * Check if the discovery operation succeeded.
2020+ */
2121+ public function isSuccess(): bool
2222+ {
2323+ return $this->success;
2424+ }
2525+2626+ /**
2727+ * Check if the discovery operation failed.
2828+ */
2929+ public function isFailed(): bool
3030+ {
3131+ return ! $this->success;
3232+ }
3333+3434+ /**
3535+ * Check if the discovery was stopped before completion (e.g., limit reached).
3636+ */
3737+ public function isIncomplete(): bool
3838+ {
3939+ return $this->incomplete;
4040+ }
4141+4242+ /**
4343+ * Create a successful result.
4444+ */
4545+ public static function success(array $dids, bool $incomplete = false): self
4646+ {
4747+ return new self(
4848+ success: true,
4949+ dids: $dids,
5050+ total: count($dids),
5151+ incomplete: $incomplete,
5252+ );
5353+ }
5454+5555+ /**
5656+ * Create a failed result.
5757+ */
5858+ public static function failed(string $error): self
5959+ {
6060+ return new self(
6161+ success: false,
6262+ error: $error,
6363+ );
6464+ }
6565+}
+119
src/Discovery/DiscoveryService.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Discovery;
44+55+use BackedEnum;
66+use Generator;
77+use SocialDept\AtpClient\Facades\Atp;
88+use SocialDept\AtpParity\Import\ImportService;
99+use Throwable;
1010+1111+/**
1212+ * Service for discovering DIDs with records in specific collections.
1313+ */
1414+class DiscoveryService
1515+{
1616+ public function __construct(
1717+ protected ImportService $importService
1818+ ) {}
1919+2020+ /**
2121+ * Discover all DIDs with records in a collection.
2222+ *
2323+ * @return Generator<string> Yields DIDs
2424+ */
2525+ public function discoverDids(string|BackedEnum $collection, ?int $limit = null): Generator
2626+ {
2727+ $collection = $collection instanceof BackedEnum ? $collection->value : $collection;
2828+ $cursor = null;
2929+ $count = 0;
3030+3131+ do {
3232+ $response = Atp::atproto->sync->listReposByCollection(
3333+ collection: $collection,
3434+ limit: min(500, $limit ? $limit - $count : 500),
3535+ cursor: $cursor,
3636+ );
3737+3838+ foreach ($response->repos as $repo) {
3939+ $did = $repo['did'] ?? null;
4040+4141+ if ($did) {
4242+ yield $did;
4343+ $count++;
4444+4545+ if ($limit !== null && $count >= $limit) {
4646+ return;
4747+ }
4848+ }
4949+ }
5050+5151+ $cursor = $response->cursor;
5252+ } while ($cursor !== null);
5353+ }
5454+5555+ /**
5656+ * Discover DIDs and return as an array.
5757+ */
5858+ public function discover(string|BackedEnum $collection, ?int $limit = null): DiscoveryResult
5959+ {
6060+ try {
6161+ $dids = iterator_to_array($this->discoverDids($collection, $limit));
6262+ $incomplete = $limit !== null && count($dids) >= $limit;
6363+6464+ return DiscoveryResult::success($dids, $incomplete);
6565+ } catch (Throwable $e) {
6666+ return DiscoveryResult::failed($e->getMessage());
6767+ }
6868+ }
6969+7070+ /**
7171+ * Discover and import all users for a collection.
7272+ */
7373+ public function discoverAndImport(
7474+ string|BackedEnum $collection,
7575+ ?int $limit = null,
7676+ ?callable $onProgress = null
7777+ ): DiscoveryResult {
7878+ $collection = $collection instanceof BackedEnum ? $collection->value : $collection;
7979+8080+ try {
8181+ $dids = [];
8282+ $count = 0;
8383+8484+ foreach ($this->discoverDids($collection, $limit) as $did) {
8585+ $dids[] = $did;
8686+ $count++;
8787+8888+ // Start import for this DID
8989+ $this->importService->import($did, [$collection]);
9090+9191+ if ($onProgress) {
9292+ $onProgress($did, $count);
9393+ }
9494+ }
9595+9696+ $incomplete = $limit !== null && count($dids) >= $limit;
9797+9898+ return DiscoveryResult::success($dids, $incomplete);
9999+ } catch (Throwable $e) {
100100+ return DiscoveryResult::failed($e->getMessage());
101101+ }
102102+ }
103103+104104+ /**
105105+ * Count total DIDs with records in a collection.
106106+ *
107107+ * Note: This iterates through all results, which can be slow.
108108+ */
109109+ public function count(string|BackedEnum $collection): int
110110+ {
111111+ $count = 0;
112112+113113+ foreach ($this->discoverDids($collection) as $_) {
114114+ $count++;
115115+ }
116116+117117+ return $count;
118118+ }
119119+}
+23
src/Events/ConflictDetected.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Events;
44+55+use Illuminate\Database\Eloquent\Model;
66+use Illuminate\Foundation\Events\Dispatchable;
77+use SocialDept\AtpParity\Sync\PendingConflict;
88+use SocialDept\AtpSchema\Data\Data;
99+1010+/**
1111+ * Dispatched when a conflict is detected that requires manual resolution.
1212+ */
1313+class ConflictDetected
1414+{
1515+ use Dispatchable;
1616+1717+ public function __construct(
1818+ public readonly Model $model,
1919+ public readonly Data $record,
2020+ public readonly array $meta,
2121+ public readonly PendingConflict $conflict,
2222+ ) {}
2323+}
+15
src/Events/ImportCompleted.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Events;
44+55+use Illuminate\Foundation\Events\Dispatchable;
66+use SocialDept\AtpParity\Import\ImportResult;
77+88+class ImportCompleted
99+{
1010+ use Dispatchable;
1111+1212+ public function __construct(
1313+ public readonly ImportResult $result,
1414+ ) {}
1515+}
+16
src/Events/ImportFailed.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Events;
44+55+use Illuminate\Foundation\Events\Dispatchable;
66+77+class ImportFailed
88+{
99+ use Dispatchable;
1010+1111+ public function __construct(
1212+ public readonly string $did,
1313+ public readonly string $collection,
1414+ public readonly string $error,
1515+ ) {}
1616+}
+17
src/Events/ImportProgress.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Events;
44+55+use Illuminate\Foundation\Events\Dispatchable;
66+77+class ImportProgress
88+{
99+ use Dispatchable;
1010+1111+ public function __construct(
1212+ public readonly string $did,
1313+ public readonly string $collection,
1414+ public readonly int $recordsSynced,
1515+ public readonly ?string $cursor = null,
1616+ ) {}
1717+}
+15
src/Events/ImportStarted.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Events;
44+55+use Illuminate\Foundation\Events\Dispatchable;
66+77+class ImportStarted
88+{
99+ use Dispatchable;
1010+1111+ public function __construct(
1212+ public readonly string $did,
1313+ public readonly string $collection,
1414+ ) {}
1515+}
+20
src/Events/RecordPublished.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Events;
44+55+use Illuminate\Database\Eloquent\Model;
66+use Illuminate\Foundation\Events\Dispatchable;
77+88+/**
99+ * Dispatched when a model is published to AT Protocol.
1010+ */
1111+class RecordPublished
1212+{
1313+ use Dispatchable;
1414+1515+ public function __construct(
1616+ public readonly Model $model,
1717+ public readonly string $uri,
1818+ public readonly string $cid,
1919+ ) {}
2020+}
+19
src/Events/RecordUnpublished.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Events;
44+55+use Illuminate\Database\Eloquent\Model;
66+use Illuminate\Foundation\Events\Dispatchable;
77+88+/**
99+ * Dispatched when a model is unpublished from AT Protocol.
1010+ */
1111+class RecordUnpublished
1212+{
1313+ use Dispatchable;
1414+1515+ public function __construct(
1616+ public readonly Model $model,
1717+ public readonly string $uri,
1818+ ) {}
1919+}
+55
src/Export/ExportResult.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Export;
44+55+/**
66+ * Value object representing the result of an export operation.
77+ */
88+readonly class ExportResult
99+{
1010+ public function __construct(
1111+ public bool $success,
1212+ public ?string $path = null,
1313+ public ?int $size = null,
1414+ public ?string $error = null,
1515+ ) {}
1616+1717+ /**
1818+ * Check if the export operation succeeded.
1919+ */
2020+ public function isSuccess(): bool
2121+ {
2222+ return $this->success;
2323+ }
2424+2525+ /**
2626+ * Check if the export operation failed.
2727+ */
2828+ public function isFailed(): bool
2929+ {
3030+ return ! $this->success;
3131+ }
3232+3333+ /**
3434+ * Create a successful result.
3535+ */
3636+ public static function success(string $path, int $size): self
3737+ {
3838+ return new self(
3939+ success: true,
4040+ path: $path,
4141+ size: $size,
4242+ );
4343+ }
4444+4545+ /**
4646+ * Create a failed result.
4747+ */
4848+ public static function failed(string $error): self
4949+ {
5050+ return new self(
5151+ success: false,
5252+ error: $error,
5353+ );
5454+ }
5555+}
+142
src/Export/ExportService.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Export;
44+55+use BackedEnum;
66+use Generator;
77+use SocialDept\AtpClient\Facades\Atp;
88+use SocialDept\AtpParity\Import\ImportService;
99+use SocialDept\AtpParity\MapperRegistry;
1010+use Throwable;
1111+1212+/**
1313+ * Service for exporting AT Protocol repositories.
1414+ */
1515+class ExportService
1616+{
1717+ public function __construct(
1818+ protected MapperRegistry $registry,
1919+ protected ImportService $importService
2020+ ) {}
2121+2222+ /**
2323+ * Download a user's repository as CAR data.
2424+ */
2525+ public function downloadRepo(string $did, ?string $since = null): RepoExport
2626+ {
2727+ $response = Atp::atproto->sync->getRepo($did, $since);
2828+ $carData = $response->body();
2929+3030+ return new RepoExport(
3131+ did: $did,
3232+ carData: $carData,
3333+ size: strlen($carData),
3434+ );
3535+ }
3636+3737+ /**
3838+ * Export a repository to a local file.
3939+ */
4040+ public function exportToFile(string $did, string $path, ?string $since = null): ExportResult
4141+ {
4242+ try {
4343+ $export = $this->downloadRepo($did, $since);
4444+4545+ if (! $export->saveTo($path)) {
4646+ return ExportResult::failed("Failed to write to file: {$path}");
4747+ }
4848+4949+ return ExportResult::success($path, $export->size);
5050+ } catch (Throwable $e) {
5151+ return ExportResult::failed($e->getMessage());
5252+ }
5353+ }
5454+5555+ /**
5656+ * Export and import records from a repository.
5757+ *
5858+ * This downloads the repository and imports records using the normal import pipeline.
5959+ * It's useful for bulk importing all records from a user.
6060+ *
6161+ * @param array<string>|null $collections Specific collections to import (null = all registered)
6262+ */
6363+ public function exportAndImport(
6464+ string $did,
6565+ ?array $collections = null,
6666+ ?callable $onProgress = null
6767+ ): ExportResult {
6868+ try {
6969+ // Use the import service to import the user's records
7070+ $result = $this->importService->importUser($did, $collections, $onProgress);
7171+7272+ if ($result->isFailed()) {
7373+ return ExportResult::failed($result->error ?? 'Import failed');
7474+ }
7575+7676+ return ExportResult::success(
7777+ path: "imported:{$did}",
7878+ size: $result->recordsSynced
7979+ );
8080+ } catch (Throwable $e) {
8181+ return ExportResult::failed($e->getMessage());
8282+ }
8383+ }
8484+8585+ /**
8686+ * List available blobs for a repository.
8787+ *
8888+ * @return Generator<string> Yields blob CIDs
8989+ */
9090+ public function listBlobs(string $did, ?string $since = null): Generator
9191+ {
9292+ $cursor = null;
9393+9494+ do {
9595+ $response = Atp::atproto->sync->listBlobs(
9696+ did: $did,
9797+ since: $since,
9898+ limit: 500,
9999+ cursor: $cursor,
100100+ );
101101+102102+ foreach ($response->cids as $cid) {
103103+ yield $cid;
104104+ }
105105+106106+ $cursor = $response->cursor;
107107+ } while ($cursor !== null);
108108+ }
109109+110110+ /**
111111+ * Download a specific blob.
112112+ */
113113+ public function downloadBlob(string $did, string $cid): string
114114+ {
115115+ $response = Atp::atproto->sync->getBlob($did, $cid);
116116+117117+ return $response->body();
118118+ }
119119+120120+ /**
121121+ * Get the latest commit for a repository.
122122+ */
123123+ public function getLatestCommit(string $did): array
124124+ {
125125+ $commit = Atp::atproto->sync->getLatestCommit($did);
126126+127127+ return [
128128+ 'cid' => $commit->cid,
129129+ 'rev' => $commit->rev,
130130+ ];
131131+ }
132132+133133+ /**
134134+ * Get the hosting status for a repository.
135135+ */
136136+ public function getRepoStatus(string $did): array
137137+ {
138138+ $status = Atp::atproto->sync->getRepoStatus($did);
139139+140140+ return $status->toArray();
141141+ }
142142+}
+40
src/Export/RepoExport.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Export;
44+55+/**
66+ * Value object representing an exported repository as CAR data.
77+ */
88+readonly class RepoExport
99+{
1010+ public function __construct(
1111+ public string $did,
1212+ public string $carData,
1313+ public int $size,
1414+ ) {}
1515+1616+ /**
1717+ * Save the CAR data to a file.
1818+ */
1919+ public function saveTo(string $path): bool
2020+ {
2121+ return file_put_contents($path, $this->carData) !== false;
2222+ }
2323+2424+ /**
2525+ * Get the size in human-readable format.
2626+ */
2727+ public function humanSize(): string
2828+ {
2929+ $units = ['B', 'KB', 'MB', 'GB'];
3030+ $size = $this->size;
3131+ $unit = 0;
3232+3333+ while ($size >= 1024 && $unit < count($units) - 1) {
3434+ $size /= 1024;
3535+ $unit++;
3636+ }
3737+3838+ return round($size, 2).' '.$units[$unit];
3939+ }
4040+}
-18
src/Facades/AtpReplicator.php
···11-<?php
22-33-namespace SocialDept\AtpReplicator\Facades;
44-55-use Illuminate\Support\Facades\Facade;
66-77-class AtpReplicator extends Facade
88-{
99- /**
1010- * Get the registered name of the component.
1111- *
1212- * @return string
1313- */
1414- protected static function getFacadeAccessor(): string
1515- {
1616- return 'atp-replicator';
1717- }
1818-}
···11+<?php
22+33+namespace SocialDept\AtpParity\Import;
44+55+/**
66+ * Immutable value object representing the result of an import operation.
77+ */
88+readonly class ImportResult
99+{
1010+ public function __construct(
1111+ public string $did,
1212+ public string $collection,
1313+ public int $recordsSynced,
1414+ public int $recordsSkipped,
1515+ public int $recordsFailed,
1616+ public bool $completed,
1717+ public ?string $cursor = null,
1818+ public ?string $error = null,
1919+ ) {}
2020+2121+ /**
2222+ * Check if the import completed successfully.
2323+ */
2424+ public function isSuccess(): bool
2525+ {
2626+ return $this->completed && $this->error === null;
2727+ }
2828+2929+ /**
3030+ * Check if the import was partially completed.
3131+ */
3232+ public function isPartial(): bool
3333+ {
3434+ return ! $this->completed && $this->recordsSynced > 0;
3535+ }
3636+3737+ /**
3838+ * Check if the import failed.
3939+ */
4040+ public function isFailed(): bool
4141+ {
4242+ return $this->error !== null;
4343+ }
4444+4545+ /**
4646+ * Get total records processed.
4747+ */
4848+ public function totalProcessed(): int
4949+ {
5050+ return $this->recordsSynced + $this->recordsSkipped + $this->recordsFailed;
5151+ }
5252+5353+ /**
5454+ * Create a successful result.
5555+ */
5656+ public static function success(string $did, string $collection, int $synced, int $skipped = 0, int $failed = 0): self
5757+ {
5858+ return new self(
5959+ did: $did,
6060+ collection: $collection,
6161+ recordsSynced: $synced,
6262+ recordsSkipped: $skipped,
6363+ recordsFailed: $failed,
6464+ completed: true,
6565+ );
6666+ }
6767+6868+ /**
6969+ * Create a partial result (incomplete).
7070+ */
7171+ public static function partial(string $did, string $collection, int $synced, string $cursor, int $skipped = 0, int $failed = 0): self
7272+ {
7373+ return new self(
7474+ did: $did,
7575+ collection: $collection,
7676+ recordsSynced: $synced,
7777+ recordsSkipped: $skipped,
7878+ recordsFailed: $failed,
7979+ completed: false,
8080+ cursor: $cursor,
8181+ );
8282+ }
8383+8484+ /**
8585+ * Create a failed result.
8686+ */
8787+ public static function failed(string $did, string $collection, string $error, int $synced = 0, int $skipped = 0, int $failed = 0, ?string $cursor = null): self
8888+ {
8989+ return new self(
9090+ did: $did,
9191+ collection: $collection,
9292+ recordsSynced: $synced,
9393+ recordsSkipped: $skipped,
9494+ recordsFailed: $failed,
9595+ completed: false,
9696+ cursor: $cursor,
9797+ error: $error,
9898+ );
9999+ }
100100+101101+ /**
102102+ * Merge multiple results for the same DID into one aggregate result.
103103+ *
104104+ * @param ImportResult[] $results
105105+ */
106106+ public static function aggregate(string $did, array $results): self
107107+ {
108108+ $synced = 0;
109109+ $skipped = 0;
110110+ $failed = 0;
111111+ $errors = [];
112112+ $allCompleted = true;
113113+114114+ foreach ($results as $result) {
115115+ $synced += $result->recordsSynced;
116116+ $skipped += $result->recordsSkipped;
117117+ $failed += $result->recordsFailed;
118118+119119+ if (! $result->completed) {
120120+ $allCompleted = false;
121121+ }
122122+123123+ if ($result->error) {
124124+ $errors[] = "{$result->collection}: {$result->error}";
125125+ }
126126+ }
127127+128128+ return new self(
129129+ did: $did,
130130+ collection: '*',
131131+ recordsSynced: $synced,
132132+ recordsSkipped: $skipped,
133133+ recordsFailed: $failed,
134134+ completed: $allCompleted,
135135+ error: $errors ? implode('; ', $errors) : null,
136136+ );
137137+ }
138138+}
+253
src/Import/ImportService.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Import;
44+55+use SocialDept\AtpClient\AtpClient;
66+use SocialDept\AtpClient\Facades\Atp;
77+use SocialDept\AtpParity\Events\ImportCompleted;
88+use SocialDept\AtpParity\Events\ImportFailed;
99+use SocialDept\AtpParity\Events\ImportProgress;
1010+use SocialDept\AtpParity\Events\ImportStarted;
1111+use SocialDept\AtpParity\MapperRegistry;
1212+use SocialDept\AtpResolver\Facades\Resolver;
1313+use Throwable;
1414+1515+/**
1616+ * Orchestrates importing of AT Protocol records to Eloquent models.
1717+ *
1818+ * Supports importing individual users, specific collections, or entire
1919+ * networks through cursor-based pagination with progress tracking.
2020+ */
2121+class ImportService
2222+{
2323+ /**
2424+ * Cache of clients by PDS endpoint.
2525+ *
2626+ * @var array<string, AtpClient>
2727+ */
2828+ protected array $clients = [];
2929+3030+ public function __construct(
3131+ protected MapperRegistry $registry
3232+ ) {}
3333+3434+ /**
3535+ * Import all records for a user in registered collections.
3636+ *
3737+ * @param array<string>|null $collections Specific collections to import, or null for all registered
3838+ */
3939+ public function importUser(string $did, ?array $collections = null, ?callable $onProgress = null): ImportResult
4040+ {
4141+ $collections = $collections ?? $this->registry->lexicons();
4242+ $results = [];
4343+4444+ foreach ($collections as $collection) {
4545+ if (! $this->registry->hasLexicon($collection)) {
4646+ continue;
4747+ }
4848+4949+ $results[] = $this->importUserCollection($did, $collection, $onProgress);
5050+ }
5151+5252+ return ImportResult::aggregate($did, $results);
5353+ }
5454+5555+ /**
5656+ * Import a specific collection for a user.
5757+ */
5858+ public function importUserCollection(string $did, string $collection, ?callable $onProgress = null): ImportResult
5959+ {
6060+ $mapper = $this->registry->forLexicon($collection);
6161+6262+ if (! $mapper) {
6363+ return ImportResult::failed($did, $collection, "No mapper registered for collection: {$collection}");
6464+ }
6565+6666+ $state = ImportState::findOrCreateFor($did, $collection);
6767+6868+ if ($state->isComplete()) {
6969+ return $state->toResult();
7070+ }
7171+7272+ $pdsEndpoint = $this->resolvePds($did);
7373+7474+ if (! $pdsEndpoint) {
7575+ $error = "Could not resolve PDS endpoint for DID: {$did}";
7676+ $state->markFailed($error);
7777+ event(new ImportFailed($did, $collection, $error));
7878+7979+ return ImportResult::failed($did, $collection, $error);
8080+ }
8181+8282+ $state->markStarted();
8383+ event(new ImportStarted($did, $collection));
8484+8585+ $client = $this->clientFor($pdsEndpoint);
8686+ $cursor = $state->cursor;
8787+ $pageSize = config('parity.import.page_size', 100);
8888+ $pageDelay = config('parity.import.page_delay', 100);
8989+ $recordClass = $mapper->recordClass();
9090+9191+ try {
9292+ do {
9393+ $response = $client->atproto->repo->listRecords(
9494+ repo: $did,
9595+ collection: $collection,
9696+ limit: $pageSize,
9797+ cursor: $cursor
9898+ );
9999+100100+ $synced = 0;
101101+ $skipped = 0;
102102+ $failed = 0;
103103+104104+ foreach ($response->records as $item) {
105105+ try {
106106+ $record = $recordClass::fromArray($item['value']);
107107+108108+ $mapper->upsert($record, [
109109+ 'uri' => $item['uri'],
110110+ 'cid' => $item['cid'],
111111+ ]);
112112+113113+ $synced++;
114114+ } catch (Throwable $e) {
115115+ $failed++;
116116+ }
117117+ }
118118+119119+ $cursor = $response->cursor;
120120+ $state->updateProgress($synced, $skipped, $failed, $cursor);
121121+122122+ if ($onProgress) {
123123+ $onProgress(new ImportProgress(
124124+ did: $did,
125125+ collection: $collection,
126126+ recordsSynced: $state->records_synced,
127127+ cursor: $cursor
128128+ ));
129129+ }
130130+131131+ event(new ImportProgress($did, $collection, $state->records_synced, $cursor));
132132+133133+ if ($cursor && $pageDelay > 0) {
134134+ usleep($pageDelay * 1000);
135135+ }
136136+ } while ($cursor);
137137+138138+ $state->markCompleted();
139139+ $result = $state->toResult();
140140+ event(new ImportCompleted($result));
141141+142142+ return $result;
143143+ } catch (Throwable $e) {
144144+ $error = $e->getMessage();
145145+ $state->markFailed($error);
146146+ event(new ImportFailed($did, $collection, $error));
147147+148148+ return ImportResult::failed(
149149+ did: $did,
150150+ collection: $collection,
151151+ error: $error,
152152+ synced: $state->records_synced,
153153+ skipped: $state->records_skipped,
154154+ failed: $state->records_failed,
155155+ cursor: $state->cursor
156156+ );
157157+ }
158158+ }
159159+160160+ /**
161161+ * Resume an interrupted import from cursor.
162162+ */
163163+ public function resume(ImportState $state, ?callable $onProgress = null): ImportResult
164164+ {
165165+ if (! $state->canResume()) {
166166+ return $state->toResult();
167167+ }
168168+169169+ $state->update(['status' => ImportState::STATUS_PENDING]);
170170+171171+ return $this->importUserCollection($state->did, $state->collection, $onProgress);
172172+ }
173173+174174+ /**
175175+ * Resume all interrupted imports.
176176+ *
177177+ * @return array<ImportResult>
178178+ */
179179+ public function resumeAll(?callable $onProgress = null): array
180180+ {
181181+ $results = [];
182182+183183+ ImportState::resumable()->each(function (ImportState $state) use (&$results, $onProgress) {
184184+ $results[] = $this->resume($state, $onProgress);
185185+ });
186186+187187+ return $results;
188188+ }
189189+190190+ /**
191191+ * Get import status for a DID/collection.
192192+ */
193193+ public function getStatus(string $did, string $collection): ?ImportState
194194+ {
195195+ return ImportState::where('did', $did)
196196+ ->where('collection', $collection)
197197+ ->first();
198198+ }
199199+200200+ /**
201201+ * Get all import states for a DID.
202202+ *
203203+ * @return \Illuminate\Database\Eloquent\Collection<int, ImportState>
204204+ */
205205+ public function getStatusForUser(string $did): \Illuminate\Database\Eloquent\Collection
206206+ {
207207+ return ImportState::where('did', $did)->get();
208208+ }
209209+210210+ /**
211211+ * Check if a user's collection has been imported.
212212+ */
213213+ public function isImported(string $did, string $collection): bool
214214+ {
215215+ $state = $this->getStatus($did, $collection);
216216+217217+ return $state && $state->isComplete();
218218+ }
219219+220220+ /**
221221+ * Reset an import state to allow re-importing.
222222+ */
223223+ public function reset(string $did, string $collection): void
224224+ {
225225+ ImportState::where('did', $did)
226226+ ->where('collection', $collection)
227227+ ->delete();
228228+ }
229229+230230+ /**
231231+ * Reset all import states for a user.
232232+ */
233233+ public function resetUser(string $did): void
234234+ {
235235+ ImportState::where('did', $did)->delete();
236236+ }
237237+238238+ /**
239239+ * Get or create a client for a PDS endpoint.
240240+ */
241241+ protected function clientFor(string $pdsEndpoint): AtpClient
242242+ {
243243+ return $this->clients[$pdsEndpoint] ??= Atp::public($pdsEndpoint);
244244+ }
245245+246246+ /**
247247+ * Resolve the PDS endpoint for a DID.
248248+ */
249249+ protected function resolvePds(string $did): ?string
250250+ {
251251+ return Resolver::resolvePds($did);
252252+ }
253253+}
+231
src/Import/ImportState.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Import;
44+55+use Illuminate\Database\Eloquent\Builder;
66+use Illuminate\Database\Eloquent\Model;
77+88+/**
99+ * Tracks import progress for a DID/collection pair.
1010+ *
1111+ * @property int $id
1212+ * @property string $did
1313+ * @property string $collection
1414+ * @property string $status
1515+ * @property string|null $cursor
1616+ * @property int $records_synced
1717+ * @property int $records_skipped
1818+ * @property int $records_failed
1919+ * @property \Carbon\Carbon|null $started_at
2020+ * @property \Carbon\Carbon|null $completed_at
2121+ * @property string|null $error
2222+ * @property \Carbon\Carbon $created_at
2323+ * @property \Carbon\Carbon $updated_at
2424+ */
2525+class ImportState extends Model
2626+{
2727+ public const STATUS_PENDING = 'pending';
2828+2929+ public const STATUS_IN_PROGRESS = 'in_progress';
3030+3131+ public const STATUS_COMPLETED = 'completed';
3232+3333+ public const STATUS_FAILED = 'failed';
3434+3535+ protected $fillable = [
3636+ 'did',
3737+ 'collection',
3838+ 'status',
3939+ 'cursor',
4040+ 'records_synced',
4141+ 'records_skipped',
4242+ 'records_failed',
4343+ 'started_at',
4444+ 'completed_at',
4545+ 'error',
4646+ ];
4747+4848+ protected $casts = [
4949+ 'records_synced' => 'integer',
5050+ 'records_skipped' => 'integer',
5151+ 'records_failed' => 'integer',
5252+ 'started_at' => 'datetime',
5353+ 'completed_at' => 'datetime',
5454+ ];
5555+5656+ public function getTable(): string
5757+ {
5858+ return config('parity.import.state_table', 'parity_import_states');
5959+ }
6060+6161+ /**
6262+ * Start the import process for this state.
6363+ */
6464+ public function markStarted(): self
6565+ {
6666+ $this->update([
6767+ 'status' => self::STATUS_IN_PROGRESS,
6868+ 'started_at' => now(),
6969+ 'error' => null,
7070+ ]);
7171+7272+ return $this;
7373+ }
7474+7575+ /**
7676+ * Mark the import as completed.
7777+ */
7878+ public function markCompleted(): self
7979+ {
8080+ $this->update([
8181+ 'status' => self::STATUS_COMPLETED,
8282+ 'completed_at' => now(),
8383+ 'cursor' => null,
8484+ ]);
8585+8686+ return $this;
8787+ }
8888+8989+ /**
9090+ * Mark the import as failed.
9191+ */
9292+ public function markFailed(string $error): self
9393+ {
9494+ $this->update([
9595+ 'status' => self::STATUS_FAILED,
9696+ 'error' => $error,
9797+ ]);
9898+9999+ return $this;
100100+ }
101101+102102+ /**
103103+ * Update progress during import.
104104+ */
105105+ public function updateProgress(int $synced, int $skipped = 0, int $failed = 0, ?string $cursor = null): self
106106+ {
107107+ $this->increment('records_synced', $synced);
108108+109109+ if ($skipped > 0) {
110110+ $this->increment('records_skipped', $skipped);
111111+ }
112112+113113+ if ($failed > 0) {
114114+ $this->increment('records_failed', $failed);
115115+ }
116116+117117+ if ($cursor !== null) {
118118+ $this->update(['cursor' => $cursor]);
119119+ }
120120+121121+ return $this;
122122+ }
123123+124124+ /**
125125+ * Check if this import can be resumed.
126126+ */
127127+ public function canResume(): bool
128128+ {
129129+ return $this->status === self::STATUS_IN_PROGRESS
130130+ || $this->status === self::STATUS_FAILED;
131131+ }
132132+133133+ /**
134134+ * Check if this import is complete.
135135+ */
136136+ public function isComplete(): bool
137137+ {
138138+ return $this->status === self::STATUS_COMPLETED;
139139+ }
140140+141141+ /**
142142+ * Check if this import is currently running.
143143+ */
144144+ public function isRunning(): bool
145145+ {
146146+ return $this->status === self::STATUS_IN_PROGRESS;
147147+ }
148148+149149+ /**
150150+ * Scope to pending imports.
151151+ */
152152+ public function scopePending(Builder $query): Builder
153153+ {
154154+ return $query->where('status', self::STATUS_PENDING);
155155+ }
156156+157157+ /**
158158+ * Scope to in-progress imports.
159159+ */
160160+ public function scopeInProgress(Builder $query): Builder
161161+ {
162162+ return $query->where('status', self::STATUS_IN_PROGRESS);
163163+ }
164164+165165+ /**
166166+ * Scope to completed imports.
167167+ */
168168+ public function scopeCompleted(Builder $query): Builder
169169+ {
170170+ return $query->where('status', self::STATUS_COMPLETED);
171171+ }
172172+173173+ /**
174174+ * Scope to failed imports.
175175+ */
176176+ public function scopeFailed(Builder $query): Builder
177177+ {
178178+ return $query->where('status', self::STATUS_FAILED);
179179+ }
180180+181181+ /**
182182+ * Scope to incomplete imports (pending, in_progress, or failed).
183183+ */
184184+ public function scopeIncomplete(Builder $query): Builder
185185+ {
186186+ return $query->whereIn('status', [
187187+ self::STATUS_PENDING,
188188+ self::STATUS_IN_PROGRESS,
189189+ self::STATUS_FAILED,
190190+ ]);
191191+ }
192192+193193+ /**
194194+ * Scope to resumable imports (in_progress or failed with cursor).
195195+ */
196196+ public function scopeResumable(Builder $query): Builder
197197+ {
198198+ return $query->whereIn('status', [
199199+ self::STATUS_IN_PROGRESS,
200200+ self::STATUS_FAILED,
201201+ ]);
202202+ }
203203+204204+ /**
205205+ * Find or create an import state for a DID/collection pair.
206206+ */
207207+ public static function findOrCreateFor(string $did, string $collection): self
208208+ {
209209+ return static::firstOrCreate(
210210+ ['did' => $did, 'collection' => $collection],
211211+ ['status' => self::STATUS_PENDING]
212212+ );
213213+ }
214214+215215+ /**
216216+ * Convert to ImportResult.
217217+ */
218218+ public function toResult(): ImportResult
219219+ {
220220+ return new ImportResult(
221221+ did: $this->did,
222222+ collection: $this->collection,
223223+ recordsSynced: $this->records_synced,
224224+ recordsSkipped: $this->records_skipped,
225225+ recordsFailed: $this->records_failed,
226226+ completed: $this->isComplete(),
227227+ cursor: $this->cursor,
228228+ error: $this->error,
229229+ );
230230+ }
231231+}
+57
src/Jobs/ImportUserJob.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Jobs;
44+55+use Illuminate\Bus\Queueable;
66+use Illuminate\Contracts\Queue\ShouldQueue;
77+use Illuminate\Foundation\Bus\Dispatchable;
88+use Illuminate\Queue\InteractsWithQueue;
99+use Illuminate\Queue\SerializesModels;
1010+use SocialDept\AtpParity\Import\ImportService;
1111+1212+class ImportUserJob implements ShouldQueue
1313+{
1414+ use Dispatchable;
1515+ use InteractsWithQueue;
1616+ use Queueable;
1717+ use SerializesModels;
1818+1919+ /**
2020+ * The number of times the job may be attempted.
2121+ */
2222+ public int $tries = 3;
2323+2424+ /**
2525+ * The number of seconds to wait before retrying.
2626+ */
2727+ public int $backoff = 60;
2828+2929+ public function __construct(
3030+ public string $did,
3131+ public ?string $collection = null,
3232+ ) {
3333+ $this->onQueue(config('parity.import.queue', 'default'));
3434+ }
3535+3636+ public function handle(ImportService $service): void
3737+ {
3838+ $collections = $this->collection ? [$this->collection] : null;
3939+ $service->importUser($this->did, $collections);
4040+ }
4141+4242+ /**
4343+ * Get the tags that should be assigned to the job.
4444+ *
4545+ * @return array<string>
4646+ */
4747+ public function tags(): array
4848+ {
4949+ $tags = ['parity-import', "did:{$this->did}"];
5050+5151+ if ($this->collection) {
5252+ $tags[] = "collection:{$this->collection}";
5353+ }
5454+5555+ return $tags;
5656+ }
5757+}
+93
src/MapperRegistry.php
···11+<?php
22+33+namespace SocialDept\AtpParity;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpParity\Contracts\RecordMapper;
77+use SocialDept\AtpSchema\Data\Data;
88+99+/**
1010+ * Registry for RecordMapper instances.
1111+ *
1212+ * Allows looking up mappers by Record class, Model class, or lexicon NSID.
1313+ */
1414+class MapperRegistry
1515+{
1616+ /** @var array<class-string<Data>, RecordMapper> */
1717+ protected array $byRecord = [];
1818+1919+ /** @var array<class-string<Model>, RecordMapper> */
2020+ protected array $byModel = [];
2121+2222+ /** @var array<string, RecordMapper> Keyed by NSID */
2323+ protected array $byLexicon = [];
2424+2525+ /**
2626+ * Register a mapper.
2727+ */
2828+ public function register(RecordMapper $mapper): void
2929+ {
3030+ $recordClass = $mapper->recordClass();
3131+ $modelClass = $mapper->modelClass();
3232+3333+ $this->byRecord[$recordClass] = $mapper;
3434+ $this->byModel[$modelClass] = $mapper;
3535+ $this->byLexicon[$mapper->lexicon()] = $mapper;
3636+ }
3737+3838+ /**
3939+ * Get a mapper by Record class.
4040+ *
4141+ * @param class-string<Data> $recordClass
4242+ */
4343+ public function forRecord(string $recordClass): ?RecordMapper
4444+ {
4545+ return $this->byRecord[$recordClass] ?? null;
4646+ }
4747+4848+ /**
4949+ * Get a mapper by Model class.
5050+ *
5151+ * @param class-string<Model> $modelClass
5252+ */
5353+ public function forModel(string $modelClass): ?RecordMapper
5454+ {
5555+ return $this->byModel[$modelClass] ?? null;
5656+ }
5757+5858+ /**
5959+ * Get a mapper by lexicon NSID.
6060+ */
6161+ public function forLexicon(string $nsid): ?RecordMapper
6262+ {
6363+ return $this->byLexicon[$nsid] ?? null;
6464+ }
6565+6666+ /**
6767+ * Check if a mapper exists for the given lexicon.
6868+ */
6969+ public function hasLexicon(string $nsid): bool
7070+ {
7171+ return isset($this->byLexicon[$nsid]);
7272+ }
7373+7474+ /**
7575+ * Get all registered lexicon NSIDs.
7676+ *
7777+ * @return array<string>
7878+ */
7979+ public function lexicons(): array
8080+ {
8181+ return array_keys($this->byLexicon);
8282+ }
8383+8484+ /**
8585+ * Get all registered mappers.
8686+ *
8787+ * @return array<RecordMapper>
8888+ */
8989+ public function all(): array
9090+ {
9191+ return array_values($this->byLexicon);
9292+ }
9393+}
···11+<?php
22+33+namespace SocialDept\AtpParity\Publish;
44+55+/**
66+ * Immutable value object representing the result of a publish operation.
77+ */
88+readonly class PublishResult
99+{
1010+ public function __construct(
1111+ public bool $success,
1212+ public ?string $uri = null,
1313+ public ?string $cid = null,
1414+ public ?string $error = null,
1515+ ) {}
1616+1717+ /**
1818+ * Check if the publish operation succeeded.
1919+ */
2020+ public function isSuccess(): bool
2121+ {
2222+ return $this->success;
2323+ }
2424+2525+ /**
2626+ * Check if the publish operation failed.
2727+ */
2828+ public function isFailed(): bool
2929+ {
3030+ return ! $this->success;
3131+ }
3232+3333+ /**
3434+ * Create a successful result.
3535+ */
3636+ public static function success(string $uri, string $cid): self
3737+ {
3838+ return new self(
3939+ success: true,
4040+ uri: $uri,
4141+ cid: $cid,
4242+ );
4343+ }
4444+4545+ /**
4646+ * Create a failed result.
4747+ */
4848+ public static function failed(string $error): self
4949+ {
5050+ return new self(
5151+ success: false,
5252+ error: $error,
5353+ );
5454+ }
5555+}
+243
src/Publish/PublishService.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Publish;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpClient\Facades\Atp;
77+use SocialDept\AtpParity\Events\RecordPublished;
88+use SocialDept\AtpParity\Events\RecordUnpublished;
99+use SocialDept\AtpParity\MapperRegistry;
1010+use Throwable;
1111+1212+/**
1313+ * Service for publishing Eloquent models to AT Protocol.
1414+ */
1515+class PublishService
1616+{
1717+ public function __construct(
1818+ protected MapperRegistry $registry
1919+ ) {}
2020+2121+ /**
2222+ * Publish a model as a new record to AT Protocol.
2323+ *
2424+ * Requires the model to have a DID association (via did column or relationship).
2525+ */
2626+ public function publish(Model $model): PublishResult
2727+ {
2828+ $did = $this->getDidFromModel($model);
2929+3030+ if (! $did) {
3131+ return PublishResult::failed('No DID associated with model. Use publishAs() to specify a DID.');
3232+ }
3333+3434+ return $this->publishAs($did, $model);
3535+ }
3636+3737+ /**
3838+ * Publish a model as a specific user.
3939+ */
4040+ public function publishAs(string $did, Model $model): PublishResult
4141+ {
4242+ $mapper = $this->registry->forModel(get_class($model));
4343+4444+ if (! $mapper) {
4545+ return PublishResult::failed('No mapper registered for model: '.get_class($model));
4646+ }
4747+4848+ // Check if already published
4949+ $existingUri = $this->getModelUri($model);
5050+ if ($existingUri) {
5151+ return $this->update($model);
5252+ }
5353+5454+ try {
5555+ $record = $mapper->toRecord($model);
5656+ $collection = $mapper->lexicon();
5757+5858+ $client = Atp::as($did);
5959+ $response = $client->atproto->repo->createRecord(
6060+ repo: $did,
6161+ collection: $collection,
6262+ record: $record->toArray(),
6363+ );
6464+6565+ // Update model with ATP metadata
6666+ $this->updateModelMeta($model, $response->uri, $response->cid);
6767+6868+ event(new RecordPublished($model, $response->uri, $response->cid));
6969+7070+ return PublishResult::success($response->uri, $response->cid);
7171+ } catch (Throwable $e) {
7272+ return PublishResult::failed($e->getMessage());
7373+ }
7474+ }
7575+7676+ /**
7777+ * Update an existing published record.
7878+ */
7979+ public function update(Model $model): PublishResult
8080+ {
8181+ $uri = $this->getModelUri($model);
8282+8383+ if (! $uri) {
8484+ return PublishResult::failed('Model has not been published yet. Use publish() first.');
8585+ }
8686+8787+ $mapper = $this->registry->forModel(get_class($model));
8888+8989+ if (! $mapper) {
9090+ return PublishResult::failed('No mapper registered for model: '.get_class($model));
9191+ }
9292+9393+ $parts = $this->parseUri($uri);
9494+9595+ if (! $parts) {
9696+ return PublishResult::failed('Invalid AT Protocol URI: '.$uri);
9797+ }
9898+9999+ try {
100100+ $record = $mapper->toRecord($model);
101101+102102+ $client = Atp::as($parts['did']);
103103+ $response = $client->atproto->repo->putRecord(
104104+ repo: $parts['did'],
105105+ collection: $parts['collection'],
106106+ rkey: $parts['rkey'],
107107+ record: $record->toArray(),
108108+ );
109109+110110+ // Update model with new CID
111111+ $this->updateModelMeta($model, $response->uri, $response->cid);
112112+113113+ event(new RecordPublished($model, $response->uri, $response->cid));
114114+115115+ return PublishResult::success($response->uri, $response->cid);
116116+ } catch (Throwable $e) {
117117+ return PublishResult::failed($e->getMessage());
118118+ }
119119+ }
120120+121121+ /**
122122+ * Delete a published record from AT Protocol.
123123+ */
124124+ public function delete(Model $model): bool
125125+ {
126126+ $uri = $this->getModelUri($model);
127127+128128+ if (! $uri) {
129129+ return false;
130130+ }
131131+132132+ $parts = $this->parseUri($uri);
133133+134134+ if (! $parts) {
135135+ return false;
136136+ }
137137+138138+ try {
139139+ $client = Atp::as($parts['did']);
140140+ $client->atproto->repo->deleteRecord(
141141+ repo: $parts['did'],
142142+ collection: $parts['collection'],
143143+ rkey: $parts['rkey'],
144144+ );
145145+146146+ // Clear ATP metadata from model
147147+ $this->clearModelMeta($model);
148148+149149+ event(new RecordUnpublished($model, $uri));
150150+151151+ return true;
152152+ } catch (Throwable $e) {
153153+ return false;
154154+ }
155155+ }
156156+157157+ /**
158158+ * Get the DID from a model.
159159+ *
160160+ * Override this method or set a did column/relationship on your model.
161161+ */
162162+ protected function getDidFromModel(Model $model): ?string
163163+ {
164164+ // Check for did column
165165+ if (isset($model->did)) {
166166+ return $model->did;
167167+ }
168168+169169+ // Check for user relationship with did
170170+ if (method_exists($model, 'user') && $model->user?->did) {
171171+ return $model->user->did;
172172+ }
173173+174174+ // Check for author relationship with did
175175+ if (method_exists($model, 'author') && $model->author?->did) {
176176+ return $model->author->did;
177177+ }
178178+179179+ // Try extracting from existing URI
180180+ $uri = $this->getModelUri($model);
181181+ if ($uri) {
182182+ $parts = $this->parseUri($uri);
183183+184184+ return $parts['did'] ?? null;
185185+ }
186186+187187+ return null;
188188+ }
189189+190190+ /**
191191+ * Get the AT Protocol URI from a model.
192192+ */
193193+ protected function getModelUri(Model $model): ?string
194194+ {
195195+ $column = config('parity.columns.uri', 'atp_uri');
196196+197197+ return $model->{$column};
198198+ }
199199+200200+ /**
201201+ * Update model with AT Protocol metadata.
202202+ */
203203+ protected function updateModelMeta(Model $model, string $uri, string $cid): void
204204+ {
205205+ $uriColumn = config('parity.columns.uri', 'atp_uri');
206206+ $cidColumn = config('parity.columns.cid', 'atp_cid');
207207+208208+ $model->{$uriColumn} = $uri;
209209+ $model->{$cidColumn} = $cid;
210210+ $model->save();
211211+ }
212212+213213+ /**
214214+ * Clear AT Protocol metadata from model.
215215+ */
216216+ protected function clearModelMeta(Model $model): void
217217+ {
218218+ $uriColumn = config('parity.columns.uri', 'atp_uri');
219219+ $cidColumn = config('parity.columns.cid', 'atp_cid');
220220+221221+ $model->{$uriColumn} = null;
222222+ $model->{$cidColumn} = null;
223223+ $model->save();
224224+ }
225225+226226+ /**
227227+ * Parse an AT Protocol URI into its components.
228228+ *
229229+ * @return array{did: string, collection: string, rkey: string}|null
230230+ */
231231+ protected function parseUri(string $uri): ?array
232232+ {
233233+ if (! preg_match('#^at://([^/]+)/([^/]+)/([^/]+)$#', $uri, $matches)) {
234234+ return null;
235235+ }
236236+237237+ return [
238238+ 'did' => $matches[1],
239239+ 'collection' => $matches[2],
240240+ 'rkey' => $matches[3],
241241+ ];
242242+ }
243243+}
+154
src/RecordMapper.php
···11+<?php
22+33+namespace SocialDept\AtpParity;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpParity\Contracts\RecordMapper as RecordMapperContract;
77+use SocialDept\AtpSchema\Data\Data;
88+99+/**
1010+ * Abstract base class for bidirectional Record <-> Model mapping.
1111+ *
1212+ * @template TRecord of Data
1313+ * @template TModel of Model
1414+ *
1515+ * @implements RecordMapperContract<TRecord, TModel>
1616+ */
1717+abstract class RecordMapper implements RecordMapperContract
1818+{
1919+ /**
2020+ * Get the Record class this mapper handles.
2121+ *
2222+ * @return class-string<TRecord>
2323+ */
2424+ abstract public function recordClass(): string;
2525+2626+ /**
2727+ * Get the Model class this mapper handles.
2828+ *
2929+ * @return class-string<TModel>
3030+ */
3131+ abstract public function modelClass(): string;
3232+3333+ /**
3434+ * Map record properties to model attributes.
3535+ *
3636+ * @param TRecord $record
3737+ * @return array<string, mixed>
3838+ */
3939+ abstract protected function recordToAttributes(Data $record): array;
4040+4141+ /**
4242+ * Map model attributes to record properties.
4343+ *
4444+ * @param TModel $model
4545+ * @return array<string, mixed>
4646+ */
4747+ abstract protected function modelToRecordData(Model $model): array;
4848+4949+ /**
5050+ * Get the lexicon NSID this mapper handles.
5151+ */
5252+ public function lexicon(): string
5353+ {
5454+ $recordClass = $this->recordClass();
5555+5656+ return $recordClass::getLexicon();
5757+ }
5858+5959+ /**
6060+ * Get the column name for storing the AT Protocol URI.
6161+ */
6262+ protected function uriColumn(): string
6363+ {
6464+ return config('parity.columns.uri', 'atp_uri');
6565+ }
6666+6767+ /**
6868+ * Get the column name for storing the AT Protocol CID.
6969+ */
7070+ protected function cidColumn(): string
7171+ {
7272+ return config('parity.columns.cid', 'atp_cid');
7373+ }
7474+7575+ public function toModel(Data $record, array $meta = []): Model
7676+ {
7777+ $modelClass = $this->modelClass();
7878+ $attributes = $this->recordToAttributes($record);
7979+ $attributes = $this->applyMeta($attributes, $meta);
8080+8181+ return new $modelClass($attributes);
8282+ }
8383+8484+ public function toRecord(Model $model): Data
8585+ {
8686+ $recordClass = $this->recordClass();
8787+8888+ return $recordClass::fromArray($this->modelToRecordData($model));
8989+ }
9090+9191+ public function updateModel(Model $model, Data $record, array $meta = []): Model
9292+ {
9393+ $attributes = $this->recordToAttributes($record);
9494+ $attributes = $this->applyMeta($attributes, $meta);
9595+ $model->fill($attributes);
9696+9797+ return $model;
9898+ }
9999+100100+ public function findByUri(string $uri): ?Model
101101+ {
102102+ $modelClass = $this->modelClass();
103103+104104+ return $modelClass::where($this->uriColumn(), $uri)->first();
105105+ }
106106+107107+ public function upsert(Data $record, array $meta = []): Model
108108+ {
109109+ $uri = $meta['uri'] ?? null;
110110+111111+ if ($uri) {
112112+ $existing = $this->findByUri($uri);
113113+114114+ if ($existing) {
115115+ $this->updateModel($existing, $record, $meta);
116116+ $existing->save();
117117+118118+ return $existing;
119119+ }
120120+ }
121121+122122+ $model = $this->toModel($record, $meta);
123123+ $model->save();
124124+125125+ return $model;
126126+ }
127127+128128+ public function deleteByUri(string $uri): bool
129129+ {
130130+ $model = $this->findByUri($uri);
131131+132132+ if ($model) {
133133+ return (bool) $model->delete();
134134+ }
135135+136136+ return false;
137137+ }
138138+139139+ /**
140140+ * Apply AT Protocol metadata to attributes.
141141+ */
142142+ protected function applyMeta(array $attributes, array $meta): array
143143+ {
144144+ if (isset($meta['uri'])) {
145145+ $attributes[$this->uriColumn()] = $meta['uri'];
146146+ }
147147+148148+ if (isset($meta['cid'])) {
149149+ $attributes[$this->cidColumn()] = $meta['cid'];
150150+ }
151151+152152+ return $attributes;
153153+ }
154154+}
+234
src/Signals/ParitySignal.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Signals;
44+55+use SocialDept\AtpParity\Contracts\RecordMapper;
66+use SocialDept\AtpParity\MapperRegistry;
77+use SocialDept\AtpParity\Sync\ConflictDetector;
88+use SocialDept\AtpParity\Sync\ConflictResolver;
99+use SocialDept\AtpParity\Sync\ConflictStrategy;
1010+use SocialDept\AtpSignals\Events\SignalEvent;
1111+use SocialDept\AtpSignals\Signals\Signal;
1212+1313+/**
1414+ * Signal that automatically syncs firehose events to Eloquent models.
1515+ *
1616+ * This signal listens for commit events on collections that have registered
1717+ * mappers and automatically creates, updates, or deletes the corresponding
1818+ * Eloquent models.
1919+ *
2020+ * Supports selective sync via configuration or by extending this class:
2121+ * - Filter by DID: config('parity.sync.dids') or override dids()
2222+ * - Filter by operation: config('parity.sync.operations') or override operations()
2323+ * - Custom filter: config('parity.sync.filter') or override shouldSync()
2424+ *
2525+ * Supports conflict resolution via configuration:
2626+ * - Strategy: config('parity.conflicts.strategy') - 'remote', 'local', 'newest', 'manual'
2727+ *
2828+ * To use this signal, register it in your atp-signals config:
2929+ *
3030+ * // config/signal.php
3131+ * return [
3232+ * 'signals' => [
3333+ * \SocialDept\AtpParity\Signals\ParitySignal::class,
3434+ * ],
3535+ * ];
3636+ */
3737+class ParitySignal extends Signal
3838+{
3939+ protected ConflictDetector $conflictDetector;
4040+4141+ protected ConflictResolver $conflictResolver;
4242+4343+ public function __construct(
4444+ protected MapperRegistry $registry
4545+ ) {
4646+ $this->conflictDetector = new ConflictDetector;
4747+ $this->conflictResolver = new ConflictResolver;
4848+ }
4949+5050+ /**
5151+ * Listen for commit events only.
5252+ */
5353+ public function eventTypes(): array
5454+ {
5555+ return ['commit'];
5656+ }
5757+5858+ /**
5959+ * Only listen for collections that have registered mappers.
6060+ */
6161+ public function collections(): ?array
6262+ {
6363+ $lexicons = $this->registry->lexicons();
6464+6565+ // Return null if no mappers registered (don't match anything)
6666+ return empty($lexicons) ? ['__none__'] : $lexicons;
6767+ }
6868+6969+ /**
7070+ * Get the DIDs to sync (null = all DIDs).
7171+ *
7272+ * Override this method for custom DID filtering logic.
7373+ */
7474+ public function dids(): ?array
7575+ {
7676+ return config('parity.sync.dids');
7777+ }
7878+7979+ /**
8080+ * Get the operations to sync (null = all operations).
8181+ *
8282+ * Possible values: 'create', 'update', 'delete'
8383+ * Override this method for custom operation filtering.
8484+ */
8585+ public function operations(): ?array
8686+ {
8787+ return config('parity.sync.operations');
8888+ }
8989+9090+ /**
9191+ * Determine if the event should be synced.
9292+ *
9393+ * Override this method for custom filtering logic.
9494+ */
9595+ public function shouldSync(SignalEvent $event): bool
9696+ {
9797+ // Check custom filter callback from config
9898+ $filter = config('parity.sync.filter');
9999+ if ($filter && is_callable($filter)) {
100100+ return $filter($event);
101101+ }
102102+103103+ return true;
104104+ }
105105+106106+ /**
107107+ * Handle the firehose event.
108108+ */
109109+ public function handle(SignalEvent $event): void
110110+ {
111111+ if (! $event->commit) {
112112+ return;
113113+ }
114114+115115+ // Apply DID filter
116116+ $dids = $this->dids();
117117+ if ($dids !== null && ! in_array($event->did, $dids)) {
118118+ return;
119119+ }
120120+121121+ $commit = $event->commit;
122122+123123+ // Apply operation filter
124124+ $operations = $this->operations();
125125+ if ($operations !== null) {
126126+ $operation = $this->getOperationType($commit);
127127+ if (! in_array($operation, $operations)) {
128128+ return;
129129+ }
130130+ }
131131+132132+ // Apply custom filter
133133+ if (! $this->shouldSync($event)) {
134134+ return;
135135+ }
136136+137137+ $mapper = $this->registry->forLexicon($commit->collection);
138138+139139+ if (! $mapper) {
140140+ return;
141141+ }
142142+143143+ if ($commit->isCreate() || $commit->isUpdate()) {
144144+ $this->handleUpsert($event, $mapper);
145145+ } elseif ($commit->isDelete()) {
146146+ $this->handleDelete($event, $mapper);
147147+ }
148148+ }
149149+150150+ /**
151151+ * Get the operation type from a commit.
152152+ */
153153+ protected function getOperationType(object $commit): string
154154+ {
155155+ if ($commit->isCreate()) {
156156+ return 'create';
157157+ }
158158+159159+ if ($commit->isUpdate()) {
160160+ return 'update';
161161+ }
162162+163163+ if ($commit->isDelete()) {
164164+ return 'delete';
165165+ }
166166+167167+ return 'unknown';
168168+ }
169169+170170+ /**
171171+ * Handle create or update operations.
172172+ */
173173+ protected function handleUpsert(SignalEvent $event, RecordMapper $mapper): void
174174+ {
175175+ $commit = $event->commit;
176176+177177+ if (! $commit->record) {
178178+ return;
179179+ }
180180+181181+ $recordClass = $mapper->recordClass();
182182+ $record = $recordClass::fromArray((array) $commit->record);
183183+184184+ $uri = $this->buildUri($event->did, $commit->collection, $commit->rkey);
185185+ $meta = [
186186+ 'uri' => $uri,
187187+ 'cid' => $commit->cid,
188188+ ];
189189+190190+ // Check for existing model and potential conflict
191191+ $existing = $mapper->findByUri($uri);
192192+193193+ if ($existing && $this->conflictDetector->hasConflict($existing, $record, $commit->cid)) {
194194+ $strategy = ConflictStrategy::fromConfig();
195195+ $resolution = $this->conflictResolver->resolve(
196196+ $existing,
197197+ $record,
198198+ $meta,
199199+ $mapper,
200200+ $strategy
201201+ );
202202+203203+ // If conflict is pending manual resolution, don't apply changes
204204+ if (! $resolution->isResolved()) {
205205+ return;
206206+ }
207207+208208+ // Conflict was resolved, model already updated if needed
209209+ return;
210210+ }
211211+212212+ // No conflict, proceed with normal upsert
213213+ $mapper->upsert($record, $meta);
214214+ }
215215+216216+ /**
217217+ * Handle delete operations.
218218+ */
219219+ protected function handleDelete(SignalEvent $event, RecordMapper $mapper): void
220220+ {
221221+ $commit = $event->commit;
222222+ $uri = $this->buildUri($event->did, $commit->collection, $commit->rkey);
223223+224224+ $mapper->deleteByUri($uri);
225225+ }
226226+227227+ /**
228228+ * Build an AT Protocol URI.
229229+ */
230230+ protected function buildUri(string $did, string $collection, string $rkey): string
231231+ {
232232+ return "at://{$did}/{$collection}/{$rkey}";
233233+ }
234234+}
+220
src/Support/RecordHelper.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Support;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpClient\AtpClient;
77+use SocialDept\AtpClient\Data\Responses\Atproto\Repo\GetRecordResponse;
88+use SocialDept\AtpClient\Facades\Atp;
99+use SocialDept\AtpParity\MapperRegistry;
1010+use SocialDept\AtpResolver\Facades\Resolver;
1111+use SocialDept\AtpSchema\Data\Data;
1212+1313+/**
1414+ * Helper for integrating atp-parity with atp-client.
1515+ *
1616+ * Provides convenient methods for fetching records from the ATP network
1717+ * and converting them to typed DTOs or Eloquent models.
1818+ */
1919+class RecordHelper
2020+{
2121+ /**
2222+ * Cache of clients by PDS endpoint.
2323+ *
2424+ * @var array<string, AtpClient>
2525+ */
2626+ protected array $clients = [];
2727+2828+ public function __construct(
2929+ protected MapperRegistry $registry
3030+ ) {}
3131+3232+ /**
3333+ * Get or create a client for a PDS endpoint.
3434+ */
3535+ protected function clientFor(string $pdsEndpoint): AtpClient
3636+ {
3737+ return $this->clients[$pdsEndpoint] ??= Atp::public($pdsEndpoint);
3838+ }
3939+4040+ /**
4141+ * Resolve the PDS endpoint for a DID or handle.
4242+ */
4343+ protected function resolvePds(string $actor): ?string
4444+ {
4545+ return Resolver::resolvePds($actor);
4646+ }
4747+4848+ /**
4949+ * Convert a GetRecordResponse to a typed record DTO.
5050+ *
5151+ * @template T of Data
5252+ *
5353+ * @param class-string<T>|null $recordClass Explicit record class, or null to auto-detect from mapper
5454+ * @return T|array The typed record, or raw array if no mapper found and no class specified
5555+ */
5656+ public function hydrateRecord(GetRecordResponse $response, ?string $recordClass = null): mixed
5757+ {
5858+ if ($recordClass) {
5959+ return $recordClass::fromArray($response->value);
6060+ }
6161+6262+ $collection = $this->extractCollection($response->uri);
6363+ $mapper = $this->registry->forLexicon($collection);
6464+6565+ if (! $mapper) {
6666+ return $response->value;
6767+ }
6868+6969+ $recordClass = $mapper->recordClass();
7070+7171+ return $recordClass::fromArray($response->value);
7272+ }
7373+7474+ /**
7575+ * Fetch a record from the ATP network by URI and return as typed DTO.
7676+ *
7777+ * @template T of Data
7878+ *
7979+ * @param class-string<T>|null $recordClass
8080+ * @return T|array|null
8181+ */
8282+ public function fetch(string $uri, ?string $recordClass = null): mixed
8383+ {
8484+ $parts = $this->parseUri($uri);
8585+8686+ if (! $parts) {
8787+ return null;
8888+ }
8989+9090+ $pdsEndpoint = $this->resolvePds($parts['repo']);
9191+9292+ if (! $pdsEndpoint) {
9393+ return null;
9494+ }
9595+9696+ $response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord(
9797+ $parts['repo'],
9898+ $parts['collection'],
9999+ $parts['rkey']
100100+ );
101101+102102+ return $this->hydrateRecord($response, $recordClass);
103103+ }
104104+105105+ /**
106106+ * Fetch a record by URI and convert directly to an Eloquent model.
107107+ *
108108+ * @template TModel of Model
109109+ *
110110+ * @return TModel|null
111111+ */
112112+ public function fetchAsModel(string $uri): ?Model
113113+ {
114114+ $parts = $this->parseUri($uri);
115115+116116+ if (! $parts) {
117117+ return null;
118118+ }
119119+120120+ $mapper = $this->registry->forLexicon($parts['collection']);
121121+122122+ if (! $mapper) {
123123+ return null;
124124+ }
125125+126126+ $pdsEndpoint = $this->resolvePds($parts['repo']);
127127+128128+ if (! $pdsEndpoint) {
129129+ return null;
130130+ }
131131+132132+ $response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord(
133133+ $parts['repo'],
134134+ $parts['collection'],
135135+ $parts['rkey']
136136+ );
137137+138138+ $recordClass = $mapper->recordClass();
139139+ $record = $recordClass::fromArray($response->value);
140140+141141+ return $mapper->toModel($record, [
142142+ 'uri' => $response->uri,
143143+ 'cid' => $response->cid,
144144+ ]);
145145+ }
146146+147147+ /**
148148+ * Fetch a record by URI and upsert to the database.
149149+ *
150150+ * @template TModel of Model
151151+ *
152152+ * @return TModel|null
153153+ */
154154+ public function sync(string $uri): ?Model
155155+ {
156156+ $parts = $this->parseUri($uri);
157157+158158+ if (! $parts) {
159159+ return null;
160160+ }
161161+162162+ $mapper = $this->registry->forLexicon($parts['collection']);
163163+164164+ if (! $mapper) {
165165+ return null;
166166+ }
167167+168168+ $pdsEndpoint = $this->resolvePds($parts['repo']);
169169+170170+ if (! $pdsEndpoint) {
171171+ return null;
172172+ }
173173+174174+ $response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord(
175175+ $parts['repo'],
176176+ $parts['collection'],
177177+ $parts['rkey']
178178+ );
179179+180180+ $recordClass = $mapper->recordClass();
181181+ $record = $recordClass::fromArray($response->value);
182182+183183+ return $mapper->upsert($record, [
184184+ 'uri' => $response->uri,
185185+ 'cid' => $response->cid,
186186+ ]);
187187+ }
188188+189189+ /**
190190+ * Parse an AT Protocol URI into its components.
191191+ *
192192+ * @return array{repo: string, collection: string, rkey: string}|null
193193+ */
194194+ protected function parseUri(string $uri): ?array
195195+ {
196196+ // at://did:plc:xxx/app.bsky.feed.post/rkey
197197+ if (! preg_match('#^at://([^/]+)/([^/]+)/([^/]+)$#', $uri, $matches)) {
198198+ return null;
199199+ }
200200+201201+ return [
202202+ 'repo' => $matches[1],
203203+ 'collection' => $matches[2],
204204+ 'rkey' => $matches[3],
205205+ ];
206206+ }
207207+208208+ /**
209209+ * Extract collection from AT Protocol URI.
210210+ */
211211+ protected function extractCollection(string $uri): string
212212+ {
213213+ // at://did:plc:xxx/app.bsky.feed.post/rkey
214214+ if (preg_match('#^at://[^/]+/([^/]+)/#', $uri, $matches)) {
215215+ return $matches[1];
216216+ }
217217+218218+ return '';
219219+ }
220220+}
+75
src/Support/SchemaMapper.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Support;
44+55+use Closure;
66+use Illuminate\Database\Eloquent\Model;
77+use SocialDept\AtpParity\RecordMapper;
88+use SocialDept\AtpSchema\Data\Data;
99+1010+/**
1111+ * Adapter for using atp-schema generated DTOs as record types.
1212+ *
1313+ * This allows you to use the auto-generated schema classes directly
1414+ * without creating custom Record classes.
1515+ *
1616+ * Example:
1717+ *
1818+ * use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
1919+ * use App\Models\Post as PostModel;
2020+ *
2121+ * $mapper = new SchemaMapper(
2222+ * schemaClass: Post::class,
2323+ * modelClass: PostModel::class,
2424+ * toAttributes: fn(Post $p) => [
2525+ * 'content' => $p->text,
2626+ * 'published_at' => $p->createdAt,
2727+ * ],
2828+ * toRecordData: fn(PostModel $m) => [
2929+ * 'text' => $m->content,
3030+ * 'createdAt' => $m->published_at->toIso8601String(),
3131+ * ],
3232+ * );
3333+ *
3434+ * $registry->register($mapper);
3535+ *
3636+ * @template TSchema of Data
3737+ * @template TModel of Model
3838+ *
3939+ * @extends RecordMapper<TSchema, TModel>
4040+ */
4141+class SchemaMapper extends RecordMapper
4242+{
4343+ /**
4444+ * @param class-string<TSchema> $schemaClass The atp-schema generated class
4545+ * @param class-string<TModel> $modelClass The Eloquent model class
4646+ * @param Closure(TSchema): array $toAttributes Convert schema to model attributes
4747+ * @param Closure(TModel): array $toRecordData Convert model to record data
4848+ */
4949+ public function __construct(
5050+ protected string $schemaClass,
5151+ protected string $modelClass,
5252+ protected Closure $toAttributes,
5353+ protected Closure $toRecordData,
5454+ ) {}
5555+5656+ public function recordClass(): string
5757+ {
5858+ return $this->schemaClass;
5959+ }
6060+6161+ public function modelClass(): string
6262+ {
6363+ return $this->modelClass;
6464+ }
6565+6666+ protected function recordToAttributes(Data $record): array
6767+ {
6868+ return ($this->toAttributes)($record);
6969+ }
7070+7171+ protected function modelToRecordData(Model $model): array
7272+ {
7373+ return ($this->toRecordData)($model);
7474+ }
7575+}
+77
src/Sync/ConflictDetector.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Sync;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpParity\Concerns\SyncsWithAtp;
77+use SocialDept\AtpSchema\Data\Data;
88+99+/**
1010+ * Detects conflicts between local and remote record versions.
1111+ */
1212+class ConflictDetector
1313+{
1414+ /**
1515+ * Check if there's a conflict between local model and remote record.
1616+ */
1717+ public function hasConflict(Model $model, Data $record, string $cid): bool
1818+ {
1919+ // No conflict if model doesn't have local changes
2020+ if (! $this->modelHasLocalChanges($model)) {
2121+ return false;
2222+ }
2323+2424+ // No conflict if CID matches (same version)
2525+ if ($this->getCid($model) === $cid) {
2626+ return false;
2727+ }
2828+2929+ return true;
3030+ }
3131+3232+ /**
3333+ * Check if the model has local changes since last sync.
3434+ */
3535+ protected function modelHasLocalChanges(Model $model): bool
3636+ {
3737+ // Use trait method if available
3838+ if ($this->usesTrait($model, SyncsWithAtp::class)) {
3939+ return $model->hasLocalChanges();
4040+ }
4141+4242+ // Fallback: compare updated_at with a sync timestamp if available
4343+ $syncedAt = $model->getAttribute('atp_synced_at');
4444+4545+ if (! $syncedAt) {
4646+ return true;
4747+ }
4848+4949+ $updatedAt = $model->getAttribute('updated_at');
5050+5151+ if (! $updatedAt) {
5252+ return false;
5353+ }
5454+5555+ return $updatedAt > $syncedAt;
5656+ }
5757+5858+ /**
5959+ * Get the CID from a model.
6060+ */
6161+ protected function getCid(Model $model): ?string
6262+ {
6363+ $column = config('parity.columns.cid', 'atp_cid');
6464+6565+ return $model->getAttribute($column);
6666+ }
6767+6868+ /**
6969+ * Check if a model uses a specific trait.
7070+ *
7171+ * @param class-string $trait
7272+ */
7373+ protected function usesTrait(Model $model, string $trait): bool
7474+ {
7575+ return in_array($trait, class_uses_recursive($model));
7676+ }
7777+}
+70
src/Sync/ConflictResolution.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Sync;
44+55+use Illuminate\Database\Eloquent\Model;
66+77+/**
88+ * Value object representing the result of conflict resolution.
99+ */
1010+readonly class ConflictResolution
1111+{
1212+ public function __construct(
1313+ public bool $resolved,
1414+ public string $winner,
1515+ public ?Model $model = null,
1616+ public ?PendingConflict $pending = null,
1717+ ) {}
1818+1919+ /**
2020+ * Check if the conflict was resolved.
2121+ */
2222+ public function isResolved(): bool
2323+ {
2424+ return $this->resolved;
2525+ }
2626+2727+ /**
2828+ * Check if the conflict requires manual resolution.
2929+ */
3030+ public function isPending(): bool
3131+ {
3232+ return ! $this->resolved && $this->pending !== null;
3333+ }
3434+3535+ /**
3636+ * Create resolution where remote wins.
3737+ */
3838+ public static function remoteWins(Model $model): self
3939+ {
4040+ return new self(
4141+ resolved: true,
4242+ winner: 'remote',
4343+ model: $model,
4444+ );
4545+ }
4646+4747+ /**
4848+ * Create resolution where local wins.
4949+ */
5050+ public static function localWins(Model $model): self
5151+ {
5252+ return new self(
5353+ resolved: true,
5454+ winner: 'local',
5555+ model: $model,
5656+ );
5757+ }
5858+5959+ /**
6060+ * Create pending resolution for manual review.
6161+ */
6262+ public static function pending(PendingConflict $conflict): self
6363+ {
6464+ return new self(
6565+ resolved: false,
6666+ winner: 'manual',
6767+ pending: $conflict,
6868+ );
6969+ }
7070+}
+118
src/Sync/ConflictResolver.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Sync;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpParity\Contracts\RecordMapper;
77+use SocialDept\AtpParity\Events\ConflictDetected;
88+use SocialDept\AtpSchema\Data\Data;
99+1010+/**
1111+ * Resolves conflicts between local and remote record versions.
1212+ */
1313+class ConflictResolver
1414+{
1515+ /**
1616+ * Resolve a conflict according to the specified strategy.
1717+ */
1818+ public function resolve(
1919+ Model $model,
2020+ Data $record,
2121+ array $meta,
2222+ RecordMapper $mapper,
2323+ ConflictStrategy $strategy
2424+ ): ConflictResolution {
2525+ return match ($strategy) {
2626+ ConflictStrategy::RemoteWins => $this->applyRemote($model, $record, $meta, $mapper),
2727+ ConflictStrategy::LocalWins => $this->keepLocal($model),
2828+ ConflictStrategy::NewestWins => $this->compareAndApply($model, $record, $meta, $mapper),
2929+ ConflictStrategy::Manual => $this->flagForReview($model, $record, $meta, $mapper),
3030+ };
3131+ }
3232+3333+ /**
3434+ * Apply the remote version, overwriting local changes.
3535+ */
3636+ protected function applyRemote(
3737+ Model $model,
3838+ Data $record,
3939+ array $meta,
4040+ RecordMapper $mapper
4141+ ): ConflictResolution {
4242+ $mapper->updateModel($model, $record, $meta);
4343+ $model->save();
4444+4545+ return ConflictResolution::remoteWins($model);
4646+ }
4747+4848+ /**
4949+ * Keep the local version, ignoring remote changes.
5050+ */
5151+ protected function keepLocal(Model $model): ConflictResolution
5252+ {
5353+ return ConflictResolution::localWins($model);
5454+ }
5555+5656+ /**
5757+ * Compare timestamps and apply the newest version.
5858+ */
5959+ protected function compareAndApply(
6060+ Model $model,
6161+ Data $record,
6262+ array $meta,
6363+ RecordMapper $mapper
6464+ ): ConflictResolution {
6565+ $localUpdatedAt = $model->getAttribute('updated_at');
6666+6767+ // Try to get remote timestamp from record
6868+ $remoteCreatedAt = $record->createdAt ?? null;
6969+7070+ // If we can't compare, default to remote wins
7171+ if (! $localUpdatedAt || ! $remoteCreatedAt) {
7272+ return $this->applyRemote($model, $record, $meta, $mapper);
7373+ }
7474+7575+ // Compare timestamps
7676+ if ($localUpdatedAt > $remoteCreatedAt) {
7777+ return $this->keepLocal($model);
7878+ }
7979+8080+ return $this->applyRemote($model, $record, $meta, $mapper);
8181+ }
8282+8383+ /**
8484+ * Flag the conflict for manual review.
8585+ */
8686+ protected function flagForReview(
8787+ Model $model,
8888+ Data $record,
8989+ array $meta,
9090+ RecordMapper $mapper
9191+ ): ConflictResolution {
9292+ // Create a pending conflict record
9393+ $conflict = PendingConflict::create([
9494+ 'model_type' => get_class($model),
9595+ 'model_id' => $model->getKey(),
9696+ 'uri' => $meta['uri'] ?? null,
9797+ 'local_data' => $model->toArray(),
9898+ 'remote_data' => $this->buildRemoteData($record, $meta, $mapper),
9999+ 'status' => 'pending',
100100+ ]);
101101+102102+ // Dispatch event for notification
103103+ event(new ConflictDetected($model, $record, $meta, $conflict));
104104+105105+ return ConflictResolution::pending($conflict);
106106+ }
107107+108108+ /**
109109+ * Build the remote data array for storage.
110110+ */
111111+ protected function buildRemoteData(Data $record, array $meta, RecordMapper $mapper): array
112112+ {
113113+ // Create a temporary model with the remote data
114114+ $tempModel = $mapper->toModel($record, $meta);
115115+116116+ return $tempModel->toArray();
117117+ }
118118+}
+42
src/Sync/ConflictStrategy.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Sync;
44+55+/**
66+ * Strategy for resolving conflicts between local and remote changes.
77+ */
88+enum ConflictStrategy: string
99+{
1010+ /**
1111+ * Remote (AT Protocol) is source of truth.
1212+ * Local changes are overwritten.
1313+ */
1414+ case RemoteWins = 'remote';
1515+1616+ /**
1717+ * Local database is source of truth.
1818+ * Remote changes are ignored.
1919+ */
2020+ case LocalWins = 'local';
2121+2222+ /**
2323+ * Compare timestamps and use the newest version.
2424+ */
2525+ case NewestWins = 'newest';
2626+2727+ /**
2828+ * Flag conflict for manual review.
2929+ * Neither version is applied automatically.
3030+ */
3131+ case Manual = 'manual';
3232+3333+ /**
3434+ * Create from config value.
3535+ */
3636+ public static function fromConfig(): self
3737+ {
3838+ $strategy = config('parity.conflicts.strategy', 'remote');
3939+4040+ return self::tryFrom($strategy) ?? self::RemoteWins;
4141+ }
4242+}
+127
src/Sync/PendingConflict.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Sync;
44+55+use Illuminate\Database\Eloquent\Model;
66+use Illuminate\Database\Eloquent\Relations\MorphTo;
77+88+/**
99+ * Model for storing pending conflicts requiring manual resolution.
1010+ */
1111+class PendingConflict extends Model
1212+{
1313+ protected $guarded = [];
1414+1515+ protected $casts = [
1616+ 'local_data' => 'array',
1717+ 'remote_data' => 'array',
1818+ 'resolved_at' => 'datetime',
1919+ ];
2020+2121+ /**
2222+ * Get the table name from config.
2323+ */
2424+ public function getTable(): string
2525+ {
2626+ return config('parity.conflicts.table', 'parity_conflicts');
2727+ }
2828+2929+ /**
3030+ * Get the related model.
3131+ */
3232+ public function model(): MorphTo
3333+ {
3434+ return $this->morphTo();
3535+ }
3636+3737+ /**
3838+ * Check if this conflict is pending.
3939+ */
4040+ public function isPending(): bool
4141+ {
4242+ return $this->status === 'pending';
4343+ }
4444+4545+ /**
4646+ * Check if this conflict has been resolved.
4747+ */
4848+ public function isResolved(): bool
4949+ {
5050+ return $this->status === 'resolved';
5151+ }
5252+5353+ /**
5454+ * Check if this conflict was dismissed.
5555+ */
5656+ public function isDismissed(): bool
5757+ {
5858+ return $this->status === 'dismissed';
5959+ }
6060+6161+ /**
6262+ * Resolve the conflict with the local version.
6363+ */
6464+ public function resolveWithLocal(): void
6565+ {
6666+ $this->update([
6767+ 'status' => 'resolved',
6868+ 'resolution' => 'local',
6969+ 'resolved_at' => now(),
7070+ ]);
7171+ }
7272+7373+ /**
7474+ * Resolve the conflict with the remote version.
7575+ */
7676+ public function resolveWithRemote(): void
7777+ {
7878+ $model = $this->model;
7979+8080+ if ($model) {
8181+ $model->fill($this->remote_data);
8282+ $model->save();
8383+ }
8484+8585+ $this->update([
8686+ 'status' => 'resolved',
8787+ 'resolution' => 'remote',
8888+ 'resolved_at' => now(),
8989+ ]);
9090+ }
9191+9292+ /**
9393+ * Dismiss this conflict without resolving.
9494+ */
9595+ public function dismiss(): void
9696+ {
9797+ $this->update([
9898+ 'status' => 'dismissed',
9999+ 'resolved_at' => now(),
100100+ ]);
101101+ }
102102+103103+ /**
104104+ * Scope to pending conflicts.
105105+ */
106106+ public function scopePending($query)
107107+ {
108108+ return $query->where('status', 'pending');
109109+ }
110110+111111+ /**
112112+ * Scope to resolved conflicts.
113113+ */
114114+ public function scopeResolved($query)
115115+ {
116116+ return $query->where('status', 'resolved');
117117+ }
118118+119119+ /**
120120+ * Scope to conflicts for a specific model.
121121+ */
122122+ public function scopeForModel($query, Model $model)
123123+ {
124124+ return $query->where('model_type', get_class($model))
125125+ ->where('model_id', $model->getKey());
126126+ }
127127+}
+14
tests/Fixtures/SyncableMapper.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Tests\Fixtures;
44+55+/**
66+ * Mapper for SyncableModel (extends TestMapper with different model class).
77+ */
88+class SyncableMapper extends TestMapper
99+{
1010+ public function modelClass(): string
1111+ {
1212+ return SyncableModel::class;
1313+ }
1414+}
+19
tests/Fixtures/SyncableModel.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Tests\Fixtures;
44+55+use SocialDept\AtpParity\Concerns\SyncsWithAtp;
66+77+/**
88+ * Test model with SyncsWithAtp trait for unit testing.
99+ *
1010+ * Extends TestModel so it gets the same mapper from the registry.
1111+ */
1212+class SyncableModel extends TestModel
1313+{
1414+ use SyncsWithAtp;
1515+1616+ protected $casts = [
1717+ 'atp_synced_at' => 'datetime',
1818+ ];
1919+}