Maintain local ⭤ remote in sync with automatic AT Protocol parity for Laravel (alpha & unstable)

Add publish service

Changed files
+298
src
+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
···
··· 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 + }