···11+<?php
22+33+namespace SocialDept\Schema\Contracts;
44+55+/**
66+ * Contract for Data classes that can participate in AT Protocol discriminated unions.
77+ *
88+ * Union types in AT Protocol use the $type field to discriminate between
99+ * different variants. This interface marks classes that can be used as
1010+ * union variants and provides access to their discriminator value.
1111+ */
1212+interface DiscriminatedUnion
1313+{
1414+ /**
1515+ * Get the lexicon NSID that identifies this union variant.
1616+ *
1717+ * This value is used as the $type discriminator in AT Protocol records
1818+ * to identify which specific type a union contains.
1919+ */
2020+ public static function getDiscriminator(): string;
2121+}
+25-6
src/Data/Data.php
···55use Illuminate\Contracts\Support\Arrayable;
66use Illuminate\Contracts\Support\Jsonable;
77use JsonSerializable;
88+use SocialDept\Schema\Contracts\DiscriminatedUnion;
89use Stringable;
9101010-abstract class Data implements Arrayable, Jsonable, JsonSerializable, Stringable
1111+abstract class Data implements Arrayable, DiscriminatedUnion, Jsonable, JsonSerializable, Stringable
1112{
1213 /**
1314 * Get the lexicon NSID for this data type.
···1516 abstract public static function getLexicon(): string;
16171718 /**
1919+ * Get the lexicon NSID that identifies this union variant.
2020+ *
2121+ * This is an alias for getLexicon() to satisfy the DiscriminatedUnion contract.
2222+ */
2323+ public static function getDiscriminator(): string
2424+ {
2525+ return static::getLexicon();
2626+ }
2727+2828+ /**
1829 * Convert the data to an array.
1930 */
2031 public function toArray(): array
···2233 $result = [];
23342435 foreach (get_object_vars($this) as $property => $value) {
2525- $result[$property] = $this->serializeValue($value);
3636+ // Skip null values to exclude optional fields that aren't set
3737+ if ($value !== null) {
3838+ $result[$property] = $this->serializeValue($value);
3939+ }
2640 }
27412828- return $result;
4242+ return array_filter($result);
2943 }
30443145 /**
···5771 */
5872 protected function serializeValue(mixed $value): mixed
5973 {
7474+ // Union variants must include $type for discrimination
6075 if ($value instanceof self) {
6161- return $value->toArray();
7676+ return $value->toRecord();
6277 }
63786479 if ($value instanceof Arrayable) {
6580 return $value->toArray();
6681 }
67828383+ // Preserve arrays with $type (open union data)
6884 if (is_array($value)) {
6985 return array_map(fn ($item) => $this->serializeValue($item), $value);
7086 }
···103119 */
104120 public static function fromRecord(array $record): static
105121 {
106106- return static::fromArray($record);
122122+ return static::fromArray($record['value'] ?? $record);
107123 }
108124109125 /**
···114130 */
115131 public function toRecord(): array
116132 {
117117- return $this->toArray();
133133+ return [
134134+ ...$this->toArray(),
135135+ '$type' => $this->getLexicon(),
136136+ ];
118137 }
119138120139 /**
···1010class UnionResolver
1111{
1212 use Macroable;
1313+1314 /**
1415 * Create a new UnionResolver.
1516 */
1617 public function __construct(
1718 protected ?LexiconRegistry $registry = null
1818- ) {
1919- }
1919+ ) {}
20202121 /**
2222 * Resolve union type from data.
+148
src/Support/DefaultBlobHandler.php
···11+<?php
22+33+namespace SocialDept\Schema\Support;
44+55+use Illuminate\Http\UploadedFile;
66+use Illuminate\Support\Facades\Storage;
77+use SocialDept\Schema\Contracts\BlobHandler;
88+use SocialDept\Schema\Data\BlobReference;
99+1010+class DefaultBlobHandler implements BlobHandler
1111+{
1212+ /**
1313+ * Storage disk to use.
1414+ */
1515+ protected string $disk;
1616+1717+ /**
1818+ * Storage path prefix.
1919+ */
2020+ protected string $path;
2121+2222+ /**
2323+ * Create a new DefaultBlobHandler.
2424+ */
2525+ public function __construct(
2626+ ?string $disk = null,
2727+ string $path = 'blobs'
2828+ ) {
2929+ $this->disk = $disk ?? config('filesystems.default', 'local');
3030+ $this->path = $path;
3131+ }
3232+3333+ /**
3434+ * Upload blob and create reference.
3535+ */
3636+ public function upload(UploadedFile $file): BlobReference
3737+ {
3838+ $hash = hash_file('sha256', $file->getPathname());
3939+ $mimeType = $file->getMimeType() ?? 'application/octet-stream';
4040+ $size = $file->getSize();
4141+4242+ // Store with hash as filename to enable deduplication
4343+ $storagePath = $this->path.'/'.$hash;
4444+ Storage::disk($this->disk)->put($storagePath, file_get_contents($file->getPathname()));
4545+4646+ return new BlobReference(
4747+ cid: $hash, // Using hash as CID for simplicity
4848+ mimeType: $mimeType,
4949+ size: $size
5050+ );
5151+ }
5252+5353+ /**
5454+ * Upload blob from path.
5555+ */
5656+ public function uploadFromPath(string $path): BlobReference
5757+ {
5858+ $hash = hash_file('sha256', $path);
5959+ $mimeType = mime_content_type($path) ?: 'application/octet-stream';
6060+ $size = filesize($path);
6161+6262+ // Store with hash as filename
6363+ $storagePath = $this->path.'/'.$hash;
6464+ Storage::disk($this->disk)->put($storagePath, file_get_contents($path));
6565+6666+ return new BlobReference(
6767+ cid: $hash,
6868+ mimeType: $mimeType,
6969+ size: $size
7070+ );
7171+ }
7272+7373+ /**
7474+ * Upload blob from content.
7575+ */
7676+ public function uploadFromContent(string $content, string $mimeType): BlobReference
7777+ {
7878+ $hash = hash('sha256', $content);
7979+ $size = strlen($content);
8080+8181+ // Store with hash as filename
8282+ $storagePath = $this->path.'/'.$hash;
8383+ Storage::disk($this->disk)->put($storagePath, $content);
8484+8585+ return new BlobReference(
8686+ cid: $hash,
8787+ mimeType: $mimeType,
8888+ size: $size
8989+ );
9090+ }
9191+9292+ /**
9393+ * Download blob content.
9494+ */
9595+ public function download(BlobReference $blob): string
9696+ {
9797+ $storagePath = $this->path.'/'.$blob->cid;
9898+9999+ if (! Storage::disk($this->disk)->exists($storagePath)) {
100100+ throw new \RuntimeException("Blob not found: {$blob->cid}");
101101+ }
102102+103103+ return Storage::disk($this->disk)->get($storagePath);
104104+ }
105105+106106+ /**
107107+ * Generate signed URL for blob.
108108+ */
109109+ public function url(BlobReference $blob): string
110110+ {
111111+ $storagePath = $this->path.'/'.$blob->cid;
112112+113113+ // Try to generate a temporary URL if the disk supports it
114114+ try {
115115+ return Storage::disk($this->disk)->temporaryUrl(
116116+ $storagePath,
117117+ now()->addHour()
118118+ );
119119+ } catch (\RuntimeException $e) {
120120+ // Fallback to regular URL for disks that don't support temporary URLs
121121+ return Storage::disk($this->disk)->url($storagePath);
122122+ }
123123+ }
124124+125125+ /**
126126+ * Check if blob exists in storage.
127127+ */
128128+ public function exists(BlobReference $blob): bool
129129+ {
130130+ $storagePath = $this->path.'/'.$blob->cid;
131131+132132+ return Storage::disk($this->disk)->exists($storagePath);
133133+ }
134134+135135+ /**
136136+ * Delete blob from storage.
137137+ */
138138+ public function delete(BlobReference $blob): bool
139139+ {
140140+ $storagePath = $this->path.'/'.$blob->cid;
141141+142142+ if (! Storage::disk($this->disk)->exists($storagePath)) {
143143+ return false;
144144+ }
145145+146146+ return Storage::disk($this->disk)->delete($storagePath);
147147+ }
148148+}
+100
src/Support/UnionHelper.php
···11+<?php
22+33+namespace SocialDept\Schema\Support;
44+55+use InvalidArgumentException;
66+use SocialDept\Schema\Contracts\DiscriminatedUnion;
77+use SocialDept\Schema\Data\Data;
88+99+/**
1010+ * Helper for resolving discriminated unions based on $type field.
1111+ *
1212+ * This class uses the DiscriminatedUnion interface to build type maps
1313+ * and resolve union data to the correct variant class.
1414+ */
1515+class UnionHelper
1616+{
1717+ /**
1818+ * Resolve a closed union to the correct variant class.
1919+ *
2020+ * @param array $data The union data containing a $type field
2121+ * @param array<class-string<Data>> $variants Array of possible variant class names
2222+ * @return Data The resolved variant instance
2323+ *
2424+ * @throws InvalidArgumentException If $type is missing or unknown
2525+ */
2626+ public static function resolveClosedUnion(array $data, array $variants): Data
2727+ {
2828+ // Validate $type field exists
2929+ if (! isset($data['$type'])) {
3030+ throw new InvalidArgumentException(
3131+ 'Closed union data must contain a $type field for discrimination'
3232+ );
3333+ }
3434+3535+ $type = $data['$type'];
3636+3737+ // Build type map using DiscriminatedUnion interface
3838+ $typeMap = static::buildTypeMap($variants);
3939+4040+ // Check if type is known
4141+ if (! isset($typeMap[$type])) {
4242+ $knownTypes = implode(', ', array_keys($typeMap));
4343+ throw new InvalidArgumentException(
4444+ "Unknown union type '{$type}'. Expected one of: {$knownTypes}"
4545+ );
4646+ }
4747+4848+ // Resolve to correct variant class
4949+ $class = $typeMap[$type];
5050+5151+ return $class::fromArray($data);
5252+ }
5353+5454+ /**
5555+ * Validate an open union has $type field.
5656+ *
5757+ * Open unions pass data through as-is but must have $type for future discrimination.
5858+ *
5959+ * @param array $data The union data
6060+ * @return array The validated union data
6161+ *
6262+ * @throws InvalidArgumentException If $type is missing
6363+ */
6464+ public static function validateOpenUnion(array $data): array
6565+ {
6666+ if (! isset($data['$type'])) {
6767+ throw new InvalidArgumentException(
6868+ 'Open union data must contain a $type field for future discrimination'
6969+ );
7070+ }
7171+7272+ return $data;
7373+ }
7474+7575+ /**
7676+ * Build a type map from variant classes using DiscriminatedUnion interface.
7777+ *
7878+ * @param array<class-string<Data>> $variants Array of variant class names
7979+ * @return array<string, class-string<Data>> Map of discriminator => class name
8080+ */
8181+ protected static function buildTypeMap(array $variants): array
8282+ {
8383+ $typeMap = [];
8484+8585+ foreach ($variants as $class) {
8686+ // Ensure class implements DiscriminatedUnion
8787+ if (! is_subclass_of($class, DiscriminatedUnion::class)) {
8888+ throw new InvalidArgumentException(
8989+ "Variant class {$class} must implement DiscriminatedUnion interface"
9090+ );
9191+ }
9292+9393+ // Get discriminator from the class
9494+ $discriminator = $class::getDiscriminator();
9595+ $typeMap[$discriminator] = $class;
9696+ }
9797+9898+ return $typeMap;
9999+ }
100100+}