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