-
Notifications
You must be signed in to change notification settings - Fork 0
Storage Abstraction and DB Drivers #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
widoz
wants to merge
29
commits into
main
Choose a base branch
from
feat/dbal-abstraction
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+3,887
−1,295
Open
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 fdaa882
Archive "Extract Storage Interface" design and related artifacts.
widoz 517d1a1
Introduce custom table-backed `TableStorage` implementations
widoz ab44976
Archive "Custom Table Storage" design and related artifacts.
widoz 3dec73c
Consolidate `use` statements in `Post\TableStorage` and `User\TableSt…
widoz eb6758d
Simplify `StorageKey` handling by removing base prefix logic from `Ta…
widoz a687bd3
Split `TableStorage` methods into distinct private helpers for improv…
widoz ea42095
Merge branch 'main' into feat/dbal-abstraction
widoz d4716d6
Remove redundant PHP CodeSniffer comments in `Post\TableStorage` and …
widoz f6cb2ed
Refactor `Post\TableStorage` and `User\TableStorage` to isolate datab…
widoz 617395a
Merge branch 'main' into feat/dbal-abstraction
widoz a8dabda
Archive legacy storage system and related tests, replacing them with …
widoz 9e7095e
Update gitignore
widoz 3189a67
Inject `PluginProperties` dependency and retrieve plugin properties i…
widoz 32aaaa2
docs: add design spec for modular activation task system
widoz e9916df
feat(activation): add Activable interface and ActivationTasks registry
widoz 21542d5
feat(activation): add ActivationExecute orchestrator
widoz 4790570
feat(activation): register Activation services in the container
widoz e5257a8
feat(database): declare lifecycle tasks via Activable
widoz d45d767
feat(activation): wire the lifecycle system in the plugin bootstrap
widoz 81a430d
Ignore docs/superpowers
widoz ed0addc
Ignore Claude worktrees
widoz c3dc8da
Merge branch 'main' into feat/dbal-abstraction
widoz afa74a5
Fix Functional Tests Bootstrap
widoz 77de249
Archive Specs Changes
widoz 73dd02f
Align specs with code changes
widoz fe7eae6
Merge Post and User Storage (#31)
widoz 512a8b6
fix(blocks): populate hooked Konomi inner blocks
widoz e412575
Fix QA
widoz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,4 +36,7 @@ | |
| /wordpress | ||
|
|
||
| # AI | ||
| .claude/worktrees/ | ||
| graphify-out/ | ||
| docs/superposers | ||
| graphify-out | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
2 changes: 2 additions & 0 deletions
2
openspec/changes/archive/2026-05-08-custom-table-storage/.openspec.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
77
openspec/changes/archive/2026-05-08-custom-table-storage/design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
| ### 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)` | ||
25 changes: 25 additions & 0 deletions
25
openspec/changes/archive/2026-05-08-custom-table-storage/proposal.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix column name inconsistency in index description.
The schema defines the column as
entity_id(line 39), but line 49 refers to it aspost_idin the index description. This inconsistency may confuse readers.✏️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents