···11+<?php
22+33+namespace SocialDept\AtpParity\Contracts;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpSchema\Data\Data;
77+88+/**
99+ * Contract for bidirectional mapping between Record DTOs and Eloquent models.
1010+ *
1111+ * @template TRecord of Data
1212+ * @template TModel of Model
1313+ */
1414+interface RecordMapper
1515+{
1616+ /**
1717+ * Get the Record class this mapper handles.
1818+ *
1919+ * @return class-string<TRecord>
2020+ */
2121+ public function recordClass(): string;
2222+2323+ /**
2424+ * Get the Model class this mapper handles.
2525+ *
2626+ * @return class-string<TModel>
2727+ */
2828+ public function modelClass(): string;
2929+3030+ /**
3131+ * Get the lexicon NSID this mapper handles.
3232+ */
3333+ public function lexicon(): string;
3434+3535+ /**
3636+ * Convert a Record DTO to an Eloquent Model.
3737+ *
3838+ * @param TRecord $record
3939+ * @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta AT Protocol metadata
4040+ * @return TModel
4141+ */
4242+ public function toModel(Data $record, array $meta = []): Model;
4343+4444+ /**
4545+ * Convert an Eloquent Model to a Record DTO.
4646+ *
4747+ * @param TModel $model
4848+ * @return TRecord
4949+ */
5050+ public function toRecord(Model $model): Data;
5151+5252+ /**
5353+ * Update an existing model with data from a record.
5454+ *
5555+ * @param TModel $model
5656+ * @param TRecord $record
5757+ * @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta
5858+ * @return TModel
5959+ */
6060+ public function updateModel(Model $model, Data $record, array $meta = []): Model;
6161+6262+ /**
6363+ * Find or create model from record.
6464+ *
6565+ * @param TRecord $record
6666+ * @param array{uri?: string, cid?: string, did?: string, rkey?: string} $meta
6767+ * @return TModel
6868+ */
6969+ public function upsert(Data $record, array $meta = []): Model;
7070+7171+ /**
7272+ * Find model by AT Protocol URI.
7373+ *
7474+ * @return TModel|null
7575+ */
7676+ public function findByUri(string $uri): ?Model;
7777+7878+ /**
7979+ * Delete model by AT Protocol URI.
8080+ */
8181+ public function deleteByUri(string $uri): bool;
8282+}
+25
src/Data/Record.php
···11+<?php
22+33+namespace SocialDept\AtpParity\Data;
44+55+use SocialDept\AtpClient\Contracts\Recordable;
66+use SocialDept\AtpSchema\Data\Data;
77+88+/**
99+ * Base class for custom AT Protocol records.
1010+ *
1111+ * Extends atp-schema's Data for full compatibility with the ecosystem,
1212+ * including union type support, validation, equality, and hashing.
1313+ *
1414+ * Implements Recordable for seamless atp-client integration.
1515+ */
1616+abstract class Record extends Data implements Recordable
1717+{
1818+ /**
1919+ * Get the record type (alias for getLexicon for Recordable interface).
2020+ */
2121+ public function getType(): string
2222+ {
2323+ return static::getLexicon();
2424+ }
2525+}
+93
src/MapperRegistry.php
···11+<?php
22+33+namespace SocialDept\AtpParity;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpParity\Contracts\RecordMapper;
77+use SocialDept\AtpSchema\Data\Data;
88+99+/**
1010+ * Registry for RecordMapper instances.
1111+ *
1212+ * Allows looking up mappers by Record class, Model class, or lexicon NSID.
1313+ */
1414+class MapperRegistry
1515+{
1616+ /** @var array<class-string<Data>, RecordMapper> */
1717+ protected array $byRecord = [];
1818+1919+ /** @var array<class-string<Model>, RecordMapper> */
2020+ protected array $byModel = [];
2121+2222+ /** @var array<string, RecordMapper> Keyed by NSID */
2323+ protected array $byLexicon = [];
2424+2525+ /**
2626+ * Register a mapper.
2727+ */
2828+ public function register(RecordMapper $mapper): void
2929+ {
3030+ $recordClass = $mapper->recordClass();
3131+ $modelClass = $mapper->modelClass();
3232+3333+ $this->byRecord[$recordClass] = $mapper;
3434+ $this->byModel[$modelClass] = $mapper;
3535+ $this->byLexicon[$mapper->lexicon()] = $mapper;
3636+ }
3737+3838+ /**
3939+ * Get a mapper by Record class.
4040+ *
4141+ * @param class-string<Data> $recordClass
4242+ */
4343+ public function forRecord(string $recordClass): ?RecordMapper
4444+ {
4545+ return $this->byRecord[$recordClass] ?? null;
4646+ }
4747+4848+ /**
4949+ * Get a mapper by Model class.
5050+ *
5151+ * @param class-string<Model> $modelClass
5252+ */
5353+ public function forModel(string $modelClass): ?RecordMapper
5454+ {
5555+ return $this->byModel[$modelClass] ?? null;
5656+ }
5757+5858+ /**
5959+ * Get a mapper by lexicon NSID.
6060+ */
6161+ public function forLexicon(string $nsid): ?RecordMapper
6262+ {
6363+ return $this->byLexicon[$nsid] ?? null;
6464+ }
6565+6666+ /**
6767+ * Check if a mapper exists for the given lexicon.
6868+ */
6969+ public function hasLexicon(string $nsid): bool
7070+ {
7171+ return isset($this->byLexicon[$nsid]);
7272+ }
7373+7474+ /**
7575+ * Get all registered lexicon NSIDs.
7676+ *
7777+ * @return array<string>
7878+ */
7979+ public function lexicons(): array
8080+ {
8181+ return array_keys($this->byLexicon);
8282+ }
8383+8484+ /**
8585+ * Get all registered mappers.
8686+ *
8787+ * @return array<RecordMapper>
8888+ */
8989+ public function all(): array
9090+ {
9191+ return array_values($this->byLexicon);
9292+ }
9393+}
+154
src/RecordMapper.php
···11+<?php
22+33+namespace SocialDept\AtpParity;
44+55+use Illuminate\Database\Eloquent\Model;
66+use SocialDept\AtpParity\Contracts\RecordMapper as RecordMapperContract;
77+use SocialDept\AtpSchema\Data\Data;
88+99+/**
1010+ * Abstract base class for bidirectional Record <-> Model mapping.
1111+ *
1212+ * @template TRecord of Data
1313+ * @template TModel of Model
1414+ *
1515+ * @implements RecordMapperContract<TRecord, TModel>
1616+ */
1717+abstract class RecordMapper implements RecordMapperContract
1818+{
1919+ /**
2020+ * Get the Record class this mapper handles.
2121+ *
2222+ * @return class-string<TRecord>
2323+ */
2424+ abstract public function recordClass(): string;
2525+2626+ /**
2727+ * Get the Model class this mapper handles.
2828+ *
2929+ * @return class-string<TModel>
3030+ */
3131+ abstract public function modelClass(): string;
3232+3333+ /**
3434+ * Map record properties to model attributes.
3535+ *
3636+ * @param TRecord $record
3737+ * @return array<string, mixed>
3838+ */
3939+ abstract protected function recordToAttributes(Data $record): array;
4040+4141+ /**
4242+ * Map model attributes to record properties.
4343+ *
4444+ * @param TModel $model
4545+ * @return array<string, mixed>
4646+ */
4747+ abstract protected function modelToRecordData(Model $model): array;
4848+4949+ /**
5050+ * Get the lexicon NSID this mapper handles.
5151+ */
5252+ public function lexicon(): string
5353+ {
5454+ $recordClass = $this->recordClass();
5555+5656+ return $recordClass::getLexicon();
5757+ }
5858+5959+ /**
6060+ * Get the column name for storing the AT Protocol URI.
6161+ */
6262+ protected function uriColumn(): string
6363+ {
6464+ return config('parity.columns.uri', 'atp_uri');
6565+ }
6666+6767+ /**
6868+ * Get the column name for storing the AT Protocol CID.
6969+ */
7070+ protected function cidColumn(): string
7171+ {
7272+ return config('parity.columns.cid', 'atp_cid');
7373+ }
7474+7575+ public function toModel(Data $record, array $meta = []): Model
7676+ {
7777+ $modelClass = $this->modelClass();
7878+ $attributes = $this->recordToAttributes($record);
7979+ $attributes = $this->applyMeta($attributes, $meta);
8080+8181+ return new $modelClass($attributes);
8282+ }
8383+8484+ public function toRecord(Model $model): Data
8585+ {
8686+ $recordClass = $this->recordClass();
8787+8888+ return $recordClass::fromArray($this->modelToRecordData($model));
8989+ }
9090+9191+ public function updateModel(Model $model, Data $record, array $meta = []): Model
9292+ {
9393+ $attributes = $this->recordToAttributes($record);
9494+ $attributes = $this->applyMeta($attributes, $meta);
9595+ $model->fill($attributes);
9696+9797+ return $model;
9898+ }
9999+100100+ public function findByUri(string $uri): ?Model
101101+ {
102102+ $modelClass = $this->modelClass();
103103+104104+ return $modelClass::where($this->uriColumn(), $uri)->first();
105105+ }
106106+107107+ public function upsert(Data $record, array $meta = []): Model
108108+ {
109109+ $uri = $meta['uri'] ?? null;
110110+111111+ if ($uri) {
112112+ $existing = $this->findByUri($uri);
113113+114114+ if ($existing) {
115115+ $this->updateModel($existing, $record, $meta);
116116+ $existing->save();
117117+118118+ return $existing;
119119+ }
120120+ }
121121+122122+ $model = $this->toModel($record, $meta);
123123+ $model->save();
124124+125125+ return $model;
126126+ }
127127+128128+ public function deleteByUri(string $uri): bool
129129+ {
130130+ $model = $this->findByUri($uri);
131131+132132+ if ($model) {
133133+ return (bool) $model->delete();
134134+ }
135135+136136+ return false;
137137+ }
138138+139139+ /**
140140+ * Apply AT Protocol metadata to attributes.
141141+ */
142142+ protected function applyMeta(array $attributes, array $meta): array
143143+ {
144144+ if (isset($meta['uri'])) {
145145+ $attributes[$this->uriColumn()] = $meta['uri'];
146146+ }
147147+148148+ if (isset($meta['cid'])) {
149149+ $attributes[$this->cidColumn()] = $meta['cid'];
150150+ }
151151+152152+ return $attributes;
153153+ }
154154+}