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

Configure Feed

Select the types of activity you want to include in your feed.

Add Eloquent model traits

+524
+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
··· 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
··· 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
··· 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
··· 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 + }