+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);
+21
LICENSE
+21
LICENSE
···
1
+
MIT License
2
+
3
+
Copyright (c) 2025 Social Dept
4
+
5
+
Permission is hereby granted, free of charge, to any person obtaining a copy
6
+
of this software and associated documentation files (the "Software"), to deal
7
+
in the Software without restriction, including without limitation the rights
8
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+
copies of the Software, and to permit persons to whom the Software is
10
+
furnished to do so, subject to the following conditions:
11
+
12
+
The above copyright notice and this permission notice shall be included in all
13
+
copies or substantial portions of the Software.
14
+
15
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+
SOFTWARE.
+426
README.md
+426
README.md
···
1
+
[](https://github.com/socialdept/atp-parity)
2
+
3
+
<h3 align="center">
4
+
Bidirectional mapping between AT Protocol records and Laravel Eloquent models.
5
+
</h3>
6
+
7
+
<p align="center">
8
+
<br>
9
+
<a href="https://packagist.org/packages/socialdept/atp-parity" title="Latest Version on Packagist"><img src="https://img.shields.io/packagist/v/socialdept/atp-parity.svg?style=flat-square"></a>
10
+
<a href="https://packagist.org/packages/socialdept/atp-parity" title="Total Downloads"><img src="https://img.shields.io/packagist/dt/socialdept/atp-parity.svg?style=flat-square"></a>
11
+
<a href="https://github.com/socialdept/atp-parity/actions/workflows/tests.yml" title="GitHub Tests Action Status"><img src="https://img.shields.io/github/actions/workflow/status/socialdept/atp-parity/tests.yml?branch=main&label=tests&style=flat-square"></a>
12
+
<a href="LICENSE" title="Software License"><img src="https://img.shields.io/github/license/socialdept/atp-parity?style=flat-square"></a>
13
+
</p>
14
+
15
+
---
16
+
17
+
## What is Parity?
18
+
19
+
**Parity** is a Laravel package that bridges your Eloquent models with AT Protocol records. It provides bidirectional mapping, automatic firehose synchronization, and type-safe transformations between your database and the decentralized social web.
20
+
21
+
Think of it as Laravel's model casts, but for AT Protocol records.
22
+
23
+
## Why use Parity?
24
+
25
+
- **Laravel-style code** - Familiar patterns you already know
26
+
- **Bidirectional mapping** - Transform records to models and back
27
+
- **Firehose sync** - Automatically sync network events to your database
28
+
- **Type-safe DTOs** - Full integration with atp-schema generated types
29
+
- **Model traits** - Add AT Protocol awareness to any Eloquent model
30
+
- **Flexible mappers** - Define custom transformations for your domain
31
+
32
+
## Quick Example
33
+
34
+
```php
35
+
use SocialDept\AtpParity\RecordMapper;
36
+
use SocialDept\AtpSchema\Data\Data;
37
+
use Illuminate\Database\Eloquent\Model;
38
+
39
+
class PostMapper extends RecordMapper
40
+
{
41
+
public function recordClass(): string
42
+
{
43
+
return \SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post::class;
44
+
}
45
+
46
+
public function modelClass(): string
47
+
{
48
+
return \App\Models\Post::class;
49
+
}
50
+
51
+
protected function recordToAttributes(Data $record): array
52
+
{
53
+
return [
54
+
'content' => $record->text,
55
+
'published_at' => $record->createdAt,
56
+
];
57
+
}
58
+
59
+
protected function modelToRecordData(Model $model): array
60
+
{
61
+
return [
62
+
'text' => $model->content,
63
+
'createdAt' => $model->published_at->toIso8601String(),
64
+
];
65
+
}
66
+
}
67
+
```
68
+
69
+
## Installation
70
+
71
+
```bash
72
+
composer require socialdept/atp-parity
73
+
```
74
+
75
+
Optionally publish the configuration:
76
+
77
+
```bash
78
+
php artisan vendor:publish --tag=parity-config
79
+
```
80
+
81
+
## Getting Started
82
+
83
+
Once installed, you're three steps away from syncing AT Protocol records:
84
+
85
+
### 1. Create a Mapper
86
+
87
+
Define how your record maps to your model:
88
+
89
+
```php
90
+
class PostMapper extends RecordMapper
91
+
{
92
+
public function recordClass(): string
93
+
{
94
+
return Post::class; // Your atp-schema DTO or custom Record
95
+
}
96
+
97
+
public function modelClass(): string
98
+
{
99
+
return \App\Models\Post::class;
100
+
}
101
+
102
+
protected function recordToAttributes(Data $record): array
103
+
{
104
+
return ['content' => $record->text];
105
+
}
106
+
107
+
protected function modelToRecordData(Model $model): array
108
+
{
109
+
return ['text' => $model->content];
110
+
}
111
+
}
112
+
```
113
+
114
+
### 2. Register Your Mapper
115
+
116
+
```php
117
+
// config/parity.php
118
+
return [
119
+
'mappers' => [
120
+
App\AtpMappers\PostMapper::class,
121
+
],
122
+
];
123
+
```
124
+
125
+
### 3. Add the Trait to Your Model
126
+
127
+
```php
128
+
use SocialDept\AtpParity\Concerns\HasAtpRecord;
129
+
130
+
class Post extends Model
131
+
{
132
+
use HasAtpRecord;
133
+
}
134
+
```
135
+
136
+
Your model can now convert to/from AT Protocol records and query by URI.
137
+
138
+
## What can you build?
139
+
140
+
- **Data mirrors** - Keep local copies of AT Protocol data
141
+
- **AppViews** - Build custom applications with synced data
142
+
- **Analytics platforms** - Store and analyze network activity
143
+
- **Content aggregators** - Collect and organize posts locally
144
+
- **Moderation tools** - Track and manage content in your database
145
+
- **Hybrid applications** - Combine local and federated data
146
+
147
+
## Ecosystem Integration
148
+
149
+
Parity is designed to work seamlessly with the other atp-* packages:
150
+
151
+
| Package | Integration |
152
+
|---------|-------------|
153
+
| **atp-schema** | Records extend `Data`, use generated DTOs directly |
154
+
| **atp-client** | `RecordHelper` for fetching and hydrating records |
155
+
| **atp-signals** | `ParitySignal` for automatic firehose sync |
156
+
157
+
### Using with atp-schema
158
+
159
+
Use generated schema classes directly with `SchemaMapper`:
160
+
161
+
```php
162
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
163
+
use SocialDept\AtpParity\Support\SchemaMapper;
164
+
165
+
$mapper = new SchemaMapper(
166
+
schemaClass: Post::class,
167
+
modelClass: \App\Models\Post::class,
168
+
toAttributes: fn(Post $p) => [
169
+
'content' => $p->text,
170
+
'published_at' => $p->createdAt,
171
+
],
172
+
toRecordData: fn($m) => [
173
+
'text' => $m->content,
174
+
'createdAt' => $m->published_at->toIso8601String(),
175
+
],
176
+
);
177
+
178
+
$registry->register($mapper);
179
+
```
180
+
181
+
### Using with atp-client
182
+
183
+
Fetch records by URI and convert directly to models:
184
+
185
+
```php
186
+
use SocialDept\AtpParity\Support\RecordHelper;
187
+
188
+
$helper = app(RecordHelper::class);
189
+
190
+
// Fetch as typed DTO
191
+
$record = $helper->fetch('at://did:plc:xxx/app.bsky.feed.post/abc123');
192
+
193
+
// Fetch and convert to model (unsaved)
194
+
$post = $helper->fetchAsModel('at://did:plc:xxx/app.bsky.feed.post/abc123');
195
+
196
+
// Fetch and sync to database (upsert)
197
+
$post = $helper->sync('at://did:plc:xxx/app.bsky.feed.post/abc123');
198
+
```
199
+
200
+
The helper automatically resolves the DID to find the correct PDS endpoint, so it works with any AT Protocol server - not just Bluesky.
201
+
202
+
### Using with atp-signals
203
+
204
+
Enable automatic firehose synchronization by registering the `ParitySignal`:
205
+
206
+
```php
207
+
// config/signal.php
208
+
return [
209
+
'signals' => [
210
+
\SocialDept\AtpParity\Signals\ParitySignal::class,
211
+
],
212
+
];
213
+
```
214
+
215
+
Run `php artisan signal:consume` and your models will automatically sync with matching firehose events.
216
+
217
+
### Importing Historical Data
218
+
219
+
For existing records created before you started consuming the firehose:
220
+
221
+
```bash
222
+
# Import a user's records
223
+
php artisan parity:import did:plc:z72i7hdynmk6r22z27h6tvur
224
+
225
+
# Check import status
226
+
php artisan parity:import-status
227
+
```
228
+
229
+
Or programmatically:
230
+
231
+
```php
232
+
use SocialDept\AtpParity\Import\ImportService;
233
+
234
+
$service = app(ImportService::class);
235
+
$result = $service->importUser('did:plc:z72i7hdynmk6r22z27h6tvur');
236
+
237
+
echo "Synced {$result->recordsSynced} records";
238
+
```
239
+
240
+
## Documentation
241
+
242
+
For detailed documentation on specific topics:
243
+
244
+
- [Record Mappers](docs/mappers.md) - Creating and using mappers
245
+
- [Model Traits](docs/traits.md) - HasAtpRecord and SyncsWithAtp
246
+
- [atp-schema Integration](docs/atp-schema-integration.md) - Using generated DTOs
247
+
- [atp-client Integration](docs/atp-client-integration.md) - RecordHelper and fetching
248
+
- [atp-signals Integration](docs/atp-signals-integration.md) - ParitySignal and firehose sync
249
+
- [Importing](docs/importing.md) - Syncing historical data
250
+
251
+
## Model Traits
252
+
253
+
### HasAtpRecord
254
+
255
+
Add AT Protocol awareness to your models:
256
+
257
+
```php
258
+
use SocialDept\AtpParity\Concerns\HasAtpRecord;
259
+
260
+
class Post extends Model
261
+
{
262
+
use HasAtpRecord;
263
+
264
+
protected $fillable = ['content', 'atp_uri', 'atp_cid'];
265
+
}
266
+
```
267
+
268
+
Available methods:
269
+
270
+
```php
271
+
// Get AT Protocol metadata
272
+
$post->getAtpUri(); // at://did:plc:xxx/app.bsky.feed.post/rkey
273
+
$post->getAtpCid(); // bafyre...
274
+
$post->getAtpDid(); // did:plc:xxx (extracted from URI)
275
+
$post->getAtpCollection(); // app.bsky.feed.post (extracted from URI)
276
+
$post->getAtpRkey(); // rkey (extracted from URI)
277
+
278
+
// Check sync status
279
+
$post->hasAtpRecord(); // true if synced
280
+
281
+
// Convert to record DTO
282
+
$record = $post->toAtpRecord();
283
+
284
+
// Query scopes
285
+
Post::withAtpRecord()->get(); // Only synced posts
286
+
Post::withoutAtpRecord()->get(); // Only unsynced posts
287
+
Post::whereAtpUri($uri)->first(); // Find by URI
288
+
```
289
+
290
+
### SyncsWithAtp
291
+
292
+
Extended trait for bidirectional sync tracking:
293
+
294
+
```php
295
+
use SocialDept\AtpParity\Concerns\SyncsWithAtp;
296
+
297
+
class Post extends Model
298
+
{
299
+
use SyncsWithAtp;
300
+
}
301
+
```
302
+
303
+
Additional methods:
304
+
305
+
```php
306
+
// Track sync status
307
+
$post->getAtpSyncedAt(); // Last sync timestamp
308
+
$post->hasLocalChanges(); // True if updated since last sync
309
+
310
+
// Mark as synced
311
+
$post->markAsSynced($uri, $cid);
312
+
313
+
// Update from remote
314
+
$post->updateFromRecord($record, $uri, $cid);
315
+
```
316
+
317
+
## Database Migration
318
+
319
+
Add AT Protocol columns to your models:
320
+
321
+
```php
322
+
Schema::table('posts', function (Blueprint $table) {
323
+
$table->string('atp_uri')->nullable()->unique();
324
+
$table->string('atp_cid')->nullable();
325
+
$table->timestamp('atp_synced_at')->nullable(); // For SyncsWithAtp
326
+
});
327
+
```
328
+
329
+
## Configuration
330
+
331
+
```php
332
+
// config/parity.php
333
+
return [
334
+
// Registered mappers
335
+
'mappers' => [
336
+
App\AtpMappers\PostMapper::class,
337
+
App\AtpMappers\ProfileMapper::class,
338
+
],
339
+
340
+
// Column names for AT Protocol metadata
341
+
'columns' => [
342
+
'uri' => 'atp_uri',
343
+
'cid' => 'atp_cid',
344
+
],
345
+
];
346
+
```
347
+
348
+
## Creating Custom Records
349
+
350
+
Extend the `Record` base class for custom AT Protocol records:
351
+
352
+
```php
353
+
use SocialDept\AtpParity\Data\Record;
354
+
use Carbon\Carbon;
355
+
356
+
class PostRecord extends Record
357
+
{
358
+
public function __construct(
359
+
public readonly string $text,
360
+
public readonly Carbon $createdAt,
361
+
public readonly ?array $facets = null,
362
+
) {}
363
+
364
+
public static function getLexicon(): string
365
+
{
366
+
return 'app.bsky.feed.post';
367
+
}
368
+
369
+
public static function fromArray(array $data): static
370
+
{
371
+
return new static(
372
+
text: $data['text'],
373
+
createdAt: Carbon::parse($data['createdAt']),
374
+
facets: $data['facets'] ?? null,
375
+
);
376
+
}
377
+
}
378
+
```
379
+
380
+
The `Record` class extends `atp-schema`'s `Data` and implements `atp-client`'s `Recordable` interface, ensuring full compatibility with the ecosystem.
381
+
382
+
## Requirements
383
+
384
+
- PHP 8.2+
385
+
- Laravel 10, 11, or 12
386
+
- [socialdept/atp-schema](https://github.com/socialdept/atp-schema) ^0.3
387
+
- [socialdept/atp-client](https://github.com/socialdept/atp-client) ^0.0
388
+
- [socialdept/atp-resolver](https://github.com/socialdept/atp-resolver) ^1.1
389
+
- [socialdept/atp-signals](https://github.com/socialdept/atp-signals) ^1.1
390
+
391
+
## Testing
392
+
393
+
```bash
394
+
composer test
395
+
```
396
+
397
+
## Resources
398
+
399
+
- [AT Protocol Documentation](https://atproto.com/)
400
+
- [Bluesky API Docs](https://docs.bsky.app/)
401
+
- [atp-schema](https://github.com/socialdept/atp-schema) - Generated AT Protocol DTOs
402
+
- [atp-client](https://github.com/socialdept/atp-client) - AT Protocol HTTP client
403
+
- [atp-signals](https://github.com/socialdept/atp-signals) - Firehose event consumer
404
+
405
+
## Support & Contributing
406
+
407
+
Found a bug or have a feature request? [Open an issue](https://github.com/socialdept/atp-parity/issues).
408
+
409
+
Want to contribute? Check out the [contribution guidelines](contributing.md).
410
+
411
+
## Changelog
412
+
413
+
Please see [changelog](changelog.md) for recent changes.
414
+
415
+
## Credits
416
+
417
+
- [Miguel Batres](https://batres.co) - founder & lead maintainer
418
+
- [All contributors](https://github.com/socialdept/atp-parity/graphs/contributors)
419
+
420
+
## License
421
+
422
+
Parity is open-source software licensed under the [MIT license](license.md).
423
+
424
+
---
425
+
426
+
**Built for the Federation** - By Social Dept.
-8
changelog.md
-8
changelog.md
+15
-9
composer.json
+15
-9
composer.json
···
1
1
{
2
2
"name": "socialdept/atp-parity",
3
-
"description": ":package_description",
3
+
"description": "AT Protocol record mapping and sync for Laravel Eloquent models",
4
4
"license": "MIT",
5
5
"authors": [
6
6
{
···
10
10
}
11
11
],
12
12
"homepage": "https://github.com/socialdept/atp-parity",
13
-
"keywords": ["Laravel", "AtpReplicator"],
13
+
"keywords": ["Laravel", "AT Protocol", "Bluesky", "ATP", "Parity", "Sync"],
14
14
"require": {
15
-
"illuminate/support": "~9"
15
+
"php": "^8.2",
16
+
"illuminate/support": "^10.0|^11.0|^12.0",
17
+
"illuminate/database": "^10.0|^11.0|^12.0",
18
+
"socialdept/atp-schema": "^0.3",
19
+
"socialdept/atp-client": "^0.0",
20
+
"socialdept/atp-resolver": "^1.1",
21
+
"socialdept/atp-signals": "^1.1"
16
22
},
17
23
"require-dev": {
18
-
"../../../vendor/phpunit/phpunit": "~9.0",
19
-
"orchestra/testbench": "~7"
24
+
"phpunit/phpunit": "^10.0|^11.0",
25
+
"orchestra/testbench": "^8.0|^9.0|^10.0"
20
26
},
21
27
"autoload": {
22
28
"psr-4": {
23
-
"SocialDept\\AtpReplicator\\": "src/"
29
+
"SocialDept\\AtpParity\\": "src/"
24
30
}
25
31
},
26
32
"autoload-dev": {
27
33
"psr-4": {
28
-
"SocialDept\\AtpReplicator\\Tests\\": "tests"
34
+
"SocialDept\\AtpParity\\Tests\\": "tests"
29
35
}
30
36
},
31
37
"extra": {
32
38
"laravel": {
33
39
"providers": [
34
-
"SocialDept\\AtpReplicator\\AtpReplicatorServiceProvider"
40
+
"SocialDept\\AtpParity\\ParityServiceProvider"
35
41
],
36
42
"aliases": {
37
-
"AtpReplicator": "SocialDept\\AtpReplicator\\Facades\\AtpReplicator"
43
+
"Parity": "SocialDept\\AtpParity\\Facades\\Parity"
38
44
}
39
45
}
40
46
}
+105
config/parity.php
+105
config/parity.php
···
1
+
<?php
2
+
3
+
return [
4
+
/*
5
+
|--------------------------------------------------------------------------
6
+
| Record Mappers
7
+
|--------------------------------------------------------------------------
8
+
|
9
+
| List of RecordMapper classes to automatically register. Each mapper
10
+
| handles bidirectional conversion between an AT Protocol record DTO
11
+
| and an Eloquent model.
12
+
|
13
+
*/
14
+
'mappers' => [
15
+
// App\AtpMappers\PostMapper::class,
16
+
// App\AtpMappers\ProfileMapper::class,
17
+
],
18
+
19
+
/*
20
+
|--------------------------------------------------------------------------
21
+
| AT Protocol Metadata Columns
22
+
|--------------------------------------------------------------------------
23
+
|
24
+
| The column names used to store AT Protocol metadata on models.
25
+
|
26
+
*/
27
+
'columns' => [
28
+
'uri' => 'atp_uri',
29
+
'cid' => 'atp_cid',
30
+
],
31
+
32
+
/*
33
+
|--------------------------------------------------------------------------
34
+
| Import Configuration
35
+
|--------------------------------------------------------------------------
36
+
|
37
+
| Settings for importing historical AT Protocol records to your database.
38
+
|
39
+
*/
40
+
'import' => [
41
+
// Records per page when listing from PDS
42
+
'page_size' => 100,
43
+
44
+
// Delay between pages in milliseconds (rate limiting)
45
+
'page_delay' => 100,
46
+
47
+
// Queue name for import jobs
48
+
'queue' => 'default',
49
+
50
+
// Database table for storing import state
51
+
'state_table' => 'parity_import_states',
52
+
],
53
+
54
+
/*
55
+
|--------------------------------------------------------------------------
56
+
| Sync Filtering
57
+
|--------------------------------------------------------------------------
58
+
|
59
+
| Control which firehose events get synced to your database.
60
+
|
61
+
*/
62
+
'sync' => [
63
+
// Only sync records from these DIDs (null = all DIDs)
64
+
'dids' => null,
65
+
66
+
// Only sync these operations: 'create', 'update', 'delete' (null = all)
67
+
'operations' => null,
68
+
69
+
// Custom filter callback: function(SignalEvent $event): bool
70
+
// Return true to sync the event, false to skip it
71
+
'filter' => null,
72
+
],
73
+
74
+
/*
75
+
|--------------------------------------------------------------------------
76
+
| Conflict Resolution
77
+
|--------------------------------------------------------------------------
78
+
|
79
+
| Strategy for handling conflicts between local and remote changes.
80
+
|
81
+
*/
82
+
'conflicts' => [
83
+
// Strategy: 'remote', 'local', 'newest', 'manual'
84
+
'strategy' => env('PARITY_CONFLICT_STRATEGY', 'remote'),
85
+
86
+
// Database table for pending conflicts (manual resolution)
87
+
'table' => 'parity_conflicts',
88
+
89
+
// Notifiable class or callback for conflict notifications
90
+
'notify' => null,
91
+
],
92
+
93
+
/*
94
+
|--------------------------------------------------------------------------
95
+
| Collection Discovery
96
+
|--------------------------------------------------------------------------
97
+
|
98
+
| Settings for discovering users with records in specific collections.
99
+
|
100
+
*/
101
+
'discovery' => [
102
+
// Relay URL for discovery queries
103
+
'relay' => env('ATP_RELAY_URL', 'https://bsky.network'),
104
+
],
105
+
];
+1
-1
contributing.md
CONTRIBUTING.md
+1
-1
contributing.md
CONTRIBUTING.md
···
2
2
3
3
Contributions are welcome and will be fully credited.
4
4
5
-
Contributions are accepted via Pull Requests on [Github](https://github.com/socialdept/atp-parity).
5
+
Contributions are accepted via Pull Requests on [Github](https://github.com/social-dept/beacon).
6
6
7
7
# Things you could do
8
8
If you want to contribute but do not know where to start, this list provides some starting points.
+37
database/migrations/create_parity_conflicts_table.php
+37
database/migrations/create_parity_conflicts_table.php
···
1
+
<?php
2
+
3
+
use Illuminate\Database\Migrations\Migration;
4
+
use Illuminate\Database\Schema\Blueprint;
5
+
use Illuminate\Support\Facades\Schema;
6
+
7
+
return new class extends Migration
8
+
{
9
+
public function up(): void
10
+
{
11
+
$table = config('parity.conflicts.table', 'parity_conflicts');
12
+
13
+
Schema::create($table, function (Blueprint $table) {
14
+
$table->id();
15
+
$table->string('model_type');
16
+
$table->unsignedBigInteger('model_id');
17
+
$table->string('uri')->nullable();
18
+
$table->json('local_data');
19
+
$table->json('remote_data');
20
+
$table->string('status')->default('pending');
21
+
$table->string('resolution')->nullable();
22
+
$table->timestamp('resolved_at')->nullable();
23
+
$table->timestamps();
24
+
25
+
$table->index(['model_type', 'model_id']);
26
+
$table->index('status');
27
+
$table->index('uri');
28
+
});
29
+
}
30
+
31
+
public function down(): void
32
+
{
33
+
$table = config('parity.conflicts.table', 'parity_conflicts');
34
+
35
+
Schema::dropIfExists($table);
36
+
}
37
+
};
+38
database/migrations/create_parity_import_states_table.php
+38
database/migrations/create_parity_import_states_table.php
···
1
+
<?php
2
+
3
+
use Illuminate\Database\Migrations\Migration;
4
+
use Illuminate\Database\Schema\Blueprint;
5
+
use Illuminate\Support\Facades\Schema;
6
+
7
+
return new class extends Migration
8
+
{
9
+
public function up(): void
10
+
{
11
+
$table = config('parity.import.state_table', 'parity_import_states');
12
+
13
+
Schema::create($table, function (Blueprint $table) {
14
+
$table->id();
15
+
$table->string('did');
16
+
$table->string('collection');
17
+
$table->string('status')->default('pending');
18
+
$table->string('cursor')->nullable();
19
+
$table->unsignedInteger('records_synced')->default(0);
20
+
$table->unsignedInteger('records_skipped')->default(0);
21
+
$table->unsignedInteger('records_failed')->default(0);
22
+
$table->timestamp('started_at')->nullable();
23
+
$table->timestamp('completed_at')->nullable();
24
+
$table->text('error')->nullable();
25
+
$table->timestamps();
26
+
27
+
$table->unique(['did', 'collection']);
28
+
$table->index('status');
29
+
});
30
+
}
31
+
32
+
public function down(): void
33
+
{
34
+
$table = config('parity.import.state_table', 'parity_import_states');
35
+
36
+
Schema::dropIfExists($table);
37
+
}
38
+
};
+343
docs/atp-client-integration.md
+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.
-5
license.md
-5
license.md
+19
-15
phpunit.xml
+19
-15
phpunit.xml
···
1
1
<?xml version="1.0" encoding="UTF-8"?>
2
-
<phpunit bootstrap="vendor/autoload.php"
3
-
backupGlobals="false"
4
-
backupStaticAttributes="false"
2
+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3
+
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
4
+
bootstrap="vendor/autoload.php"
5
5
colors="true"
6
-
verbose="true"
7
-
convertErrorsToExceptions="true"
8
-
convertNoticesToExceptions="true"
9
-
convertWarningsToExceptions="true"
10
-
processIsolation="false"
11
-
stopOnFailure="false">
6
+
cacheDirectory=".phpunit.cache">
12
7
<testsuites>
13
-
<testsuite name="Package">
14
-
<directory suffix=".php">./tests/</directory>
8
+
<testsuite name="Parity Test Suite">
9
+
<directory suffix="Test.php">./tests/</directory>
10
+
<exclude>./tests/Fixtures/</exclude>
15
11
</testsuite>
16
12
</testsuites>
17
-
<filter>
18
-
<whitelist>
13
+
<source>
14
+
<include>
19
15
<directory>src/</directory>
20
-
</whitelist>
21
-
</filter>
16
+
</include>
17
+
</source>
18
+
<php>
19
+
<env name="APP_ENV" value="testing"/>
20
+
<env name="CACHE_DRIVER" value="array"/>
21
+
<env name="SESSION_DRIVER" value="array"/>
22
+
<env name="QUEUE_CONNECTION" value="sync"/>
23
+
<env name="DB_CONNECTION" value="sqlite"/>
24
+
<env name="DB_DATABASE" value=":memory:"/>
25
+
</php>
22
26
</phpunit>
-57
readme.md
-57
readme.md
···
1
-
# AtpReplicator
2
-
3
-
[![Latest Version on Packagist][ico-version]][link-packagist]
4
-
[![Total Downloads][ico-downloads]][link-downloads]
5
-
[![Build Status][ico-travis]][link-travis]
6
-
[![StyleCI][ico-styleci]][link-styleci]
7
-
8
-
This is where your description should go. Take a look at [contributing.md](contributing.md) to see a to do list.
9
-
10
-
## Installation
11
-
12
-
Via Composer
13
-
14
-
```bash
15
-
composer require socialdept/atp-parity
16
-
```
17
-
18
-
## Usage
19
-
20
-
## Change log
21
-
22
-
Please see the [changelog](changelog.md) for more information on what has changed recently.
23
-
24
-
## Testing
25
-
26
-
```bash
27
-
composer test
28
-
```
29
-
30
-
## Contributing
31
-
32
-
Please see [contributing.md](contributing.md) for details and a todolist.
33
-
34
-
## Security
35
-
36
-
If you discover any security related issues, please email author@email.com instead of using the issue tracker.
37
-
38
-
## Credits
39
-
40
-
- [Author Name][link-author]
41
-
- [All Contributors][link-contributors]
42
-
43
-
## License
44
-
45
-
MIT. Please see the [license file](license.md) for more information.
46
-
47
-
[ico-version]: https://img.shields.io/packagist/v/socialdept/atp-parity.svg?style=flat-square
48
-
[ico-downloads]: https://img.shields.io/packagist/dt/socialdept/atp-parity.svg?style=flat-square
49
-
[ico-travis]: https://img.shields.io/travis/socialdept/atp-parity/master.svg?style=flat-square
50
-
[ico-styleci]: https://styleci.io/repos/12345678/shield
51
-
52
-
[link-packagist]: https://packagist.org/packages/socialdept/atp-parity
53
-
[link-downloads]: https://packagist.org/packages/socialdept/atp-parity
54
-
[link-travis]: https://travis-ci.org/socialdept/atp-parity
55
-
[link-styleci]: https://styleci.io/repos/12345678
56
-
[link-author]: https://github.com/social-dept
57
-
[link-contributors]: ../../contributors
-8
src/AtpReplicator.php
-8
src/AtpReplicator.php
-82
src/AtpReplicatorServiceProvider.php
-82
src/AtpReplicatorServiceProvider.php
···
1
-
<?php
2
-
3
-
namespace SocialDept\AtpReplicator;
4
-
5
-
use Illuminate\Support\ServiceProvider;
6
-
7
-
class AtpReplicatorServiceProvider extends ServiceProvider
8
-
{
9
-
/**
10
-
* Perform post-registration booting of services.
11
-
*
12
-
* @return void
13
-
*/
14
-
public function boot(): void
15
-
{
16
-
// $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'social-dept');
17
-
// $this->loadViewsFrom(__DIR__.'/../resources/views', 'social-dept');
18
-
// $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
19
-
// $this->loadRoutesFrom(__DIR__.'/routes.php');
20
-
21
-
// Publishing is only necessary when using the CLI.
22
-
if ($this->app->runningInConsole()) {
23
-
$this->bootForConsole();
24
-
}
25
-
}
26
-
27
-
/**
28
-
* Register any package services.
29
-
*
30
-
* @return void
31
-
*/
32
-
public function register(): void
33
-
{
34
-
$this->mergeConfigFrom(__DIR__.'/../config/atp-replicator.php', 'atp-replicator');
35
-
36
-
// Register the service the package provides.
37
-
$this->app->singleton('atp-replicator', function ($app) {
38
-
return new AtpReplicator;
39
-
});
40
-
}
41
-
42
-
/**
43
-
* Get the services provided by the provider.
44
-
*
45
-
* @return array
46
-
*/
47
-
public function provides()
48
-
{
49
-
return ['atp-replicator'];
50
-
}
51
-
52
-
/**
53
-
* Console-specific booting.
54
-
*
55
-
* @return void
56
-
*/
57
-
protected function bootForConsole(): void
58
-
{
59
-
// Publishing the configuration file.
60
-
$this->publishes([
61
-
__DIR__.'/../config/atp-replicator.php' => config_path('atp-replicator.php'),
62
-
], 'atp-replicator.config');
63
-
64
-
// Publishing the views.
65
-
/*$this->publishes([
66
-
__DIR__.'/../resources/views' => base_path('resources/views/vendor/social-dept'),
67
-
], 'atp-replicator.views');*/
68
-
69
-
// Publishing assets.
70
-
/*$this->publishes([
71
-
__DIR__.'/../resources/assets' => public_path('vendor/social-dept'),
72
-
], 'atp-replicator.assets');*/
73
-
74
-
// Publishing the translation files.
75
-
/*$this->publishes([
76
-
__DIR__.'/../resources/lang' => resource_path('lang/vendor/social-dept'),
77
-
], 'atp-replicator.lang');*/
78
-
79
-
// Registering package commands.
80
-
// $this->commands([]);
81
-
}
82
-
}
+122
src/Commands/DiscoverCommand.php
+122
src/Commands/DiscoverCommand.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Commands;
4
+
5
+
use Illuminate\Console\Command;
6
+
use SocialDept\AtpParity\Discovery\DiscoveryService;
7
+
8
+
use function Laravel\Prompts\error;
9
+
use function Laravel\Prompts\info;
10
+
use function Laravel\Prompts\note;
11
+
12
+
class DiscoverCommand extends Command
13
+
{
14
+
protected $signature = 'parity:discover
15
+
{collection : The collection NSID to discover (e.g., app.bsky.feed.post)}
16
+
{--limit= : Maximum number of DIDs to discover}
17
+
{--import : Import records for discovered DIDs}
18
+
{--output= : Output DIDs to file (one per line)}
19
+
{--count : Only count DIDs without listing them}';
20
+
21
+
protected $description = 'Discover DIDs with records in a specific collection';
22
+
23
+
public function handle(DiscoveryService $service): int
24
+
{
25
+
$collection = $this->argument('collection');
26
+
$limit = $this->option('limit') ? (int) $this->option('limit') : null;
27
+
28
+
if ($this->option('count')) {
29
+
return $this->handleCount($service, $collection);
30
+
}
31
+
32
+
if ($this->option('import')) {
33
+
return $this->handleDiscoverAndImport($service, $collection, $limit);
34
+
}
35
+
36
+
return $this->handleDiscover($service, $collection, $limit);
37
+
}
38
+
39
+
protected function handleCount(DiscoveryService $service, string $collection): int
40
+
{
41
+
info("Counting DIDs with records in {$collection}...");
42
+
43
+
$count = $service->count($collection);
44
+
45
+
info("Found {$count} DIDs");
46
+
47
+
return self::SUCCESS;
48
+
}
49
+
50
+
protected function handleDiscover(DiscoveryService $service, string $collection, ?int $limit): int
51
+
{
52
+
$limitDisplay = $limit ? " (limit: {$limit})" : '';
53
+
info("Discovering DIDs with records in {$collection}{$limitDisplay}...");
54
+
55
+
$result = $service->discover($collection, $limit);
56
+
57
+
if ($result->isFailed()) {
58
+
error("Discovery failed: {$result->error}");
59
+
60
+
return self::FAILURE;
61
+
}
62
+
63
+
if ($result->total === 0) {
64
+
note('No DIDs found');
65
+
66
+
return self::SUCCESS;
67
+
}
68
+
69
+
// Output to file if requested
70
+
if ($output = $this->option('output')) {
71
+
file_put_contents($output, implode("\n", $result->dids)."\n");
72
+
info("Found {$result->total} DIDs, written to {$output}");
73
+
74
+
if ($result->isIncomplete()) {
75
+
note('Results may be incomplete due to limit');
76
+
}
77
+
78
+
return self::SUCCESS;
79
+
}
80
+
81
+
// Output to console
82
+
foreach ($result->dids as $did) {
83
+
$this->line($did);
84
+
}
85
+
86
+
info("Found {$result->total} DIDs");
87
+
88
+
if ($result->isIncomplete()) {
89
+
note('Results may be incomplete due to limit');
90
+
}
91
+
92
+
return self::SUCCESS;
93
+
}
94
+
95
+
protected function handleDiscoverAndImport(DiscoveryService $service, string $collection, ?int $limit): int
96
+
{
97
+
$limitDisplay = $limit ? " (limit: {$limit})" : '';
98
+
info("Discovering and importing DIDs with records in {$collection}{$limitDisplay}...");
99
+
100
+
$result = $service->discoverAndImport(
101
+
$collection,
102
+
$limit,
103
+
function (string $did, int $count) {
104
+
note("[{$count}] Importing {$did}");
105
+
}
106
+
);
107
+
108
+
if ($result->isFailed()) {
109
+
error("Discovery failed: {$result->error}");
110
+
111
+
return self::FAILURE;
112
+
}
113
+
114
+
info("Imported records for {$result->total} DIDs");
115
+
116
+
if ($result->isIncomplete()) {
117
+
note('Results may be incomplete due to limit');
118
+
}
119
+
120
+
return self::SUCCESS;
121
+
}
122
+
}
+135
src/Commands/ExportCommand.php
+135
src/Commands/ExportCommand.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Commands;
4
+
5
+
use Illuminate\Console\Command;
6
+
use SocialDept\AtpParity\Events\ImportProgress;
7
+
use SocialDept\AtpParity\Export\ExportService;
8
+
9
+
use function Laravel\Prompts\error;
10
+
use function Laravel\Prompts\info;
11
+
use function Laravel\Prompts\note;
12
+
13
+
class ExportCommand extends Command
14
+
{
15
+
protected $signature = 'parity:export
16
+
{did : The DID to export}
17
+
{--output= : Output CAR file path}
18
+
{--import : Import records to database instead of saving CAR file}
19
+
{--collection=* : Specific collections to import (with --import)}
20
+
{--since= : Only export changes since this revision}
21
+
{--status : Show repository status instead of exporting}';
22
+
23
+
protected $description = 'Export an AT Protocol repository as CAR file or import to database';
24
+
25
+
public function handle(ExportService $service): int
26
+
{
27
+
$did = $this->argument('did');
28
+
29
+
if (! str_starts_with($did, 'did:')) {
30
+
error("Invalid DID format: {$did}");
31
+
32
+
return self::FAILURE;
33
+
}
34
+
35
+
if ($this->option('status')) {
36
+
return $this->handleStatus($service, $did);
37
+
}
38
+
39
+
if ($this->option('import')) {
40
+
return $this->handleImport($service, $did);
41
+
}
42
+
43
+
return $this->handleExport($service, $did);
44
+
}
45
+
46
+
protected function handleStatus(ExportService $service, string $did): int
47
+
{
48
+
info("Getting repository status for {$did}...");
49
+
50
+
try {
51
+
$commit = $service->getLatestCommit($did);
52
+
$status = $service->getRepoStatus($did);
53
+
54
+
$this->table(['Property', 'Value'], [
55
+
['DID', $did],
56
+
['Latest CID', $commit['cid'] ?? 'N/A'],
57
+
['Latest Rev', $commit['rev'] ?? 'N/A'],
58
+
['Active', ($status['active'] ?? false) ? 'Yes' : 'No'],
59
+
['Status', $status['status'] ?? 'N/A'],
60
+
]);
61
+
62
+
return self::SUCCESS;
63
+
} catch (\Throwable $e) {
64
+
error("Failed to get status: {$e->getMessage()}");
65
+
66
+
return self::FAILURE;
67
+
}
68
+
}
69
+
70
+
protected function handleExport(ExportService $service, string $did): int
71
+
{
72
+
$output = $this->option('output') ?? "{$did}.car";
73
+
$since = $this->option('since');
74
+
75
+
// Sanitize filename if using DID as filename
76
+
$output = str_replace([':', '/'], ['_', '_'], $output);
77
+
78
+
info("Exporting repository {$did} to {$output}...");
79
+
80
+
$result = $service->exportToFile($did, $output, $since);
81
+
82
+
if ($result->isFailed()) {
83
+
error("Export failed: {$result->error}");
84
+
85
+
return self::FAILURE;
86
+
}
87
+
88
+
$size = $this->formatBytes($result->size);
89
+
info("Exported {$size} to {$output}");
90
+
91
+
return self::SUCCESS;
92
+
}
93
+
94
+
protected function handleImport(ExportService $service, string $did): int
95
+
{
96
+
$collections = $this->option('collection') ?: null;
97
+
$collectionDisplay = $collections ? implode(', ', $collections) : 'all registered';
98
+
99
+
info("Exporting and importing {$did} ({$collectionDisplay})...");
100
+
101
+
$result = $service->exportAndImport(
102
+
$did,
103
+
$collections,
104
+
function (ImportProgress $progress) {
105
+
$this->output->write("\r");
106
+
$this->output->write(" [{$progress->collection}] {$progress->recordsSynced} records synced");
107
+
}
108
+
);
109
+
110
+
$this->output->write("\n");
111
+
112
+
if ($result->isFailed()) {
113
+
error("Import failed: {$result->error}");
114
+
115
+
return self::FAILURE;
116
+
}
117
+
118
+
info("Imported {$result->size} records");
119
+
120
+
return self::SUCCESS;
121
+
}
122
+
123
+
protected function formatBytes(int $bytes): string
124
+
{
125
+
$units = ['B', 'KB', 'MB', 'GB'];
126
+
$unit = 0;
127
+
128
+
while ($bytes >= 1024 && $unit < count($units) - 1) {
129
+
$bytes /= 1024;
130
+
$unit++;
131
+
}
132
+
133
+
return round($bytes, 2).' '.$units[$unit];
134
+
}
135
+
}
+190
src/Commands/ImportCommand.php
+190
src/Commands/ImportCommand.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Commands;
4
+
5
+
use Illuminate\Console\Command;
6
+
use SocialDept\AtpParity\Events\ImportProgress;
7
+
use SocialDept\AtpParity\Import\ImportService;
8
+
use SocialDept\AtpParity\Jobs\ImportUserJob;
9
+
use SocialDept\AtpParity\MapperRegistry;
10
+
11
+
use function Laravel\Prompts\error;
12
+
use function Laravel\Prompts\info;
13
+
use function Laravel\Prompts\note;
14
+
use function Laravel\Prompts\warning;
15
+
16
+
class ImportCommand extends Command
17
+
{
18
+
protected $signature = 'parity:import
19
+
{did? : The DID to import}
20
+
{--collection= : Specific collection to import}
21
+
{--file= : File containing DIDs to import (one per line)}
22
+
{--resume : Resume all interrupted imports}
23
+
{--queue : Queue the import job instead of running synchronously}
24
+
{--progress : Show progress output}';
25
+
26
+
protected $description = 'Import AT Protocol records for a user or from a file of DIDs';
27
+
28
+
public function handle(ImportService $service, MapperRegistry $registry): int
29
+
{
30
+
if ($this->option('resume')) {
31
+
return $this->handleResume($service);
32
+
}
33
+
34
+
$did = $this->argument('did');
35
+
$file = $this->option('file');
36
+
37
+
if (! $did && ! $file) {
38
+
error('Please provide a DID or use --file to specify a file of DIDs');
39
+
40
+
return self::FAILURE;
41
+
}
42
+
43
+
if ($file) {
44
+
return $this->handleFile($file, $service);
45
+
}
46
+
47
+
return $this->importDid($did, $service, $registry);
48
+
}
49
+
50
+
protected function handleResume(ImportService $service): int
51
+
{
52
+
info('Resuming interrupted imports...');
53
+
54
+
$results = $service->resumeAll($this->getProgressCallback());
55
+
56
+
if (empty($results)) {
57
+
note('No interrupted imports found');
58
+
59
+
return self::SUCCESS;
60
+
}
61
+
62
+
$success = 0;
63
+
$failed = 0;
64
+
65
+
foreach ($results as $result) {
66
+
if ($result->isSuccess()) {
67
+
$success++;
68
+
} else {
69
+
$failed++;
70
+
}
71
+
}
72
+
73
+
info("Resumed {$success} imports successfully");
74
+
75
+
if ($failed > 0) {
76
+
warning("{$failed} imports failed");
77
+
}
78
+
79
+
return $failed > 0 ? self::FAILURE : self::SUCCESS;
80
+
}
81
+
82
+
protected function handleFile(string $file, ImportService $service): int
83
+
{
84
+
if (! file_exists($file)) {
85
+
error("File not found: {$file}");
86
+
87
+
return self::FAILURE;
88
+
}
89
+
90
+
$dids = array_filter(array_map('trim', file($file)));
91
+
$total = count($dids);
92
+
$success = 0;
93
+
$failed = 0;
94
+
95
+
info("Importing {$total} DIDs from {$file}");
96
+
97
+
foreach ($dids as $index => $did) {
98
+
if (! str_starts_with($did, 'did:')) {
99
+
warning("Skipping invalid DID: {$did}");
100
+
101
+
continue;
102
+
}
103
+
104
+
$current = $index + 1;
105
+
note("[{$current}/{$total}] Importing {$did}");
106
+
107
+
if ($this->option('queue')) {
108
+
ImportUserJob::dispatch($did, $this->option('collection'));
109
+
$success++;
110
+
} else {
111
+
$result = $service->importUser($did, $this->getCollections(), $this->getProgressCallback());
112
+
113
+
if ($result->isSuccess()) {
114
+
$success++;
115
+
} else {
116
+
$failed++;
117
+
warning("Failed: {$result->error}");
118
+
}
119
+
}
120
+
}
121
+
122
+
info("Completed: {$success} successful, {$failed} failed");
123
+
124
+
return $failed > 0 ? self::FAILURE : self::SUCCESS;
125
+
}
126
+
127
+
protected function importDid(string $did, ImportService $service, MapperRegistry $registry): int
128
+
{
129
+
if (! str_starts_with($did, 'did:')) {
130
+
error("Invalid DID format: {$did}");
131
+
132
+
return self::FAILURE;
133
+
}
134
+
135
+
$collections = $this->getCollections();
136
+
$collectionDisplay = $collections ? implode(', ', $collections) : 'all registered';
137
+
138
+
info("Importing {$did} ({$collectionDisplay})");
139
+
140
+
if ($this->option('queue')) {
141
+
ImportUserJob::dispatch($did, $this->option('collection'));
142
+
note('Import job queued');
143
+
144
+
return self::SUCCESS;
145
+
}
146
+
147
+
$result = $service->importUser($did, $collections, $this->getProgressCallback());
148
+
149
+
if ($result->isSuccess()) {
150
+
info("Import completed: {$result->recordsSynced} records synced");
151
+
152
+
if ($result->recordsSkipped > 0) {
153
+
note("{$result->recordsSkipped} records skipped");
154
+
}
155
+
156
+
if ($result->recordsFailed > 0) {
157
+
warning("{$result->recordsFailed} records failed");
158
+
}
159
+
160
+
return self::SUCCESS;
161
+
}
162
+
163
+
error("Import failed: {$result->error}");
164
+
165
+
if ($result->recordsSynced > 0) {
166
+
note("Partial progress: {$result->recordsSynced} records synced before failure");
167
+
}
168
+
169
+
return self::FAILURE;
170
+
}
171
+
172
+
protected function getCollections(): ?array
173
+
{
174
+
$collection = $this->option('collection');
175
+
176
+
return $collection ? [$collection] : null;
177
+
}
178
+
179
+
protected function getProgressCallback(): ?callable
180
+
{
181
+
if (! $this->option('progress')) {
182
+
return null;
183
+
}
184
+
185
+
return function (ImportProgress $progress) {
186
+
$this->output->write("\r");
187
+
$this->output->write(" [{$progress->collection}] {$progress->recordsSynced} records synced");
188
+
};
189
+
}
190
+
}
+143
src/Commands/ImportStatusCommand.php
+143
src/Commands/ImportStatusCommand.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Commands;
4
+
5
+
use Illuminate\Console\Command;
6
+
use SocialDept\AtpParity\Import\ImportState;
7
+
8
+
use function Laravel\Prompts\info;
9
+
use function Laravel\Prompts\note;
10
+
use function Laravel\Prompts\table;
11
+
use function Laravel\Prompts\warning;
12
+
13
+
class ImportStatusCommand extends Command
14
+
{
15
+
protected $signature = 'parity:import-status
16
+
{did? : Show status for specific DID}
17
+
{--pending : Show only pending/incomplete imports}
18
+
{--failed : Show only failed imports}
19
+
{--completed : Show only completed imports}';
20
+
21
+
protected $description = 'Show import status';
22
+
23
+
public function handle(): int
24
+
{
25
+
$did = $this->argument('did');
26
+
27
+
if ($did) {
28
+
return $this->showDidStatus($did);
29
+
}
30
+
31
+
return $this->showAllStatus();
32
+
}
33
+
34
+
protected function showDidStatus(string $did): int
35
+
{
36
+
$states = ImportState::where('did', $did)->get();
37
+
38
+
if ($states->isEmpty()) {
39
+
note("No import records found for {$did}");
40
+
41
+
return self::SUCCESS;
42
+
}
43
+
44
+
info("Import status for {$did}");
45
+
46
+
table(
47
+
headers: ['Collection', 'Status', 'Synced', 'Skipped', 'Failed', 'Started', 'Completed'],
48
+
rows: $states->map(fn (ImportState $state) => [
49
+
$state->collection,
50
+
$this->formatStatus($state->status),
51
+
$state->records_synced,
52
+
$state->records_skipped,
53
+
$state->records_failed,
54
+
$state->started_at?->diffForHumans() ?? '-',
55
+
$state->completed_at?->diffForHumans() ?? '-',
56
+
])->toArray()
57
+
);
58
+
59
+
return self::SUCCESS;
60
+
}
61
+
62
+
protected function showAllStatus(): int
63
+
{
64
+
$query = ImportState::query();
65
+
66
+
if ($this->option('pending')) {
67
+
$query->incomplete();
68
+
} elseif ($this->option('failed')) {
69
+
$query->failed();
70
+
} elseif ($this->option('completed')) {
71
+
$query->completed();
72
+
}
73
+
74
+
$states = $query->orderByDesc('updated_at')->limit(100)->get();
75
+
76
+
if ($states->isEmpty()) {
77
+
note('No import records found');
78
+
79
+
return self::SUCCESS;
80
+
}
81
+
82
+
$this->displaySummary();
83
+
84
+
table(
85
+
headers: ['DID', 'Collection', 'Status', 'Synced', 'Updated'],
86
+
rows: $states->map(fn (ImportState $state) => [
87
+
$this->truncateDid($state->did),
88
+
$state->collection,
89
+
$this->formatStatus($state->status),
90
+
$state->records_synced,
91
+
$state->updated_at->diffForHumans(),
92
+
])->toArray()
93
+
);
94
+
95
+
if ($states->count() >= 100) {
96
+
note('Showing first 100 results. Use --pending, --failed, or --completed to filter.');
97
+
}
98
+
99
+
return self::SUCCESS;
100
+
}
101
+
102
+
protected function displaySummary(): void
103
+
{
104
+
$counts = ImportState::query()
105
+
->selectRaw('status, count(*) as count')
106
+
->groupBy('status')
107
+
->pluck('count', 'status');
108
+
109
+
$pending = $counts->get('pending', 0);
110
+
$inProgress = $counts->get('in_progress', 0);
111
+
$completed = $counts->get('completed', 0);
112
+
$failed = $counts->get('failed', 0);
113
+
114
+
info("Import Status Summary");
115
+
note("Pending: {$pending} | In Progress: {$inProgress} | Completed: {$completed} | Failed: {$failed}");
116
+
117
+
if ($failed > 0) {
118
+
warning("Use 'php artisan parity:import --resume' to retry failed imports");
119
+
}
120
+
121
+
$this->newLine();
122
+
}
123
+
124
+
protected function formatStatus(string $status): string
125
+
{
126
+
return match ($status) {
127
+
ImportState::STATUS_PENDING => 'pending',
128
+
ImportState::STATUS_IN_PROGRESS => 'running',
129
+
ImportState::STATUS_COMPLETED => 'done',
130
+
ImportState::STATUS_FAILED => 'FAILED',
131
+
default => $status,
132
+
};
133
+
}
134
+
135
+
protected function truncateDid(string $did): string
136
+
{
137
+
if (strlen($did) <= 30) {
138
+
return $did;
139
+
}
140
+
141
+
return substr($did, 0, 15).'...'.substr($did, -12);
142
+
}
143
+
}
+90
src/Concerns/AutoPublish.php
+90
src/Concerns/AutoPublish.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Concerns;
4
+
5
+
use SocialDept\AtpParity\Publish\PublishService;
6
+
7
+
/**
8
+
* Trait for Eloquent models that automatically publish to AT Protocol.
9
+
*
10
+
* This trait sets up model observers to automatically publish, update,
11
+
* and unpublish records when the model is created, updated, or deleted.
12
+
*
13
+
* Override shouldAutoPublish() and shouldAutoUnpublish() to customize
14
+
* the conditions under which auto-publishing occurs.
15
+
*
16
+
* @mixin \Illuminate\Database\Eloquent\Model
17
+
*/
18
+
trait AutoPublish
19
+
{
20
+
use PublishesRecords;
21
+
22
+
/**
23
+
* Boot the AutoPublish trait.
24
+
*/
25
+
public static function bootAutoPublish(): void
26
+
{
27
+
static::created(function ($model) {
28
+
if ($model->shouldAutoPublish()) {
29
+
app(PublishService::class)->publish($model);
30
+
}
31
+
});
32
+
33
+
static::updated(function ($model) {
34
+
if ($model->isPublished() && $model->shouldAutoPublish()) {
35
+
app(PublishService::class)->update($model);
36
+
}
37
+
});
38
+
39
+
static::deleted(function ($model) {
40
+
if ($model->isPublished() && $model->shouldAutoUnpublish()) {
41
+
app(PublishService::class)->delete($model);
42
+
}
43
+
});
44
+
}
45
+
46
+
/**
47
+
* Determine if the model should be auto-published.
48
+
*
49
+
* Override this method to add custom conditions.
50
+
*/
51
+
public function shouldAutoPublish(): bool
52
+
{
53
+
return true;
54
+
}
55
+
56
+
/**
57
+
* Determine if the model should be auto-unpublished when deleted.
58
+
*
59
+
* Override this method to add custom conditions.
60
+
*/
61
+
public function shouldAutoUnpublish(): bool
62
+
{
63
+
return true;
64
+
}
65
+
66
+
/**
67
+
* Get the DID to use for auto-publishing.
68
+
*
69
+
* Override this method to customize DID resolution.
70
+
*/
71
+
public function getAutoPublishDid(): ?string
72
+
{
73
+
// Check for did column
74
+
if (isset($this->did)) {
75
+
return $this->did;
76
+
}
77
+
78
+
// Check for user relationship with did
79
+
if (method_exists($this, 'user') && $this->user?->did) {
80
+
return $this->user->did;
81
+
}
82
+
83
+
// Check for author relationship with did
84
+
if (method_exists($this, 'author') && $this->author?->did) {
85
+
return $this->author->did;
86
+
}
87
+
88
+
return null;
89
+
}
90
+
}
+152
src/Concerns/HasAtpRecord.php
+152
src/Concerns/HasAtpRecord.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Concerns;
4
+
5
+
use SocialDept\AtpParity\Contracts\RecordMapper;
6
+
use SocialDept\AtpParity\MapperRegistry;
7
+
use SocialDept\AtpSchema\Data\Data;
8
+
9
+
/**
10
+
* Trait for Eloquent models that map to AT Protocol records.
11
+
*
12
+
* @mixin \Illuminate\Database\Eloquent\Model
13
+
*/
14
+
trait HasAtpRecord
15
+
{
16
+
/**
17
+
* Get the AT Protocol URI for this model.
18
+
*/
19
+
public function getAtpUri(): ?string
20
+
{
21
+
$column = config('parity.columns.uri', 'atp_uri');
22
+
23
+
return $this->getAttribute($column);
24
+
}
25
+
26
+
/**
27
+
* Get the AT Protocol CID for this model.
28
+
*/
29
+
public function getAtpCid(): ?string
30
+
{
31
+
$column = config('parity.columns.cid', 'atp_cid');
32
+
33
+
return $this->getAttribute($column);
34
+
}
35
+
36
+
/**
37
+
* Get the DID from the AT Protocol URI.
38
+
*/
39
+
public function getAtpDid(): ?string
40
+
{
41
+
$uri = $this->getAtpUri();
42
+
43
+
if (! $uri) {
44
+
return null;
45
+
}
46
+
47
+
// at://did:plc:xxx/app.bsky.feed.post/rkey
48
+
if (preg_match('#^at://([^/]+)/#', $uri, $matches)) {
49
+
return $matches[1];
50
+
}
51
+
52
+
return null;
53
+
}
54
+
55
+
/**
56
+
* Get the collection (lexicon NSID) from the AT Protocol URI.
57
+
*/
58
+
public function getAtpCollection(): ?string
59
+
{
60
+
$uri = $this->getAtpUri();
61
+
62
+
if (! $uri) {
63
+
return null;
64
+
}
65
+
66
+
// at://did:plc:xxx/app.bsky.feed.post/rkey
67
+
if (preg_match('#^at://[^/]+/([^/]+)/#', $uri, $matches)) {
68
+
return $matches[1];
69
+
}
70
+
71
+
return null;
72
+
}
73
+
74
+
/**
75
+
* Get the rkey from the AT Protocol URI.
76
+
*/
77
+
public function getAtpRkey(): ?string
78
+
{
79
+
$uri = $this->getAtpUri();
80
+
81
+
if (! $uri) {
82
+
return null;
83
+
}
84
+
85
+
// at://did:plc:xxx/app.bsky.feed.post/rkey
86
+
if (preg_match('#^at://[^/]+/[^/]+/([^/]+)$#', $uri, $matches)) {
87
+
return $matches[1];
88
+
}
89
+
90
+
return null;
91
+
}
92
+
93
+
/**
94
+
* Check if this model has been synced to AT Protocol.
95
+
*/
96
+
public function hasAtpRecord(): bool
97
+
{
98
+
return $this->getAtpUri() !== null;
99
+
}
100
+
101
+
/**
102
+
* Get the mapper for this model.
103
+
*/
104
+
public function getAtpMapper(): ?RecordMapper
105
+
{
106
+
return app(MapperRegistry::class)->forModel(static::class);
107
+
}
108
+
109
+
/**
110
+
* Convert this model to an AT Protocol record DTO.
111
+
*/
112
+
public function toAtpRecord(): ?Data
113
+
{
114
+
$mapper = $this->getAtpMapper();
115
+
116
+
if (! $mapper) {
117
+
return null;
118
+
}
119
+
120
+
return $mapper->toRecord($this);
121
+
}
122
+
123
+
/**
124
+
* Scope to query models that have been synced to AT Protocol.
125
+
*/
126
+
public function scopeWithAtpRecord($query)
127
+
{
128
+
$column = config('parity.columns.uri', 'atp_uri');
129
+
130
+
return $query->whereNotNull($column);
131
+
}
132
+
133
+
/**
134
+
* Scope to query models that have not been synced to AT Protocol.
135
+
*/
136
+
public function scopeWithoutAtpRecord($query)
137
+
{
138
+
$column = config('parity.columns.uri', 'atp_uri');
139
+
140
+
return $query->whereNull($column);
141
+
}
142
+
143
+
/**
144
+
* Scope to find by AT Protocol URI.
145
+
*/
146
+
public function scopeWhereAtpUri($query, string $uri)
147
+
{
148
+
$column = config('parity.columns.uri', 'atp_uri');
149
+
150
+
return $query->where($column, $uri);
151
+
}
152
+
}
+127
src/Concerns/HasAtpRelationships.php
+127
src/Concerns/HasAtpRelationships.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Concerns;
4
+
5
+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
6
+
use Illuminate\Database\Eloquent\Relations\HasMany;
7
+
8
+
/**
9
+
* Trait for Eloquent models with AT Protocol relationships.
10
+
*
11
+
* Provides helpers for defining relationships based on AT Protocol URI references.
12
+
* Common relationship patterns:
13
+
*
14
+
* - reply.parent -> parent_uri column
15
+
* - reply.root -> root_uri column
16
+
* - embed.record (quote) -> quoted_uri column
17
+
* - like.subject -> subject_uri column
18
+
* - follow.subject -> subject_did column
19
+
* - repost.subject -> subject_uri column
20
+
*
21
+
* @mixin \Illuminate\Database\Eloquent\Model
22
+
*/
23
+
trait HasAtpRelationships
24
+
{
25
+
/**
26
+
* Define an AT Protocol relationship via URI reference.
27
+
*
28
+
* This creates a BelongsTo relationship where the foreign key is an AT Protocol URI
29
+
* stored in the specified column, matched against the related model's atp_uri column.
30
+
*
31
+
* Example:
32
+
* ```php
33
+
* public function parent(): BelongsTo
34
+
* {
35
+
* return $this->atpBelongsTo(Post::class, 'parent_uri');
36
+
* }
37
+
* ```
38
+
*
39
+
* @param class-string<\Illuminate\Database\Eloquent\Model> $related
40
+
*/
41
+
public function atpBelongsTo(string $related, string $uriColumn, ?string $ownerKey = null): BelongsTo
42
+
{
43
+
$ownerKey = $ownerKey ?? config('parity.columns.uri', 'atp_uri');
44
+
45
+
// Create a custom BelongsTo that uses URI matching
46
+
return $this->belongsTo($related, $uriColumn, $ownerKey);
47
+
}
48
+
49
+
/**
50
+
* Define an inverse AT Protocol relationship via URI reference.
51
+
*
52
+
* This creates a HasMany relationship where related models have a column
53
+
* containing this model's AT Protocol URI.
54
+
*
55
+
* Example:
56
+
* ```php
57
+
* public function replies(): HasMany
58
+
* {
59
+
* return $this->atpHasMany(Post::class, 'parent_uri');
60
+
* }
61
+
* ```
62
+
*
63
+
* @param class-string<\Illuminate\Database\Eloquent\Model> $related
64
+
*/
65
+
public function atpHasMany(string $related, string $foreignKey, ?string $localKey = null): HasMany
66
+
{
67
+
$localKey = $localKey ?? config('parity.columns.uri', 'atp_uri');
68
+
69
+
return $this->hasMany($related, $foreignKey, $localKey);
70
+
}
71
+
72
+
/**
73
+
* Define an AT Protocol relationship via DID reference.
74
+
*
75
+
* This creates a BelongsTo relationship where the foreign key is a DID
76
+
* stored in the specified column, matched against a did column on the related model.
77
+
*
78
+
* Example:
79
+
* ```php
80
+
* public function subject(): BelongsTo
81
+
* {
82
+
* return $this->atpBelongsToByDid(User::class, 'subject_did');
83
+
* }
84
+
* ```
85
+
*
86
+
* @param class-string<\Illuminate\Database\Eloquent\Model> $related
87
+
*/
88
+
public function atpBelongsToByDid(string $related, string $didColumn, string $ownerKey = 'did'): BelongsTo
89
+
{
90
+
return $this->belongsTo($related, $didColumn, $ownerKey);
91
+
}
92
+
93
+
/**
94
+
* Define an inverse AT Protocol relationship via DID reference.
95
+
*
96
+
* Example:
97
+
* ```php
98
+
* public function followers(): HasMany
99
+
* {
100
+
* return $this->atpHasManyByDid(Follow::class, 'subject_did');
101
+
* }
102
+
* ```
103
+
*
104
+
* @param class-string<\Illuminate\Database\Eloquent\Model> $related
105
+
*/
106
+
public function atpHasManyByDid(string $related, string $foreignKey, string $localKey = 'did'): HasMany
107
+
{
108
+
return $this->hasMany($related, $foreignKey, $localKey);
109
+
}
110
+
111
+
/**
112
+
* Get a related model by AT Protocol URI.
113
+
*
114
+
* @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass
115
+
* @return \Illuminate\Database\Eloquent\Model|null
116
+
*/
117
+
public function findByAtpUri(string $modelClass, ?string $uri)
118
+
{
119
+
if (! $uri) {
120
+
return null;
121
+
}
122
+
123
+
$column = config('parity.columns.uri', 'atp_uri');
124
+
125
+
return $modelClass::where($column, $uri)->first();
126
+
}
127
+
}
+59
src/Concerns/PublishesRecords.php
+59
src/Concerns/PublishesRecords.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Concerns;
4
+
5
+
use SocialDept\AtpParity\Publish\PublishResult;
6
+
use SocialDept\AtpParity\Publish\PublishService;
7
+
8
+
/**
9
+
* Trait for Eloquent models that can be manually published to AT Protocol.
10
+
*
11
+
* @mixin \Illuminate\Database\Eloquent\Model
12
+
*/
13
+
trait PublishesRecords
14
+
{
15
+
use HasAtpRecord;
16
+
17
+
/**
18
+
* Publish this model to AT Protocol.
19
+
*
20
+
* If the model has a DID association (via did column or relationship),
21
+
* it will be used. Otherwise, use publishAs() to specify the DID.
22
+
*/
23
+
public function publish(): PublishResult
24
+
{
25
+
return app(PublishService::class)->publish($this);
26
+
}
27
+
28
+
/**
29
+
* Publish this model as a specific user.
30
+
*/
31
+
public function publishAs(string $did): PublishResult
32
+
{
33
+
return app(PublishService::class)->publishAs($did, $this);
34
+
}
35
+
36
+
/**
37
+
* Update the published record on AT Protocol.
38
+
*/
39
+
public function republish(): PublishResult
40
+
{
41
+
return app(PublishService::class)->update($this);
42
+
}
43
+
44
+
/**
45
+
* Delete the record from AT Protocol.
46
+
*/
47
+
public function unpublish(): bool
48
+
{
49
+
return app(PublishService::class)->delete($this);
50
+
}
51
+
52
+
/**
53
+
* Check if this model has been published to AT Protocol.
54
+
*/
55
+
public function isPublished(): bool
56
+
{
57
+
return $this->hasAtpRecord();
58
+
}
59
+
}
+96
src/Concerns/SyncsWithAtp.php
+96
src/Concerns/SyncsWithAtp.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Concerns;
4
+
5
+
use SocialDept\AtpSchema\Data\Data;
6
+
7
+
/**
8
+
* Trait for models that sync bidirectionally with AT Protocol.
9
+
*
10
+
* Extends HasAtpRecord with additional sync tracking and conflict handling.
11
+
*
12
+
* @mixin \Illuminate\Database\Eloquent\Model
13
+
*/
14
+
trait SyncsWithAtp
15
+
{
16
+
use HasAtpRecord;
17
+
18
+
/**
19
+
* Get the column name for tracking the last sync timestamp.
20
+
*/
21
+
public function getAtpSyncedAtColumn(): string
22
+
{
23
+
return 'atp_synced_at';
24
+
}
25
+
26
+
/**
27
+
* Get the timestamp of the last sync.
28
+
*/
29
+
public function getAtpSyncedAt(): ?\DateTimeInterface
30
+
{
31
+
$column = $this->getAtpSyncedAtColumn();
32
+
33
+
return $this->getAttribute($column);
34
+
}
35
+
36
+
/**
37
+
* Mark the model as synced with the given metadata.
38
+
*/
39
+
public function markAsSynced(string $uri, string $cid): void
40
+
{
41
+
$uriColumn = config('parity.columns.uri', 'atp_uri');
42
+
$cidColumn = config('parity.columns.cid', 'atp_cid');
43
+
$syncColumn = $this->getAtpSyncedAtColumn();
44
+
45
+
$this->setAttribute($uriColumn, $uri);
46
+
$this->setAttribute($cidColumn, $cid);
47
+
$this->setAttribute($syncColumn, now());
48
+
}
49
+
50
+
/**
51
+
* Check if the model has local changes since last sync.
52
+
*/
53
+
public function hasLocalChanges(): bool
54
+
{
55
+
$syncedAt = $this->getAtpSyncedAt();
56
+
57
+
if (! $syncedAt) {
58
+
return true;
59
+
}
60
+
61
+
$updatedAt = $this->getAttribute('updated_at');
62
+
63
+
if (! $updatedAt) {
64
+
return false;
65
+
}
66
+
67
+
return $updatedAt > $syncedAt;
68
+
}
69
+
70
+
/**
71
+
* Update the model from a remote record.
72
+
*/
73
+
public function updateFromRecord(Data $record, string $uri, string $cid): void
74
+
{
75
+
$mapper = $this->getAtpMapper();
76
+
77
+
if (! $mapper) {
78
+
return;
79
+
}
80
+
81
+
$mapper->updateModel($this, $record, [
82
+
'uri' => $uri,
83
+
'cid' => $cid,
84
+
]);
85
+
86
+
$this->setAttribute($this->getAtpSyncedAtColumn(), now());
87
+
}
88
+
89
+
/**
90
+
* Boot the trait.
91
+
*/
92
+
public static function bootSyncsWithAtp(): void
93
+
{
94
+
// Hook into model events if needed
95
+
}
96
+
}
+82
src/Contracts/RecordMapper.php
+82
src/Contracts/RecordMapper.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Contracts;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use SocialDept\AtpSchema\Data\Data;
7
+
8
+
/**
9
+
* Contract for bidirectional mapping between Record DTOs and Eloquent models.
10
+
*
11
+
* @template TRecord of Data
12
+
* @template TModel of Model
13
+
*/
14
+
interface RecordMapper
15
+
{
16
+
/**
17
+
* Get the Record class this mapper handles.
18
+
*
19
+
* @return class-string<TRecord>
20
+
*/
21
+
public function recordClass(): string;
22
+
23
+
/**
24
+
* Get the Model class this mapper handles.
25
+
*
26
+
* @return class-string<TModel>
27
+
*/
28
+
public function modelClass(): string;
29
+
30
+
/**
31
+
* Get the lexicon NSID this mapper handles.
32
+
*/
33
+
public function lexicon(): string;
34
+
35
+
/**
36
+
* Convert a Record DTO to an Eloquent Model.
37
+
*
38
+
* @param TRecord $record
39
+
* @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta AT Protocol metadata
40
+
* @return TModel
41
+
*/
42
+
public function toModel(Data $record, array $meta = []): Model;
43
+
44
+
/**
45
+
* Convert an Eloquent Model to a Record DTO.
46
+
*
47
+
* @param TModel $model
48
+
* @return TRecord
49
+
*/
50
+
public function toRecord(Model $model): Data;
51
+
52
+
/**
53
+
* Update an existing model with data from a record.
54
+
*
55
+
* @param TModel $model
56
+
* @param TRecord $record
57
+
* @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta
58
+
* @return TModel
59
+
*/
60
+
public function updateModel(Model $model, Data $record, array $meta = []): Model;
61
+
62
+
/**
63
+
* Find or create model from record.
64
+
*
65
+
* @param TRecord $record
66
+
* @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta
67
+
* @return TModel
68
+
*/
69
+
public function upsert(Data $record, array $meta = []): Model;
70
+
71
+
/**
72
+
* Find model by AT Protocol URI.
73
+
*
74
+
* @return TModel|null
75
+
*/
76
+
public function findByUri(string $uri): ?Model;
77
+
78
+
/**
79
+
* Delete model by AT Protocol URI.
80
+
*/
81
+
public function deleteByUri(string $uri): bool;
82
+
}
+25
src/Data/Record.php
+25
src/Data/Record.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Data;
4
+
5
+
use SocialDept\AtpClient\Contracts\Recordable;
6
+
use SocialDept\AtpSchema\Data\Data;
7
+
8
+
/**
9
+
* Base class for custom AT Protocol records.
10
+
*
11
+
* Extends atp-schema's Data for full compatibility with the ecosystem,
12
+
* including union type support, validation, equality, and hashing.
13
+
*
14
+
* Implements Recordable for seamless atp-client integration.
15
+
*/
16
+
abstract class Record extends Data implements Recordable
17
+
{
18
+
/**
19
+
* Get the record type (alias for getLexicon for Recordable interface).
20
+
*/
21
+
public function getType(): string
22
+
{
23
+
return static::getLexicon();
24
+
}
25
+
}
+65
src/Discovery/DiscoveryResult.php
+65
src/Discovery/DiscoveryResult.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Discovery;
4
+
5
+
/**
6
+
* Immutable value object representing the result of a discovery operation.
7
+
*/
8
+
readonly class DiscoveryResult
9
+
{
10
+
public function __construct(
11
+
public bool $success,
12
+
public array $dids = [],
13
+
public int $total = 0,
14
+
public ?string $error = null,
15
+
public bool $incomplete = false,
16
+
) {}
17
+
18
+
/**
19
+
* Check if the discovery operation succeeded.
20
+
*/
21
+
public function isSuccess(): bool
22
+
{
23
+
return $this->success;
24
+
}
25
+
26
+
/**
27
+
* Check if the discovery operation failed.
28
+
*/
29
+
public function isFailed(): bool
30
+
{
31
+
return ! $this->success;
32
+
}
33
+
34
+
/**
35
+
* Check if the discovery was stopped before completion (e.g., limit reached).
36
+
*/
37
+
public function isIncomplete(): bool
38
+
{
39
+
return $this->incomplete;
40
+
}
41
+
42
+
/**
43
+
* Create a successful result.
44
+
*/
45
+
public static function success(array $dids, bool $incomplete = false): self
46
+
{
47
+
return new self(
48
+
success: true,
49
+
dids: $dids,
50
+
total: count($dids),
51
+
incomplete: $incomplete,
52
+
);
53
+
}
54
+
55
+
/**
56
+
* Create a failed result.
57
+
*/
58
+
public static function failed(string $error): self
59
+
{
60
+
return new self(
61
+
success: false,
62
+
error: $error,
63
+
);
64
+
}
65
+
}
+119
src/Discovery/DiscoveryService.php
+119
src/Discovery/DiscoveryService.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Discovery;
4
+
5
+
use BackedEnum;
6
+
use Generator;
7
+
use SocialDept\AtpClient\Facades\Atp;
8
+
use SocialDept\AtpParity\Import\ImportService;
9
+
use Throwable;
10
+
11
+
/**
12
+
* Service for discovering DIDs with records in specific collections.
13
+
*/
14
+
class DiscoveryService
15
+
{
16
+
public function __construct(
17
+
protected ImportService $importService
18
+
) {}
19
+
20
+
/**
21
+
* Discover all DIDs with records in a collection.
22
+
*
23
+
* @return Generator<string> Yields DIDs
24
+
*/
25
+
public function discoverDids(string|BackedEnum $collection, ?int $limit = null): Generator
26
+
{
27
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
28
+
$cursor = null;
29
+
$count = 0;
30
+
31
+
do {
32
+
$response = Atp::atproto->sync->listReposByCollection(
33
+
collection: $collection,
34
+
limit: min(500, $limit ? $limit - $count : 500),
35
+
cursor: $cursor,
36
+
);
37
+
38
+
foreach ($response->repos as $repo) {
39
+
$did = $repo['did'] ?? null;
40
+
41
+
if ($did) {
42
+
yield $did;
43
+
$count++;
44
+
45
+
if ($limit !== null && $count >= $limit) {
46
+
return;
47
+
}
48
+
}
49
+
}
50
+
51
+
$cursor = $response->cursor;
52
+
} while ($cursor !== null);
53
+
}
54
+
55
+
/**
56
+
* Discover DIDs and return as an array.
57
+
*/
58
+
public function discover(string|BackedEnum $collection, ?int $limit = null): DiscoveryResult
59
+
{
60
+
try {
61
+
$dids = iterator_to_array($this->discoverDids($collection, $limit));
62
+
$incomplete = $limit !== null && count($dids) >= $limit;
63
+
64
+
return DiscoveryResult::success($dids, $incomplete);
65
+
} catch (Throwable $e) {
66
+
return DiscoveryResult::failed($e->getMessage());
67
+
}
68
+
}
69
+
70
+
/**
71
+
* Discover and import all users for a collection.
72
+
*/
73
+
public function discoverAndImport(
74
+
string|BackedEnum $collection,
75
+
?int $limit = null,
76
+
?callable $onProgress = null
77
+
): DiscoveryResult {
78
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
79
+
80
+
try {
81
+
$dids = [];
82
+
$count = 0;
83
+
84
+
foreach ($this->discoverDids($collection, $limit) as $did) {
85
+
$dids[] = $did;
86
+
$count++;
87
+
88
+
// Start import for this DID
89
+
$this->importService->import($did, [$collection]);
90
+
91
+
if ($onProgress) {
92
+
$onProgress($did, $count);
93
+
}
94
+
}
95
+
96
+
$incomplete = $limit !== null && count($dids) >= $limit;
97
+
98
+
return DiscoveryResult::success($dids, $incomplete);
99
+
} catch (Throwable $e) {
100
+
return DiscoveryResult::failed($e->getMessage());
101
+
}
102
+
}
103
+
104
+
/**
105
+
* Count total DIDs with records in a collection.
106
+
*
107
+
* Note: This iterates through all results, which can be slow.
108
+
*/
109
+
public function count(string|BackedEnum $collection): int
110
+
{
111
+
$count = 0;
112
+
113
+
foreach ($this->discoverDids($collection) as $_) {
114
+
$count++;
115
+
}
116
+
117
+
return $count;
118
+
}
119
+
}
+23
src/Events/ConflictDetected.php
+23
src/Events/ConflictDetected.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Events;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use Illuminate\Foundation\Events\Dispatchable;
7
+
use SocialDept\AtpParity\Sync\PendingConflict;
8
+
use SocialDept\AtpSchema\Data\Data;
9
+
10
+
/**
11
+
* Dispatched when a conflict is detected that requires manual resolution.
12
+
*/
13
+
class ConflictDetected
14
+
{
15
+
use Dispatchable;
16
+
17
+
public function __construct(
18
+
public readonly Model $model,
19
+
public readonly Data $record,
20
+
public readonly array $meta,
21
+
public readonly PendingConflict $conflict,
22
+
) {}
23
+
}
+15
src/Events/ImportCompleted.php
+15
src/Events/ImportCompleted.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Events;
4
+
5
+
use Illuminate\Foundation\Events\Dispatchable;
6
+
use SocialDept\AtpParity\Import\ImportResult;
7
+
8
+
class ImportCompleted
9
+
{
10
+
use Dispatchable;
11
+
12
+
public function __construct(
13
+
public readonly ImportResult $result,
14
+
) {}
15
+
}
+16
src/Events/ImportFailed.php
+16
src/Events/ImportFailed.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Events;
4
+
5
+
use Illuminate\Foundation\Events\Dispatchable;
6
+
7
+
class ImportFailed
8
+
{
9
+
use Dispatchable;
10
+
11
+
public function __construct(
12
+
public readonly string $did,
13
+
public readonly string $collection,
14
+
public readonly string $error,
15
+
) {}
16
+
}
+17
src/Events/ImportProgress.php
+17
src/Events/ImportProgress.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Events;
4
+
5
+
use Illuminate\Foundation\Events\Dispatchable;
6
+
7
+
class ImportProgress
8
+
{
9
+
use Dispatchable;
10
+
11
+
public function __construct(
12
+
public readonly string $did,
13
+
public readonly string $collection,
14
+
public readonly int $recordsSynced,
15
+
public readonly ?string $cursor = null,
16
+
) {}
17
+
}
+15
src/Events/ImportStarted.php
+15
src/Events/ImportStarted.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Events;
4
+
5
+
use Illuminate\Foundation\Events\Dispatchable;
6
+
7
+
class ImportStarted
8
+
{
9
+
use Dispatchable;
10
+
11
+
public function __construct(
12
+
public readonly string $did,
13
+
public readonly string $collection,
14
+
) {}
15
+
}
+20
src/Events/RecordPublished.php
+20
src/Events/RecordPublished.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Events;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use Illuminate\Foundation\Events\Dispatchable;
7
+
8
+
/**
9
+
* Dispatched when a model is published to AT Protocol.
10
+
*/
11
+
class RecordPublished
12
+
{
13
+
use Dispatchable;
14
+
15
+
public function __construct(
16
+
public readonly Model $model,
17
+
public readonly string $uri,
18
+
public readonly string $cid,
19
+
) {}
20
+
}
+19
src/Events/RecordUnpublished.php
+19
src/Events/RecordUnpublished.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Events;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use Illuminate\Foundation\Events\Dispatchable;
7
+
8
+
/**
9
+
* Dispatched when a model is unpublished from AT Protocol.
10
+
*/
11
+
class RecordUnpublished
12
+
{
13
+
use Dispatchable;
14
+
15
+
public function __construct(
16
+
public readonly Model $model,
17
+
public readonly string $uri,
18
+
) {}
19
+
}
+55
src/Export/ExportResult.php
+55
src/Export/ExportResult.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Export;
4
+
5
+
/**
6
+
* Value object representing the result of an export operation.
7
+
*/
8
+
readonly class ExportResult
9
+
{
10
+
public function __construct(
11
+
public bool $success,
12
+
public ?string $path = null,
13
+
public ?int $size = null,
14
+
public ?string $error = null,
15
+
) {}
16
+
17
+
/**
18
+
* Check if the export operation succeeded.
19
+
*/
20
+
public function isSuccess(): bool
21
+
{
22
+
return $this->success;
23
+
}
24
+
25
+
/**
26
+
* Check if the export operation failed.
27
+
*/
28
+
public function isFailed(): bool
29
+
{
30
+
return ! $this->success;
31
+
}
32
+
33
+
/**
34
+
* Create a successful result.
35
+
*/
36
+
public static function success(string $path, int $size): self
37
+
{
38
+
return new self(
39
+
success: true,
40
+
path: $path,
41
+
size: $size,
42
+
);
43
+
}
44
+
45
+
/**
46
+
* Create a failed result.
47
+
*/
48
+
public static function failed(string $error): self
49
+
{
50
+
return new self(
51
+
success: false,
52
+
error: $error,
53
+
);
54
+
}
55
+
}
+142
src/Export/ExportService.php
+142
src/Export/ExportService.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Export;
4
+
5
+
use BackedEnum;
6
+
use Generator;
7
+
use SocialDept\AtpClient\Facades\Atp;
8
+
use SocialDept\AtpParity\Import\ImportService;
9
+
use SocialDept\AtpParity\MapperRegistry;
10
+
use Throwable;
11
+
12
+
/**
13
+
* Service for exporting AT Protocol repositories.
14
+
*/
15
+
class ExportService
16
+
{
17
+
public function __construct(
18
+
protected MapperRegistry $registry,
19
+
protected ImportService $importService
20
+
) {}
21
+
22
+
/**
23
+
* Download a user's repository as CAR data.
24
+
*/
25
+
public function downloadRepo(string $did, ?string $since = null): RepoExport
26
+
{
27
+
$response = Atp::atproto->sync->getRepo($did, $since);
28
+
$carData = $response->body();
29
+
30
+
return new RepoExport(
31
+
did: $did,
32
+
carData: $carData,
33
+
size: strlen($carData),
34
+
);
35
+
}
36
+
37
+
/**
38
+
* Export a repository to a local file.
39
+
*/
40
+
public function exportToFile(string $did, string $path, ?string $since = null): ExportResult
41
+
{
42
+
try {
43
+
$export = $this->downloadRepo($did, $since);
44
+
45
+
if (! $export->saveTo($path)) {
46
+
return ExportResult::failed("Failed to write to file: {$path}");
47
+
}
48
+
49
+
return ExportResult::success($path, $export->size);
50
+
} catch (Throwable $e) {
51
+
return ExportResult::failed($e->getMessage());
52
+
}
53
+
}
54
+
55
+
/**
56
+
* Export and import records from a repository.
57
+
*
58
+
* This downloads the repository and imports records using the normal import pipeline.
59
+
* It's useful for bulk importing all records from a user.
60
+
*
61
+
* @param array<string>|null $collections Specific collections to import (null = all registered)
62
+
*/
63
+
public function exportAndImport(
64
+
string $did,
65
+
?array $collections = null,
66
+
?callable $onProgress = null
67
+
): ExportResult {
68
+
try {
69
+
// Use the import service to import the user's records
70
+
$result = $this->importService->importUser($did, $collections, $onProgress);
71
+
72
+
if ($result->isFailed()) {
73
+
return ExportResult::failed($result->error ?? 'Import failed');
74
+
}
75
+
76
+
return ExportResult::success(
77
+
path: "imported:{$did}",
78
+
size: $result->recordsSynced
79
+
);
80
+
} catch (Throwable $e) {
81
+
return ExportResult::failed($e->getMessage());
82
+
}
83
+
}
84
+
85
+
/**
86
+
* List available blobs for a repository.
87
+
*
88
+
* @return Generator<string> Yields blob CIDs
89
+
*/
90
+
public function listBlobs(string $did, ?string $since = null): Generator
91
+
{
92
+
$cursor = null;
93
+
94
+
do {
95
+
$response = Atp::atproto->sync->listBlobs(
96
+
did: $did,
97
+
since: $since,
98
+
limit: 500,
99
+
cursor: $cursor,
100
+
);
101
+
102
+
foreach ($response->cids as $cid) {
103
+
yield $cid;
104
+
}
105
+
106
+
$cursor = $response->cursor;
107
+
} while ($cursor !== null);
108
+
}
109
+
110
+
/**
111
+
* Download a specific blob.
112
+
*/
113
+
public function downloadBlob(string $did, string $cid): string
114
+
{
115
+
$response = Atp::atproto->sync->getBlob($did, $cid);
116
+
117
+
return $response->body();
118
+
}
119
+
120
+
/**
121
+
* Get the latest commit for a repository.
122
+
*/
123
+
public function getLatestCommit(string $did): array
124
+
{
125
+
$commit = Atp::atproto->sync->getLatestCommit($did);
126
+
127
+
return [
128
+
'cid' => $commit->cid,
129
+
'rev' => $commit->rev,
130
+
];
131
+
}
132
+
133
+
/**
134
+
* Get the hosting status for a repository.
135
+
*/
136
+
public function getRepoStatus(string $did): array
137
+
{
138
+
$status = Atp::atproto->sync->getRepoStatus($did);
139
+
140
+
return $status->toArray();
141
+
}
142
+
}
+40
src/Export/RepoExport.php
+40
src/Export/RepoExport.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Export;
4
+
5
+
/**
6
+
* Value object representing an exported repository as CAR data.
7
+
*/
8
+
readonly class RepoExport
9
+
{
10
+
public function __construct(
11
+
public string $did,
12
+
public string $carData,
13
+
public int $size,
14
+
) {}
15
+
16
+
/**
17
+
* Save the CAR data to a file.
18
+
*/
19
+
public function saveTo(string $path): bool
20
+
{
21
+
return file_put_contents($path, $this->carData) !== false;
22
+
}
23
+
24
+
/**
25
+
* Get the size in human-readable format.
26
+
*/
27
+
public function humanSize(): string
28
+
{
29
+
$units = ['B', 'KB', 'MB', 'GB'];
30
+
$size = $this->size;
31
+
$unit = 0;
32
+
33
+
while ($size >= 1024 && $unit < count($units) - 1) {
34
+
$size /= 1024;
35
+
$unit++;
36
+
}
37
+
38
+
return round($size, 2).' '.$units[$unit];
39
+
}
40
+
}
-18
src/Facades/AtpReplicator.php
-18
src/Facades/AtpReplicator.php
···
1
-
<?php
2
-
3
-
namespace SocialDept\AtpReplicator\Facades;
4
-
5
-
use Illuminate\Support\Facades\Facade;
6
-
7
-
class AtpReplicator extends Facade
8
-
{
9
-
/**
10
-
* Get the registered name of the component.
11
-
*
12
-
* @return string
13
-
*/
14
-
protected static function getFacadeAccessor(): string
15
-
{
16
-
return 'atp-replicator';
17
-
}
18
-
}
+23
src/Facades/Parity.php
+23
src/Facades/Parity.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Facades;
4
+
5
+
use Illuminate\Support\Facades\Facade;
6
+
use SocialDept\AtpParity\Contracts\RecordMapper;
7
+
use SocialDept\AtpParity\MapperRegistry;
8
+
9
+
/**
10
+
* @method static void register(RecordMapper $mapper)
11
+
* @method static RecordMapper|null forRecord(string $recordClass)
12
+
* @method static RecordMapper|null forModel(string $modelClass)
13
+
* @method static RecordMapper|null forLexicon(string $nsid)
14
+
*
15
+
* @see MapperRegistry
16
+
*/
17
+
class Parity extends Facade
18
+
{
19
+
protected static function getFacadeAccessor(): string
20
+
{
21
+
return 'parity';
22
+
}
23
+
}
+138
src/Import/ImportResult.php
+138
src/Import/ImportResult.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Import;
4
+
5
+
/**
6
+
* Immutable value object representing the result of an import operation.
7
+
*/
8
+
readonly class ImportResult
9
+
{
10
+
public function __construct(
11
+
public string $did,
12
+
public string $collection,
13
+
public int $recordsSynced,
14
+
public int $recordsSkipped,
15
+
public int $recordsFailed,
16
+
public bool $completed,
17
+
public ?string $cursor = null,
18
+
public ?string $error = null,
19
+
) {}
20
+
21
+
/**
22
+
* Check if the import completed successfully.
23
+
*/
24
+
public function isSuccess(): bool
25
+
{
26
+
return $this->completed && $this->error === null;
27
+
}
28
+
29
+
/**
30
+
* Check if the import was partially completed.
31
+
*/
32
+
public function isPartial(): bool
33
+
{
34
+
return ! $this->completed && $this->recordsSynced > 0;
35
+
}
36
+
37
+
/**
38
+
* Check if the import failed.
39
+
*/
40
+
public function isFailed(): bool
41
+
{
42
+
return $this->error !== null;
43
+
}
44
+
45
+
/**
46
+
* Get total records processed.
47
+
*/
48
+
public function totalProcessed(): int
49
+
{
50
+
return $this->recordsSynced + $this->recordsSkipped + $this->recordsFailed;
51
+
}
52
+
53
+
/**
54
+
* Create a successful result.
55
+
*/
56
+
public static function success(string $did, string $collection, int $synced, int $skipped = 0, int $failed = 0): self
57
+
{
58
+
return new self(
59
+
did: $did,
60
+
collection: $collection,
61
+
recordsSynced: $synced,
62
+
recordsSkipped: $skipped,
63
+
recordsFailed: $failed,
64
+
completed: true,
65
+
);
66
+
}
67
+
68
+
/**
69
+
* Create a partial result (incomplete).
70
+
*/
71
+
public static function partial(string $did, string $collection, int $synced, string $cursor, int $skipped = 0, int $failed = 0): self
72
+
{
73
+
return new self(
74
+
did: $did,
75
+
collection: $collection,
76
+
recordsSynced: $synced,
77
+
recordsSkipped: $skipped,
78
+
recordsFailed: $failed,
79
+
completed: false,
80
+
cursor: $cursor,
81
+
);
82
+
}
83
+
84
+
/**
85
+
* Create a failed result.
86
+
*/
87
+
public static function failed(string $did, string $collection, string $error, int $synced = 0, int $skipped = 0, int $failed = 0, ?string $cursor = null): self
88
+
{
89
+
return new self(
90
+
did: $did,
91
+
collection: $collection,
92
+
recordsSynced: $synced,
93
+
recordsSkipped: $skipped,
94
+
recordsFailed: $failed,
95
+
completed: false,
96
+
cursor: $cursor,
97
+
error: $error,
98
+
);
99
+
}
100
+
101
+
/**
102
+
* Merge multiple results for the same DID into one aggregate result.
103
+
*
104
+
* @param ImportResult[] $results
105
+
*/
106
+
public static function aggregate(string $did, array $results): self
107
+
{
108
+
$synced = 0;
109
+
$skipped = 0;
110
+
$failed = 0;
111
+
$errors = [];
112
+
$allCompleted = true;
113
+
114
+
foreach ($results as $result) {
115
+
$synced += $result->recordsSynced;
116
+
$skipped += $result->recordsSkipped;
117
+
$failed += $result->recordsFailed;
118
+
119
+
if (! $result->completed) {
120
+
$allCompleted = false;
121
+
}
122
+
123
+
if ($result->error) {
124
+
$errors[] = "{$result->collection}: {$result->error}";
125
+
}
126
+
}
127
+
128
+
return new self(
129
+
did: $did,
130
+
collection: '*',
131
+
recordsSynced: $synced,
132
+
recordsSkipped: $skipped,
133
+
recordsFailed: $failed,
134
+
completed: $allCompleted,
135
+
error: $errors ? implode('; ', $errors) : null,
136
+
);
137
+
}
138
+
}
+253
src/Import/ImportService.php
+253
src/Import/ImportService.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Import;
4
+
5
+
use SocialDept\AtpClient\AtpClient;
6
+
use SocialDept\AtpClient\Facades\Atp;
7
+
use SocialDept\AtpParity\Events\ImportCompleted;
8
+
use SocialDept\AtpParity\Events\ImportFailed;
9
+
use SocialDept\AtpParity\Events\ImportProgress;
10
+
use SocialDept\AtpParity\Events\ImportStarted;
11
+
use SocialDept\AtpParity\MapperRegistry;
12
+
use SocialDept\AtpResolver\Facades\Resolver;
13
+
use Throwable;
14
+
15
+
/**
16
+
* Orchestrates importing of AT Protocol records to Eloquent models.
17
+
*
18
+
* Supports importing individual users, specific collections, or entire
19
+
* networks through cursor-based pagination with progress tracking.
20
+
*/
21
+
class ImportService
22
+
{
23
+
/**
24
+
* Cache of clients by PDS endpoint.
25
+
*
26
+
* @var array<string, AtpClient>
27
+
*/
28
+
protected array $clients = [];
29
+
30
+
public function __construct(
31
+
protected MapperRegistry $registry
32
+
) {}
33
+
34
+
/**
35
+
* Import all records for a user in registered collections.
36
+
*
37
+
* @param array<string>|null $collections Specific collections to import, or null for all registered
38
+
*/
39
+
public function importUser(string $did, ?array $collections = null, ?callable $onProgress = null): ImportResult
40
+
{
41
+
$collections = $collections ?? $this->registry->lexicons();
42
+
$results = [];
43
+
44
+
foreach ($collections as $collection) {
45
+
if (! $this->registry->hasLexicon($collection)) {
46
+
continue;
47
+
}
48
+
49
+
$results[] = $this->importUserCollection($did, $collection, $onProgress);
50
+
}
51
+
52
+
return ImportResult::aggregate($did, $results);
53
+
}
54
+
55
+
/**
56
+
* Import a specific collection for a user.
57
+
*/
58
+
public function importUserCollection(string $did, string $collection, ?callable $onProgress = null): ImportResult
59
+
{
60
+
$mapper = $this->registry->forLexicon($collection);
61
+
62
+
if (! $mapper) {
63
+
return ImportResult::failed($did, $collection, "No mapper registered for collection: {$collection}");
64
+
}
65
+
66
+
$state = ImportState::findOrCreateFor($did, $collection);
67
+
68
+
if ($state->isComplete()) {
69
+
return $state->toResult();
70
+
}
71
+
72
+
$pdsEndpoint = $this->resolvePds($did);
73
+
74
+
if (! $pdsEndpoint) {
75
+
$error = "Could not resolve PDS endpoint for DID: {$did}";
76
+
$state->markFailed($error);
77
+
event(new ImportFailed($did, $collection, $error));
78
+
79
+
return ImportResult::failed($did, $collection, $error);
80
+
}
81
+
82
+
$state->markStarted();
83
+
event(new ImportStarted($did, $collection));
84
+
85
+
$client = $this->clientFor($pdsEndpoint);
86
+
$cursor = $state->cursor;
87
+
$pageSize = config('parity.import.page_size', 100);
88
+
$pageDelay = config('parity.import.page_delay', 100);
89
+
$recordClass = $mapper->recordClass();
90
+
91
+
try {
92
+
do {
93
+
$response = $client->atproto->repo->listRecords(
94
+
repo: $did,
95
+
collection: $collection,
96
+
limit: $pageSize,
97
+
cursor: $cursor
98
+
);
99
+
100
+
$synced = 0;
101
+
$skipped = 0;
102
+
$failed = 0;
103
+
104
+
foreach ($response->records as $item) {
105
+
try {
106
+
$record = $recordClass::fromArray($item['value']);
107
+
108
+
$mapper->upsert($record, [
109
+
'uri' => $item['uri'],
110
+
'cid' => $item['cid'],
111
+
]);
112
+
113
+
$synced++;
114
+
} catch (Throwable $e) {
115
+
$failed++;
116
+
}
117
+
}
118
+
119
+
$cursor = $response->cursor;
120
+
$state->updateProgress($synced, $skipped, $failed, $cursor);
121
+
122
+
if ($onProgress) {
123
+
$onProgress(new ImportProgress(
124
+
did: $did,
125
+
collection: $collection,
126
+
recordsSynced: $state->records_synced,
127
+
cursor: $cursor
128
+
));
129
+
}
130
+
131
+
event(new ImportProgress($did, $collection, $state->records_synced, $cursor));
132
+
133
+
if ($cursor && $pageDelay > 0) {
134
+
usleep($pageDelay * 1000);
135
+
}
136
+
} while ($cursor);
137
+
138
+
$state->markCompleted();
139
+
$result = $state->toResult();
140
+
event(new ImportCompleted($result));
141
+
142
+
return $result;
143
+
} catch (Throwable $e) {
144
+
$error = $e->getMessage();
145
+
$state->markFailed($error);
146
+
event(new ImportFailed($did, $collection, $error));
147
+
148
+
return ImportResult::failed(
149
+
did: $did,
150
+
collection: $collection,
151
+
error: $error,
152
+
synced: $state->records_synced,
153
+
skipped: $state->records_skipped,
154
+
failed: $state->records_failed,
155
+
cursor: $state->cursor
156
+
);
157
+
}
158
+
}
159
+
160
+
/**
161
+
* Resume an interrupted import from cursor.
162
+
*/
163
+
public function resume(ImportState $state, ?callable $onProgress = null): ImportResult
164
+
{
165
+
if (! $state->canResume()) {
166
+
return $state->toResult();
167
+
}
168
+
169
+
$state->update(['status' => ImportState::STATUS_PENDING]);
170
+
171
+
return $this->importUserCollection($state->did, $state->collection, $onProgress);
172
+
}
173
+
174
+
/**
175
+
* Resume all interrupted imports.
176
+
*
177
+
* @return array<ImportResult>
178
+
*/
179
+
public function resumeAll(?callable $onProgress = null): array
180
+
{
181
+
$results = [];
182
+
183
+
ImportState::resumable()->each(function (ImportState $state) use (&$results, $onProgress) {
184
+
$results[] = $this->resume($state, $onProgress);
185
+
});
186
+
187
+
return $results;
188
+
}
189
+
190
+
/**
191
+
* Get import status for a DID/collection.
192
+
*/
193
+
public function getStatus(string $did, string $collection): ?ImportState
194
+
{
195
+
return ImportState::where('did', $did)
196
+
->where('collection', $collection)
197
+
->first();
198
+
}
199
+
200
+
/**
201
+
* Get all import states for a DID.
202
+
*
203
+
* @return \Illuminate\Database\Eloquent\Collection<int, ImportState>
204
+
*/
205
+
public function getStatusForUser(string $did): \Illuminate\Database\Eloquent\Collection
206
+
{
207
+
return ImportState::where('did', $did)->get();
208
+
}
209
+
210
+
/**
211
+
* Check if a user's collection has been imported.
212
+
*/
213
+
public function isImported(string $did, string $collection): bool
214
+
{
215
+
$state = $this->getStatus($did, $collection);
216
+
217
+
return $state && $state->isComplete();
218
+
}
219
+
220
+
/**
221
+
* Reset an import state to allow re-importing.
222
+
*/
223
+
public function reset(string $did, string $collection): void
224
+
{
225
+
ImportState::where('did', $did)
226
+
->where('collection', $collection)
227
+
->delete();
228
+
}
229
+
230
+
/**
231
+
* Reset all import states for a user.
232
+
*/
233
+
public function resetUser(string $did): void
234
+
{
235
+
ImportState::where('did', $did)->delete();
236
+
}
237
+
238
+
/**
239
+
* Get or create a client for a PDS endpoint.
240
+
*/
241
+
protected function clientFor(string $pdsEndpoint): AtpClient
242
+
{
243
+
return $this->clients[$pdsEndpoint] ??= Atp::public($pdsEndpoint);
244
+
}
245
+
246
+
/**
247
+
* Resolve the PDS endpoint for a DID.
248
+
*/
249
+
protected function resolvePds(string $did): ?string
250
+
{
251
+
return Resolver::resolvePds($did);
252
+
}
253
+
}
+231
src/Import/ImportState.php
+231
src/Import/ImportState.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Import;
4
+
5
+
use Illuminate\Database\Eloquent\Builder;
6
+
use Illuminate\Database\Eloquent\Model;
7
+
8
+
/**
9
+
* Tracks import progress for a DID/collection pair.
10
+
*
11
+
* @property int $id
12
+
* @property string $did
13
+
* @property string $collection
14
+
* @property string $status
15
+
* @property string|null $cursor
16
+
* @property int $records_synced
17
+
* @property int $records_skipped
18
+
* @property int $records_failed
19
+
* @property \Carbon\Carbon|null $started_at
20
+
* @property \Carbon\Carbon|null $completed_at
21
+
* @property string|null $error
22
+
* @property \Carbon\Carbon $created_at
23
+
* @property \Carbon\Carbon $updated_at
24
+
*/
25
+
class ImportState extends Model
26
+
{
27
+
public const STATUS_PENDING = 'pending';
28
+
29
+
public const STATUS_IN_PROGRESS = 'in_progress';
30
+
31
+
public const STATUS_COMPLETED = 'completed';
32
+
33
+
public const STATUS_FAILED = 'failed';
34
+
35
+
protected $fillable = [
36
+
'did',
37
+
'collection',
38
+
'status',
39
+
'cursor',
40
+
'records_synced',
41
+
'records_skipped',
42
+
'records_failed',
43
+
'started_at',
44
+
'completed_at',
45
+
'error',
46
+
];
47
+
48
+
protected $casts = [
49
+
'records_synced' => 'integer',
50
+
'records_skipped' => 'integer',
51
+
'records_failed' => 'integer',
52
+
'started_at' => 'datetime',
53
+
'completed_at' => 'datetime',
54
+
];
55
+
56
+
public function getTable(): string
57
+
{
58
+
return config('parity.import.state_table', 'parity_import_states');
59
+
}
60
+
61
+
/**
62
+
* Start the import process for this state.
63
+
*/
64
+
public function markStarted(): self
65
+
{
66
+
$this->update([
67
+
'status' => self::STATUS_IN_PROGRESS,
68
+
'started_at' => now(),
69
+
'error' => null,
70
+
]);
71
+
72
+
return $this;
73
+
}
74
+
75
+
/**
76
+
* Mark the import as completed.
77
+
*/
78
+
public function markCompleted(): self
79
+
{
80
+
$this->update([
81
+
'status' => self::STATUS_COMPLETED,
82
+
'completed_at' => now(),
83
+
'cursor' => null,
84
+
]);
85
+
86
+
return $this;
87
+
}
88
+
89
+
/**
90
+
* Mark the import as failed.
91
+
*/
92
+
public function markFailed(string $error): self
93
+
{
94
+
$this->update([
95
+
'status' => self::STATUS_FAILED,
96
+
'error' => $error,
97
+
]);
98
+
99
+
return $this;
100
+
}
101
+
102
+
/**
103
+
* Update progress during import.
104
+
*/
105
+
public function updateProgress(int $synced, int $skipped = 0, int $failed = 0, ?string $cursor = null): self
106
+
{
107
+
$this->increment('records_synced', $synced);
108
+
109
+
if ($skipped > 0) {
110
+
$this->increment('records_skipped', $skipped);
111
+
}
112
+
113
+
if ($failed > 0) {
114
+
$this->increment('records_failed', $failed);
115
+
}
116
+
117
+
if ($cursor !== null) {
118
+
$this->update(['cursor' => $cursor]);
119
+
}
120
+
121
+
return $this;
122
+
}
123
+
124
+
/**
125
+
* Check if this import can be resumed.
126
+
*/
127
+
public function canResume(): bool
128
+
{
129
+
return $this->status === self::STATUS_IN_PROGRESS
130
+
|| $this->status === self::STATUS_FAILED;
131
+
}
132
+
133
+
/**
134
+
* Check if this import is complete.
135
+
*/
136
+
public function isComplete(): bool
137
+
{
138
+
return $this->status === self::STATUS_COMPLETED;
139
+
}
140
+
141
+
/**
142
+
* Check if this import is currently running.
143
+
*/
144
+
public function isRunning(): bool
145
+
{
146
+
return $this->status === self::STATUS_IN_PROGRESS;
147
+
}
148
+
149
+
/**
150
+
* Scope to pending imports.
151
+
*/
152
+
public function scopePending(Builder $query): Builder
153
+
{
154
+
return $query->where('status', self::STATUS_PENDING);
155
+
}
156
+
157
+
/**
158
+
* Scope to in-progress imports.
159
+
*/
160
+
public function scopeInProgress(Builder $query): Builder
161
+
{
162
+
return $query->where('status', self::STATUS_IN_PROGRESS);
163
+
}
164
+
165
+
/**
166
+
* Scope to completed imports.
167
+
*/
168
+
public function scopeCompleted(Builder $query): Builder
169
+
{
170
+
return $query->where('status', self::STATUS_COMPLETED);
171
+
}
172
+
173
+
/**
174
+
* Scope to failed imports.
175
+
*/
176
+
public function scopeFailed(Builder $query): Builder
177
+
{
178
+
return $query->where('status', self::STATUS_FAILED);
179
+
}
180
+
181
+
/**
182
+
* Scope to incomplete imports (pending, in_progress, or failed).
183
+
*/
184
+
public function scopeIncomplete(Builder $query): Builder
185
+
{
186
+
return $query->whereIn('status', [
187
+
self::STATUS_PENDING,
188
+
self::STATUS_IN_PROGRESS,
189
+
self::STATUS_FAILED,
190
+
]);
191
+
}
192
+
193
+
/**
194
+
* Scope to resumable imports (in_progress or failed with cursor).
195
+
*/
196
+
public function scopeResumable(Builder $query): Builder
197
+
{
198
+
return $query->whereIn('status', [
199
+
self::STATUS_IN_PROGRESS,
200
+
self::STATUS_FAILED,
201
+
]);
202
+
}
203
+
204
+
/**
205
+
* Find or create an import state for a DID/collection pair.
206
+
*/
207
+
public static function findOrCreateFor(string $did, string $collection): self
208
+
{
209
+
return static::firstOrCreate(
210
+
['did' => $did, 'collection' => $collection],
211
+
['status' => self::STATUS_PENDING]
212
+
);
213
+
}
214
+
215
+
/**
216
+
* Convert to ImportResult.
217
+
*/
218
+
public function toResult(): ImportResult
219
+
{
220
+
return new ImportResult(
221
+
did: $this->did,
222
+
collection: $this->collection,
223
+
recordsSynced: $this->records_synced,
224
+
recordsSkipped: $this->records_skipped,
225
+
recordsFailed: $this->records_failed,
226
+
completed: $this->isComplete(),
227
+
cursor: $this->cursor,
228
+
error: $this->error,
229
+
);
230
+
}
231
+
}
+57
src/Jobs/ImportUserJob.php
+57
src/Jobs/ImportUserJob.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Jobs;
4
+
5
+
use Illuminate\Bus\Queueable;
6
+
use Illuminate\Contracts\Queue\ShouldQueue;
7
+
use Illuminate\Foundation\Bus\Dispatchable;
8
+
use Illuminate\Queue\InteractsWithQueue;
9
+
use Illuminate\Queue\SerializesModels;
10
+
use SocialDept\AtpParity\Import\ImportService;
11
+
12
+
class ImportUserJob implements ShouldQueue
13
+
{
14
+
use Dispatchable;
15
+
use InteractsWithQueue;
16
+
use Queueable;
17
+
use SerializesModels;
18
+
19
+
/**
20
+
* The number of times the job may be attempted.
21
+
*/
22
+
public int $tries = 3;
23
+
24
+
/**
25
+
* The number of seconds to wait before retrying.
26
+
*/
27
+
public int $backoff = 60;
28
+
29
+
public function __construct(
30
+
public string $did,
31
+
public ?string $collection = null,
32
+
) {
33
+
$this->onQueue(config('parity.import.queue', 'default'));
34
+
}
35
+
36
+
public function handle(ImportService $service): void
37
+
{
38
+
$collections = $this->collection ? [$this->collection] : null;
39
+
$service->importUser($this->did, $collections);
40
+
}
41
+
42
+
/**
43
+
* Get the tags that should be assigned to the job.
44
+
*
45
+
* @return array<string>
46
+
*/
47
+
public function tags(): array
48
+
{
49
+
$tags = ['parity-import', "did:{$this->did}"];
50
+
51
+
if ($this->collection) {
52
+
$tags[] = "collection:{$this->collection}";
53
+
}
54
+
55
+
return $tags;
56
+
}
57
+
}
+93
src/MapperRegistry.php
+93
src/MapperRegistry.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use SocialDept\AtpParity\Contracts\RecordMapper;
7
+
use SocialDept\AtpSchema\Data\Data;
8
+
9
+
/**
10
+
* Registry for RecordMapper instances.
11
+
*
12
+
* Allows looking up mappers by Record class, Model class, or lexicon NSID.
13
+
*/
14
+
class MapperRegistry
15
+
{
16
+
/** @var array<class-string<Data>, RecordMapper> */
17
+
protected array $byRecord = [];
18
+
19
+
/** @var array<class-string<Model>, RecordMapper> */
20
+
protected array $byModel = [];
21
+
22
+
/** @var array<string, RecordMapper> Keyed by NSID */
23
+
protected array $byLexicon = [];
24
+
25
+
/**
26
+
* Register a mapper.
27
+
*/
28
+
public function register(RecordMapper $mapper): void
29
+
{
30
+
$recordClass = $mapper->recordClass();
31
+
$modelClass = $mapper->modelClass();
32
+
33
+
$this->byRecord[$recordClass] = $mapper;
34
+
$this->byModel[$modelClass] = $mapper;
35
+
$this->byLexicon[$mapper->lexicon()] = $mapper;
36
+
}
37
+
38
+
/**
39
+
* Get a mapper by Record class.
40
+
*
41
+
* @param class-string<Data> $recordClass
42
+
*/
43
+
public function forRecord(string $recordClass): ?RecordMapper
44
+
{
45
+
return $this->byRecord[$recordClass] ?? null;
46
+
}
47
+
48
+
/**
49
+
* Get a mapper by Model class.
50
+
*
51
+
* @param class-string<Model> $modelClass
52
+
*/
53
+
public function forModel(string $modelClass): ?RecordMapper
54
+
{
55
+
return $this->byModel[$modelClass] ?? null;
56
+
}
57
+
58
+
/**
59
+
* Get a mapper by lexicon NSID.
60
+
*/
61
+
public function forLexicon(string $nsid): ?RecordMapper
62
+
{
63
+
return $this->byLexicon[$nsid] ?? null;
64
+
}
65
+
66
+
/**
67
+
* Check if a mapper exists for the given lexicon.
68
+
*/
69
+
public function hasLexicon(string $nsid): bool
70
+
{
71
+
return isset($this->byLexicon[$nsid]);
72
+
}
73
+
74
+
/**
75
+
* Get all registered lexicon NSIDs.
76
+
*
77
+
* @return array<string>
78
+
*/
79
+
public function lexicons(): array
80
+
{
81
+
return array_keys($this->byLexicon);
82
+
}
83
+
84
+
/**
85
+
* Get all registered mappers.
86
+
*
87
+
* @return array<RecordMapper>
88
+
*/
89
+
public function all(): array
90
+
{
91
+
return array_values($this->byLexicon);
92
+
}
93
+
}
+102
src/ParityServiceProvider.php
+102
src/ParityServiceProvider.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity;
4
+
5
+
use Illuminate\Support\ServiceProvider;
6
+
use SocialDept\AtpParity\Commands\DiscoverCommand;
7
+
use SocialDept\AtpParity\Commands\ExportCommand;
8
+
use SocialDept\AtpParity\Commands\ImportCommand;
9
+
use SocialDept\AtpParity\Commands\ImportStatusCommand;
10
+
use SocialDept\AtpParity\Discovery\DiscoveryService;
11
+
use SocialDept\AtpParity\Export\ExportService;
12
+
use SocialDept\AtpParity\Import\ImportService;
13
+
use SocialDept\AtpParity\Publish\PublishService;
14
+
use SocialDept\AtpParity\Support\RecordHelper;
15
+
16
+
class ParityServiceProvider extends ServiceProvider
17
+
{
18
+
public function boot(): void
19
+
{
20
+
$this->registerConfiguredMappers();
21
+
22
+
if ($this->app->runningInConsole()) {
23
+
$this->bootForConsole();
24
+
}
25
+
}
26
+
27
+
public function register(): void
28
+
{
29
+
$this->mergeConfigFrom(__DIR__.'/../config/parity.php', 'parity');
30
+
31
+
$this->app->singleton(MapperRegistry::class);
32
+
$this->app->alias(MapperRegistry::class, 'parity');
33
+
34
+
$this->app->singleton(RecordHelper::class, function ($app) {
35
+
return new RecordHelper($app->make(MapperRegistry::class));
36
+
});
37
+
38
+
$this->app->singleton(ImportService::class, function ($app) {
39
+
return new ImportService($app->make(MapperRegistry::class));
40
+
});
41
+
42
+
$this->app->singleton(PublishService::class, function ($app) {
43
+
return new PublishService($app->make(MapperRegistry::class));
44
+
});
45
+
46
+
$this->app->singleton(DiscoveryService::class, function ($app) {
47
+
return new DiscoveryService($app->make(ImportService::class));
48
+
});
49
+
50
+
$this->app->singleton(ExportService::class, function ($app) {
51
+
return new ExportService(
52
+
$app->make(MapperRegistry::class),
53
+
$app->make(ImportService::class)
54
+
);
55
+
});
56
+
}
57
+
58
+
/**
59
+
* Register mappers defined in config.
60
+
*/
61
+
protected function registerConfiguredMappers(): void
62
+
{
63
+
$registry = $this->app->make(MapperRegistry::class);
64
+
65
+
foreach (config('parity.mappers', []) as $mapperClass) {
66
+
if (class_exists($mapperClass)) {
67
+
$registry->register($this->app->make($mapperClass));
68
+
}
69
+
}
70
+
}
71
+
72
+
protected function bootForConsole(): void
73
+
{
74
+
$this->publishes([
75
+
__DIR__.'/../config/parity.php' => config_path('parity.php'),
76
+
], 'parity-config');
77
+
78
+
$this->publishes([
79
+
__DIR__.'/../database/migrations' => database_path('migrations'),
80
+
], 'parity-migrations');
81
+
82
+
$this->commands([
83
+
DiscoverCommand::class,
84
+
ExportCommand::class,
85
+
ImportCommand::class,
86
+
ImportStatusCommand::class,
87
+
]);
88
+
}
89
+
90
+
public function provides(): array
91
+
{
92
+
return [
93
+
'parity',
94
+
MapperRegistry::class,
95
+
RecordHelper::class,
96
+
ImportService::class,
97
+
PublishService::class,
98
+
DiscoveryService::class,
99
+
ExportService::class,
100
+
];
101
+
}
102
+
}
+55
src/Publish/PublishResult.php
+55
src/Publish/PublishResult.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Publish;
4
+
5
+
/**
6
+
* Immutable value object representing the result of a publish operation.
7
+
*/
8
+
readonly class PublishResult
9
+
{
10
+
public function __construct(
11
+
public bool $success,
12
+
public ?string $uri = null,
13
+
public ?string $cid = null,
14
+
public ?string $error = null,
15
+
) {}
16
+
17
+
/**
18
+
* Check if the publish operation succeeded.
19
+
*/
20
+
public function isSuccess(): bool
21
+
{
22
+
return $this->success;
23
+
}
24
+
25
+
/**
26
+
* Check if the publish operation failed.
27
+
*/
28
+
public function isFailed(): bool
29
+
{
30
+
return ! $this->success;
31
+
}
32
+
33
+
/**
34
+
* Create a successful result.
35
+
*/
36
+
public static function success(string $uri, string $cid): self
37
+
{
38
+
return new self(
39
+
success: true,
40
+
uri: $uri,
41
+
cid: $cid,
42
+
);
43
+
}
44
+
45
+
/**
46
+
* Create a failed result.
47
+
*/
48
+
public static function failed(string $error): self
49
+
{
50
+
return new self(
51
+
success: false,
52
+
error: $error,
53
+
);
54
+
}
55
+
}
+243
src/Publish/PublishService.php
+243
src/Publish/PublishService.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Publish;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use SocialDept\AtpClient\Facades\Atp;
7
+
use SocialDept\AtpParity\Events\RecordPublished;
8
+
use SocialDept\AtpParity\Events\RecordUnpublished;
9
+
use SocialDept\AtpParity\MapperRegistry;
10
+
use Throwable;
11
+
12
+
/**
13
+
* Service for publishing Eloquent models to AT Protocol.
14
+
*/
15
+
class PublishService
16
+
{
17
+
public function __construct(
18
+
protected MapperRegistry $registry
19
+
) {}
20
+
21
+
/**
22
+
* Publish a model as a new record to AT Protocol.
23
+
*
24
+
* Requires the model to have a DID association (via did column or relationship).
25
+
*/
26
+
public function publish(Model $model): PublishResult
27
+
{
28
+
$did = $this->getDidFromModel($model);
29
+
30
+
if (! $did) {
31
+
return PublishResult::failed('No DID associated with model. Use publishAs() to specify a DID.');
32
+
}
33
+
34
+
return $this->publishAs($did, $model);
35
+
}
36
+
37
+
/**
38
+
* Publish a model as a specific user.
39
+
*/
40
+
public function publishAs(string $did, Model $model): PublishResult
41
+
{
42
+
$mapper = $this->registry->forModel(get_class($model));
43
+
44
+
if (! $mapper) {
45
+
return PublishResult::failed('No mapper registered for model: '.get_class($model));
46
+
}
47
+
48
+
// Check if already published
49
+
$existingUri = $this->getModelUri($model);
50
+
if ($existingUri) {
51
+
return $this->update($model);
52
+
}
53
+
54
+
try {
55
+
$record = $mapper->toRecord($model);
56
+
$collection = $mapper->lexicon();
57
+
58
+
$client = Atp::as($did);
59
+
$response = $client->atproto->repo->createRecord(
60
+
repo: $did,
61
+
collection: $collection,
62
+
record: $record->toArray(),
63
+
);
64
+
65
+
// Update model with ATP metadata
66
+
$this->updateModelMeta($model, $response->uri, $response->cid);
67
+
68
+
event(new RecordPublished($model, $response->uri, $response->cid));
69
+
70
+
return PublishResult::success($response->uri, $response->cid);
71
+
} catch (Throwable $e) {
72
+
return PublishResult::failed($e->getMessage());
73
+
}
74
+
}
75
+
76
+
/**
77
+
* Update an existing published record.
78
+
*/
79
+
public function update(Model $model): PublishResult
80
+
{
81
+
$uri = $this->getModelUri($model);
82
+
83
+
if (! $uri) {
84
+
return PublishResult::failed('Model has not been published yet. Use publish() first.');
85
+
}
86
+
87
+
$mapper = $this->registry->forModel(get_class($model));
88
+
89
+
if (! $mapper) {
90
+
return PublishResult::failed('No mapper registered for model: '.get_class($model));
91
+
}
92
+
93
+
$parts = $this->parseUri($uri);
94
+
95
+
if (! $parts) {
96
+
return PublishResult::failed('Invalid AT Protocol URI: '.$uri);
97
+
}
98
+
99
+
try {
100
+
$record = $mapper->toRecord($model);
101
+
102
+
$client = Atp::as($parts['did']);
103
+
$response = $client->atproto->repo->putRecord(
104
+
repo: $parts['did'],
105
+
collection: $parts['collection'],
106
+
rkey: $parts['rkey'],
107
+
record: $record->toArray(),
108
+
);
109
+
110
+
// Update model with new CID
111
+
$this->updateModelMeta($model, $response->uri, $response->cid);
112
+
113
+
event(new RecordPublished($model, $response->uri, $response->cid));
114
+
115
+
return PublishResult::success($response->uri, $response->cid);
116
+
} catch (Throwable $e) {
117
+
return PublishResult::failed($e->getMessage());
118
+
}
119
+
}
120
+
121
+
/**
122
+
* Delete a published record from AT Protocol.
123
+
*/
124
+
public function delete(Model $model): bool
125
+
{
126
+
$uri = $this->getModelUri($model);
127
+
128
+
if (! $uri) {
129
+
return false;
130
+
}
131
+
132
+
$parts = $this->parseUri($uri);
133
+
134
+
if (! $parts) {
135
+
return false;
136
+
}
137
+
138
+
try {
139
+
$client = Atp::as($parts['did']);
140
+
$client->atproto->repo->deleteRecord(
141
+
repo: $parts['did'],
142
+
collection: $parts['collection'],
143
+
rkey: $parts['rkey'],
144
+
);
145
+
146
+
// Clear ATP metadata from model
147
+
$this->clearModelMeta($model);
148
+
149
+
event(new RecordUnpublished($model, $uri));
150
+
151
+
return true;
152
+
} catch (Throwable $e) {
153
+
return false;
154
+
}
155
+
}
156
+
157
+
/**
158
+
* Get the DID from a model.
159
+
*
160
+
* Override this method or set a did column/relationship on your model.
161
+
*/
162
+
protected function getDidFromModel(Model $model): ?string
163
+
{
164
+
// Check for did column
165
+
if (isset($model->did)) {
166
+
return $model->did;
167
+
}
168
+
169
+
// Check for user relationship with did
170
+
if (method_exists($model, 'user') && $model->user?->did) {
171
+
return $model->user->did;
172
+
}
173
+
174
+
// Check for author relationship with did
175
+
if (method_exists($model, 'author') && $model->author?->did) {
176
+
return $model->author->did;
177
+
}
178
+
179
+
// Try extracting from existing URI
180
+
$uri = $this->getModelUri($model);
181
+
if ($uri) {
182
+
$parts = $this->parseUri($uri);
183
+
184
+
return $parts['did'] ?? null;
185
+
}
186
+
187
+
return null;
188
+
}
189
+
190
+
/**
191
+
* Get the AT Protocol URI from a model.
192
+
*/
193
+
protected function getModelUri(Model $model): ?string
194
+
{
195
+
$column = config('parity.columns.uri', 'atp_uri');
196
+
197
+
return $model->{$column};
198
+
}
199
+
200
+
/**
201
+
* Update model with AT Protocol metadata.
202
+
*/
203
+
protected function updateModelMeta(Model $model, string $uri, string $cid): void
204
+
{
205
+
$uriColumn = config('parity.columns.uri', 'atp_uri');
206
+
$cidColumn = config('parity.columns.cid', 'atp_cid');
207
+
208
+
$model->{$uriColumn} = $uri;
209
+
$model->{$cidColumn} = $cid;
210
+
$model->save();
211
+
}
212
+
213
+
/**
214
+
* Clear AT Protocol metadata from model.
215
+
*/
216
+
protected function clearModelMeta(Model $model): void
217
+
{
218
+
$uriColumn = config('parity.columns.uri', 'atp_uri');
219
+
$cidColumn = config('parity.columns.cid', 'atp_cid');
220
+
221
+
$model->{$uriColumn} = null;
222
+
$model->{$cidColumn} = null;
223
+
$model->save();
224
+
}
225
+
226
+
/**
227
+
* Parse an AT Protocol URI into its components.
228
+
*
229
+
* @return array{did: string, collection: string, rkey: string}|null
230
+
*/
231
+
protected function parseUri(string $uri): ?array
232
+
{
233
+
if (! preg_match('#^at://([^/]+)/([^/]+)/([^/]+)$#', $uri, $matches)) {
234
+
return null;
235
+
}
236
+
237
+
return [
238
+
'did' => $matches[1],
239
+
'collection' => $matches[2],
240
+
'rkey' => $matches[3],
241
+
];
242
+
}
243
+
}
+154
src/RecordMapper.php
+154
src/RecordMapper.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use SocialDept\AtpParity\Contracts\RecordMapper as RecordMapperContract;
7
+
use SocialDept\AtpSchema\Data\Data;
8
+
9
+
/**
10
+
* Abstract base class for bidirectional Record <-> Model mapping.
11
+
*
12
+
* @template TRecord of Data
13
+
* @template TModel of Model
14
+
*
15
+
* @implements RecordMapperContract<TRecord, TModel>
16
+
*/
17
+
abstract class RecordMapper implements RecordMapperContract
18
+
{
19
+
/**
20
+
* Get the Record class this mapper handles.
21
+
*
22
+
* @return class-string<TRecord>
23
+
*/
24
+
abstract public function recordClass(): string;
25
+
26
+
/**
27
+
* Get the Model class this mapper handles.
28
+
*
29
+
* @return class-string<TModel>
30
+
*/
31
+
abstract public function modelClass(): string;
32
+
33
+
/**
34
+
* Map record properties to model attributes.
35
+
*
36
+
* @param TRecord $record
37
+
* @return array<string, mixed>
38
+
*/
39
+
abstract protected function recordToAttributes(Data $record): array;
40
+
41
+
/**
42
+
* Map model attributes to record properties.
43
+
*
44
+
* @param TModel $model
45
+
* @return array<string, mixed>
46
+
*/
47
+
abstract protected function modelToRecordData(Model $model): array;
48
+
49
+
/**
50
+
* Get the lexicon NSID this mapper handles.
51
+
*/
52
+
public function lexicon(): string
53
+
{
54
+
$recordClass = $this->recordClass();
55
+
56
+
return $recordClass::getLexicon();
57
+
}
58
+
59
+
/**
60
+
* Get the column name for storing the AT Protocol URI.
61
+
*/
62
+
protected function uriColumn(): string
63
+
{
64
+
return config('parity.columns.uri', 'atp_uri');
65
+
}
66
+
67
+
/**
68
+
* Get the column name for storing the AT Protocol CID.
69
+
*/
70
+
protected function cidColumn(): string
71
+
{
72
+
return config('parity.columns.cid', 'atp_cid');
73
+
}
74
+
75
+
public function toModel(Data $record, array $meta = []): Model
76
+
{
77
+
$modelClass = $this->modelClass();
78
+
$attributes = $this->recordToAttributes($record);
79
+
$attributes = $this->applyMeta($attributes, $meta);
80
+
81
+
return new $modelClass($attributes);
82
+
}
83
+
84
+
public function toRecord(Model $model): Data
85
+
{
86
+
$recordClass = $this->recordClass();
87
+
88
+
return $recordClass::fromArray($this->modelToRecordData($model));
89
+
}
90
+
91
+
public function updateModel(Model $model, Data $record, array $meta = []): Model
92
+
{
93
+
$attributes = $this->recordToAttributes($record);
94
+
$attributes = $this->applyMeta($attributes, $meta);
95
+
$model->fill($attributes);
96
+
97
+
return $model;
98
+
}
99
+
100
+
public function findByUri(string $uri): ?Model
101
+
{
102
+
$modelClass = $this->modelClass();
103
+
104
+
return $modelClass::where($this->uriColumn(), $uri)->first();
105
+
}
106
+
107
+
public function upsert(Data $record, array $meta = []): Model
108
+
{
109
+
$uri = $meta['uri'] ?? null;
110
+
111
+
if ($uri) {
112
+
$existing = $this->findByUri($uri);
113
+
114
+
if ($existing) {
115
+
$this->updateModel($existing, $record, $meta);
116
+
$existing->save();
117
+
118
+
return $existing;
119
+
}
120
+
}
121
+
122
+
$model = $this->toModel($record, $meta);
123
+
$model->save();
124
+
125
+
return $model;
126
+
}
127
+
128
+
public function deleteByUri(string $uri): bool
129
+
{
130
+
$model = $this->findByUri($uri);
131
+
132
+
if ($model) {
133
+
return (bool) $model->delete();
134
+
}
135
+
136
+
return false;
137
+
}
138
+
139
+
/**
140
+
* Apply AT Protocol metadata to attributes.
141
+
*/
142
+
protected function applyMeta(array $attributes, array $meta): array
143
+
{
144
+
if (isset($meta['uri'])) {
145
+
$attributes[$this->uriColumn()] = $meta['uri'];
146
+
}
147
+
148
+
if (isset($meta['cid'])) {
149
+
$attributes[$this->cidColumn()] = $meta['cid'];
150
+
}
151
+
152
+
return $attributes;
153
+
}
154
+
}
+234
src/Signals/ParitySignal.php
+234
src/Signals/ParitySignal.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Signals;
4
+
5
+
use SocialDept\AtpParity\Contracts\RecordMapper;
6
+
use SocialDept\AtpParity\MapperRegistry;
7
+
use SocialDept\AtpParity\Sync\ConflictDetector;
8
+
use SocialDept\AtpParity\Sync\ConflictResolver;
9
+
use SocialDept\AtpParity\Sync\ConflictStrategy;
10
+
use SocialDept\AtpSignals\Events\SignalEvent;
11
+
use SocialDept\AtpSignals\Signals\Signal;
12
+
13
+
/**
14
+
* Signal that automatically syncs firehose events to Eloquent models.
15
+
*
16
+
* This signal listens for commit events on collections that have registered
17
+
* mappers and automatically creates, updates, or deletes the corresponding
18
+
* Eloquent models.
19
+
*
20
+
* Supports selective sync via configuration or by extending this class:
21
+
* - Filter by DID: config('parity.sync.dids') or override dids()
22
+
* - Filter by operation: config('parity.sync.operations') or override operations()
23
+
* - Custom filter: config('parity.sync.filter') or override shouldSync()
24
+
*
25
+
* Supports conflict resolution via configuration:
26
+
* - Strategy: config('parity.conflicts.strategy') - 'remote', 'local', 'newest', 'manual'
27
+
*
28
+
* To use this signal, register it in your atp-signals config:
29
+
*
30
+
* // config/signal.php
31
+
* return [
32
+
* 'signals' => [
33
+
* \SocialDept\AtpParity\Signals\ParitySignal::class,
34
+
* ],
35
+
* ];
36
+
*/
37
+
class ParitySignal extends Signal
38
+
{
39
+
protected ConflictDetector $conflictDetector;
40
+
41
+
protected ConflictResolver $conflictResolver;
42
+
43
+
public function __construct(
44
+
protected MapperRegistry $registry
45
+
) {
46
+
$this->conflictDetector = new ConflictDetector;
47
+
$this->conflictResolver = new ConflictResolver;
48
+
}
49
+
50
+
/**
51
+
* Listen for commit events only.
52
+
*/
53
+
public function eventTypes(): array
54
+
{
55
+
return ['commit'];
56
+
}
57
+
58
+
/**
59
+
* Only listen for collections that have registered mappers.
60
+
*/
61
+
public function collections(): ?array
62
+
{
63
+
$lexicons = $this->registry->lexicons();
64
+
65
+
// Return null if no mappers registered (don't match anything)
66
+
return empty($lexicons) ? ['__none__'] : $lexicons;
67
+
}
68
+
69
+
/**
70
+
* Get the DIDs to sync (null = all DIDs).
71
+
*
72
+
* Override this method for custom DID filtering logic.
73
+
*/
74
+
public function dids(): ?array
75
+
{
76
+
return config('parity.sync.dids');
77
+
}
78
+
79
+
/**
80
+
* Get the operations to sync (null = all operations).
81
+
*
82
+
* Possible values: 'create', 'update', 'delete'
83
+
* Override this method for custom operation filtering.
84
+
*/
85
+
public function operations(): ?array
86
+
{
87
+
return config('parity.sync.operations');
88
+
}
89
+
90
+
/**
91
+
* Determine if the event should be synced.
92
+
*
93
+
* Override this method for custom filtering logic.
94
+
*/
95
+
public function shouldSync(SignalEvent $event): bool
96
+
{
97
+
// Check custom filter callback from config
98
+
$filter = config('parity.sync.filter');
99
+
if ($filter && is_callable($filter)) {
100
+
return $filter($event);
101
+
}
102
+
103
+
return true;
104
+
}
105
+
106
+
/**
107
+
* Handle the firehose event.
108
+
*/
109
+
public function handle(SignalEvent $event): void
110
+
{
111
+
if (! $event->commit) {
112
+
return;
113
+
}
114
+
115
+
// Apply DID filter
116
+
$dids = $this->dids();
117
+
if ($dids !== null && ! in_array($event->did, $dids)) {
118
+
return;
119
+
}
120
+
121
+
$commit = $event->commit;
122
+
123
+
// Apply operation filter
124
+
$operations = $this->operations();
125
+
if ($operations !== null) {
126
+
$operation = $this->getOperationType($commit);
127
+
if (! in_array($operation, $operations)) {
128
+
return;
129
+
}
130
+
}
131
+
132
+
// Apply custom filter
133
+
if (! $this->shouldSync($event)) {
134
+
return;
135
+
}
136
+
137
+
$mapper = $this->registry->forLexicon($commit->collection);
138
+
139
+
if (! $mapper) {
140
+
return;
141
+
}
142
+
143
+
if ($commit->isCreate() || $commit->isUpdate()) {
144
+
$this->handleUpsert($event, $mapper);
145
+
} elseif ($commit->isDelete()) {
146
+
$this->handleDelete($event, $mapper);
147
+
}
148
+
}
149
+
150
+
/**
151
+
* Get the operation type from a commit.
152
+
*/
153
+
protected function getOperationType(object $commit): string
154
+
{
155
+
if ($commit->isCreate()) {
156
+
return 'create';
157
+
}
158
+
159
+
if ($commit->isUpdate()) {
160
+
return 'update';
161
+
}
162
+
163
+
if ($commit->isDelete()) {
164
+
return 'delete';
165
+
}
166
+
167
+
return 'unknown';
168
+
}
169
+
170
+
/**
171
+
* Handle create or update operations.
172
+
*/
173
+
protected function handleUpsert(SignalEvent $event, RecordMapper $mapper): void
174
+
{
175
+
$commit = $event->commit;
176
+
177
+
if (! $commit->record) {
178
+
return;
179
+
}
180
+
181
+
$recordClass = $mapper->recordClass();
182
+
$record = $recordClass::fromArray((array) $commit->record);
183
+
184
+
$uri = $this->buildUri($event->did, $commit->collection, $commit->rkey);
185
+
$meta = [
186
+
'uri' => $uri,
187
+
'cid' => $commit->cid,
188
+
];
189
+
190
+
// Check for existing model and potential conflict
191
+
$existing = $mapper->findByUri($uri);
192
+
193
+
if ($existing && $this->conflictDetector->hasConflict($existing, $record, $commit->cid)) {
194
+
$strategy = ConflictStrategy::fromConfig();
195
+
$resolution = $this->conflictResolver->resolve(
196
+
$existing,
197
+
$record,
198
+
$meta,
199
+
$mapper,
200
+
$strategy
201
+
);
202
+
203
+
// If conflict is pending manual resolution, don't apply changes
204
+
if (! $resolution->isResolved()) {
205
+
return;
206
+
}
207
+
208
+
// Conflict was resolved, model already updated if needed
209
+
return;
210
+
}
211
+
212
+
// No conflict, proceed with normal upsert
213
+
$mapper->upsert($record, $meta);
214
+
}
215
+
216
+
/**
217
+
* Handle delete operations.
218
+
*/
219
+
protected function handleDelete(SignalEvent $event, RecordMapper $mapper): void
220
+
{
221
+
$commit = $event->commit;
222
+
$uri = $this->buildUri($event->did, $commit->collection, $commit->rkey);
223
+
224
+
$mapper->deleteByUri($uri);
225
+
}
226
+
227
+
/**
228
+
* Build an AT Protocol URI.
229
+
*/
230
+
protected function buildUri(string $did, string $collection, string $rkey): string
231
+
{
232
+
return "at://{$did}/{$collection}/{$rkey}";
233
+
}
234
+
}
+220
src/Support/RecordHelper.php
+220
src/Support/RecordHelper.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Support;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use SocialDept\AtpClient\AtpClient;
7
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\GetRecordResponse;
8
+
use SocialDept\AtpClient\Facades\Atp;
9
+
use SocialDept\AtpParity\MapperRegistry;
10
+
use SocialDept\AtpResolver\Facades\Resolver;
11
+
use SocialDept\AtpSchema\Data\Data;
12
+
13
+
/**
14
+
* Helper for integrating atp-parity with atp-client.
15
+
*
16
+
* Provides convenient methods for fetching records from the ATP network
17
+
* and converting them to typed DTOs or Eloquent models.
18
+
*/
19
+
class RecordHelper
20
+
{
21
+
/**
22
+
* Cache of clients by PDS endpoint.
23
+
*
24
+
* @var array<string, AtpClient>
25
+
*/
26
+
protected array $clients = [];
27
+
28
+
public function __construct(
29
+
protected MapperRegistry $registry
30
+
) {}
31
+
32
+
/**
33
+
* Get or create a client for a PDS endpoint.
34
+
*/
35
+
protected function clientFor(string $pdsEndpoint): AtpClient
36
+
{
37
+
return $this->clients[$pdsEndpoint] ??= Atp::public($pdsEndpoint);
38
+
}
39
+
40
+
/**
41
+
* Resolve the PDS endpoint for a DID or handle.
42
+
*/
43
+
protected function resolvePds(string $actor): ?string
44
+
{
45
+
return Resolver::resolvePds($actor);
46
+
}
47
+
48
+
/**
49
+
* Convert a GetRecordResponse to a typed record DTO.
50
+
*
51
+
* @template T of Data
52
+
*
53
+
* @param class-string<T>|null $recordClass Explicit record class, or null to auto-detect from mapper
54
+
* @return T|array The typed record, or raw array if no mapper found and no class specified
55
+
*/
56
+
public function hydrateRecord(GetRecordResponse $response, ?string $recordClass = null): mixed
57
+
{
58
+
if ($recordClass) {
59
+
return $recordClass::fromArray($response->value);
60
+
}
61
+
62
+
$collection = $this->extractCollection($response->uri);
63
+
$mapper = $this->registry->forLexicon($collection);
64
+
65
+
if (! $mapper) {
66
+
return $response->value;
67
+
}
68
+
69
+
$recordClass = $mapper->recordClass();
70
+
71
+
return $recordClass::fromArray($response->value);
72
+
}
73
+
74
+
/**
75
+
* Fetch a record from the ATP network by URI and return as typed DTO.
76
+
*
77
+
* @template T of Data
78
+
*
79
+
* @param class-string<T>|null $recordClass
80
+
* @return T|array|null
81
+
*/
82
+
public function fetch(string $uri, ?string $recordClass = null): mixed
83
+
{
84
+
$parts = $this->parseUri($uri);
85
+
86
+
if (! $parts) {
87
+
return null;
88
+
}
89
+
90
+
$pdsEndpoint = $this->resolvePds($parts['repo']);
91
+
92
+
if (! $pdsEndpoint) {
93
+
return null;
94
+
}
95
+
96
+
$response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord(
97
+
$parts['repo'],
98
+
$parts['collection'],
99
+
$parts['rkey']
100
+
);
101
+
102
+
return $this->hydrateRecord($response, $recordClass);
103
+
}
104
+
105
+
/**
106
+
* Fetch a record by URI and convert directly to an Eloquent model.
107
+
*
108
+
* @template TModel of Model
109
+
*
110
+
* @return TModel|null
111
+
*/
112
+
public function fetchAsModel(string $uri): ?Model
113
+
{
114
+
$parts = $this->parseUri($uri);
115
+
116
+
if (! $parts) {
117
+
return null;
118
+
}
119
+
120
+
$mapper = $this->registry->forLexicon($parts['collection']);
121
+
122
+
if (! $mapper) {
123
+
return null;
124
+
}
125
+
126
+
$pdsEndpoint = $this->resolvePds($parts['repo']);
127
+
128
+
if (! $pdsEndpoint) {
129
+
return null;
130
+
}
131
+
132
+
$response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord(
133
+
$parts['repo'],
134
+
$parts['collection'],
135
+
$parts['rkey']
136
+
);
137
+
138
+
$recordClass = $mapper->recordClass();
139
+
$record = $recordClass::fromArray($response->value);
140
+
141
+
return $mapper->toModel($record, [
142
+
'uri' => $response->uri,
143
+
'cid' => $response->cid,
144
+
]);
145
+
}
146
+
147
+
/**
148
+
* Fetch a record by URI and upsert to the database.
149
+
*
150
+
* @template TModel of Model
151
+
*
152
+
* @return TModel|null
153
+
*/
154
+
public function sync(string $uri): ?Model
155
+
{
156
+
$parts = $this->parseUri($uri);
157
+
158
+
if (! $parts) {
159
+
return null;
160
+
}
161
+
162
+
$mapper = $this->registry->forLexicon($parts['collection']);
163
+
164
+
if (! $mapper) {
165
+
return null;
166
+
}
167
+
168
+
$pdsEndpoint = $this->resolvePds($parts['repo']);
169
+
170
+
if (! $pdsEndpoint) {
171
+
return null;
172
+
}
173
+
174
+
$response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord(
175
+
$parts['repo'],
176
+
$parts['collection'],
177
+
$parts['rkey']
178
+
);
179
+
180
+
$recordClass = $mapper->recordClass();
181
+
$record = $recordClass::fromArray($response->value);
182
+
183
+
return $mapper->upsert($record, [
184
+
'uri' => $response->uri,
185
+
'cid' => $response->cid,
186
+
]);
187
+
}
188
+
189
+
/**
190
+
* Parse an AT Protocol URI into its components.
191
+
*
192
+
* @return array{repo: string, collection: string, rkey: string}|null
193
+
*/
194
+
protected function parseUri(string $uri): ?array
195
+
{
196
+
// at://did:plc:xxx/app.bsky.feed.post/rkey
197
+
if (! preg_match('#^at://([^/]+)/([^/]+)/([^/]+)$#', $uri, $matches)) {
198
+
return null;
199
+
}
200
+
201
+
return [
202
+
'repo' => $matches[1],
203
+
'collection' => $matches[2],
204
+
'rkey' => $matches[3],
205
+
];
206
+
}
207
+
208
+
/**
209
+
* Extract collection from AT Protocol URI.
210
+
*/
211
+
protected function extractCollection(string $uri): string
212
+
{
213
+
// at://did:plc:xxx/app.bsky.feed.post/rkey
214
+
if (preg_match('#^at://[^/]+/([^/]+)/#', $uri, $matches)) {
215
+
return $matches[1];
216
+
}
217
+
218
+
return '';
219
+
}
220
+
}
+75
src/Support/SchemaMapper.php
+75
src/Support/SchemaMapper.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Support;
4
+
5
+
use Closure;
6
+
use Illuminate\Database\Eloquent\Model;
7
+
use SocialDept\AtpParity\RecordMapper;
8
+
use SocialDept\AtpSchema\Data\Data;
9
+
10
+
/**
11
+
* Adapter for using atp-schema generated DTOs as record types.
12
+
*
13
+
* This allows you to use the auto-generated schema classes directly
14
+
* without creating custom Record classes.
15
+
*
16
+
* Example:
17
+
*
18
+
* use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Post;
19
+
* use App\Models\Post as PostModel;
20
+
*
21
+
* $mapper = new SchemaMapper(
22
+
* schemaClass: Post::class,
23
+
* modelClass: PostModel::class,
24
+
* toAttributes: fn(Post $p) => [
25
+
* 'content' => $p->text,
26
+
* 'published_at' => $p->createdAt,
27
+
* ],
28
+
* toRecordData: fn(PostModel $m) => [
29
+
* 'text' => $m->content,
30
+
* 'createdAt' => $m->published_at->toIso8601String(),
31
+
* ],
32
+
* );
33
+
*
34
+
* $registry->register($mapper);
35
+
*
36
+
* @template TSchema of Data
37
+
* @template TModel of Model
38
+
*
39
+
* @extends RecordMapper<TSchema, TModel>
40
+
*/
41
+
class SchemaMapper extends RecordMapper
42
+
{
43
+
/**
44
+
* @param class-string<TSchema> $schemaClass The atp-schema generated class
45
+
* @param class-string<TModel> $modelClass The Eloquent model class
46
+
* @param Closure(TSchema): array $toAttributes Convert schema to model attributes
47
+
* @param Closure(TModel): array $toRecordData Convert model to record data
48
+
*/
49
+
public function __construct(
50
+
protected string $schemaClass,
51
+
protected string $modelClass,
52
+
protected Closure $toAttributes,
53
+
protected Closure $toRecordData,
54
+
) {}
55
+
56
+
public function recordClass(): string
57
+
{
58
+
return $this->schemaClass;
59
+
}
60
+
61
+
public function modelClass(): string
62
+
{
63
+
return $this->modelClass;
64
+
}
65
+
66
+
protected function recordToAttributes(Data $record): array
67
+
{
68
+
return ($this->toAttributes)($record);
69
+
}
70
+
71
+
protected function modelToRecordData(Model $model): array
72
+
{
73
+
return ($this->toRecordData)($model);
74
+
}
75
+
}
+77
src/Sync/ConflictDetector.php
+77
src/Sync/ConflictDetector.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Sync;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use SocialDept\AtpParity\Concerns\SyncsWithAtp;
7
+
use SocialDept\AtpSchema\Data\Data;
8
+
9
+
/**
10
+
* Detects conflicts between local and remote record versions.
11
+
*/
12
+
class ConflictDetector
13
+
{
14
+
/**
15
+
* Check if there's a conflict between local model and remote record.
16
+
*/
17
+
public function hasConflict(Model $model, Data $record, string $cid): bool
18
+
{
19
+
// No conflict if model doesn't have local changes
20
+
if (! $this->modelHasLocalChanges($model)) {
21
+
return false;
22
+
}
23
+
24
+
// No conflict if CID matches (same version)
25
+
if ($this->getCid($model) === $cid) {
26
+
return false;
27
+
}
28
+
29
+
return true;
30
+
}
31
+
32
+
/**
33
+
* Check if the model has local changes since last sync.
34
+
*/
35
+
protected function modelHasLocalChanges(Model $model): bool
36
+
{
37
+
// Use trait method if available
38
+
if ($this->usesTrait($model, SyncsWithAtp::class)) {
39
+
return $model->hasLocalChanges();
40
+
}
41
+
42
+
// Fallback: compare updated_at with a sync timestamp if available
43
+
$syncedAt = $model->getAttribute('atp_synced_at');
44
+
45
+
if (! $syncedAt) {
46
+
return true;
47
+
}
48
+
49
+
$updatedAt = $model->getAttribute('updated_at');
50
+
51
+
if (! $updatedAt) {
52
+
return false;
53
+
}
54
+
55
+
return $updatedAt > $syncedAt;
56
+
}
57
+
58
+
/**
59
+
* Get the CID from a model.
60
+
*/
61
+
protected function getCid(Model $model): ?string
62
+
{
63
+
$column = config('parity.columns.cid', 'atp_cid');
64
+
65
+
return $model->getAttribute($column);
66
+
}
67
+
68
+
/**
69
+
* Check if a model uses a specific trait.
70
+
*
71
+
* @param class-string $trait
72
+
*/
73
+
protected function usesTrait(Model $model, string $trait): bool
74
+
{
75
+
return in_array($trait, class_uses_recursive($model));
76
+
}
77
+
}
+70
src/Sync/ConflictResolution.php
+70
src/Sync/ConflictResolution.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Sync;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
7
+
/**
8
+
* Value object representing the result of conflict resolution.
9
+
*/
10
+
readonly class ConflictResolution
11
+
{
12
+
public function __construct(
13
+
public bool $resolved,
14
+
public string $winner,
15
+
public ?Model $model = null,
16
+
public ?PendingConflict $pending = null,
17
+
) {}
18
+
19
+
/**
20
+
* Check if the conflict was resolved.
21
+
*/
22
+
public function isResolved(): bool
23
+
{
24
+
return $this->resolved;
25
+
}
26
+
27
+
/**
28
+
* Check if the conflict requires manual resolution.
29
+
*/
30
+
public function isPending(): bool
31
+
{
32
+
return ! $this->resolved && $this->pending !== null;
33
+
}
34
+
35
+
/**
36
+
* Create resolution where remote wins.
37
+
*/
38
+
public static function remoteWins(Model $model): self
39
+
{
40
+
return new self(
41
+
resolved: true,
42
+
winner: 'remote',
43
+
model: $model,
44
+
);
45
+
}
46
+
47
+
/**
48
+
* Create resolution where local wins.
49
+
*/
50
+
public static function localWins(Model $model): self
51
+
{
52
+
return new self(
53
+
resolved: true,
54
+
winner: 'local',
55
+
model: $model,
56
+
);
57
+
}
58
+
59
+
/**
60
+
* Create pending resolution for manual review.
61
+
*/
62
+
public static function pending(PendingConflict $conflict): self
63
+
{
64
+
return new self(
65
+
resolved: false,
66
+
winner: 'manual',
67
+
pending: $conflict,
68
+
);
69
+
}
70
+
}
+118
src/Sync/ConflictResolver.php
+118
src/Sync/ConflictResolver.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Sync;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use SocialDept\AtpParity\Contracts\RecordMapper;
7
+
use SocialDept\AtpParity\Events\ConflictDetected;
8
+
use SocialDept\AtpSchema\Data\Data;
9
+
10
+
/**
11
+
* Resolves conflicts between local and remote record versions.
12
+
*/
13
+
class ConflictResolver
14
+
{
15
+
/**
16
+
* Resolve a conflict according to the specified strategy.
17
+
*/
18
+
public function resolve(
19
+
Model $model,
20
+
Data $record,
21
+
array $meta,
22
+
RecordMapper $mapper,
23
+
ConflictStrategy $strategy
24
+
): ConflictResolution {
25
+
return match ($strategy) {
26
+
ConflictStrategy::RemoteWins => $this->applyRemote($model, $record, $meta, $mapper),
27
+
ConflictStrategy::LocalWins => $this->keepLocal($model),
28
+
ConflictStrategy::NewestWins => $this->compareAndApply($model, $record, $meta, $mapper),
29
+
ConflictStrategy::Manual => $this->flagForReview($model, $record, $meta, $mapper),
30
+
};
31
+
}
32
+
33
+
/**
34
+
* Apply the remote version, overwriting local changes.
35
+
*/
36
+
protected function applyRemote(
37
+
Model $model,
38
+
Data $record,
39
+
array $meta,
40
+
RecordMapper $mapper
41
+
): ConflictResolution {
42
+
$mapper->updateModel($model, $record, $meta);
43
+
$model->save();
44
+
45
+
return ConflictResolution::remoteWins($model);
46
+
}
47
+
48
+
/**
49
+
* Keep the local version, ignoring remote changes.
50
+
*/
51
+
protected function keepLocal(Model $model): ConflictResolution
52
+
{
53
+
return ConflictResolution::localWins($model);
54
+
}
55
+
56
+
/**
57
+
* Compare timestamps and apply the newest version.
58
+
*/
59
+
protected function compareAndApply(
60
+
Model $model,
61
+
Data $record,
62
+
array $meta,
63
+
RecordMapper $mapper
64
+
): ConflictResolution {
65
+
$localUpdatedAt = $model->getAttribute('updated_at');
66
+
67
+
// Try to get remote timestamp from record
68
+
$remoteCreatedAt = $record->createdAt ?? null;
69
+
70
+
// If we can't compare, default to remote wins
71
+
if (! $localUpdatedAt || ! $remoteCreatedAt) {
72
+
return $this->applyRemote($model, $record, $meta, $mapper);
73
+
}
74
+
75
+
// Compare timestamps
76
+
if ($localUpdatedAt > $remoteCreatedAt) {
77
+
return $this->keepLocal($model);
78
+
}
79
+
80
+
return $this->applyRemote($model, $record, $meta, $mapper);
81
+
}
82
+
83
+
/**
84
+
* Flag the conflict for manual review.
85
+
*/
86
+
protected function flagForReview(
87
+
Model $model,
88
+
Data $record,
89
+
array $meta,
90
+
RecordMapper $mapper
91
+
): ConflictResolution {
92
+
// Create a pending conflict record
93
+
$conflict = PendingConflict::create([
94
+
'model_type' => get_class($model),
95
+
'model_id' => $model->getKey(),
96
+
'uri' => $meta['uri'] ?? null,
97
+
'local_data' => $model->toArray(),
98
+
'remote_data' => $this->buildRemoteData($record, $meta, $mapper),
99
+
'status' => 'pending',
100
+
]);
101
+
102
+
// Dispatch event for notification
103
+
event(new ConflictDetected($model, $record, $meta, $conflict));
104
+
105
+
return ConflictResolution::pending($conflict);
106
+
}
107
+
108
+
/**
109
+
* Build the remote data array for storage.
110
+
*/
111
+
protected function buildRemoteData(Data $record, array $meta, RecordMapper $mapper): array
112
+
{
113
+
// Create a temporary model with the remote data
114
+
$tempModel = $mapper->toModel($record, $meta);
115
+
116
+
return $tempModel->toArray();
117
+
}
118
+
}
+42
src/Sync/ConflictStrategy.php
+42
src/Sync/ConflictStrategy.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Sync;
4
+
5
+
/**
6
+
* Strategy for resolving conflicts between local and remote changes.
7
+
*/
8
+
enum ConflictStrategy: string
9
+
{
10
+
/**
11
+
* Remote (AT Protocol) is source of truth.
12
+
* Local changes are overwritten.
13
+
*/
14
+
case RemoteWins = 'remote';
15
+
16
+
/**
17
+
* Local database is source of truth.
18
+
* Remote changes are ignored.
19
+
*/
20
+
case LocalWins = 'local';
21
+
22
+
/**
23
+
* Compare timestamps and use the newest version.
24
+
*/
25
+
case NewestWins = 'newest';
26
+
27
+
/**
28
+
* Flag conflict for manual review.
29
+
* Neither version is applied automatically.
30
+
*/
31
+
case Manual = 'manual';
32
+
33
+
/**
34
+
* Create from config value.
35
+
*/
36
+
public static function fromConfig(): self
37
+
{
38
+
$strategy = config('parity.conflicts.strategy', 'remote');
39
+
40
+
return self::tryFrom($strategy) ?? self::RemoteWins;
41
+
}
42
+
}
+127
src/Sync/PendingConflict.php
+127
src/Sync/PendingConflict.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Sync;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use Illuminate\Database\Eloquent\Relations\MorphTo;
7
+
8
+
/**
9
+
* Model for storing pending conflicts requiring manual resolution.
10
+
*/
11
+
class PendingConflict extends Model
12
+
{
13
+
protected $guarded = [];
14
+
15
+
protected $casts = [
16
+
'local_data' => 'array',
17
+
'remote_data' => 'array',
18
+
'resolved_at' => 'datetime',
19
+
];
20
+
21
+
/**
22
+
* Get the table name from config.
23
+
*/
24
+
public function getTable(): string
25
+
{
26
+
return config('parity.conflicts.table', 'parity_conflicts');
27
+
}
28
+
29
+
/**
30
+
* Get the related model.
31
+
*/
32
+
public function model(): MorphTo
33
+
{
34
+
return $this->morphTo();
35
+
}
36
+
37
+
/**
38
+
* Check if this conflict is pending.
39
+
*/
40
+
public function isPending(): bool
41
+
{
42
+
return $this->status === 'pending';
43
+
}
44
+
45
+
/**
46
+
* Check if this conflict has been resolved.
47
+
*/
48
+
public function isResolved(): bool
49
+
{
50
+
return $this->status === 'resolved';
51
+
}
52
+
53
+
/**
54
+
* Check if this conflict was dismissed.
55
+
*/
56
+
public function isDismissed(): bool
57
+
{
58
+
return $this->status === 'dismissed';
59
+
}
60
+
61
+
/**
62
+
* Resolve the conflict with the local version.
63
+
*/
64
+
public function resolveWithLocal(): void
65
+
{
66
+
$this->update([
67
+
'status' => 'resolved',
68
+
'resolution' => 'local',
69
+
'resolved_at' => now(),
70
+
]);
71
+
}
72
+
73
+
/**
74
+
* Resolve the conflict with the remote version.
75
+
*/
76
+
public function resolveWithRemote(): void
77
+
{
78
+
$model = $this->model;
79
+
80
+
if ($model) {
81
+
$model->fill($this->remote_data);
82
+
$model->save();
83
+
}
84
+
85
+
$this->update([
86
+
'status' => 'resolved',
87
+
'resolution' => 'remote',
88
+
'resolved_at' => now(),
89
+
]);
90
+
}
91
+
92
+
/**
93
+
* Dismiss this conflict without resolving.
94
+
*/
95
+
public function dismiss(): void
96
+
{
97
+
$this->update([
98
+
'status' => 'dismissed',
99
+
'resolved_at' => now(),
100
+
]);
101
+
}
102
+
103
+
/**
104
+
* Scope to pending conflicts.
105
+
*/
106
+
public function scopePending($query)
107
+
{
108
+
return $query->where('status', 'pending');
109
+
}
110
+
111
+
/**
112
+
* Scope to resolved conflicts.
113
+
*/
114
+
public function scopeResolved($query)
115
+
{
116
+
return $query->where('status', 'resolved');
117
+
}
118
+
119
+
/**
120
+
* Scope to conflicts for a specific model.
121
+
*/
122
+
public function scopeForModel($query, Model $model)
123
+
{
124
+
return $query->where('model_type', get_class($model))
125
+
->where('model_id', $model->getKey());
126
+
}
127
+
}
+14
tests/Fixtures/SyncableMapper.php
+14
tests/Fixtures/SyncableMapper.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Fixtures;
4
+
5
+
/**
6
+
* Mapper for SyncableModel (extends TestMapper with different model class).
7
+
*/
8
+
class SyncableMapper extends TestMapper
9
+
{
10
+
public function modelClass(): string
11
+
{
12
+
return SyncableModel::class;
13
+
}
14
+
}
+19
tests/Fixtures/SyncableModel.php
+19
tests/Fixtures/SyncableModel.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Fixtures;
4
+
5
+
use SocialDept\AtpParity\Concerns\SyncsWithAtp;
6
+
7
+
/**
8
+
* Test model with SyncsWithAtp trait for unit testing.
9
+
*
10
+
* Extends TestModel so it gets the same mapper from the registry.
11
+
*/
12
+
class SyncableModel extends TestModel
13
+
{
14
+
use SyncsWithAtp;
15
+
16
+
protected $casts = [
17
+
'atp_synced_at' => 'datetime',
18
+
];
19
+
}
+38
tests/Fixtures/TestMapper.php
+38
tests/Fixtures/TestMapper.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Fixtures;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use SocialDept\AtpParity\RecordMapper;
7
+
use SocialDept\AtpSchema\Data\Data;
8
+
9
+
/**
10
+
* Test mapper for unit testing.
11
+
*/
12
+
class TestMapper extends RecordMapper
13
+
{
14
+
public function recordClass(): string
15
+
{
16
+
return TestRecord::class;
17
+
}
18
+
19
+
public function modelClass(): string
20
+
{
21
+
return TestModel::class;
22
+
}
23
+
24
+
protected function recordToAttributes(Data $record): array
25
+
{
26
+
return [
27
+
'content' => $record->text,
28
+
];
29
+
}
30
+
31
+
protected function modelToRecordData(Model $model): array
32
+
{
33
+
return [
34
+
'text' => $model->content,
35
+
'createdAt' => $model->created_at?->toIso8601String(),
36
+
];
37
+
}
38
+
}
+22
tests/Fixtures/TestModel.php
+22
tests/Fixtures/TestModel.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Fixtures;
4
+
5
+
use Illuminate\Database\Eloquent\Model;
6
+
use SocialDept\AtpParity\Concerns\HasAtpRecord;
7
+
8
+
/**
9
+
* Test model for unit testing.
10
+
*/
11
+
class TestModel extends Model
12
+
{
13
+
use HasAtpRecord;
14
+
15
+
protected $table = 'test_models';
16
+
17
+
protected $guarded = [];
18
+
19
+
protected $casts = [
20
+
'atp_synced_at' => 'datetime',
21
+
];
22
+
}
+37
tests/Fixtures/TestRecord.php
+37
tests/Fixtures/TestRecord.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Fixtures;
4
+
5
+
use SocialDept\AtpSchema\Data\Data;
6
+
7
+
/**
8
+
* Test record for unit testing.
9
+
*/
10
+
class TestRecord extends Data
11
+
{
12
+
public function __construct(
13
+
public readonly string $text,
14
+
public readonly ?string $createdAt = null,
15
+
) {}
16
+
17
+
public static function getLexicon(): string
18
+
{
19
+
return 'app.test.record';
20
+
}
21
+
22
+
public static function fromArray(array $data): static
23
+
{
24
+
return new static(
25
+
text: $data['text'] ?? '',
26
+
createdAt: $data['createdAt'] ?? null,
27
+
);
28
+
}
29
+
30
+
public function toArray(): array
31
+
{
32
+
return array_filter([
33
+
'text' => $this->text,
34
+
'createdAt' => $this->createdAt,
35
+
], fn ($v) => $v !== null);
36
+
}
37
+
}
+85
tests/TestCase.php
+85
tests/TestCase.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests;
4
+
5
+
use Illuminate\Database\Schema\Blueprint;
6
+
use Illuminate\Support\Facades\Schema;
7
+
use Orchestra\Testbench\TestCase as Orchestra;
8
+
use SocialDept\AtpClient\AtpClientServiceProvider;
9
+
use SocialDept\AtpParity\ParityServiceProvider;
10
+
use SocialDept\AtpResolver\ResolverServiceProvider;
11
+
use SocialDept\AtpSignals\SignalServiceProvider;
12
+
13
+
abstract class TestCase extends Orchestra
14
+
{
15
+
protected function setUp(): void
16
+
{
17
+
parent::setUp();
18
+
19
+
$this->setUpDatabase();
20
+
}
21
+
22
+
protected function getPackageProviders($app): array
23
+
{
24
+
return [
25
+
ResolverServiceProvider::class,
26
+
AtpClientServiceProvider::class,
27
+
SignalServiceProvider::class,
28
+
ParityServiceProvider::class,
29
+
];
30
+
}
31
+
32
+
protected function getEnvironmentSetUp($app): void
33
+
{
34
+
$app['config']->set('database.default', 'testing');
35
+
$app['config']->set('database.connections.testing', [
36
+
'driver' => 'sqlite',
37
+
'database' => ':memory:',
38
+
'prefix' => '',
39
+
]);
40
+
41
+
$app['config']->set('parity.columns.uri', 'atp_uri');
42
+
$app['config']->set('parity.columns.cid', 'atp_cid');
43
+
}
44
+
45
+
protected function setUpDatabase(): void
46
+
{
47
+
Schema::create('test_models', function (Blueprint $table) {
48
+
$table->id();
49
+
$table->string('content')->nullable();
50
+
$table->string('did')->nullable();
51
+
$table->string('atp_uri')->nullable()->unique();
52
+
$table->string('atp_cid')->nullable();
53
+
$table->timestamp('atp_synced_at')->nullable();
54
+
$table->timestamps();
55
+
});
56
+
57
+
Schema::create('parity_import_states', function (Blueprint $table) {
58
+
$table->id();
59
+
$table->string('did');
60
+
$table->string('collection');
61
+
$table->string('status')->default('pending');
62
+
$table->integer('records_synced')->default(0);
63
+
$table->integer('records_skipped')->default(0);
64
+
$table->integer('records_failed')->default(0);
65
+
$table->string('cursor')->nullable();
66
+
$table->text('error')->nullable();
67
+
$table->timestamp('started_at')->nullable();
68
+
$table->timestamp('completed_at')->nullable();
69
+
$table->timestamps();
70
+
71
+
$table->unique(['did', 'collection']);
72
+
});
73
+
74
+
Schema::create('parity_conflicts', function (Blueprint $table) {
75
+
$table->id();
76
+
$table->morphs('model');
77
+
$table->string('uri');
78
+
$table->string('remote_cid');
79
+
$table->json('remote_data');
80
+
$table->string('status')->default('pending');
81
+
$table->timestamp('resolved_at')->nullable();
82
+
$table->timestamps();
83
+
});
84
+
}
85
+
}
+191
tests/Unit/Concerns/HasAtpRecordTest.php
+191
tests/Unit/Concerns/HasAtpRecordTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit\Concerns;
4
+
5
+
use SocialDept\AtpParity\MapperRegistry;
6
+
use SocialDept\AtpParity\Tests\Fixtures\TestMapper;
7
+
use SocialDept\AtpParity\Tests\Fixtures\TestModel;
8
+
use SocialDept\AtpParity\Tests\Fixtures\TestRecord;
9
+
use SocialDept\AtpParity\Tests\TestCase;
10
+
11
+
class HasAtpRecordTest extends TestCase
12
+
{
13
+
public function test_get_atp_uri_returns_uri_from_column(): void
14
+
{
15
+
$model = new TestModel(['atp_uri' => 'at://did:plc:test/app.test.record/abc123']);
16
+
17
+
$this->assertSame('at://did:plc:test/app.test.record/abc123', $model->getAtpUri());
18
+
}
19
+
20
+
public function test_get_atp_uri_returns_null_when_not_set(): void
21
+
{
22
+
$model = new TestModel();
23
+
24
+
$this->assertNull($model->getAtpUri());
25
+
}
26
+
27
+
public function test_get_atp_cid_returns_cid_from_column(): void
28
+
{
29
+
$model = new TestModel(['atp_cid' => 'bafyreiabc123']);
30
+
31
+
$this->assertSame('bafyreiabc123', $model->getAtpCid());
32
+
}
33
+
34
+
public function test_get_atp_cid_returns_null_when_not_set(): void
35
+
{
36
+
$model = new TestModel();
37
+
38
+
$this->assertNull($model->getAtpCid());
39
+
}
40
+
41
+
public function test_get_atp_did_extracts_did_from_uri(): void
42
+
{
43
+
$model = new TestModel(['atp_uri' => 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/abc123']);
44
+
45
+
$this->assertSame('did:plc:z72i7hdynmk6r22z27h6tvur', $model->getAtpDid());
46
+
}
47
+
48
+
public function test_get_atp_did_returns_null_when_no_uri(): void
49
+
{
50
+
$model = new TestModel();
51
+
52
+
$this->assertNull($model->getAtpDid());
53
+
}
54
+
55
+
public function test_get_atp_did_returns_null_for_malformed_uri(): void
56
+
{
57
+
$model = new TestModel(['atp_uri' => 'invalid-uri']);
58
+
59
+
$this->assertNull($model->getAtpDid());
60
+
}
61
+
62
+
public function test_get_atp_collection_extracts_collection_from_uri(): void
63
+
{
64
+
$model = new TestModel(['atp_uri' => 'at://did:plc:test/app.bsky.feed.post/abc123']);
65
+
66
+
$this->assertSame('app.bsky.feed.post', $model->getAtpCollection());
67
+
}
68
+
69
+
public function test_get_atp_collection_returns_null_when_no_uri(): void
70
+
{
71
+
$model = new TestModel();
72
+
73
+
$this->assertNull($model->getAtpCollection());
74
+
}
75
+
76
+
public function test_get_atp_rkey_extracts_rkey_from_uri(): void
77
+
{
78
+
$model = new TestModel(['atp_uri' => 'at://did:plc:test/app.bsky.feed.post/3kj2h4k5j']);
79
+
80
+
$this->assertSame('3kj2h4k5j', $model->getAtpRkey());
81
+
}
82
+
83
+
public function test_get_atp_rkey_returns_null_when_no_uri(): void
84
+
{
85
+
$model = new TestModel();
86
+
87
+
$this->assertNull($model->getAtpRkey());
88
+
}
89
+
90
+
public function test_has_atp_record_returns_true_when_uri_set(): void
91
+
{
92
+
$model = new TestModel(['atp_uri' => 'at://did/col/rkey']);
93
+
94
+
$this->assertTrue($model->hasAtpRecord());
95
+
}
96
+
97
+
public function test_has_atp_record_returns_false_when_no_uri(): void
98
+
{
99
+
$model = new TestModel();
100
+
101
+
$this->assertFalse($model->hasAtpRecord());
102
+
}
103
+
104
+
public function test_get_atp_mapper_returns_mapper_when_registered(): void
105
+
{
106
+
$registry = app(MapperRegistry::class);
107
+
$registry->register(new TestMapper());
108
+
109
+
$model = new TestModel();
110
+
$mapper = $model->getAtpMapper();
111
+
112
+
$this->assertInstanceOf(TestMapper::class, $mapper);
113
+
}
114
+
115
+
public function test_get_atp_mapper_returns_null_when_not_registered(): void
116
+
{
117
+
// Fresh registry without any mappers
118
+
$this->app->forgetInstance(MapperRegistry::class);
119
+
$this->app->singleton(MapperRegistry::class);
120
+
121
+
$model = new TestModel();
122
+
123
+
$this->assertNull($model->getAtpMapper());
124
+
}
125
+
126
+
public function test_to_atp_record_converts_model_to_record(): void
127
+
{
128
+
$registry = app(MapperRegistry::class);
129
+
$registry->register(new TestMapper());
130
+
131
+
$model = new TestModel(['content' => 'Hello world']);
132
+
$record = $model->toAtpRecord();
133
+
134
+
$this->assertInstanceOf(TestRecord::class, $record);
135
+
$this->assertSame('Hello world', $record->text);
136
+
}
137
+
138
+
public function test_to_atp_record_returns_null_when_no_mapper(): void
139
+
{
140
+
$this->app->forgetInstance(MapperRegistry::class);
141
+
$this->app->singleton(MapperRegistry::class);
142
+
143
+
$model = new TestModel(['content' => 'Hello']);
144
+
145
+
$this->assertNull($model->toAtpRecord());
146
+
}
147
+
148
+
public function test_scope_with_atp_record_filters_synced_models(): void
149
+
{
150
+
TestModel::create(['content' => 'Synced', 'atp_uri' => 'at://did/col/rkey1']);
151
+
TestModel::create(['content' => 'Not synced']);
152
+
TestModel::create(['content' => 'Also synced', 'atp_uri' => 'at://did/col/rkey2']);
153
+
154
+
$synced = TestModel::withAtpRecord()->get();
155
+
156
+
$this->assertCount(2, $synced);
157
+
$this->assertTrue($synced->every(fn ($m) => $m->atp_uri !== null));
158
+
}
159
+
160
+
public function test_scope_without_atp_record_filters_unsynced_models(): void
161
+
{
162
+
TestModel::create(['content' => 'Synced', 'atp_uri' => 'at://did/col/rkey']);
163
+
TestModel::create(['content' => 'Not synced 1']);
164
+
TestModel::create(['content' => 'Not synced 2']);
165
+
166
+
$unsynced = TestModel::withoutAtpRecord()->get();
167
+
168
+
$this->assertCount(2, $unsynced);
169
+
$this->assertTrue($unsynced->every(fn ($m) => $m->atp_uri === null));
170
+
}
171
+
172
+
public function test_scope_where_atp_uri_finds_by_uri(): void
173
+
{
174
+
TestModel::create(['content' => 'Target', 'atp_uri' => 'at://did/col/target']);
175
+
TestModel::create(['content' => 'Other', 'atp_uri' => 'at://did/col/other']);
176
+
177
+
$found = TestModel::whereAtpUri('at://did/col/target')->first();
178
+
179
+
$this->assertNotNull($found);
180
+
$this->assertSame('Target', $found->content);
181
+
}
182
+
183
+
public function test_scope_where_atp_uri_returns_null_when_not_found(): void
184
+
{
185
+
TestModel::create(['content' => 'Some', 'atp_uri' => 'at://did/col/some']);
186
+
187
+
$found = TestModel::whereAtpUri('at://did/col/nonexistent')->first();
188
+
189
+
$this->assertNull($found);
190
+
}
191
+
}
+128
tests/Unit/Concerns/SyncsWithAtpTest.php
+128
tests/Unit/Concerns/SyncsWithAtpTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit\Concerns;
4
+
5
+
use Carbon\Carbon;
6
+
use SocialDept\AtpParity\MapperRegistry;
7
+
use SocialDept\AtpParity\Tests\Fixtures\SyncableMapper;
8
+
use SocialDept\AtpParity\Tests\Fixtures\SyncableModel;
9
+
use SocialDept\AtpParity\Tests\Fixtures\TestRecord;
10
+
use SocialDept\AtpParity\Tests\TestCase;
11
+
12
+
class SyncsWithAtpTest extends TestCase
13
+
{
14
+
public function test_get_atp_synced_at_column_returns_default(): void
15
+
{
16
+
$model = new SyncableModel();
17
+
18
+
$this->assertSame('atp_synced_at', $model->getAtpSyncedAtColumn());
19
+
}
20
+
21
+
public function test_get_atp_synced_at_returns_timestamp(): void
22
+
{
23
+
$now = Carbon::now();
24
+
$model = new SyncableModel(['atp_synced_at' => $now]);
25
+
26
+
$this->assertEquals($now->toDateTimeString(), $model->getAtpSyncedAt()->toDateTimeString());
27
+
}
28
+
29
+
public function test_get_atp_synced_at_returns_null_when_not_set(): void
30
+
{
31
+
$model = new SyncableModel();
32
+
33
+
$this->assertNull($model->getAtpSyncedAt());
34
+
}
35
+
36
+
public function test_mark_as_synced_sets_all_attributes(): void
37
+
{
38
+
Carbon::setTestNow('2024-01-15 12:00:00');
39
+
40
+
$model = new SyncableModel();
41
+
$model->markAsSynced('at://did/col/rkey', 'cid123');
42
+
43
+
$this->assertSame('at://did/col/rkey', $model->atp_uri);
44
+
$this->assertSame('cid123', $model->atp_cid);
45
+
$this->assertSame('2024-01-15 12:00:00', $model->atp_synced_at->toDateTimeString());
46
+
47
+
Carbon::setTestNow();
48
+
}
49
+
50
+
public function test_has_local_changes_returns_true_when_never_synced(): void
51
+
{
52
+
$model = new SyncableModel();
53
+
54
+
$this->assertTrue($model->hasLocalChanges());
55
+
}
56
+
57
+
public function test_has_local_changes_returns_false_when_no_updated_at(): void
58
+
{
59
+
$model = new SyncableModel([
60
+
'atp_synced_at' => Carbon::now(),
61
+
]);
62
+
63
+
$this->assertFalse($model->hasLocalChanges());
64
+
}
65
+
66
+
public function test_has_local_changes_returns_true_when_updated_after_sync(): void
67
+
{
68
+
$model = new SyncableModel([
69
+
'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'),
70
+
'updated_at' => Carbon::parse('2024-01-15 13:00:00'),
71
+
]);
72
+
73
+
$this->assertTrue($model->hasLocalChanges());
74
+
}
75
+
76
+
public function test_has_local_changes_returns_false_when_synced_after_update(): void
77
+
{
78
+
$model = new SyncableModel([
79
+
'updated_at' => Carbon::parse('2024-01-15 12:00:00'),
80
+
'atp_synced_at' => Carbon::parse('2024-01-15 13:00:00'),
81
+
]);
82
+
83
+
$this->assertFalse($model->hasLocalChanges());
84
+
}
85
+
86
+
public function test_update_from_record_updates_model_and_sync_timestamp(): void
87
+
{
88
+
Carbon::setTestNow('2024-01-15 14:00:00');
89
+
90
+
$registry = app(MapperRegistry::class);
91
+
$registry->register(new SyncableMapper());
92
+
93
+
$model = new SyncableModel(['content' => 'Original']);
94
+
$record = new TestRecord(text: 'From remote');
95
+
96
+
$model->updateFromRecord($record, 'at://did/col/rkey', 'newcid');
97
+
98
+
$this->assertSame('From remote', $model->content);
99
+
$this->assertSame('at://did/col/rkey', $model->atp_uri);
100
+
$this->assertSame('newcid', $model->atp_cid);
101
+
$this->assertSame('2024-01-15 14:00:00', $model->atp_synced_at->toDateTimeString());
102
+
103
+
Carbon::setTestNow();
104
+
}
105
+
106
+
public function test_update_from_record_does_nothing_without_mapper(): void
107
+
{
108
+
$this->app->forgetInstance(MapperRegistry::class);
109
+
$this->app->singleton(MapperRegistry::class);
110
+
111
+
$model = new SyncableModel(['content' => 'Original']);
112
+
$record = new TestRecord(text: 'From remote');
113
+
114
+
$model->updateFromRecord($record, 'at://did/col/rkey', 'cid');
115
+
116
+
$this->assertSame('Original', $model->content);
117
+
}
118
+
119
+
public function test_inherits_has_atp_record_methods(): void
120
+
{
121
+
$model = new SyncableModel(['atp_uri' => 'at://did:plc:test/app.test.record/rkey']);
122
+
123
+
$this->assertTrue($model->hasAtpRecord());
124
+
$this->assertSame('did:plc:test', $model->getAtpDid());
125
+
$this->assertSame('app.test.record', $model->getAtpCollection());
126
+
$this->assertSame('rkey', $model->getAtpRkey());
127
+
}
128
+
}
+139
tests/Unit/Import/ImportResultTest.php
+139
tests/Unit/Import/ImportResultTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit\Import;
4
+
5
+
use SocialDept\AtpParity\Import\ImportResult;
6
+
use SocialDept\AtpParity\Tests\TestCase;
7
+
8
+
class ImportResultTest extends TestCase
9
+
{
10
+
public function test_success_creates_completed_result(): void
11
+
{
12
+
$result = ImportResult::success(
13
+
did: 'did:plc:test123',
14
+
collection: 'app.bsky.feed.post',
15
+
synced: 50,
16
+
skipped: 5,
17
+
failed: 2
18
+
);
19
+
20
+
$this->assertTrue($result->isSuccess());
21
+
$this->assertFalse($result->isPartial());
22
+
$this->assertFalse($result->isFailed());
23
+
$this->assertTrue($result->completed);
24
+
$this->assertNull($result->error);
25
+
$this->assertSame(50, $result->recordsSynced);
26
+
$this->assertSame(5, $result->recordsSkipped);
27
+
$this->assertSame(2, $result->recordsFailed);
28
+
}
29
+
30
+
public function test_partial_creates_incomplete_result(): void
31
+
{
32
+
$result = ImportResult::partial(
33
+
did: 'did:plc:test123',
34
+
collection: 'app.bsky.feed.post',
35
+
synced: 100,
36
+
cursor: 'abc123'
37
+
);
38
+
39
+
$this->assertFalse($result->isSuccess());
40
+
$this->assertTrue($result->isPartial());
41
+
$this->assertFalse($result->isFailed());
42
+
$this->assertFalse($result->completed);
43
+
$this->assertSame('abc123', $result->cursor);
44
+
$this->assertNull($result->error);
45
+
}
46
+
47
+
public function test_failed_creates_error_result(): void
48
+
{
49
+
$result = ImportResult::failed(
50
+
did: 'did:plc:test123',
51
+
collection: 'app.bsky.feed.post',
52
+
error: 'Connection failed'
53
+
);
54
+
55
+
$this->assertFalse($result->isSuccess());
56
+
$this->assertFalse($result->isPartial()); // no records synced
57
+
$this->assertTrue($result->isFailed());
58
+
$this->assertSame('Connection failed', $result->error);
59
+
}
60
+
61
+
public function test_failed_with_partial_progress(): void
62
+
{
63
+
$result = ImportResult::failed(
64
+
did: 'did:plc:test123',
65
+
collection: 'app.bsky.feed.post',
66
+
error: 'Connection lost',
67
+
synced: 50,
68
+
cursor: 'xyz789'
69
+
);
70
+
71
+
$this->assertTrue($result->isFailed());
72
+
$this->assertTrue($result->isPartial()); // has synced records
73
+
$this->assertSame(50, $result->recordsSynced);
74
+
$this->assertSame('xyz789', $result->cursor);
75
+
}
76
+
77
+
public function test_total_processed_sums_all_records(): void
78
+
{
79
+
$result = ImportResult::success(
80
+
did: 'did:plc:test123',
81
+
collection: 'app.bsky.feed.post',
82
+
synced: 50,
83
+
skipped: 10,
84
+
failed: 5
85
+
);
86
+
87
+
$this->assertSame(65, $result->totalProcessed());
88
+
}
89
+
90
+
public function test_aggregate_combines_multiple_results(): void
91
+
{
92
+
$results = [
93
+
ImportResult::success('did:plc:test', 'app.bsky.feed.post', synced: 50),
94
+
ImportResult::success('did:plc:test', 'app.bsky.feed.like', synced: 100, failed: 5),
95
+
];
96
+
97
+
$aggregate = ImportResult::aggregate('did:plc:test', $results);
98
+
99
+
$this->assertTrue($aggregate->isSuccess());
100
+
$this->assertSame('*', $aggregate->collection);
101
+
$this->assertSame(150, $aggregate->recordsSynced);
102
+
$this->assertSame(5, $aggregate->recordsFailed);
103
+
$this->assertNull($aggregate->error);
104
+
}
105
+
106
+
public function test_aggregate_marks_incomplete_when_any_incomplete(): void
107
+
{
108
+
$results = [
109
+
ImportResult::success('did:plc:test', 'app.bsky.feed.post', synced: 50),
110
+
ImportResult::partial('did:plc:test', 'app.bsky.feed.like', synced: 100, cursor: 'abc'),
111
+
];
112
+
113
+
$aggregate = ImportResult::aggregate('did:plc:test', $results);
114
+
115
+
$this->assertFalse($aggregate->completed);
116
+
}
117
+
118
+
public function test_aggregate_combines_errors(): void
119
+
{
120
+
$results = [
121
+
ImportResult::failed('did:plc:test', 'app.bsky.feed.post', error: 'Error 1'),
122
+
ImportResult::failed('did:plc:test', 'app.bsky.feed.like', error: 'Error 2'),
123
+
];
124
+
125
+
$aggregate = ImportResult::aggregate('did:plc:test', $results);
126
+
127
+
$this->assertTrue($aggregate->isFailed());
128
+
$this->assertStringContainsString('app.bsky.feed.post: Error 1', $aggregate->error);
129
+
$this->assertStringContainsString('app.bsky.feed.like: Error 2', $aggregate->error);
130
+
}
131
+
132
+
public function test_aggregate_with_empty_array(): void
133
+
{
134
+
$aggregate = ImportResult::aggregate('did:plc:test', []);
135
+
136
+
$this->assertTrue($aggregate->completed);
137
+
$this->assertSame(0, $aggregate->recordsSynced);
138
+
}
139
+
}
+169
tests/Unit/Import/ImportServiceTest.php
+169
tests/Unit/Import/ImportServiceTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit\Import;
4
+
5
+
use Illuminate\Support\Facades\Event;
6
+
use Mockery;
7
+
use SocialDept\AtpParity\Events\ImportCompleted;
8
+
use SocialDept\AtpParity\Events\ImportFailed;
9
+
use SocialDept\AtpParity\Events\ImportProgress;
10
+
use SocialDept\AtpParity\Events\ImportStarted;
11
+
use SocialDept\AtpParity\Import\ImportService;
12
+
use SocialDept\AtpParity\Import\ImportState;
13
+
use SocialDept\AtpParity\MapperRegistry;
14
+
use SocialDept\AtpParity\Tests\Fixtures\TestMapper;
15
+
use SocialDept\AtpParity\Tests\Fixtures\TestModel;
16
+
use SocialDept\AtpParity\Tests\TestCase;
17
+
use SocialDept\AtpResolver\Facades\Resolver;
18
+
19
+
class ImportServiceTest extends TestCase
20
+
{
21
+
private ImportService $service;
22
+
23
+
private MapperRegistry $registry;
24
+
25
+
protected function setUp(): void
26
+
{
27
+
parent::setUp();
28
+
29
+
$this->registry = new MapperRegistry();
30
+
$this->registry->register(new TestMapper());
31
+
32
+
$this->service = new ImportService($this->registry);
33
+
34
+
Event::fake();
35
+
}
36
+
37
+
public function test_import_user_collection_returns_failed_when_no_mapper(): void
38
+
{
39
+
$this->app->forgetInstance(MapperRegistry::class);
40
+
$emptyRegistry = new MapperRegistry();
41
+
$service = new ImportService($emptyRegistry);
42
+
43
+
$result = $service->importUserCollection('did:plc:test', 'unknown.collection');
44
+
45
+
$this->assertTrue($result->isFailed());
46
+
$this->assertStringContainsString('No mapper registered', $result->error);
47
+
}
48
+
49
+
public function test_import_user_collection_fails_when_pds_not_resolved(): void
50
+
{
51
+
Resolver::shouldReceive('resolvePds')
52
+
->with('did:plc:test')
53
+
->andReturnNull();
54
+
55
+
$result = $this->service->importUserCollection('did:plc:test', 'app.test.record');
56
+
57
+
$this->assertTrue($result->isFailed());
58
+
$this->assertStringContainsString('Could not resolve PDS', $result->error);
59
+
Event::assertDispatched(ImportFailed::class);
60
+
}
61
+
62
+
/**
63
+
* @group integration
64
+
*/
65
+
public function test_import_user_collection_imports_records(): void
66
+
{
67
+
$this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking');
68
+
}
69
+
70
+
/**
71
+
* @group integration
72
+
*/
73
+
public function test_import_dispatches_events(): void
74
+
{
75
+
$this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking');
76
+
}
77
+
78
+
/**
79
+
* @group integration
80
+
*/
81
+
public function test_import_calls_progress_callback(): void
82
+
{
83
+
$this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking');
84
+
}
85
+
86
+
/**
87
+
* @group integration
88
+
*/
89
+
public function test_import_user_imports_multiple_collections(): void
90
+
{
91
+
$this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking');
92
+
}
93
+
94
+
public function test_get_status_returns_import_state(): void
95
+
{
96
+
ImportState::create([
97
+
'did' => 'did:plc:test',
98
+
'collection' => 'app.test.record',
99
+
'status' => 'completed',
100
+
'records_synced' => 50,
101
+
]);
102
+
103
+
$state = $this->service->getStatus('did:plc:test', 'app.test.record');
104
+
105
+
$this->assertNotNull($state);
106
+
$this->assertSame('completed', $state->status);
107
+
$this->assertSame(50, $state->records_synced);
108
+
}
109
+
110
+
public function test_get_status_returns_null_when_not_found(): void
111
+
{
112
+
$state = $this->service->getStatus('did:plc:unknown', 'unknown');
113
+
114
+
$this->assertNull($state);
115
+
}
116
+
117
+
public function test_is_imported_returns_true_when_completed(): void
118
+
{
119
+
ImportState::create([
120
+
'did' => 'did:plc:test',
121
+
'collection' => 'app.test.record',
122
+
'status' => 'completed',
123
+
]);
124
+
125
+
$this->assertTrue($this->service->isImported('did:plc:test', 'app.test.record'));
126
+
}
127
+
128
+
public function test_is_imported_returns_false_when_not_started(): void
129
+
{
130
+
$this->assertFalse($this->service->isImported('did:plc:test', 'app.test.record'));
131
+
}
132
+
133
+
public function test_reset_deletes_import_state(): void
134
+
{
135
+
ImportState::create([
136
+
'did' => 'did:plc:test',
137
+
'collection' => 'app.test.record',
138
+
'status' => 'completed',
139
+
]);
140
+
141
+
$this->service->reset('did:plc:test', 'app.test.record');
142
+
143
+
$this->assertNull($this->service->getStatus('did:plc:test', 'app.test.record'));
144
+
}
145
+
146
+
public function test_import_skips_already_completed(): void
147
+
{
148
+
ImportState::create([
149
+
'did' => 'did:plc:test',
150
+
'collection' => 'app.test.record',
151
+
'status' => 'completed',
152
+
'records_synced' => 100,
153
+
]);
154
+
155
+
// No mocking needed - should return cached result
156
+
$result = $this->service->importUserCollection('did:plc:test', 'app.test.record');
157
+
158
+
$this->assertTrue($result->completed);
159
+
$this->assertSame(100, $result->recordsSynced);
160
+
}
161
+
162
+
/**
163
+
* @group integration
164
+
*/
165
+
public function test_import_handles_record_failures_gracefully(): void
166
+
{
167
+
$this->markTestSkipped('Requires integration test with real or mock ATP client - AtpClient has typed properties that prevent mocking');
168
+
}
169
+
}
+107
tests/Unit/MapperRegistryTest.php
+107
tests/Unit/MapperRegistryTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit;
4
+
5
+
use SocialDept\AtpParity\MapperRegistry;
6
+
use SocialDept\AtpParity\Tests\Fixtures\TestMapper;
7
+
use SocialDept\AtpParity\Tests\Fixtures\TestModel;
8
+
use SocialDept\AtpParity\Tests\Fixtures\TestRecord;
9
+
use SocialDept\AtpParity\Tests\TestCase;
10
+
11
+
class MapperRegistryTest extends TestCase
12
+
{
13
+
private MapperRegistry $registry;
14
+
15
+
protected function setUp(): void
16
+
{
17
+
parent::setUp();
18
+
$this->registry = new MapperRegistry();
19
+
}
20
+
21
+
public function test_register_adds_mapper_to_all_indices(): void
22
+
{
23
+
$mapper = new TestMapper();
24
+
25
+
$this->registry->register($mapper);
26
+
27
+
$this->assertSame($mapper, $this->registry->forRecord(TestRecord::class));
28
+
$this->assertSame($mapper, $this->registry->forModel(TestModel::class));
29
+
$this->assertSame($mapper, $this->registry->forLexicon('app.test.record'));
30
+
}
31
+
32
+
public function test_for_record_returns_null_for_unregistered_class(): void
33
+
{
34
+
$result = $this->registry->forRecord('NonExistent\\Record');
35
+
36
+
$this->assertNull($result);
37
+
}
38
+
39
+
public function test_for_model_returns_null_for_unregistered_class(): void
40
+
{
41
+
$result = $this->registry->forModel('NonExistent\\Model');
42
+
43
+
$this->assertNull($result);
44
+
}
45
+
46
+
public function test_for_lexicon_returns_null_for_unregistered_nsid(): void
47
+
{
48
+
$result = $this->registry->forLexicon('app.unknown.record');
49
+
50
+
$this->assertNull($result);
51
+
}
52
+
53
+
public function test_has_lexicon_returns_true_when_registered(): void
54
+
{
55
+
$this->registry->register(new TestMapper());
56
+
57
+
$this->assertTrue($this->registry->hasLexicon('app.test.record'));
58
+
}
59
+
60
+
public function test_has_lexicon_returns_false_when_not_registered(): void
61
+
{
62
+
$this->assertFalse($this->registry->hasLexicon('app.unknown.record'));
63
+
}
64
+
65
+
public function test_lexicons_returns_all_registered_nsids(): void
66
+
{
67
+
$this->registry->register(new TestMapper());
68
+
69
+
$lexicons = $this->registry->lexicons();
70
+
71
+
$this->assertContains('app.test.record', $lexicons);
72
+
$this->assertCount(1, $lexicons);
73
+
}
74
+
75
+
public function test_lexicons_returns_empty_array_when_no_mappers_registered(): void
76
+
{
77
+
$this->assertEmpty($this->registry->lexicons());
78
+
}
79
+
80
+
public function test_all_returns_all_registered_mappers(): void
81
+
{
82
+
$mapper = new TestMapper();
83
+
$this->registry->register($mapper);
84
+
85
+
$all = $this->registry->all();
86
+
87
+
$this->assertCount(1, $all);
88
+
$this->assertSame($mapper, $all[0]);
89
+
}
90
+
91
+
public function test_all_returns_empty_array_when_no_mappers_registered(): void
92
+
{
93
+
$this->assertEmpty($this->registry->all());
94
+
}
95
+
96
+
public function test_registering_same_mapper_twice_overwrites(): void
97
+
{
98
+
$mapper1 = new TestMapper();
99
+
$mapper2 = new TestMapper();
100
+
101
+
$this->registry->register($mapper1);
102
+
$this->registry->register($mapper2);
103
+
104
+
$this->assertSame($mapper2, $this->registry->forRecord(TestRecord::class));
105
+
$this->assertCount(1, $this->registry->all());
106
+
}
107
+
}
+69
tests/Unit/Publish/PublishResultTest.php
+69
tests/Unit/Publish/PublishResultTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit\Publish;
4
+
5
+
use SocialDept\AtpParity\Publish\PublishResult;
6
+
use SocialDept\AtpParity\Tests\TestCase;
7
+
8
+
class PublishResultTest extends TestCase
9
+
{
10
+
public function test_success_creates_successful_result(): void
11
+
{
12
+
$result = PublishResult::success(
13
+
uri: 'at://did:plc:test/app.bsky.feed.post/abc123',
14
+
cid: 'bafyreiabc123'
15
+
);
16
+
17
+
$this->assertTrue($result->isSuccess());
18
+
$this->assertFalse($result->isFailed());
19
+
$this->assertSame('at://did:plc:test/app.bsky.feed.post/abc123', $result->uri);
20
+
$this->assertSame('bafyreiabc123', $result->cid);
21
+
$this->assertNull($result->error);
22
+
}
23
+
24
+
public function test_failed_creates_failed_result(): void
25
+
{
26
+
$result = PublishResult::failed('Authentication required');
27
+
28
+
$this->assertFalse($result->isSuccess());
29
+
$this->assertTrue($result->isFailed());
30
+
$this->assertNull($result->uri);
31
+
$this->assertNull($result->cid);
32
+
$this->assertSame('Authentication required', $result->error);
33
+
}
34
+
35
+
public function test_is_success_returns_correct_boolean(): void
36
+
{
37
+
$success = new PublishResult(success: true);
38
+
$failure = new PublishResult(success: false);
39
+
40
+
$this->assertTrue($success->isSuccess());
41
+
$this->assertFalse($failure->isSuccess());
42
+
}
43
+
44
+
public function test_is_failed_returns_correct_boolean(): void
45
+
{
46
+
$success = new PublishResult(success: true);
47
+
$failure = new PublishResult(success: false);
48
+
49
+
$this->assertFalse($success->isFailed());
50
+
$this->assertTrue($failure->isFailed());
51
+
}
52
+
53
+
public function test_success_result_properties_are_accessible(): void
54
+
{
55
+
$result = PublishResult::success('at://did/col/rkey', 'cid123');
56
+
57
+
$this->assertTrue($result->success);
58
+
$this->assertSame('at://did/col/rkey', $result->uri);
59
+
$this->assertSame('cid123', $result->cid);
60
+
}
61
+
62
+
public function test_failed_result_error_is_accessible(): void
63
+
{
64
+
$result = PublishResult::failed('Something went wrong');
65
+
66
+
$this->assertFalse($result->success);
67
+
$this->assertSame('Something went wrong', $result->error);
68
+
}
69
+
}
+299
tests/Unit/Publish/PublishServiceTest.php
+299
tests/Unit/Publish/PublishServiceTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit\Publish;
4
+
5
+
use Illuminate\Support\Facades\Event;
6
+
use Mockery;
7
+
use SocialDept\AtpParity\Events\RecordPublished;
8
+
use SocialDept\AtpParity\Events\RecordUnpublished;
9
+
use SocialDept\AtpParity\MapperRegistry;
10
+
use SocialDept\AtpParity\Publish\PublishService;
11
+
use SocialDept\AtpParity\Tests\Fixtures\TestMapper;
12
+
use SocialDept\AtpParity\Tests\Fixtures\TestModel;
13
+
use SocialDept\AtpParity\Tests\TestCase;
14
+
15
+
class PublishServiceTest extends TestCase
16
+
{
17
+
private PublishService $service;
18
+
19
+
private MapperRegistry $registry;
20
+
21
+
protected function setUp(): void
22
+
{
23
+
parent::setUp();
24
+
25
+
$this->registry = new MapperRegistry();
26
+
$this->registry->register(new TestMapper());
27
+
28
+
$this->service = new PublishService($this->registry);
29
+
30
+
Event::fake();
31
+
}
32
+
33
+
public function test_publish_fails_when_no_did_available(): void
34
+
{
35
+
$model = new TestModel(['content' => 'Test']);
36
+
37
+
$result = $this->service->publish($model);
38
+
39
+
$this->assertTrue($result->isFailed());
40
+
$this->assertStringContainsString('No DID', $result->error);
41
+
}
42
+
43
+
public function test_publish_uses_did_from_model_column(): void
44
+
{
45
+
$model = new TestModel([
46
+
'content' => 'Test',
47
+
'did' => 'did:plc:test123',
48
+
]);
49
+
50
+
$this->mockAtpClient('did:plc:test123', 'at://did:plc:test123/app.test.record/abc', 'cid123');
51
+
52
+
$result = $this->service->publish($model);
53
+
54
+
$this->assertTrue($result->isSuccess());
55
+
$this->assertSame('at://did:plc:test123/app.test.record/abc', $result->uri);
56
+
}
57
+
58
+
public function test_publish_as_creates_record_with_specified_did(): void
59
+
{
60
+
$model = new TestModel(['content' => 'Hello world']);
61
+
62
+
$this->mockAtpClient('did:plc:specified', 'at://did:plc:specified/app.test.record/xyz', 'newcid');
63
+
64
+
$result = $this->service->publishAs('did:plc:specified', $model);
65
+
66
+
$this->assertTrue($result->isSuccess());
67
+
$this->assertSame('at://did:plc:specified/app.test.record/xyz', $result->uri);
68
+
$this->assertSame('newcid', $result->cid);
69
+
}
70
+
71
+
public function test_publish_as_updates_model_metadata(): void
72
+
{
73
+
$model = TestModel::create(['content' => 'Test']);
74
+
75
+
$this->mockAtpClient('did:plc:test', 'at://did:plc:test/app.test.record/rkey', 'cid');
76
+
77
+
$this->service->publishAs('did:plc:test', $model);
78
+
79
+
$model->refresh();
80
+
$this->assertSame('at://did:plc:test/app.test.record/rkey', $model->atp_uri);
81
+
$this->assertSame('cid', $model->atp_cid);
82
+
}
83
+
84
+
public function test_publish_dispatches_record_published_event(): void
85
+
{
86
+
$model = new TestModel(['content' => 'Test', 'did' => 'did:plc:test']);
87
+
88
+
$this->mockAtpClient('did:plc:test', 'at://did/col/rkey', 'cid');
89
+
90
+
$this->service->publish($model);
91
+
92
+
Event::assertDispatched(RecordPublished::class);
93
+
}
94
+
95
+
public function test_publish_redirects_to_update_when_already_published(): void
96
+
{
97
+
$model = TestModel::create([
98
+
'content' => 'Existing',
99
+
'atp_uri' => 'at://did:plc:test/app.test.record/existing',
100
+
'atp_cid' => 'oldcid',
101
+
]);
102
+
103
+
$this->mockAtpClientForUpdate('did:plc:test', 'at://did:plc:test/app.test.record/existing', 'newcid');
104
+
105
+
$result = $this->service->publishAs('did:plc:test', $model);
106
+
107
+
$this->assertTrue($result->isSuccess());
108
+
}
109
+
110
+
public function test_update_fails_when_not_published(): void
111
+
{
112
+
$model = new TestModel(['content' => 'Not published']);
113
+
114
+
$result = $this->service->update($model);
115
+
116
+
$this->assertTrue($result->isFailed());
117
+
$this->assertStringContainsString('not been published', $result->error);
118
+
}
119
+
120
+
public function test_update_calls_put_record(): void
121
+
{
122
+
$model = TestModel::create([
123
+
'content' => 'Updated content',
124
+
'atp_uri' => 'at://did:plc:test/app.test.record/rkey123',
125
+
'atp_cid' => 'oldcid',
126
+
]);
127
+
128
+
$this->mockAtpClientForUpdate('did:plc:test', 'at://did:plc:test/app.test.record/rkey123', 'updatedcid');
129
+
130
+
$result = $this->service->update($model);
131
+
132
+
$this->assertTrue($result->isSuccess());
133
+
$this->assertSame('updatedcid', $result->cid);
134
+
}
135
+
136
+
public function test_delete_removes_record_and_clears_metadata(): void
137
+
{
138
+
$model = TestModel::create([
139
+
'content' => 'To delete',
140
+
'atp_uri' => 'at://did:plc:test/app.test.record/todelete',
141
+
'atp_cid' => 'cid',
142
+
]);
143
+
144
+
$this->mockAtpClientForDelete('did:plc:test');
145
+
146
+
$result = $this->service->delete($model);
147
+
148
+
$this->assertTrue($result);
149
+
150
+
$model->refresh();
151
+
$this->assertNull($model->atp_uri);
152
+
$this->assertNull($model->atp_cid);
153
+
}
154
+
155
+
public function test_delete_dispatches_record_unpublished_event(): void
156
+
{
157
+
$model = TestModel::create([
158
+
'content' => 'To delete',
159
+
'atp_uri' => 'at://did:plc:test/app.test.record/xyz',
160
+
'atp_cid' => 'cid',
161
+
]);
162
+
163
+
$this->mockAtpClientForDelete('did:plc:test');
164
+
165
+
$this->service->delete($model);
166
+
167
+
Event::assertDispatched(RecordUnpublished::class);
168
+
}
169
+
170
+
public function test_delete_returns_false_when_not_published(): void
171
+
{
172
+
$model = new TestModel(['content' => 'Not published']);
173
+
174
+
$result = $this->service->delete($model);
175
+
176
+
$this->assertFalse($result);
177
+
}
178
+
179
+
public function test_publish_handles_exception_gracefully(): void
180
+
{
181
+
$model = new TestModel(['content' => 'Test', 'did' => 'did:plc:test']);
182
+
183
+
$this->mockAtpClientWithException('did:plc:test', 'API error occurred');
184
+
185
+
$result = $this->service->publish($model);
186
+
187
+
$this->assertTrue($result->isFailed());
188
+
$this->assertSame('API error occurred', $result->error);
189
+
}
190
+
191
+
public function test_update_fails_for_invalid_uri(): void
192
+
{
193
+
$model = new TestModel([
194
+
'content' => 'Test',
195
+
'atp_uri' => 'invalid-uri-format',
196
+
]);
197
+
198
+
$result = $this->service->update($model);
199
+
200
+
$this->assertTrue($result->isFailed());
201
+
$this->assertStringContainsString('Invalid AT Protocol URI', $result->error);
202
+
}
203
+
204
+
/**
205
+
* Mock AtpClient for create operations.
206
+
*/
207
+
protected function mockAtpClient(string $did, string $returnUri, string $returnCid): void
208
+
{
209
+
$response = new \stdClass();
210
+
$response->uri = $returnUri;
211
+
$response->cid = $returnCid;
212
+
213
+
$repoClient = Mockery::mock();
214
+
$repoClient->shouldReceive('createRecord')
215
+
->andReturn($response);
216
+
217
+
// Create client mock with property chain (no typed properties)
218
+
$atprotoClient = Mockery::mock();
219
+
$atprotoClient->repo = $repoClient;
220
+
221
+
$atpClient = Mockery::mock();
222
+
$atpClient->atproto = $atprotoClient;
223
+
224
+
// Bind a manager mock to the container
225
+
$manager = Mockery::mock();
226
+
$manager->shouldReceive('as')
227
+
->with($did)
228
+
->andReturn($atpClient);
229
+
230
+
$this->app->instance('atp-client', $manager);
231
+
}
232
+
233
+
/**
234
+
* Mock AtpClient for update operations.
235
+
*/
236
+
protected function mockAtpClientForUpdate(string $did, string $returnUri, string $returnCid): void
237
+
{
238
+
$response = new \stdClass();
239
+
$response->uri = $returnUri;
240
+
$response->cid = $returnCid;
241
+
242
+
$repoClient = Mockery::mock();
243
+
$repoClient->shouldReceive('putRecord')
244
+
->andReturn($response);
245
+
246
+
// Create client mock with property chain (no typed properties)
247
+
$atprotoClient = Mockery::mock();
248
+
$atprotoClient->repo = $repoClient;
249
+
250
+
$atpClient = Mockery::mock();
251
+
$atpClient->atproto = $atprotoClient;
252
+
253
+
// Bind a manager mock to the container
254
+
$manager = Mockery::mock();
255
+
$manager->shouldReceive('as')
256
+
->with($did)
257
+
->andReturn($atpClient);
258
+
259
+
$this->app->instance('atp-client', $manager);
260
+
}
261
+
262
+
/**
263
+
* Mock AtpClient for delete operations.
264
+
*/
265
+
protected function mockAtpClientForDelete(string $did): void
266
+
{
267
+
$repoClient = Mockery::mock();
268
+
$repoClient->shouldReceive('deleteRecord')
269
+
->andReturnNull();
270
+
271
+
// Create client mock with property chain (no typed properties)
272
+
$atprotoClient = Mockery::mock();
273
+
$atprotoClient->repo = $repoClient;
274
+
275
+
$atpClient = Mockery::mock();
276
+
$atpClient->atproto = $atprotoClient;
277
+
278
+
// Bind a manager mock to the container
279
+
$manager = Mockery::mock();
280
+
$manager->shouldReceive('as')
281
+
->with($did)
282
+
->andReturn($atpClient);
283
+
284
+
$this->app->instance('atp-client', $manager);
285
+
}
286
+
287
+
/**
288
+
* Mock AtpClient to throw exception.
289
+
*/
290
+
protected function mockAtpClientWithException(string $did, string $message): void
291
+
{
292
+
$manager = Mockery::mock();
293
+
$manager->shouldReceive('as')
294
+
->with($did)
295
+
->andThrow(new \Exception($message));
296
+
297
+
$this->app->instance('atp-client', $manager);
298
+
}
299
+
}
+184
tests/Unit/RecordMapperTest.php
+184
tests/Unit/RecordMapperTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit;
4
+
5
+
use SocialDept\AtpParity\Tests\Fixtures\TestMapper;
6
+
use SocialDept\AtpParity\Tests\Fixtures\TestModel;
7
+
use SocialDept\AtpParity\Tests\Fixtures\TestRecord;
8
+
use SocialDept\AtpParity\Tests\TestCase;
9
+
10
+
class RecordMapperTest extends TestCase
11
+
{
12
+
private TestMapper $mapper;
13
+
14
+
protected function setUp(): void
15
+
{
16
+
parent::setUp();
17
+
$this->mapper = new TestMapper();
18
+
}
19
+
20
+
public function test_record_class_returns_correct_class(): void
21
+
{
22
+
$this->assertSame(TestRecord::class, $this->mapper->recordClass());
23
+
}
24
+
25
+
public function test_model_class_returns_correct_class(): void
26
+
{
27
+
$this->assertSame(TestModel::class, $this->mapper->modelClass());
28
+
}
29
+
30
+
public function test_lexicon_returns_record_lexicon(): void
31
+
{
32
+
$this->assertSame('app.test.record', $this->mapper->lexicon());
33
+
}
34
+
35
+
public function test_to_model_creates_model_with_attributes(): void
36
+
{
37
+
$record = new TestRecord(text: 'Hello world');
38
+
39
+
$model = $this->mapper->toModel($record);
40
+
41
+
$this->assertInstanceOf(TestModel::class, $model);
42
+
$this->assertSame('Hello world', $model->content);
43
+
$this->assertFalse($model->exists);
44
+
}
45
+
46
+
public function test_to_model_applies_meta(): void
47
+
{
48
+
$record = new TestRecord(text: 'Test');
49
+
$meta = [
50
+
'uri' => 'at://did:plc:test/app.test.record/abc123',
51
+
'cid' => 'bafyreiabc',
52
+
];
53
+
54
+
$model = $this->mapper->toModel($record, $meta);
55
+
56
+
$this->assertSame('at://did:plc:test/app.test.record/abc123', $model->atp_uri);
57
+
$this->assertSame('bafyreiabc', $model->atp_cid);
58
+
}
59
+
60
+
public function test_to_record_converts_model_to_record(): void
61
+
{
62
+
$model = new TestModel(['content' => 'Test content']);
63
+
64
+
$record = $this->mapper->toRecord($model);
65
+
66
+
$this->assertInstanceOf(TestRecord::class, $record);
67
+
$this->assertSame('Test content', $record->text);
68
+
}
69
+
70
+
public function test_update_model_fills_model_without_saving(): void
71
+
{
72
+
$model = new TestModel(['content' => 'Original']);
73
+
$model->save();
74
+
$originalUpdatedAt = $model->updated_at;
75
+
76
+
$record = new TestRecord(text: 'Updated');
77
+
78
+
$result = $this->mapper->updateModel($model, $record);
79
+
80
+
$this->assertSame($model, $result);
81
+
$this->assertSame('Updated', $model->content);
82
+
// Model is filled but not saved
83
+
$this->assertTrue($model->isDirty('content'));
84
+
}
85
+
86
+
public function test_update_model_applies_meta(): void
87
+
{
88
+
$model = new TestModel(['content' => 'Original']);
89
+
$record = new TestRecord(text: 'Updated');
90
+
$meta = ['uri' => 'at://test/col/rkey', 'cid' => 'cid123'];
91
+
92
+
$this->mapper->updateModel($model, $record, $meta);
93
+
94
+
$this->assertSame('at://test/col/rkey', $model->atp_uri);
95
+
$this->assertSame('cid123', $model->atp_cid);
96
+
}
97
+
98
+
public function test_find_by_uri_returns_model_when_exists(): void
99
+
{
100
+
$model = TestModel::create([
101
+
'content' => 'Test',
102
+
'atp_uri' => 'at://did:plc:test/app.test.record/abc',
103
+
]);
104
+
105
+
$found = $this->mapper->findByUri('at://did:plc:test/app.test.record/abc');
106
+
107
+
$this->assertNotNull($found);
108
+
$this->assertSame($model->id, $found->id);
109
+
}
110
+
111
+
public function test_find_by_uri_returns_null_when_not_exists(): void
112
+
{
113
+
$found = $this->mapper->findByUri('at://nonexistent/col/rkey');
114
+
115
+
$this->assertNull($found);
116
+
}
117
+
118
+
public function test_upsert_creates_new_model_when_uri_not_found(): void
119
+
{
120
+
$record = new TestRecord(text: 'New record');
121
+
$meta = [
122
+
'uri' => 'at://did:plc:test/app.test.record/new123',
123
+
'cid' => 'bafyrei123',
124
+
];
125
+
126
+
$model = $this->mapper->upsert($record, $meta);
127
+
128
+
$this->assertTrue($model->exists);
129
+
$this->assertSame('New record', $model->content);
130
+
$this->assertSame('at://did:plc:test/app.test.record/new123', $model->atp_uri);
131
+
}
132
+
133
+
public function test_upsert_updates_existing_model_when_uri_found(): void
134
+
{
135
+
$existing = TestModel::create([
136
+
'content' => 'Original',
137
+
'atp_uri' => 'at://did:plc:test/app.test.record/exists',
138
+
'atp_cid' => 'old_cid',
139
+
]);
140
+
141
+
$record = new TestRecord(text: 'Updated content');
142
+
$meta = [
143
+
'uri' => 'at://did:plc:test/app.test.record/exists',
144
+
'cid' => 'new_cid',
145
+
];
146
+
147
+
$model = $this->mapper->upsert($record, $meta);
148
+
149
+
$this->assertSame($existing->id, $model->id);
150
+
$this->assertSame('Updated content', $model->content);
151
+
$this->assertSame('new_cid', $model->atp_cid);
152
+
}
153
+
154
+
public function test_upsert_without_uri_creates_new_model(): void
155
+
{
156
+
$record = new TestRecord(text: 'No URI');
157
+
158
+
$model = $this->mapper->upsert($record, []);
159
+
160
+
$this->assertTrue($model->exists);
161
+
$this->assertSame('No URI', $model->content);
162
+
$this->assertNull($model->atp_uri);
163
+
}
164
+
165
+
public function test_delete_by_uri_deletes_model_when_exists(): void
166
+
{
167
+
TestModel::create([
168
+
'content' => 'To delete',
169
+
'atp_uri' => 'at://did:plc:test/app.test.record/todelete',
170
+
]);
171
+
172
+
$result = $this->mapper->deleteByUri('at://did:plc:test/app.test.record/todelete');
173
+
174
+
$this->assertTrue($result);
175
+
$this->assertNull($this->mapper->findByUri('at://did:plc:test/app.test.record/todelete'));
176
+
}
177
+
178
+
public function test_delete_by_uri_returns_false_when_not_exists(): void
179
+
{
180
+
$result = $this->mapper->deleteByUri('at://nonexistent/col/rkey');
181
+
182
+
$this->assertFalse($result);
183
+
}
184
+
}
+137
tests/Unit/SchemaMapperTest.php
+137
tests/Unit/SchemaMapperTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit;
4
+
5
+
use SocialDept\AtpParity\Support\SchemaMapper;
6
+
use SocialDept\AtpParity\Tests\Fixtures\TestModel;
7
+
use SocialDept\AtpParity\Tests\Fixtures\TestRecord;
8
+
use SocialDept\AtpParity\Tests\TestCase;
9
+
10
+
class SchemaMapperTest extends TestCase
11
+
{
12
+
public function test_record_class_returns_schema_class(): void
13
+
{
14
+
$mapper = new SchemaMapper(
15
+
schemaClass: TestRecord::class,
16
+
modelClass: TestModel::class,
17
+
toAttributes: fn () => [],
18
+
toRecordData: fn () => [],
19
+
);
20
+
21
+
$this->assertSame(TestRecord::class, $mapper->recordClass());
22
+
}
23
+
24
+
public function test_model_class_returns_model_class(): void
25
+
{
26
+
$mapper = new SchemaMapper(
27
+
schemaClass: TestRecord::class,
28
+
modelClass: TestModel::class,
29
+
toAttributes: fn () => [],
30
+
toRecordData: fn () => [],
31
+
);
32
+
33
+
$this->assertSame(TestModel::class, $mapper->modelClass());
34
+
}
35
+
36
+
public function test_lexicon_returns_schema_lexicon(): void
37
+
{
38
+
$mapper = new SchemaMapper(
39
+
schemaClass: TestRecord::class,
40
+
modelClass: TestModel::class,
41
+
toAttributes: fn () => [],
42
+
toRecordData: fn () => [],
43
+
);
44
+
45
+
$this->assertSame('app.test.record', $mapper->lexicon());
46
+
}
47
+
48
+
public function test_to_model_invokes_to_attributes_closure(): void
49
+
{
50
+
$closureCalled = false;
51
+
52
+
$mapper = new SchemaMapper(
53
+
schemaClass: TestRecord::class,
54
+
modelClass: TestModel::class,
55
+
toAttributes: function (TestRecord $record) use (&$closureCalled) {
56
+
$closureCalled = true;
57
+
58
+
return ['content' => $record->text.'_transformed'];
59
+
},
60
+
toRecordData: fn () => [],
61
+
);
62
+
63
+
$record = new TestRecord(text: 'original');
64
+
$model = $mapper->toModel($record);
65
+
66
+
$this->assertTrue($closureCalled);
67
+
$this->assertSame('original_transformed', $model->content);
68
+
}
69
+
70
+
public function test_to_record_invokes_to_record_data_closure(): void
71
+
{
72
+
$closureCalled = false;
73
+
74
+
$mapper = new SchemaMapper(
75
+
schemaClass: TestRecord::class,
76
+
modelClass: TestModel::class,
77
+
toAttributes: fn () => [],
78
+
toRecordData: function (TestModel $model) use (&$closureCalled) {
79
+
$closureCalled = true;
80
+
81
+
return ['text' => strtoupper($model->content)];
82
+
},
83
+
);
84
+
85
+
$model = new TestModel(['content' => 'hello']);
86
+
$record = $mapper->toRecord($model);
87
+
88
+
$this->assertTrue($closureCalled);
89
+
$this->assertSame('HELLO', $record->text);
90
+
}
91
+
92
+
public function test_closures_receive_correct_types(): void
93
+
{
94
+
$receivedRecordType = null;
95
+
$receivedModelType = null;
96
+
97
+
$mapper = new SchemaMapper(
98
+
schemaClass: TestRecord::class,
99
+
modelClass: TestModel::class,
100
+
toAttributes: function ($record) use (&$receivedRecordType) {
101
+
$receivedRecordType = get_class($record);
102
+
103
+
return ['content' => $record->text];
104
+
},
105
+
toRecordData: function ($model) use (&$receivedModelType) {
106
+
$receivedModelType = get_class($model);
107
+
108
+
return ['text' => $model->content];
109
+
},
110
+
);
111
+
112
+
$mapper->toModel(new TestRecord(text: 'test'));
113
+
$mapper->toRecord(new TestModel(['content' => 'test']));
114
+
115
+
$this->assertSame(TestRecord::class, $receivedRecordType);
116
+
$this->assertSame(TestModel::class, $receivedModelType);
117
+
}
118
+
119
+
public function test_upsert_works_with_schema_mapper(): void
120
+
{
121
+
$mapper = new SchemaMapper(
122
+
schemaClass: TestRecord::class,
123
+
modelClass: TestModel::class,
124
+
toAttributes: fn (TestRecord $r) => ['content' => $r->text],
125
+
toRecordData: fn (TestModel $m) => ['text' => $m->content],
126
+
);
127
+
128
+
$record = new TestRecord(text: 'schema mapper test');
129
+
$meta = ['uri' => 'at://test/col/rkey', 'cid' => 'cid123'];
130
+
131
+
$model = $mapper->upsert($record, $meta);
132
+
133
+
$this->assertTrue($model->exists);
134
+
$this->assertSame('schema mapper test', $model->content);
135
+
$this->assertSame('at://test/col/rkey', $model->atp_uri);
136
+
}
137
+
}
+118
tests/Unit/Sync/ConflictDetectorTest.php
+118
tests/Unit/Sync/ConflictDetectorTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit\Sync;
4
+
5
+
use Carbon\Carbon;
6
+
use SocialDept\AtpParity\Sync\ConflictDetector;
7
+
use SocialDept\AtpParity\Tests\Fixtures\SyncableModel;
8
+
use SocialDept\AtpParity\Tests\Fixtures\TestModel;
9
+
use SocialDept\AtpParity\Tests\Fixtures\TestRecord;
10
+
use SocialDept\AtpParity\Tests\TestCase;
11
+
12
+
class ConflictDetectorTest extends TestCase
13
+
{
14
+
private ConflictDetector $detector;
15
+
16
+
protected function setUp(): void
17
+
{
18
+
parent::setUp();
19
+
$this->detector = new ConflictDetector();
20
+
}
21
+
22
+
public function test_no_conflict_when_cid_matches(): void
23
+
{
24
+
$model = new TestModel([
25
+
'atp_cid' => 'samecid123',
26
+
'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'),
27
+
'updated_at' => Carbon::parse('2024-01-15 13:00:00'), // local changes
28
+
]);
29
+
$record = new TestRecord(text: 'Remote content');
30
+
31
+
$hasConflict = $this->detector->hasConflict($model, $record, 'samecid123');
32
+
33
+
$this->assertFalse($hasConflict);
34
+
}
35
+
36
+
public function test_no_conflict_when_no_local_changes(): void
37
+
{
38
+
$model = new TestModel([
39
+
'atp_cid' => 'oldcid',
40
+
'atp_synced_at' => Carbon::parse('2024-01-15 13:00:00'),
41
+
'updated_at' => Carbon::parse('2024-01-15 12:00:00'), // updated before sync
42
+
]);
43
+
$record = new TestRecord(text: 'Remote content');
44
+
45
+
$hasConflict = $this->detector->hasConflict($model, $record, 'newcid');
46
+
47
+
$this->assertFalse($hasConflict);
48
+
}
49
+
50
+
public function test_conflict_when_cid_differs_and_local_changes(): void
51
+
{
52
+
$model = new TestModel([
53
+
'atp_cid' => 'oldcid',
54
+
'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'),
55
+
'updated_at' => Carbon::parse('2024-01-15 13:00:00'), // local changes
56
+
]);
57
+
$record = new TestRecord(text: 'Remote content');
58
+
59
+
$hasConflict = $this->detector->hasConflict($model, $record, 'newcid');
60
+
61
+
$this->assertTrue($hasConflict);
62
+
}
63
+
64
+
public function test_conflict_when_never_synced(): void
65
+
{
66
+
$model = new TestModel([
67
+
'atp_cid' => 'cid',
68
+
// No atp_synced_at means never synced, which implies local changes
69
+
]);
70
+
$record = new TestRecord(text: 'Remote');
71
+
72
+
$hasConflict = $this->detector->hasConflict($model, $record, 'differentcid');
73
+
74
+
$this->assertTrue($hasConflict);
75
+
}
76
+
77
+
public function test_uses_syncs_with_atp_trait_method(): void
78
+
{
79
+
$model = new SyncableModel([
80
+
'atp_cid' => 'oldcid',
81
+
'atp_synced_at' => Carbon::parse('2024-01-15 12:00:00'),
82
+
'updated_at' => Carbon::parse('2024-01-15 13:00:00'),
83
+
]);
84
+
$record = new TestRecord(text: 'Remote');
85
+
86
+
$hasConflict = $this->detector->hasConflict($model, $record, 'newcid');
87
+
88
+
$this->assertTrue($hasConflict);
89
+
}
90
+
91
+
public function test_no_conflict_when_synced_after_update_with_trait(): void
92
+
{
93
+
$model = new SyncableModel([
94
+
'atp_cid' => 'oldcid',
95
+
'updated_at' => Carbon::parse('2024-01-15 12:00:00'),
96
+
'atp_synced_at' => Carbon::parse('2024-01-15 13:00:00'),
97
+
]);
98
+
$record = new TestRecord(text: 'Remote');
99
+
100
+
$hasConflict = $this->detector->hasConflict($model, $record, 'newcid');
101
+
102
+
$this->assertFalse($hasConflict);
103
+
}
104
+
105
+
public function test_no_conflict_without_updated_at(): void
106
+
{
107
+
$model = new TestModel([
108
+
'atp_cid' => 'cid',
109
+
'atp_synced_at' => Carbon::now(),
110
+
// No updated_at
111
+
]);
112
+
$record = new TestRecord(text: 'Remote');
113
+
114
+
$hasConflict = $this->detector->hasConflict($model, $record, 'newcid');
115
+
116
+
$this->assertFalse($hasConflict);
117
+
}
118
+
}
+66
tests/Unit/Sync/ConflictResolutionTest.php
+66
tests/Unit/Sync/ConflictResolutionTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit\Sync;
4
+
5
+
use SocialDept\AtpParity\Sync\ConflictResolution;
6
+
use SocialDept\AtpParity\Sync\PendingConflict;
7
+
use SocialDept\AtpParity\Tests\Fixtures\TestModel;
8
+
use SocialDept\AtpParity\Tests\TestCase;
9
+
10
+
class ConflictResolutionTest extends TestCase
11
+
{
12
+
public function test_remote_wins_creates_resolved_resolution(): void
13
+
{
14
+
$model = new TestModel();
15
+
16
+
$resolution = ConflictResolution::remoteWins($model);
17
+
18
+
$this->assertTrue($resolution->isResolved());
19
+
$this->assertFalse($resolution->isPending());
20
+
$this->assertSame('remote', $resolution->winner);
21
+
$this->assertSame($model, $resolution->model);
22
+
$this->assertNull($resolution->pending);
23
+
}
24
+
25
+
public function test_local_wins_creates_resolved_resolution(): void
26
+
{
27
+
$model = new TestModel();
28
+
29
+
$resolution = ConflictResolution::localWins($model);
30
+
31
+
$this->assertTrue($resolution->isResolved());
32
+
$this->assertFalse($resolution->isPending());
33
+
$this->assertSame('local', $resolution->winner);
34
+
$this->assertSame($model, $resolution->model);
35
+
$this->assertNull($resolution->pending);
36
+
}
37
+
38
+
public function test_pending_creates_unresolved_resolution(): void
39
+
{
40
+
$pending = new PendingConflict();
41
+
42
+
$resolution = ConflictResolution::pending($pending);
43
+
44
+
$this->assertFalse($resolution->isResolved());
45
+
$this->assertTrue($resolution->isPending());
46
+
$this->assertSame('manual', $resolution->winner);
47
+
$this->assertNull($resolution->model);
48
+
$this->assertSame($pending, $resolution->pending);
49
+
}
50
+
51
+
public function test_is_resolved_returns_correct_boolean(): void
52
+
{
53
+
$resolved = new ConflictResolution(resolved: true, winner: 'remote');
54
+
$unresolved = new ConflictResolution(resolved: false, winner: 'manual');
55
+
56
+
$this->assertTrue($resolved->isResolved());
57
+
$this->assertFalse($unresolved->isResolved());
58
+
}
59
+
60
+
public function test_is_pending_returns_false_when_no_pending_conflict(): void
61
+
{
62
+
$resolution = new ConflictResolution(resolved: false, winner: 'manual');
63
+
64
+
$this->assertFalse($resolution->isPending());
65
+
}
66
+
}
+83
tests/Unit/Sync/ConflictStrategyTest.php
+83
tests/Unit/Sync/ConflictStrategyTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpParity\Tests\Unit\Sync;
4
+
5
+
use SocialDept\AtpParity\Sync\ConflictStrategy;
6
+
use SocialDept\AtpParity\Tests\TestCase;
7
+
8
+
class ConflictStrategyTest extends TestCase
9
+
{
10
+
public function test_remote_wins_has_correct_value(): void
11
+
{
12
+
$this->assertSame('remote', ConflictStrategy::RemoteWins->value);
13
+
}
14
+
15
+
public function test_local_wins_has_correct_value(): void
16
+
{
17
+
$this->assertSame('local', ConflictStrategy::LocalWins->value);
18
+
}
19
+
20
+
public function test_newest_wins_has_correct_value(): void
21
+
{
22
+
$this->assertSame('newest', ConflictStrategy::NewestWins->value);
23
+
}
24
+
25
+
public function test_manual_has_correct_value(): void
26
+
{
27
+
$this->assertSame('manual', ConflictStrategy::Manual->value);
28
+
}
29
+
30
+
public function test_from_config_returns_remote_wins_by_default(): void
31
+
{
32
+
config()->set('parity.conflicts.strategy', 'remote');
33
+
34
+
$strategy = ConflictStrategy::fromConfig();
35
+
36
+
$this->assertSame(ConflictStrategy::RemoteWins, $strategy);
37
+
}
38
+
39
+
public function test_from_config_returns_local_wins(): void
40
+
{
41
+
config()->set('parity.conflicts.strategy', 'local');
42
+
43
+
$strategy = ConflictStrategy::fromConfig();
44
+
45
+
$this->assertSame(ConflictStrategy::LocalWins, $strategy);
46
+
}
47
+
48
+
public function test_from_config_returns_newest_wins(): void
49
+
{
50
+
config()->set('parity.conflicts.strategy', 'newest');
51
+
52
+
$strategy = ConflictStrategy::fromConfig();
53
+
54
+
$this->assertSame(ConflictStrategy::NewestWins, $strategy);
55
+
}
56
+
57
+
public function test_from_config_returns_manual(): void
58
+
{
59
+
config()->set('parity.conflicts.strategy', 'manual');
60
+
61
+
$strategy = ConflictStrategy::fromConfig();
62
+
63
+
$this->assertSame(ConflictStrategy::Manual, $strategy);
64
+
}
65
+
66
+
public function test_from_config_defaults_to_remote_wins_for_invalid_value(): void
67
+
{
68
+
config()->set('parity.conflicts.strategy', 'invalid');
69
+
70
+
$strategy = ConflictStrategy::fromConfig();
71
+
72
+
$this->assertSame(ConflictStrategy::RemoteWins, $strategy);
73
+
}
74
+
75
+
public function test_from_config_defaults_to_remote_wins_when_not_set(): void
76
+
{
77
+
config()->set('parity.conflicts.strategy', null);
78
+
79
+
$strategy = ConflictStrategy::fromConfig();
80
+
81
+
$this->assertSame(ConflictStrategy::RemoteWins, $strategy);
82
+
}
83
+
}