Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4fb06a3
Extract Storage interfaces for `Post` and `User`, moving concrete imp…
widoz May 8, 2026
fdaa882
Archive "Extract Storage Interface" design and related artifacts.
widoz May 8, 2026
517d1a1
Introduce custom table-backed `TableStorage` implementations
widoz May 8, 2026
ab44976
Archive "Custom Table Storage" design and related artifacts.
widoz May 8, 2026
3dec73c
Consolidate `use` statements in `Post\TableStorage` and `User\TableSt…
widoz May 8, 2026
eb6758d
Simplify `StorageKey` handling by removing base prefix logic from `Ta…
widoz May 8, 2026
a687bd3
Split `TableStorage` methods into distinct private helpers for improv…
widoz May 8, 2026
ea42095
Merge branch 'main' into feat/dbal-abstraction
widoz May 8, 2026
d4716d6
Remove redundant PHP CodeSniffer comments in `Post\TableStorage` and …
widoz May 8, 2026
f6cb2ed
Refactor `Post\TableStorage` and `User\TableStorage` to isolate datab…
widoz May 8, 2026
617395a
Merge branch 'main' into feat/dbal-abstraction
widoz May 10, 2026
a8dabda
Archive legacy storage system and related tests, replacing them with …
widoz May 10, 2026
9e7095e
Update gitignore
widoz Jul 3, 2026
3189a67
Inject `PluginProperties` dependency and retrieve plugin properties i…
widoz Jul 4, 2026
32aaaa2
docs: add design spec for modular activation task system
widoz Jul 4, 2026
e9916df
feat(activation): add Activable interface and ActivationTasks registry
widoz Jul 4, 2026
21542d5
feat(activation): add ActivationExecute orchestrator
widoz Jul 4, 2026
4790570
feat(activation): register Activation services in the container
widoz Jul 4, 2026
e5257a8
feat(database): declare lifecycle tasks via Activable
widoz Jul 4, 2026
d45d767
feat(activation): wire the lifecycle system in the plugin bootstrap
widoz Jul 4, 2026
81a430d
Ignore docs/superpowers
widoz Jul 4, 2026
ed0addc
Ignore Claude worktrees
widoz Jul 4, 2026
c3dc8da
Merge branch 'main' into feat/dbal-abstraction
widoz Jul 4, 2026
afa74a5
Fix Functional Tests Bootstrap
widoz Jul 5, 2026
77de249
Archive Specs Changes
widoz Jul 5, 2026
73dd02f
Align specs with code changes
widoz Jul 5, 2026
fe7eae6
Merge Post and User Storage (#31)
widoz Jul 8, 2026
512a8b6
fix(blocks): populate hooked Konomi inner blocks
widoz Jul 8, 2026
e412575
Fix QA
widoz Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ ij_php_keep_control_statement_in_one_line = true
ij_php_keep_first_column_comment = true
ij_php_keep_indents_on_empty_lines = false
ij_php_keep_line_breaks = true
ij_php_keep_rparen_and_lbrace_on_one_line = false
ij_php_keep_rparen_and_lbrace_on_one_line = true
ij_php_keep_simple_classes_in_one_line = false
ij_php_keep_simple_methods_in_one_line = false
ij_php_lambda_brace_style = end_of_line
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,7 @@
/wordpress

# AI
.claude/worktrees/
graphify-out/
docs/superposers
graphify-out
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@
"SpaghettiDojo\\Konomi\\Tests\\Unit\\": "tests/unit/php",
"SpaghettiDojo\\Konomi\\Tests\\Integration\\": "tests/integration/php",
"SpaghettiDojo\\Konomi\\Tests\\Functional\\": "tests/functional/php"
}
},
"files": [
"tests/Helpers/functions.php"
]
},
"config": {
"platform": {
Expand Down
514 changes: 262 additions & 252 deletions composer.lock

Large diffs are not rendered by default.

171 changes: 171 additions & 0 deletions docs/storage-drivers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Storage Drivers

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).

## The `Storage` interface

```php
namespace SpaghettiDojo\Konomi\Storage;

interface Storage
{
/** @return list<Record> */
public function read(Axis $axis, int $id, string $groupKey): array;

/** @param list<Record> $records */
public function write(Axis $axis, int $id, string $groupKey, array $records): bool;
}
```

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:

```php
enum Axis
{
case Entity; // $id is an entity (post) id -> column entity_id
case User; // $id is a user id -> column user_id
}
```

`Post\Repository` always calls with `Axis::Entity`, `User\Repository` always with `Axis::User`.

`Record` is a readonly value object:

```php
final class Record
{
public function __construct(
public readonly int $entityId,
public readonly int $userId,
public readonly string $entityType,
) {}
}
```

`$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"`).

`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`.

## Reference: meta-backed driver

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.

```php
namespace MyPlugin\Storage;

use SpaghettiDojo\Konomi\Storage\Axis;
use SpaghettiDojo\Konomi\Storage\Record;
use SpaghettiDojo\Konomi\Storage\Storage;

final class MetaStorage implements Storage
{
private const BASE = '_konomi_items';

public function read(Axis $axis, int $id, string $groupKey): array
{
if ($id <= 0 || $groupKey === '') {
return [];
}

$raw = $this->getMeta($axis, $id, self::BASE . '.' . $groupKey);
if (!is_array($raw)) {
return [];
}

$records = [];
foreach ($raw as $row) {
if (!is_array($row)) {
continue;
}
$entityId = (int) ($row['entity_id'] ?? 0);
$userId = (int) ($row['user_id'] ?? 0);
$entityType = (string) ($row['entity_type'] ?? '');
if ($entityId <= 0 || $userId <= 0 || $entityType === '') {
continue;
}
$records[] = new Record($entityId, $userId, $entityType);
}
return $records;
}

public function write(Axis $axis, int $id, string $groupKey, array $records): bool
{
if ($id <= 0 || $groupKey === '') {
return false;
}

$payload = array_map(
static fn (Record $r) => [
// Preserve the axis invariant: force the axis column to $id.
'entity_id' => $axis === Axis::Entity ? $id : $r->entityId,
'user_id' => $axis === Axis::User ? $id : $r->userId,
'entity_type' => $r->entityType,
],
$records
);

return $this->updateMeta($axis, $id, self::BASE . '.' . $groupKey, $payload);
}

private function getMeta(Axis $axis, int $id, string $key): mixed
{
return $axis === Axis::Entity
? get_post_meta($id, $key, true)
: get_user_meta($id, $key, true);
}

/** @param list<array<string, mixed>> $payload */
private function updateMeta(Axis $axis, int $id, string $key, array $payload): bool
{
return (bool) ($axis === Axis::Entity
? update_post_meta($id, $key, $payload)
: update_user_meta($id, $key, $payload));
}
}
```

## Extending the storage service

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:

```php
use Inpsyde\Modularity\Module\ExtendingModule;
use Inpsyde\Modularity\Module\ModuleClassNameIdTrait;
use MyPlugin\Storage\MetaStorage;
use Psr\Container\ContainerInterface;
use SpaghettiDojo\Konomi\Storage\Storage;

final class StorageOverrideModule implements ExtendingModule
{
use ModuleClassNameIdTrait;

public static function new(): self
{
return new self();
}

private function __construct() {}

public function extensions(): array
{
return [
Storage::class => static fn (Storage $original, ContainerInterface $c): Storage
=> new MetaStorage(),
];
}
}
```

Add the extending module after Konomi's bundled modules so its extension is applied:

```php
\SpaghettiDojo\Konomi\package()->addModule(StorageOverrideModule::new());
```

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).

## Notes

- `groupKey` is operation scope, not row data — `write` always replaces the entire `($axis, $id, $groupKey)` slice.
- `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.
- 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.
75 changes: 48 additions & 27 deletions konomi.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,33 +18,54 @@

namespace SpaghettiDojo\Konomi;

use Inpsyde\Modularity\Properties\PluginProperties;
use SpaghettiDojo\Konomi\Activation\ActivationExecute;

function autoload(string $projectRoot): void
{
$autoloadFile = "{$projectRoot}/vendor/autoload.php";
if (!\is_readable($autoloadFile)) {
return;
}
require_once $autoloadFile;
}

autoload(__DIR__);

$package = package();
/** @var PluginProperties $properties */
$properties = $package->properties();

$modules = [
Configuration\Module::new($properties, '/sources/Icons/icons'),
Database\Module::new(),
Storage\Module::new(),
ApiFetch\Module::new($properties),
Icons\Module::new($properties),
User\Module::new(),
Post\Module::new(),
Rest\Module::new(),
Blocks\Module::new($properties),
Activation\Module::new(),
];

foreach ($modules as $module) {
$package->addModule($module);
}

$package->build();

// Register the plugin lifecycle hooks at top level scope, after the container is
// built but before the deferred `boot()` on `plugins_loaded`. This is early
// enough for WordPress to catch the activation hook, which fires before
// `plugins_loaded` for the plugin being activated.
$activation = $package->container()->get(ActivationExecute::class);
$activation->prepare($modules);
$activation->registerActivationLogic();
$activation->registerDeactivationLogic();
$activation->registerUninstallLogic();

add_action(
'plugins_loaded',
static function () {
// phpcs:disable Squiz.PHP.InnerFunctions.NotAllowed
function autoload(string $projectRoot): void
{
// phpcs:enable Squiz.PHP.InnerFunctions.NotAllowed

$autoloadFile = "{$projectRoot}/vendor/autoload.php";
if (!\is_readable($autoloadFile)) {
return;
}
require_once $autoloadFile;
}

autoload(__DIR__);
$package = package();
$properties = $package->properties();

$package
->addModule(Configuration\Module::new($properties, '/sources/Icons/icons'))
->addModule(ApiFetch\Module::new($properties))
->addModule(Icons\Module::new($properties))
->addModule(User\Module::new())
->addModule(Post\Module::new())
->addModule(Rest\Module::new())
->addModule(Blocks\Module::new($properties))
->boot();
}
static fn () => $package->boot()
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-08
77 changes: 77 additions & 0 deletions openspec/changes/archive/2026-05-08-custom-table-storage/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
## Context

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.

Current data model:
- **Post storage**: `wp_postmeta` key `_konomi_items.{group}` → serialized array `[userId => [[entityId, entityType]]]`
- **User storage**: `wp_usermeta` key `_konomi_items.{group}` → serialized array `[[entityId, entityType], ...]`

The `Storage` interface contract is: `read(int $id, string $key): array` and `write(int $id, string $key, array $data): bool`.

## Goals / Non-Goals

**Goals:**
- Single custom DB table for structured, queryable storage of user interactions
- `$wpdb`-based storage implementations for both Post and User domains
- Schema management with full lifecycle (activation + uninstall)

**Non-Goals:**
- Changing the `Storage` interface contract — `TableStorage` must conform to existing `read`/`write` signatures
- Removing `MetaStorage` — kept but no longer wired by default
- Adding query capabilities beyond what `Storage` interface exposes

## Decisions

### 1. `$wpdb` for database operations

Use WordPress `$wpdb` directly for all custom table operations.

**Why**: No external dependency needed. `$wpdb` provides prepared statements via `$wpdb->prepare()`, handles table prefix, and is always available. `dbDelta()` handles schema creation/updates.

### 2. Single shared table

`{prefix}konomi_interactions` — stores all user-post interactions.

**Schema:**
| Column | Type | Description |
|--------|------|-------------|
| id | BIGINT UNSIGNED AUTO_INCREMENT | PK |
| entity_id | BIGINT UNSIGNED NOT NULL | Entity being interacted with (currently always a post ID) |
| user_id | BIGINT UNSIGNED NOT NULL | WordPress user ID — who interacted |
| entity_type | VARCHAR(50) NOT NULL | Entity type string (`Item::type()`) |
| group_key | VARCHAR(50) NOT NULL | ItemGroup value (reaction, bookmark) |
| UNIQUE | (entity_id, user_id, group_key) | One interaction per user per group per entity |

**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.

**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.

**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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix column name inconsistency in index description.

The schema defines the column as entity_id (line 39), but line 49 refers to it as post_id in the index description. This inconsistency may confuse readers.

✏️ Proposed fix
-**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.
+**Index strategy**: The UNIQUE constraint on `(entity_id, user_id, group_key)` covers post-side queries. An additional index on `(user_id, group_key)` covers user-side queries efficiently.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**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.
**Index strategy**: The UNIQUE constraint on `(entity_id, user_id, group_key)` covers post-side queries. An additional index on `(user_id, group_key)` covers user-side queries efficiently.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openspec/changes/archive/2026-05-08-custom-table-storage/design.md` at line
49, Update the index description to use the correct column name `entity_id` (as
defined earlier) instead of `post_id`: change the UNIQUE constraint text that
currently reads "(post_id, user_id, group_key)" to "(entity_id, user_id,
group_key)" so the index description matches the schema and avoids confusion.


### 3. TableStorage implements existing Storage interface

Two `TableStorage` classes still needed — `Post\TableStorage` and `User\TableStorage` — because the return formats differ:
- Post: `[userId => [[entityId, entityType]]]`
- User: `[[entityId, entityType], ...]`

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')`.

The `key` parameter encodes `{base}.{group}` — `TableStorage` parses the group from the key to filter by `group_key` column.

### 4. Schema management via `dbDelta` with full lifecycle

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`:
- `register_activation_hook` → `SchemaManager::create()`
- `register_uninstall_hook` → `SchemaManager::drop()`

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.

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/`).

## Risks / Trade-offs

- **`$wpdb` typing** → Weak return types, stringly-typed queries. Mitigate with thorough tests and `$wpdb->prepare()` for all parameterized queries
- **`dbDelta` quirks** → Specific SQL formatting requirements (e.g., two spaces after PRIMARY KEY). Mitigate by following WordPress codex guidelines exactly
- **Rollback complexity** → Switching back to `MetaStorage` in Module wiring is sufficient
- **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
- **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)`
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## Why

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.

## What Changes

- Create a single custom DB table for all interactions on plugin activation
- Implement `TableStorage` classes for both `Post\Storage` and `User\Storage` interfaces, reading/writing to the same table via `$wpdb` (queried from different sides)
- Wire `TableStorage` as the default storage backend in both modules
- **BREAKING**: Storage uses custom table instead of meta; MetaStorage kept but no longer wired by default

## Capabilities

### New Capabilities
- `table-schema`: Database schema management — single table creation, lifecycle (activate/uninstall), and `Database` package
- `table-storage`: TableStorage implementations for Post and User that read/write to the shared custom table

### Modified Capabilities

## Impact

- `Post\Module`, `User\Module`: Rewired to use `TableStorage` instead of `MetaStorage`
- Plugin activation hook: Must create custom table
- Plugin uninstall hook: Drops custom table
- Test suite: New integration tests for TableStorage
Loading
Loading