···11+<?php
22+33+namespace SocialDept\AtpParity\Sync;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpParity\Concerns\SyncsWithAtp;
77+use SocialDept\AtpSchema\Data\Data;
88+99+/**
1010+ * Detects conflicts between local and remote record versions.
1111+ */
1212+class ConflictDetector
1313+{
1414+ /**
1515+ * Check if there's a conflict between local model and remote record.
1616+ */
1717+ public function hasConflict(Model $model, Data $record, string $cid): bool
1818+ {
1919+ // No conflict if model doesn't have local changes
2020+ if (! $this->modelHasLocalChanges($model)) {
2121+ return false;
2222+ }
2323+2424+ // No conflict if CID matches (same version)
2525+ if ($this->getCid($model) === $cid) {
2626+ return false;
2727+ }
2828+2929+ return true;
3030+ }
3131+3232+ /**
3333+ * Check if the model has local changes since last sync.
3434+ */
3535+ protected function modelHasLocalChanges(Model $model): bool
3636+ {
3737+ // Use trait method if available
3838+ if ($this->usesTrait($model, SyncsWithAtp::class)) {
3939+ return $model->hasLocalChanges();
4040+ }
4141+4242+ // Fallback: compare updated_at with a sync timestamp if available
4343+ $syncedAt = $model->getAttribute('atp_synced_at');
4444+4545+ if (! $syncedAt) {
4646+ return true;
4747+ }
4848+4949+ $updatedAt = $model->getAttribute('updated_at');
5050+5151+ if (! $updatedAt) {
5252+ return false;
5353+ }
5454+5555+ return $updatedAt > $syncedAt;
5656+ }
5757+5858+ /**
5959+ * Get the CID from a model.
6060+ */
6161+ protected function getCid(Model $model): ?string
6262+ {
6363+ $column = config('parity.columns.cid', 'atp_cid');
6464+6565+ return $model->getAttribute($column);
6666+ }
6767+6868+ /**
6969+ * Check if a model uses a specific trait.
7070+ *
7171+ * @param class-string $trait
7272+ */
7373+ protected function usesTrait(Model $model, string $trait): bool
7474+ {
7575+ return in_array($trait, class_uses_recursive($model));
7676+ }
7777+}
+70
src/Sync/ConflictResolution.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Sync;
44+55+use Illuminate\Database\Eloquent\Model;
66+77+/**
88+ * Value object representing the result of conflict resolution.
99+ */
1010+readonly class ConflictResolution
1111+{
1212+ public function __construct(
1313+ public bool $resolved,
1414+ public string $winner,
1515+ public ?Model $model = null,
1616+ public ?PendingConflict $pending = null,
1717+ ) {}
1818+1919+ /**
2020+ * Check if the conflict was resolved.
2121+ */
2222+ public function isResolved(): bool
2323+ {
2424+ return $this->resolved;
2525+ }
2626+2727+ /**
2828+ * Check if the conflict requires manual resolution.
2929+ */
3030+ public function isPending(): bool
3131+ {
3232+ return ! $this->resolved && $this->pending !== null;
3333+ }
3434+3535+ /**
3636+ * Create resolution where remote wins.
3737+ */
3838+ public static function remoteWins(Model $model): self
3939+ {
4040+ return new self(
4141+ resolved: true,
4242+ winner: 'remote',
4343+ model: $model,
4444+ );
4545+ }
4646+4747+ /**
4848+ * Create resolution where local wins.
4949+ */
5050+ public static function localWins(Model $model): self
5151+ {
5252+ return new self(
5353+ resolved: true,
5454+ winner: 'local',
5555+ model: $model,
5656+ );
5757+ }
5858+5959+ /**
6060+ * Create pending resolution for manual review.
6161+ */
6262+ public static function pending(PendingConflict $conflict): self
6363+ {
6464+ return new self(
6565+ resolved: false,
6666+ winner: 'manual',
6767+ pending: $conflict,
6868+ );
6969+ }
7070+}
+118
src/Sync/ConflictResolver.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Sync;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpParity\Contracts\RecordMapper;
77+use SocialDept\AtpParity\Events\ConflictDetected;
88+use SocialDept\AtpSchema\Data\Data;
99+1010+/**
1111+ * Resolves conflicts between local and remote record versions.
1212+ */
1313+class ConflictResolver
1414+{
1515+ /**
1616+ * Resolve a conflict according to the specified strategy.
1717+ */
1818+ public function resolve(
1919+ Model $model,
2020+ Data $record,
2121+ array $meta,
2222+ RecordMapper $mapper,
2323+ ConflictStrategy $strategy
2424+ ): ConflictResolution {
2525+ return match ($strategy) {
2626+ ConflictStrategy::RemoteWins => $this->applyRemote($model, $record, $meta, $mapper),
2727+ ConflictStrategy::LocalWins => $this->keepLocal($model),
2828+ ConflictStrategy::NewestWins => $this->compareAndApply($model, $record, $meta, $mapper),
2929+ ConflictStrategy::Manual => $this->flagForReview($model, $record, $meta, $mapper),
3030+ };
3131+ }
3232+3333+ /**
3434+ * Apply the remote version, overwriting local changes.
3535+ */
3636+ protected function applyRemote(
3737+ Model $model,
3838+ Data $record,
3939+ array $meta,
4040+ RecordMapper $mapper
4141+ ): ConflictResolution {
4242+ $mapper->updateModel($model, $record, $meta);
4343+ $model->save();
4444+4545+ return ConflictResolution::remoteWins($model);
4646+ }
4747+4848+ /**
4949+ * Keep the local version, ignoring remote changes.
5050+ */
5151+ protected function keepLocal(Model $model): ConflictResolution
5252+ {
5353+ return ConflictResolution::localWins($model);
5454+ }
5555+5656+ /**
5757+ * Compare timestamps and apply the newest version.
5858+ */
5959+ protected function compareAndApply(
6060+ Model $model,
6161+ Data $record,
6262+ array $meta,
6363+ RecordMapper $mapper
6464+ ): ConflictResolution {
6565+ $localUpdatedAt = $model->getAttribute('updated_at');
6666+6767+ // Try to get remote timestamp from record
6868+ $remoteCreatedAt = $record->createdAt ?? null;
6969+7070+ // If we can't compare, default to remote wins
7171+ if (! $localUpdatedAt || ! $remoteCreatedAt) {
7272+ return $this->applyRemote($model, $record, $meta, $mapper);
7373+ }
7474+7575+ // Compare timestamps
7676+ if ($localUpdatedAt > $remoteCreatedAt) {
7777+ return $this->keepLocal($model);
7878+ }
7979+8080+ return $this->applyRemote($model, $record, $meta, $mapper);
8181+ }
8282+8383+ /**
8484+ * Flag the conflict for manual review.
8585+ */
8686+ protected function flagForReview(
8787+ Model $model,
8888+ Data $record,
8989+ array $meta,
9090+ RecordMapper $mapper
9191+ ): ConflictResolution {
9292+ // Create a pending conflict record
9393+ $conflict = PendingConflict::create([
9494+ 'model_type' => get_class($model),
9595+ 'model_id' => $model->getKey(),
9696+ 'uri' => $meta['uri'] ?? null,
9797+ 'local_data' => $model->toArray(),
9898+ 'remote_data' => $this->buildRemoteData($record, $meta, $mapper),
9999+ 'status' => 'pending',
100100+ ]);
101101+102102+ // Dispatch event for notification
103103+ event(new ConflictDetected($model, $record, $meta, $conflict));
104104+105105+ return ConflictResolution::pending($conflict);
106106+ }
107107+108108+ /**
109109+ * Build the remote data array for storage.
110110+ */
111111+ protected function buildRemoteData(Data $record, array $meta, RecordMapper $mapper): array
112112+ {
113113+ // Create a temporary model with the remote data
114114+ $tempModel = $mapper->toModel($record, $meta);
115115+116116+ return $tempModel->toArray();
117117+ }
118118+}
+42
src/Sync/ConflictStrategy.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Sync;
44+55+/**
66+ * Strategy for resolving conflicts between local and remote changes.
77+ */
88+enum ConflictStrategy: string
99+{
1010+ /**
1111+ * Remote (AT Protocol) is source of truth.
1212+ * Local changes are overwritten.
1313+ */
1414+ case RemoteWins = 'remote';
1515+1616+ /**
1717+ * Local database is source of truth.
1818+ * Remote changes are ignored.
1919+ */
2020+ case LocalWins = 'local';
2121+2222+ /**
2323+ * Compare timestamps and use the newest version.
2424+ */
2525+ case NewestWins = 'newest';
2626+2727+ /**
2828+ * Flag conflict for manual review.
2929+ * Neither version is applied automatically.
3030+ */
3131+ case Manual = 'manual';
3232+3333+ /**
3434+ * Create from config value.
3535+ */
3636+ public static function fromConfig(): self
3737+ {
3838+ $strategy = config('parity.conflicts.strategy', 'remote');
3939+4040+ return self::tryFrom($strategy) ?? self::RemoteWins;
4141+ }
4242+}
+127
src/Sync/PendingConflict.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Sync;
44+55+use Illuminate\Database\Eloquent\Model;
66+use Illuminate\Database\Eloquent\Relations\MorphTo;
77+88+/**
99+ * Model for storing pending conflicts requiring manual resolution.
1010+ */
1111+class PendingConflict extends Model
1212+{
1313+ protected $guarded = [];
1414+1515+ protected $casts = [
1616+ 'local_data' => 'array',
1717+ 'remote_data' => 'array',
1818+ 'resolved_at' => 'datetime',
1919+ ];
2020+2121+ /**
2222+ * Get the table name from config.
2323+ */
2424+ public function getTable(): string
2525+ {
2626+ return config('parity.conflicts.table', 'parity_conflicts');
2727+ }
2828+2929+ /**
3030+ * Get the related model.
3131+ */
3232+ public function model(): MorphTo
3333+ {
3434+ return $this->morphTo();
3535+ }
3636+3737+ /**
3838+ * Check if this conflict is pending.
3939+ */
4040+ public function isPending(): bool
4141+ {
4242+ return $this->status === 'pending';
4343+ }
4444+4545+ /**
4646+ * Check if this conflict has been resolved.
4747+ */
4848+ public function isResolved(): bool
4949+ {
5050+ return $this->status === 'resolved';
5151+ }
5252+5353+ /**
5454+ * Check if this conflict was dismissed.
5555+ */
5656+ public function isDismissed(): bool
5757+ {
5858+ return $this->status === 'dismissed';
5959+ }
6060+6161+ /**
6262+ * Resolve the conflict with the local version.
6363+ */
6464+ public function resolveWithLocal(): void
6565+ {
6666+ $this->update([
6767+ 'status' => 'resolved',
6868+ 'resolution' => 'local',
6969+ 'resolved_at' => now(),
7070+ ]);
7171+ }
7272+7373+ /**
7474+ * Resolve the conflict with the remote version.
7575+ */
7676+ public function resolveWithRemote(): void
7777+ {
7878+ $model = $this->model;
7979+8080+ if ($model) {
8181+ $model->fill($this->remote_data);
8282+ $model->save();
8383+ }
8484+8585+ $this->update([
8686+ 'status' => 'resolved',
8787+ 'resolution' => 'remote',
8888+ 'resolved_at' => now(),
8989+ ]);
9090+ }
9191+9292+ /**
9393+ * Dismiss this conflict without resolving.
9494+ */
9595+ public function dismiss(): void
9696+ {
9797+ $this->update([
9898+ 'status' => 'dismissed',
9999+ 'resolved_at' => now(),
100100+ ]);
101101+ }
102102+103103+ /**
104104+ * Scope to pending conflicts.
105105+ */
106106+ public function scopePending($query)
107107+ {
108108+ return $query->where('status', 'pending');
109109+ }
110110+111111+ /**
112112+ * Scope to resolved conflicts.
113113+ */
114114+ public function scopeResolved($query)
115115+ {
116116+ return $query->where('status', 'resolved');
117117+ }
118118+119119+ /**
120120+ * Scope to conflicts for a specific model.
121121+ */
122122+ public function scopeForModel($query, Model $model)
123123+ {
124124+ return $query->where('model_type', get_class($model))
125125+ ->where('model_id', $model->getKey());
126126+ }
127127+}