···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+```