|
| 1 | +# Storage Drivers |
| 2 | + |
| 3 | +Konomi persists user/entity interactions through the `SpaghettiDojo\Konomi\Storage\Storage` interface. Core ships a single implementation, `Storage\TableStorage`, which writes to the `{prefix}konomi_interactions` custom table. The driver is registered once as a shared container service (`Storage\Storage::class`) and consumed by both the Post and User repositories. This document describes the contract and shows how to swap in an alternative driver (e.g. WordPress meta) the Modularity way, via an [Extending Module](https://inpsyde.github.io/modularity/Modules/#extendingmodule). |
| 4 | + |
| 5 | +## The `Storage` interface |
| 6 | + |
| 7 | +```php |
| 8 | +namespace SpaghettiDojo\Konomi\Storage; |
| 9 | + |
| 10 | +interface Storage |
| 11 | +{ |
| 12 | + /** @return list<Record> */ |
| 13 | + public function read(Axis $axis, int $id, string $groupKey): array; |
| 14 | + |
| 15 | + /** @param list<Record> $records */ |
| 16 | + public function write(Axis $axis, int $id, string $groupKey, array $records): bool; |
| 17 | +} |
| 18 | +``` |
| 19 | + |
| 20 | +The same storage instance serves both the Post and User domains. There is a single `konomi_interactions` table; the `Axis` supplied per call tells the driver which column the bare `$id` addresses: |
| 21 | + |
| 22 | +```php |
| 23 | +enum Axis |
| 24 | +{ |
| 25 | + case Entity; // $id is an entity (post) id -> column entity_id |
| 26 | + case User; // $id is a user id -> column user_id |
| 27 | +} |
| 28 | +``` |
| 29 | + |
| 30 | +`Post\Repository` always calls with `Axis::Entity`, `User\Repository` always with `Axis::User`. |
| 31 | + |
| 32 | +`Record` is a readonly value object: |
| 33 | + |
| 34 | +```php |
| 35 | +final class Record |
| 36 | +{ |
| 37 | + public function __construct( |
| 38 | + public readonly int $entityId, |
| 39 | + public readonly int $userId, |
| 40 | + public readonly string $entityType, |
| 41 | + ) {} |
| 42 | +} |
| 43 | +``` |
| 44 | + |
| 45 | +`$id` is the axis identifier (a post id under `Axis::Entity`, a user id under `Axis::User`). `$groupKey` is a sanitized `User\ItemGroup` value (e.g. `"reaction"`, `"bookmark"`). |
| 46 | + |
| 47 | +`read` returns every record scoped to `($axis, $id, $groupKey)`. `write` replaces the entire scope: existing rows for that `(axis column = $id, groupKey)` are deleted and the supplied `$records` are inserted. An empty `$records` list clears the scope. Implementations should be transactional — partial writes must roll back and return `false`. |
| 48 | + |
| 49 | +## Reference: meta-backed driver |
| 50 | + |
| 51 | +The example below stores each scope as a single serialized array on `wp_postmeta` (for `Axis::Entity`) or `wp_usermeta` (for `Axis::User`). A single implementation branches on the `Axis` it receives. Copy and adapt as needed. |
| 52 | + |
| 53 | +```php |
| 54 | +namespace MyPlugin\Storage; |
| 55 | + |
| 56 | +use SpaghettiDojo\Konomi\Storage\Axis; |
| 57 | +use SpaghettiDojo\Konomi\Storage\Record; |
| 58 | +use SpaghettiDojo\Konomi\Storage\Storage; |
| 59 | + |
| 60 | +final class MetaStorage implements Storage |
| 61 | +{ |
| 62 | + private const BASE = '_konomi_items'; |
| 63 | + |
| 64 | + public function read(Axis $axis, int $id, string $groupKey): array |
| 65 | + { |
| 66 | + if ($id <= 0 || $groupKey === '') { |
| 67 | + return []; |
| 68 | + } |
| 69 | + |
| 70 | + $raw = $this->getMeta($axis, $id, self::BASE . '.' . $groupKey); |
| 71 | + if (!is_array($raw)) { |
| 72 | + return []; |
| 73 | + } |
| 74 | + |
| 75 | + $records = []; |
| 76 | + foreach ($raw as $row) { |
| 77 | + if (!is_array($row)) { |
| 78 | + continue; |
| 79 | + } |
| 80 | + $entityId = (int) ($row['entity_id'] ?? 0); |
| 81 | + $userId = (int) ($row['user_id'] ?? 0); |
| 82 | + $entityType = (string) ($row['entity_type'] ?? ''); |
| 83 | + if ($entityId <= 0 || $userId <= 0 || $entityType === '') { |
| 84 | + continue; |
| 85 | + } |
| 86 | + $records[] = new Record($entityId, $userId, $entityType); |
| 87 | + } |
| 88 | + return $records; |
| 89 | + } |
| 90 | + |
| 91 | + public function write(Axis $axis, int $id, string $groupKey, array $records): bool |
| 92 | + { |
| 93 | + if ($id <= 0 || $groupKey === '') { |
| 94 | + return false; |
| 95 | + } |
| 96 | + |
| 97 | + $payload = array_map( |
| 98 | + static fn (Record $r) => [ |
| 99 | + // Preserve the axis invariant: force the axis column to $id. |
| 100 | + 'entity_id' => $axis === Axis::Entity ? $id : $r->entityId, |
| 101 | + 'user_id' => $axis === Axis::User ? $id : $r->userId, |
| 102 | + 'entity_type' => $r->entityType, |
| 103 | + ], |
| 104 | + $records |
| 105 | + ); |
| 106 | + |
| 107 | + return $this->updateMeta($axis, $id, self::BASE . '.' . $groupKey, $payload); |
| 108 | + } |
| 109 | + |
| 110 | + private function getMeta(Axis $axis, int $id, string $key): mixed |
| 111 | + { |
| 112 | + return $axis === Axis::Entity |
| 113 | + ? get_post_meta($id, $key, true) |
| 114 | + : get_user_meta($id, $key, true); |
| 115 | + } |
| 116 | + |
| 117 | + /** @param list<array<string, mixed>> $payload */ |
| 118 | + private function updateMeta(Axis $axis, int $id, string $key, array $payload): bool |
| 119 | + { |
| 120 | + return (bool) ($axis === Axis::Entity |
| 121 | + ? update_post_meta($id, $key, $payload) |
| 122 | + : update_user_meta($id, $key, $payload)); |
| 123 | + } |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +## Extending the storage service |
| 128 | + |
| 129 | +Konomi registers the driver once, under the `Storage\Storage::class` id (see `Storage\Module`). To swap it from a consumer plugin or site, extend that service with an [`ExtendingModule`](https://inpsyde.github.io/modularity/Modules/#extendingmodule) — you do **not** re-register the repositories: |
| 130 | + |
| 131 | +```php |
| 132 | +use Inpsyde\Modularity\Module\ExtendingModule; |
| 133 | +use Inpsyde\Modularity\Module\ModuleClassNameIdTrait; |
| 134 | +use MyPlugin\Storage\MetaStorage; |
| 135 | +use Psr\Container\ContainerInterface; |
| 136 | +use SpaghettiDojo\Konomi\Storage\Storage; |
| 137 | + |
| 138 | +final class StorageOverrideModule implements ExtendingModule |
| 139 | +{ |
| 140 | + use ModuleClassNameIdTrait; |
| 141 | + |
| 142 | + public static function new(): self |
| 143 | + { |
| 144 | + return new self(); |
| 145 | + } |
| 146 | + |
| 147 | + private function __construct() {} |
| 148 | + |
| 149 | + public function extensions(): array |
| 150 | + { |
| 151 | + return [ |
| 152 | + Storage::class => static fn (Storage $original, ContainerInterface $c): Storage |
| 153 | + => new MetaStorage(), |
| 154 | + ]; |
| 155 | + } |
| 156 | +} |
| 157 | +``` |
| 158 | + |
| 159 | +Add the extending module after Konomi's bundled modules so its extension is applied: |
| 160 | + |
| 161 | +```php |
| 162 | +\SpaghettiDojo\Konomi\package()->addModule(StorageOverrideModule::new()); |
| 163 | +``` |
| 164 | + |
| 165 | +Because there is a single shared service, one extension swaps the driver for **both** the Post and User repositories. The custom driver receives the `Axis` on every call and can branch on it when the backend differs per axis (as the `MetaStorage` reference does). |
| 166 | + |
| 167 | +## Notes |
| 168 | + |
| 169 | +- `groupKey` is operation scope, not row data — `write` always replaces the entire `($axis, $id, $groupKey)` slice. |
| 170 | +- `TableStorage` enforces an axis invariant: the column corresponding to the call's `Axis` is forced to `$id` regardless of the value carried on a `Record`. Custom drivers should preserve that invariant if they rely on the same shape. |
| 171 | +- Validation is the driver's responsibility at the read boundary: `read` must return only well-formed `Record`s. Repositories assume the type is the contract. |
0 commit comments