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

Add sync and conflict resolution system

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