Skip to content

Commit 311cdef

Browse files
authored
Storage Abstraction and DB Drivers
1 parent 8323730 commit 311cdef

96 files changed

Lines changed: 6758 additions & 3718 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.coderabbit.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
path_filters:
2+
- "!openspec"

‎.editorconfig‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ ij_php_keep_control_statement_in_one_line = true
559559
ij_php_keep_first_column_comment = true
560560
ij_php_keep_indents_on_empty_lines = false
561561
ij_php_keep_line_breaks = true
562-
ij_php_keep_rparen_and_lbrace_on_one_line = false
562+
ij_php_keep_rparen_and_lbrace_on_one_line = true
563563
ij_php_keep_simple_classes_in_one_line = false
564564
ij_php_keep_simple_methods_in_one_line = false
565565
ij_php_lambda_brace_style = end_of_line

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,7 @@
3636
/wordpress
3737

3838
# AI
39+
.claude/worktrees/
40+
graphify-out/
41+
docs/superposers
3942
graphify-out

‎composer.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@
4646
"SpaghettiDojo\\Konomi\\Tests\\Unit\\": "tests/unit/php",
4747
"SpaghettiDojo\\Konomi\\Tests\\Integration\\": "tests/integration/php",
4848
"SpaghettiDojo\\Konomi\\Tests\\Functional\\": "tests/functional/php"
49-
}
49+
},
50+
"files": [
51+
"tests/Helpers/functions.php"
52+
]
5053
},
5154
"config": {
5255
"platform": {

‎composer.lock‎

Lines changed: 262 additions & 252 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/storage-drivers.md‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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.

‎konomi.php‎

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,33 +18,54 @@
1818

1919
namespace SpaghettiDojo\Konomi;
2020

21+
use Inpsyde\Modularity\Properties\PluginProperties;
22+
use SpaghettiDojo\Konomi\Activation\ActivationExecute;
23+
24+
function autoload(string $projectRoot): void
25+
{
26+
$autoloadFile = "{$projectRoot}/vendor/autoload.php";
27+
if (!\is_readable($autoloadFile)) {
28+
return;
29+
}
30+
require_once $autoloadFile;
31+
}
32+
33+
autoload(__DIR__);
34+
35+
$package = package();
36+
/** @var PluginProperties $properties */
37+
$properties = $package->properties();
38+
39+
$modules = [
40+
Configuration\Module::new($properties, '/sources/Icons/icons'),
41+
Database\Module::new(),
42+
Storage\Module::new(),
43+
ApiFetch\Module::new($properties),
44+
Icons\Module::new($properties),
45+
User\Module::new(),
46+
Post\Module::new(),
47+
Rest\Module::new(),
48+
Blocks\Module::new($properties),
49+
Activation\Module::new(),
50+
];
51+
52+
foreach ($modules as $module) {
53+
$package->addModule($module);
54+
}
55+
56+
$package->build();
57+
58+
// Register the plugin lifecycle hooks at top level scope, after the container is
59+
// built but before the deferred `boot()` on `plugins_loaded`. This is early
60+
// enough for WordPress to catch the activation hook, which fires before
61+
// `plugins_loaded` for the plugin being activated.
62+
$activation = $package->container()->get(ActivationExecute::class);
63+
$activation->prepare($modules);
64+
$activation->registerActivationLogic();
65+
$activation->registerDeactivationLogic();
66+
$activation->registerUninstallLogic();
67+
2168
add_action(
2269
'plugins_loaded',
23-
static function () {
24-
// phpcs:disable Squiz.PHP.InnerFunctions.NotAllowed
25-
function autoload(string $projectRoot): void
26-
{
27-
// phpcs:enable Squiz.PHP.InnerFunctions.NotAllowed
28-
29-
$autoloadFile = "{$projectRoot}/vendor/autoload.php";
30-
if (!\is_readable($autoloadFile)) {
31-
return;
32-
}
33-
require_once $autoloadFile;
34-
}
35-
36-
autoload(__DIR__);
37-
$package = package();
38-
$properties = $package->properties();
39-
40-
$package
41-
->addModule(Configuration\Module::new($properties, '/sources/Icons/icons'))
42-
->addModule(ApiFetch\Module::new($properties))
43-
->addModule(Icons\Module::new($properties))
44-
->addModule(User\Module::new())
45-
->addModule(Post\Module::new())
46-
->addModule(Rest\Module::new())
47-
->addModule(Blocks\Module::new($properties))
48-
->boot();
49-
}
70+
static fn () => $package->boot()
5071
);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-05-08
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
## Context
2+
3+
Konomi stores user interactions (reactions, bookmarks) as serialized arrays in WordPress meta tables (`wp_usermeta` and `wp_postmeta`). The previous change extracted `Storage` interfaces from concrete implementations, creating `Post\Storage` and `User\Storage` interfaces with `MetaStorage` implementations. This change introduces a single custom database table and `$wpdb`-backed storage implementations behind those interfaces.
4+
5+
Current data model:
6+
- **Post storage**: `wp_postmeta` key `_konomi_items.{group}` → serialized array `[userId => [[entityId, entityType]]]`
7+
- **User storage**: `wp_usermeta` key `_konomi_items.{group}` → serialized array `[[entityId, entityType], ...]`
8+
9+
The `Storage` interface contract is: `read(int $id, string $key): array` and `write(int $id, string $key, array $data): bool`.
10+
11+
## Goals / Non-Goals
12+
13+
**Goals:**
14+
- Single custom DB table for structured, queryable storage of user interactions
15+
- `$wpdb`-based storage implementations for both Post and User domains
16+
- Schema management with full lifecycle (activation + uninstall)
17+
18+
**Non-Goals:**
19+
- Changing the `Storage` interface contract — `TableStorage` must conform to existing `read`/`write` signatures
20+
- Removing `MetaStorage` — kept but no longer wired by default
21+
- Adding query capabilities beyond what `Storage` interface exposes
22+
23+
## Decisions
24+
25+
### 1. `$wpdb` for database operations
26+
27+
Use WordPress `$wpdb` directly for all custom table operations.
28+
29+
**Why**: No external dependency needed. `$wpdb` provides prepared statements via `$wpdb->prepare()`, handles table prefix, and is always available. `dbDelta()` handles schema creation/updates.
30+
31+
### 2. Single shared table
32+
33+
`{prefix}konomi_interactions` — stores all user-post interactions.
34+
35+
**Schema:**
36+
| Column | Type | Description |
37+
|--------|------|-------------|
38+
| id | BIGINT UNSIGNED AUTO_INCREMENT | PK |
39+
| entity_id | BIGINT UNSIGNED NOT NULL | Entity being interacted with (currently always a post ID) |
40+
| user_id | BIGINT UNSIGNED NOT NULL | WordPress user ID — who interacted |
41+
| entity_type | VARCHAR(50) NOT NULL | Entity type string (`Item::type()`) |
42+
| group_key | VARCHAR(50) NOT NULL | ItemGroup value (reaction, bookmark) |
43+
| UNIQUE | (entity_id, user_id, group_key) | One interaction per user per group per entity |
44+
45+
**Why one table**: Post and User storage represent the same relationship queried from different sides. `Post\TableStorage` queries `WHERE entity_id = ?` (where the entity ID is the post ID passed in), `User\TableStorage` queries `WHERE user_id = ?`. Same data, different access patterns. One table is simpler to manage, fewer moving parts.
46+
47+
**Naming — `entity_id` over `post_id`**: In the current code `Item::id()` always equals the post ID, but the domain concept is "entity being interacted with". Using `entity_id` keeps the schema neutral if non-post entities are added later. `Post\TableStorage` passes the post ID as `entity_id` since today they are the same value.
48+
49+
**Index strategy**: The UNIQUE constraint on `(post_id, user_id, group_key)` covers post-side queries. An additional index on `(user_id, group_key)` covers user-side queries efficiently.
50+
51+
### 3. TableStorage implements existing Storage interface
52+
53+
Two `TableStorage` classes still needed — `Post\TableStorage` and `User\TableStorage` — because the return formats differ:
54+
- Post: `[userId => [[entityId, entityType]]]`
55+
- User: `[[entityId, entityType], ...]`
56+
57+
Both query the same table but filter and serialize differently. `write()` replaces all rows for the given (id, key) combination in a transaction using `$wpdb->query('START TRANSACTION')`.
58+
59+
The `key` parameter encodes `{base}.{group}` — `TableStorage` parses the group from the key to filter by `group_key` column.
60+
61+
### 4. Schema management via `dbDelta` with full lifecycle
62+
63+
A `Database\SchemaManager` class owns both table creation and destruction. Creation uses `dbDelta()` (idempotent). Destruction uses `DROP TABLE IF EXISTS`. Both triggered from the `Database` package's modularity `Module`:
64+
- `register_activation_hook` → `SchemaManager::create()`
65+
- `register_uninstall_hook` → `SchemaManager::drop()`
66+
67+
Using `register_uninstall_hook` over `uninstall.php` because `SchemaManager` needs `$wpdb` and table prefix knowledge already encapsulated in the package. WordPress loads the plugin for `register_uninstall_hook`, giving access to the container.
68+
69+
The `Database` package lives at `sources/Database/` and may contain one or more `Module.php` files following the established pattern (see `sources/Post/`, `sources/User/`).
70+
71+
## Risks / Trade-offs
72+
73+
- **`$wpdb` typing** → Weak return types, stringly-typed queries. Mitigate with thorough tests and `$wpdb->prepare()` for all parameterized queries
74+
- **`dbDelta` quirks** → Specific SQL formatting requirements (e.g., two spaces after PRIMARY KEY). Mitigate by following WordPress codex guidelines exactly
75+
- **Rollback complexity** → Switching back to `MetaStorage` in Module wiring is sufficient
76+
- **Key parsing in TableStorage** → `write(id, "base.group", data)` requires parsing the key to extract group. Fragile if key format changes. Mitigate with `StorageKey` being the single source of key formatting
77+
- **User-side query performance** → UNIQUE index is `(post_id, user_id, group_key)`, not optimal for `WHERE user_id = ?`. Mitigate with additional index on `(user_id, group_key)`
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## Why
2+
3+
Konomi stores user reactions and bookmarks as serialized arrays in `wp_usermeta`/`wp_postmeta`. This blocks structured queries, search, aggregation, and integration with WP Data Views. The `Storage` interface extracted in the previous change provides the seam — now we need the custom table backend to use it.
4+
5+
## What Changes
6+
7+
- Create a single custom DB table for all interactions on plugin activation
8+
- Implement `TableStorage` classes for both `Post\Storage` and `User\Storage` interfaces, reading/writing to the same table via `$wpdb` (queried from different sides)
9+
- Wire `TableStorage` as the default storage backend in both modules
10+
- **BREAKING**: Storage uses custom table instead of meta; MetaStorage kept but no longer wired by default
11+
12+
## Capabilities
13+
14+
### New Capabilities
15+
- `table-schema`: Database schema management — single table creation, lifecycle (activate/uninstall), and `Database` package
16+
- `table-storage`: TableStorage implementations for Post and User that read/write to the shared custom table
17+
18+
### Modified Capabilities
19+
20+
## Impact
21+
22+
- `Post\Module`, `User\Module`: Rewired to use `TableStorage` instead of `MetaStorage`
23+
- Plugin activation hook: Must create custom table
24+
- Plugin uninstall hook: Drops custom table
25+
- Test suite: New integration tests for TableStorage

0 commit comments

Comments
 (0)