Maintain local ⭤ remote in sync with automatic AT Protocol parity for Laravel (alpha & unstable)
1<?php
2
3namespace SocialDept\AtpParity\Support;
4
5use Illuminate\Database\Eloquent\Model;
6use SocialDept\AtpClient\AtpClient;
7use SocialDept\AtpClient\Data\Responses\Atproto\Repo\GetRecordResponse;
8use SocialDept\AtpClient\Facades\Atp;
9use SocialDept\AtpParity\MapperRegistry;
10use SocialDept\AtpResolver\Facades\Resolver;
11use 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 */
19class 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}