From 4fb06a3265a8ef3b7f92ba64b7dc978aa4fa38ea Mon Sep 17 00:00:00 2001 From: guido Date: Fri, 8 May 2026 08:26:42 +0200 Subject: [PATCH 01/26] Extract Storage interfaces for `Post` and `User`, moving concrete implementations to `MetaStorage`. Updated modules and tests to reflect changes. --- .../extract-storage-interface/design.md | 80 +++++++++++++++++++ .../extract-storage-interface/proposal.md | 36 +++++++++ .../extract-storage-interface/tasks.md | 6 ++ openspec/config.yaml | 20 +++++ sources/Post/MetaStorage.php | 45 +++++++++++ sources/Post/Module.php | 2 +- sources/Post/Storage.php | 34 +------- sources/User/MetaStorage.php | 45 +++++++++++ sources/User/Module.php | 2 +- sources/User/Storage.php | 33 +------- tests/integration/php/PostRepositoryTest.php | 4 +- tests/integration/php/UserRepositoryTest.php | 2 +- tests/unit/php/Post/StorageTest.php | 18 ++--- tests/unit/php/User/StorageTest.php | 4 +- 14 files changed, 254 insertions(+), 77 deletions(-) create mode 100644 openspec/changes/extract-storage-interface/design.md create mode 100644 openspec/changes/extract-storage-interface/proposal.md create mode 100644 openspec/changes/extract-storage-interface/tasks.md create mode 100644 openspec/config.yaml create mode 100644 sources/Post/MetaStorage.php create mode 100644 sources/User/MetaStorage.php diff --git a/openspec/changes/extract-storage-interface/design.md b/openspec/changes/extract-storage-interface/design.md new file mode 100644 index 0000000..a235266 --- /dev/null +++ b/openspec/changes/extract-storage-interface/design.md @@ -0,0 +1,80 @@ +# Design: Extract Storage Interface + +## Overview + +Convert `Post\Storage` and `User\Storage` from concrete classes to interfaces. Current implementations become `MetaStorage`. No behavior change — pure structural refactor. + +## Current State + +``` +Post\Storage (concrete) +├── read(int $id, string $key): array → get_post_meta() +└── write(int $id, string $key, array $data): bool → update_post_meta() + +User\Storage (concrete) +├── read(int $id, string $key): array → get_user_meta() +└── write(int $id, string $key, array $data): bool → update_user_meta() +``` + +Both registered in their Module under `Storage::class` as service ID. + +## Target State + +``` +Post\Storage (interface) +├── read(int $id, string $key): array +└── write(int $id, string $key, array $data): bool + +Post\MetaStorage implements Post\Storage +├── read() → get_post_meta() +└── write() → update_post_meta() + +User\Storage (interface) +├── read(int $id, string $key): array +└── write(int $id, string $key, array $data): bool + +User\MetaStorage implements User\Storage +├── read() → get_user_meta() +└── write() → update_user_meta() +``` + +## Decisions + +### Interface contract matches current signatures exactly + +`read(int $id, string $key): array` and `write(int $id, string $key, array $data): bool`. No changes to what flows through. Repository code untouched. + +### Service ID stays `Storage::class` + +Modules register under `Storage::class` (the interface). Factory returns `MetaStorage::new()`. Consumers resolve `Storage::class` — decoupled from implementation. + +```php +// Post\Module — before +Storage::class => static fn () => Storage::new(), + +// Post\Module — after +Storage::class => static fn () => MetaStorage::new(), +``` + +### Static factory `new()` moves to MetaStorage + +Interface has no constructor. `MetaStorage::new()` replaces `Storage::new()`. + +### `@internal` stays on MetaStorage + +Both current Storage classes are `@internal`. MetaStorage keeps that. Interface itself is not `@internal` — it's the public contract. + +## Files Changed + +| File | Change | +|------|--------| +| `sources/Post/Storage.php` | Concrete class → interface | +| `sources/Post/MetaStorage.php` | New file, current impl moved here | +| `sources/User/Storage.php` | Concrete class → interface | +| `sources/User/MetaStorage.php` | New file, current impl moved here | +| `sources/Post/Module.php` | `Storage::new()` → `MetaStorage::new()` | +| `sources/User/Module.php` | `Storage::new()` → `MetaStorage::new()` | +| `tests/unit/php/Post/StorageTest.php` | Rename to MetaStorageTest, update references | +| `tests/unit/php/User/StorageTest.php` | Rename to MetaStorageTest, update references | +| `tests/integration/php/PostRepositoryTest.php` | Update if references Storage directly | +| `tests/integration/php/UserRepositoryTest.php` | Update if references Storage directly | diff --git a/openspec/changes/extract-storage-interface/proposal.md b/openspec/changes/extract-storage-interface/proposal.md new file mode 100644 index 0000000..130cf31 --- /dev/null +++ b/openspec/changes/extract-storage-interface/proposal.md @@ -0,0 +1,36 @@ +# Extract Storage Interface + +## Summary + +Extract `Post\Storage` and `User\Storage` concrete classes into interfaces, renaming current implementations to `MetaStorage`. This creates the seam needed to swap storage backends (e.g., Dbal custom tables) without changing Repository code. + +## Motivation + +Konomi stores user reactions and bookmarks as serialized arrays in `wp_usermeta`/`wp_postmeta`. This blocks structured queries, search, and integration with WP Data Views. Moving to custom tables requires a storage abstraction — which doesn't exist yet since `Storage` classes are concrete with hardcoded `get_*_meta`/`update_*_meta` calls. + +## Scope + +- `Post\Storage` becomes an interface with `read(int, string): array` and `write(int, string, array): bool` +- `Post\MetaStorage` implements `Post\Storage` with current `get_post_meta`/`update_post_meta` logic +- `User\Storage` becomes an interface with same contract +- `User\MetaStorage` implements `User\Storage` with current `get_user_meta`/`update_user_meta` logic +- `Post\Module` and `User\Module` updated to wire `MetaStorage` where `Storage` was constructed +- All tests updated to reflect rename + +## Non-goals + +- No new storage backends (Dbal comes in a follow-up change) +- No signature changes — interface matches current `read`/`write` exactly +- No data migration + +## Risks + +- Low risk. Pure refactor, no behavior change. Existing tests validate. + +## Part of + +This is Step 1 of moving storage from meta to custom tables: +1. **Extract Storage interface** ← this change +2. Add Dbal dependency + table creation +3. Implement `DbalStorage` +4. (Later) Normalize to single table diff --git a/openspec/changes/extract-storage-interface/tasks.md b/openspec/changes/extract-storage-interface/tasks.md new file mode 100644 index 0000000..72ca3d3 --- /dev/null +++ b/openspec/changes/extract-storage-interface/tasks.md @@ -0,0 +1,6 @@ +# Tasks: Extract Storage Interface + +- [x] Extract Post\Storage interface and create Post\MetaStorage: Convert `sources/Post/Storage.php` to interface, create `sources/Post/MetaStorage.php` with current impl, update `sources/Post/Module.php` +- [x] Extract User\Storage interface and create User\MetaStorage: Convert `sources/User/Storage.php` to interface, create `sources/User/MetaStorage.php` with current impl, update `sources/User/Module.php` +- [x] Update tests: Rename/update Storage tests to MetaStorage, check integration tests for direct Storage references +- [x] Verify: Run `composer cs`, `composer analysis`, `composer tests` diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..392946c --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/sources/Post/MetaStorage.php b/sources/Post/MetaStorage.php new file mode 100644 index 0000000..fc9ac92 --- /dev/null +++ b/sources/Post/MetaStorage.php @@ -0,0 +1,45 @@ + + */ + public function read(int $id, string $key): array + { + if ($id <= 0 || $key === '') { + return []; + } + + $data = get_post_meta($id, $key, true); + return is_array($data) ? $data : []; + } + + /** + * @param array $data + */ + public function write(int $id, string $key, array $data): bool + { + if ($id <= 0 || $key === '') { + return false; + } + + return (bool) update_post_meta($id, $key, $data); + } +} diff --git a/sources/Post/Module.php b/sources/Post/Module.php index ebb0d43..fd1322d 100644 --- a/sources/Post/Module.php +++ b/sources/Post/Module.php @@ -31,7 +31,7 @@ public function services(): array Post::class => static fn (ContainerInterface $container) => Post::new( $container->get(Repository::class) ), - Storage::class => static fn () => Storage::new(), + Storage::class => static fn () => MetaStorage::new(), ItemRegistryKey::class => static fn () => ItemRegistryKey::new(), ItemRegistry::class => static fn ( ContainerInterface $container diff --git a/sources/Post/Storage.php b/sources/Post/Storage.php index 51b8633..043176f 100644 --- a/sources/Post/Storage.php +++ b/sources/Post/Storage.php @@ -4,43 +4,15 @@ namespace SpaghettiDojo\Konomi\Post; -/** - * @internal - * TODO Convert this into an Interface - */ -class Storage +interface Storage { - public static function new(): Storage - { - return new self(); - } - - final private function __construct() - { - } - /** * @return array */ - public function read(int $id, string $key): array - { - if ($id <= 0 || $key === '') { - return []; - } - - $data = get_post_meta($id, $key, true); - return is_array($data) ? $data : []; - } + public function read(int $id, string $key): array; /** * @param array $data */ - public function write(int $id, string $key, array $data): bool - { - if ($id <= 0 || $key === '') { - return false; - } - - return (bool) update_post_meta($id, $key, $data); - } + public function write(int $id, string $key, array $data): bool; } diff --git a/sources/User/MetaStorage.php b/sources/User/MetaStorage.php new file mode 100644 index 0000000..c072c20 --- /dev/null +++ b/sources/User/MetaStorage.php @@ -0,0 +1,45 @@ + + */ + public function read(int $id, string $key): array + { + if ($id <= 0 || $key === '') { + return []; + } + + $data = get_user_meta($id, $key, true); + return is_array($data) ? $data : []; + } + + /** + * @param array $data + */ + public function write(int $id, string $key, array $data): bool + { + if ($id <= 0 || $key === '') { + return false; + } + + return (bool) update_user_meta($id, $key, $data); + } +} diff --git a/sources/User/Module.php b/sources/User/Module.php index 49514b8..661986e 100644 --- a/sources/User/Module.php +++ b/sources/User/Module.php @@ -26,7 +26,7 @@ final private function __construct() public function services(): array { return [ - Storage::class => static fn () => Storage::new(), + Storage::class => static fn () => MetaStorage::new(), UserFactory::class => static fn ( ContainerInterface $container ) => UserFactory::new( diff --git a/sources/User/Storage.php b/sources/User/Storage.php index bf10a87..eeb8568 100644 --- a/sources/User/Storage.php +++ b/sources/User/Storage.php @@ -4,42 +4,15 @@ namespace SpaghettiDojo\Konomi\User; -/** - * @internal - */ -class Storage +interface Storage { - public static function new(): Storage - { - return new self(); - } - - final private function __construct() - { - } - /** * @return array */ - public function read(int $id, string $key): array - { - if ($id <= 0 || $key === '') { - return []; - } - - $data = get_user_meta($id, $key, true); - return is_array($data) ? $data : []; - } + public function read(int $id, string $key): array; /** * @param array $data */ - public function write(int $id, string $key, array $data): bool - { - if ($id <= 0 || $key === '') { - return false; - } - - return (bool) update_user_meta($id, $key, $data); - } + public function write(int $id, string $key, array $data): bool; } diff --git a/tests/integration/php/PostRepositoryTest.php b/tests/integration/php/PostRepositoryTest.php index eff999f..525ac39 100644 --- a/tests/integration/php/PostRepositoryTest.php +++ b/tests/integration/php/PostRepositoryTest.php @@ -23,7 +23,7 @@ $this->currentUser = User\CurrentUser::new( User\Repository::new( User\StorageKey::new('_konomi_items'), - User\Storage::new(), + User\MetaStorage::new(), User\ItemFactory::new(), User\ItemRegistry::new($itemRegistryKey), User\RawDataAssert::new() @@ -32,7 +32,7 @@ $postItemRegistryKey = Post\ItemRegistryKey::new(); $this->repository = Post\Repository::new( Post\StorageKey::new('_konomi_items'), - Post\Storage::new(), + Post\MetaStorage::new(), Post\RawDataAssert::new(), User\ItemFactory::new(), Post\ItemRegistry::new($postItemRegistryKey) diff --git a/tests/integration/php/UserRepositoryTest.php b/tests/integration/php/UserRepositoryTest.php index 3d5ec47..1d1c4ba 100644 --- a/tests/integration/php/UserRepositoryTest.php +++ b/tests/integration/php/UserRepositoryTest.php @@ -25,7 +25,7 @@ $itemRegistryKey = User\ItemRegistryKey::new(); $this->repository = User\Repository::new( User\StorageKey::new('_konomi_items'), - User\Storage::new(), + User\MetaStorage::new(), User\ItemFactory::new(), User\ItemRegistry::new($itemRegistryKey), User\RawDataAssert::new() diff --git a/tests/unit/php/Post/StorageTest.php b/tests/unit/php/Post/StorageTest.php index 08845ce..97ff3a4 100644 --- a/tests/unit/php/Post/StorageTest.php +++ b/tests/unit/php/Post/StorageTest.php @@ -5,51 +5,51 @@ namespace SpaghettiDojo\Konomi\Tests\Unit\Post; use Brain\Monkey\Functions; -use SpaghettiDojo\Konomi\Post\Storage; +use SpaghettiDojo\Konomi\Post\MetaStorage; -describe('Storage', function (): void { +describe('MetaStorage', function (): void { it('read data from the storage', function (): void { - $storage = Storage::new(); + $storage = MetaStorage::new(); Functions\expect('get_post_meta')->once()->with(1, 'key', true)->andReturn(['data']); $data = $storage->read(1, 'key'); expect($data)->toBe(['data']); }); it('write data to the storage', function (): void { - $storage = Storage::new(); + $storage = MetaStorage::new(); Functions\expect('update_post_meta')->once()->with(1, 'key', ['data'])->andReturn(true); $result = $storage->write(1, 'key', ['data']); expect($result)->toBeTrue(); }); it('read returns empty array when id is invalid', function (): void { - $storage = Storage::new(); + $storage = MetaStorage::new(); Functions\expect('get_post_meta')->never(); expect($storage->read(0, 'key'))->toBe([]); expect($storage->read(-1, 'key'))->toBe([]); }); it('read returns empty array when key is empty', function (): void { - $storage = Storage::new(); + $storage = MetaStorage::new(); Functions\expect('get_post_meta')->never(); expect($storage->read(1, ''))->toBe([]); }); it('read returns empty array when result is not an array', function (): void { - $storage = Storage::new(); + $storage = MetaStorage::new(); Functions\expect('get_post_meta')->once()->with(1, 'key', true)->andReturn('not-an-array'); expect($storage->read(1, 'key'))->toBe([]); }); it('write returns false when id is invalid', function (): void { - $storage = Storage::new(); + $storage = MetaStorage::new(); Functions\expect('update_post_meta')->never(); expect($storage->write(0, 'key', ['data']))->toBeFalse(); expect($storage->write(-1, 'key', ['data']))->toBeFalse(); }); it('write returns false when key is empty', function (): void { - $storage = Storage::new(); + $storage = MetaStorage::new(); Functions\expect('update_post_meta')->never(); expect($storage->write(1, '', ['data']))->toBeFalse(); }); diff --git a/tests/unit/php/User/StorageTest.php b/tests/unit/php/User/StorageTest.php index 0c4110a..6b70493 100644 --- a/tests/unit/php/User/StorageTest.php +++ b/tests/unit/php/User/StorageTest.php @@ -8,10 +8,10 @@ use SpaghettiDojo\Konomi\User; beforeEach(function (): void { - $this->storage = User\Storage::new(); + $this->storage = User\MetaStorage::new(); }); -describe('Storage', function (): void { +describe('MetaStorage', function (): void { it('returns empty array for invalid ID', function (): void { expect($this->storage->read(0, 'test_key'))->toBe([]); }); From fdaa88271797837d68653722249ba7e5bc04ac0c Mon Sep 17 00:00:00 2001 From: guido Date: Fri, 8 May 2026 08:30:34 +0200 Subject: [PATCH 02/26] Archive "Extract Storage Interface" design and related artifacts. --- .../2026-05-08-extract-storage-interface}/design.md | 0 .../2026-05-08-extract-storage-interface}/proposal.md | 0 .../2026-05-08-extract-storage-interface}/tasks.md | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename openspec/changes/{extract-storage-interface => archive/2026-05-08-extract-storage-interface}/design.md (100%) rename openspec/changes/{extract-storage-interface => archive/2026-05-08-extract-storage-interface}/proposal.md (100%) rename openspec/changes/{extract-storage-interface => archive/2026-05-08-extract-storage-interface}/tasks.md (100%) diff --git a/openspec/changes/extract-storage-interface/design.md b/openspec/changes/archive/2026-05-08-extract-storage-interface/design.md similarity index 100% rename from openspec/changes/extract-storage-interface/design.md rename to openspec/changes/archive/2026-05-08-extract-storage-interface/design.md diff --git a/openspec/changes/extract-storage-interface/proposal.md b/openspec/changes/archive/2026-05-08-extract-storage-interface/proposal.md similarity index 100% rename from openspec/changes/extract-storage-interface/proposal.md rename to openspec/changes/archive/2026-05-08-extract-storage-interface/proposal.md diff --git a/openspec/changes/extract-storage-interface/tasks.md b/openspec/changes/archive/2026-05-08-extract-storage-interface/tasks.md similarity index 100% rename from openspec/changes/extract-storage-interface/tasks.md rename to openspec/changes/archive/2026-05-08-extract-storage-interface/tasks.md From 517d1a1409af277d9c4ff1d6d134f4a37e471a2b Mon Sep 17 00:00:00 2001 From: guido Date: Fri, 8 May 2026 19:57:07 +0200 Subject: [PATCH 03/26] Introduce custom table-backed `TableStorage` implementations - Added `Post\TableStorage` and `User\TableStorage` classes implementing the `Storage` interface, using the `{prefix}konomi_interactions` table. - Implemented schema management via a new `Database\SchemaManager` class, handling table creation (`dbDelta`) and deletion (`DROP TABLE IF EXISTS`) on plugin lifecycle hooks. - Created a shared `konomi_interactions` table schema to store user-post interactions with indexes to optimize both user-side and post-side queries. - Rewired `Post\Module` and `User\Module` to use `TableStorage` as the default backend. - Updated tests to validate `TableStorage` behavior, including transactional writes, schema management, and data querying for both posts and users. --- .editorconfig | 2 +- composer.lock | 514 +++++++++--------- konomi.php | 1 + .../custom-table-storage/.openspec.yaml | 2 + .../changes/custom-table-storage/design.md | 77 +++ .../changes/custom-table-storage/proposal.md | 25 + .../specs/table-schema/spec.md | 41 ++ .../specs/table-storage/spec.md | 85 +++ .../changes/custom-table-storage/tasks.md | 30 + .../changes/simplify-storage-key/design.md | 91 ++++ .../changes/simplify-storage-key/proposal.md | 27 + .../specs/table-storage/spec.md | 101 ++++ .../changes/simplify-storage-key/tasks.md | 33 ++ sources/Database/InteractionsTable.php | 29 + sources/Database/Module.php | 69 +++ sources/Database/SchemaManager.php | 58 ++ sources/Database/StorageKeyParser.php | 46 ++ sources/Post/Module.php | 8 +- sources/Post/TableStorage.php | 143 +++++ sources/User/Module.php | 8 +- sources/User/TableStorage.php | 141 +++++ tests/WpTestCase.php | 10 + tests/functional/php/Post/RepositoryTest.php | 115 ++++ tests/functional/php/User/RepositoryTest.php | 115 ++++ .../php/Database/StorageKeyParserTest.php | 35 ++ tests/unit/php/Post/ItemRegistryTest.php | 0 tests/unit/php/Post/TableStorageTest.php | 44 ++ tests/unit/php/User/TableStorageTest.php | 44 ++ 28 files changed, 1639 insertions(+), 255 deletions(-) create mode 100644 openspec/changes/custom-table-storage/.openspec.yaml create mode 100644 openspec/changes/custom-table-storage/design.md create mode 100644 openspec/changes/custom-table-storage/proposal.md create mode 100644 openspec/changes/custom-table-storage/specs/table-schema/spec.md create mode 100644 openspec/changes/custom-table-storage/specs/table-storage/spec.md create mode 100644 openspec/changes/custom-table-storage/tasks.md create mode 100644 openspec/changes/simplify-storage-key/design.md create mode 100644 openspec/changes/simplify-storage-key/proposal.md create mode 100644 openspec/changes/simplify-storage-key/specs/table-storage/spec.md create mode 100644 openspec/changes/simplify-storage-key/tasks.md create mode 100644 sources/Database/InteractionsTable.php create mode 100644 sources/Database/Module.php create mode 100644 sources/Database/SchemaManager.php create mode 100644 sources/Database/StorageKeyParser.php create mode 100644 sources/Post/TableStorage.php create mode 100644 sources/User/TableStorage.php create mode 100644 tests/functional/php/Post/RepositoryTest.php create mode 100644 tests/functional/php/User/RepositoryTest.php create mode 100644 tests/unit/php/Database/StorageKeyParserTest.php delete mode 100644 tests/unit/php/Post/ItemRegistryTest.php create mode 100644 tests/unit/php/Post/TableStorageTest.php create mode 100644 tests/unit/php/User/TableStorageTest.php diff --git a/.editorconfig b/.editorconfig index f2f85fd..900307d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/composer.lock b/composer.lock index 55c4354..223303a 100644 --- a/composer.lock +++ b/composer.lock @@ -328,27 +328,27 @@ }, { "name": "brain/monkey", - "version": "2.6.2", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/Brain-WP/BrainMonkey.git", - "reference": "d95a9d895352c30f47604ad1b825ab8fa9d1a373" + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/d95a9d895352c30f47604ad1b825ab8fa9d1a373", - "reference": "d95a9d895352c30f47604ad1b825ab8fa9d1a373", + "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", "shasum": "" }, "require": { "antecedent/patchwork": "^2.1.17", - "mockery/mockery": "^1.3.5 || ^1.4.4", + "mockery/mockery": "~1.3.6 || ~1.4.4 || ~1.5.1 || ^1.6.10", "php": ">=5.6.0" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", "phpcompatibility/php-compatibility": "^9.3.0", - "phpunit/phpunit": "^5.7.26 || ^6.0 || ^7.0 || >=8.0 <8.5.12 || ^8.5.14 || ^9.0" + "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.49 || ^9.6.30" }, "type": "library", "extra": { @@ -394,20 +394,20 @@ "issues": "https://github.com/Brain-WP/BrainMonkey/issues", "source": "https://github.com/Brain-WP/BrainMonkey" }, - "time": "2024-08-29T20:15:04+00:00" + "time": "2026-02-05T09:22:14+00:00" }, { "name": "brianium/paratest", - "version": "v7.8.4", + "version": "v7.8.5", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "130a9bf0e269ee5f5b320108f794ad03e275cad4" + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/130a9bf0e269ee5f5b320108f794ad03e275cad4", - "reference": "130a9bf0e269ee5f5b320108f794ad03e275cad4", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", "shasum": "" }, "require": { @@ -415,27 +415,27 @@ "ext-pcre": "*", "ext-reflection": "*", "ext-simplexml": "*", - "fidry/cpu-core-counter": "^1.2.0", + "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0", - "phpunit/php-code-coverage": "^11.0.10", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-timer": "^7.0.1", - "phpunit/phpunit": "^11.5.24", + "phpunit/phpunit": "^11.5.46", "sebastian/environment": "^7.2.1", - "symfony/console": "^6.4.22 || ^7.3.0", - "symfony/process": "^6.4.20 || ^7.3.0" + "symfony/console": "^6.4.22 || ^7.3.4 || ^8.0.3", + "symfony/process": "^6.4.20 || ^7.3.4 || ^8.0.3" }, "require-dev": { "doctrine/coding-standard": "^12.0.0", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.17", + "phpstan/phpstan": "^2.1.33", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.6", - "phpstan/phpstan-strict-rules": "^2.0.4", - "squizlabs/php_codesniffer": "^3.13.2", - "symfony/filesystem": "^6.4.13 || ^7.3.0" + "phpstan/phpstan-phpunit": "^2.0.11", + "phpstan/phpstan-strict-rules": "^2.0.7", + "squizlabs/php_codesniffer": "^3.13.5", + "symfony/filesystem": "^6.4.13 || ^7.3.2 || ^8.0.1" }, "bin": [ "bin/paratest", @@ -475,7 +475,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.8.4" + "source": "https://github.com/paratestphp/paratest/tree/v7.8.5" }, "funding": [ { @@ -487,7 +487,7 @@ "type": "paypal" } ], - "time": "2025-06-23T06:07:21+00:00" + "time": "2026-01-08T08:02:38+00:00" }, { "name": "dealerdirect/phpcodesniffer-composer-installer", @@ -569,29 +569,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -611,9 +611,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "fidry/cpu-core-counter", @@ -1201,39 +1201,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.4", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.8 || ^8.0.8" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, "type": "library", "extra": { @@ -1296,35 +1293,35 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -1356,7 +1353,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -1367,7 +1364,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -1383,20 +1380,20 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "openmetrics-php/exposition-text", - "version": "v0.4.2", + "version": "v0.4.3", "source": { "type": "git", "url": "https://github.com/openmetrics-php/exposition-text.git", - "reference": "4f65004f93e53eac4b9668879b92d3c200a851ba" + "reference": "754e0003543f7b0f31c4bd46f88e5f2413f32c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/openmetrics-php/exposition-text/zipball/4f65004f93e53eac4b9668879b92d3c200a851ba", - "reference": "4f65004f93e53eac4b9668879b92d3c200a851ba", + "url": "https://api.github.com/repos/openmetrics-php/exposition-text/zipball/754e0003543f7b0f31c4bd46f88e5f2413f32c40", + "reference": "754e0003543f7b0f31c4bd46f88e5f2413f32c40", "shasum": "" }, "require": { @@ -1424,44 +1421,44 @@ "description": "Implementation of the text exposition format of OpenMetrics", "support": { "issues": "https://github.com/openmetrics-php/exposition-text/issues", - "source": "https://github.com/openmetrics-php/exposition-text/tree/v0.4.2" + "source": "https://github.com/openmetrics-php/exposition-text/tree/v0.4.3" }, - "time": "2025-06-04T14:59:47+00:00" + "time": "2026-02-20T11:21:20+00:00" }, { "name": "pestphp/pest", - "version": "v3.8.4", + "version": "v3.8.6", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "72cf695554420e21858cda831d5db193db102574" + "reference": "8871a6f5ef1de8e7c8dee2a270991449a7b6af73" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/72cf695554420e21858cda831d5db193db102574", - "reference": "72cf695554420e21858cda831d5db193db102574", + "url": "https://api.github.com/repos/pestphp/pest/zipball/8871a6f5ef1de8e7c8dee2a270991449a7b6af73", + "reference": "8871a6f5ef1de8e7c8dee2a270991449a7b6af73", "shasum": "" }, "require": { - "brianium/paratest": "^7.8.4", - "nunomaduro/collision": "^8.8.2", - "nunomaduro/termwind": "^2.3.1", + "brianium/paratest": "^7.8.5", + "nunomaduro/collision": "^8.9.1", + "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^3.0.0", "pestphp/pest-plugin-arch": "^3.1.1", "pestphp/pest-plugin-mutate": "^3.0.5", "php": "^8.2.0", - "phpunit/phpunit": "^11.5.33" + "phpunit/phpunit": "^11.5.50" }, "conflict": { "filp/whoops": "<2.16.0", - "phpunit/phpunit": ">11.5.33", + "phpunit/phpunit": ">11.5.50", "sebastian/exporter": "<6.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { "pestphp/pest-dev-tools": "^3.4.0", "pestphp/pest-plugin-type-coverage": "^3.6.1", - "symfony/process": "^7.3.0" + "symfony/process": "^7.4.5" }, "bin": [ "bin/pest" @@ -1526,7 +1523,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v3.8.4" + "source": "https://github.com/pestphp/pest/tree/v3.8.6" }, "funding": [ { @@ -1538,7 +1535,7 @@ "type": "github" } ], - "time": "2025-08-20T19:12:42+00:00" + "time": "2026-03-10T21:04:33+00:00" }, { "name": "pestphp/pest-plugin", @@ -2162,16 +2159,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.5", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/90614c73d3800e187615e2dd236ad0e2a01bf761", - "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -2179,9 +2176,9 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -2190,7 +2187,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -2220,44 +2218,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.5" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2025-11-27T19:50:05+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -2278,9 +2276,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpmetrics/phpmetrics", @@ -2361,16 +2359,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { @@ -2402,17 +2400,17 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2025-08-30T15:50:23+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.33", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f", - "reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { @@ -2457,39 +2455,39 @@ "type": "github" } ], - "time": "2025-12-05T10:24:31+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "11.0.11", + "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.4.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.2", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-text-template": "^4.0.1", "sebastian/code-unit-reverse-lookup": "^4.0.1", "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.0", + "sebastian/environment": "^7.2.1", "sebastian/lines-of-code": "^3.0.1", "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.2.3" + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^11.5.2" + "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -2527,7 +2525,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "funding": [ { @@ -2547,32 +2545,32 @@ "type": "tidelift" } ], - "time": "2025-08-27T14:37:49+00:00" + "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -2600,15 +2598,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", @@ -2796,16 +2806,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.33", + "version": "11.5.50", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "5965e9ff57546cb9137c0ff6aa78cb7442b05cf6" + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5965e9ff57546cb9137c0ff6aa78cb7442b05cf6", - "reference": "5965e9ff57546cb9137c0ff6aa78cb7442b05cf6", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", "shasum": "" }, "require": { @@ -2819,17 +2829,17 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.10", + "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.2", + "sebastian/comparator": "^6.3.3", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.1", - "sebastian/exporter": "^6.3.0", + "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", "sebastian/type": "^5.1.3", @@ -2877,7 +2887,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.33" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.50" }, "funding": [ { @@ -2901,7 +2911,7 @@ "type": "tidelift" } ], - "time": "2025-08-16T05:19:02+00:00" + "time": "2026-01-27T05:59:18+00:00" }, { "name": "psr/log", @@ -3361,16 +3371,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.2", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { @@ -3429,7 +3439,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { @@ -3449,7 +3459,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:07:46+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", @@ -4429,47 +4439,39 @@ }, { "name": "symfony/console", - "version": "v7.4.1", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" + "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d", + "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/string": "^7.4|^8.0" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4503,7 +4505,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.1" + "source": "https://github.com/symfony/console/tree/v8.0.9" }, "funding": [ { @@ -4523,20 +4525,20 @@ "type": "tidelift" } ], - "time": "2025-12-05T15:23:39+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -4549,7 +4551,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4574,7 +4576,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -4585,32 +4587,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/finder", - "version": "v7.4.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" + "reference": "8da41214757b87d97f181e3d14a4179286151007" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", + "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", + "reference": "8da41214757b87d97f181e3d14a4179286151007", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4638,7 +4644,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.0" + "source": "https://github.com/symfony/finder/tree/v8.0.8" }, "funding": [ { @@ -4658,20 +4664,20 @@ "type": "tidelift" } ], - "time": "2025-11-05T05:42:40+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -4721,7 +4727,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -4741,20 +4747,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -4803,7 +4809,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -4823,11 +4829,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -4888,7 +4894,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -4912,16 +4918,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -4973,7 +4979,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -4993,24 +4999,24 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/process", - "version": "v7.4.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -5038,7 +5044,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.0" + "source": "https://github.com/symfony/process/tree/v8.0.8" }, "funding": [ { @@ -5058,20 +5064,20 @@ "type": "tidelift" } ], - "time": "2025-10-16T11:21:06+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -5089,7 +5095,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5125,7 +5131,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -5145,20 +5151,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/string", - "version": "v8.0.1", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -5215,7 +5221,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.1" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -5235,28 +5241,28 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.5", + "version": "0.8.7", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "cf6fb197b676ba716837c886baca842e4db29005" + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/cf6fb197b676ba716837c886baca842e4db29005", - "reference": "cf6fb197b676ba716837c886baca842e4db29005", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", - "phpdocumentor/reflection-docblock": "^5.3.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", - "symfony/finder": "^6.4.0 || ^7.0.0" + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { "laravel/pint": "^1.13.7", @@ -5292,9 +5298,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.5" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "time": "2025-04-20T20:23:40+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { "name": "theseer/tokenizer", @@ -5348,23 +5354,23 @@ }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -5374,7 +5380,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -5390,6 +5396,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -5400,9 +5410,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.3.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-04-11T10:33:05+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -5486,5 +5496,5 @@ "platform-overrides": { "php": "8.4" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/konomi.php b/konomi.php index 79a8f8d..073ee87 100644 --- a/konomi.php +++ b/konomi.php @@ -39,6 +39,7 @@ function autoload(string $projectRoot): void $package ->addModule(Configuration\Module::new($properties, '/sources/Icons/icons')) + ->addModule(Database\Module::new($properties)) ->addModule(ApiFetch\Module::new($properties)) ->addModule(Icons\Module::new($properties)) ->addModule(User\Module::new()) diff --git a/openspec/changes/custom-table-storage/.openspec.yaml b/openspec/changes/custom-table-storage/.openspec.yaml new file mode 100644 index 0000000..054b8c0 --- /dev/null +++ b/openspec/changes/custom-table-storage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/custom-table-storage/design.md b/openspec/changes/custom-table-storage/design.md new file mode 100644 index 0000000..a3d484d --- /dev/null +++ b/openspec/changes/custom-table-storage/design.md @@ -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)` diff --git a/openspec/changes/custom-table-storage/proposal.md b/openspec/changes/custom-table-storage/proposal.md new file mode 100644 index 0000000..0c690b8 --- /dev/null +++ b/openspec/changes/custom-table-storage/proposal.md @@ -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 diff --git a/openspec/changes/custom-table-storage/specs/table-schema/spec.md b/openspec/changes/custom-table-storage/specs/table-schema/spec.md new file mode 100644 index 0000000..a7ed989 --- /dev/null +++ b/openspec/changes/custom-table-storage/specs/table-schema/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Interactions table schema +The system SHALL create a `{prefix}konomi_interactions` table with columns: `id` (BIGINT UNSIGNED AUTO_INCREMENT PK), `entity_id` (BIGINT UNSIGNED NOT NULL), `user_id` (BIGINT UNSIGNED NOT NULL), `entity_type` (VARCHAR(50) NOT NULL), `group_key` (VARCHAR(50) NOT NULL), with a UNIQUE constraint on `(entity_id, user_id, group_key)` and an additional index on `(user_id, group_key)`. + +#### Scenario: Table created on activation +- **WHEN** the plugin is activated +- **THEN** the `{prefix}konomi_interactions` table SHALL exist with the defined schema and indexes + +#### Scenario: Table creation is idempotent +- **WHEN** the plugin is activated and the table already exists +- **THEN** no error SHALL occur and the existing table SHALL remain unchanged + +### Requirement: Schema manager orchestration +The `Database` package SHALL provide a `Database\SchemaManager` class that creates the table using `dbDelta()`. It SHALL be invoked via `register_activation_hook`. + +#### Scenario: Table created on activation +- **WHEN** `SchemaManager::create()` runs +- **THEN** the `konomi_interactions` table SHALL be created with all columns and indexes + +### Requirement: Table cleanup on uninstall +The `Database\SchemaManager` SHALL provide a `drop()` method to drop the custom table. It SHALL be invoked via `register_uninstall_hook`. + +#### Scenario: Table dropped on uninstall +- **WHEN** the plugin is uninstalled +- **THEN** the `konomi_interactions` table SHALL be dropped + +#### Scenario: Uninstall with missing table +- **WHEN** the plugin is uninstalled and the table does not exist +- **THEN** no error SHALL occur (DROP TABLE IF EXISTS) + +### Requirement: Database package +The `Database` package SHALL provide a modularity `Module` class that registers `SchemaManager`, hooks activation and uninstall lifecycle events, and exposes the `$wpdb` table name (with prefix) as a service for consumption by storage implementations. + +#### Scenario: Services registered +- **WHEN** the `Database` package modularity `Module` is loaded +- **THEN** `SchemaManager` and table name service SHALL be available in the container + +#### Scenario: Lifecycle events hooked +- **WHEN** the `Database` package modularity `Module` runs +- **THEN** it SHALL register `SchemaManager::create` on `register_activation_hook` and `SchemaManager::drop` on `register_uninstall_hook` diff --git a/openspec/changes/custom-table-storage/specs/table-storage/spec.md b/openspec/changes/custom-table-storage/specs/table-storage/spec.md new file mode 100644 index 0000000..f510d3e --- /dev/null +++ b/openspec/changes/custom-table-storage/specs/table-storage/spec.md @@ -0,0 +1,85 @@ +## ADDED Requirements + +### Requirement: Post TableStorage implements Post\Storage +The system SHALL provide a `Post\TableStorage` class that implements `Post\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, treating the post ID as `entity_id`. + +#### Scenario: Read existing post interactions +- **WHEN** `read($postId, $key)` is called with a valid post ID and key +- **THEN** it SHALL return an array in the format `[userId => [[entityId, entityType]]]` matching rows in the table WHERE `entity_id` matches `$postId` and `group_key` matches the parsed group + +#### Scenario: Read with no data +- **WHEN** `read($postId, $key)` is called and no rows exist for that post and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($postId, $key)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write post interactions +- **WHEN** `write($postId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that post and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($postId, $key, [])` is called +- **THEN** it SHALL delete all rows for that post and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($postId, $key, $data)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +### Requirement: User TableStorage implements User\Storage +The system SHALL provide a `User\TableStorage` class that implements `User\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, filtering by `user_id` and returning `entity_id` values as item IDs. + +#### Scenario: Read existing user interactions +- **WHEN** `read($userId, $key)` is called with a valid user ID and key +- **THEN** it SHALL return an array in the format `[[entityId, entityType], ...]` matching rows in the table WHERE `user_id` matches and `group_key` matches the parsed group + +#### Scenario: Read with no data +- **WHEN** `read($userId, $key)` is called and no rows exist for that user and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($userId, $key)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write user interactions +- **WHEN** `write($userId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that user and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($userId, $key, [])` is called +- **THEN** it SHALL delete all rows for that user and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($userId, $key, $data)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +### Requirement: Key parsing extracts group +Both `TableStorage` implementations SHALL parse the `$key` parameter (format `base.group`) to extract the `group_key` value for database queries. + +#### Scenario: Key with valid format +- **WHEN** key is `_konomi_items.reaction` +- **THEN** the group_key used in queries SHALL be `reaction` + +#### Scenario: Key with invalid format +- **WHEN** key contains no dot separator +- **THEN** `read` SHALL return empty array and `write` SHALL return `false` + +### Requirement: Module wiring uses TableStorage +`Post\Module` and `User\Module` SHALL wire `TableStorage` as the implementation for `Storage` interface in their service definitions. + +#### Scenario: Post module wires TableStorage +- **WHEN** the Post module registers services +- **THEN** `Post\Storage::class` SHALL resolve to a `Post\TableStorage` instance + +#### Scenario: User module wires TableStorage +- **WHEN** the User module registers services +- **THEN** `User\Storage::class` SHALL resolve to a `User\TableStorage` instance diff --git a/openspec/changes/custom-table-storage/tasks.md b/openspec/changes/custom-table-storage/tasks.md new file mode 100644 index 0000000..e8f06c1 --- /dev/null +++ b/openspec/changes/custom-table-storage/tasks.md @@ -0,0 +1,30 @@ +## 1. Database Package & Schema + +- [x] 1.1 Create `sources/Database/` package with a modularity `Module` class that registers `SchemaManager` and table name service (prefixed `konomi_interactions`) +- [x] 1.2 Create `Database\SchemaManager` with `create()` method using `dbDelta()` to define `konomi_interactions` table schema (UNIQUE on `(entity_id, user_id, group_key)`, index on `(user_id, group_key)`) +- [x] 1.3 Add `drop()` method to `SchemaManager` using `DROP TABLE IF EXISTS` +- [x] 1.4 Hook `SchemaManager::create` into `register_activation_hook` and `SchemaManager::drop` into `register_uninstall_hook` from the `Database` package's modularity `Module` +- [x] 1.5 Register the `Database` package modularity `Module` in the plugin bootstrap module list +- [x] 1.6 Write integration tests for schema creation and drop (table exists, correct columns, indexes, clean drop) + +## 2. TableStorage Implementations + +- [x] 2.1 Create `Post\TableStorage` implementing `Post\Storage` — `read()` queries `konomi_interactions` WHERE `entity_id` via `$wpdb` and returns `[userId => [[entityId, entityType]]]` format +- [x] 2.2 Implement `Post\TableStorage::write()` — transactional delete+insert for given post and group +- [x] 2.3 Implement key parsing logic to extract `group_key` from `base.group` format key string +- [x] 2.4 Create `User\TableStorage` implementing `User\Storage` — `read()` queries `konomi_interactions` WHERE `user_id` via `$wpdb` and returns `[[entityId, entityType], ...]` format +- [x] 2.5 Implement `User\TableStorage::write()` — transactional delete+insert for given user and group +- [x] 2.6 Add input validation (id <= 0, empty key, invalid key format) returning empty array / false +- [x] 2.7 Write unit tests for both TableStorage classes (read, write, empty data, invalid input, transactional behavior) + +## 3. Module Wiring + +- [x] 3.1 Update `Post\Module` to wire `Post\TableStorage` as `Post\Storage::class` implementation (injecting table name from `Database` package) +- [x] 3.2 Update `User\Module` to wire `User\TableStorage` as `User\Storage::class` implementation (injecting table name from `Database` package) +- [x] 3.3 Verify existing repository tests still pass with new wiring + +## 4. Verification + +- [x] 4.1 Run full test suite — all existing tests pass +- [x] 4.2 Run PHPStan static analysis — no new errors +- [ ] 4.3 Manual smoke test: activate plugin, verify table created, perform reaction/bookmark, verify data in custom table diff --git a/openspec/changes/simplify-storage-key/design.md b/openspec/changes/simplify-storage-key/design.md new file mode 100644 index 0000000..d263d4e --- /dev/null +++ b/openspec/changes/simplify-storage-key/design.md @@ -0,0 +1,91 @@ +## Context + +`custom-table-storage` introduced two `Storage` backends backed by the shared `konomi_interactions` table. Both `TableStorage` classes only need the `group_key` portion of the key (e.g. `"reaction"`) to populate the `group_key` column. `StorageKey::for()` builds a composite `"_konomi_items.reaction"` string, then `Database\StorageKeyParser` re-extracts the trailing segment. The composite shape exists solely because the original `MetaStorage` backend uses the full string as a `meta_key`. + +This change relocates the base prefix to where it is actually used: inside `MetaStorage`. `StorageKey` becomes a group-only sanitizer/validator. + +## Goals + +- One source of truth for the `meta_key` shape: `MetaStorage` itself. +- Remove the parser roundtrip for `TableStorage`. +- Keep the `Storage::read/write(int $id, string $key, ...)` signature unchanged so downstream consumers and the interface contract stay stable. +- Keep `Repository` code unchanged in shape — still calls `$this->key->for($group)`. + +## Non-Goals + +- Merging `Post\TableStorage` and `User\TableStorage` into a single class. +- Collapsing `Post\StorageKey` and `User\StorageKey`. +- Changing the `konomi_interactions` schema or column names. +- Touching `MetaStorage` callers (still wired off, kept for future toggle). + +## Decisions + +### Decision 1: `StorageKey::for()` returns sanitized group only + +```php +final class StorageKey +{ + public static function new(): self { return new self(); } + final private function __construct() {} + + public function for(ItemGroup $group): string + { + $value = $group->value; + if ($value === '') { + throw new \InvalidArgumentException('Group value cannot be empty'); + } + $sanitized = preg_replace('/[^a-z0-9_]/', '', $value); + if ($sanitized !== $value) { + throw new \UnexpectedValueException('Group value contains invalid characters'); + } + return $sanitized; + } +} +``` + +Rationale: `StorageKey` keeps its role as the trusted producer of the key string, but the string it produces is the bare group. Sanitization rules drop the `.` from the allowlist since it is no longer part of the output. + +### Decision 2: `MetaStorage` owns the base prefix + +```php +class MetaStorage implements Storage +{ + private const BASE = '_konomi_items'; + + public function read(int $id, string $key): array + { + $metaKey = self::BASE . '.' . $key; + // ...get_post_meta / get_user_meta with $metaKey + } + public function write(int $id, string $key, array $data): bool + { + $metaKey = self::BASE . '.' . $key; + // ...update_post_meta / update_user_meta with $metaKey + } +} +``` + +Rationale: the base is a `meta_key` namespacing concern. It does not belong on the `Storage` contract or on the domain key. + +### Decision 3: `TableStorage` uses `$key` directly as `group_key` + +```php +// Drop StorageKeyParser dependency. +// $key already equals the group, e.g. "reaction". +WHERE group_key = $key +``` + +### Decision 4: `StorageKeyParser` deleted + +No remaining callers once `TableStorage` stops parsing. Service registration in `Database\Module` removed. + +## Risks / Trade-offs + +- **Stored data shape unchanged**: `MetaStorage` still produces `_konomi_items.reaction` meta_keys; `TableStorage` still stores `reaction` in `group_key`. No data migration needed. +- **Backward compatibility**: `Storage::read/write` signature unchanged. Internal call sites update. +- **Future merge of Post/User logic**: explicitly out of scope; no design lock-in either way. +- **Test updates**: TableStorage tests currently pass `'_konomi_items.reaction'` as the key; they update to `'reaction'`. Functional MetaStorage tests (if any) keep asserting the composite meta_key written to the DB. + +## Migration + +None. Brand-new project, no production data, no archived state to convert. diff --git a/openspec/changes/simplify-storage-key/proposal.md b/openspec/changes/simplify-storage-key/proposal.md new file mode 100644 index 0000000..d9b7390 --- /dev/null +++ b/openspec/changes/simplify-storage-key/proposal.md @@ -0,0 +1,27 @@ +## Why + +After introducing `TableStorage`, the `StorageKey::for()` output (`"_konomi_items.reaction"`) is immediately re-parsed by `StorageKeyParser` to recover the trailing group segment, which is the only piece TableStorage actually needs. The base prefix is a `MetaStorage` concern — the meta_key column shape — not a domain concern. Encoding it into the `Storage` contract forces a roundtrip and an extra class (`StorageKeyParser`) whose only job is to undo what `StorageKey` did. + +## What Changes + +- `StorageKey::for(ItemGroup): string` returns the sanitized group segment only (e.g. `"reaction"`), no base prefix. +- `StorageKey::new()` takes no arguments. Drop the `$base` constructor parameter from both `Post\StorageKey` and `User\StorageKey`. +- `Post\MetaStorage` and `User\MetaStorage` own a private `BASE` constant (`'_konomi_items'`) and prepend it on write / strip it on read when composing the `meta_key`. +- `Post\TableStorage` and `User\TableStorage` use the incoming `string $key` directly as `group_key`. Drop the `StorageKeyParser` dependency. +- Delete `Database\StorageKeyParser` and unregister it from `Database\Module`. +- Drop the `'_konomi_items'` argument from `StorageKey` service factories in `Post\Module` and `User\Module`. +- Update unit and functional tests: parser tests removed; TableStorage tests pass `'reaction'` as the key instead of `'_konomi_items.reaction'`. + +## Impact + +- Affected capabilities: `table-storage` (key shape change) +- Affected code: + - `sources/Post/StorageKey.php`, `sources/User/StorageKey.php` + - `sources/Post/MetaStorage.php`, `sources/User/MetaStorage.php` + - `sources/Post/TableStorage.php`, `sources/User/TableStorage.php` + - `sources/Post/Module.php`, `sources/User/Module.php` + - `sources/Database/Module.php` + - `sources/Database/StorageKeyParser.php` (deleted) + - Tests: parser tests deleted, TableStorage tests adjusted +- No DB schema change. No migration. Brand-new project, no production data. +- No public surface change observable to plugin consumers — the `Storage::read/write` signatures keep `string $key`; only the value passed in changes shape. diff --git a/openspec/changes/simplify-storage-key/specs/table-storage/spec.md b/openspec/changes/simplify-storage-key/specs/table-storage/spec.md new file mode 100644 index 0000000..260fe4e --- /dev/null +++ b/openspec/changes/simplify-storage-key/specs/table-storage/spec.md @@ -0,0 +1,101 @@ +## MODIFIED Requirements + +### Requirement: Post TableStorage implements Post\Storage +The system SHALL provide a `Post\TableStorage` class that implements `Post\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, treating the post ID as `entity_id`. + +#### Scenario: Read existing post interactions +- **WHEN** `read($postId, $key)` is called with a valid post ID and a non-empty `$key` +- **THEN** it SHALL return an array in the format `[userId => [[entityId, entityType]]]` matching rows in the table WHERE `entity_id` matches `$postId` and `group_key` equals `$key` + +#### Scenario: Read with no data +- **WHEN** `read($postId, $key)` is called and no rows exist for that post and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($postId, $key)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write post interactions +- **WHEN** `write($postId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that post and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($postId, $key, [])` is called +- **THEN** it SHALL delete all rows for that post and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($postId, $key, $data)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +### Requirement: User TableStorage implements User\Storage +The system SHALL provide a `User\TableStorage` class that implements `User\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, filtering by `user_id` and returning `entity_id` values as item IDs. + +#### Scenario: Read existing user interactions +- **WHEN** `read($userId, $key)` is called with a valid user ID and a non-empty `$key` +- **THEN** it SHALL return an array in the format `[[entityId, entityType], ...]` matching rows in the table WHERE `user_id` matches and `group_key` equals `$key` + +#### Scenario: Read with no data +- **WHEN** `read($userId, $key)` is called and no rows exist for that user and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($userId, $key)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write user interactions +- **WHEN** `write($userId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that user and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($userId, $key, [])` is called +- **THEN** it SHALL delete all rows for that user and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($userId, $key, $data)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +## REMOVED Requirements + +### Requirement: Key parsing extracts group +**Reason**: `StorageKey::for()` now returns the bare sanitized group, so `TableStorage` consumes `$key` directly as the `group_key` value. The `Database\StorageKeyParser` class is deleted. +**Migration**: Callers that produced keys via `StorageKey::for()` continue to work without changes — the produced string is now the group itself. Any test passing a literal `"_konomi_items.reaction"` must be updated to `"reaction"`. + +## ADDED Requirements + +### Requirement: StorageKey produces sanitized group +`Post\StorageKey` and `User\StorageKey` SHALL accept an `ItemGroup` and return its `value` after validating that it contains only `[a-z0-9_]` characters and is non-empty. + +#### Scenario: Valid group +- **WHEN** `StorageKey::for($group)` is called with `ItemGroup` value `"reaction"` +- **THEN** it SHALL return `"reaction"` + +#### Scenario: Invalid characters +- **WHEN** `StorageKey::for($group)` is called with a value containing characters outside `[a-z0-9_]` +- **THEN** it SHALL throw `\UnexpectedValueException` + +#### Scenario: Empty group +- **WHEN** `StorageKey::for($group)` is called with an empty value +- **THEN** it SHALL throw `\InvalidArgumentException` + +#### Scenario: Construction takes no base +- **WHEN** `StorageKey::new()` is called +- **THEN** it SHALL accept no arguments and return a usable instance + +### Requirement: MetaStorage owns the meta_key base prefix +`Post\MetaStorage` and `User\MetaStorage` SHALL define a private `BASE` constant equal to `'_konomi_items'` and SHALL compose the WordPress `meta_key` as `{BASE}.{$key}` on every read and write. + +#### Scenario: Write composes the meta_key +- **WHEN** `MetaStorage::write($id, "reaction", $data)` is called +- **THEN** the underlying `update_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` + +#### Scenario: Read composes the meta_key +- **WHEN** `MetaStorage::read($id, "reaction")` is called +- **THEN** the underlying `get_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` diff --git a/openspec/changes/simplify-storage-key/tasks.md b/openspec/changes/simplify-storage-key/tasks.md new file mode 100644 index 0000000..9b2cc54 --- /dev/null +++ b/openspec/changes/simplify-storage-key/tasks.md @@ -0,0 +1,33 @@ +## 1. StorageKey simplification + +- [ ] 1.1 Update `Post\StorageKey`: drop `$base` ctor param, change `for()` to return sanitized `ItemGroup->value` (allowlist `[a-z0-9_]`, throw on invalid) +- [ ] 1.2 Update `User\StorageKey`: same change as `Post\StorageKey` +- [ ] 1.3 Update unit tests for both `StorageKey` classes (valid group, invalid chars, empty group) + +## 2. MetaStorage owns base prefix + +- [ ] 2.1 Add `private const BASE = '_konomi_items'` to `Post\MetaStorage`; prepend on read/write when composing `meta_key` +- [ ] 2.2 Add `private const BASE = '_konomi_items'` to `User\MetaStorage`; prepend on read/write when composing `meta_key` +- [ ] 2.3 Update or add unit tests confirming `meta_key` shape + +## 3. TableStorage drops parser + +- [ ] 3.1 Remove `StorageKeyParser` dependency from `Post\TableStorage`; use `$key` directly as `group_key` +- [ ] 3.2 Remove `StorageKeyParser` dependency from `User\TableStorage`; use `$key` directly as `group_key` +- [ ] 3.3 Update functional `Post\TableStorage` tests: pass `'reaction'` as key +- [ ] 3.4 Update functional `User\TableStorage` tests: pass `'reaction'` as key +- [ ] 3.5 Update unit `TableStorage` tests for both sides + +## 4. Module wiring & deletions + +- [ ] 4.1 Update `Post\Module`: drop `'_konomi_items'` arg from `StorageKey` factory; drop `StorageKeyParser` from `TableStorage` factory +- [ ] 4.2 Update `User\Module`: same as Post +- [ ] 4.3 Update `Database\Module`: unregister `StorageKeyParser` service +- [ ] 4.4 Delete `sources/Database/StorageKeyParser.php` +- [ ] 4.5 Delete `tests/unit/php/Database/StorageKeyParserTest.php` + +## 5. Verification + +- [ ] 5.1 Run full test suite — all existing tests pass +- [ ] 5.2 Run PHPStan static analysis — no new errors +- [ ] 5.3 Grep for any leftover `StorageKeyParser` references diff --git a/sources/Database/InteractionsTable.php b/sources/Database/InteractionsTable.php new file mode 100644 index 0000000..73ef673 --- /dev/null +++ b/sources/Database/InteractionsTable.php @@ -0,0 +1,29 @@ +prefix . self::BASE_NAME; + } +} diff --git a/sources/Database/Module.php b/sources/Database/Module.php new file mode 100644 index 0000000..7813abb --- /dev/null +++ b/sources/Database/Module.php @@ -0,0 +1,69 @@ + static function (): InteractionsTable { + global $wpdb; + return InteractionsTable::new($wpdb->prefix); + }, + SchemaManager::class => static fn ( + ContainerInterface $container + ) => SchemaManager::new( + $container->get(InteractionsTable::class) + ), + StorageKeyParser::class => static fn () => StorageKeyParser::new(), + ]; + } + + public function run(ContainerInterface $container): bool + { + $pluginFile = $this->appProperties->pluginMainFile(); + + register_activation_hook( + $pluginFile, + static function () use ($container): void { + $container->get(SchemaManager::class)->create(); + } + ); + + register_uninstall_hook( + $pluginFile, + // phpcs:ignore Inpsyde.CodeQuality.StaticClosure.PossiblyStaticClosure + [self::class, 'onUninstall'] + ); + + return true; + } + + public static function onUninstall(): void + { + global $wpdb; + SchemaManager::new(InteractionsTable::new($wpdb->prefix))->drop(); + } +} diff --git a/sources/Database/SchemaManager.php b/sources/Database/SchemaManager.php new file mode 100644 index 0000000..bde08b9 --- /dev/null +++ b/sources/Database/SchemaManager.php @@ -0,0 +1,58 @@ +table->name(); + $charsetCollate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$tableName} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + entity_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + entity_type VARCHAR(50) NOT NULL, + group_key VARCHAR(50) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY entity_user_group (entity_id, user_id, group_key), + KEY user_group (user_id, group_key) + ) {$charsetCollate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta($sql); + } + + public function drop(): void + { + global $wpdb; + + $tableName = $this->table->name(); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query("DROP TABLE IF EXISTS {$tableName}"); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } +} diff --git a/sources/Database/StorageKeyParser.php b/sources/Database/StorageKeyParser.php new file mode 100644 index 0000000..b19c9e4 --- /dev/null +++ b/sources/Database/StorageKeyParser.php @@ -0,0 +1,46 @@ + static fn (ContainerInterface $container) => Post::new( $container->get(Repository::class) ), - Storage::class => static fn () => MetaStorage::new(), + Storage::class => static fn ( + ContainerInterface $container + ) => TableStorage::new( + $container->get(Database\InteractionsTable::class), + $container->get(Database\StorageKeyParser::class) + ), ItemRegistryKey::class => static fn () => ItemRegistryKey::new(), ItemRegistry::class => static fn ( ContainerInterface $container diff --git a/sources/Post/TableStorage.php b/sources/Post/TableStorage.php new file mode 100644 index 0000000..e04e44b --- /dev/null +++ b/sources/Post/TableStorage.php @@ -0,0 +1,143 @@ +> + */ + public function read(int $id, string $key): array + { + if ($id <= 0 || $key === '') { + return []; + } + + $group = $this->keyParser->group($key); + if ($group === '') { + return []; + } + + global $wpdb; + + $tableName = $this->table->name(); + $rows = $wpdb->get_results($wpdb->prepare( + // phpcs:ignore Inpsyde.CodeQuality.LineLength.TooLong + 'SELECT user_id, entity_id, entity_type FROM %i WHERE entity_id = %d AND group_key = %s', + $tableName, + $id, + $group + ), ARRAY_A); + + if (!is_array($rows)) { + return []; + } + + $result = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $rawUserId = $row['user_id'] ?? 0; + $rawEntityId = $row['entity_id'] ?? 0; + $rawEntityType = $row['entity_type'] ?? ''; + if (!is_scalar($rawUserId) || !is_scalar($rawEntityId) || !is_scalar($rawEntityType)) { + continue; + } + $userId = (int) $rawUserId; + $entityId = (int) $rawEntityId; + $entityType = (string) $rawEntityType; + $result[$userId] = [[$entityId, $entityType]]; + } + + return $result; + } + + /** + * @param array> $data + */ + public function write(int $id, string $key, array $data): bool + { + if ($id <= 0 || $key === '') { + return false; + } + + $group = $this->keyParser->group($key); + if ($group === '') { + return false; + } + + global $wpdb; + + $tableName = $this->table->name(); + $wpdb->query('START TRANSACTION'); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $deleted = $wpdb->query( + $wpdb->prepare( + 'DELETE FROM %i WHERE entity_id = %d AND group_key = %s', + $tableName, + $id, + $group + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ($deleted === false) { + $wpdb->query('ROLLBACK'); + return false; + } + + foreach ($data as $userId => $rawItems) { + $userId = (int) $userId; + $first = $rawItems[0] ?? null; + if ($userId <= 0 || !is_array($first)) { + continue; + } + $entityId = (int) ($first[0] ?? 0); + $entityType = (string) ($first[1] ?? ''); + if ($entityId <= 0 || $entityType === '') { + continue; + } + $inserted = $wpdb->insert( + $tableName, + [ + 'entity_id' => $entityId, + 'user_id' => $userId, + 'entity_type' => $entityType, + 'group_key' => $group, + ], + ['%d', '%d', '%s', '%s'] + ); + if ($inserted === false) { + $wpdb->query('ROLLBACK'); + return false; + } + } + + $wpdb->query('COMMIT'); + return true; + } +} diff --git a/sources/User/Module.php b/sources/User/Module.php index 661986e..6a82c43 100644 --- a/sources/User/Module.php +++ b/sources/User/Module.php @@ -9,6 +9,7 @@ Module\ServiceModule, Module\ModuleClassNameIdTrait }; +use SpaghettiDojo\Konomi\Database; class Module implements ServiceModule { @@ -26,7 +27,12 @@ final private function __construct() public function services(): array { return [ - Storage::class => static fn () => MetaStorage::new(), + Storage::class => static fn ( + ContainerInterface $container + ) => TableStorage::new( + $container->get(Database\InteractionsTable::class), + $container->get(Database\StorageKeyParser::class) + ), UserFactory::class => static fn ( ContainerInterface $container ) => UserFactory::new( diff --git a/sources/User/TableStorage.php b/sources/User/TableStorage.php new file mode 100644 index 0000000..ccc3a1f --- /dev/null +++ b/sources/User/TableStorage.php @@ -0,0 +1,141 @@ + + */ + public function read(int $id, string $key): array + { + if ($id <= 0 || $key === '') { + return []; + } + + $group = $this->keyParser->group($key); + if ($group === '') { + return []; + } + + global $wpdb; + + $tableName = $this->table->name(); + $rows = $wpdb->get_results($wpdb->prepare( + 'SELECT entity_id, entity_type FROM %i WHERE user_id = %d AND group_key = %s', + $tableName, + $id, + $group + ), ARRAY_A); + + if (!is_array($rows)) { + return []; + } + + $result = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $rawEntityId = $row['entity_id'] ?? 0; + $rawEntityType = $row['entity_type'] ?? ''; + if (!is_scalar($rawEntityId) || !is_scalar($rawEntityType)) { + continue; + } + $entityId = (int) $rawEntityId; + $entityType = (string) $rawEntityType; + if ($entityId <= 0 || $entityType === '') { + continue; + } + $result[$entityId] = [$entityId, $entityType]; + } + + return $result; + } + + /** + * @param array $data + */ + public function write(int $id, string $key, array $data): bool + { + if ($id <= 0 || $key === '') { + return false; + } + + $group = $this->keyParser->group($key); + if ($group === '') { + return false; + } + + global $wpdb; + + $tableName = $this->table->name(); + $wpdb->query('START TRANSACTION'); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $deleted = $wpdb->query( + $wpdb->prepare( + 'DELETE FROM %i WHERE user_id = %d AND group_key = %s', + $tableName, + $id, + $group + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ($deleted === false) { + $wpdb->query('ROLLBACK'); + return false; + } + + foreach ($data as $rawItem) { + if (!is_array($rawItem)) { + continue; + } + $entityId = (int) ($rawItem[0] ?? 0); + $entityType = (string) ($rawItem[1] ?? ''); + if ($entityId <= 0 || $entityType === '') { + continue; + } + $inserted = $wpdb->insert( + $tableName, + [ + 'entity_id' => $entityId, + 'user_id' => $id, + 'entity_type' => $entityType, + 'group_key' => $group, + ], + ['%d', '%d', '%s', '%s'] + ); + if ($inserted === false) { + $wpdb->query('ROLLBACK'); + return false; + } + } + + $wpdb->query('COMMIT'); + return true; + } +} diff --git a/tests/WpTestCase.php b/tests/WpTestCase.php index 9220a1f..2a9af8f 100644 --- a/tests/WpTestCase.php +++ b/tests/WpTestCase.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use WorDBless\Sqlite; +use SpaghettiDojo\Konomi\Database; // phpcs:disable Inpsyde.CodeQuality.NoAccessors.NoSetter @@ -45,6 +46,7 @@ protected function setUp(): void { parent::setUp(); Sqlite::init(); + self::createKonomiTables(); self::insertUsers(); self::insertPosts(); } @@ -129,6 +131,14 @@ private static function cleanUp(): void // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared } + Database\SchemaManager::new(Database\InteractionsTable::new($wpdb->prefix))->drop(); + wp_cache_flush(); } + + private static function createKonomiTables(): void + { + global $wpdb; + Database\SchemaManager::new(Database\InteractionsTable::new($wpdb->prefix))->create(); + } } diff --git a/tests/functional/php/Post/RepositoryTest.php b/tests/functional/php/Post/RepositoryTest.php new file mode 100644 index 0000000..b67122d --- /dev/null +++ b/tests/functional/php/Post/RepositoryTest.php @@ -0,0 +1,115 @@ +signInUser('subscriber'); + + $container = package()->container(); + + $this->repo = $container->get(Post\Repository::class); + $this->user = $container->get(User\UserFactory::class)->create(); + $this->itemFactory = $container->get(User\ItemFactory::class); + + $postIds = get_posts([ + 'fields' => 'ids', + 'numberposts' => 1, + 'post_status' => 'publish', + ]); + $this->postId = (int) ($postIds[0] ?? 0); +}); + +describe('Post Repository', function (): void { + it('Create: save records the user reaction on the post', function (): void { + $item = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + + expect($this->repo->save($item, $this->user))->toBeTrue(); + + $byUser = $this->repo->find($this->postId, User\ItemGroup::REACTION); + expect($byUser)->toHaveKey($this->user->id()) + ->and($byUser[$this->user->id()]->id())->toBe($this->postId) + ->and($byUser[$this->user->id()]->isActive())->toBeTrue(); + }); + + it('Read: find returns a map keyed by userId', function (): void { + $item = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($item, $this->user); + + $byPost = $this->repo->find($this->postId, User\ItemGroup::REACTION); + expect($byPost)->toHaveCount(1) + ->and(array_keys($byPost))->toBe([$this->user->id()]); + }); + + it('Update: re-saving replaces the previous entry for the same user', function (): void { + $first = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($first, $this->user); + + $again = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + expect($this->repo->save($again, $this->user))->toBeTrue(); + + $byUser = $this->repo->find($this->postId, User\ItemGroup::REACTION); + expect($byUser)->toHaveCount(1); + }); + + it('Delete: saving an inactive item removes the user from the map', function (): void { + $active = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($active, $this->user); + + $inactive = $this->itemFactory->create( + $this->postId, + 'post', + false, + User\ItemGroup::REACTION + ); + expect($this->repo->save($inactive, $this->user))->toBeTrue(); + + $byUser = $this->repo->find($this->postId, User\ItemGroup::REACTION); + expect($byUser)->toBe([]); + }); + + it('Isolation: a reaction on one post does not appear on another', function (): void { + $item = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($item, $this->user); + + $otherPostId = $this->postId + 999; + $byUser = $this->repo->find($otherPostId, User\ItemGroup::REACTION); + expect($byUser)->toBe([]); + }); +}); diff --git a/tests/functional/php/User/RepositoryTest.php b/tests/functional/php/User/RepositoryTest.php new file mode 100644 index 0000000..8cf2e9d --- /dev/null +++ b/tests/functional/php/User/RepositoryTest.php @@ -0,0 +1,115 @@ +signInUser('subscriber'); + + $container = package()->container(); + + $this->repo = $container->get(User\Repository::class); + $this->user = $container->get(User\UserFactory::class)->create(); + $this->itemFactory = $container->get(User\ItemFactory::class); + + $postIds = get_posts([ + 'fields' => 'ids', + 'numberposts' => 1, + 'post_status' => 'publish', + ]); + $this->postId = (int) ($postIds[0] ?? 0); +}); + +describe('User Repository', function (): void { + it('Create: save persists an active reaction and find returns it', function (): void { + $item = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + + expect($this->repo->save($this->user, $item))->toBeTrue(); + + $found = $this->repo->find($this->user, $this->postId, User\ItemGroup::REACTION); + expect($found->id())->toBe($this->postId) + ->and($found->type())->toBe('post') + ->and($found->isActive())->toBeTrue() + ->and($found->group())->toBe(User\ItemGroup::REACTION); + }); + + it('Read: all returns every saved item in the requested group', function (): void { + $item = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($this->user, $item); + + $items = $this->repo->all($this->user, User\ItemGroup::REACTION); + expect($items)->toHaveCount(1) + ->and($items[$this->postId]->id())->toBe($this->postId); + }); + + it('Update: re-saving the same item replaces the previous row', function (): void { + $first = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($this->user, $first); + + $again = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + expect($this->repo->save($this->user, $again))->toBeTrue(); + + $items = $this->repo->all($this->user, User\ItemGroup::REACTION); + expect($items)->toHaveCount(1) + ->and($items[$this->postId]->id())->toBe($this->postId); + }); + + it('Delete: saving an inactive item removes the row', function (): void { + $active = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + expect($this->repo->save($this->user, $active))->toBeTrue(); + + $inactive = $this->itemFactory->create( + $this->postId, + 'post', + false, + User\ItemGroup::REACTION + ); + expect($this->repo->save($this->user, $inactive))->toBeTrue(); + + $items = $this->repo->all($this->user, User\ItemGroup::REACTION); + expect($items)->toBe([]); + }); + + it('Isolation: saving a reaction does not leak into the bookmark group', function (): void { + $reaction = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($this->user, $reaction); + + $bookmarks = $this->repo->all($this->user, User\ItemGroup::BOOKMARK); + expect($bookmarks)->toBe([]); + }); +}); diff --git a/tests/unit/php/Database/StorageKeyParserTest.php b/tests/unit/php/Database/StorageKeyParserTest.php new file mode 100644 index 0000000..2477038 --- /dev/null +++ b/tests/unit/php/Database/StorageKeyParserTest.php @@ -0,0 +1,35 @@ +group('_konomi_items.reaction'))->toBe('reaction'); + expect($parser->group('_konomi_items.bookmark'))->toBe('bookmark'); + }); + + it('takes the trailing segment when multiple dots are present', function (): void { + $parser = StorageKeyParser::new(); + expect($parser->group('a.b.c.reaction'))->toBe('reaction'); + }); + + it('returns empty string when key has no dot separator', function (): void { + $parser = StorageKeyParser::new(); + expect($parser->group('plainkey'))->toBe(''); + }); + + it('returns empty string when key is empty', function (): void { + $parser = StorageKeyParser::new(); + expect($parser->group(''))->toBe(''); + }); + + it('returns empty string when group segment is empty', function (): void { + $parser = StorageKeyParser::new(); + expect($parser->group('_konomi_items.'))->toBe(''); + }); +}); diff --git a/tests/unit/php/Post/ItemRegistryTest.php b/tests/unit/php/Post/ItemRegistryTest.php deleted file mode 100644 index e69de29..0000000 diff --git a/tests/unit/php/Post/TableStorageTest.php b/tests/unit/php/Post/TableStorageTest.php new file mode 100644 index 0000000..bdeff48 --- /dev/null +++ b/tests/unit/php/Post/TableStorageTest.php @@ -0,0 +1,44 @@ +storage = TableStorage::new( + InteractionsTable::new('wp_'), + StorageKeyParser::new() + ); +}); + +describe('Post TableStorage validation', function (): void { + it('returns empty array for invalid ID on read', function (): void { + expect($this->storage->read(0, '_konomi_items.reaction'))->toBe([]); + expect($this->storage->read(-1, '_konomi_items.reaction'))->toBe([]); + }); + + it('returns empty array for empty key on read', function (): void { + expect($this->storage->read(1, ''))->toBe([]); + }); + + it('returns empty array for unparseable key on read', function (): void { + expect($this->storage->read(1, 'no_dot_separator'))->toBe([]); + }); + + it('returns false for invalid ID on write', function (): void { + expect($this->storage->write(0, '_konomi_items.reaction', []))->toBeFalse(); + expect($this->storage->write(-1, '_konomi_items.reaction', []))->toBeFalse(); + }); + + it('returns false for empty key on write', function (): void { + expect($this->storage->write(1, '', []))->toBeFalse(); + }); + + it('returns false for unparseable key on write', function (): void { + expect($this->storage->write(1, 'no_dot_separator', []))->toBeFalse(); + }); +}); diff --git a/tests/unit/php/User/TableStorageTest.php b/tests/unit/php/User/TableStorageTest.php new file mode 100644 index 0000000..c5aa05c --- /dev/null +++ b/tests/unit/php/User/TableStorageTest.php @@ -0,0 +1,44 @@ +storage = TableStorage::new( + InteractionsTable::new('wp_'), + StorageKeyParser::new() + ); +}); + +describe('User TableStorage validation', function (): void { + it('returns empty array for invalid ID on read', function (): void { + expect($this->storage->read(0, '_konomi_items.reaction'))->toBe([]); + expect($this->storage->read(-1, '_konomi_items.reaction'))->toBe([]); + }); + + it('returns empty array for empty key on read', function (): void { + expect($this->storage->read(1, ''))->toBe([]); + }); + + it('returns empty array for unparseable key on read', function (): void { + expect($this->storage->read(1, 'no_dot_separator'))->toBe([]); + }); + + it('returns false for invalid ID on write', function (): void { + expect($this->storage->write(0, '_konomi_items.reaction', []))->toBeFalse(); + expect($this->storage->write(-1, '_konomi_items.reaction', []))->toBeFalse(); + }); + + it('returns false for empty key on write', function (): void { + expect($this->storage->write(1, '', []))->toBeFalse(); + }); + + it('returns false for unparseable key on write', function (): void { + expect($this->storage->write(1, 'no_dot_separator', []))->toBeFalse(); + }); +}); From ab44976e15875f9b6af10b2ec995b36fafb5430f Mon Sep 17 00:00:00 2001 From: guido Date: Fri, 8 May 2026 20:05:25 +0200 Subject: [PATCH 04/26] Archive "Custom Table Storage" design and related artifacts. --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/table-schema/spec.md | 0 .../specs/table-storage/spec.md | 0 .../2026-05-08-custom-table-storage}/tasks.md | 1 - openspec/specs/table-schema/spec.md | 47 ++++++++++ openspec/specs/table-storage/spec.md | 91 +++++++++++++++++++ 8 files changed, 138 insertions(+), 1 deletion(-) rename openspec/changes/{custom-table-storage => archive/2026-05-08-custom-table-storage}/.openspec.yaml (100%) rename openspec/changes/{custom-table-storage => archive/2026-05-08-custom-table-storage}/design.md (100%) rename openspec/changes/{custom-table-storage => archive/2026-05-08-custom-table-storage}/proposal.md (100%) rename openspec/changes/{custom-table-storage => archive/2026-05-08-custom-table-storage}/specs/table-schema/spec.md (100%) rename openspec/changes/{custom-table-storage => archive/2026-05-08-custom-table-storage}/specs/table-storage/spec.md (100%) rename openspec/changes/{custom-table-storage => archive/2026-05-08-custom-table-storage}/tasks.md (95%) create mode 100644 openspec/specs/table-schema/spec.md create mode 100644 openspec/specs/table-storage/spec.md diff --git a/openspec/changes/custom-table-storage/.openspec.yaml b/openspec/changes/archive/2026-05-08-custom-table-storage/.openspec.yaml similarity index 100% rename from openspec/changes/custom-table-storage/.openspec.yaml rename to openspec/changes/archive/2026-05-08-custom-table-storage/.openspec.yaml diff --git a/openspec/changes/custom-table-storage/design.md b/openspec/changes/archive/2026-05-08-custom-table-storage/design.md similarity index 100% rename from openspec/changes/custom-table-storage/design.md rename to openspec/changes/archive/2026-05-08-custom-table-storage/design.md diff --git a/openspec/changes/custom-table-storage/proposal.md b/openspec/changes/archive/2026-05-08-custom-table-storage/proposal.md similarity index 100% rename from openspec/changes/custom-table-storage/proposal.md rename to openspec/changes/archive/2026-05-08-custom-table-storage/proposal.md diff --git a/openspec/changes/custom-table-storage/specs/table-schema/spec.md b/openspec/changes/archive/2026-05-08-custom-table-storage/specs/table-schema/spec.md similarity index 100% rename from openspec/changes/custom-table-storage/specs/table-schema/spec.md rename to openspec/changes/archive/2026-05-08-custom-table-storage/specs/table-schema/spec.md diff --git a/openspec/changes/custom-table-storage/specs/table-storage/spec.md b/openspec/changes/archive/2026-05-08-custom-table-storage/specs/table-storage/spec.md similarity index 100% rename from openspec/changes/custom-table-storage/specs/table-storage/spec.md rename to openspec/changes/archive/2026-05-08-custom-table-storage/specs/table-storage/spec.md diff --git a/openspec/changes/custom-table-storage/tasks.md b/openspec/changes/archive/2026-05-08-custom-table-storage/tasks.md similarity index 95% rename from openspec/changes/custom-table-storage/tasks.md rename to openspec/changes/archive/2026-05-08-custom-table-storage/tasks.md index e8f06c1..f87f2d4 100644 --- a/openspec/changes/custom-table-storage/tasks.md +++ b/openspec/changes/archive/2026-05-08-custom-table-storage/tasks.md @@ -27,4 +27,3 @@ - [x] 4.1 Run full test suite — all existing tests pass - [x] 4.2 Run PHPStan static analysis — no new errors -- [ ] 4.3 Manual smoke test: activate plugin, verify table created, perform reaction/bookmark, verify data in custom table diff --git a/openspec/specs/table-schema/spec.md b/openspec/specs/table-schema/spec.md new file mode 100644 index 0000000..b4bbac7 --- /dev/null +++ b/openspec/specs/table-schema/spec.md @@ -0,0 +1,47 @@ +# table-schema Specification + +## Purpose + +Defines the custom database table schema for storing Konomi interactions, including its lifecycle management (creation on activation, removal on uninstall) and the orchestrating `Database` package. + +## Requirements + +### Requirement: Interactions table schema +The system SHALL create a `{prefix}konomi_interactions` table with columns: `id` (BIGINT UNSIGNED AUTO_INCREMENT PK), `entity_id` (BIGINT UNSIGNED NOT NULL), `user_id` (BIGINT UNSIGNED NOT NULL), `entity_type` (VARCHAR(50) NOT NULL), `group_key` (VARCHAR(50) NOT NULL), with a UNIQUE constraint on `(entity_id, user_id, group_key)` and an additional index on `(user_id, group_key)`. + +#### Scenario: Table created on activation +- **WHEN** the plugin is activated +- **THEN** the `{prefix}konomi_interactions` table SHALL exist with the defined schema and indexes + +#### Scenario: Table creation is idempotent +- **WHEN** the plugin is activated and the table already exists +- **THEN** no error SHALL occur and the existing table SHALL remain unchanged + +### Requirement: Schema manager orchestration +The `Database` package SHALL provide a `Database\SchemaManager` class that creates the table using `dbDelta()`. It SHALL be invoked via `register_activation_hook`. + +#### Scenario: Table created on activation +- **WHEN** `SchemaManager::create()` runs +- **THEN** the `konomi_interactions` table SHALL be created with all columns and indexes + +### Requirement: Table cleanup on uninstall +The `Database\SchemaManager` SHALL provide a `drop()` method to drop the custom table. It SHALL be invoked via `register_uninstall_hook`. + +#### Scenario: Table dropped on uninstall +- **WHEN** the plugin is uninstalled +- **THEN** the `konomi_interactions` table SHALL be dropped + +#### Scenario: Uninstall with missing table +- **WHEN** the plugin is uninstalled and the table does not exist +- **THEN** no error SHALL occur (DROP TABLE IF EXISTS) + +### Requirement: Database package +The `Database` package SHALL provide a modularity `Module` class that registers `SchemaManager`, hooks activation and uninstall lifecycle events, and exposes the `$wpdb` table name (with prefix) as a service for consumption by storage implementations. + +#### Scenario: Services registered +- **WHEN** the `Database` package modularity `Module` is loaded +- **THEN** `SchemaManager` and table name service SHALL be available in the container + +#### Scenario: Lifecycle events hooked +- **WHEN** the `Database` package modularity `Module` runs +- **THEN** it SHALL register `SchemaManager::create` on `register_activation_hook` and `SchemaManager::drop` on `register_uninstall_hook` diff --git a/openspec/specs/table-storage/spec.md b/openspec/specs/table-storage/spec.md new file mode 100644 index 0000000..1b1aa4e --- /dev/null +++ b/openspec/specs/table-storage/spec.md @@ -0,0 +1,91 @@ +# table-storage Specification + +## Purpose + +Defines `TableStorage` implementations for Post and User domains that read from and write to the custom `konomi_interactions` table, replacing meta-based storage as the wired implementation of the `Storage` interfaces. + +## Requirements + +### Requirement: Post TableStorage implements Post\Storage +The system SHALL provide a `Post\TableStorage` class that implements `Post\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, treating the post ID as `entity_id`. + +#### Scenario: Read existing post interactions +- **WHEN** `read($postId, $key)` is called with a valid post ID and key +- **THEN** it SHALL return an array in the format `[userId => [[entityId, entityType]]]` matching rows in the table WHERE `entity_id` matches `$postId` and `group_key` matches the parsed group + +#### Scenario: Read with no data +- **WHEN** `read($postId, $key)` is called and no rows exist for that post and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($postId, $key)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write post interactions +- **WHEN** `write($postId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that post and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($postId, $key, [])` is called +- **THEN** it SHALL delete all rows for that post and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($postId, $key, $data)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +### Requirement: User TableStorage implements User\Storage +The system SHALL provide a `User\TableStorage` class that implements `User\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, filtering by `user_id` and returning `entity_id` values as item IDs. + +#### Scenario: Read existing user interactions +- **WHEN** `read($userId, $key)` is called with a valid user ID and key +- **THEN** it SHALL return an array in the format `[[entityId, entityType], ...]` matching rows in the table WHERE `user_id` matches and `group_key` matches the parsed group + +#### Scenario: Read with no data +- **WHEN** `read($userId, $key)` is called and no rows exist for that user and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($userId, $key)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write user interactions +- **WHEN** `write($userId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that user and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($userId, $key, [])` is called +- **THEN** it SHALL delete all rows for that user and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($userId, $key, $data)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +### Requirement: Key parsing extracts group +Both `TableStorage` implementations SHALL parse the `$key` parameter (format `base.group`) to extract the `group_key` value for database queries. + +#### Scenario: Key with valid format +- **WHEN** key is `_konomi_items.reaction` +- **THEN** the group_key used in queries SHALL be `reaction` + +#### Scenario: Key with invalid format +- **WHEN** key contains no dot separator +- **THEN** `read` SHALL return empty array and `write` SHALL return `false` + +### Requirement: Module wiring uses TableStorage +`Post\Module` and `User\Module` SHALL wire `TableStorage` as the implementation for `Storage` interface in their service definitions. + +#### Scenario: Post module wires TableStorage +- **WHEN** the Post module registers services +- **THEN** `Post\Storage::class` SHALL resolve to a `Post\TableStorage` instance + +#### Scenario: User module wires TableStorage +- **WHEN** the User module registers services +- **THEN** `User\Storage::class` SHALL resolve to a `User\TableStorage` instance From 3dec73c4ddabd5115e59807586bdde8bf75bf5d7 Mon Sep 17 00:00:00 2001 From: guido Date: Fri, 8 May 2026 20:07:06 +0200 Subject: [PATCH 05/26] Consolidate `use` statements in `Post\TableStorage` and `User\TableStorage` for cleaner namespace imports. --- sources/Post/TableStorage.php | 11 +++++------ sources/User/TableStorage.php | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/sources/Post/TableStorage.php b/sources/Post/TableStorage.php index e04e44b..9d6f0cb 100644 --- a/sources/Post/TableStorage.php +++ b/sources/Post/TableStorage.php @@ -4,8 +4,7 @@ namespace SpaghettiDojo\Konomi\Post; -use SpaghettiDojo\Konomi\Database\InteractionsTable; -use SpaghettiDojo\Konomi\Database\StorageKeyParser; +use SpaghettiDojo\Konomi\Database; /** * @internal @@ -13,16 +12,16 @@ class TableStorage implements Storage { public static function new( - InteractionsTable $table, - StorageKeyParser $keyParser + Database\InteractionsTable $table, + Database\StorageKeyParser $keyParser ): TableStorage { return new self($table, $keyParser); } final private function __construct( - private readonly InteractionsTable $table, - private readonly StorageKeyParser $keyParser + private readonly Database\InteractionsTable $table, + private readonly Database\StorageKeyParser $keyParser ) { } diff --git a/sources/User/TableStorage.php b/sources/User/TableStorage.php index ccc3a1f..c24a600 100644 --- a/sources/User/TableStorage.php +++ b/sources/User/TableStorage.php @@ -4,8 +4,7 @@ namespace SpaghettiDojo\Konomi\User; -use SpaghettiDojo\Konomi\Database\InteractionsTable; -use SpaghettiDojo\Konomi\Database\StorageKeyParser; +use SpaghettiDojo\Konomi\Database; /** * @internal @@ -13,16 +12,16 @@ class TableStorage implements Storage { public static function new( - InteractionsTable $table, - StorageKeyParser $keyParser + Database\InteractionsTable $table, + Database\StorageKeyParser $keyParser ): TableStorage { return new self($table, $keyParser); } final private function __construct( - private readonly InteractionsTable $table, - private readonly StorageKeyParser $keyParser + private readonly Database\InteractionsTable $table, + private readonly Database\StorageKeyParser $keyParser ) { } From eb6758ddecb4aca1046cc331e32096d97bedcad6 Mon Sep 17 00:00:00 2001 From: guido Date: Fri, 8 May 2026 20:26:48 +0200 Subject: [PATCH 06/26] Simplify `StorageKey` handling by removing base prefix logic from `TableStorage`, consolidating it in `MetaStorage`, and retiring `StorageKeyParser`. --- .../changes/simplify-storage-key/design.md | 91 ---------------- .../changes/simplify-storage-key/proposal.md | 27 ----- .../specs/table-storage/spec.md | 101 ------------------ .../changes/simplify-storage-key/tasks.md | 33 ------ openspec/specs/table-storage/spec.md | 49 ++++++--- sources/Database/Module.php | 1 - sources/Database/StorageKeyParser.php | 46 -------- sources/Post/MetaStorage.php | 6 +- sources/Post/Module.php | 5 +- sources/Post/StorageKey.php | 32 +++--- sources/Post/TableStorage.php | 31 ++---- sources/User/MetaStorage.php | 6 +- sources/User/Module.php | 5 +- sources/User/StorageKey.php | 36 +++---- sources/User/TableStorage.php | 31 ++---- tests/integration/php/PostRepositoryTest.php | 4 +- tests/integration/php/UserRepositoryTest.php | 2 +- .../php/Database/StorageKeyParserTest.php | 35 ------ tests/unit/php/Post/StorageKeyTest.php | 37 ++----- tests/unit/php/Post/StorageTest.php | 20 ++-- tests/unit/php/Post/TableStorageTest.php | 22 +--- tests/unit/php/User/StorageKeyTest.php | 38 ++----- tests/unit/php/User/StorageTest.php | 20 ++-- tests/unit/php/User/TableStorageTest.php | 22 +--- 24 files changed, 132 insertions(+), 568 deletions(-) delete mode 100644 openspec/changes/simplify-storage-key/design.md delete mode 100644 openspec/changes/simplify-storage-key/proposal.md delete mode 100644 openspec/changes/simplify-storage-key/specs/table-storage/spec.md delete mode 100644 openspec/changes/simplify-storage-key/tasks.md delete mode 100644 sources/Database/StorageKeyParser.php delete mode 100644 tests/unit/php/Database/StorageKeyParserTest.php diff --git a/openspec/changes/simplify-storage-key/design.md b/openspec/changes/simplify-storage-key/design.md deleted file mode 100644 index d263d4e..0000000 --- a/openspec/changes/simplify-storage-key/design.md +++ /dev/null @@ -1,91 +0,0 @@ -## Context - -`custom-table-storage` introduced two `Storage` backends backed by the shared `konomi_interactions` table. Both `TableStorage` classes only need the `group_key` portion of the key (e.g. `"reaction"`) to populate the `group_key` column. `StorageKey::for()` builds a composite `"_konomi_items.reaction"` string, then `Database\StorageKeyParser` re-extracts the trailing segment. The composite shape exists solely because the original `MetaStorage` backend uses the full string as a `meta_key`. - -This change relocates the base prefix to where it is actually used: inside `MetaStorage`. `StorageKey` becomes a group-only sanitizer/validator. - -## Goals - -- One source of truth for the `meta_key` shape: `MetaStorage` itself. -- Remove the parser roundtrip for `TableStorage`. -- Keep the `Storage::read/write(int $id, string $key, ...)` signature unchanged so downstream consumers and the interface contract stay stable. -- Keep `Repository` code unchanged in shape — still calls `$this->key->for($group)`. - -## Non-Goals - -- Merging `Post\TableStorage` and `User\TableStorage` into a single class. -- Collapsing `Post\StorageKey` and `User\StorageKey`. -- Changing the `konomi_interactions` schema or column names. -- Touching `MetaStorage` callers (still wired off, kept for future toggle). - -## Decisions - -### Decision 1: `StorageKey::for()` returns sanitized group only - -```php -final class StorageKey -{ - public static function new(): self { return new self(); } - final private function __construct() {} - - public function for(ItemGroup $group): string - { - $value = $group->value; - if ($value === '') { - throw new \InvalidArgumentException('Group value cannot be empty'); - } - $sanitized = preg_replace('/[^a-z0-9_]/', '', $value); - if ($sanitized !== $value) { - throw new \UnexpectedValueException('Group value contains invalid characters'); - } - return $sanitized; - } -} -``` - -Rationale: `StorageKey` keeps its role as the trusted producer of the key string, but the string it produces is the bare group. Sanitization rules drop the `.` from the allowlist since it is no longer part of the output. - -### Decision 2: `MetaStorage` owns the base prefix - -```php -class MetaStorage implements Storage -{ - private const BASE = '_konomi_items'; - - public function read(int $id, string $key): array - { - $metaKey = self::BASE . '.' . $key; - // ...get_post_meta / get_user_meta with $metaKey - } - public function write(int $id, string $key, array $data): bool - { - $metaKey = self::BASE . '.' . $key; - // ...update_post_meta / update_user_meta with $metaKey - } -} -``` - -Rationale: the base is a `meta_key` namespacing concern. It does not belong on the `Storage` contract or on the domain key. - -### Decision 3: `TableStorage` uses `$key` directly as `group_key` - -```php -// Drop StorageKeyParser dependency. -// $key already equals the group, e.g. "reaction". -WHERE group_key = $key -``` - -### Decision 4: `StorageKeyParser` deleted - -No remaining callers once `TableStorage` stops parsing. Service registration in `Database\Module` removed. - -## Risks / Trade-offs - -- **Stored data shape unchanged**: `MetaStorage` still produces `_konomi_items.reaction` meta_keys; `TableStorage` still stores `reaction` in `group_key`. No data migration needed. -- **Backward compatibility**: `Storage::read/write` signature unchanged. Internal call sites update. -- **Future merge of Post/User logic**: explicitly out of scope; no design lock-in either way. -- **Test updates**: TableStorage tests currently pass `'_konomi_items.reaction'` as the key; they update to `'reaction'`. Functional MetaStorage tests (if any) keep asserting the composite meta_key written to the DB. - -## Migration - -None. Brand-new project, no production data, no archived state to convert. diff --git a/openspec/changes/simplify-storage-key/proposal.md b/openspec/changes/simplify-storage-key/proposal.md deleted file mode 100644 index d9b7390..0000000 --- a/openspec/changes/simplify-storage-key/proposal.md +++ /dev/null @@ -1,27 +0,0 @@ -## Why - -After introducing `TableStorage`, the `StorageKey::for()` output (`"_konomi_items.reaction"`) is immediately re-parsed by `StorageKeyParser` to recover the trailing group segment, which is the only piece TableStorage actually needs. The base prefix is a `MetaStorage` concern — the meta_key column shape — not a domain concern. Encoding it into the `Storage` contract forces a roundtrip and an extra class (`StorageKeyParser`) whose only job is to undo what `StorageKey` did. - -## What Changes - -- `StorageKey::for(ItemGroup): string` returns the sanitized group segment only (e.g. `"reaction"`), no base prefix. -- `StorageKey::new()` takes no arguments. Drop the `$base` constructor parameter from both `Post\StorageKey` and `User\StorageKey`. -- `Post\MetaStorage` and `User\MetaStorage` own a private `BASE` constant (`'_konomi_items'`) and prepend it on write / strip it on read when composing the `meta_key`. -- `Post\TableStorage` and `User\TableStorage` use the incoming `string $key` directly as `group_key`. Drop the `StorageKeyParser` dependency. -- Delete `Database\StorageKeyParser` and unregister it from `Database\Module`. -- Drop the `'_konomi_items'` argument from `StorageKey` service factories in `Post\Module` and `User\Module`. -- Update unit and functional tests: parser tests removed; TableStorage tests pass `'reaction'` as the key instead of `'_konomi_items.reaction'`. - -## Impact - -- Affected capabilities: `table-storage` (key shape change) -- Affected code: - - `sources/Post/StorageKey.php`, `sources/User/StorageKey.php` - - `sources/Post/MetaStorage.php`, `sources/User/MetaStorage.php` - - `sources/Post/TableStorage.php`, `sources/User/TableStorage.php` - - `sources/Post/Module.php`, `sources/User/Module.php` - - `sources/Database/Module.php` - - `sources/Database/StorageKeyParser.php` (deleted) - - Tests: parser tests deleted, TableStorage tests adjusted -- No DB schema change. No migration. Brand-new project, no production data. -- No public surface change observable to plugin consumers — the `Storage::read/write` signatures keep `string $key`; only the value passed in changes shape. diff --git a/openspec/changes/simplify-storage-key/specs/table-storage/spec.md b/openspec/changes/simplify-storage-key/specs/table-storage/spec.md deleted file mode 100644 index 260fe4e..0000000 --- a/openspec/changes/simplify-storage-key/specs/table-storage/spec.md +++ /dev/null @@ -1,101 +0,0 @@ -## MODIFIED Requirements - -### Requirement: Post TableStorage implements Post\Storage -The system SHALL provide a `Post\TableStorage` class that implements `Post\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, treating the post ID as `entity_id`. - -#### Scenario: Read existing post interactions -- **WHEN** `read($postId, $key)` is called with a valid post ID and a non-empty `$key` -- **THEN** it SHALL return an array in the format `[userId => [[entityId, entityType]]]` matching rows in the table WHERE `entity_id` matches `$postId` and `group_key` equals `$key` - -#### Scenario: Read with no data -- **WHEN** `read($postId, $key)` is called and no rows exist for that post and group -- **THEN** it SHALL return an empty array - -#### Scenario: Read with invalid ID -- **WHEN** `read($postId, $key)` is called with `$postId <= 0` or empty `$key` -- **THEN** it SHALL return an empty array - -#### Scenario: Write post interactions -- **WHEN** `write($postId, $key, $data)` is called with valid data -- **THEN** it SHALL replace all rows for that post and group in the table and return `true` - -#### Scenario: Write empty data clears rows -- **WHEN** `write($postId, $key, [])` is called -- **THEN** it SHALL delete all rows for that post and group and return `true` - -#### Scenario: Write with invalid ID -- **WHEN** `write($postId, $key, $data)` is called with `$postId <= 0` or empty `$key` -- **THEN** it SHALL return `false` without modifying data - -#### Scenario: Write is transactional -- **WHEN** `write()` deletes existing rows and inserts new ones -- **THEN** both operations SHALL execute within a single database transaction - -### Requirement: User TableStorage implements User\Storage -The system SHALL provide a `User\TableStorage` class that implements `User\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, filtering by `user_id` and returning `entity_id` values as item IDs. - -#### Scenario: Read existing user interactions -- **WHEN** `read($userId, $key)` is called with a valid user ID and a non-empty `$key` -- **THEN** it SHALL return an array in the format `[[entityId, entityType], ...]` matching rows in the table WHERE `user_id` matches and `group_key` equals `$key` - -#### Scenario: Read with no data -- **WHEN** `read($userId, $key)` is called and no rows exist for that user and group -- **THEN** it SHALL return an empty array - -#### Scenario: Read with invalid ID -- **WHEN** `read($userId, $key)` is called with `$userId <= 0` or empty `$key` -- **THEN** it SHALL return an empty array - -#### Scenario: Write user interactions -- **WHEN** `write($userId, $key, $data)` is called with valid data -- **THEN** it SHALL replace all rows for that user and group in the table and return `true` - -#### Scenario: Write empty data clears rows -- **WHEN** `write($userId, $key, [])` is called -- **THEN** it SHALL delete all rows for that user and group and return `true` - -#### Scenario: Write with invalid ID -- **WHEN** `write($userId, $key, $data)` is called with `$userId <= 0` or empty `$key` -- **THEN** it SHALL return `false` without modifying data - -#### Scenario: Write is transactional -- **WHEN** `write()` deletes existing rows and inserts new ones -- **THEN** both operations SHALL execute within a single database transaction - -## REMOVED Requirements - -### Requirement: Key parsing extracts group -**Reason**: `StorageKey::for()` now returns the bare sanitized group, so `TableStorage` consumes `$key` directly as the `group_key` value. The `Database\StorageKeyParser` class is deleted. -**Migration**: Callers that produced keys via `StorageKey::for()` continue to work without changes — the produced string is now the group itself. Any test passing a literal `"_konomi_items.reaction"` must be updated to `"reaction"`. - -## ADDED Requirements - -### Requirement: StorageKey produces sanitized group -`Post\StorageKey` and `User\StorageKey` SHALL accept an `ItemGroup` and return its `value` after validating that it contains only `[a-z0-9_]` characters and is non-empty. - -#### Scenario: Valid group -- **WHEN** `StorageKey::for($group)` is called with `ItemGroup` value `"reaction"` -- **THEN** it SHALL return `"reaction"` - -#### Scenario: Invalid characters -- **WHEN** `StorageKey::for($group)` is called with a value containing characters outside `[a-z0-9_]` -- **THEN** it SHALL throw `\UnexpectedValueException` - -#### Scenario: Empty group -- **WHEN** `StorageKey::for($group)` is called with an empty value -- **THEN** it SHALL throw `\InvalidArgumentException` - -#### Scenario: Construction takes no base -- **WHEN** `StorageKey::new()` is called -- **THEN** it SHALL accept no arguments and return a usable instance - -### Requirement: MetaStorage owns the meta_key base prefix -`Post\MetaStorage` and `User\MetaStorage` SHALL define a private `BASE` constant equal to `'_konomi_items'` and SHALL compose the WordPress `meta_key` as `{BASE}.{$key}` on every read and write. - -#### Scenario: Write composes the meta_key -- **WHEN** `MetaStorage::write($id, "reaction", $data)` is called -- **THEN** the underlying `update_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` - -#### Scenario: Read composes the meta_key -- **WHEN** `MetaStorage::read($id, "reaction")` is called -- **THEN** the underlying `get_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` diff --git a/openspec/changes/simplify-storage-key/tasks.md b/openspec/changes/simplify-storage-key/tasks.md deleted file mode 100644 index 9b2cc54..0000000 --- a/openspec/changes/simplify-storage-key/tasks.md +++ /dev/null @@ -1,33 +0,0 @@ -## 1. StorageKey simplification - -- [ ] 1.1 Update `Post\StorageKey`: drop `$base` ctor param, change `for()` to return sanitized `ItemGroup->value` (allowlist `[a-z0-9_]`, throw on invalid) -- [ ] 1.2 Update `User\StorageKey`: same change as `Post\StorageKey` -- [ ] 1.3 Update unit tests for both `StorageKey` classes (valid group, invalid chars, empty group) - -## 2. MetaStorage owns base prefix - -- [ ] 2.1 Add `private const BASE = '_konomi_items'` to `Post\MetaStorage`; prepend on read/write when composing `meta_key` -- [ ] 2.2 Add `private const BASE = '_konomi_items'` to `User\MetaStorage`; prepend on read/write when composing `meta_key` -- [ ] 2.3 Update or add unit tests confirming `meta_key` shape - -## 3. TableStorage drops parser - -- [ ] 3.1 Remove `StorageKeyParser` dependency from `Post\TableStorage`; use `$key` directly as `group_key` -- [ ] 3.2 Remove `StorageKeyParser` dependency from `User\TableStorage`; use `$key` directly as `group_key` -- [ ] 3.3 Update functional `Post\TableStorage` tests: pass `'reaction'` as key -- [ ] 3.4 Update functional `User\TableStorage` tests: pass `'reaction'` as key -- [ ] 3.5 Update unit `TableStorage` tests for both sides - -## 4. Module wiring & deletions - -- [ ] 4.1 Update `Post\Module`: drop `'_konomi_items'` arg from `StorageKey` factory; drop `StorageKeyParser` from `TableStorage` factory -- [ ] 4.2 Update `User\Module`: same as Post -- [ ] 4.3 Update `Database\Module`: unregister `StorageKeyParser` service -- [ ] 4.4 Delete `sources/Database/StorageKeyParser.php` -- [ ] 4.5 Delete `tests/unit/php/Database/StorageKeyParserTest.php` - -## 5. Verification - -- [ ] 5.1 Run full test suite — all existing tests pass -- [ ] 5.2 Run PHPStan static analysis — no new errors -- [ ] 5.3 Grep for any leftover `StorageKeyParser` references diff --git a/openspec/specs/table-storage/spec.md b/openspec/specs/table-storage/spec.md index 1b1aa4e..44c8cfb 100644 --- a/openspec/specs/table-storage/spec.md +++ b/openspec/specs/table-storage/spec.md @@ -10,8 +10,8 @@ Defines `TableStorage` implementations for Post and User domains that read from The system SHALL provide a `Post\TableStorage` class that implements `Post\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, treating the post ID as `entity_id`. #### Scenario: Read existing post interactions -- **WHEN** `read($postId, $key)` is called with a valid post ID and key -- **THEN** it SHALL return an array in the format `[userId => [[entityId, entityType]]]` matching rows in the table WHERE `entity_id` matches `$postId` and `group_key` matches the parsed group +- **WHEN** `read($postId, $key)` is called with a valid post ID and a non-empty `$key` +- **THEN** it SHALL return an array in the format `[userId => [[entityId, entityType]]]` matching rows in the table WHERE `entity_id` matches `$postId` and `group_key` equals `$key` #### Scenario: Read with no data - **WHEN** `read($postId, $key)` is called and no rows exist for that post and group @@ -41,8 +41,8 @@ The system SHALL provide a `Post\TableStorage` class that implements `Post\Stora The system SHALL provide a `User\TableStorage` class that implements `User\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, filtering by `user_id` and returning `entity_id` values as item IDs. #### Scenario: Read existing user interactions -- **WHEN** `read($userId, $key)` is called with a valid user ID and key -- **THEN** it SHALL return an array in the format `[[entityId, entityType], ...]` matching rows in the table WHERE `user_id` matches and `group_key` matches the parsed group +- **WHEN** `read($userId, $key)` is called with a valid user ID and a non-empty `$key` +- **THEN** it SHALL return an array in the format `[[entityId, entityType], ...]` matching rows in the table WHERE `user_id` matches and `group_key` equals `$key` #### Scenario: Read with no data - **WHEN** `read($userId, $key)` is called and no rows exist for that user and group @@ -68,17 +68,6 @@ The system SHALL provide a `User\TableStorage` class that implements `User\Stora - **WHEN** `write()` deletes existing rows and inserts new ones - **THEN** both operations SHALL execute within a single database transaction -### Requirement: Key parsing extracts group -Both `TableStorage` implementations SHALL parse the `$key` parameter (format `base.group`) to extract the `group_key` value for database queries. - -#### Scenario: Key with valid format -- **WHEN** key is `_konomi_items.reaction` -- **THEN** the group_key used in queries SHALL be `reaction` - -#### Scenario: Key with invalid format -- **WHEN** key contains no dot separator -- **THEN** `read` SHALL return empty array and `write` SHALL return `false` - ### Requirement: Module wiring uses TableStorage `Post\Module` and `User\Module` SHALL wire `TableStorage` as the implementation for `Storage` interface in their service definitions. @@ -89,3 +78,33 @@ Both `TableStorage` implementations SHALL parse the `$key` parameter (format `ba #### Scenario: User module wires TableStorage - **WHEN** the User module registers services - **THEN** `User\Storage::class` SHALL resolve to a `User\TableStorage` instance + +### Requirement: StorageKey produces sanitized group +`Post\StorageKey` and `User\StorageKey` SHALL accept an `ItemGroup` and return its `value` after validating that it contains only `[a-z0-9_]` characters and is non-empty. + +#### Scenario: Valid group +- **WHEN** `StorageKey::for($group)` is called with `ItemGroup` value `"reaction"` +- **THEN** it SHALL return `"reaction"` + +#### Scenario: Invalid characters +- **WHEN** `StorageKey::for($group)` is called with a value containing characters outside `[a-z0-9_]` +- **THEN** it SHALL throw `\UnexpectedValueException` + +#### Scenario: Empty group +- **WHEN** `StorageKey::for($group)` is called with an empty value +- **THEN** it SHALL throw `\InvalidArgumentException` + +#### Scenario: Construction takes no base +- **WHEN** `StorageKey::new()` is called +- **THEN** it SHALL accept no arguments and return a usable instance + +### Requirement: MetaStorage owns the meta_key base prefix +`Post\MetaStorage` and `User\MetaStorage` SHALL define a private `BASE` constant equal to `'_konomi_items'` and SHALL compose the WordPress `meta_key` as `{BASE}.{$key}` on every read and write. + +#### Scenario: Write composes the meta_key +- **WHEN** `MetaStorage::write($id, "reaction", $data)` is called +- **THEN** the underlying `update_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` + +#### Scenario: Read composes the meta_key +- **WHEN** `MetaStorage::read($id, "reaction")` is called +- **THEN** the underlying `get_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` diff --git a/sources/Database/Module.php b/sources/Database/Module.php index 7813abb..91063f3 100644 --- a/sources/Database/Module.php +++ b/sources/Database/Module.php @@ -37,7 +37,6 @@ public function services(): array ) => SchemaManager::new( $container->get(InteractionsTable::class) ), - StorageKeyParser::class => static fn () => StorageKeyParser::new(), ]; } diff --git a/sources/Database/StorageKeyParser.php b/sources/Database/StorageKeyParser.php deleted file mode 100644 index b19c9e4..0000000 --- a/sources/Database/StorageKeyParser.php +++ /dev/null @@ -1,46 +0,0 @@ - static fn ( ContainerInterface $container ) => TableStorage::new( - $container->get(Database\InteractionsTable::class), - $container->get(Database\StorageKeyParser::class) + $container->get(Database\InteractionsTable::class) ), ItemRegistryKey::class => static fn () => ItemRegistryKey::new(), ItemRegistry::class => static fn ( @@ -45,7 +44,7 @@ public function services(): array $container->get(ItemRegistryKey::class) ), RawDataAssert::class => static fn () => RawDataAssert::new(), - StorageKey::class => static fn () => StorageKey::new('_konomi_items'), + StorageKey::class => static fn () => StorageKey::new(), Repository::class => static fn ( ContainerInterface $container ) => Repository::new( diff --git a/sources/Post/StorageKey.php b/sources/Post/StorageKey.php index 5aeebb3..f7b4407 100644 --- a/sources/Post/StorageKey.php +++ b/sources/Post/StorageKey.php @@ -8,39 +8,33 @@ class StorageKey { - /** - * @param non-empty-string $base - */ - public static function new(string $base): StorageKey + public static function new(): StorageKey { - return new self($base); + return new self(); } - /** - * @param non-empty-string $base - */ - final private function __construct(private readonly string $base) + final private function __construct() { } + /** + * @throws \InvalidArgumentException If ItemGroup value is empty + * @throws \UnexpectedValueException If ItemGroup value contains invalid characters + */ public function for(User\ItemGroup $group): string { - $groupValue = $group->value; + $value = $group->value; - if (!$groupValue) { + if ($value === '') { throw new \InvalidArgumentException('Group value cannot be empty'); } - if (!$this->base) { - throw new \InvalidArgumentException('Base value cannot be empty'); - } - $expectedKey = $this->base . '.' . $groupValue; - $key = preg_replace('/[^a-z0-9._]/', '', $expectedKey); + $sanitized = preg_replace('/[^a-z0-9_]/', '', $value); - if ($key !== $expectedKey) { - throw new \UnexpectedValueException('Storage key cannot be empty after sanitization'); + if ($sanitized !== $value) { + throw new \UnexpectedValueException('Group value contains invalid characters'); } - return $key; + return $sanitized; } } diff --git a/sources/Post/TableStorage.php b/sources/Post/TableStorage.php index 9d6f0cb..563300e 100644 --- a/sources/Post/TableStorage.php +++ b/sources/Post/TableStorage.php @@ -11,18 +11,13 @@ */ class TableStorage implements Storage { - public static function new( - Database\InteractionsTable $table, - Database\StorageKeyParser $keyParser - ): TableStorage { - - return new self($table, $keyParser); + public static function new(Database\InteractionsTable $table): TableStorage + { + return new self($table); } - final private function __construct( - private readonly Database\InteractionsTable $table, - private readonly Database\StorageKeyParser $keyParser - ) { + final private function __construct(private readonly Database\InteractionsTable $table) + { } /** @@ -34,11 +29,6 @@ public function read(int $id, string $key): array return []; } - $group = $this->keyParser->group($key); - if ($group === '') { - return []; - } - global $wpdb; $tableName = $this->table->name(); @@ -47,7 +37,7 @@ public function read(int $id, string $key): array 'SELECT user_id, entity_id, entity_type FROM %i WHERE entity_id = %d AND group_key = %s', $tableName, $id, - $group + $key ), ARRAY_A); if (!is_array($rows)) { @@ -83,11 +73,6 @@ public function write(int $id, string $key, array $data): bool return false; } - $group = $this->keyParser->group($key); - if ($group === '') { - return false; - } - global $wpdb; $tableName = $this->table->name(); @@ -99,7 +84,7 @@ public function write(int $id, string $key, array $data): bool 'DELETE FROM %i WHERE entity_id = %d AND group_key = %s', $tableName, $id, - $group + $key ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -126,7 +111,7 @@ public function write(int $id, string $key, array $data): bool 'entity_id' => $entityId, 'user_id' => $userId, 'entity_type' => $entityType, - 'group_key' => $group, + 'group_key' => $key, ], ['%d', '%d', '%s', '%s'] ); diff --git a/sources/User/MetaStorage.php b/sources/User/MetaStorage.php index c072c20..ddecb57 100644 --- a/sources/User/MetaStorage.php +++ b/sources/User/MetaStorage.php @@ -9,6 +9,8 @@ */ class MetaStorage implements Storage { + private const BASE = '_konomi_items'; + public static function new(): MetaStorage { return new self(); @@ -27,7 +29,7 @@ public function read(int $id, string $key): array return []; } - $data = get_user_meta($id, $key, true); + $data = get_user_meta($id, self::BASE . '.' . $key, true); return is_array($data) ? $data : []; } @@ -40,6 +42,6 @@ public function write(int $id, string $key, array $data): bool return false; } - return (bool) update_user_meta($id, $key, $data); + return (bool) update_user_meta($id, self::BASE . '.' . $key, $data); } } diff --git a/sources/User/Module.php b/sources/User/Module.php index 6a82c43..b9d9f3b 100644 --- a/sources/User/Module.php +++ b/sources/User/Module.php @@ -30,8 +30,7 @@ public function services(): array Storage::class => static fn ( ContainerInterface $container ) => TableStorage::new( - $container->get(Database\InteractionsTable::class), - $container->get(Database\StorageKeyParser::class) + $container->get(Database\InteractionsTable::class) ), UserFactory::class => static fn ( ContainerInterface $container @@ -47,7 +46,7 @@ public function services(): array $container->get(ItemRegistryKey::class) ), RawDataAssert::class => static fn () => RawDataAssert::new(), - StorageKey::class => static fn () => StorageKey::new('_konomi_items'), + StorageKey::class => static fn () => StorageKey::new(), Repository::class => static fn ( ContainerInterface $container ) => Repository::new( diff --git a/sources/User/StorageKey.php b/sources/User/StorageKey.php index 1b2f9bd..da90722 100644 --- a/sources/User/StorageKey.php +++ b/sources/User/StorageKey.php @@ -4,47 +4,35 @@ namespace SpaghettiDojo\Konomi\User; -use SpaghettiDojo\Konomi\User; - class StorageKey { - /** - * @param non-empty-string $base - */ - public static function new(string $base): StorageKey + public static function new(): StorageKey { - return new self($base); + return new self(); } - /** - * @param non-empty-string $base - */ - final private function __construct(private readonly string $base) + final private function __construct() { } /** - * @throws \InvalidArgumentException If ItemGroup is an empty string - * @throws \UnexpectedValueException If the returning key is an empty string + * @throws \InvalidArgumentException If ItemGroup value is empty + * @throws \UnexpectedValueException If ItemGroup value contains invalid characters */ - public function for(User\ItemGroup $group): string + public function for(ItemGroup $group): string { - $groupValue = $group->value; + $value = $group->value; - if (!$groupValue) { + if ($value === '') { throw new \InvalidArgumentException('Group value cannot be empty'); } - if (!$this->base) { - throw new \InvalidArgumentException('Base value cannot be empty'); - } - $expectedKey = $this->base . '.' . $groupValue; - $key = preg_replace('/[^a-z0-9._]/', '', $expectedKey); + $sanitized = preg_replace('/[^a-z0-9_]/', '', $value); - if ($key !== $expectedKey) { - throw new \UnexpectedValueException('Storage key cannot be empty after sanitization'); + if ($sanitized !== $value) { + throw new \UnexpectedValueException('Group value contains invalid characters'); } - return $key; + return $sanitized; } } diff --git a/sources/User/TableStorage.php b/sources/User/TableStorage.php index c24a600..e3cb38b 100644 --- a/sources/User/TableStorage.php +++ b/sources/User/TableStorage.php @@ -11,18 +11,13 @@ */ class TableStorage implements Storage { - public static function new( - Database\InteractionsTable $table, - Database\StorageKeyParser $keyParser - ): TableStorage { - - return new self($table, $keyParser); + public static function new(Database\InteractionsTable $table): TableStorage + { + return new self($table); } - final private function __construct( - private readonly Database\InteractionsTable $table, - private readonly Database\StorageKeyParser $keyParser - ) { + final private function __construct(private readonly Database\InteractionsTable $table) + { } /** @@ -34,11 +29,6 @@ public function read(int $id, string $key): array return []; } - $group = $this->keyParser->group($key); - if ($group === '') { - return []; - } - global $wpdb; $tableName = $this->table->name(); @@ -46,7 +36,7 @@ public function read(int $id, string $key): array 'SELECT entity_id, entity_type FROM %i WHERE user_id = %d AND group_key = %s', $tableName, $id, - $group + $key ), ARRAY_A); if (!is_array($rows)) { @@ -83,11 +73,6 @@ public function write(int $id, string $key, array $data): bool return false; } - $group = $this->keyParser->group($key); - if ($group === '') { - return false; - } - global $wpdb; $tableName = $this->table->name(); @@ -99,7 +84,7 @@ public function write(int $id, string $key, array $data): bool 'DELETE FROM %i WHERE user_id = %d AND group_key = %s', $tableName, $id, - $group + $key ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -124,7 +109,7 @@ public function write(int $id, string $key, array $data): bool 'entity_id' => $entityId, 'user_id' => $id, 'entity_type' => $entityType, - 'group_key' => $group, + 'group_key' => $key, ], ['%d', '%d', '%s', '%s'] ); diff --git a/tests/integration/php/PostRepositoryTest.php b/tests/integration/php/PostRepositoryTest.php index 525ac39..7a705a1 100644 --- a/tests/integration/php/PostRepositoryTest.php +++ b/tests/integration/php/PostRepositoryTest.php @@ -22,7 +22,7 @@ $itemRegistryKey = User\ItemRegistryKey::new(); $this->currentUser = User\CurrentUser::new( User\Repository::new( - User\StorageKey::new('_konomi_items'), + User\StorageKey::new(), User\MetaStorage::new(), User\ItemFactory::new(), User\ItemRegistry::new($itemRegistryKey), @@ -31,7 +31,7 @@ ); $postItemRegistryKey = Post\ItemRegistryKey::new(); $this->repository = Post\Repository::new( - Post\StorageKey::new('_konomi_items'), + Post\StorageKey::new(), Post\MetaStorage::new(), Post\RawDataAssert::new(), User\ItemFactory::new(), diff --git a/tests/integration/php/UserRepositoryTest.php b/tests/integration/php/UserRepositoryTest.php index 1d1c4ba..ae858eb 100644 --- a/tests/integration/php/UserRepositoryTest.php +++ b/tests/integration/php/UserRepositoryTest.php @@ -24,7 +24,7 @@ $itemRegistryKey = User\ItemRegistryKey::new(); $this->repository = User\Repository::new( - User\StorageKey::new('_konomi_items'), + User\StorageKey::new(), User\MetaStorage::new(), User\ItemFactory::new(), User\ItemRegistry::new($itemRegistryKey), diff --git a/tests/unit/php/Database/StorageKeyParserTest.php b/tests/unit/php/Database/StorageKeyParserTest.php deleted file mode 100644 index 2477038..0000000 --- a/tests/unit/php/Database/StorageKeyParserTest.php +++ /dev/null @@ -1,35 +0,0 @@ -group('_konomi_items.reaction'))->toBe('reaction'); - expect($parser->group('_konomi_items.bookmark'))->toBe('bookmark'); - }); - - it('takes the trailing segment when multiple dots are present', function (): void { - $parser = StorageKeyParser::new(); - expect($parser->group('a.b.c.reaction'))->toBe('reaction'); - }); - - it('returns empty string when key has no dot separator', function (): void { - $parser = StorageKeyParser::new(); - expect($parser->group('plainkey'))->toBe(''); - }); - - it('returns empty string when key is empty', function (): void { - $parser = StorageKeyParser::new(); - expect($parser->group(''))->toBe(''); - }); - - it('returns empty string when group segment is empty', function (): void { - $parser = StorageKeyParser::new(); - expect($parser->group('_konomi_items.'))->toBe(''); - }); -}); diff --git a/tests/unit/php/Post/StorageKeyTest.php b/tests/unit/php/Post/StorageKeyTest.php index 3889649..6c2e2a1 100644 --- a/tests/unit/php/Post/StorageKeyTest.php +++ b/tests/unit/php/Post/StorageKeyTest.php @@ -8,42 +8,17 @@ use SpaghettiDojo\Konomi\User\ItemGroup; describe('new', function (): void { - it('creates a new instance with a valid base key', function (): void { - $base = 'test_base'; - $storageKey = StorageKey::new($base); - expect($storageKey)->toBeInstanceOf(StorageKey::class); - }); - - it('throws an assertion error when base key is empty', function (): void { - expect(static fn () => StorageKey::new('')->for(ItemGroup::BOOKMARK))->toThrow(\InvalidArgumentException::class); + it('creates a new instance', function (): void { + expect(StorageKey::new())->toBeInstanceOf(StorageKey::class); }); }); describe('for', function (): void { - it('generates a valid storage key for REACTION group', function (): void { - $base = 'test_base'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::REACTION); - expect($key)->toBe('test_base.reaction'); - }); - - it('generates a valid storage key for BOOKMARK group', function (): void { - $base = 'test_base'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::BOOKMARK); - expect($key)->toBe('test_base.bookmark'); - }); - - it('sanitizes the key by removing non-alphanumeric characters', function (): void { - $base = 'test-base!@#'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::REACTION); - expect($key)->toBe('testbase.reaction'); + it('returns the sanitized group value for REACTION', function (): void { + expect(StorageKey::new()->for(ItemGroup::REACTION))->toBe('reaction'); }); - it('throws a RuntimeException when key is empty after sanitization', function (): void { - $base = '!@#$%^&*()'; - $storageKey = StorageKey::new($base); - expect(fn () => $storageKey->for(ItemGroup::REACTION))->toThrow(\UnexpectedValueException::class, 'Storage key cannot be empty after sanitization'); + it('returns the sanitized group value for BOOKMARK', function (): void { + expect(StorageKey::new()->for(ItemGroup::BOOKMARK))->toBe('bookmark'); }); }); diff --git a/tests/unit/php/Post/StorageTest.php b/tests/unit/php/Post/StorageTest.php index 97ff3a4..cb81515 100644 --- a/tests/unit/php/Post/StorageTest.php +++ b/tests/unit/php/Post/StorageTest.php @@ -10,23 +10,23 @@ describe('MetaStorage', function (): void { it('read data from the storage', function (): void { $storage = MetaStorage::new(); - Functions\expect('get_post_meta')->once()->with(1, 'key', true)->andReturn(['data']); - $data = $storage->read(1, 'key'); + Functions\expect('get_post_meta')->once()->with(1, '_konomi_items.reaction', true)->andReturn(['data']); + $data = $storage->read(1, 'reaction'); expect($data)->toBe(['data']); }); it('write data to the storage', function (): void { $storage = MetaStorage::new(); - Functions\expect('update_post_meta')->once()->with(1, 'key', ['data'])->andReturn(true); - $result = $storage->write(1, 'key', ['data']); + Functions\expect('update_post_meta')->once()->with(1, '_konomi_items.reaction', ['data'])->andReturn(true); + $result = $storage->write(1, 'reaction', ['data']); expect($result)->toBeTrue(); }); it('read returns empty array when id is invalid', function (): void { $storage = MetaStorage::new(); Functions\expect('get_post_meta')->never(); - expect($storage->read(0, 'key'))->toBe([]); - expect($storage->read(-1, 'key'))->toBe([]); + expect($storage->read(0, 'reaction'))->toBe([]); + expect($storage->read(-1, 'reaction'))->toBe([]); }); it('read returns empty array when key is empty', function (): void { @@ -37,15 +37,15 @@ it('read returns empty array when result is not an array', function (): void { $storage = MetaStorage::new(); - Functions\expect('get_post_meta')->once()->with(1, 'key', true)->andReturn('not-an-array'); - expect($storage->read(1, 'key'))->toBe([]); + Functions\expect('get_post_meta')->once()->with(1, '_konomi_items.reaction', true)->andReturn('not-an-array'); + expect($storage->read(1, 'reaction'))->toBe([]); }); it('write returns false when id is invalid', function (): void { $storage = MetaStorage::new(); Functions\expect('update_post_meta')->never(); - expect($storage->write(0, 'key', ['data']))->toBeFalse(); - expect($storage->write(-1, 'key', ['data']))->toBeFalse(); + expect($storage->write(0, 'reaction', ['data']))->toBeFalse(); + expect($storage->write(-1, 'reaction', ['data']))->toBeFalse(); }); it('write returns false when key is empty', function (): void { diff --git a/tests/unit/php/Post/TableStorageTest.php b/tests/unit/php/Post/TableStorageTest.php index bdeff48..5e7297c 100644 --- a/tests/unit/php/Post/TableStorageTest.php +++ b/tests/unit/php/Post/TableStorageTest.php @@ -5,40 +5,28 @@ namespace SpaghettiDojo\Konomi\Tests\Unit\Post; use SpaghettiDojo\Konomi\Database\InteractionsTable; -use SpaghettiDojo\Konomi\Database\StorageKeyParser; use SpaghettiDojo\Konomi\Post\TableStorage; beforeEach(function (): void { - $this->storage = TableStorage::new( - InteractionsTable::new('wp_'), - StorageKeyParser::new() - ); + $this->storage = TableStorage::new(InteractionsTable::new('wp_')); }); describe('Post TableStorage validation', function (): void { it('returns empty array for invalid ID on read', function (): void { - expect($this->storage->read(0, '_konomi_items.reaction'))->toBe([]); - expect($this->storage->read(-1, '_konomi_items.reaction'))->toBe([]); + expect($this->storage->read(0, 'reaction'))->toBe([]); + expect($this->storage->read(-1, 'reaction'))->toBe([]); }); it('returns empty array for empty key on read', function (): void { expect($this->storage->read(1, ''))->toBe([]); }); - it('returns empty array for unparseable key on read', function (): void { - expect($this->storage->read(1, 'no_dot_separator'))->toBe([]); - }); - it('returns false for invalid ID on write', function (): void { - expect($this->storage->write(0, '_konomi_items.reaction', []))->toBeFalse(); - expect($this->storage->write(-1, '_konomi_items.reaction', []))->toBeFalse(); + expect($this->storage->write(0, 'reaction', []))->toBeFalse(); + expect($this->storage->write(-1, 'reaction', []))->toBeFalse(); }); it('returns false for empty key on write', function (): void { expect($this->storage->write(1, '', []))->toBeFalse(); }); - - it('returns false for unparseable key on write', function (): void { - expect($this->storage->write(1, 'no_dot_separator', []))->toBeFalse(); - }); }); diff --git a/tests/unit/php/User/StorageKeyTest.php b/tests/unit/php/User/StorageKeyTest.php index 4e4a6e9..23f4237 100644 --- a/tests/unit/php/User/StorageKeyTest.php +++ b/tests/unit/php/User/StorageKeyTest.php @@ -4,47 +4,21 @@ namespace SpaghettiDojo\Konomi\Tests\Unit\User; -use Brain\Monkey\Functions; use SpaghettiDojo\Konomi\User\StorageKey; use SpaghettiDojo\Konomi\User\ItemGroup; describe('new', function (): void { - it('creates a new instance with a valid base key', function (): void { - $base = 'test_base'; - $storageKey = StorageKey::new($base); - expect($storageKey)->toBeInstanceOf(StorageKey::class); - }); - - it('throws an assertion error when base key is empty', function (): void { - expect(static fn () => StorageKey::new('')->for(ItemGroup::BOOKMARK))->toThrow(\InvalidArgumentException::class); + it('creates a new instance', function (): void { + expect(StorageKey::new())->toBeInstanceOf(StorageKey::class); }); }); describe('for', function (): void { - it('generates a valid storage key for REACTION group', function (): void { - $base = 'test_base'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::REACTION); - expect($key)->toBe('test_base.reaction'); - }); - - it('generates a valid storage key for BOOKMARK group', function (): void { - $base = 'test_base'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::BOOKMARK); - expect($key)->toBe('test_base.bookmark'); - }); - - it('sanitizes the key by removing non-alphanumeric characters', function (): void { - $base = 'test-base!@#'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::REACTION); - expect($key)->toBe('testbase.reaction'); + it('returns the sanitized group value for REACTION', function (): void { + expect(StorageKey::new()->for(ItemGroup::REACTION))->toBe('reaction'); }); - it('throws an UnexpectedValueException when key is empty after sanitization', function (): void { - $base = '!@#$%^&*()'; - $storageKey = StorageKey::new($base); - expect(fn () => $storageKey->for(ItemGroup::REACTION))->toThrow(\UnexpectedValueException::class, 'Storage key cannot be empty after sanitization'); + it('returns the sanitized group value for BOOKMARK', function (): void { + expect(StorageKey::new()->for(ItemGroup::BOOKMARK))->toBe('bookmark'); }); }); diff --git a/tests/unit/php/User/StorageTest.php b/tests/unit/php/User/StorageTest.php index 6b70493..61cf2d0 100644 --- a/tests/unit/php/User/StorageTest.php +++ b/tests/unit/php/User/StorageTest.php @@ -13,7 +13,7 @@ describe('MetaStorage', function (): void { it('returns empty array for invalid ID', function (): void { - expect($this->storage->read(0, 'test_key'))->toBe([]); + expect($this->storage->read(0, 'reaction'))->toBe([]); }); it('returns empty array for empty key', function (): void { @@ -25,23 +25,23 @@ Functions\expect('get_user_meta') ->once() - ->with(1, 'likes', true) + ->with(1, '_konomi_items.reaction', true) ->andReturn($expected); - expect($this->storage->read(1, 'likes'))->toBe($expected); + expect($this->storage->read(1, 'reaction'))->toBe($expected); }); it('returns empty array when meta value is not an array', function (): void { Functions\expect('get_user_meta') ->once() - ->with(1, 'likes', true) + ->with(1, '_konomi_items.reaction', true) ->andReturn('string_value'); - expect($this->storage->read(1, 'likes'))->toBe([]); + expect($this->storage->read(1, 'reaction'))->toBe([]); }); it('returns false for invalid ID', function (): void { - expect($this->storage->write(0, 'test_key', []))->toBeFalse(); + expect($this->storage->write(0, 'reaction', []))->toBeFalse(); }); it('returns false for empty key', function (): void { @@ -51,18 +51,18 @@ it('returns true when update is successful', function (): void { Functions\expect('update_user_meta') ->once() - ->with(1, 'likes', ['item1', 'item2']) + ->with(1, '_konomi_items.reaction', ['item1', 'item2']) ->andReturn(1); - expect($this->storage->write(1, 'likes', ['item1', 'item2']))->toBeTrue(); + expect($this->storage->write(1, 'reaction', ['item1', 'item2']))->toBeTrue(); }); it('returns false when update fails', function (): void { Functions\expect('update_user_meta') ->once() - ->with(1, 'likes', []) + ->with(1, '_konomi_items.reaction', []) ->andReturn(false); - expect($this->storage->write(1, 'likes', []))->toBeFalse(); + expect($this->storage->write(1, 'reaction', []))->toBeFalse(); }); }); diff --git a/tests/unit/php/User/TableStorageTest.php b/tests/unit/php/User/TableStorageTest.php index c5aa05c..ffc3a70 100644 --- a/tests/unit/php/User/TableStorageTest.php +++ b/tests/unit/php/User/TableStorageTest.php @@ -5,40 +5,28 @@ namespace SpaghettiDojo\Konomi\Tests\Unit\User; use SpaghettiDojo\Konomi\Database\InteractionsTable; -use SpaghettiDojo\Konomi\Database\StorageKeyParser; use SpaghettiDojo\Konomi\User\TableStorage; beforeEach(function (): void { - $this->storage = TableStorage::new( - InteractionsTable::new('wp_'), - StorageKeyParser::new() - ); + $this->storage = TableStorage::new(InteractionsTable::new('wp_')); }); describe('User TableStorage validation', function (): void { it('returns empty array for invalid ID on read', function (): void { - expect($this->storage->read(0, '_konomi_items.reaction'))->toBe([]); - expect($this->storage->read(-1, '_konomi_items.reaction'))->toBe([]); + expect($this->storage->read(0, 'reaction'))->toBe([]); + expect($this->storage->read(-1, 'reaction'))->toBe([]); }); it('returns empty array for empty key on read', function (): void { expect($this->storage->read(1, ''))->toBe([]); }); - it('returns empty array for unparseable key on read', function (): void { - expect($this->storage->read(1, 'no_dot_separator'))->toBe([]); - }); - it('returns false for invalid ID on write', function (): void { - expect($this->storage->write(0, '_konomi_items.reaction', []))->toBeFalse(); - expect($this->storage->write(-1, '_konomi_items.reaction', []))->toBeFalse(); + expect($this->storage->write(0, 'reaction', []))->toBeFalse(); + expect($this->storage->write(-1, 'reaction', []))->toBeFalse(); }); it('returns false for empty key on write', function (): void { expect($this->storage->write(1, '', []))->toBeFalse(); }); - - it('returns false for unparseable key on write', function (): void { - expect($this->storage->write(1, 'no_dot_separator', []))->toBeFalse(); - }); }); From a687bd3740db16f441e8d248ec54cb6bf7eccb20 Mon Sep 17 00:00:00 2001 From: guido Date: Fri, 8 May 2026 20:45:53 +0200 Subject: [PATCH 07/26] Split `TableStorage` methods into distinct private helpers for improved readability and separation of concerns, preserving existing behavior and quirks in both `Post` and `User` implementations. --- .../2026-05-08-simplify-storage-key/design.md | 91 ++++++++++++++++ .../proposal.md | 27 +++++ .../specs/table-storage/spec.md | 101 ++++++++++++++++++ .../2026-05-08-simplify-storage-key/tasks.md | 33 ++++++ .../.openspec.yaml | 2 + .../split-table-storage-query-build/design.md | 50 +++++++++ .../proposal.md | 26 +++++ .../specs/table-storage/spec.md | 23 ++++ .../split-table-storage-query-build/tasks.md | 25 +++++ 9 files changed, 378 insertions(+) create mode 100644 openspec/changes/archive/2026-05-08-simplify-storage-key/design.md create mode 100644 openspec/changes/archive/2026-05-08-simplify-storage-key/proposal.md create mode 100644 openspec/changes/archive/2026-05-08-simplify-storage-key/specs/table-storage/spec.md create mode 100644 openspec/changes/archive/2026-05-08-simplify-storage-key/tasks.md create mode 100644 openspec/changes/split-table-storage-query-build/.openspec.yaml create mode 100644 openspec/changes/split-table-storage-query-build/design.md create mode 100644 openspec/changes/split-table-storage-query-build/proposal.md create mode 100644 openspec/changes/split-table-storage-query-build/specs/table-storage/spec.md create mode 100644 openspec/changes/split-table-storage-query-build/tasks.md diff --git a/openspec/changes/archive/2026-05-08-simplify-storage-key/design.md b/openspec/changes/archive/2026-05-08-simplify-storage-key/design.md new file mode 100644 index 0000000..d263d4e --- /dev/null +++ b/openspec/changes/archive/2026-05-08-simplify-storage-key/design.md @@ -0,0 +1,91 @@ +## Context + +`custom-table-storage` introduced two `Storage` backends backed by the shared `konomi_interactions` table. Both `TableStorage` classes only need the `group_key` portion of the key (e.g. `"reaction"`) to populate the `group_key` column. `StorageKey::for()` builds a composite `"_konomi_items.reaction"` string, then `Database\StorageKeyParser` re-extracts the trailing segment. The composite shape exists solely because the original `MetaStorage` backend uses the full string as a `meta_key`. + +This change relocates the base prefix to where it is actually used: inside `MetaStorage`. `StorageKey` becomes a group-only sanitizer/validator. + +## Goals + +- One source of truth for the `meta_key` shape: `MetaStorage` itself. +- Remove the parser roundtrip for `TableStorage`. +- Keep the `Storage::read/write(int $id, string $key, ...)` signature unchanged so downstream consumers and the interface contract stay stable. +- Keep `Repository` code unchanged in shape — still calls `$this->key->for($group)`. + +## Non-Goals + +- Merging `Post\TableStorage` and `User\TableStorage` into a single class. +- Collapsing `Post\StorageKey` and `User\StorageKey`. +- Changing the `konomi_interactions` schema or column names. +- Touching `MetaStorage` callers (still wired off, kept for future toggle). + +## Decisions + +### Decision 1: `StorageKey::for()` returns sanitized group only + +```php +final class StorageKey +{ + public static function new(): self { return new self(); } + final private function __construct() {} + + public function for(ItemGroup $group): string + { + $value = $group->value; + if ($value === '') { + throw new \InvalidArgumentException('Group value cannot be empty'); + } + $sanitized = preg_replace('/[^a-z0-9_]/', '', $value); + if ($sanitized !== $value) { + throw new \UnexpectedValueException('Group value contains invalid characters'); + } + return $sanitized; + } +} +``` + +Rationale: `StorageKey` keeps its role as the trusted producer of the key string, but the string it produces is the bare group. Sanitization rules drop the `.` from the allowlist since it is no longer part of the output. + +### Decision 2: `MetaStorage` owns the base prefix + +```php +class MetaStorage implements Storage +{ + private const BASE = '_konomi_items'; + + public function read(int $id, string $key): array + { + $metaKey = self::BASE . '.' . $key; + // ...get_post_meta / get_user_meta with $metaKey + } + public function write(int $id, string $key, array $data): bool + { + $metaKey = self::BASE . '.' . $key; + // ...update_post_meta / update_user_meta with $metaKey + } +} +``` + +Rationale: the base is a `meta_key` namespacing concern. It does not belong on the `Storage` contract or on the domain key. + +### Decision 3: `TableStorage` uses `$key` directly as `group_key` + +```php +// Drop StorageKeyParser dependency. +// $key already equals the group, e.g. "reaction". +WHERE group_key = $key +``` + +### Decision 4: `StorageKeyParser` deleted + +No remaining callers once `TableStorage` stops parsing. Service registration in `Database\Module` removed. + +## Risks / Trade-offs + +- **Stored data shape unchanged**: `MetaStorage` still produces `_konomi_items.reaction` meta_keys; `TableStorage` still stores `reaction` in `group_key`. No data migration needed. +- **Backward compatibility**: `Storage::read/write` signature unchanged. Internal call sites update. +- **Future merge of Post/User logic**: explicitly out of scope; no design lock-in either way. +- **Test updates**: TableStorage tests currently pass `'_konomi_items.reaction'` as the key; they update to `'reaction'`. Functional MetaStorage tests (if any) keep asserting the composite meta_key written to the DB. + +## Migration + +None. Brand-new project, no production data, no archived state to convert. diff --git a/openspec/changes/archive/2026-05-08-simplify-storage-key/proposal.md b/openspec/changes/archive/2026-05-08-simplify-storage-key/proposal.md new file mode 100644 index 0000000..d9b7390 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-simplify-storage-key/proposal.md @@ -0,0 +1,27 @@ +## Why + +After introducing `TableStorage`, the `StorageKey::for()` output (`"_konomi_items.reaction"`) is immediately re-parsed by `StorageKeyParser` to recover the trailing group segment, which is the only piece TableStorage actually needs. The base prefix is a `MetaStorage` concern — the meta_key column shape — not a domain concern. Encoding it into the `Storage` contract forces a roundtrip and an extra class (`StorageKeyParser`) whose only job is to undo what `StorageKey` did. + +## What Changes + +- `StorageKey::for(ItemGroup): string` returns the sanitized group segment only (e.g. `"reaction"`), no base prefix. +- `StorageKey::new()` takes no arguments. Drop the `$base` constructor parameter from both `Post\StorageKey` and `User\StorageKey`. +- `Post\MetaStorage` and `User\MetaStorage` own a private `BASE` constant (`'_konomi_items'`) and prepend it on write / strip it on read when composing the `meta_key`. +- `Post\TableStorage` and `User\TableStorage` use the incoming `string $key` directly as `group_key`. Drop the `StorageKeyParser` dependency. +- Delete `Database\StorageKeyParser` and unregister it from `Database\Module`. +- Drop the `'_konomi_items'` argument from `StorageKey` service factories in `Post\Module` and `User\Module`. +- Update unit and functional tests: parser tests removed; TableStorage tests pass `'reaction'` as the key instead of `'_konomi_items.reaction'`. + +## Impact + +- Affected capabilities: `table-storage` (key shape change) +- Affected code: + - `sources/Post/StorageKey.php`, `sources/User/StorageKey.php` + - `sources/Post/MetaStorage.php`, `sources/User/MetaStorage.php` + - `sources/Post/TableStorage.php`, `sources/User/TableStorage.php` + - `sources/Post/Module.php`, `sources/User/Module.php` + - `sources/Database/Module.php` + - `sources/Database/StorageKeyParser.php` (deleted) + - Tests: parser tests deleted, TableStorage tests adjusted +- No DB schema change. No migration. Brand-new project, no production data. +- No public surface change observable to plugin consumers — the `Storage::read/write` signatures keep `string $key`; only the value passed in changes shape. diff --git a/openspec/changes/archive/2026-05-08-simplify-storage-key/specs/table-storage/spec.md b/openspec/changes/archive/2026-05-08-simplify-storage-key/specs/table-storage/spec.md new file mode 100644 index 0000000..260fe4e --- /dev/null +++ b/openspec/changes/archive/2026-05-08-simplify-storage-key/specs/table-storage/spec.md @@ -0,0 +1,101 @@ +## MODIFIED Requirements + +### Requirement: Post TableStorage implements Post\Storage +The system SHALL provide a `Post\TableStorage` class that implements `Post\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, treating the post ID as `entity_id`. + +#### Scenario: Read existing post interactions +- **WHEN** `read($postId, $key)` is called with a valid post ID and a non-empty `$key` +- **THEN** it SHALL return an array in the format `[userId => [[entityId, entityType]]]` matching rows in the table WHERE `entity_id` matches `$postId` and `group_key` equals `$key` + +#### Scenario: Read with no data +- **WHEN** `read($postId, $key)` is called and no rows exist for that post and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($postId, $key)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write post interactions +- **WHEN** `write($postId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that post and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($postId, $key, [])` is called +- **THEN** it SHALL delete all rows for that post and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($postId, $key, $data)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +### Requirement: User TableStorage implements User\Storage +The system SHALL provide a `User\TableStorage` class that implements `User\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, filtering by `user_id` and returning `entity_id` values as item IDs. + +#### Scenario: Read existing user interactions +- **WHEN** `read($userId, $key)` is called with a valid user ID and a non-empty `$key` +- **THEN** it SHALL return an array in the format `[[entityId, entityType], ...]` matching rows in the table WHERE `user_id` matches and `group_key` equals `$key` + +#### Scenario: Read with no data +- **WHEN** `read($userId, $key)` is called and no rows exist for that user and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($userId, $key)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write user interactions +- **WHEN** `write($userId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that user and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($userId, $key, [])` is called +- **THEN** it SHALL delete all rows for that user and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($userId, $key, $data)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +## REMOVED Requirements + +### Requirement: Key parsing extracts group +**Reason**: `StorageKey::for()` now returns the bare sanitized group, so `TableStorage` consumes `$key` directly as the `group_key` value. The `Database\StorageKeyParser` class is deleted. +**Migration**: Callers that produced keys via `StorageKey::for()` continue to work without changes — the produced string is now the group itself. Any test passing a literal `"_konomi_items.reaction"` must be updated to `"reaction"`. + +## ADDED Requirements + +### Requirement: StorageKey produces sanitized group +`Post\StorageKey` and `User\StorageKey` SHALL accept an `ItemGroup` and return its `value` after validating that it contains only `[a-z0-9_]` characters and is non-empty. + +#### Scenario: Valid group +- **WHEN** `StorageKey::for($group)` is called with `ItemGroup` value `"reaction"` +- **THEN** it SHALL return `"reaction"` + +#### Scenario: Invalid characters +- **WHEN** `StorageKey::for($group)` is called with a value containing characters outside `[a-z0-9_]` +- **THEN** it SHALL throw `\UnexpectedValueException` + +#### Scenario: Empty group +- **WHEN** `StorageKey::for($group)` is called with an empty value +- **THEN** it SHALL throw `\InvalidArgumentException` + +#### Scenario: Construction takes no base +- **WHEN** `StorageKey::new()` is called +- **THEN** it SHALL accept no arguments and return a usable instance + +### Requirement: MetaStorage owns the meta_key base prefix +`Post\MetaStorage` and `User\MetaStorage` SHALL define a private `BASE` constant equal to `'_konomi_items'` and SHALL compose the WordPress `meta_key` as `{BASE}.{$key}` on every read and write. + +#### Scenario: Write composes the meta_key +- **WHEN** `MetaStorage::write($id, "reaction", $data)` is called +- **THEN** the underlying `update_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` + +#### Scenario: Read composes the meta_key +- **WHEN** `MetaStorage::read($id, "reaction")` is called +- **THEN** the underlying `get_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` diff --git a/openspec/changes/archive/2026-05-08-simplify-storage-key/tasks.md b/openspec/changes/archive/2026-05-08-simplify-storage-key/tasks.md new file mode 100644 index 0000000..7ee461a --- /dev/null +++ b/openspec/changes/archive/2026-05-08-simplify-storage-key/tasks.md @@ -0,0 +1,33 @@ +## 1. StorageKey simplification + +- [x] 1.1 Update `Post\StorageKey`: drop `$base` ctor param, change `for()` to return sanitized `ItemGroup->value` (allowlist `[a-z0-9_]`, throw on invalid) +- [x] 1.2 Update `User\StorageKey`: same change as `Post\StorageKey` +- [x] 1.3 Update unit tests for both `StorageKey` classes (valid group, invalid chars, empty group) + +## 2. MetaStorage owns base prefix + +- [x] 2.1 Add `private const BASE = '_konomi_items'` to `Post\MetaStorage`; prepend on read/write when composing `meta_key` +- [x] 2.2 Add `private const BASE = '_konomi_items'` to `User\MetaStorage`; prepend on read/write when composing `meta_key` +- [x] 2.3 Update or add unit tests confirming `meta_key` shape + +## 3. TableStorage drops parser + +- [x] 3.1 Remove `StorageKeyParser` dependency from `Post\TableStorage`; use `$key` directly as `group_key` +- [x] 3.2 Remove `StorageKeyParser` dependency from `User\TableStorage`; use `$key` directly as `group_key` +- [x] 3.3 Update functional `Post\TableStorage` tests: pass `'reaction'` as key +- [x] 3.4 Update functional `User\TableStorage` tests: pass `'reaction'` as key +- [x] 3.5 Update unit `TableStorage` tests for both sides + +## 4. Module wiring & deletions + +- [x] 4.1 Update `Post\Module`: drop `'_konomi_items'` arg from `StorageKey` factory; drop `StorageKeyParser` from `TableStorage` factory +- [x] 4.2 Update `User\Module`: same as Post +- [x] 4.3 Update `Database\Module`: unregister `StorageKeyParser` service +- [x] 4.4 Delete `sources/Database/StorageKeyParser.php` +- [x] 4.5 Delete `tests/unit/php/Database/StorageKeyParserTest.php` + +## 5. Verification + +- [x] 5.1 Run full test suite — all existing tests pass +- [x] 5.2 Run PHPStan static analysis — no new errors +- [x] 5.3 Grep for any leftover `StorageKeyParser` references diff --git a/openspec/changes/split-table-storage-query-build/.openspec.yaml b/openspec/changes/split-table-storage-query-build/.openspec.yaml new file mode 100644 index 0000000..054b8c0 --- /dev/null +++ b/openspec/changes/split-table-storage-query-build/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/split-table-storage-query-build/design.md b/openspec/changes/split-table-storage-query-build/design.md new file mode 100644 index 0000000..642e653 --- /dev/null +++ b/openspec/changes/split-table-storage-query-build/design.md @@ -0,0 +1,50 @@ +## Context + +`Post\TableStorage` and `User\TableStorage` each implement two public methods (`read`, `write`) that braid together input validation, raw `$wpdb` SQL calls, and array coercion/payload shaping. The two classes are ~90% identical and total ~250 lines. Before any future cross-class refactor (shared storage module, bulk inserts), the internal structure of each class needs to clearly separate "talk to DB" from "shape data". + +## Goals / Non-Goals + +**Goals:** +- Inside each `TableStorage`, isolate `$wpdb` calls into private helpers and isolate array coercion/payload building into separate private helpers. +- Keep public method signatures, return shapes, transactional semantics, and validation behavior identical. +- Leave existing quirks intact (Post `write` only persists `$rawItems[0]`; Post `read` returns one row per user via the `[userId => [[…]]]` shape). + +**Non-Goals:** +- No shared base class, trait, or new module across Post/User. Tracked for a later iteration. +- No bulk multi-VALUES INSERT. Tracked for a later iteration. +- No change to SQL statements, table layout, or service wiring. +- No new tests added as part of this change (test suite untouched). + +## Decisions + +### Split each method into two private helpers + +`read()` becomes: +- `fetchRows(int $id, string $key): array` — only `$wpdb->prepare` + `$wpdb->get_results`. Returns the raw `ARRAY_A` rows (or `[]` on failure). +- `mapRows(array $rows): array` — pure coercion/shaping into the documented return type. + +`write()` becomes: +- `buildPayloads(array $data): array` — pure coercion. Returns a list of associative payloads ready for `$wpdb->insert` (Post payload includes `user_id`; User payload omits it because `$id` is the user ID and is injected by the persist helper). +- `persist(int $id, string $key, array $payloads): bool` — owns the transaction, the DELETE, and the per-row INSERT loop. + +Rationale: smallest change that lets the eye see "DB call" vs "data shape" at a glance, and that lets a future test target either side without `$wpdb` in scope. Alternative considered: extract a single private `query()` helper. Rejected — it would still leave coercion tangled inside `read`/`write`. + +### Keep `$wpdb` access inline (no abstraction) + +Helpers continue to call `global $wpdb` directly. No injected gateway, no `Database` wrapper. The proposal explicitly scopes shared abstractions to a later iteration. + +### Preserve all existing quirks + +- Post `buildPayloads` reads only `$rawItems[0]` per user, matching current semantics confirmed during exploration. +- Post `mapRows` writes `$result[$userId] = [[…]]`, the list-of-list shape kept for downstream consistency. +- All silent-skip branches (non-array row, non-scalar value, non-positive ID, empty type) remain. + +### Public surface frozen + +`read(int $id, string $key): array` and `write(int $id, string $key, array $data): bool` keep their exact signatures, return types, and error semantics. Guard checks (`$id <= 0 || $key === ''`) stay in the public methods so the helpers can assume valid inputs. + +## Risks / Trade-offs + +- [Behavioral drift while refactoring] → Reuse exact existing coercion logic in `buildPayloads`/`mapRows` line-for-line. No tests cover these paths today, so manual diff review is the safety net. +- [Helper proliferation] → Two extra private methods per class (4 per file). Acceptable given the clarity gain; no helpers shared across classes yet. +- [Post `[0]`-only quirk hidden behind helper] → Mitigation: short inline comment in `Post\TableStorage::buildPayloads` noting the behavior is intentional, so a future reader does not "fix" it. diff --git a/openspec/changes/split-table-storage-query-build/proposal.md b/openspec/changes/split-table-storage-query-build/proposal.md new file mode 100644 index 0000000..37adae1 --- /dev/null +++ b/openspec/changes/split-table-storage-query-build/proposal.md @@ -0,0 +1,26 @@ +## Why + +`Post\TableStorage` and `User\TableStorage` mix three concerns inside `read()` and `write()`: input guards, raw SQL execution against `$wpdb`, and array coercion/payload building. The methods are hard to read and test, and any future bulk-insert or shared-storage refactor will compound the mess. Splitting query execution from data shaping now keeps the change minimal while unblocking later work. + +## What Changes + +- Refactor `Post\TableStorage::read()` into private helpers: one for the SQL fetch, one for mapping rows into the return shape. +- Refactor `Post\TableStorage::write()` into private helpers: one for building row payloads from input, one for executing the DELETE + INSERTs inside the transaction. +- Apply the same split to `User\TableStorage::read()` and `User\TableStorage::write()`. +- No public API change. No SQL change. No behavior change. Existing quirks preserved (Post `write` indexes `[0]` only; Post `read` collapses one row per user). + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +None. Public behavior of `table-storage` is unchanged; this is an internal restructure of the existing implementation. + +## Impact + +- Affected files: `sources/Post/TableStorage.php`, `sources/User/TableStorage.php`. +- No DB schema, no service wiring, no consumer change. +- Unblocks future iterations: extracting a shared storage module across Post/User, and switching per-row inserts to a single multi-VALUES insert. diff --git a/openspec/changes/split-table-storage-query-build/specs/table-storage/spec.md b/openspec/changes/split-table-storage-query-build/specs/table-storage/spec.md new file mode 100644 index 0000000..b472626 --- /dev/null +++ b/openspec/changes/split-table-storage-query-build/specs/table-storage/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Post TableStorage write persists only the first item per user +`Post\TableStorage::write()` SHALL, for each `userId => $rawItems` entry in the input array, persist only `$rawItems[0]` and ignore any additional indices. This codifies existing behavior and prevents regressions during the internal split between payload building and DB execution. + +#### Scenario: Multiple items under the same user +- **WHEN** `write($postId, $key, [$userId => [[$entityId1, $type1], [$entityId2, $type2]]])` is called with valid IDs +- **THEN** only one row SHALL be inserted for `$userId` using `$entityId1` and `$type1`, and the second tuple SHALL be ignored + +#### Scenario: Single item under a user +- **WHEN** `write($postId, $key, [$userId => [[$entityId, $type]]])` is called with valid IDs +- **THEN** exactly one row SHALL be inserted for `$userId` with `$entityId` and `$type` + +### Requirement: Post TableStorage read returns one row per user +`Post\TableStorage::read()` SHALL return at most one entry per `userId` in the result map, even when multiple rows exist in the table for the same `(entity_id, group_key, user_id)` combination. The list-of-list shape `[userId => [[entityId, entityType]]]` is preserved for downstream consumer consistency. + +#### Scenario: Multiple rows for the same user +- **WHEN** `read($postId, $key)` is called and the underlying table contains more than one row for the same `user_id` +- **THEN** the returned map SHALL contain a single entry per `userId`, each holding a one-element list with the last seen `[entityId, entityType]` tuple + +#### Scenario: Distinct users +- **WHEN** `read($postId, $key)` is called and rows exist for distinct user IDs +- **THEN** the returned map SHALL contain one entry per distinct `userId` diff --git a/openspec/changes/split-table-storage-query-build/tasks.md b/openspec/changes/split-table-storage-query-build/tasks.md new file mode 100644 index 0000000..758a90f --- /dev/null +++ b/openspec/changes/split-table-storage-query-build/tasks.md @@ -0,0 +1,25 @@ +## 1. Refactor Post\TableStorage + +- [ ] 1.1 Extract `fetchRows(int $id, string $key): array` private helper from `read()` containing only the `$wpdb->prepare` + `$wpdb->get_results` call and the `is_array` guard, returning the raw `ARRAY_A` rows or `[]` +- [ ] 1.2 Extract `mapRows(array $rows): array` private helper from `read()` containing the row coercion loop that produces `[userId => [[entityId, entityType]]]` +- [ ] 1.3 Reduce `read()` body to: input guard, `$rows = $this->fetchRows($id, $key)`, `return $this->mapRows($rows)` +- [ ] 1.4 Extract `buildPayloads(array $data, string $key): array` private helper from `write()` that returns a list of associative payloads `['entity_id', 'user_id', 'entity_type', 'group_key']`, preserving the `$rawItems[0]`-only behavior and silent skips; add a one-line comment noting the `[0]`-only behavior is intentional +- [ ] 1.5 Extract `persist(int $id, string $key, array $payloads): bool` private helper from `write()` that owns `START TRANSACTION`, the prepared `DELETE`, the per-row `$wpdb->insert` loop, and `COMMIT`/`ROLLBACK` semantics +- [ ] 1.6 Reduce `write()` body to: input guard, `$payloads = $this->buildPayloads($data, $key)`, `return $this->persist($id, $key, $payloads)` + +## 2. Refactor User\TableStorage + +- [ ] 2.1 Extract `fetchRows(int $id, string $key): array` private helper from `read()` containing only the `$wpdb->prepare` + `$wpdb->get_results` call and the `is_array` guard +- [ ] 2.2 Extract `mapRows(array $rows): array` private helper from `read()` containing the row coercion loop that produces `[entityId => [entityId, entityType]]` +- [ ] 2.3 Reduce `read()` body to: input guard, `$rows = $this->fetchRows($id, $key)`, `return $this->mapRows($rows)` +- [ ] 2.4 Extract `buildPayloads(array $data): array` private helper from `write()` that returns a list of partial payloads `['entity_id', 'entity_type']`, preserving silent skips for non-array entries and zero/empty values +- [ ] 2.5 Extract `persist(int $id, string $key, array $payloads): bool` private helper from `write()` that owns `START TRANSACTION`, the prepared `DELETE`, the per-row `$wpdb->insert` loop (injecting `user_id => $id` and `group_key => $key` at insert time), and `COMMIT`/`ROLLBACK` semantics +- [ ] 2.6 Reduce `write()` body to: input guard, `$payloads = $this->buildPayloads($data)`, `return $this->persist($id, $key, $payloads)` + +## 3. Verify + +- [ ] 3.1 Run `composer run-script phpcs` (or equivalent) and resolve any new violations introduced by the refactor +- [ ] 3.2 Run `composer run-script phpstan` (or equivalent) and resolve any new type errors +- [ ] 3.3 Run the existing test suite and confirm no regressions +- [ ] 3.4 Manually diff `read()` and `write()` behavior against the pre-refactor version: inputs in → identical SQL executed, identical return values, identical transactional behavior +- [ ] 3.5 Run `openspec validate split-table-storage-query-build --strict` and confirm the change validates From d4716d6818964b3f73bd25f121c182ed66d264a6 Mon Sep 17 00:00:00 2001 From: guido Date: Fri, 8 May 2026 21:24:31 +0200 Subject: [PATCH 08/26] Remove redundant PHP CodeSniffer comments in `Post\TableStorage` and `User\TableStorage` --- sources/Post/TableStorage.php | 2 -- sources/User/TableStorage.php | 2 -- 2 files changed, 4 deletions(-) diff --git a/sources/Post/TableStorage.php b/sources/Post/TableStorage.php index 563300e..93ff294 100644 --- a/sources/Post/TableStorage.php +++ b/sources/Post/TableStorage.php @@ -78,7 +78,6 @@ public function write(int $id, string $key, array $data): bool $tableName = $this->table->name(); $wpdb->query('START TRANSACTION'); - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE entity_id = %d AND group_key = %s', @@ -87,7 +86,6 @@ public function write(int $id, string $key, array $data): bool $key ) ); - // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ($deleted === false) { $wpdb->query('ROLLBACK'); diff --git a/sources/User/TableStorage.php b/sources/User/TableStorage.php index e3cb38b..d945ad9 100644 --- a/sources/User/TableStorage.php +++ b/sources/User/TableStorage.php @@ -78,7 +78,6 @@ public function write(int $id, string $key, array $data): bool $tableName = $this->table->name(); $wpdb->query('START TRANSACTION'); - // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared $deleted = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE user_id = %d AND group_key = %s', @@ -87,7 +86,6 @@ public function write(int $id, string $key, array $data): bool $key ) ); - // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ($deleted === false) { $wpdb->query('ROLLBACK'); From f6cb2edf300459bc30099c039b80735cb7af772f Mon Sep 17 00:00:00 2001 From: guido Date: Fri, 8 May 2026 21:43:32 +0200 Subject: [PATCH 09/26] Refactor `Post\TableStorage` and `User\TableStorage` to isolate database interactions and payload shaping into separate private helpers, preserving all existing functionality and quirks. --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/table-storage/spec.md | 0 .../tasks.md | 34 +++---- openspec/specs/table-storage/spec.md | 22 +++++ sources/Post/TableStorage.php | 96 ++++++++++++++----- sources/User/TableStorage.php | 75 +++++++++++---- 8 files changed, 166 insertions(+), 61 deletions(-) rename openspec/changes/{split-table-storage-query-build => archive/2026-05-08-split-table-storage-query-build}/.openspec.yaml (100%) rename openspec/changes/{split-table-storage-query-build => archive/2026-05-08-split-table-storage-query-build}/design.md (100%) rename openspec/changes/{split-table-storage-query-build => archive/2026-05-08-split-table-storage-query-build}/proposal.md (100%) rename openspec/changes/{split-table-storage-query-build => archive/2026-05-08-split-table-storage-query-build}/specs/table-storage/spec.md (100%) rename openspec/changes/{split-table-storage-query-build => archive/2026-05-08-split-table-storage-query-build}/tasks.md (63%) diff --git a/openspec/changes/split-table-storage-query-build/.openspec.yaml b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/.openspec.yaml similarity index 100% rename from openspec/changes/split-table-storage-query-build/.openspec.yaml rename to openspec/changes/archive/2026-05-08-split-table-storage-query-build/.openspec.yaml diff --git a/openspec/changes/split-table-storage-query-build/design.md b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/design.md similarity index 100% rename from openspec/changes/split-table-storage-query-build/design.md rename to openspec/changes/archive/2026-05-08-split-table-storage-query-build/design.md diff --git a/openspec/changes/split-table-storage-query-build/proposal.md b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/proposal.md similarity index 100% rename from openspec/changes/split-table-storage-query-build/proposal.md rename to openspec/changes/archive/2026-05-08-split-table-storage-query-build/proposal.md diff --git a/openspec/changes/split-table-storage-query-build/specs/table-storage/spec.md b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/specs/table-storage/spec.md similarity index 100% rename from openspec/changes/split-table-storage-query-build/specs/table-storage/spec.md rename to openspec/changes/archive/2026-05-08-split-table-storage-query-build/specs/table-storage/spec.md diff --git a/openspec/changes/split-table-storage-query-build/tasks.md b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/tasks.md similarity index 63% rename from openspec/changes/split-table-storage-query-build/tasks.md rename to openspec/changes/archive/2026-05-08-split-table-storage-query-build/tasks.md index 758a90f..baf1930 100644 --- a/openspec/changes/split-table-storage-query-build/tasks.md +++ b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/tasks.md @@ -1,25 +1,25 @@ ## 1. Refactor Post\TableStorage -- [ ] 1.1 Extract `fetchRows(int $id, string $key): array` private helper from `read()` containing only the `$wpdb->prepare` + `$wpdb->get_results` call and the `is_array` guard, returning the raw `ARRAY_A` rows or `[]` -- [ ] 1.2 Extract `mapRows(array $rows): array` private helper from `read()` containing the row coercion loop that produces `[userId => [[entityId, entityType]]]` -- [ ] 1.3 Reduce `read()` body to: input guard, `$rows = $this->fetchRows($id, $key)`, `return $this->mapRows($rows)` -- [ ] 1.4 Extract `buildPayloads(array $data, string $key): array` private helper from `write()` that returns a list of associative payloads `['entity_id', 'user_id', 'entity_type', 'group_key']`, preserving the `$rawItems[0]`-only behavior and silent skips; add a one-line comment noting the `[0]`-only behavior is intentional -- [ ] 1.5 Extract `persist(int $id, string $key, array $payloads): bool` private helper from `write()` that owns `START TRANSACTION`, the prepared `DELETE`, the per-row `$wpdb->insert` loop, and `COMMIT`/`ROLLBACK` semantics -- [ ] 1.6 Reduce `write()` body to: input guard, `$payloads = $this->buildPayloads($data, $key)`, `return $this->persist($id, $key, $payloads)` +- [x] 1.1 Extract `fetchRows(int $id, string $key): array` private helper from `read()` containing only the `$wpdb->prepare` + `$wpdb->get_results` call and the `is_array` guard, returning the raw `ARRAY_A` rows or `[]` +- [x] 1.2 Extract `mapRows(array $rows): array` private helper from `read()` containing the row coercion loop that produces `[userId => [[entityId, entityType]]]` +- [x] 1.3 Reduce `read()` body to: input guard, `$rows = $this->fetchRows($id, $key)`, `return $this->mapRows($rows)` +- [x] 1.4 Extract `buildPayloads(array $data, string $key): array` private helper from `write()` that returns a list of associative payloads `['entity_id', 'user_id', 'entity_type', 'group_key']`, preserving the `$rawItems[0]`-only behavior and silent skips; add a one-line comment noting the `[0]`-only behavior is intentional +- [x] 1.5 Extract `persist(int $id, string $key, array $payloads): bool` private helper from `write()` that owns `START TRANSACTION`, the prepared `DELETE`, the per-row `$wpdb->insert` loop, and `COMMIT`/`ROLLBACK` semantics +- [x] 1.6 Reduce `write()` body to: input guard, `$payloads = $this->buildPayloads($data, $key)`, `return $this->persist($id, $key, $payloads)` ## 2. Refactor User\TableStorage -- [ ] 2.1 Extract `fetchRows(int $id, string $key): array` private helper from `read()` containing only the `$wpdb->prepare` + `$wpdb->get_results` call and the `is_array` guard -- [ ] 2.2 Extract `mapRows(array $rows): array` private helper from `read()` containing the row coercion loop that produces `[entityId => [entityId, entityType]]` -- [ ] 2.3 Reduce `read()` body to: input guard, `$rows = $this->fetchRows($id, $key)`, `return $this->mapRows($rows)` -- [ ] 2.4 Extract `buildPayloads(array $data): array` private helper from `write()` that returns a list of partial payloads `['entity_id', 'entity_type']`, preserving silent skips for non-array entries and zero/empty values -- [ ] 2.5 Extract `persist(int $id, string $key, array $payloads): bool` private helper from `write()` that owns `START TRANSACTION`, the prepared `DELETE`, the per-row `$wpdb->insert` loop (injecting `user_id => $id` and `group_key => $key` at insert time), and `COMMIT`/`ROLLBACK` semantics -- [ ] 2.6 Reduce `write()` body to: input guard, `$payloads = $this->buildPayloads($data)`, `return $this->persist($id, $key, $payloads)` +- [x] 2.1 Extract `fetchRows(int $id, string $key): array` private helper from `read()` containing only the `$wpdb->prepare` + `$wpdb->get_results` call and the `is_array` guard +- [x] 2.2 Extract `mapRows(array $rows): array` private helper from `read()` containing the row coercion loop that produces `[entityId => [entityId, entityType]]` +- [x] 2.3 Reduce `read()` body to: input guard, `$rows = $this->fetchRows($id, $key)`, `return $this->mapRows($rows)` +- [x] 2.4 Extract `buildPayloads(array $data): array` private helper from `write()` that returns a list of partial payloads `['entity_id', 'entity_type']`, preserving silent skips for non-array entries and zero/empty values +- [x] 2.5 Extract `persist(int $id, string $key, array $payloads): bool` private helper from `write()` that owns `START TRANSACTION`, the prepared `DELETE`, the per-row `$wpdb->insert` loop (injecting `user_id => $id` and `group_key => $key` at insert time), and `COMMIT`/`ROLLBACK` semantics +- [x] 2.6 Reduce `write()` body to: input guard, `$payloads = $this->buildPayloads($data)`, `return $this->persist($id, $key, $payloads)` ## 3. Verify -- [ ] 3.1 Run `composer run-script phpcs` (or equivalent) and resolve any new violations introduced by the refactor -- [ ] 3.2 Run `composer run-script phpstan` (or equivalent) and resolve any new type errors -- [ ] 3.3 Run the existing test suite and confirm no regressions -- [ ] 3.4 Manually diff `read()` and `write()` behavior against the pre-refactor version: inputs in → identical SQL executed, identical return values, identical transactional behavior -- [ ] 3.5 Run `openspec validate split-table-storage-query-build --strict` and confirm the change validates +- [x] 3.1 Run `composer run-script phpcs` (or equivalent) and resolve any new violations introduced by the refactor +- [x] 3.2 Run `composer run-script phpstan` (or equivalent) and resolve any new type errors +- [x] 3.3 Run the existing test suite and confirm no regressions +- [x] 3.4 Manually diff `read()` and `write()` behavior against the pre-refactor version: inputs in → identical SQL executed, identical return values, identical transactional behavior +- [x] 3.5 Run `openspec validate split-table-storage-query-build --strict` and confirm the change validates diff --git a/openspec/specs/table-storage/spec.md b/openspec/specs/table-storage/spec.md index 44c8cfb..7ea0977 100644 --- a/openspec/specs/table-storage/spec.md +++ b/openspec/specs/table-storage/spec.md @@ -37,6 +37,28 @@ The system SHALL provide a `Post\TableStorage` class that implements `Post\Stora - **WHEN** `write()` deletes existing rows and inserts new ones - **THEN** both operations SHALL execute within a single database transaction +### Requirement: Post TableStorage write persists only the first item per user +`Post\TableStorage::write()` SHALL, for each `userId => $rawItems` entry in the input array, persist only `$rawItems[0]` and ignore any additional indices. This codifies existing behavior and prevents regressions during the internal split between payload building and DB execution. + +#### Scenario: Multiple items under the same user +- **WHEN** `write($postId, $key, [$userId => [[$entityId1, $type1], [$entityId2, $type2]]])` is called with valid IDs +- **THEN** only one row SHALL be inserted for `$userId` using `$entityId1` and `$type1`, and the second tuple SHALL be ignored + +#### Scenario: Single item under a user +- **WHEN** `write($postId, $key, [$userId => [[$entityId, $type]]])` is called with valid IDs +- **THEN** exactly one row SHALL be inserted for `$userId` with `$entityId` and `$type` + +### Requirement: Post TableStorage read returns one row per user +`Post\TableStorage::read()` SHALL return at most one entry per `userId` in the result map, even when multiple rows exist in the table for the same `(entity_id, group_key, user_id)` combination. The list-of-list shape `[userId => [[entityId, entityType]]]` is preserved for downstream consumer consistency. + +#### Scenario: Multiple rows for the same user +- **WHEN** `read($postId, $key)` is called and the underlying table contains more than one row for the same `user_id` +- **THEN** the returned map SHALL contain a single entry per `userId`, each holding a one-element list with the last seen `[entityId, entityType]` tuple + +#### Scenario: Distinct users +- **WHEN** `read($postId, $key)` is called and rows exist for distinct user IDs +- **THEN** the returned map SHALL contain one entry per distinct `userId` + ### Requirement: User TableStorage implements User\Storage The system SHALL provide a `User\TableStorage` class that implements `User\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, filtering by `user_id` and returning `entity_id` values as item IDs. diff --git a/sources/Post/TableStorage.php b/sources/Post/TableStorage.php index 93ff294..822ac36 100644 --- a/sources/Post/TableStorage.php +++ b/sources/Post/TableStorage.php @@ -29,21 +29,45 @@ public function read(int $id, string $key): array return []; } + return $this->mapRows($this->fetchRows($id, $key)); + } + + /** + * @param array> $data + */ + public function write(int $id, string $key, array $data): bool + { + if ($id <= 0 || $key === '') { + return false; + } + + return $this->persist($id, $key, $this->buildPayloads($data, $key)); + } + + /** + * @return array + */ + private function fetchRows(int $id, string $key): array + { global $wpdb; - $tableName = $this->table->name(); $rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore Inpsyde.CodeQuality.LineLength.TooLong 'SELECT user_id, entity_id, entity_type FROM %i WHERE entity_id = %d AND group_key = %s', - $tableName, + $this->table->name(), $id, $key ), ARRAY_A); - if (!is_array($rows)) { - return []; - } + return is_array($rows) ? $rows : []; + } + /** + * @param array $rows + * @return array> + */ + private function mapRows(array $rows): array + { $result = []; foreach ($rows as $row) { if (!is_array($row)) { @@ -66,13 +90,49 @@ public function read(int $id, string $key): array /** * @param array> $data + * @return list */ - public function write(int $id, string $key, array $data): bool + private function buildPayloads(array $data, string $key): array { - if ($id <= 0 || $key === '') { - return false; + $payloads = []; + foreach ($data as $userId => $rawItems) { + $userId = (int) $userId; + // Intentional: only the first item per user is persisted. + $first = $rawItems[0] ?? null; + if ($userId <= 0 || !is_array($first)) { + continue; + } + $entityId = (int) ($first[0] ?? 0); + $entityType = (string) ($first[1] ?? ''); + if ($entityId <= 0 || $entityType === '') { + continue; + } + $payloads[] = [ + 'entity_id' => $entityId, + 'user_id' => $userId, + 'entity_type' => $entityType, + 'group_key' => $key, + ]; } + return $payloads; + } + + /** + * @param list $payloads + */ + private function persist(int $id, string $key, array $payloads): bool + { global $wpdb; $tableName = $this->table->name(); @@ -80,6 +140,7 @@ public function write(int $id, string $key, array $data): bool $deleted = $wpdb->query( $wpdb->prepare( + // phpcs:ignore Inpsyde.CodeQuality.LineLength.TooLong 'DELETE FROM %i WHERE entity_id = %d AND group_key = %s', $tableName, $id, @@ -92,25 +153,10 @@ public function write(int $id, string $key, array $data): bool return false; } - foreach ($data as $userId => $rawItems) { - $userId = (int) $userId; - $first = $rawItems[0] ?? null; - if ($userId <= 0 || !is_array($first)) { - continue; - } - $entityId = (int) ($first[0] ?? 0); - $entityType = (string) ($first[1] ?? ''); - if ($entityId <= 0 || $entityType === '') { - continue; - } + foreach ($payloads as $payload) { $inserted = $wpdb->insert( $tableName, - [ - 'entity_id' => $entityId, - 'user_id' => $userId, - 'entity_type' => $entityType, - 'group_key' => $key, - ], + $payload, ['%d', '%d', '%s', '%s'] ); if ($inserted === false) { diff --git a/sources/User/TableStorage.php b/sources/User/TableStorage.php index d945ad9..b9552f8 100644 --- a/sources/User/TableStorage.php +++ b/sources/User/TableStorage.php @@ -29,20 +29,44 @@ public function read(int $id, string $key): array return []; } + return $this->mapRows($this->fetchRows($id, $key)); + } + + /** + * @param array $data + */ + public function write(int $id, string $key, array $data): bool + { + if ($id <= 0 || $key === '') { + return false; + } + + return $this->persist($id, $key, $this->buildPayloads($data)); + } + + /** + * @return array + */ + private function fetchRows(int $id, string $key): array + { global $wpdb; - $tableName = $this->table->name(); $rows = $wpdb->get_results($wpdb->prepare( 'SELECT entity_id, entity_type FROM %i WHERE user_id = %d AND group_key = %s', - $tableName, + $this->table->name(), $id, $key ), ARRAY_A); - if (!is_array($rows)) { - return []; - } + return is_array($rows) ? $rows : []; + } + /** + * @param array $rows + * @return array + */ + private function mapRows(array $rows): array + { $result = []; foreach ($rows as $row) { if (!is_array($row)) { @@ -66,13 +90,34 @@ public function read(int $id, string $key): array /** * @param array $data + * @return list */ - public function write(int $id, string $key, array $data): bool + private function buildPayloads(array $data): array { - if ($id <= 0 || $key === '') { - return false; + $payloads = []; + foreach ($data as $rawItem) { + if (!is_array($rawItem)) { + continue; + } + $entityId = (int) ($rawItem[0] ?? 0); + $entityType = (string) ($rawItem[1] ?? ''); + if ($entityId <= 0 || $entityType === '') { + continue; + } + $payloads[] = [ + 'entity_id' => $entityId, + 'entity_type' => $entityType, + ]; } + return $payloads; + } + + /** + * @param list $payloads + */ + private function persist(int $id, string $key, array $payloads): bool + { global $wpdb; $tableName = $this->table->name(); @@ -92,21 +137,13 @@ public function write(int $id, string $key, array $data): bool return false; } - foreach ($data as $rawItem) { - if (!is_array($rawItem)) { - continue; - } - $entityId = (int) ($rawItem[0] ?? 0); - $entityType = (string) ($rawItem[1] ?? ''); - if ($entityId <= 0 || $entityType === '') { - continue; - } + foreach ($payloads as $payload) { $inserted = $wpdb->insert( $tableName, [ - 'entity_id' => $entityId, + 'entity_id' => $payload['entity_id'], 'user_id' => $id, - 'entity_type' => $entityType, + 'entity_type' => $payload['entity_type'], 'group_key' => $key, ], ['%d', '%d', '%s', '%s'] From a8dabdacb3b884a0a05b2e6f593a688e30a60ef9 Mon Sep 17 00:00:00 2001 From: guido Date: Mon, 11 May 2026 00:05:25 +0200 Subject: [PATCH 10/26] Archive legacy storage system and related tests, replacing them with a new unified `Storage` interface and documentation. --- docs/storage-drivers.md | 159 +++++++++ .../extract-shared-storage-module/design.md | 174 ++++++++++ .../extract-shared-storage-module/proposal.md | 309 ++++++++++++++++++ .../specs/table-storage/spec.md | 153 +++++++++ .../extract-shared-storage-module/tasks.md | 13 + sources/Post/MetaStorage.php | 47 --- sources/Post/Module.php | 16 +- sources/Post/RawDataAssert.php | 83 ----- sources/Post/Repository.php | 63 +--- sources/Post/Storage.php | 18 - sources/Post/TableStorage.php | 171 ---------- sources/Storage/Axis.php | 23 ++ sources/Storage/Record.php | 18 + sources/Storage/Storage.php | 18 + sources/{Post => Storage}/StorageKey.php | 2 +- sources/Storage/TableStorage.php | 140 ++++++++ sources/User/MetaStorage.php | 47 --- sources/User/Module.php | 18 +- sources/User/RawDataAssert.php | 46 --- sources/User/Repository.php | 53 ++- sources/User/Storage.php | 18 - sources/User/StorageKey.php | 38 --- sources/User/TableStorage.php | 160 --------- tests/helpers/InMemoryStorage.php | 79 +++++ tests/helpers/integration.php | 77 ++--- tests/integration/php/PostRepositoryTest.php | 84 ++--- tests/integration/php/UserRepositoryTest.php | 107 +++--- tests/stubs/php/valid-post-user-reactions.php | 40 --- tests/stubs/php/valid-users-reactions.php | 24 -- tests/unit/php/Post/RawDataAssertTest.php | 71 ---- tests/unit/php/Post/StorageKeyTest.php | 24 -- tests/unit/php/Post/StorageTest.php | 56 ---- tests/unit/php/Post/TableStorageTest.php | 32 -- tests/unit/php/Storage/RecordTest.php | 16 + .../php/{User => Storage}/StorageKeyTest.php | 4 +- tests/unit/php/Storage/TableStorageTest.php | 49 +++ tests/unit/php/User/RawDataAssertTest.php | 74 ----- tests/unit/php/User/StorageTest.php | 68 ---- tests/unit/php/User/TableStorageTest.php | 32 -- 39 files changed, 1314 insertions(+), 1310 deletions(-) create mode 100644 docs/storage-drivers.md create mode 100644 openspec/changes/extract-shared-storage-module/design.md create mode 100644 openspec/changes/extract-shared-storage-module/proposal.md create mode 100644 openspec/changes/extract-shared-storage-module/specs/table-storage/spec.md create mode 100644 openspec/changes/extract-shared-storage-module/tasks.md delete mode 100644 sources/Post/MetaStorage.php delete mode 100644 sources/Post/RawDataAssert.php delete mode 100644 sources/Post/Storage.php delete mode 100644 sources/Post/TableStorage.php create mode 100644 sources/Storage/Axis.php create mode 100644 sources/Storage/Record.php create mode 100644 sources/Storage/Storage.php rename sources/{Post => Storage}/StorageKey.php (95%) create mode 100644 sources/Storage/TableStorage.php delete mode 100644 sources/User/MetaStorage.php delete mode 100644 sources/User/RawDataAssert.php delete mode 100644 sources/User/Storage.php delete mode 100644 sources/User/StorageKey.php delete mode 100644 sources/User/TableStorage.php create mode 100644 tests/helpers/InMemoryStorage.php delete mode 100644 tests/stubs/php/valid-post-user-reactions.php delete mode 100644 tests/stubs/php/valid-users-reactions.php delete mode 100644 tests/unit/php/Post/RawDataAssertTest.php delete mode 100644 tests/unit/php/Post/StorageKeyTest.php delete mode 100644 tests/unit/php/Post/StorageTest.php delete mode 100644 tests/unit/php/Post/TableStorageTest.php create mode 100644 tests/unit/php/Storage/RecordTest.php rename tests/unit/php/{User => Storage}/StorageKeyTest.php (86%) create mode 100644 tests/unit/php/Storage/TableStorageTest.php delete mode 100644 tests/unit/php/User/RawDataAssertTest.php delete mode 100644 tests/unit/php/User/StorageTest.php delete mode 100644 tests/unit/php/User/TableStorageTest.php diff --git a/docs/storage-drivers.md b/docs/storage-drivers.md new file mode 100644 index 0000000..0dbde06 --- /dev/null +++ b/docs/storage-drivers.md @@ -0,0 +1,159 @@ +# 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. This document describes the contract and shows how to swap in an alternative driver (e.g. WordPress meta) via the DI container. + +## The `Storage` interface + +```php +namespace SpaghettiDojo\Konomi\Storage; + +interface Storage +{ + /** @return list */ + public function read(int $id, string $groupKey): array; + + /** @param list $records */ + public function write(int $id, string $groupKey, array $records): bool; +} +``` + +`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 when used by `Post\Repository`, a user id when used by `User\Repository`). `$groupKey` is a sanitized `User\ItemGroup` value (e.g. `"reaction"`, `"bookmark"`). + +`read` returns every record scoped to `($id, $groupKey)`. `write` replaces the entire scope: existing rows for that `(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` / `wp_usermeta`. Copy and adapt as needed. + +### Post variant + +```php +namespace MyPlugin\Storage; + +use SpaghettiDojo\Konomi\Storage\Record; +use SpaghettiDojo\Konomi\Storage\Storage; + +final class PostMetaStorage implements Storage +{ + private const BASE = '_konomi_items'; + + public function read(int $id, string $groupKey): array + { + if ($id <= 0 || $groupKey === '') { + return []; + } + + $raw = get_post_meta($id, self::BASE . '.' . $groupKey, true); + 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(int $id, string $groupKey, array $records): bool + { + if ($id <= 0 || $groupKey === '') { + return false; + } + + $payload = array_map( + static fn (Record $r) => [ + 'entity_id' => $r->entityId, + 'user_id' => $r->userId, + 'entity_type' => $r->entityType, + ], + $records + ); + + return (bool) update_post_meta($id, self::BASE . '.' . $groupKey, $payload); + } +} +``` + +### User variant + +Identical to the post variant with `get_user_meta` / `update_user_meta` substituted for `get_post_meta` / `update_post_meta`. + +## Container override + +Konomi's per-module `Module::services()` constructs `TableStorage` inline inside each `Repository` factory. To swap drivers from a consumer plugin or site, override the `Repository` binding directly: + +```php +use Inpsyde\Modularity\Module\ServiceModule; +use Inpsyde\Modularity\Module\ModuleClassNameIdTrait; +use MyPlugin\Storage\PostMetaStorage; +use MyPlugin\Storage\UserMetaStorage; +use Psr\Container\ContainerInterface; +use SpaghettiDojo\Konomi\Post; +use SpaghettiDojo\Konomi\Storage; +use SpaghettiDojo\Konomi\User; + +final class StorageOverrideModule implements ServiceModule +{ + use ModuleClassNameIdTrait; + + public static function new(): self + { + return new self(); + } + + private function __construct() {} + + public function services(): array + { + return [ + Post\Repository::class => static fn (ContainerInterface $c) => Post\Repository::new( + Storage\StorageKey::new(), + new PostMetaStorage(), + $c->get(User\ItemFactory::class), + $c->get(Post\ItemRegistry::class) + ), + User\Repository::class => static fn (ContainerInterface $c) => User\Repository::new( + Storage\StorageKey::new(), + new UserMetaStorage(), + $c->get(User\ItemFactory::class), + $c->get(User\ItemRegistry::class) + ), + ]; + } +} +``` + +Add the override module after Konomi's bundled modules so its bindings win: + +```php +\SpaghettiDojo\Konomi\package()->addModule(StorageOverrideModule::new()); +``` + +## Notes + +- `groupKey` is operation scope, not row data — `write` always replaces the entire `(id, groupKey)` slice. +- `TableStorage` enforces an axis invariant: the column corresponding to its configured `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. diff --git a/openspec/changes/extract-shared-storage-module/design.md b/openspec/changes/extract-shared-storage-module/design.md new file mode 100644 index 0000000..27275d4 --- /dev/null +++ b/openspec/changes/extract-shared-storage-module/design.md @@ -0,0 +1,174 @@ +# Design: Extract Shared Storage Module + +## Overview + +Collapse the duplicated `Post\*` and `User\*` persistence stack into a single `sources/Storage/` namespace. The shared layer exposes a DTO-typed interface; per-module behavior is reduced to a single configuration parameter (`Axis`) injected via the DI container. + +## Decisions + +### DTO over array shapes + +`Storage` interface returns `list` and accepts `list` for writes. `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, + ) {} +} +``` + +This eliminates the diverging `array` (User) vs `array>` (Post) shapes and the `RawDataAssert` validators that exist only because the shape was opaque. + +### Axis enum, constructor-injected + +`TableStorage` is parameterized by `Axis`, set once at construction. Repos do not pass `Axis` per call — they receive an already-bound `Storage` instance from the container. + +```php +enum Axis { + case Entity; + case User; + + public function column(): string { + return match ($this) { + self::Entity => 'entity_id', + self::User => 'user_id', + }; + } +} +``` + +`column()` is the single point of axis-driven dispatch. `match` exhaustiveness guarantees that any new Axis case forces a code update at every consumption site. + +### `groupKey` stays a method parameter + +Group is operation scope, not row data. The DB transaction in `write` is bounded by `(axis_id, group_key)` — DELETE + INSERTs in one TX. Putting `groupKey` on `Record` would either: +- require a uniformity check in `write` (all records share the same key), or +- allow heterogeneous keys, breaking the single-scope-per-call invariant. + +Method parameter is simpler and matches current semantics. + +### Boundary validation, no `RawDataAssert` + +`TableStorage::read` maps each raw row through a private `mapRow(array $row): ?Record`. Malformed rows return `null` and are skipped. The repo always receives a clean `list`. No separate validator class exists — the type is the contract. + +### Flatten Post in-memory shape + +The Post-side `[[id, type]]` envelope was introduced for symmetry with the User shape, never for actual multi-item-per-user storage. Confirmed by inspection: every site treats it as a single-tuple list (`$rawItems[0]`). On disk, each row is one tuple; the envelope existed only in PHP memory. + +Removing it: +- collapses `Post\RawDataAssert` and `User\RawDataAssert` into the same logic (both validate `array`) +- eliminates the "first-item-per-user persisted" quirk currently codified as a SHALL requirement — the registry, keyed by `userId`, already enforces uniqueness +- enables a single `Record` DTO contract for both modules + +No DB migration is required because the on-disk format was never nested. + +### `MetaStorage` becomes documentation + +`MetaStorage` is not wired in any production container; both modules bind `Storage::class` to `TableStorage`. It exists only as legacy fallback for environments where custom tables may not be viable. + +Container-based DI already supports per-environment swap of the bound implementation. We move the swap pattern into `docs/storage-drivers.md`: +- the `Storage` interface contract (DTO-based) +- a reference `MetaStorage` implementation (post + user variants) +- a container override snippet showing how to rebind `Storage::class` from a consumer plugin or site + +Users who need meta-backed storage copy the example and adapt. Core no longer carries unused code. + +### `StorageKey` has no `base` field + +Current `StorageKey` (both copies) returns the sanitized group string with no prefix. The `_konomi_items` prefix lives only inside `MetaStorage`. With `MetaStorage` removed, the prefix concern is driver-local: any consumer copying the documented `MetaStorage` example embeds their own base inside that class. + +The shared `StorageKey` therefore takes no constructor arguments. Behavior is identical to the current per-module copies, just deduplicated. + +### Per-module DI binding + +Each `Module::services()` binds `Storage::class` (the interface) to a `TableStorage` instance configured with the module's axis: + +```php +// Post\Module +Storage::class => static fn (ContainerInterface $c) => + TableStorage::new( + $c->get(Database\InteractionsTable::class), + Axis::Entity, + ), + +// User\Module +Storage::class => static fn (ContainerInterface $c) => + TableStorage::new( + $c->get(Database\InteractionsTable::class), + Axis::User, + ), +``` + +Repos resolve `Storage::class` from the per-module container scope and remain axis-blind. + +### Registry snapshot/rollback preserved + +The current `loadItems → snapshot → mutate → write → rollback-on-failure` flow stays in both repos: + +``` +$this->loadItems(...); +$snapshot = clone $this->registry; +$this->prepareDataToStore(...); // mutates registry +$stored = $this->storage->write(...); +$stored or $this->registry->replace($snapshot); +``` + +`TableStorage::write` keeps DB-side TX rollback. The two layers are complementary: DB rollback returns disk to pre-write state; repo rollback returns memory to pre-mutation state. Refactor leaves this structurally intact. + +## Files Changed + +| File | Change | +|------|--------| +| `sources/Storage/Storage.php` | New — DTO-typed interface | +| `sources/Storage/TableStorage.php` | New — axis-bound impl, boundary validation, transactional write | +| `sources/Storage/Record.php` | New — readonly DTO | +| `sources/Storage/Axis.php` | New — enum with `column()` | +| `sources/Storage/StorageKey.php` | New — moved from `Post\StorageKey` (identical) | +| `sources/Post/Storage.php` | Deleted | +| `sources/Post/TableStorage.php` | Deleted | +| `sources/Post/MetaStorage.php` | Deleted | +| `sources/Post/RawDataAssert.php` | Deleted | +| `sources/Post/StorageKey.php` | Deleted | +| `sources/Post/Repository.php` | Use shared `Storage`, `Record`, `StorageKey`; flatten serialize; drop `RawDataAssert` import | +| `sources/Post/Module.php` | Wire `Storage::class` to shared `TableStorage(Axis::Entity)` | +| `sources/User/Storage.php` | Deleted | +| `sources/User/TableStorage.php` | Deleted | +| `sources/User/MetaStorage.php` | Deleted | +| `sources/User/RawDataAssert.php` | Deleted | +| `sources/User/StorageKey.php` | Deleted | +| `sources/User/Repository.php` | Use shared `Storage`, `Record`, `StorageKey`; emit `list`; drop `RawDataAssert` import | +| `sources/User/Module.php` | Wire `Storage::class` to shared `TableStorage(Axis::User)` | +| `tests/unit/php/Post/TableStorageTest.php` | Move/rewrite under `tests/unit/php/Storage/`; assert `list` results | +| `tests/unit/php/User/TableStorageTest.php` | Merged into `tests/unit/php/Storage/TableStorageTest.php` (axis-parameterized) | +| `tests/unit/php/Post/StorageKeyTest.php` | Move to `tests/unit/php/Storage/StorageKeyTest.php` | +| `tests/unit/php/User/StorageKeyTest.php` | Deleted (covered by shared test) | +| `tests/unit/php/Post/StorageTest.php` | Deleted (`MetaStorage` no longer in code) | +| `tests/unit/php/User/StorageTest.php` | Deleted (`MetaStorage` no longer in code) | +| `tests/integration/php/PostRepositoryTest.php` | Update fixtures to flat shape; use shared types | +| `tests/integration/php/UserRepositoryTest.php` | Use shared types; signature is unchanged otherwise | +| `docs/storage-drivers.md` | New — Storage interface contract, `MetaStorage` reference impl, container override how-to | + +## Open Questions + +None at design time. Outstanding work is implementation-only. + +## Alternatives considered + +### Single shared abstract base + two thin subclasses + +`AbstractTableStorage` holds the TX skeleton; `Post\PostTableStorage` and `User\UserTableStorage` override the column. Rejected: still ships two classes for zero behavioral difference once `Axis` exists. Constructor-injected `Axis` collapses them into one impl with a `match` expression on a single line. + +### Axis as method parameter + +Pass `Axis` to `read`/`write` instead of injecting via constructor. Rejected: forces every caller (i.e. every repo) to know about `Axis`. With constructor injection, repos remain axis-blind and the per-module DI binding is the single point of axis configuration. + +### Keep `MetaStorage` in code + +Wire it as a fallback or expose a setting to swap. Rejected: it isn't wired anywhere today and has no callers. The container itself is the swap mechanism — a documented example serves the "I need meta backend on this host" use case without dragging unused production code through future refactors. + +### Keep nested `[[id, type]]` envelope + +Preserve it in case of future multi-item-per-user storage. Rejected: speculative, never implemented, requires keeping a divergent `RawDataAssert` and the "first-item-per-user persisted" quirk. If multi-item storage is ever needed, a new change can introduce it explicitly. diff --git a/openspec/changes/extract-shared-storage-module/proposal.md b/openspec/changes/extract-shared-storage-module/proposal.md new file mode 100644 index 0000000..37e187c --- /dev/null +++ b/openspec/changes/extract-shared-storage-module/proposal.md @@ -0,0 +1,309 @@ +# Extract Shared Storage Module + +## Summary + +Extract the persistence layer duplicated across `Post` and `User` modules into a single shared `sources/Storage/` namespace. Replace opaque array shapes with a `Record` DTO, parameterize the table-side filter column with an `Axis` enum injected at construction, drop `MetaStorage` from production code (preserved as documented reference impl), flatten the vestigial `[[id, type]]` envelope on the Post side, and delete `RawDataAssert` in favor of boundary validation in `TableStorage`. Repositories stay module-specific and consume the shared `Storage` interface. + +## Motivation + +The current code has two near-identical persistence stacks: + +- `Post\Storage` / `User\Storage` interfaces — identical except namespace +- `Post\TableStorage` / `User\TableStorage` — ~95% duplicated; differ only in filter column (`entity_id` vs `user_id`) and result-map keying +- `Post\MetaStorage` / `User\MetaStorage` — parallel; differ only in WP meta function (`get/update_post_meta` vs `get/update_user_meta`) +- `Post\StorageKey` / `User\StorageKey` — near-identical +- `Post\RawDataAssert` / `User\RawDataAssert` — diverge only because Post uses a nested `[[id, type]]` envelope that was never multi-element in practice + +Both `TableStorage` impls write to the same `{prefix}konomi_interactions` table. The duplication is a copy-paste artifact of two domains evolving in parallel, not a real shape divergence. The Post nested envelope was introduced for symmetry with the User shape, never for actual multi-item-per-user storage. Removing it eliminates the "first-item-per-user persisted" quirk codified in the existing spec. + +`MetaStorage` is no longer wired in any production container — both modules bind `Storage::class` to `TableStorage`. It exists only as legacy fallback kept "in case." A documented swap pattern via DI override serves users on hosts where custom tables are not viable, without dragging unused code through future refactors. + +## Scope + +### Added (`sources/Storage/`) + +- `Storage` interface, DTO-typed: `read(int $id, string $groupKey): list`, `write(int $id, string $groupKey, list $records): bool` +- `Record` readonly DTO: `entityId`, `userId`, `entityType` +- `Axis` enum: `Entity | User`; `column(): string` returns `"entity_id"` or `"user_id"` +- `TableStorage` implementing `Storage`; constructor takes `InteractionsTable` + `Axis`; validates rows at boundary (skips malformed); preserves transactional write +- `StorageKey` (moved from per-module copies), no `base` field, `for(ItemGroup): string` sanitizes and returns the group value + +### Changed + +- `Post\Repository` consumes `Storage`; `serializeData` emits flat `list` (one per `userId`); registry-based uniqueness replaces dedupe quirk +- `User\Repository` consumes `Storage`; `serializeData` emits flat `list` (one per `entityId`) +- `Post\Module` binds `Storage::class` → `TableStorage::new($table, Axis::Entity)` +- `User\Module` binds `Storage::class` → `TableStorage::new($table, Axis::User)` + +### Removed + +- `sources/Post/Storage.php`, `sources/Post/TableStorage.php`, `sources/Post/MetaStorage.php`, `sources/Post/RawDataAssert.php`, `sources/Post/StorageKey.php` +- `sources/User/Storage.php`, `sources/User/TableStorage.php`, `sources/User/MetaStorage.php`, `sources/User/RawDataAssert.php`, `sources/User/StorageKey.php` +- Post nested `[[id, type]]` in-memory envelope and the "first-item-per-user persisted" requirement (vestigial; never written nested to disk) + +### Documentation + +- `docs/storage-drivers.md`: documents `Storage` interface contract, provides `MetaStorage` reference impl (post + user variants) and a container-override snippet for swapping the bound `Storage::class` driver per environment + +## Non-goals + +- No DB schema migration. The on-disk row format is already flat per row; the nested envelope existed only in PHP memory. +- No change to repository semantics (find/save/all signatures, action hooks, registry snapshot/rollback flow). +- No new domains or new `Axis` cases; enum has exactly the two cases needed today. +- No change to `Database\InteractionsTable` or `Database\SchemaManager`. +- No new storage backends shipped in core. + +## Risks + +- Type-narrowing the `Storage` interface from `array` to `list` is a breaking change at the iface level. Mitigated by replacing all callers in this same change (only two: `Post\Repository`, `User\Repository`). +- Test fixtures using the nested `[[id, type]]` literal must be updated alongside. Mitigated by full test pass requirement (`composer cs`, `composer analysis`, `composer tests`) before merge. +- Registry snapshot/rollback invariant must be preserved through repo refactor. Mitigated by leaving `loadItems → snapshot → mutate → write → rollback-on-failure` flow structurally intact in both repos. + +## Part of + +Continuation of the Storage refactor sequence: +1. Extract Storage interface (archived 2026-05-08) +2. Custom table storage (archived 2026-05-08) +3. Simplify StorageKey (archived 2026-05-08) +4. Split TableStorage query/build (archived 2026-05-08) +5. **Extract shared Storage module** ← this change + +--- + +## Final Agreement (with artifacts) + +### Agreements + +1. **New shared module** `sources/Storage/` — persistence primitives only. +2. **DTO contract**: `Record { entityId, userId, entityType }` readonly. Replaces all `array` shapes. +3. **`groupKey`** = method param (scope), not on Record. +4. **`Axis` enum** (`Entity | User`) injected via `TableStorage` constructor. Repos axis-blind. `match` exhaustiveness enforces lockstep on new Axis cases. +5. **Repos stay module-specific.** `Post\Repository` + `User\Repository` keep intent, registry, save/find. Consume shared `Storage` iface. +6. **`StorageKey`** moves to shared as-is. No `base` field. Single class, ctor takes nothing, `for(ItemGroup)` returns sanitized group value. Prefix concerns are driver-local. +7. **`MetaStorage` dropped from code.** Documented in `docs/storage-drivers.md` as reference impl + container override how-to. +8. **Flatten Post in-memory shape** `[[id, type]]` → `[id, type]`. Vestigial nested envelope removed. +9. **Post quirk eliminated.** Map-keyed-by-userId guarantees uniqueness. No dedupe step. +10. **`RawDataAssert` deleted from both modules.** Validation collapses into `TableStorage` row mapping; malformed rows skipped at boundary. DTO list IS the validated contract. +11. **Registry snapshot/rollback preserved.** Repo-side `clone $registry` before mutation, `replace(snapshot)` on write failure. DB-side TX rollback in `TableStorage::write` is complementary. Refactor leaves this flow untouched — invariant to preserve. +12. **Per-module DI**: `Module::services()` binds `Storage::class` → `TableStorage::new($table, Axis::{Entity|User})`. +13. **Drop**: `Post\Storage`, `Post\TableStorage`, `Post\MetaStorage`, `Post\RawDataAssert`, `Post\StorageKey`, `User\Storage`, `User\TableStorage`, `User\MetaStorage`, `User\RawDataAssert`, `User\StorageKey`. + +### Structure + +``` +sources/ +├── Storage/ ← NEW (shared) +│ ├── Storage.php interface (DTO-typed) +│ ├── TableStorage.php impl, axis-bound; validates rows at boundary +│ ├── Record.php readonly DTO +│ ├── Axis.php enum: Entity | User; +column():string +│ └── StorageKey.php sanitize group → string; no base +├── Post/ +│ ├── Repository.php consumes Storage; flat serialization +│ ├── Module.php Storage::class → TableStorage(Axis::Entity) +│ ├── ItemRegistry.php +│ ├── ItemRegistryKey.php +│ └── Post.php +├── User/ +│ ├── Repository.php consumes Storage +│ ├── Module.php Storage::class → TableStorage(Axis::User) +│ ├── Item.php / ItemFactory.php / ItemGroup.php / ItemRegistry.php / User.php / ... +│ └── (no Storage / TableStorage / MetaStorage / RawDataAssert / StorageKey) +└── Database/ + └── InteractionsTable.php unchanged + +docs/ +└── storage-drivers.md ← NEW: MetaStorage example + DI override snippet +``` + +### Component diagram + +``` + ┌────────────────────────────────────────────────────┐ + │ sources/Storage/ │ + │ │ + │ ┌──────────────┐ ┌──────────────┐ │ + │ │ Storage │ │ Record │ │ + │ │ «interface» │ │ «DTO» │ │ + │ └──────▲───────┘ └──────────────┘ │ + │ │ implements │ + │ ┌──────┴────────┐ ┌──────────────┐ │ + │ │ TableStorage │──▶│ Axis «enum» │ │ + │ │ axis-bound │ │ Entity│User │ │ + │ │ validates │ └──────────────┘ │ + │ │ rows at │ ┌──────────────┐ │ + │ │ boundary │ │ StorageKey │ │ + │ └──────┬────────┘ └──────────────┘ │ + └─────────┼─────────────────────▲────────────────┬───┘ + │ │ │ + ┌──────────┴──────────┐ ┌──────┴───────────┐ │ + │ Post\Module │ │ User\Module │ │ + │ Storage::class → │ │ Storage::class → │ │ + │ TableStorage( │ │ TableStorage( │ │ + │ Axis::Entity) │ │ Axis::User) │ │ + └──────────┬──────────┘ └─────────┬────────┘ │ + │ │ │ + ┌──────────▼─────────┐ ┌──────────▼────────┐ │ + │ Post\Repository │ │ User\Repository │ │ + │ flat serialize │ │ flat serialize │ │ + │ snapshot+rollback │ │ snapshot+rollback │ │ + └────────────────────┘ └───────────────────┘ │ + │ + ┌─────────────────────────────────┘ + ▼ + ┌──────────────────┐ + │ InteractionsTable│ (wp_konomi_*) + └──────────────────┘ + + ┌──────────────────────────────────────────────┐ + │ docs/storage-drivers.md │ + │ - Storage interface contract │ + │ - MetaStorage reference impl (post + user) │ + │ - Container override snippet │ + └──────────────────────────────────────────────┘ +``` + +### UML class + +``` +┌────────────────────────────────────────────────┐ +│ «interface» Storage │ +├────────────────────────────────────────────────┤ +│ +read(id:int, groupKey:string): list │ +│ +write(id:int, groupKey:string, │ +│ records: list): bool │ +└──────────────────▲─────────────────────────────┘ + │ implements + ┌──────────┴────────────┐ + │ TableStorage │ + ├───────────────────────┤ + │ -table: InteractionsTable │ + │ -axis : Axis │ + ├───────────────────────┤ + │ +new(table, axis) │ + │ +read(id, groupKey) │ + │ +write(id, groupKey, │ + │ records) │ + │ -mapRow(row): ?Record │ ← boundary validation + └───────────────────────┘ + + ┌────────────────────┐ ┌──────────────────────┐ + │ «enum» Axis │ │ Record «readonly» │ + ├────────────────────┤ ├──────────────────────┤ + │ Entity │ │ +entityId : int │ + │ User │ │ +userId : int │ + ├────────────────────┤ │ +entityType: string │ + │ +column():string │ └──────────────────────┘ + └────────────────────┘ + + ┌────────────────────────┐ + │ StorageKey │ + ├────────────────────────┤ + │ +new(): self │ + │ +for(group): string │ sanitize + return + └────────────────────────┘ + + ┌──────────────────────────────┐ + │ Post\Repository │ + ├──────────────────────────────┤ + │ -storage : Storage │ TableStorage(Axis::Entity) + │ -storageKey: StorageKey │ + │ -registry : ItemRegistry │ + │ -itemFactory: User\ItemFactory│ + ├──────────────────────────────┤ + │ +find(entityId, group) │ + │ +save(item, user) │ + │ -serialize(): list │ flat; one Record per userId + └──────────────────────────────┘ + + ┌──────────────────────────────┐ + │ User\Repository │ + ├──────────────────────────────┤ + │ -storage : Storage │ TableStorage(Axis::User) + │ -storageKey: StorageKey │ + │ -registry : ItemRegistry │ + │ -itemFactory: ItemFactory │ + ├──────────────────────────────┤ + │ +find(user, id, group) │ + │ +all(user, group) │ + │ +save(user, item) │ + │ -serialize(): list │ + └──────────────────────────────┘ +``` + +### Read flow + +``` +Post\Repository::find(postId, group) + │ + │ key = storageKey.for(group) + ▼ +Storage::read(postId, key) ◀── TableStorage(Axis::Entity) + │ + ▼ +TableStorage::read(id, key) + │ col = axis.column() // "entity_id" + │ SELECT entity_id, user_id, entity_type + │ FROM %i WHERE {col}=%d AND group_key=%s + │ + │ for each row: + │ mapRow(row) → ?Record // null = malformed, skip + ▼ + list + │ + ▼ +Repository::loadItems + for each Record r: + item = factory.create(r.entityId, r.entityType, true, group) + item.isValid() && registry.set(postId, r.userId, item) + │ + ▼ + registry.all(postId, group) + + +User flow: same shape; TableStorage(Axis::User); col="user_id"; +repo keys registry by r.entityId. +``` + +### Write flow + +``` +Post\Repository::save(item, user) + │ + │ loadItems() // ensure hydrated + │ snapshot = clone registry + │ item.isActive() + │ ? registry.set(postId, user.id, item) + │ : registry.unset(postId, user.id, group) + │ + │ records = serialize(postId, group): + │ ┌─ for each registry.all(postId, group) as $userId => $item: + │ │ records[] = new Record(item.id, $userId, item.type) + │ └─ return records // list + ▼ +Storage::write(postId, key, records) ◀── TableStorage(Axis::Entity) + │ + ▼ +TableStorage::write(id, key, records) + │ col = axis.column() + │ BEGIN TX + │ DELETE FROM %i WHERE {col}=id AND group_key=key + │ on fail → ROLLBACK; return false + │ for each Record r: + │ force r.{axisField} = id // axis invariant + │ INSERT (entity_id, user_id, entity_type, group_key) + │ on fail → ROLLBACK; return false + │ COMMIT + ▼ + bool + │ + ▼ +Repository + true → fire `konomi.post.collection.save` (Post) + or `konomi.user.repository.save-successfully` (User) + false → registry.replace(snapshot) ← memory rollback + + +User-side write: same shape; TableStorage(Axis::User); +serialize foreach registry.all(user, group) as $item → Record(item.id, user.id, item.type). +``` diff --git a/openspec/changes/extract-shared-storage-module/specs/table-storage/spec.md b/openspec/changes/extract-shared-storage-module/specs/table-storage/spec.md new file mode 100644 index 0000000..7bde71a --- /dev/null +++ b/openspec/changes/extract-shared-storage-module/specs/table-storage/spec.md @@ -0,0 +1,153 @@ +## ADDED Requirements + +### Requirement: Shared Storage interface uses Record DTO +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Storage` interface with two methods: `read(int $id, string $groupKey): list` and `write(int $id, string $groupKey, array $records): bool`. The `$records` parameter SHALL be a `list`. The interface SHALL replace the per-module `Post\Storage` and `User\Storage` interfaces. + +#### Scenario: Read returns a list of Record DTOs +- **WHEN** `read($id, $groupKey)` is called on any `Storage` implementation +- **THEN** it SHALL return a `list`, where each `Record` exposes readonly `entityId`, `userId`, and `entityType` properties + +#### Scenario: Write accepts a list of Record DTOs +- **WHEN** `write($id, $groupKey, $records)` is called +- **THEN** the implementation SHALL persist exactly the rows described by the supplied `Record` list, scoped to the given `(id, groupKey)` + +### Requirement: Record DTO shape +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Record` readonly value object with three public readonly properties: `entityId: int`, `userId: int`, `entityType: string`. The class SHALL be `final`. + +#### Scenario: Construction +- **WHEN** `new Record($entityId, $userId, $entityType)` is invoked +- **THEN** the resulting instance SHALL expose those values via readonly public properties of the declared types + +### Requirement: Axis enum drives table-side filter column +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Axis` enum with cases `Entity` and `User`, and a method `column(): string` returning `"entity_id"` for `Entity` and `"user_id"` for `User`. Adding any new case SHALL force `match` updates at every consumption site. + +#### Scenario: Entity axis maps to entity_id +- **WHEN** `Axis::Entity->column()` is called +- **THEN** it SHALL return `"entity_id"` + +#### Scenario: User axis maps to user_id +- **WHEN** `Axis::User->column()` is called +- **THEN** it SHALL return `"user_id"` + +### Requirement: Shared TableStorage is axis-bound at construction +The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\TableStorage` class implementing the shared `Storage` interface, taking `Database\InteractionsTable` and `Axis` via constructor. The configured `Axis` SHALL determine which column is used as the filter for both `read` and `write`. The class SHALL replace `Post\TableStorage` and `User\TableStorage`. + +#### Scenario: Read filters by axis-resolved column +- **WHEN** `read($id, $groupKey)` is called on a `TableStorage` constructed with a given `Axis` +- **THEN** it SHALL execute `SELECT entity_id, user_id, entity_type FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` + +#### Scenario: Read with invalid id or empty key +- **WHEN** `read($id, $groupKey)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return an empty list without querying the database + +#### Scenario: Read maps rows to Record at boundary +- **WHEN** the underlying query returns rows +- **THEN** each row SHALL be passed through a `mapRow` step that returns either a `Record` for valid rows or `null` for malformed rows +- **AND** rows mapping to `null` SHALL be skipped from the returned list +- **AND** `entity_id`, `user_id` SHALL be cast to non-negative integers and `entity_type` to a non-empty string for a row to be considered valid + +#### Scenario: Write filters and replaces by axis-resolved column +- **WHEN** `write($id, $groupKey, $records)` is called +- **THEN** it SHALL execute `DELETE FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` followed by one `INSERT` per `Record`, all within a single database transaction + +#### Scenario: Write with invalid id or empty key +- **WHEN** `write($id, $groupKey, $records)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write enforces axis invariant on each Record +- **WHEN** `write($id, $groupKey, $records)` is called with a configured `Axis` +- **THEN** for each `Record`, the field corresponding to `Axis::column()` SHALL be set to `$id` before insertion (overriding any divergent value on the input `Record`) + +#### Scenario: Write empty list clears scope +- **WHEN** `write($id, $groupKey, [])` is called with valid `$id` and `$groupKey` +- **THEN** existing rows for that `(axis.column() = $id, group_key = $groupKey)` SHALL be deleted and the call SHALL return `true` + +#### Scenario: Write is transactional +- **WHEN** the DELETE or any INSERT fails during `write` +- **THEN** the transaction SHALL be rolled back and `write` SHALL return `false` + +### Requirement: Module wiring binds shared Storage with the module's Axis +`Post\Module` and `User\Module` SHALL bind `SpaghettiDojo\Konomi\Storage\Storage::class` in their `services()` definitions to a `TableStorage` instance constructed with the appropriate `Axis`. + +#### Scenario: Post module wires Axis::Entity +- **WHEN** the Post module registers services +- **THEN** `Storage\Storage::class` SHALL resolve to `Storage\TableStorage::new($interactionsTable, Storage\Axis::Entity)` + +#### Scenario: User module wires Axis::User +- **WHEN** the User module registers services +- **THEN** `Storage\Storage::class` SHALL resolve to `Storage\TableStorage::new($interactionsTable, Storage\Axis::User)` + +### Requirement: Repositories consume Record-typed Storage with flat serialization +`Post\Repository` and `User\Repository` SHALL consume the shared `Storage` interface, accepting `list` from `read()` and emitting `list` to `write()`. Both repositories SHALL build the records list by iterating their registry and instantiating one `Record` per registry entry. + +#### Scenario: Post repository serializes one Record per userId +- **WHEN** `Post\Repository::save($item, $user)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($item->id(), $userId, $item->type())` per `$userId` currently held in the registry for the post and group + +#### Scenario: User repository serializes one Record per entityId +- **WHEN** `User\Repository::save($user, $item)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($entityId, $user->id(), $entityType)` per item currently held in the registry for the user and group + +#### Scenario: Registry rollback on write failure +- **WHEN** `Storage::write()` returns `false` from a repository `save()` call +- **THEN** the in-memory registry SHALL be restored to the snapshot captured before the save mutation + +### Requirement: Shared StorageKey deduplication +The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\StorageKey` class that produces sanitized group strings. The class SHALL replace per-module `Post\StorageKey` and `User\StorageKey`. The constructor SHALL take no arguments. + +#### Scenario: Valid group +- **WHEN** `StorageKey::for($group)` is called with `ItemGroup` value `"reaction"` +- **THEN** it SHALL return `"reaction"` + +#### Scenario: Invalid characters +- **WHEN** `StorageKey::for($group)` is called with a value containing characters outside `[a-z0-9_]` +- **THEN** it SHALL throw `\UnexpectedValueException` + +#### Scenario: Empty group +- **WHEN** `StorageKey::for($group)` is called with an empty value +- **THEN** it SHALL throw `\InvalidArgumentException` + +#### Scenario: Construction takes no arguments +- **WHEN** `StorageKey::new()` is called +- **THEN** it SHALL accept no arguments and return a usable instance + +### Requirement: MetaStorage exists only as documented reference impl +The repository SHALL provide a `docs/storage-drivers.md` document that includes: a description of the `Storage` interface contract, a reference `MetaStorage` implementation showing how to back `Storage` by `wp_postmeta` and `wp_usermeta`, and a container-override snippet showing how to rebind `Storage\Storage::class` from a consumer plugin or site. No `MetaStorage` class SHALL exist in `sources/`. + +#### Scenario: Documentation present +- **WHEN** the repository is checked out +- **THEN** `docs/storage-drivers.md` SHALL exist and SHALL contain a reference `MetaStorage` example for both post and user backends along with a DI override snippet + +#### Scenario: No MetaStorage in sources +- **WHEN** the codebase is searched +- **THEN** no `MetaStorage` class SHALL exist under `sources/Post/`, `sources/User/`, or `sources/Storage/` + +## REMOVED Requirements + +### Requirement: Post TableStorage implements Post\Storage +**Reason**: `Post\TableStorage` is replaced by the single shared `Storage\TableStorage`, configured with `Axis::Entity` via constructor. The `Post\Storage` interface no longer exists; `Storage\Storage` is the shared interface. +**Migration**: Update `Post\Module::services()` to bind `Storage\Storage::class` to `Storage\TableStorage::new($table, Axis::Entity)`. Repository code that previously typed against `Post\Storage` SHALL type against `Storage\Storage`. + +### Requirement: Post TableStorage write persists only the first item per user +**Reason**: The vestigial `[[id, type]]` envelope is removed. The registry is keyed by `userId`, so uniqueness per user is already enforced before the records list is built. There is no longer a place where multiple items per user can be expressed in input. +**Migration**: No code change required; behavior of writing one row per user is preserved by virtue of the registry shape. + +### Requirement: Post TableStorage read returns one row per user +**Reason**: Reads return a `list` directly. Per-user collation is the repository's concern (it builds the registry keyed by `userId`); the storage layer no longer shapes results into a per-user map. +**Migration**: `Post\Repository::loadItems()` iterates the returned `list` and calls `registry.set($postId, $r->userId, $item)` once per record — last-write-wins for duplicates. + +### Requirement: User TableStorage implements User\Storage +**Reason**: `User\TableStorage` is replaced by the single shared `Storage\TableStorage`, configured with `Axis::User` via constructor. +**Migration**: Update `User\Module::services()` to bind `Storage\Storage::class` to `Storage\TableStorage::new($table, Axis::User)`. Repository code that previously typed against `User\Storage` SHALL type against `Storage\Storage`. + +### Requirement: Module wiring uses TableStorage +**Reason**: Replaced by the shared variant that wires `Storage\Storage::class` to `Storage\TableStorage` with the appropriate `Axis`. +**Migration**: See "Module wiring binds shared Storage with the module's Axis" above. + +### Requirement: StorageKey produces sanitized group +**Reason**: Replaced by the shared `Storage\StorageKey` (same behavior, deduplicated). Per-module `Post\StorageKey` and `User\StorageKey` are deleted. +**Migration**: Update consumers to import `SpaghettiDojo\Konomi\Storage\StorageKey`. + +### Requirement: MetaStorage owns the meta_key base prefix +**Reason**: `MetaStorage` is removed from production code and survives only as a documented reference implementation in `docs/storage-drivers.md`. The `_konomi_items` base is therefore an example concern, not a runtime requirement. +**Migration**: Sites that depend on the meta-backed driver SHALL register their own `MetaStorage`-style implementation via container override, copying the documented example. diff --git a/openspec/changes/extract-shared-storage-module/tasks.md b/openspec/changes/extract-shared-storage-module/tasks.md new file mode 100644 index 0000000..074805b --- /dev/null +++ b/openspec/changes/extract-shared-storage-module/tasks.md @@ -0,0 +1,13 @@ +# Tasks: Extract Shared Storage Module + +- [x] Create `sources/Storage/` namespace with `Storage` interface (DTO-typed), `Record` DTO, `Axis` enum (with `column()`), `StorageKey` (no base), and `TableStorage` (axis-bound, boundary validation in `mapRow`, transactional `write`). +- [x] Update `Post\Repository` to consume shared `Storage` + `Record` + `StorageKey`; flatten `serializeData` to emit `list` (one per `userId`); drop `RawDataAssert` import and any nested `[[id, type]]` references; preserve `loadItems → snapshot → mutate → write → rollback-on-failure` flow. +- [x] Update `User\Repository` to consume shared `Storage` + `Record` + `StorageKey`; emit `list` (one per `entityId`); drop `RawDataAssert` import. +- [x] Update `Post\Module::services()` to bind `Storage::class` → `TableStorage::new($table, Axis::Entity)`. +- [x] Update `User\Module::services()` to bind `Storage::class` → `TableStorage::new($table, Axis::User)`. +- [x] Delete `sources/Post/Storage.php`, `sources/Post/TableStorage.php`, `sources/Post/MetaStorage.php`, `sources/Post/RawDataAssert.php`, `sources/Post/StorageKey.php`. +- [x] Delete `sources/User/Storage.php`, `sources/User/TableStorage.php`, `sources/User/MetaStorage.php`, `sources/User/RawDataAssert.php`, `sources/User/StorageKey.php`. +- [x] Move and parameterize tests: create `tests/unit/php/Storage/TableStorageTest.php` (axis-parameterized), `tests/unit/php/Storage/StorageKeyTest.php`, `tests/unit/php/Storage/RecordTest.php`. Delete old per-module `TableStorageTest`, `StorageKeyTest`, `StorageTest` (MetaStorage) under `tests/unit/php/Post/` and `tests/unit/php/User/`. +- [x] Update integration tests `tests/integration/php/PostRepositoryTest.php` + `tests/integration/php/UserRepositoryTest.php`: flatten Post fixtures, use shared `Record` and `Storage` types, confirm registry rollback path. +- [x] Write `docs/storage-drivers.md`: document `Storage` interface contract; include reference `MetaStorage` impl for post + user backends; show container override snippet for swapping `Storage::class` binding. +- [x] Verify: run `composer cs`, `composer analysis`, `composer tests`. Confirm all pass. diff --git a/sources/Post/MetaStorage.php b/sources/Post/MetaStorage.php deleted file mode 100644 index 1c6333d..0000000 --- a/sources/Post/MetaStorage.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ - public function read(int $id, string $key): array - { - if ($id <= 0 || $key === '') { - return []; - } - - $data = get_post_meta($id, self::BASE . '.' . $key, true); - return is_array($data) ? $data : []; - } - - /** - * @param array $data - */ - public function write(int $id, string $key, array $data): bool - { - if ($id <= 0 || $key === '') { - return false; - } - - return (bool) update_post_meta($id, self::BASE . '.' . $key, $data); - } -} diff --git a/sources/Post/Module.php b/sources/Post/Module.php index 4e0c7f8..44e07dd 100644 --- a/sources/Post/Module.php +++ b/sources/Post/Module.php @@ -11,6 +11,7 @@ Module\ModuleClassNameIdTrait }; use SpaghettiDojo\Konomi\Database; +use SpaghettiDojo\Konomi\Storage; use SpaghettiDojo\Konomi\User; class Module implements ServiceModule, ExecutableModule @@ -32,25 +33,20 @@ public function services(): array Post::class => static fn (ContainerInterface $container) => Post::new( $container->get(Repository::class) ), - Storage::class => static fn ( - ContainerInterface $container - ) => TableStorage::new( - $container->get(Database\InteractionsTable::class) - ), ItemRegistryKey::class => static fn () => ItemRegistryKey::new(), ItemRegistry::class => static fn ( ContainerInterface $container ) => ItemRegistry::new( $container->get(ItemRegistryKey::class) ), - RawDataAssert::class => static fn () => RawDataAssert::new(), - StorageKey::class => static fn () => StorageKey::new(), Repository::class => static fn ( ContainerInterface $container ) => Repository::new( - $container->get(StorageKey::class), - $container->get(Storage::class), - $container->get(RawDataAssert::class), + Storage\StorageKey::new(), + Storage\TableStorage::new( + $container->get(Database\InteractionsTable::class), + Storage\Axis::Entity + ), $container->get(User\ItemFactory::class), $container->get(ItemRegistry::class) ), diff --git a/sources/Post/RawDataAssert.php b/sources/Post/RawDataAssert.php deleted file mode 100644 index efd19b0..0000000 --- a/sources/Post/RawDataAssert.php +++ /dev/null @@ -1,83 +0,0 @@ - - * @phpstan-type StoredData = array - * @phpstan-type GeneratorStoredData = \Generator - */ -class RawDataAssert -{ - public static function new(): RawDataAssert - { - return new self(); - } - - final private function __construct() - { - } - - /** - * @param array $data - * @return GeneratorStoredData - */ - public function ensureDataStructure(array $data): \Generator - { - foreach ($data as $userId => $rawItems) { - if (!self::isValidUserId($userId)) { - continue; - } - if (!self::areRawItemsValid($rawItems)) { - continue; - } - - yield $userId => $rawItems; - } - } - - /** - * @phpstan-assert-if-true UserId $userId - */ - private static function isValidUserId(mixed $userId): bool - { - return is_int($userId) && $userId > 0; - } - - /** - * @phpstan-assert-if-true RawItems $rawItems - * @param mixed $rawItems - */ - private static function areRawItemsValid(mixed $rawItems): bool - { - if (!is_array($rawItems)) { - return false; - } - if (!self::isValidRawItem($rawItems[0] ?? [])) { - return false; - } - - return true; - } - - /** - * @phpstan-assert-if-true RawItem $rawItem - */ - private static function isValidRawItem(mixed $rawItem): bool - { - return is_array($rawItem) - && count($rawItem) === 2 - && isset($rawItem[0], $rawItem[1]) - && is_int($rawItem[0]) - && $rawItem[0] > 0 - && is_string($rawItem[1]) - && $rawItem[1] !== ''; - } -} diff --git a/sources/Post/Repository.php b/sources/Post/Repository.php index 9578565..e38805f 100644 --- a/sources/Post/Repository.php +++ b/sources/Post/Repository.php @@ -4,41 +4,34 @@ namespace SpaghettiDojo\Konomi\Post; +use SpaghettiDojo\Konomi\Storage; use SpaghettiDojo\Konomi\User; /** * @internal - * - * @phpstan-import-type UserId from RawDataAssert - * @phpstan-import-type RawItem from RawDataAssert - * @phpstan-import-type RawItems from RawDataAssert - * @phpstan-import-type StoredData from RawDataAssert - * @phpstan-import-type GeneratorStoredData from RawDataAssert */ class Repository { public static function new( - StorageKey $key, - Storage $storage, - RawDataAssert $rawDataAsserter, + Storage\StorageKey $key, + Storage\Storage $storage, User\ItemFactory $itemFactory, ItemRegistry $registry ): Repository { - return new self($key, $storage, $rawDataAsserter, $itemFactory, $registry); + return new self($key, $storage, $itemFactory, $registry); } final private function __construct( - readonly private StorageKey $key, - readonly private Storage $storage, - readonly private RawDataAssert $rawDataAsserter, + readonly private Storage\StorageKey $key, + readonly private Storage\Storage $storage, readonly private User\ItemFactory $itemFactory, readonly private ItemRegistry $registry ) { } /** - * @return array + * @return array */ public function find(int $entityId, User\ItemGroup $group): array { @@ -54,14 +47,14 @@ public function save(User\Item $item, User\User $user): bool $this->loadItems($item->id(), $item->group()); $registrySnapshot = clone $this->registry; - $dataToStore = $this->prepareDataToStore($item, $user); + $records = $this->prepareDataToStore($item, $user); do_action('konomi.post.collection.save', $item, $user, $this->key); $stored = $this->storage->write( $item->id(), - "{$this->key->for($item->group())}", - $dataToStore + $this->key->for($item->group()), + $records ); $stored or $this->rollbackRegistry($registrySnapshot); @@ -75,7 +68,7 @@ private function canSave(User\User $user, User\Item $item): bool } /** - * @return StoredData + * @return list */ private function prepareDataToStore(User\Item $item, User\User $user): array { @@ -90,15 +83,15 @@ private function prepareDataToStore(User\Item $item, User\User $user): array } /** - * @return StoredData + * @return list */ private function serializeData(int $postId, User\ItemGroup $group): array { - $result = []; + $records = []; foreach ($this->registry->all($postId, $group) as $userId => $item) { - $result[$userId] = [[$item->id(), $item->type()]]; + $records[] = new Storage\Record($item->id(), (int) $userId, $item->type()); } - return $result; + return $records; } private function rollbackRegistry(ItemRegistry $registry): void @@ -112,29 +105,9 @@ private function loadItems(int $postId, User\ItemGroup $group): void return; } - foreach ($this->read($postId, $group) as $userId => $rawItems) { - $item = $this->unserialize($rawItems, $group); - $item->isValid() and $this->registry->set($postId, $userId, $item); + foreach ($this->storage->read($postId, $this->key->for($group)) as $record) { + $item = $this->itemFactory->create($record->entityId, $record->entityType, true, $group); + $item->isValid() and $this->registry->set($postId, $record->userId, $item); } } - - /** - * @return GeneratorStoredData - */ - private function read(int $entityId, User\ItemGroup $group): \Generator - { - $storedData = $this->storage->read($entityId, $this->key->for($group)); - yield from $this->rawDataAsserter->ensureDataStructure($storedData); - } - - /** - * @param RawItems $rawItems - * @return User\Item - */ - private function unserialize(array $rawItems, User\ItemGroup $group): User\Item - { - $id = (int) ($rawItems[0][0] ?? null); - $type = (string) ($rawItems[0][1] ?? null); - return $this->itemFactory->create($id, $type, true, $group); - } } diff --git a/sources/Post/Storage.php b/sources/Post/Storage.php deleted file mode 100644 index 043176f..0000000 --- a/sources/Post/Storage.php +++ /dev/null @@ -1,18 +0,0 @@ - - */ - public function read(int $id, string $key): array; - - /** - * @param array $data - */ - public function write(int $id, string $key, array $data): bool; -} diff --git a/sources/Post/TableStorage.php b/sources/Post/TableStorage.php deleted file mode 100644 index 822ac36..0000000 --- a/sources/Post/TableStorage.php +++ /dev/null @@ -1,171 +0,0 @@ -> - */ - public function read(int $id, string $key): array - { - if ($id <= 0 || $key === '') { - return []; - } - - return $this->mapRows($this->fetchRows($id, $key)); - } - - /** - * @param array> $data - */ - public function write(int $id, string $key, array $data): bool - { - if ($id <= 0 || $key === '') { - return false; - } - - return $this->persist($id, $key, $this->buildPayloads($data, $key)); - } - - /** - * @return array - */ - private function fetchRows(int $id, string $key): array - { - global $wpdb; - - $rows = $wpdb->get_results($wpdb->prepare( - // phpcs:ignore Inpsyde.CodeQuality.LineLength.TooLong - 'SELECT user_id, entity_id, entity_type FROM %i WHERE entity_id = %d AND group_key = %s', - $this->table->name(), - $id, - $key - ), ARRAY_A); - - return is_array($rows) ? $rows : []; - } - - /** - * @param array $rows - * @return array> - */ - private function mapRows(array $rows): array - { - $result = []; - foreach ($rows as $row) { - if (!is_array($row)) { - continue; - } - $rawUserId = $row['user_id'] ?? 0; - $rawEntityId = $row['entity_id'] ?? 0; - $rawEntityType = $row['entity_type'] ?? ''; - if (!is_scalar($rawUserId) || !is_scalar($rawEntityId) || !is_scalar($rawEntityType)) { - continue; - } - $userId = (int) $rawUserId; - $entityId = (int) $rawEntityId; - $entityType = (string) $rawEntityType; - $result[$userId] = [[$entityId, $entityType]]; - } - - return $result; - } - - /** - * @param array> $data - * @return list - */ - private function buildPayloads(array $data, string $key): array - { - $payloads = []; - foreach ($data as $userId => $rawItems) { - $userId = (int) $userId; - // Intentional: only the first item per user is persisted. - $first = $rawItems[0] ?? null; - if ($userId <= 0 || !is_array($first)) { - continue; - } - $entityId = (int) ($first[0] ?? 0); - $entityType = (string) ($first[1] ?? ''); - if ($entityId <= 0 || $entityType === '') { - continue; - } - $payloads[] = [ - 'entity_id' => $entityId, - 'user_id' => $userId, - 'entity_type' => $entityType, - 'group_key' => $key, - ]; - } - - return $payloads; - } - - /** - * @param list $payloads - */ - private function persist(int $id, string $key, array $payloads): bool - { - global $wpdb; - - $tableName = $this->table->name(); - $wpdb->query('START TRANSACTION'); - - $deleted = $wpdb->query( - $wpdb->prepare( - // phpcs:ignore Inpsyde.CodeQuality.LineLength.TooLong - 'DELETE FROM %i WHERE entity_id = %d AND group_key = %s', - $tableName, - $id, - $key - ) - ); - - if ($deleted === false) { - $wpdb->query('ROLLBACK'); - return false; - } - - foreach ($payloads as $payload) { - $inserted = $wpdb->insert( - $tableName, - $payload, - ['%d', '%d', '%s', '%s'] - ); - if ($inserted === false) { - $wpdb->query('ROLLBACK'); - return false; - } - } - - $wpdb->query('COMMIT'); - return true; - } -} diff --git a/sources/Storage/Axis.php b/sources/Storage/Axis.php new file mode 100644 index 0000000..83e1782 --- /dev/null +++ b/sources/Storage/Axis.php @@ -0,0 +1,23 @@ + 'entity_id', + self::User => 'user_id', + }; + } +} diff --git a/sources/Storage/Record.php b/sources/Storage/Record.php new file mode 100644 index 0000000..3596bb0 --- /dev/null +++ b/sources/Storage/Record.php @@ -0,0 +1,18 @@ + + */ + public function read(int $id, string $groupKey): array; + + /** + * @param list $records + */ + public function write(int $id, string $groupKey, array $records): bool; +} diff --git a/sources/Post/StorageKey.php b/sources/Storage/StorageKey.php similarity index 95% rename from sources/Post/StorageKey.php rename to sources/Storage/StorageKey.php index f7b4407..f7569f0 100644 --- a/sources/Post/StorageKey.php +++ b/sources/Storage/StorageKey.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SpaghettiDojo\Konomi\Post; +namespace SpaghettiDojo\Konomi\Storage; use SpaghettiDojo\Konomi\User; diff --git a/sources/Storage/TableStorage.php b/sources/Storage/TableStorage.php new file mode 100644 index 0000000..c28318f --- /dev/null +++ b/sources/Storage/TableStorage.php @@ -0,0 +1,140 @@ + + */ + public function read(int $id, string $groupKey): array + { + if ($id <= 0 || $groupKey === '') { + return []; + } + + global $wpdb; + + $column = $this->axis->column(); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, Inpsyde.CodeQuality.LineLength.TooLong + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT entity_id, user_id, entity_type FROM %i WHERE {$column} = %d AND group_key = %s", + $this->table->name(), + $id, + $groupKey + ), ARRAY_A); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, Inpsyde.CodeQuality.LineLength.TooLong + + if (!is_array($rows)) { + return []; + } + + $records = []; + foreach ($rows as $row) { + $record = is_array($row) ? $this->mapRow($row) : null; + if ($record !== null) { + $records[] = $record; + } + } + + return $records; + } + + /** + * @param list $records + */ + public function write(int $id, string $groupKey, array $records): bool + { + if ($id <= 0 || $groupKey === '') { + return false; + } + + global $wpdb; + + $tableName = $this->table->name(); + $column = $this->axis->column(); + + $wpdb->query('START TRANSACTION'); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $deleted = $wpdb->query( + $wpdb->prepare( + "DELETE FROM %i WHERE {$column} = %d AND group_key = %s", + $tableName, + $id, + $groupKey + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ($deleted === false) { + $wpdb->query('ROLLBACK'); + return false; + } + + foreach ($records as $record) { + $payload = [ + 'entity_id' => $this->axis === Axis::Entity ? $id : $record->entityId, + 'user_id' => $this->axis === Axis::User ? $id : $record->userId, + 'entity_type' => $record->entityType, + 'group_key' => $groupKey, + ]; + + $inserted = $wpdb->insert( + $tableName, + $payload, + ['%d', '%d', '%s', '%s'] + ); + + if ($inserted === false) { + $wpdb->query('ROLLBACK'); + return false; + } + } + + $wpdb->query('COMMIT'); + return true; + } + + /** + * @param array $row + */ + private function mapRow(array $row): ?Record + { + $rawEntityId = $row['entity_id'] ?? null; + $rawUserId = $row['user_id'] ?? null; + $rawEntityType = $row['entity_type'] ?? null; + + if (!is_scalar($rawEntityId) || !is_scalar($rawUserId) || !is_scalar($rawEntityType)) { + return null; + } + + $entityId = (int) $rawEntityId; + $userId = (int) $rawUserId; + $entityType = (string) $rawEntityType; + + if ($entityId < 0 || $userId < 0 || $entityType === '') { + return null; + } + + return new Record($entityId, $userId, $entityType); + } +} diff --git a/sources/User/MetaStorage.php b/sources/User/MetaStorage.php deleted file mode 100644 index ddecb57..0000000 --- a/sources/User/MetaStorage.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ - public function read(int $id, string $key): array - { - if ($id <= 0 || $key === '') { - return []; - } - - $data = get_user_meta($id, self::BASE . '.' . $key, true); - return is_array($data) ? $data : []; - } - - /** - * @param array $data - */ - public function write(int $id, string $key, array $data): bool - { - if ($id <= 0 || $key === '') { - return false; - } - - return (bool) update_user_meta($id, self::BASE . '.' . $key, $data); - } -} diff --git a/sources/User/Module.php b/sources/User/Module.php index b9d9f3b..dd68068 100644 --- a/sources/User/Module.php +++ b/sources/User/Module.php @@ -10,6 +10,7 @@ Module\ModuleClassNameIdTrait }; use SpaghettiDojo\Konomi\Database; +use SpaghettiDojo\Konomi\Storage; class Module implements ServiceModule { @@ -27,11 +28,6 @@ final private function __construct() public function services(): array { return [ - Storage::class => static fn ( - ContainerInterface $container - ) => TableStorage::new( - $container->get(Database\InteractionsTable::class) - ), UserFactory::class => static fn ( ContainerInterface $container ) => UserFactory::new( @@ -45,16 +41,16 @@ public function services(): array ) => ItemRegistry::new( $container->get(ItemRegistryKey::class) ), - RawDataAssert::class => static fn () => RawDataAssert::new(), - StorageKey::class => static fn () => StorageKey::new(), Repository::class => static fn ( ContainerInterface $container ) => Repository::new( - $container->get(StorageKey::class), - $container->get(Storage::class), + Storage\StorageKey::new(), + Storage\TableStorage::new( + $container->get(Database\InteractionsTable::class), + Storage\Axis::User + ), $container->get(ItemFactory::class), - $container->get(ItemRegistry::class), - $container->get(RawDataAssert::class) + $container->get(ItemRegistry::class) ), ]; } diff --git a/sources/User/RawDataAssert.php b/sources/User/RawDataAssert.php deleted file mode 100644 index 8644338..0000000 --- a/sources/User/RawDataAssert.php +++ /dev/null @@ -1,46 +0,0 @@ - $rawItems - * @return \Generator - */ - public function ensureDataStructure(array $rawItems): \Generator - { - foreach ($rawItems as $item) { - self::isValidRawItem($item) and yield $item; - } - } - - /** - * @phpstan-assert-if-true RawItem $rawItem - */ - private static function isValidRawItem(mixed $rawItem): bool - { - return is_array($rawItem) - && count($rawItem) === 2 - && isset($rawItem[0], $rawItem[1]) - && is_int($rawItem[0]) - && $rawItem[0] > 0 - && is_string($rawItem[1]) - && $rawItem[1] !== ''; - } -} diff --git a/sources/User/Repository.php b/sources/User/Repository.php index 6ebb9ff..bfd1cd8 100644 --- a/sources/User/Repository.php +++ b/sources/User/Repository.php @@ -4,33 +4,28 @@ namespace SpaghettiDojo\Konomi\User; +use SpaghettiDojo\Konomi\Storage; + /** * @internal - * - * @phpstan-type EntityId = int - * @phpstan-type EntityType = string - * @phpstan-type RawItem = array{0: EntityId, 1: EntityType} - * @phpstan-type RawItems = array */ class Repository { public static function new( - StorageKey $key, - Storage $storage, + Storage\StorageKey $key, + Storage\Storage $storage, ItemFactory $itemFactory, - ItemRegistry $registry, - RawDataAssert $rawDataAsserter + ItemRegistry $registry ): Repository { - return new self($key, $storage, $itemFactory, $registry, $rawDataAsserter); + return new self($key, $storage, $itemFactory, $registry); } final private function __construct( - readonly private StorageKey $storageKey, - readonly private Storage $storage, + readonly private Storage\StorageKey $storageKey, + readonly private Storage\Storage $storage, readonly private ItemFactory $itemFactory, readonly private ItemRegistry $registry, - readonly private RawDataAssert $rawDataAsserter ) { } @@ -65,12 +60,12 @@ public function save(User $user, Item $item): bool $this->loadItems($user, $item->group()); $registrySnapshot = clone $this->registry; - $dataToStore = $this->prepareDataToStore($user, $item); + $records = $this->prepareDataToStore($user, $item); $stored = $this->storage->write( $user->id(), $this->storageKey->for($item->group()), - $dataToStore + $records ); $stored @@ -92,7 +87,7 @@ private function canSave(User $user, Item $item): bool } /** - * @return RawItems + * @return list */ private function prepareDataToStore(User $user, Item $item): array { @@ -104,14 +99,15 @@ private function prepareDataToStore(User $user, Item $item): array } /** - * @return RawItems + * @return list */ private function serializeData(User $user, ItemGroup $group): array { - return \array_map( - static fn (Item $item) => [$item->id(), $item->type()], - $this->registry->all($user, $group) - ); + $records = []; + foreach ($this->registry->all($user, $group) as $item) { + $records[] = new Storage\Record($item->id(), $user->id(), $item->type()); + } + return $records; } private function rollbackRegistry(ItemRegistry $registry): void @@ -125,23 +121,14 @@ private function loadItems(User $user, ItemGroup $group): void return; } - foreach ($this->read($user, $group) as [$entityId, $entityType]) { + foreach ($this->storage->read($user->id(), $this->storageKey->for($group)) as $record) { $item = $this->itemFactory->create( - $entityId, - $entityType, + $record->entityId, + $record->entityType, true, $group ); $item->isValid() and $this->registry->set($user, $item); } } - - /** - * @return \Generator - */ - private function read(User $user, ItemGroup $group): \Generator - { - $storedData = $this->storage->read($user->id(), $this->storageKey->for($group)); - yield from $this->rawDataAsserter->ensureDataStructure($storedData); - } } diff --git a/sources/User/Storage.php b/sources/User/Storage.php deleted file mode 100644 index eeb8568..0000000 --- a/sources/User/Storage.php +++ /dev/null @@ -1,18 +0,0 @@ - - */ - public function read(int $id, string $key): array; - - /** - * @param array $data - */ - public function write(int $id, string $key, array $data): bool; -} diff --git a/sources/User/StorageKey.php b/sources/User/StorageKey.php deleted file mode 100644 index da90722..0000000 --- a/sources/User/StorageKey.php +++ /dev/null @@ -1,38 +0,0 @@ -value; - - if ($value === '') { - throw new \InvalidArgumentException('Group value cannot be empty'); - } - - $sanitized = preg_replace('/[^a-z0-9_]/', '', $value); - - if ($sanitized !== $value) { - throw new \UnexpectedValueException('Group value contains invalid characters'); - } - - return $sanitized; - } -} diff --git a/sources/User/TableStorage.php b/sources/User/TableStorage.php deleted file mode 100644 index b9552f8..0000000 --- a/sources/User/TableStorage.php +++ /dev/null @@ -1,160 +0,0 @@ - - */ - public function read(int $id, string $key): array - { - if ($id <= 0 || $key === '') { - return []; - } - - return $this->mapRows($this->fetchRows($id, $key)); - } - - /** - * @param array $data - */ - public function write(int $id, string $key, array $data): bool - { - if ($id <= 0 || $key === '') { - return false; - } - - return $this->persist($id, $key, $this->buildPayloads($data)); - } - - /** - * @return array - */ - private function fetchRows(int $id, string $key): array - { - global $wpdb; - - $rows = $wpdb->get_results($wpdb->prepare( - 'SELECT entity_id, entity_type FROM %i WHERE user_id = %d AND group_key = %s', - $this->table->name(), - $id, - $key - ), ARRAY_A); - - return is_array($rows) ? $rows : []; - } - - /** - * @param array $rows - * @return array - */ - private function mapRows(array $rows): array - { - $result = []; - foreach ($rows as $row) { - if (!is_array($row)) { - continue; - } - $rawEntityId = $row['entity_id'] ?? 0; - $rawEntityType = $row['entity_type'] ?? ''; - if (!is_scalar($rawEntityId) || !is_scalar($rawEntityType)) { - continue; - } - $entityId = (int) $rawEntityId; - $entityType = (string) $rawEntityType; - if ($entityId <= 0 || $entityType === '') { - continue; - } - $result[$entityId] = [$entityId, $entityType]; - } - - return $result; - } - - /** - * @param array $data - * @return list - */ - private function buildPayloads(array $data): array - { - $payloads = []; - foreach ($data as $rawItem) { - if (!is_array($rawItem)) { - continue; - } - $entityId = (int) ($rawItem[0] ?? 0); - $entityType = (string) ($rawItem[1] ?? ''); - if ($entityId <= 0 || $entityType === '') { - continue; - } - $payloads[] = [ - 'entity_id' => $entityId, - 'entity_type' => $entityType, - ]; - } - - return $payloads; - } - - /** - * @param list $payloads - */ - private function persist(int $id, string $key, array $payloads): bool - { - global $wpdb; - - $tableName = $this->table->name(); - $wpdb->query('START TRANSACTION'); - - $deleted = $wpdb->query( - $wpdb->prepare( - 'DELETE FROM %i WHERE user_id = %d AND group_key = %s', - $tableName, - $id, - $key - ) - ); - - if ($deleted === false) { - $wpdb->query('ROLLBACK'); - return false; - } - - foreach ($payloads as $payload) { - $inserted = $wpdb->insert( - $tableName, - [ - 'entity_id' => $payload['entity_id'], - 'user_id' => $id, - 'entity_type' => $payload['entity_type'], - 'group_key' => $key, - ], - ['%d', '%d', '%s', '%s'] - ); - if ($inserted === false) { - $wpdb->query('ROLLBACK'); - return false; - } - } - - $wpdb->query('COMMIT'); - return true; - } -} diff --git a/tests/helpers/InMemoryStorage.php b/tests/helpers/InMemoryStorage.php new file mode 100644 index 0000000..e77bfe8 --- /dev/null +++ b/tests/helpers/InMemoryStorage.php @@ -0,0 +1,79 @@ +> */ + private array $data = []; + + private bool $writeFails = false; + + public int $reads = 0; + + public int $writes = 0; + + public static function new(): InMemoryStorage + { + return new self(); + } + + public function read(int $id, string $groupKey): array + { + if ($id <= 0 || $groupKey === '') { + return []; + } + $this->reads++; + return $this->data[self::keyFor($id, $groupKey)] ?? []; + } + + public function write(int $id, string $groupKey, array $records): bool + { + if ($id <= 0 || $groupKey === '') { + return false; + } + if ($this->writeFails) { + return false; + } + $this->writes++; + $this->data[self::keyFor($id, $groupKey)] = array_values($records); + return true; + } + + public function failWrites(bool $fail = true): void + { + $this->writeFails = $fail; + } + + /** + * @param list $records + */ + public function seed(int $id, User\ItemGroup $groupKey, array $records): void + { + $this->data[self::keyFor($id, $groupKey->value)] = $records; + } + + /** + * @return list + */ + public function get(int $id, string $groupKey): array + { + return $this->data[self::keyFor($id, $groupKey)] ?? []; + } + + public function has(int $id, string $groupKey): bool + { + return isset($this->data[self::keyFor($id, $groupKey)]); + } + + private static function keyFor(int $id, string $groupKey): string + { + return $id . '|' . $groupKey; + } +} diff --git a/tests/helpers/integration.php b/tests/helpers/integration.php index acabbfc..1daac16 100644 --- a/tests/helpers/integration.php +++ b/tests/helpers/integration.php @@ -4,65 +4,26 @@ require_once __DIR__ . '/functions.php'; -function includeValidPostUserLikes(): array -{ - return include stubsDirectory() . '/php/valid-post-user-reactions.php'; -} - -function includeValidUsersLikes(): array -{ - return include stubsDirectory() . '/php/valid-users-reactions.php'; -} +use SpaghettiDojo\Konomi\Storage; +use SpaghettiDojo\Konomi\Tests\Helpers; +use SpaghettiDojo\Konomi\User; /** - * @param array> $data - * @return array{0: callable, 1: callable, 2: callable, 3: callable} + * @param array $rows other-axis id => entity type */ -function setupIntegrationUserMetaStorage(array &$data): array -{ - $stubsCounter = [ - 'get_user_meta' => 0, - 'update_user_meta' => 0, - ]; - - return [ - static function () use (&$stubsCounter): array { - return $stubsCounter; - }, - static function (int $entityId, string $key) use (&$data, &$stubsCounter): array { - $stubsCounter['get_user_meta']++; - return (array)($data[$entityId][$key] ?? null); - }, - static function (int $entityId, string $key, array $newData) use (&$data, &$stubsCounter): bool { - $stubsCounter['update_user_meta']++; - $data[$entityId][$key] = $newData; - return true; - }, - ]; -} - -/** - * @return array{0: callable, 1: callable, 2: callable, 3: callable} - */ -function setupIntegrationPostMetaStorage(array &$data): array -{ - $stubsCounter = [ - 'get_post_meta' => 0, - 'update_post_meta' => 0, - ]; - - return [ - static function () use (&$stubsCounter): array { - return $stubsCounter; - }, - static function (int $entityId, string $key, bool $single) use (&$data, &$stubsCounter): array { - $stubsCounter['get_post_meta']++; - return (array)($data[$entityId][$key] ?? null); - }, - static function (int $entityId, string $key, array $newData) use (&$data, &$stubsCounter): bool { - $stubsCounter['update_post_meta']++; - $data[$entityId][$key] = $newData; - return true; - }, - ]; +function seedRecords( + Helpers\InMemoryStorage $storage, + int $id, + User\ItemGroup $group, + array $rows, + Storage\Axis $axis = Storage\Axis::User +): void { + + $records = []; + foreach ($rows as $otherId => $type) { + $records[] = $axis === Storage\Axis::User + ? new Storage\Record($otherId, $id, $type) + : new Storage\Record($id, $otherId, $type); + } + $storage->seed($id, $group, $records); } diff --git a/tests/integration/php/PostRepositoryTest.php b/tests/integration/php/PostRepositoryTest.php index 7a705a1..08f14fa 100644 --- a/tests/integration/php/PostRepositoryTest.php +++ b/tests/integration/php/PostRepositoryTest.php @@ -6,34 +6,45 @@ use Brain\Monkey\Functions; use SpaghettiDojo\Konomi\Post; +use SpaghettiDojo\Konomi\Storage; +use SpaghettiDojo\Konomi\Tests\Helpers; use SpaghettiDojo\Konomi\User; beforeEach(function (): void { $this->wpUser = \Mockery::mock('\WP_User'); $this->wpUser->ID = 34; - $this->postMetaStorage = includeValidPostUserLikes(); - [, $getter, $setter] = setupIntegrationPostMetaStorage($this->postMetaStorage); - - Functions\when('get_post_meta')->alias($getter); - Functions\when('update_post_meta')->alias($setter); Functions\when('wp_get_current_user')->justReturn($this->wpUser); + $this->postStorage = Helpers\InMemoryStorage::new(); + $this->userStorage = Helpers\InMemoryStorage::new(); + + seedRecords($this->postStorage, 10, User\ItemGroup::REACTION, [ + 100 => 'post', + 21 => 'product', + 33 => 'video', + 45 => 'page', + 53 => 'post', + 6 => 'post', + 79 => 'page', + 83 => 'page', + 92 => 'post', + 1000 => 'post', + ], Storage\Axis::Entity); + $itemRegistryKey = User\ItemRegistryKey::new(); $this->currentUser = User\CurrentUser::new( User\Repository::new( - User\StorageKey::new(), - User\MetaStorage::new(), + Storage\StorageKey::new(), + $this->userStorage, User\ItemFactory::new(), - User\ItemRegistry::new($itemRegistryKey), - User\RawDataAssert::new() + User\ItemRegistry::new($itemRegistryKey) ) ); $postItemRegistryKey = Post\ItemRegistryKey::new(); $this->repository = Post\Repository::new( - Post\StorageKey::new(), - Post\MetaStorage::new(), - Post\RawDataAssert::new(), + Storage\StorageKey::new(), + $this->postStorage, User\ItemFactory::new(), Post\ItemRegistry::new($postItemRegistryKey) ); @@ -43,14 +54,10 @@ it('find items from post repository', function (): void { $items = $this->repository->find(10, User\ItemGroup::REACTION); - expect($items) - ->toBeArray() - ->and(count($items))->toBe(10); + expect($items)->toBeArray()->and(count($items))->toBe(10); foreach ($items as $userId => $item) { - expect($userId) - ->toBeInt() - ->and($item instanceof User\Item)->toBeTrue(); + expect($userId)->toBeInt()->and($item instanceof User\Item)->toBeTrue(); } }); @@ -78,17 +85,17 @@ it('save items to post repository', function (): void { $itemToStore = User\Item::new(1, 'type', true); $result = $this->repository->save($itemToStore, $this->currentUser); - $storedItem = User\Item::new( - $this->postMetaStorage[1]['_konomi_items.reaction'][$this->wpUser->ID][0][0], - $this->postMetaStorage[1]['_konomi_items.reaction'][$this->wpUser->ID][0][1], - $itemToStore->isActive() - ); + + $stored = $this->postStorage->get(1, 'reaction'); + $matching = array_values(array_filter( + $stored, + fn (Storage\Record $record) => $record->userId === $this->wpUser->ID + )); expect($result)->toBeTrue() - ->and($storedItem->id())->toEqual($itemToStore->id()) - ->and($storedItem->type())->toEqual($itemToStore->type()) - ->and($storedItem->isActive())->toEqual($itemToStore->isActive()) - ->and($storedItem->isValid())->toBeTrue(); + ->and($matching)->toHaveCount(1) + ->and($matching[0]->entityId)->toBe(1) + ->and($matching[0]->entityType)->toBe('type'); }); it('override existing item in post repository', function (): void { @@ -98,10 +105,13 @@ $itemToStore = User\Item::new(1, 'type', false); $result = $this->repository->save($itemToStore, $this->currentUser); - expect($result) - ->toBeTrue() - ->and(isset($this->postMetaStorage[1]['_konomi_items.reaction'][$this->wpUser->ID])) - ->toBeFalse(); + $matching = array_filter( + $this->postStorage->get(1, 'reaction'), + fn (Storage\Record $record) => $record->userId === $this->wpUser->ID + ); + + expect($result)->toBeTrue() + ->and($matching)->toBeEmpty(); }); it('do not store invalid items', function (): void { @@ -109,14 +119,12 @@ $this->repository->save($itemToStore, $this->currentUser); $result = $this->repository->save($itemToStore, $this->currentUser); - expect($result) - ->toBeFalse() - ->and($this->postMetaStorage[-1]['_konomi_items.reaction'][$this->wpUser->ID] ?? []) - ->toBeEmpty(); + expect($result)->toBeFalse() + ->and($this->postStorage->has(-1, 'reaction'))->toBeFalse(); }); it('rollback registry when storage write fails for a new active item', function (): void { - Functions\when('update_post_meta')->justReturn(false); + $this->postStorage->failWrites(); $itemToStore = User\Item::new(50, 'post', true); @@ -137,7 +145,7 @@ $found = $this->repository->find(10, User\ItemGroup::REACTION); expect($found[$this->wpUser->ID]->isActive())->toBeTrue(); - Functions\when('update_post_meta')->justReturn(false); + $this->postStorage->failWrites(); $inactiveItem = User\Item::new(10, 'post', false); $result = $this->repository->save($inactiveItem, $this->currentUser); @@ -151,7 +159,7 @@ $find = $this->repository->find(10, User\ItemGroup::REACTION); expect($find)->toHaveCount(10); - Functions\when('update_post_meta')->justReturn(false); + $this->postStorage->failWrites(); $itemToStore = User\Item::new(10, 'post', false); $afterSaving = $this->repository->save($itemToStore, $this->currentUser); diff --git a/tests/integration/php/UserRepositoryTest.php b/tests/integration/php/UserRepositoryTest.php index ae858eb..2a6e570 100644 --- a/tests/integration/php/UserRepositoryTest.php +++ b/tests/integration/php/UserRepositoryTest.php @@ -6,6 +6,8 @@ use Brain\Monkey\Actions; use Brain\Monkey\Functions; +use SpaghettiDojo\Konomi\Storage; +use SpaghettiDojo\Konomi\Tests\Helpers; use SpaghettiDojo\Konomi\User; beforeAll(function (): void { @@ -15,20 +17,26 @@ }); beforeEach(function (): void { - $this->userMetaStorage = includeValidUsersLikes(); - [$stubsCounter, $getter, $setter] = setupIntegrationUserMetaStorage($this->userMetaStorage); - $this->stubsCounter = $stubsCounter; - - Functions\when('get_user_meta')->alias($getter); - Functions\when('update_user_meta')->alias($setter); + $this->storage = Helpers\InMemoryStorage::new(); + seedRecords($this->storage, 1, User\ItemGroup::REACTION, [ + 1 => 'product', + 2 => 'page', + ]); + seedRecords($this->storage, 2, User\ItemGroup::REACTION, [ + 100 => 'product', + 20 => 'page', + ]); + seedRecords($this->storage, 3, User\ItemGroup::REACTION, [ + 11 => 'page', + 2 => 'post', + ]); $itemRegistryKey = User\ItemRegistryKey::new(); $this->repository = User\Repository::new( - User\StorageKey::new(), - User\MetaStorage::new(), + Storage\StorageKey::new(), + $this->storage, User\ItemFactory::new(), - User\ItemRegistry::new($itemRegistryKey), - User\RawDataAssert::new() + User\ItemRegistry::new($itemRegistryKey) ); }); @@ -46,24 +54,7 @@ $this->repository->find($user, 2, User\ItemGroup::REACTION); $this->repository->find($user, 2, User\ItemGroup::REACTION); - expect(($this->stubsCounter)()['get_user_meta'])->toBe(1); - }); - - it('skip invalid stored items when loading', function (): void { - $this->userMetaStorage[1]['_konomi_items.reaction'] = [ - 1 => [1, 'product'], - 2 => 'invalid', - 3 => [3, 'page', 'extra'], - 4 => ['not_int', 'post'], - 5 => [5, 123], // the type must be string - ]; - - $user = User\CurrentUser::new($this->repository); - $items = $this->repository->find($user, 1, User\ItemGroup::REACTION); - - // Only the first valid item should be loaded - expect($items->id())->toBe(1); - expect($items->type())->toBe('product'); + expect($this->storage->reads)->toBe(1); }); it('cannot save an invalid item', function (): void { @@ -75,7 +66,7 @@ $result = $this->repository->save($user, $invalidItem); expect($result)->toBeFalse(); - expect(($this->stubsCounter)()['update_user_meta'])->toBe(0); + expect($this->storage->writes)->toBe(0); }); it('save a valid item', function (): void { @@ -84,9 +75,15 @@ $result = $this->repository->save($user, $item); + $stored = $this->storage->get(1, 'bookmark'); + $matching = array_values(array_filter( + $stored, + fn (Storage\Record $record) => $record->entityId === 1 + )); + expect($result)->toBeTrue(); - expect(($this->stubsCounter)()['update_user_meta'])->toBe(1); - expect($this->userMetaStorage[1]['_konomi_items.bookmark'][1])->toBe([1, 'product']); + expect($this->storage->writes)->toBe(1); + expect($matching)->toHaveCount(1)->and($matching[0]->entityType)->toBe('product'); expect(did_action('konomi.user.repository.save-successfully'))->toBe(1); }); @@ -94,87 +91,71 @@ $user = User\CurrentUser::new($this->repository); $inactiveItem = User\Item::new(1, 'product', false); - expect($this->userMetaStorage[1]['_konomi_items.reaction'])->toEqual([ - 1 => [1, 'product'], - 2 => [2, 'page'], - ]); + $beforeIds = array_map(fn (Storage\Record $record) => $record->entityId, $this->storage->get(1, 'reaction')); + expect($beforeIds)->toBe([1, 2]); $this->repository->save($user, $inactiveItem); - expect($this->userMetaStorage[1]['_konomi_items.reaction'])->toEqual([ - 2 => [2, 'page'], - ]); + $afterIds = array_map(fn (Storage\Record $record) => $record->entityId, $this->storage->get(1, 'reaction')); + expect($afterIds)->toBe([2]); }); it('rollback registry when storage write fails for a new active item', function (): void { $user = User\CurrentUser::new($this->repository); $item = User\Item::new(3, 'post', true, User\ItemGroup::BOOKMARK); - // Make update_user_meta return false to simulate storage write failure - Functions\when('update_user_meta')->justReturn(false); + $this->storage->failWrites(); - // Verify an item is not in the registry before save $foundBefore = $this->repository->find($user, 3, User\ItemGroup::BOOKMARK); - expect($foundBefore->id())->toBe(0); // null item + expect($foundBefore->id())->toBe(0); - // Attempt to save (will fail) $result = $this->repository->save($user, $item); expect($result)->toBeFalse(); - // Verify registry was rolled back - item should not be in the registry $foundAfter = $this->repository->find($user, 3, User\ItemGroup::BOOKMARK); - expect($foundAfter->id())->toBe(0); // null item - rollback successful + expect($foundAfter->id())->toBe(0); }); it('rollback registry when storage write fails for inactive item', function (): void { $user = User\CurrentUser::new($this->repository); $inactiveItem = User\Item::new(1, 'product', false); - // Verify item is in registry before save $foundBefore = $this->repository->find($user, 1, User\ItemGroup::REACTION); expect($foundBefore->id())->toBe(1); - // Make update_user_meta return false to simulate storage write failure - Functions\when('update_user_meta')->justReturn(false); + $this->storage->failWrites(); - // Attempt to save inactive item (will fail) $result = $this->repository->save($user, $inactiveItem); expect($result)->toBeFalse(); - // Verify registry was rolled back - item should still be in the registry as active $foundAfter = $this->repository->find($user, 1, User\ItemGroup::REACTION); - expect($foundAfter->id())->toBe(1); // item restored - rollback successful - expect($foundAfter->isActive())->toBeTrue(); // restored as active + expect($foundAfter->id())->toBe(1); + expect($foundAfter->isActive())->toBeTrue(); }); it('replace method restores entire registry state on rollback with multiple items', function (): void { $user = User\CurrentUser::new($this->repository); - // Load initial items from storage (items 1 and 2) $itemsBefore = $this->repository->all($user, User\ItemGroup::REACTION); expect($itemsBefore)->toHaveCount(2); expect($itemsBefore[1]->id())->toBe(1); expect($itemsBefore[2]->id())->toBe(2); - // Make update_user_meta return false to simulate storage write failure - Functions\when('update_user_meta')->justReturn(false); + $this->storage->failWrites(); - // Attempt to save a new item (will fail) $newItem = User\Item::new(3, 'post', true); $result = $this->repository->save($user, $newItem); expect($result)->toBeFalse(); - // Verify replace method restored the entire registry - all original items should be present $itemsAfter = $this->repository->all($user, User\ItemGroup::REACTION); - expect($itemsAfter)->toHaveCount(2); // Should have same count as before - expect($itemsAfter[1]->id())->toBe(1); // Original item 1 still present - expect($itemsAfter[2]->id())->toBe(2); // Original item 2 still present - expect(isset($itemsAfter[3]))->toBeFalse(); // New item 3 should not be present + expect($itemsAfter)->toHaveCount(2); + expect($itemsAfter[1]->id())->toBe(1); + expect($itemsAfter[2]->id())->toBe(2); + expect(isset($itemsAfter[3]))->toBeFalse(); - // Verify individual items can still be found $found1 = $this->repository->find($user, 1, User\ItemGroup::REACTION); expect($found1->id())->toBe(1); expect($found1->isActive())->toBeTrue(); @@ -184,6 +165,6 @@ expect($found2->isActive())->toBeTrue(); $found3 = $this->repository->find($user, 3, User\ItemGroup::REACTION); - expect($found3->id())->toBe(0); // Should be null item - not in registry + expect($found3->id())->toBe(0); }); }); diff --git a/tests/stubs/php/valid-post-user-reactions.php b/tests/stubs/php/valid-post-user-reactions.php deleted file mode 100644 index b88815c..0000000 --- a/tests/stubs/php/valid-post-user-reactions.php +++ /dev/null @@ -1,40 +0,0 @@ - [ - '_konomi_items.reaction' => [ - 100 => [ - [10, 'post'], - ], - 21 => [ - [10, 'product'], - ], - 33 => [ - [10, 'video'], - ], - 45 => [ - [10, 'page'], - ], - 53 => [ - [10, 'post'], - ], - 6 => [ - [10, 'post'], - ], - 79 => [ - [10, 'page'], - ], - 83 => [ - [10, 'page'], - ], - 92 => [ - [10, 'post'], - ], - 1000 => [ - [10, 'post'], - ], - ], - ], -]; diff --git a/tests/stubs/php/valid-users-reactions.php b/tests/stubs/php/valid-users-reactions.php deleted file mode 100644 index 1a69f63..0000000 --- a/tests/stubs/php/valid-users-reactions.php +++ /dev/null @@ -1,24 +0,0 @@ - [ - '_konomi_items.reaction' => [ - 1 => [1, 'product'], - 2 => [2, 'page'], - ], - ], - 2 => [ - '_konomi_items.reaction' => [ - 100 => [100, 'product'], - 20 => [20, 'page'], - ], - ], - 3 => [ - '_konomi_items.reaction' => [ - 11 => [11, 'page'], - 2 => [2, 'post'], - ], - ], -]; diff --git a/tests/unit/php/Post/RawDataAssertTest.php b/tests/unit/php/Post/RawDataAssertTest.php deleted file mode 100644 index 5f9a278..0000000 --- a/tests/unit/php/Post/RawDataAssertTest.php +++ /dev/null @@ -1,71 +0,0 @@ -rawDataAsserter = RawDataAssert::new(); -}); - -describe('ensureDataStructure', function (): void { - it('validates correct data structure', function (): void { - $data = [ - 1 => [[1, 'post']], - 2 => [[2, 'page'], [3, 'post']], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe($data); - }); - - it('filters out invalid user IDs', function (): void { - $data = [ - 0 => [[1, 'post']], - -1 => [[2, 'page']], - 'string' => [[3, 'post']], - 1 => [[4, 'page']], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe([1 => [[4, 'page']]]); - }); - - it('filters out invalid raw items structure', function (): void { - $data = [ - 1 => 'not an array', - 2 => [], - 3 => [[1, 'post']], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe([3 => [[1, 'post']]]); - }); - - it('filters out invalid item format', function (): void { - $data = [ - 1 => [[0, 'post']], - 2 => [['1', 'post']], - 3 => [[1, '']], - 4 => [[1, 123]], - 5 => [[1]], - 6 => [[1, 'post', 'extra']], - 7 => [[1, 'post']], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe([7 => [[1, 'post']]]); - }); - - it('handles empty input array', function (): void { - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure([])); - - expect($result)->toBe([]); - }); -}); diff --git a/tests/unit/php/Post/StorageKeyTest.php b/tests/unit/php/Post/StorageKeyTest.php deleted file mode 100644 index 6c2e2a1..0000000 --- a/tests/unit/php/Post/StorageKeyTest.php +++ /dev/null @@ -1,24 +0,0 @@ -toBeInstanceOf(StorageKey::class); - }); -}); - -describe('for', function (): void { - it('returns the sanitized group value for REACTION', function (): void { - expect(StorageKey::new()->for(ItemGroup::REACTION))->toBe('reaction'); - }); - - it('returns the sanitized group value for BOOKMARK', function (): void { - expect(StorageKey::new()->for(ItemGroup::BOOKMARK))->toBe('bookmark'); - }); -}); diff --git a/tests/unit/php/Post/StorageTest.php b/tests/unit/php/Post/StorageTest.php deleted file mode 100644 index cb81515..0000000 --- a/tests/unit/php/Post/StorageTest.php +++ /dev/null @@ -1,56 +0,0 @@ -once()->with(1, '_konomi_items.reaction', true)->andReturn(['data']); - $data = $storage->read(1, 'reaction'); - expect($data)->toBe(['data']); - }); - - it('write data to the storage', function (): void { - $storage = MetaStorage::new(); - Functions\expect('update_post_meta')->once()->with(1, '_konomi_items.reaction', ['data'])->andReturn(true); - $result = $storage->write(1, 'reaction', ['data']); - expect($result)->toBeTrue(); - }); - - it('read returns empty array when id is invalid', function (): void { - $storage = MetaStorage::new(); - Functions\expect('get_post_meta')->never(); - expect($storage->read(0, 'reaction'))->toBe([]); - expect($storage->read(-1, 'reaction'))->toBe([]); - }); - - it('read returns empty array when key is empty', function (): void { - $storage = MetaStorage::new(); - Functions\expect('get_post_meta')->never(); - expect($storage->read(1, ''))->toBe([]); - }); - - it('read returns empty array when result is not an array', function (): void { - $storage = MetaStorage::new(); - Functions\expect('get_post_meta')->once()->with(1, '_konomi_items.reaction', true)->andReturn('not-an-array'); - expect($storage->read(1, 'reaction'))->toBe([]); - }); - - it('write returns false when id is invalid', function (): void { - $storage = MetaStorage::new(); - Functions\expect('update_post_meta')->never(); - expect($storage->write(0, 'reaction', ['data']))->toBeFalse(); - expect($storage->write(-1, 'reaction', ['data']))->toBeFalse(); - }); - - it('write returns false when key is empty', function (): void { - $storage = MetaStorage::new(); - Functions\expect('update_post_meta')->never(); - expect($storage->write(1, '', ['data']))->toBeFalse(); - }); -}); diff --git a/tests/unit/php/Post/TableStorageTest.php b/tests/unit/php/Post/TableStorageTest.php deleted file mode 100644 index 5e7297c..0000000 --- a/tests/unit/php/Post/TableStorageTest.php +++ /dev/null @@ -1,32 +0,0 @@ -storage = TableStorage::new(InteractionsTable::new('wp_')); -}); - -describe('Post TableStorage validation', function (): void { - it('returns empty array for invalid ID on read', function (): void { - expect($this->storage->read(0, 'reaction'))->toBe([]); - expect($this->storage->read(-1, 'reaction'))->toBe([]); - }); - - it('returns empty array for empty key on read', function (): void { - expect($this->storage->read(1, ''))->toBe([]); - }); - - it('returns false for invalid ID on write', function (): void { - expect($this->storage->write(0, 'reaction', []))->toBeFalse(); - expect($this->storage->write(-1, 'reaction', []))->toBeFalse(); - }); - - it('returns false for empty key on write', function (): void { - expect($this->storage->write(1, '', []))->toBeFalse(); - }); -}); diff --git a/tests/unit/php/Storage/RecordTest.php b/tests/unit/php/Storage/RecordTest.php new file mode 100644 index 0000000..5a19e7e --- /dev/null +++ b/tests/unit/php/Storage/RecordTest.php @@ -0,0 +1,16 @@ +entityId)->toBe(10) + ->and($record->userId)->toBe(5) + ->and($record->entityType)->toBe('post'); + }); +}); diff --git a/tests/unit/php/User/StorageKeyTest.php b/tests/unit/php/Storage/StorageKeyTest.php similarity index 86% rename from tests/unit/php/User/StorageKeyTest.php rename to tests/unit/php/Storage/StorageKeyTest.php index 23f4237..59bc624 100644 --- a/tests/unit/php/User/StorageKeyTest.php +++ b/tests/unit/php/Storage/StorageKeyTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace SpaghettiDojo\Konomi\Tests\Unit\User; +namespace SpaghettiDojo\Konomi\Tests\Unit\Storage; -use SpaghettiDojo\Konomi\User\StorageKey; +use SpaghettiDojo\Konomi\Storage\StorageKey; use SpaghettiDojo\Konomi\User\ItemGroup; describe('new', function (): void { diff --git a/tests/unit/php/Storage/TableStorageTest.php b/tests/unit/php/Storage/TableStorageTest.php new file mode 100644 index 0000000..1c9b983 --- /dev/null +++ b/tests/unit/php/Storage/TableStorageTest.php @@ -0,0 +1,49 @@ +column())->toBe('entity_id'); + }); + + it('maps User to user_id', function (): void { + expect(Axis::User->column())->toBe('user_id'); + }); +}); + +dataset('axes', [ + 'Entity axis' => [Axis::Entity], + 'User axis' => [Axis::User], +]); + +describe('TableStorage validation', function (): void { + it('returns empty list for invalid id on read', function (Axis $axis): void { + $storage = TableStorage::new(InteractionsTable::new('wp_'), $axis); + expect($storage->read(0, 'reaction'))->toBe([]) + ->and($storage->read(-1, 'reaction'))->toBe([]); + })->with('axes'); + + it('returns empty list for empty key on read', function (Axis $axis): void { + $storage = TableStorage::new(InteractionsTable::new('wp_'), $axis); + expect($storage->read(1, ''))->toBe([]); + })->with('axes'); + + it('returns false for invalid id on write', function (Axis $axis): void { + $storage = TableStorage::new(InteractionsTable::new('wp_'), $axis); + expect($storage->write(0, 'reaction', []))->toBeFalse() + ->and($storage->write(-1, 'reaction', []))->toBeFalse(); + })->with('axes'); + + it('returns false for empty key on write', function (Axis $axis): void { + $storage = TableStorage::new(InteractionsTable::new('wp_'), $axis); + expect($storage->write(1, '', [new Record(1, 1, 'post')]))->toBeFalse(); + })->with('axes'); +}); diff --git a/tests/unit/php/User/RawDataAssertTest.php b/tests/unit/php/User/RawDataAssertTest.php deleted file mode 100644 index 3ce59f3..0000000 --- a/tests/unit/php/User/RawDataAssertTest.php +++ /dev/null @@ -1,74 +0,0 @@ -rawDataAsserter = RawDataAssert::new(); -}); - -describe('ensureDataStructure', function (): void { - it('validates correct data structure', function (): void { - $data = [ - [1, 'post'], - [2, 'page'], - [3, 'video'], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe($data); - }); - - it('filters out invalid raw entities', function (): void { - $data = [ - ['0', 'post'], - [-1, 'video'], - ['abc', 'product'], - [2, 'page'], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect(count($result))->toBe(1); - expect($result)->toBe([[2, 'page']]); - }); - - it('filters out invalid raw items structure', function (): void { - $data = [ - null, - 'string', - [], - [1, 'administrator'], - (object) ['id' => 1], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe([[1, 'administrator']]); - }); - - it('filters out invalid item format', function (): void { - $data = [ - [1, ''], - [1, null], - [1], - [1, 'author', 'extra'], - [2, 'editor'], - [3, 123], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe([[2, 'editor']]); - }); - - it('handles empty input array', function (): void { - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure([])); - - expect($result)->toBe([]); - }); -}); diff --git a/tests/unit/php/User/StorageTest.php b/tests/unit/php/User/StorageTest.php deleted file mode 100644 index 61cf2d0..0000000 --- a/tests/unit/php/User/StorageTest.php +++ /dev/null @@ -1,68 +0,0 @@ -storage = User\MetaStorage::new(); -}); - -describe('MetaStorage', function (): void { - it('returns empty array for invalid ID', function (): void { - expect($this->storage->read(0, 'reaction'))->toBe([]); - }); - - it('returns empty array for empty key', function (): void { - expect($this->storage->read(1, ''))->toBe([]); - }); - - it('returns array when meta value is an array', function (): void { - $expected = ['item1', 'item2']; - - Functions\expect('get_user_meta') - ->once() - ->with(1, '_konomi_items.reaction', true) - ->andReturn($expected); - - expect($this->storage->read(1, 'reaction'))->toBe($expected); - }); - - it('returns empty array when meta value is not an array', function (): void { - Functions\expect('get_user_meta') - ->once() - ->with(1, '_konomi_items.reaction', true) - ->andReturn('string_value'); - - expect($this->storage->read(1, 'reaction'))->toBe([]); - }); - - it('returns false for invalid ID', function (): void { - expect($this->storage->write(0, 'reaction', []))->toBeFalse(); - }); - - it('returns false for empty key', function (): void { - expect($this->storage->write(1, '', []))->toBeFalse(); - }); - - it('returns true when update is successful', function (): void { - Functions\expect('update_user_meta') - ->once() - ->with(1, '_konomi_items.reaction', ['item1', 'item2']) - ->andReturn(1); - - expect($this->storage->write(1, 'reaction', ['item1', 'item2']))->toBeTrue(); - }); - - it('returns false when update fails', function (): void { - Functions\expect('update_user_meta') - ->once() - ->with(1, '_konomi_items.reaction', []) - ->andReturn(false); - - expect($this->storage->write(1, 'reaction', []))->toBeFalse(); - }); -}); diff --git a/tests/unit/php/User/TableStorageTest.php b/tests/unit/php/User/TableStorageTest.php deleted file mode 100644 index ffc3a70..0000000 --- a/tests/unit/php/User/TableStorageTest.php +++ /dev/null @@ -1,32 +0,0 @@ -storage = TableStorage::new(InteractionsTable::new('wp_')); -}); - -describe('User TableStorage validation', function (): void { - it('returns empty array for invalid ID on read', function (): void { - expect($this->storage->read(0, 'reaction'))->toBe([]); - expect($this->storage->read(-1, 'reaction'))->toBe([]); - }); - - it('returns empty array for empty key on read', function (): void { - expect($this->storage->read(1, ''))->toBe([]); - }); - - it('returns false for invalid ID on write', function (): void { - expect($this->storage->write(0, 'reaction', []))->toBeFalse(); - expect($this->storage->write(-1, 'reaction', []))->toBeFalse(); - }); - - it('returns false for empty key on write', function (): void { - expect($this->storage->write(1, '', []))->toBeFalse(); - }); -}); From 9e7095ed75c5f2ae379cb50dfe3469733859769b Mon Sep 17 00:00:00 2001 From: guido Date: Fri, 3 Jul 2026 19:28:56 +0200 Subject: [PATCH 11/26] Update gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 44b5e88..95e28e1 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ # Runtime /wordpress + +# AI +graphify-out/ From 3189a6783221cdf0d4a9dd3aa46552015118c259 Mon Sep 17 00:00:00 2001 From: guido Date: Sat, 4 Jul 2026 10:13:53 +0200 Subject: [PATCH 12/26] Inject `PluginProperties` dependency and retrieve plugin properties in `Konomi`. --- konomi.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/konomi.php b/konomi.php index 073ee87..1c37517 100644 --- a/konomi.php +++ b/konomi.php @@ -18,6 +18,8 @@ namespace SpaghettiDojo\Konomi; +use Inpsyde\Modularity\Properties\PluginProperties; + add_action( 'plugins_loaded', static function () { @@ -35,6 +37,7 @@ function autoload(string $projectRoot): void autoload(__DIR__); $package = package(); + /** @var PluginProperties $properties */ $properties = $package->properties(); $package From 32aaaa264b04d0f5081ce7b1b475a86d5e40c26a Mon Sep 17 00:00:00 2001 From: guido Date: Sat, 4 Jul 2026 12:36:28 +0200 Subject: [PATCH 13/26] docs: add design spec for modular activation task system --- .../2026-07-04-activation-tasks-design.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-04-activation-tasks-design.md diff --git a/docs/superpowers/specs/2026-07-04-activation-tasks-design.md b/docs/superpowers/specs/2026-07-04-activation-tasks-design.md new file mode 100644 index 0000000..0776ed1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-activation-tasks-design.md @@ -0,0 +1,63 @@ +# Design Spec: Modular Activation Task System + +## Date +2026-07-04 + +## Context +The current plugin activation hook registration in `sources/Database/Module.php` happens within the `run()` method of the module. Since the modularity system boots these `run()` methods during the `plugins_loaded` action, the hooks are registered too late to be caught by the WordPress activation process, which occurs before `plugins_loaded`. + +The goal is to create a decentralized, DI-driven system that allows individual modules to specify their activation, deactivation, and uninstall tasks while ensuring these tasks are registered with WordPress at the correct time. + +## Architecture + +### 1. The `Activation` Module +A new module dedicated to managing the lifecycle of plugin setup and cleanup. + +#### `ActivationTasks` (Service) +A fluent registry that collects callables to be executed during plugin lifecycle events. +- **Responsibilities**: Store lists of activation, deactivation, and uninstall tasks. +- **Interface**: + - `addActivationTask(callable $callback): self` + - `addDeactivationTask(callable $callback): self` + - `addUninstallTask(callable $callback): self` + - `getActivationTasks(): array` (and similar for others) + +#### `ActivationExecute` (Service) +The orchestrator that connects the modularity system to WordPress hooks. +- **Dependencies**: `Package`, `ActivationTasks`, `ContainerInterface`. +- **Key Methods**: + - `prepare(array $modules): void`: Iterates through provided modules. If a module implements `Activable`, it calls `$module->activate($this->tasks, $this->container)`. + - `registerActivationLogic(): void`: Calls `register_activation_hook()` using the main plugin file. The callback iterates through and executes all tasks in `ActivationTasks`. + - `registerDeactivationLogic(): void`: Similar to activation, using `register_deactivation_hook()`. + - `registerUninstallLogic(): void`: Similar to activation, using `register_uninstall_hook()`. + +### 2. The `Activable` Interface +Defines a contract for modules that require custom activation logic. + +```php +interface Activable { + public function activate(ActivationTasks $tasks, ContainerInterface $container): void; +} +``` + +## Data Flow & Lifecycle + +### Registration Flow (Bootstrapping) +1. `konomi.php` defines a list of modules and adds them to the `Package`. +2. `$package->build()` is called, initializing the PSR-11 container. +3. `ActivationExecute::prepare($modules)` is invoked. + - Each `Activable` module is called. + - Modules inject closures into `ActivationTasks` (e.g., `fn() => $container->get(SchemaManager::class)->create()`). +4. `ActivationExecute` registers the 3 primary WordPress hooks. +5. The rest of the system boots on `plugins_loaded`. + +### Execution Flow (WordPress Event) +1. User activates the plugin. +2. WordPress triggers the `activate_{plugin}` action. +3. The closure registered by `ActivationExecute` runs. +4. `ActivationExecute` retrieves the ordered list of tasks from `ActivationTasks` and executes each callable sequentially. + +## Testing & Verification +- **Verification**: Deactivate and reactivate the plugin to ensure `SchemaManager::create()` is called and the database table is generated. +- **Isolation**: Verify that modules not implementing `Activable` are ignored during the `prepare` phase. +- **Order**: Verify that tasks are executed in the order the modules were registered in `konomi.php`. From e9916dfc014bd98cf357c5be7cc532fb727c29d5 Mon Sep 17 00:00:00 2001 From: guido Date: Sat, 4 Jul 2026 12:44:15 +0200 Subject: [PATCH 14/26] feat(activation): add Activable interface and ActivationTasks registry Introduce the `Activable` contract for modules that need lifecycle setup or teardown, and the `ActivationTasks` fluent registry that collects the activation, deactivation, and uninstall callables. `ActivationTasks` is `final readonly` but backs each task list with a mutable `ArrayObject`, so modules receiving the shared registry through `Activable::activate()` accumulate their tasks into the same instance. Co-Authored-By: Claude Opus 4.8 --- sources/Activation/Activable.php | 16 +++++ sources/Activation/ActivationTasks.php | 88 ++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 sources/Activation/Activable.php create mode 100644 sources/Activation/ActivationTasks.php diff --git a/sources/Activation/Activable.php b/sources/Activation/Activable.php new file mode 100644 index 0000000..82b4705 --- /dev/null +++ b/sources/Activation/Activable.php @@ -0,0 +1,16 @@ + */ + private \ArrayObject $activationTasks; + /** @var \ArrayObject */ + private \ArrayObject $deactivationTasks; + /** @var \ArrayObject */ + private \ArrayObject $uninstallTasks; + + public static function new(): self + { + return new self(); + } + + private function __construct() + { + $this->activationTasks = new \ArrayObject(); + $this->deactivationTasks = new \ArrayObject(); + $this->uninstallTasks = new \ArrayObject(); + } + + /** + * @param Task $callback + */ + public function addActivationTask(callable $callback): self + { + $this->activationTasks->append($callback); + return $this; + } + + /** + * @param Task $callback + */ + public function addDeactivationTask(callable $callback): self + { + $this->deactivationTasks->append($callback); + return $this; + } + + /** + * @param Task $callback + */ + public function addUninstallTask(callable $callback): self + { + $this->uninstallTasks->append($callback); + return $this; + } + + /** + * @return list + */ + public function getActivationTasks(): array + { + return array_values($this->activationTasks->getArrayCopy()); + } + + /** + * @return list + */ + public function getDeactivationTasks(): array + { + return array_values($this->deactivationTasks->getArrayCopy()); + } + + /** + * @return list + */ + public function getUninstallTasks(): array + { + return array_values($this->uninstallTasks->getArrayCopy()); + } +} From 21542d54f14523864b838529e7f63745d6389db0 Mon Sep 17 00:00:00 2001 From: guido Date: Sat, 4 Jul 2026 12:46:35 +0200 Subject: [PATCH 15/26] feat(activation): add ActivationExecute orchestrator Wire the collected `ActivationTasks` to the WordPress activation, deactivation, and uninstall hooks. `prepare()` iterates modules in registration order, calling `Activable::activate()` so tasks accumulate in module order. Activation and deactivation use closures (registered in-request, not persisted). Uninstall is wired through a serializable static callback bridged to the collected registry, because `register_uninstall_hook()` persists its callback to the `uninstall_plugins` option and a closure cannot be serialized. Co-Authored-By: Claude Opus 4.8 --- sources/Activation/ActivationExecute.php | 106 +++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 sources/Activation/ActivationExecute.php diff --git a/sources/Activation/ActivationExecute.php b/sources/Activation/ActivationExecute.php new file mode 100644 index 0000000..75b12ed --- /dev/null +++ b/sources/Activation/ActivationExecute.php @@ -0,0 +1,106 @@ + $modules + */ + public function prepare(iterable $modules): void + { + foreach ($modules as $module) { + if ($module instanceof Activable) { + $module->activate($this->tasks, $this->container); + } + } + } + + public function registerActivationLogic(): void + { + register_activation_hook( + $this->properties->pluginMainFile(), + function (): void { + foreach ($this->tasks->getActivationTasks() as $task) { + $task(); + } + } + ); + } + + public function registerDeactivationLogic(): void + { + register_deactivation_hook( + $this->properties->pluginMainFile(), + function (): void { + foreach ($this->tasks->getDeactivationTasks() as $task) { + $task(); + } + } + ); + } + + public function registerUninstallLogic(): void + { + // The registry is bridged to the static uninstall callback because + // `register_uninstall_hook()` stores the callback in the database and a + // closure cannot be serialized. Only a serializable static callable is + // safe here. + self::$uninstallTasks = $this->tasks; + + register_uninstall_hook( + $this->properties->pluginMainFile(), + // phpcs:ignore Inpsyde.CodeQuality.StaticClosure.PossiblyStaticClosure + [self::class, 'executeUninstallTasks'] + ); + } + + public static function executeUninstallTasks(): void + { + foreach (self::$uninstallTasks?->getUninstallTasks() ?? [] as $task) { + $task(); + } + } +} From 47905708bac89287376cf4b3253bcf1c24bd457c Mon Sep 17 00:00:00 2001 From: guido Date: Sat, 4 Jul 2026 12:46:52 +0200 Subject: [PATCH 16/26] feat(activation): register Activation services in the container Add the `Activation\Module` ServiceModule registering `ActivationTasks` and `ActivationExecute`. `ActivationExecute` resolves the container's `Package::PROPERTIES` service for its `PluginProperties` dependency. Co-Authored-By: Claude Opus 4.8 --- sources/Activation/Module.php | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 sources/Activation/Module.php diff --git a/sources/Activation/Module.php b/sources/Activation/Module.php new file mode 100644 index 0000000..bda83db --- /dev/null +++ b/sources/Activation/Module.php @@ -0,0 +1,46 @@ + static fn (): ActivationTasks => ActivationTasks::new(), + ActivationExecute::class => static function ( + ContainerInterface $container + ): ActivationExecute { + /** @var PluginProperties $properties */ + $properties = $container->get(Package::PROPERTIES); + + return ActivationExecute::new( + $properties, + $container->get(ActivationTasks::class), + $container + ); + }, + ]; + } +} From e5257a8a3341525a2c9ba22593add260123c5030 Mon Sep 17 00:00:00 2001 From: guido Date: Sat, 4 Jul 2026 12:47:35 +0200 Subject: [PATCH 17/26] feat(database): declare lifecycle tasks via Activable Implement the `Activable` contract instead of registering the WordPress activation and uninstall hooks directly in the module. The schema create and drop operations are now declared as activation and uninstall tasks, which the `Activation` module wires to WordPress at the correct time (before `plugins_loaded`). This fixes the first-activation bug where `register_activation_hook`, registered during `plugins_loaded`, was too late for WordPress to catch. The unused `PluginProperties` dependency is dropped; the main plugin file is now resolved by `ActivationExecute`. Co-Authored-By: Claude Opus 4.8 --- sources/Database/Module.php | 47 ++++++++++++++----------------------- 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/sources/Database/Module.php b/sources/Database/Module.php index 91063f3..1ae064c 100644 --- a/sources/Database/Module.php +++ b/sources/Database/Module.php @@ -5,23 +5,25 @@ namespace SpaghettiDojo\Konomi\Database; use Psr\Container\ContainerInterface; +use SpaghettiDojo\Konomi\Activation\{ + Activable, + ActivationTasks +}; use Inpsyde\Modularity\{ Module\ServiceModule, - Module\ExecutableModule, - Module\ModuleClassNameIdTrait, - Properties\PluginProperties + Module\ModuleClassNameIdTrait }; -class Module implements ServiceModule, ExecutableModule +class Module implements ServiceModule, Activable { use ModuleClassNameIdTrait; - public static function new(PluginProperties $appProperties): self + public static function new(): self { - return new self($appProperties); + return new self(); } - final private function __construct(private readonly PluginProperties $appProperties) + final private function __construct() { } @@ -40,29 +42,14 @@ public function services(): array ]; } - public function run(ContainerInterface $container): bool - { - $pluginFile = $this->appProperties->pluginMainFile(); - - register_activation_hook( - $pluginFile, - static function () use ($container): void { - $container->get(SchemaManager::class)->create(); - } - ); - - register_uninstall_hook( - $pluginFile, - // phpcs:ignore Inpsyde.CodeQuality.StaticClosure.PossiblyStaticClosure - [self::class, 'onUninstall'] - ); - - return true; - } - - public static function onUninstall(): void + public function activate(ActivationTasks $tasks, ContainerInterface $container): void { - global $wpdb; - SchemaManager::new(InteractionsTable::new($wpdb->prefix))->drop(); + $tasks + ->addActivationTask( + static fn () => $container->get(SchemaManager::class)->create() + ) + ->addUninstallTask( + static fn () => $container->get(SchemaManager::class)->drop() + ); } } From d45d7670bdb7e24fecea4f2f4eec509befc79371 Mon Sep 17 00:00:00 2001 From: guido Date: Sat, 4 Jul 2026 12:55:38 +0200 Subject: [PATCH 18/26] feat(activation): wire the lifecycle system in the plugin bootstrap Restructure konomi.php to run at top level scope: collect the modules, add them to the package, build the container, then resolve `ActivationExecute` to prepare the modules' tasks and register the activation, deactivation, and uninstall logic. The package now boots on `plugins_loaded`. Running the registration before `plugins_loaded` is what lets WordPress catch the activation hook on first activation. Also rename the `ActivationTasks` accessors to the project's property-style convention (activationTasks/deactivationTasks/uninstallTasks), drop `final` from the `ActivationExecute` service to match the codebase's non-final services and allow test doubles, and update PackageTest to assert the new bootstrap contract. Co-Authored-By: Claude Opus 4.8 --- konomi.php | 74 ++++++++++++++---------- sources/Activation/ActivationExecute.php | 9 +-- sources/Activation/ActivationTasks.php | 6 +- tests/unit/php/PackageTest.php | 19 +++++- 4 files changed, 70 insertions(+), 38 deletions(-) diff --git a/konomi.php b/konomi.php index 1c37517..fac7455 100644 --- a/konomi.php +++ b/konomi.php @@ -19,36 +19,52 @@ 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(), + 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(); - /** @var PluginProperties $properties */ - $properties = $package->properties(); - - $package - ->addModule(Configuration\Module::new($properties, '/sources/Icons/icons')) - ->addModule(Database\Module::new($properties)) - ->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() ); diff --git a/sources/Activation/ActivationExecute.php b/sources/Activation/ActivationExecute.php index 75b12ed..fc85a20 100644 --- a/sources/Activation/ActivationExecute.php +++ b/sources/Activation/ActivationExecute.php @@ -22,7 +22,7 @@ * is safe because WordPress re-includes the main plugin file during uninstall, * which re-runs the bootstrap and repopulates the registry in the same request. */ -final class ActivationExecute +class ActivationExecute { private static ?ActivationTasks $uninstallTasks = null; @@ -31,6 +31,7 @@ public static function new( ActivationTasks $tasks, ContainerInterface $container ): self { + return new self($properties, $tasks, $container); } @@ -63,7 +64,7 @@ public function registerActivationLogic(): void register_activation_hook( $this->properties->pluginMainFile(), function (): void { - foreach ($this->tasks->getActivationTasks() as $task) { + foreach ($this->tasks->activationTasks() as $task) { $task(); } } @@ -75,7 +76,7 @@ public function registerDeactivationLogic(): void register_deactivation_hook( $this->properties->pluginMainFile(), function (): void { - foreach ($this->tasks->getDeactivationTasks() as $task) { + foreach ($this->tasks->deactivationTasks() as $task) { $task(); } } @@ -99,7 +100,7 @@ public function registerUninstallLogic(): void public static function executeUninstallTasks(): void { - foreach (self::$uninstallTasks?->getUninstallTasks() ?? [] as $task) { + foreach (self::$uninstallTasks?->uninstallTasks() ?? [] as $task) { $task(); } } diff --git a/sources/Activation/ActivationTasks.php b/sources/Activation/ActivationTasks.php index 97fadaf..c114b3f 100644 --- a/sources/Activation/ActivationTasks.php +++ b/sources/Activation/ActivationTasks.php @@ -65,7 +65,7 @@ public function addUninstallTask(callable $callback): self /** * @return list */ - public function getActivationTasks(): array + public function activationTasks(): array { return array_values($this->activationTasks->getArrayCopy()); } @@ -73,7 +73,7 @@ public function getActivationTasks(): array /** * @return list */ - public function getDeactivationTasks(): array + public function deactivationTasks(): array { return array_values($this->deactivationTasks->getArrayCopy()); } @@ -81,7 +81,7 @@ public function getDeactivationTasks(): array /** * @return list */ - public function getUninstallTasks(): array + public function uninstallTasks(): array { return array_values($this->uninstallTasks->getArrayCopy()); } diff --git a/tests/unit/php/PackageTest.php b/tests/unit/php/PackageTest.php index e88c14c..554653a 100644 --- a/tests/unit/php/PackageTest.php +++ b/tests/unit/php/PackageTest.php @@ -5,15 +5,29 @@ namespace SpaghettiDojo\Konomi\Tests\Unit; use Brain\Monkey\Functions; +use Psr\Container\ContainerInterface; +use SpaghettiDojo\Konomi\Activation\ActivationExecute; describe('Package', function (): void { - it('bootstrap the package during plugins_loaded action', function (): void { + it('bootstrap the package and register the lifecycle logic at plugin load', function (): void { $properties = \Mockery::mock( 'alias:Inpsyde\Modularity\Properties\PluginProperties', 'Inpsyde\Modularity\Properties\Properties', ); $properties->shouldReceive('new')->andReturnSelf(); + $activation = \Mockery::mock(ActivationExecute::class); + $activation->expects('prepare'); + $activation->expects('registerActivationLogic'); + $activation->expects('registerDeactivationLogic'); + $activation->expects('registerUninstallLogic'); + + $container = \Mockery::mock(ContainerInterface::class); + $container + ->shouldReceive('get') + ->with(ActivationExecute::class) + ->andReturn($activation); + $package = \Mockery::mock( 'alias:Inpsyde\Modularity\Package', [ @@ -22,7 +36,8 @@ ); $package->shouldReceive('new')->with($properties)->andReturn($package); $package->shouldReceive('addModule')->andReturnSelf(); - + $package->shouldReceive('container')->andReturn($container); + $package->expects('build'); $package->expects('boot'); Functions\expect('add_action') From 81a430d0a41b89a9d8799ce933a73b58101cdf18 Mon Sep 17 00:00:00 2001 From: guido Date: Sat, 4 Jul 2026 16:13:36 +0200 Subject: [PATCH 19/26] Ignore docs/superpowers --- .gitignore | 1 + .../2026-07-04-activation-tasks-design.md | 63 ------------------- 2 files changed, 1 insertion(+), 63 deletions(-) delete mode 100644 docs/superpowers/specs/2026-07-04-activation-tasks-design.md diff --git a/.gitignore b/.gitignore index 95e28e1..e730356 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ # AI graphify-out/ +docs/superposers diff --git a/docs/superpowers/specs/2026-07-04-activation-tasks-design.md b/docs/superpowers/specs/2026-07-04-activation-tasks-design.md deleted file mode 100644 index 0776ed1..0000000 --- a/docs/superpowers/specs/2026-07-04-activation-tasks-design.md +++ /dev/null @@ -1,63 +0,0 @@ -# Design Spec: Modular Activation Task System - -## Date -2026-07-04 - -## Context -The current plugin activation hook registration in `sources/Database/Module.php` happens within the `run()` method of the module. Since the modularity system boots these `run()` methods during the `plugins_loaded` action, the hooks are registered too late to be caught by the WordPress activation process, which occurs before `plugins_loaded`. - -The goal is to create a decentralized, DI-driven system that allows individual modules to specify their activation, deactivation, and uninstall tasks while ensuring these tasks are registered with WordPress at the correct time. - -## Architecture - -### 1. The `Activation` Module -A new module dedicated to managing the lifecycle of plugin setup and cleanup. - -#### `ActivationTasks` (Service) -A fluent registry that collects callables to be executed during plugin lifecycle events. -- **Responsibilities**: Store lists of activation, deactivation, and uninstall tasks. -- **Interface**: - - `addActivationTask(callable $callback): self` - - `addDeactivationTask(callable $callback): self` - - `addUninstallTask(callable $callback): self` - - `getActivationTasks(): array` (and similar for others) - -#### `ActivationExecute` (Service) -The orchestrator that connects the modularity system to WordPress hooks. -- **Dependencies**: `Package`, `ActivationTasks`, `ContainerInterface`. -- **Key Methods**: - - `prepare(array $modules): void`: Iterates through provided modules. If a module implements `Activable`, it calls `$module->activate($this->tasks, $this->container)`. - - `registerActivationLogic(): void`: Calls `register_activation_hook()` using the main plugin file. The callback iterates through and executes all tasks in `ActivationTasks`. - - `registerDeactivationLogic(): void`: Similar to activation, using `register_deactivation_hook()`. - - `registerUninstallLogic(): void`: Similar to activation, using `register_uninstall_hook()`. - -### 2. The `Activable` Interface -Defines a contract for modules that require custom activation logic. - -```php -interface Activable { - public function activate(ActivationTasks $tasks, ContainerInterface $container): void; -} -``` - -## Data Flow & Lifecycle - -### Registration Flow (Bootstrapping) -1. `konomi.php` defines a list of modules and adds them to the `Package`. -2. `$package->build()` is called, initializing the PSR-11 container. -3. `ActivationExecute::prepare($modules)` is invoked. - - Each `Activable` module is called. - - Modules inject closures into `ActivationTasks` (e.g., `fn() => $container->get(SchemaManager::class)->create()`). -4. `ActivationExecute` registers the 3 primary WordPress hooks. -5. The rest of the system boots on `plugins_loaded`. - -### Execution Flow (WordPress Event) -1. User activates the plugin. -2. WordPress triggers the `activate_{plugin}` action. -3. The closure registered by `ActivationExecute` runs. -4. `ActivationExecute` retrieves the ordered list of tasks from `ActivationTasks` and executes each callable sequentially. - -## Testing & Verification -- **Verification**: Deactivate and reactivate the plugin to ensure `SchemaManager::create()` is called and the database table is generated. -- **Isolation**: Verify that modules not implementing `Activable` are ignored during the `prepare` phase. -- **Order**: Verify that tasks are executed in the order the modules were registered in `konomi.php`. From ed0addc50dad6dbb251bfd7c32d07d0fe8c8be21 Mon Sep 17 00:00:00 2001 From: guido Date: Sat, 4 Jul 2026 16:19:15 +0200 Subject: [PATCH 20/26] Ignore Claude worktrees --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index e730356..9dd014f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ # AI graphify-out/ docs/superposers + +/.claude/worktrees/ From afa74a59cabebdc428d9517bea0945ca3bbb39c4 Mon Sep 17 00:00:00 2001 From: guido Date: Sun, 5 Jul 2026 12:18:41 +0200 Subject: [PATCH 21/26] Fix Functional Tests Bootstrap --- tests/WpLoad.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/WpLoad.php b/tests/WpLoad.php index 4a3fed4..c249f07 100644 --- a/tests/WpLoad.php +++ b/tests/WpLoad.php @@ -29,7 +29,13 @@ public static function load(): void // phpcs:enable Inpsyde.CodeQuality.VariablesName.SnakeCaseVar require_once ABSPATH . 'wp-includes/plugin.php'; - require_once dirname(__DIR__) . '/konomi.php'; + + // Load the plugin the same way core does: during muplugins_loaded, after + // wp-includes/functions.php is available (get_file_data) and before plugins_loaded + // fires, so the plugin's own plugins_loaded/boot registration still lands in time. + add_action('muplugins_loaded', static function (): void { + require_once dirname(__DIR__) . '/konomi.php'; + }); require_once ABSPATH . '/wp-settings.php'; require_once ABSPATH . 'wp-admin/includes/admin.php'; From 77de249010bd80b4c795aa8ef45d718f1987f88f Mon Sep 17 00:00:00 2001 From: guido Date: Sun, 5 Jul 2026 12:38:44 +0200 Subject: [PATCH 22/26] Archive Specs Changes --- .../design.md | 0 .../proposal.md | 0 .../specs/table-storage/spec.md | 0 .../tasks.md | 0 openspec/specs/table-storage/spec.md | 163 +++++++++--------- 5 files changed, 80 insertions(+), 83 deletions(-) rename openspec/changes/{extract-shared-storage-module => archive/2026-07-05-extract-shared-storage-module}/design.md (100%) rename openspec/changes/{extract-shared-storage-module => archive/2026-07-05-extract-shared-storage-module}/proposal.md (100%) rename openspec/changes/{extract-shared-storage-module => archive/2026-07-05-extract-shared-storage-module}/specs/table-storage/spec.md (100%) rename openspec/changes/{extract-shared-storage-module => archive/2026-07-05-extract-shared-storage-module}/tasks.md (100%) diff --git a/openspec/changes/extract-shared-storage-module/design.md b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/design.md similarity index 100% rename from openspec/changes/extract-shared-storage-module/design.md rename to openspec/changes/archive/2026-07-05-extract-shared-storage-module/design.md diff --git a/openspec/changes/extract-shared-storage-module/proposal.md b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/proposal.md similarity index 100% rename from openspec/changes/extract-shared-storage-module/proposal.md rename to openspec/changes/archive/2026-07-05-extract-shared-storage-module/proposal.md diff --git a/openspec/changes/extract-shared-storage-module/specs/table-storage/spec.md b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/specs/table-storage/spec.md similarity index 100% rename from openspec/changes/extract-shared-storage-module/specs/table-storage/spec.md rename to openspec/changes/archive/2026-07-05-extract-shared-storage-module/specs/table-storage/spec.md diff --git a/openspec/changes/extract-shared-storage-module/tasks.md b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/tasks.md similarity index 100% rename from openspec/changes/extract-shared-storage-module/tasks.md rename to openspec/changes/archive/2026-07-05-extract-shared-storage-module/tasks.md diff --git a/openspec/specs/table-storage/spec.md b/openspec/specs/table-storage/spec.md index 7ea0977..a888552 100644 --- a/openspec/specs/table-storage/spec.md +++ b/openspec/specs/table-storage/spec.md @@ -2,107 +2,104 @@ ## Purpose -Defines `TableStorage` implementations for Post and User domains that read from and write to the custom `konomi_interactions` table, replacing meta-based storage as the wired implementation of the `Storage` interfaces. +Defines a single shared, axis-bound `Storage` interface and its `TableStorage` implementation that read from and write to the custom `konomi_interactions` table. The storage layer exchanges a `Record` DTO (rather than per-module array shapes) and is configured with an `Axis` at construction so the same `TableStorage` serves both the Post and User domains, filtering by `entity_id` or `user_id` respectively. ## Requirements -### Requirement: Post TableStorage implements Post\Storage -The system SHALL provide a `Post\TableStorage` class that implements `Post\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, treating the post ID as `entity_id`. +### Requirement: Shared Storage interface uses Record DTO +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Storage` interface with two methods: `read(int $id, string $groupKey): list` and `write(int $id, string $groupKey, array $records): bool`. The `$records` parameter SHALL be a `list`. The interface SHALL replace the per-module `Post\Storage` and `User\Storage` interfaces. -#### Scenario: Read existing post interactions -- **WHEN** `read($postId, $key)` is called with a valid post ID and a non-empty `$key` -- **THEN** it SHALL return an array in the format `[userId => [[entityId, entityType]]]` matching rows in the table WHERE `entity_id` matches `$postId` and `group_key` equals `$key` +#### Scenario: Read returns a list of Record DTOs +- **WHEN** `read($id, $groupKey)` is called on any `Storage` implementation +- **THEN** it SHALL return a `list`, where each `Record` exposes readonly `entityId`, `userId`, and `entityType` properties -#### Scenario: Read with no data -- **WHEN** `read($postId, $key)` is called and no rows exist for that post and group -- **THEN** it SHALL return an empty array +#### Scenario: Write accepts a list of Record DTOs +- **WHEN** `write($id, $groupKey, $records)` is called +- **THEN** the implementation SHALL persist exactly the rows described by the supplied `Record` list, scoped to the given `(id, groupKey)` -#### Scenario: Read with invalid ID -- **WHEN** `read($postId, $key)` is called with `$postId <= 0` or empty `$key` -- **THEN** it SHALL return an empty array +### Requirement: Record DTO shape +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Record` readonly value object with three public readonly properties: `entityId: int`, `userId: int`, `entityType: string`. The class SHALL be `final`. -#### Scenario: Write post interactions -- **WHEN** `write($postId, $key, $data)` is called with valid data -- **THEN** it SHALL replace all rows for that post and group in the table and return `true` +#### Scenario: Construction +- **WHEN** `new Record($entityId, $userId, $entityType)` is invoked +- **THEN** the resulting instance SHALL expose those values via readonly public properties of the declared types -#### Scenario: Write empty data clears rows -- **WHEN** `write($postId, $key, [])` is called -- **THEN** it SHALL delete all rows for that post and group and return `true` +### Requirement: Axis enum drives table-side filter column +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Axis` enum with cases `Entity` and `User`, and a method `column(): string` returning `"entity_id"` for `Entity` and `"user_id"` for `User`. Adding any new case SHALL force `match` updates at every consumption site. -#### Scenario: Write with invalid ID -- **WHEN** `write($postId, $key, $data)` is called with `$postId <= 0` or empty `$key` -- **THEN** it SHALL return `false` without modifying data - -#### Scenario: Write is transactional -- **WHEN** `write()` deletes existing rows and inserts new ones -- **THEN** both operations SHALL execute within a single database transaction +#### Scenario: Entity axis maps to entity_id +- **WHEN** `Axis::Entity->column()` is called +- **THEN** it SHALL return `"entity_id"` -### Requirement: Post TableStorage write persists only the first item per user -`Post\TableStorage::write()` SHALL, for each `userId => $rawItems` entry in the input array, persist only `$rawItems[0]` and ignore any additional indices. This codifies existing behavior and prevents regressions during the internal split between payload building and DB execution. +#### Scenario: User axis maps to user_id +- **WHEN** `Axis::User->column()` is called +- **THEN** it SHALL return `"user_id"` -#### Scenario: Multiple items under the same user -- **WHEN** `write($postId, $key, [$userId => [[$entityId1, $type1], [$entityId2, $type2]]])` is called with valid IDs -- **THEN** only one row SHALL be inserted for `$userId` using `$entityId1` and `$type1`, and the second tuple SHALL be ignored +### Requirement: Shared TableStorage is axis-bound at construction +The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\TableStorage` class implementing the shared `Storage` interface, taking `Database\InteractionsTable` and `Axis` via constructor. The configured `Axis` SHALL determine which column is used as the filter for both `read` and `write`. The class SHALL replace `Post\TableStorage` and `User\TableStorage`. -#### Scenario: Single item under a user -- **WHEN** `write($postId, $key, [$userId => [[$entityId, $type]]])` is called with valid IDs -- **THEN** exactly one row SHALL be inserted for `$userId` with `$entityId` and `$type` +#### Scenario: Read filters by axis-resolved column +- **WHEN** `read($id, $groupKey)` is called on a `TableStorage` constructed with a given `Axis` +- **THEN** it SHALL execute `SELECT entity_id, user_id, entity_type FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` -### Requirement: Post TableStorage read returns one row per user -`Post\TableStorage::read()` SHALL return at most one entry per `userId` in the result map, even when multiple rows exist in the table for the same `(entity_id, group_key, user_id)` combination. The list-of-list shape `[userId => [[entityId, entityType]]]` is preserved for downstream consumer consistency. +#### Scenario: Read with invalid id or empty key +- **WHEN** `read($id, $groupKey)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return an empty list without querying the database -#### Scenario: Multiple rows for the same user -- **WHEN** `read($postId, $key)` is called and the underlying table contains more than one row for the same `user_id` -- **THEN** the returned map SHALL contain a single entry per `userId`, each holding a one-element list with the last seen `[entityId, entityType]` tuple +#### Scenario: Read maps rows to Record at boundary +- **WHEN** the underlying query returns rows +- **THEN** each row SHALL be passed through a `mapRow` step that returns either a `Record` for valid rows or `null` for malformed rows +- **AND** rows mapping to `null` SHALL be skipped from the returned list +- **AND** `entity_id`, `user_id` SHALL be cast to non-negative integers and `entity_type` to a non-empty string for a row to be considered valid -#### Scenario: Distinct users -- **WHEN** `read($postId, $key)` is called and rows exist for distinct user IDs -- **THEN** the returned map SHALL contain one entry per distinct `userId` +#### Scenario: Write filters and replaces by axis-resolved column +- **WHEN** `write($id, $groupKey, $records)` is called +- **THEN** it SHALL execute `DELETE FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` followed by one `INSERT` per `Record`, all within a single database transaction -### Requirement: User TableStorage implements User\Storage -The system SHALL provide a `User\TableStorage` class that implements `User\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, filtering by `user_id` and returning `entity_id` values as item IDs. +#### Scenario: Write with invalid id or empty key +- **WHEN** `write($id, $groupKey, $records)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return `false` without modifying data -#### Scenario: Read existing user interactions -- **WHEN** `read($userId, $key)` is called with a valid user ID and a non-empty `$key` -- **THEN** it SHALL return an array in the format `[[entityId, entityType], ...]` matching rows in the table WHERE `user_id` matches and `group_key` equals `$key` +#### Scenario: Write enforces axis invariant on each Record +- **WHEN** `write($id, $groupKey, $records)` is called with a configured `Axis` +- **THEN** for each `Record`, the field corresponding to `Axis::column()` SHALL be set to `$id` before insertion (overriding any divergent value on the input `Record`) -#### Scenario: Read with no data -- **WHEN** `read($userId, $key)` is called and no rows exist for that user and group -- **THEN** it SHALL return an empty array +#### Scenario: Write empty list clears scope +- **WHEN** `write($id, $groupKey, [])` is called with valid `$id` and `$groupKey` +- **THEN** existing rows for that `(axis.column() = $id, group_key = $groupKey)` SHALL be deleted and the call SHALL return `true` -#### Scenario: Read with invalid ID -- **WHEN** `read($userId, $key)` is called with `$userId <= 0` or empty `$key` -- **THEN** it SHALL return an empty array +#### Scenario: Write is transactional +- **WHEN** the DELETE or any INSERT fails during `write` +- **THEN** the transaction SHALL be rolled back and `write` SHALL return `false` -#### Scenario: Write user interactions -- **WHEN** `write($userId, $key, $data)` is called with valid data -- **THEN** it SHALL replace all rows for that user and group in the table and return `true` +### Requirement: Module wiring binds shared Storage with the module's Axis +`Post\Module` and `User\Module` SHALL bind `SpaghettiDojo\Konomi\Storage\Storage::class` in their `services()` definitions to a `TableStorage` instance constructed with the appropriate `Axis`. -#### Scenario: Write empty data clears rows -- **WHEN** `write($userId, $key, [])` is called -- **THEN** it SHALL delete all rows for that user and group and return `true` +#### Scenario: Post module wires Axis::Entity +- **WHEN** the Post module registers services +- **THEN** `Storage\Storage::class` SHALL resolve to `Storage\TableStorage::new($interactionsTable, Storage\Axis::Entity)` -#### Scenario: Write with invalid ID -- **WHEN** `write($userId, $key, $data)` is called with `$userId <= 0` or empty `$key` -- **THEN** it SHALL return `false` without modifying data +#### Scenario: User module wires Axis::User +- **WHEN** the User module registers services +- **THEN** `Storage\Storage::class` SHALL resolve to `Storage\TableStorage::new($interactionsTable, Storage\Axis::User)` -#### Scenario: Write is transactional -- **WHEN** `write()` deletes existing rows and inserts new ones -- **THEN** both operations SHALL execute within a single database transaction +### Requirement: Repositories consume Record-typed Storage with flat serialization +`Post\Repository` and `User\Repository` SHALL consume the shared `Storage` interface, accepting `list` from `read()` and emitting `list` to `write()`. Both repositories SHALL build the records list by iterating their registry and instantiating one `Record` per registry entry. -### Requirement: Module wiring uses TableStorage -`Post\Module` and `User\Module` SHALL wire `TableStorage` as the implementation for `Storage` interface in their service definitions. +#### Scenario: Post repository serializes one Record per userId +- **WHEN** `Post\Repository::save($item, $user)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($item->id(), $userId, $item->type())` per `$userId` currently held in the registry for the post and group -#### Scenario: Post module wires TableStorage -- **WHEN** the Post module registers services -- **THEN** `Post\Storage::class` SHALL resolve to a `Post\TableStorage` instance +#### Scenario: User repository serializes one Record per entityId +- **WHEN** `User\Repository::save($user, $item)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($entityId, $user->id(), $entityType)` per item currently held in the registry for the user and group -#### Scenario: User module wires TableStorage -- **WHEN** the User module registers services -- **THEN** `User\Storage::class` SHALL resolve to a `User\TableStorage` instance +#### Scenario: Registry rollback on write failure +- **WHEN** `Storage::write()` returns `false` from a repository `save()` call +- **THEN** the in-memory registry SHALL be restored to the snapshot captured before the save mutation -### Requirement: StorageKey produces sanitized group -`Post\StorageKey` and `User\StorageKey` SHALL accept an `ItemGroup` and return its `value` after validating that it contains only `[a-z0-9_]` characters and is non-empty. +### Requirement: Shared StorageKey deduplication +The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\StorageKey` class that produces sanitized group strings. The class SHALL replace per-module `Post\StorageKey` and `User\StorageKey`. The constructor SHALL take no arguments. #### Scenario: Valid group - **WHEN** `StorageKey::for($group)` is called with `ItemGroup` value `"reaction"` @@ -116,17 +113,17 @@ The system SHALL provide a `User\TableStorage` class that implements `User\Stora - **WHEN** `StorageKey::for($group)` is called with an empty value - **THEN** it SHALL throw `\InvalidArgumentException` -#### Scenario: Construction takes no base +#### Scenario: Construction takes no arguments - **WHEN** `StorageKey::new()` is called - **THEN** it SHALL accept no arguments and return a usable instance -### Requirement: MetaStorage owns the meta_key base prefix -`Post\MetaStorage` and `User\MetaStorage` SHALL define a private `BASE` constant equal to `'_konomi_items'` and SHALL compose the WordPress `meta_key` as `{BASE}.{$key}` on every read and write. +### Requirement: MetaStorage exists only as documented reference impl +The repository SHALL provide a `docs/storage-drivers.md` document that includes: a description of the `Storage` interface contract, a reference `MetaStorage` implementation showing how to back `Storage` by `wp_postmeta` and `wp_usermeta`, and a container-override snippet showing how to rebind `Storage\Storage::class` from a consumer plugin or site. No `MetaStorage` class SHALL exist in `sources/`. -#### Scenario: Write composes the meta_key -- **WHEN** `MetaStorage::write($id, "reaction", $data)` is called -- **THEN** the underlying `update_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` +#### Scenario: Documentation present +- **WHEN** the repository is checked out +- **THEN** `docs/storage-drivers.md` SHALL exist and SHALL contain a reference `MetaStorage` example for both post and user backends along with a DI override snippet -#### Scenario: Read composes the meta_key -- **WHEN** `MetaStorage::read($id, "reaction")` is called -- **THEN** the underlying `get_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` +#### Scenario: No MetaStorage in sources +- **WHEN** the codebase is searched +- **THEN** no `MetaStorage` class SHALL exist under `sources/Post/`, `sources/User/`, or `sources/Storage/` From 73dd02f18d8998372d2f631b5d5714e95d90f443 Mon Sep 17 00:00:00 2001 From: guido Date: Sun, 5 Jul 2026 12:47:32 +0200 Subject: [PATCH 23/26] Align specs with code changes --- openspec/specs/table-storage/spec.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openspec/specs/table-storage/spec.md b/openspec/specs/table-storage/spec.md index a888552..d84c941 100644 --- a/openspec/specs/table-storage/spec.md +++ b/openspec/specs/table-storage/spec.md @@ -72,16 +72,16 @@ The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\TableStorage` cl - **WHEN** the DELETE or any INSERT fails during `write` - **THEN** the transaction SHALL be rolled back and `write` SHALL return `false` -### Requirement: Module wiring binds shared Storage with the module's Axis -`Post\Module` and `User\Module` SHALL bind `SpaghettiDojo\Konomi\Storage\Storage::class` in their `services()` definitions to a `TableStorage` instance constructed with the appropriate `Axis`. +### Requirement: Module wiring constructs an axis-bound TableStorage per Repository +`Post\Module` and `User\Module` SHALL construct a `TableStorage` instance with the appropriate `Axis` inline within their `Repository::class` service definition and inject it as the `Storage\Storage` dependency. No standalone `SpaghettiDojo\Konomi\Storage\Storage::class` container binding SHALL be registered; the storage driver is swapped by overriding the module's `Repository::class` binding. #### Scenario: Post module wires Axis::Entity -- **WHEN** the Post module registers services -- **THEN** `Storage\Storage::class` SHALL resolve to `Storage\TableStorage::new($interactionsTable, Storage\Axis::Entity)` +- **WHEN** the Post module registers the `Repository::class` service +- **THEN** the `Storage\Storage` injected into `Post\Repository` SHALL be `Storage\TableStorage::new($interactionsTable, Storage\Axis::Entity)` #### Scenario: User module wires Axis::User -- **WHEN** the User module registers services -- **THEN** `Storage\Storage::class` SHALL resolve to `Storage\TableStorage::new($interactionsTable, Storage\Axis::User)` +- **WHEN** the User module registers the `Repository::class` service +- **THEN** the `Storage\Storage` injected into `User\Repository` SHALL be `Storage\TableStorage::new($interactionsTable, Storage\Axis::User)` ### Requirement: Repositories consume Record-typed Storage with flat serialization `Post\Repository` and `User\Repository` SHALL consume the shared `Storage` interface, accepting `list` from `read()` and emitting `list` to `write()`. Both repositories SHALL build the records list by iterating their registry and instantiating one `Record` per registry entry. @@ -118,7 +118,7 @@ The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\StorageKey` clas - **THEN** it SHALL accept no arguments and return a usable instance ### Requirement: MetaStorage exists only as documented reference impl -The repository SHALL provide a `docs/storage-drivers.md` document that includes: a description of the `Storage` interface contract, a reference `MetaStorage` implementation showing how to back `Storage` by `wp_postmeta` and `wp_usermeta`, and a container-override snippet showing how to rebind `Storage\Storage::class` from a consumer plugin or site. No `MetaStorage` class SHALL exist in `sources/`. +The repository SHALL provide a `docs/storage-drivers.md` document that includes: a description of the `Storage` interface contract, a reference `MetaStorage` implementation showing how to back `Storage` by `wp_postmeta` and `wp_usermeta`, and a container-override snippet showing how to swap the storage driver by overriding each module's `Repository::class` binding from a consumer plugin or site. No `MetaStorage` class SHALL exist in `sources/`. #### Scenario: Documentation present - **WHEN** the repository is checked out From fe7eae61516a3da8e37fd617183efe0a63bbef50 Mon Sep 17 00:00:00 2001 From: Guido Scialfa Date: Wed, 8 Jul 2026 22:26:45 +0200 Subject: [PATCH 24/26] Merge Post and User Storage (#31) Convert the Axis from being a dependency of the Storage implementation to be a component of the storage methods, this allow us to put the storage service into the container and third parties to extend it. --------- Co-authored-by: Claude Opus 4.8 --- docs/storage-drivers.md | 100 +++++++++------- konomi.php | 1 + .../single-storage-service/.openspec.yaml | 2 + .../changes/single-storage-service/design.md | 42 +++++++ .../single-storage-service/proposal.md | 27 +++++ .../specs/table-storage/spec.md | 110 ++++++++++++++++++ .../changes/single-storage-service/tasks.md | 34 ++++++ phpcs.xml | 2 +- phpstan.neon | 4 +- sources/Post/Module.php | 6 +- sources/Post/Repository.php | 3 +- sources/Storage/Axis.php | 3 - sources/Storage/Module.php | 37 ++++++ sources/Storage/Storage.php | 4 +- sources/Storage/TableStorage.php | 17 ++- sources/User/Module.php | 6 +- sources/User/Repository.php | 3 +- .../{helpers => Helpers}/InMemoryStorage.php | 5 +- tests/{helpers => Helpers}/functions.php | 0 tests/{helpers => Helpers}/integration.php | 0 tests/unit/php/Storage/ModuleTest.php | 68 +++++++++++ tests/unit/php/Storage/TableStorageTest.php | 20 ++-- 22 files changed, 409 insertions(+), 85 deletions(-) create mode 100644 openspec/changes/single-storage-service/.openspec.yaml create mode 100644 openspec/changes/single-storage-service/design.md create mode 100644 openspec/changes/single-storage-service/proposal.md create mode 100644 openspec/changes/single-storage-service/specs/table-storage/spec.md create mode 100644 openspec/changes/single-storage-service/tasks.md create mode 100644 sources/Storage/Module.php rename tests/{helpers => Helpers}/InMemoryStorage.php (89%) rename tests/{helpers => Helpers}/functions.php (100%) rename tests/{helpers => Helpers}/integration.php (100%) create mode 100644 tests/unit/php/Storage/ModuleTest.php diff --git a/docs/storage-drivers.md b/docs/storage-drivers.md index 0dbde06..52c6c2b 100644 --- a/docs/storage-drivers.md +++ b/docs/storage-drivers.md @@ -1,6 +1,6 @@ # 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. This document describes the contract and shows how to swap in an alternative driver (e.g. WordPress meta) via the DI container. +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 @@ -10,13 +10,25 @@ namespace SpaghettiDojo\Konomi\Storage; interface Storage { /** @return list */ - public function read(int $id, string $groupKey): array; + public function read(Axis $axis, int $id, string $groupKey): array; /** @param list $records */ - public function write(int $id, string $groupKey, array $records): bool; + 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 @@ -30,33 +42,32 @@ final class Record } ``` -`$id` is the axis identifier (a post id when used by `Post\Repository`, a user id when used by `User\Repository`). `$groupKey` is a sanitized `User\ItemGroup` value (e.g. `"reaction"`, `"bookmark"`). +`$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 `($id, $groupKey)`. `write` replaces the entire scope: existing rows for that `(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`. +`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` / `wp_usermeta`. Copy and adapt as needed. - -### Post variant +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 PostMetaStorage implements Storage +final class MetaStorage implements Storage { private const BASE = '_konomi_items'; - public function read(int $id, string $groupKey): array + public function read(Axis $axis, int $id, string $groupKey): array { if ($id <= 0 || $groupKey === '') { return []; } - $raw = get_post_meta($id, self::BASE . '.' . $groupKey, true); + $raw = $this->getMeta($axis, $id, self::BASE . '.' . $groupKey); if (!is_array($raw)) { return []; } @@ -77,7 +88,7 @@ final class PostMetaStorage implements Storage return $records; } - public function write(int $id, string $groupKey, array $records): bool + public function write(Axis $axis, int $id, string $groupKey, array $records): bool { if ($id <= 0 || $groupKey === '') { return false; @@ -85,37 +96,46 @@ final class PostMetaStorage implements Storage $payload = array_map( static fn (Record $r) => [ - 'entity_id' => $r->entityId, - 'user_id' => $r->userId, + // 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 (bool) update_post_meta($id, self::BASE . '.' . $groupKey, $payload); + return $this->updateMeta($axis, $id, self::BASE . '.' . $groupKey, $payload); } -} -``` -### User variant + 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); + } -Identical to the post variant with `get_user_meta` / `update_user_meta` substituted for `get_post_meta` / `update_post_meta`. + /** @param list> $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)); + } +} +``` -## Container override +## Extending the storage service -Konomi's per-module `Module::services()` constructs `TableStorage` inline inside each `Repository` factory. To swap drivers from a consumer plugin or site, override the `Repository` binding directly: +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\ServiceModule; +use Inpsyde\Modularity\Module\ExtendingModule; use Inpsyde\Modularity\Module\ModuleClassNameIdTrait; -use MyPlugin\Storage\PostMetaStorage; -use MyPlugin\Storage\UserMetaStorage; +use MyPlugin\Storage\MetaStorage; use Psr\Container\ContainerInterface; -use SpaghettiDojo\Konomi\Post; -use SpaghettiDojo\Konomi\Storage; -use SpaghettiDojo\Konomi\User; +use SpaghettiDojo\Konomi\Storage\Storage; -final class StorageOverrideModule implements ServiceModule +final class StorageOverrideModule implements ExtendingModule { use ModuleClassNameIdTrait; @@ -126,34 +146,26 @@ final class StorageOverrideModule implements ServiceModule private function __construct() {} - public function services(): array + public function extensions(): array { return [ - Post\Repository::class => static fn (ContainerInterface $c) => Post\Repository::new( - Storage\StorageKey::new(), - new PostMetaStorage(), - $c->get(User\ItemFactory::class), - $c->get(Post\ItemRegistry::class) - ), - User\Repository::class => static fn (ContainerInterface $c) => User\Repository::new( - Storage\StorageKey::new(), - new UserMetaStorage(), - $c->get(User\ItemFactory::class), - $c->get(User\ItemRegistry::class) - ), + Storage::class => static fn (Storage $original, ContainerInterface $c): Storage + => new MetaStorage(), ]; } } ``` -Add the override module after Konomi's bundled modules so its bindings win: +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 `(id, groupKey)` slice. -- `TableStorage` enforces an axis invariant: the column corresponding to its configured `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. +- `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. diff --git a/konomi.php b/konomi.php index fac7455..62b46fe 100644 --- a/konomi.php +++ b/konomi.php @@ -39,6 +39,7 @@ function autoload(string $projectRoot): void $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(), diff --git a/openspec/changes/single-storage-service/.openspec.yaml b/openspec/changes/single-storage-service/.openspec.yaml new file mode 100644 index 0000000..8cceb8d --- /dev/null +++ b/openspec/changes/single-storage-service/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-08 diff --git a/openspec/changes/single-storage-service/design.md b/openspec/changes/single-storage-service/design.md new file mode 100644 index 0000000..589534c --- /dev/null +++ b/openspec/changes/single-storage-service/design.md @@ -0,0 +1,42 @@ +## Context + +`docs/storage-drivers.md` documents driver swapping via re-registering each module's `Repository::class` binding — which contradicts Inpsyde Modularity's `ExtendingModule` mechanism for modifying already-registered services. That mechanism needs the driver to be a real container service, but today each `Repository` factory builds `TableStorage` inline. + +Investigation established that there is a **single** `konomi_interactions` table (one `InteractionsTable` value object, one `SchemaManager`), and that the two storage instances differ **only** by `Storage\Axis`: the axis selects which column `$id` filters on (`entity_id` vs `user_id`) in the read `WHERE` / write `DELETE`, and which column a write forces to `$id`. Everything else is identical. The two instances exist purely because the `Storage` interface hides the axis at the call site, forcing it to be baked into the instance. + +## Goals / Non-Goals + +**Goals:** +- One shared `Storage` service in the container under `Storage::class`. +- Driver swap via a single `ExtendingModule` extension on `Storage::class`. +- Preserve all existing storage behaviour (validation, transactional write, axis invariant, `Record` mapping). + +**Non-Goals:** +- Changing the `konomi_interactions` schema or `SchemaManager`. +- Changing `Repository` public behaviour or the `Record` / `StorageKey` contracts. +- Supporting genuinely different backends per axis via separate services. + +## Decisions + +- **Lift `Axis` into the method signature** (`read(Axis, id, key)`, `write(Axis, id, key, records)`) rather than keeping it on the instance. This is the minimal change that lets a single instance serve both domains. Alternative — two marker-interface services (`EntityStorage`/`UserStorage`) — was rejected: two of everything for an axis-only difference. Alternative — a `Criteria` value object — was rejected as YAGNI given the shared schema. +- **Repositories pass their axis inline** at their two call sites (each repository is inherently axis-specific by namespace). No new `Repository` constructor parameter; the injected dependency stays typed `Storage\Storage`. +- **New `Storage\Module` (ServiceModule)** owns the single `Storage::class` binding (`TableStorage::new($interactionsTable)`), registered in `konomi.php` after `Database\Module`. A dedicated module keeps storage as its own domain and gives consumers a stable id to extend. Alternative — registering in `Database\Module` — was rejected as conflating schema management with the storage driver. +- **`Axis` becomes public contract** (drops `@internal`) since custom drivers now receive it. + +## Risks / Trade-offs + +- [A consumer wants different backends for post vs user] → One service cannot bind two backends; the custom driver branches on the `Axis` argument it receives per call. Acceptable given identical schema/logic. +- [Interface signature change breaks any existing `Storage` implementers] → In-tree there is only `TableStorage`; the reference `MetaStorage` lives in docs and is updated in the same change. Flagged BREAKING in the proposal. + +## Migration Plan + +1. Change the `Storage` interface and `TableStorage` (axis-less ctor, axis-per-call). +2. Add `Storage\Module`; register in `konomi.php`. +3. Update `Post\Module` / `User\Module` to consume `Storage::class`; update repositories to pass their axis. +4. Update tests and `docs/storage-drivers.md`. + +Rollback is a straight revert of the branch; no data migration is involved (schema unchanged). + +## Open Questions + +None. diff --git a/openspec/changes/single-storage-service/proposal.md b/openspec/changes/single-storage-service/proposal.md new file mode 100644 index 0000000..449434b --- /dev/null +++ b/openspec/changes/single-storage-service/proposal.md @@ -0,0 +1,27 @@ +## Why + +The documented way to swap the persistence driver — re-registering each module's `Repository::class` binding — contradicts Inpsyde Modularity's mechanism for modifying already-registered services ([Extending Module](https://inpsyde.github.io/modularity/Modules/#extendingmodule)), which requires the driver to be a real container service. Today it is not: each `Repository` factory constructs `TableStorage` inline, and two instances exist only because the `Storage` interface hides the `Axis` at the call site (there is a single shared `konomi_interactions` table; the two instances differ solely by axis). + +## What Changes + +- **BREAKING** `Storage::read()`/`write()` gain an `Axis` first parameter; the axis is lifted out of the instance and passed per call. +- `TableStorage` becomes a single axis-less concrete constructed with only `Database\InteractionsTable`; it is no longer axis-bound at construction. +- A new `Storage\Module` registers one shared `Storage::class` service in the container; `Post\Module`/`User\Module` stop constructing `TableStorage` inline and consume `Storage::class`. +- `Post\Repository`/`User\Repository` pass their axis (`Axis::Entity` / `Axis::User`) on every storage call. +- The driver is swapped via a single `ExtendingModule` extension on `Storage::class` instead of overriding `Repository::class`; `docs/storage-drivers.md` and its reference driver are reworked accordingly (single `Axis`-branching `MetaStorage`). +- `Axis` becomes part of the public storage contract (drops `@internal`). + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `table-storage`: `Storage` interface methods take `Axis` per call; `TableStorage` is a single axis-less service (not axis-bound at construction); module wiring registers one shared `Storage::class` service instead of inline per-`Repository` construction; repositories pass their axis; the driver is swapped via `ExtendingModule` on `Storage::class` rather than by overriding `Repository::class`; the reference `MetaStorage` doc becomes a single axis-branching implementation. + +## Impact + +- Code: `sources/Storage/Storage.php`, `sources/Storage/TableStorage.php`, `sources/Storage/Axis.php`, new `sources/Storage/Module.php`, `sources/Post/Module.php`, `sources/User/Module.php`, `sources/Post/Repository.php`, `sources/User/Repository.php`, `konomi.php`. +- Docs: `docs/storage-drivers.md`. +- Tests: `tests/unit/php/Storage/TableStorageTest.php` and new coverage for `Storage\Module` binding + `ExtendingModule` override. +- No schema change: `SchemaManager` and the `konomi_interactions` table are untouched. diff --git a/openspec/changes/single-storage-service/specs/table-storage/spec.md b/openspec/changes/single-storage-service/specs/table-storage/spec.md new file mode 100644 index 0000000..6539576 --- /dev/null +++ b/openspec/changes/single-storage-service/specs/table-storage/spec.md @@ -0,0 +1,110 @@ +## RENAMED Requirements + +- FROM: `### Requirement: Shared TableStorage is axis-bound at construction` +- TO: `### Requirement: Shared TableStorage takes Axis per call` + +- FROM: `### Requirement: Module wiring constructs an axis-bound TableStorage per Repository` +- TO: `### Requirement: Storage service registered once and consumed by repositories` + +## MODIFIED Requirements + +### Requirement: Shared Storage interface uses Record DTO +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Storage` interface with two methods: `read(Axis $axis, int $id, string $groupKey): list` and `write(Axis $axis, int $id, string $groupKey, array $records): bool`. The `Axis` SHALL be supplied per call (not bound to the instance) and determine which column `$id` filters on. The `$records` parameter SHALL be a `list`. The interface SHALL replace the per-module `Post\Storage` and `User\Storage` interfaces. + +#### Scenario: Read returns a list of Record DTOs +- **WHEN** `read($axis, $id, $groupKey)` is called on any `Storage` implementation +- **THEN** it SHALL return a `list`, where each `Record` exposes readonly `entityId`, `userId`, and `entityType` properties + +#### Scenario: Write accepts a list of Record DTOs +- **WHEN** `write($axis, $id, $groupKey, $records)` is called +- **THEN** the implementation SHALL persist exactly the rows described by the supplied `Record` list, scoped to the given `($axis, $id, $groupKey)` + +### Requirement: Shared TableStorage takes Axis per call +The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\TableStorage` class implementing the shared `Storage` interface, taking only `Database\InteractionsTable` via constructor. It SHALL NOT be bound to an `Axis` at construction. The `Axis` supplied to each `read`/`write` call SHALL determine which column is used as the filter. The class SHALL replace `Post\TableStorage` and `User\TableStorage`. + +#### Scenario: Read filters by axis-resolved column +- **WHEN** `read($axis, $id, $groupKey)` is called +- **THEN** it SHALL execute `SELECT entity_id, user_id, entity_type FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` + +#### Scenario: Read with invalid id or empty key +- **WHEN** `read($axis, $id, $groupKey)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return an empty list without querying the database + +#### Scenario: Read maps rows to Record at boundary +- **WHEN** the underlying query returns rows +- **THEN** each row SHALL be passed through a `mapRow` step that returns either a `Record` for valid rows or `null` for malformed rows +- **AND** rows mapping to `null` SHALL be skipped from the returned list +- **AND** `entity_id`, `user_id` SHALL be cast to non-negative integers and `entity_type` to a non-empty string for a row to be considered valid + +#### Scenario: Write filters and replaces by axis-resolved column +- **WHEN** `write($axis, $id, $groupKey, $records)` is called +- **THEN** it SHALL execute `DELETE FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` followed by one `INSERT` per `Record`, all within a single database transaction + +#### Scenario: Write with invalid id or empty key +- **WHEN** `write($axis, $id, $groupKey, $records)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write enforces axis invariant on each Record +- **WHEN** `write($axis, $id, $groupKey, $records)` is called +- **THEN** for each `Record`, the field corresponding to `$axis->column()` SHALL be set to `$id` before insertion (overriding any divergent value on the input `Record`) + +#### Scenario: Write empty list clears scope +- **WHEN** `write($axis, $id, $groupKey, [])` is called with valid `$id` and `$groupKey` +- **THEN** existing rows for that `($axis->column() = $id, group_key = $groupKey)` SHALL be deleted and the call SHALL return `true` + +#### Scenario: Write is transactional +- **WHEN** the DELETE or any INSERT fails during `write` +- **THEN** the transaction SHALL be rolled back and `write` SHALL return `false` + +### Requirement: Storage service registered once and consumed by repositories +A new `SpaghettiDojo\Konomi\Storage\Module` (ServiceModule) SHALL register a single `SpaghettiDojo\Konomi\Storage\Storage::class` container service bound to `Storage\TableStorage::new($interactionsTable)`. `Post\Module` and `User\Module` SHALL inject that `Storage::class` service into their `Repository::class` definition instead of constructing `TableStorage` inline. The storage driver SHALL be swapped by extending the `Storage::class` service via an `Inpsyde\Modularity\Module\ExtendingModule`, not by overriding any `Repository::class` binding. + +#### Scenario: Storage module binds the shared service +- **WHEN** the container is built +- **THEN** `Storage\Module::services()` SHALL bind `Storage\Storage::class` to `Storage\TableStorage::new($container->get(Database\InteractionsTable::class))` + +#### Scenario: Post module injects the shared Storage service +- **WHEN** the Post module registers the `Repository::class` service +- **THEN** the `Storage\Storage` injected into `Post\Repository` SHALL be `$container->get(Storage\Storage::class)` (no inline `TableStorage` construction) + +#### Scenario: User module injects the shared Storage service +- **WHEN** the User module registers the `Repository::class` service +- **THEN** the `Storage\Storage` injected into `User\Repository` SHALL be `$container->get(Storage\Storage::class)` (no inline `TableStorage` construction) + +#### Scenario: Driver swapped via ExtendingModule +- **WHEN** a consumer registers an `ExtendingModule` whose `extensions()` returns `[Storage\Storage::class => fn(Storage $original, $c) => new CustomStorage()]` +- **THEN** both `Post\Repository` and `User\Repository` SHALL consume `CustomStorage` without any `Repository::class` override + +### Requirement: Repositories consume Record-typed Storage with flat serialization +`Post\Repository` and `User\Repository` SHALL consume the shared `Storage` interface, accepting `list` from `read()` and emitting `list` to `write()`, and SHALL pass their own `Axis` (`Axis::Entity` for `Post\Repository`, `Axis::User` for `User\Repository`) as the first argument on every storage call. Both repositories SHALL build the records list by iterating their registry and instantiating one `Record` per registry entry. + +#### Scenario: Post repository passes Axis::Entity +- **WHEN** `Post\Repository` calls `Storage::read()` or `Storage::write()` +- **THEN** it SHALL pass `Storage\Axis::Entity` as the first argument + +#### Scenario: User repository passes Axis::User +- **WHEN** `User\Repository` calls `Storage::read()` or `Storage::write()` +- **THEN** it SHALL pass `Storage\Axis::User` as the first argument + +#### Scenario: Post repository serializes one Record per userId +- **WHEN** `Post\Repository::save($item, $user)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($item->id(), $userId, $item->type())` per `$userId` currently held in the registry for the post and group + +#### Scenario: User repository serializes one Record per entityId +- **WHEN** `User\Repository::save($user, $item)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($entityId, $user->id(), $entityType)` per item currently held in the registry for the user and group + +#### Scenario: Registry rollback on write failure +- **WHEN** `Storage::write()` returns `false` from a repository `save()` call +- **THEN** the in-memory registry SHALL be restored to the snapshot captured before the save mutation + +### Requirement: MetaStorage exists only as documented reference impl +The repository SHALL provide a `docs/storage-drivers.md` document that includes: a description of the `Storage` interface contract (including the per-call `Axis` parameter), a single reference `MetaStorage` implementation that branches on `Axis` to back `Storage` by `wp_postmeta` (`Axis::Entity`) and `wp_usermeta` (`Axis::User`), and an `ExtendingModule` snippet showing how to swap the driver by extending the `Storage\Storage::class` service. The document SHALL link to the Modularity Extending Module documentation. No `MetaStorage` class SHALL exist in `sources/`. + +#### Scenario: Documentation present +- **WHEN** the repository is checked out +- **THEN** `docs/storage-drivers.md` SHALL exist and SHALL contain a single `Axis`-branching `MetaStorage` reference example and an `ExtendingModule` override snippet targeting `Storage\Storage::class` + +#### Scenario: No MetaStorage in sources +- **WHEN** the codebase is searched +- **THEN** no `MetaStorage` class SHALL exist under `sources/Post/`, `sources/User/`, or `sources/Storage/` diff --git a/openspec/changes/single-storage-service/tasks.md b/openspec/changes/single-storage-service/tasks.md new file mode 100644 index 0000000..bd5f7a1 --- /dev/null +++ b/openspec/changes/single-storage-service/tasks.md @@ -0,0 +1,34 @@ +## 1. Storage core (interface + concrete) + +- [x] 1.1 Change `Storage\Storage` methods to `read(Axis $axis, int $id, string $groupKey): array` and `write(Axis $axis, int $id, string $groupKey, array $records): bool` +- [x] 1.2 Update `Storage\TableStorage`: drop the `Axis` constructor arg (ctor takes only `Database\InteractionsTable`); accept `Axis $axis` per call and use `$axis->column()` for the read `WHERE`, write `DELETE`, and per-`Record` axis invariant +- [x] 1.3 Remove `@internal` from `Storage\Axis` (now part of the public storage contract); leave `column()` unchanged + +## 2. Service registration & wiring + +- [x] 2.1 Add `Storage\Module` (ServiceModule) binding `Storage\Storage::class => TableStorage::new($container->get(Database\InteractionsTable::class))` +- [x] 2.2 Register `Storage\Module::new()` in `konomi.php` (after `Database\Module::new()`) +- [x] 2.3 Update `Post\Module`: inject `$container->get(Storage\Storage::class)` into `Repository::class`; remove inline `TableStorage::new(...)` and the direct `Database\InteractionsTable` use +- [x] 2.4 Update `User\Module`: same as 2.3 for `User\Repository` + +## 3. Repositories pass their axis + +- [x] 3.1 `Post\Repository`: pass `Storage\Axis::Entity` on its `read` and `write` calls +- [x] 3.2 `User\Repository`: pass `Storage\Axis::User` on its `read` and `write` calls + +## 4. Tests + +- [x] 4.1 Update `tests/unit/php/Storage/TableStorageTest.php`: construct `TableStorage::new($table)` (no axis) and feed the `axes` dataset into `read`/`write` calls; keep `Axis::column()` cases +- [x] 4.2 Add coverage that `Storage\Module` binds `Storage::class` to a `Storage` instance +- [x] 4.3 Add coverage that an `ExtendingModule` extension on `Storage::class` is honored (repositories consume the swapped driver) + +## 5. Documentation + +- [x] 5.1 Rewrite `docs/storage-drivers.md`: update the `Storage` interface section to the per-call `Axis` signatures; document `Axis` as contract +- [x] 5.2 Replace the reference driver with a single `Axis`-branching `MetaStorage` (post meta for `Axis::Entity`, user meta for `Axis::User`) +- [x] 5.3 Replace the "Container override" section with an `ExtendingModule` snippet targeting `Storage\Storage::class`, and link the Modularity Extending Module docs + +## 6. Verification + +- [x] 6.1 Run the test suite and static analysis; confirm green +- [x] 6.2 `openspec validate single-storage-service --strict` diff --git a/phpcs.xml b/phpcs.xml index d2378c6..3fb53cb 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -41,7 +41,7 @@ */vendor/* */wordpress/* */dist/* - */tests/helpers/* + */tests/Helpers/* */tests/fixtures/* */tests/stubs/* sources/Blocks/blocks-manifest.php diff --git a/phpstan.neon b/phpstan.neon index b1e7085..0d97164 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,8 +4,8 @@ parameters: - sources scanFiles: - vendor/inpsyde/wp-stubs-versions/6.8.php - - tests/helpers/functions.php - - tests/helpers/integration.php + - tests/Helpers/functions.php + - tests/Helpers/integration.php treatPhpDocTypesAsCertain: false includes: - vendor/bnf/phpstan-psr-container/extension.neon diff --git a/sources/Post/Module.php b/sources/Post/Module.php index 44e07dd..bf78e6b 100644 --- a/sources/Post/Module.php +++ b/sources/Post/Module.php @@ -10,7 +10,6 @@ Module\ExecutableModule, Module\ModuleClassNameIdTrait }; -use SpaghettiDojo\Konomi\Database; use SpaghettiDojo\Konomi\Storage; use SpaghettiDojo\Konomi\User; @@ -43,10 +42,7 @@ public function services(): array ContainerInterface $container ) => Repository::new( Storage\StorageKey::new(), - Storage\TableStorage::new( - $container->get(Database\InteractionsTable::class), - Storage\Axis::Entity - ), + $container->get(Storage\Storage::class), $container->get(User\ItemFactory::class), $container->get(ItemRegistry::class) ), diff --git a/sources/Post/Repository.php b/sources/Post/Repository.php index e38805f..50d8603 100644 --- a/sources/Post/Repository.php +++ b/sources/Post/Repository.php @@ -52,6 +52,7 @@ public function save(User\Item $item, User\User $user): bool do_action('konomi.post.collection.save', $item, $user, $this->key); $stored = $this->storage->write( + Storage\Axis::Entity, $item->id(), $this->key->for($item->group()), $records @@ -105,7 +106,7 @@ private function loadItems(int $postId, User\ItemGroup $group): void return; } - foreach ($this->storage->read($postId, $this->key->for($group)) as $record) { + foreach ($this->storage->read(Storage\Axis::Entity, $postId, $this->key->for($group)) as $record) { $item = $this->itemFactory->create($record->entityId, $record->entityType, true, $group); $item->isValid() and $this->registry->set($postId, $record->userId, $item); } diff --git a/sources/Storage/Axis.php b/sources/Storage/Axis.php index 83e1782..2037d42 100644 --- a/sources/Storage/Axis.php +++ b/sources/Storage/Axis.php @@ -4,9 +4,6 @@ namespace SpaghettiDojo\Konomi\Storage; -/** - * @internal - */ enum Axis { case Entity; diff --git a/sources/Storage/Module.php b/sources/Storage/Module.php new file mode 100644 index 0000000..e7bc8f4 --- /dev/null +++ b/sources/Storage/Module.php @@ -0,0 +1,37 @@ + static fn ( + ContainerInterface $container + ) => TableStorage::new( + $container->get(Database\InteractionsTable::class) + ), + ]; + } +} diff --git a/sources/Storage/Storage.php b/sources/Storage/Storage.php index cd0c314..2d77e4a 100644 --- a/sources/Storage/Storage.php +++ b/sources/Storage/Storage.php @@ -9,10 +9,10 @@ interface Storage /** * @return list */ - public function read(int $id, string $groupKey): array; + public function read(Axis $axis, int $id, string $groupKey): array; /** * @param list $records */ - public function write(int $id, string $groupKey, array $records): bool; + public function write(Axis $axis, int $id, string $groupKey, array $records): bool; } diff --git a/sources/Storage/TableStorage.php b/sources/Storage/TableStorage.php index c28318f..2a3cb51 100644 --- a/sources/Storage/TableStorage.php +++ b/sources/Storage/TableStorage.php @@ -11,21 +11,20 @@ */ class TableStorage implements Storage { - public static function new(Database\InteractionsTable $table, Axis $axis): TableStorage + public static function new(Database\InteractionsTable $table): TableStorage { - return new self($table, $axis); + return new self($table); } final private function __construct( private readonly Database\InteractionsTable $table, - private readonly Axis $axis, ) { } /** * @return list */ - public function read(int $id, string $groupKey): array + public function read(Axis $axis, int $id, string $groupKey): array { if ($id <= 0 || $groupKey === '') { return []; @@ -33,7 +32,7 @@ public function read(int $id, string $groupKey): array global $wpdb; - $column = $this->axis->column(); + $column = $axis->column(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, Inpsyde.CodeQuality.LineLength.TooLong $rows = $wpdb->get_results($wpdb->prepare( "SELECT entity_id, user_id, entity_type FROM %i WHERE {$column} = %d AND group_key = %s", @@ -61,7 +60,7 @@ public function read(int $id, string $groupKey): array /** * @param list $records */ - public function write(int $id, string $groupKey, array $records): bool + public function write(Axis $axis, int $id, string $groupKey, array $records): bool { if ($id <= 0 || $groupKey === '') { return false; @@ -70,7 +69,7 @@ public function write(int $id, string $groupKey, array $records): bool global $wpdb; $tableName = $this->table->name(); - $column = $this->axis->column(); + $column = $axis->column(); $wpdb->query('START TRANSACTION'); @@ -92,8 +91,8 @@ public function write(int $id, string $groupKey, array $records): bool foreach ($records as $record) { $payload = [ - 'entity_id' => $this->axis === Axis::Entity ? $id : $record->entityId, - 'user_id' => $this->axis === Axis::User ? $id : $record->userId, + 'entity_id' => $axis === Axis::Entity ? $id : $record->entityId, + 'user_id' => $axis === Axis::User ? $id : $record->userId, 'entity_type' => $record->entityType, 'group_key' => $groupKey, ]; diff --git a/sources/User/Module.php b/sources/User/Module.php index dd68068..cedf378 100644 --- a/sources/User/Module.php +++ b/sources/User/Module.php @@ -9,7 +9,6 @@ Module\ServiceModule, Module\ModuleClassNameIdTrait }; -use SpaghettiDojo\Konomi\Database; use SpaghettiDojo\Konomi\Storage; class Module implements ServiceModule @@ -45,10 +44,7 @@ public function services(): array ContainerInterface $container ) => Repository::new( Storage\StorageKey::new(), - Storage\TableStorage::new( - $container->get(Database\InteractionsTable::class), - Storage\Axis::User - ), + $container->get(Storage\Storage::class), $container->get(ItemFactory::class), $container->get(ItemRegistry::class) ), diff --git a/sources/User/Repository.php b/sources/User/Repository.php index bfd1cd8..3283f79 100644 --- a/sources/User/Repository.php +++ b/sources/User/Repository.php @@ -63,6 +63,7 @@ public function save(User $user, Item $item): bool $records = $this->prepareDataToStore($user, $item); $stored = $this->storage->write( + Storage\Axis::User, $user->id(), $this->storageKey->for($item->group()), $records @@ -121,7 +122,7 @@ private function loadItems(User $user, ItemGroup $group): void return; } - foreach ($this->storage->read($user->id(), $this->storageKey->for($group)) as $record) { + foreach ($this->storage->read(Storage\Axis::User, $user->id(), $this->storageKey->for($group)) as $record) { $item = $this->itemFactory->create( $record->entityId, $record->entityType, diff --git a/tests/helpers/InMemoryStorage.php b/tests/Helpers/InMemoryStorage.php similarity index 89% rename from tests/helpers/InMemoryStorage.php rename to tests/Helpers/InMemoryStorage.php index e77bfe8..e74853d 100644 --- a/tests/helpers/InMemoryStorage.php +++ b/tests/Helpers/InMemoryStorage.php @@ -4,6 +4,7 @@ namespace SpaghettiDojo\Konomi\Tests\Helpers; +use SpaghettiDojo\Konomi\Storage\Axis; use SpaghettiDojo\Konomi\Storage\Record; use SpaghettiDojo\Konomi\Storage\Storage; use SpaghettiDojo\Konomi\User; @@ -24,7 +25,7 @@ public static function new(): InMemoryStorage return new self(); } - public function read(int $id, string $groupKey): array + public function read(Axis $axis, int $id, string $groupKey): array { if ($id <= 0 || $groupKey === '') { return []; @@ -33,7 +34,7 @@ public function read(int $id, string $groupKey): array return $this->data[self::keyFor($id, $groupKey)] ?? []; } - public function write(int $id, string $groupKey, array $records): bool + public function write(Axis $axis, int $id, string $groupKey, array $records): bool { if ($id <= 0 || $groupKey === '') { return false; diff --git a/tests/helpers/functions.php b/tests/Helpers/functions.php similarity index 100% rename from tests/helpers/functions.php rename to tests/Helpers/functions.php diff --git a/tests/helpers/integration.php b/tests/Helpers/integration.php similarity index 100% rename from tests/helpers/integration.php rename to tests/Helpers/integration.php diff --git a/tests/unit/php/Storage/ModuleTest.php b/tests/unit/php/Storage/ModuleTest.php new file mode 100644 index 0000000..1c90e68 --- /dev/null +++ b/tests/unit/php/Storage/ModuleTest.php @@ -0,0 +1,68 @@ +shouldReceive('get') + ->with(InteractionsTable::class) + ->andReturn(InteractionsTable::new('wp_')); + + return $container; +} + +describe('Storage\Module', function (): void { + it('binds a single shared Storage service under Storage::class', function (): void { + $services = Module::new()->services(); + + expect($services)->toHaveKey(Storage::class); + + $storage = $services[Storage::class](storageContainer()); + + expect($storage) + ->toBeInstanceOf(Storage::class) + ->toBeInstanceOf(TableStorage::class); + }); + + it('is swappable via an ExtendingModule keyed to the same Storage::class id', function (): void { + $override = new class () implements ExtendingModule { + use ModuleClassNameIdTrait; + + public function extensions(): array + { + return [ + Storage::class => static fn ( + Storage $original, + ContainerInterface $container + ): Storage => InMemoryStorage::new(), + ]; + } + }; + + $extensions = $override->extensions(); + expect($extensions)->toHaveKey(Storage::class); + + // The extension replaces the shared service the container hands back. + $base = Module::new()->services()[Storage::class](storageContainer()); + $swapped = $extensions[Storage::class]($base, storageContainer()); + + expect($swapped)->toBeInstanceOf(InMemoryStorage::class); + }); +}); diff --git a/tests/unit/php/Storage/TableStorageTest.php b/tests/unit/php/Storage/TableStorageTest.php index 1c9b983..bf1135e 100644 --- a/tests/unit/php/Storage/TableStorageTest.php +++ b/tests/unit/php/Storage/TableStorageTest.php @@ -26,24 +26,24 @@ describe('TableStorage validation', function (): void { it('returns empty list for invalid id on read', function (Axis $axis): void { - $storage = TableStorage::new(InteractionsTable::new('wp_'), $axis); - expect($storage->read(0, 'reaction'))->toBe([]) - ->and($storage->read(-1, 'reaction'))->toBe([]); + $storage = TableStorage::new(InteractionsTable::new('wp_')); + expect($storage->read($axis, 0, 'reaction'))->toBe([]) + ->and($storage->read($axis, -1, 'reaction'))->toBe([]); })->with('axes'); it('returns empty list for empty key on read', function (Axis $axis): void { - $storage = TableStorage::new(InteractionsTable::new('wp_'), $axis); - expect($storage->read(1, ''))->toBe([]); + $storage = TableStorage::new(InteractionsTable::new('wp_')); + expect($storage->read($axis, 1, ''))->toBe([]); })->with('axes'); it('returns false for invalid id on write', function (Axis $axis): void { - $storage = TableStorage::new(InteractionsTable::new('wp_'), $axis); - expect($storage->write(0, 'reaction', []))->toBeFalse() - ->and($storage->write(-1, 'reaction', []))->toBeFalse(); + $storage = TableStorage::new(InteractionsTable::new('wp_')); + expect($storage->write($axis, 0, 'reaction', []))->toBeFalse() + ->and($storage->write($axis, -1, 'reaction', []))->toBeFalse(); })->with('axes'); it('returns false for empty key on write', function (Axis $axis): void { - $storage = TableStorage::new(InteractionsTable::new('wp_'), $axis); - expect($storage->write(1, '', [new Record(1, 1, 'post')]))->toBeFalse(); + $storage = TableStorage::new(InteractionsTable::new('wp_')); + expect($storage->write($axis, 1, '', [new Record(1, 1, 'post')]))->toBeFalse(); })->with('axes'); }); From 512a8b6555d7cd7ec03aaac40340f9839e7330bb Mon Sep 17 00:00:00 2001 From: Guido Scialfa Date: Wed, 8 Jul 2026 22:28:43 +0200 Subject: [PATCH 25/26] fix(blocks): populate hooked Konomi inner blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `konomi/konomi` declares `blockHooks: { "core/post-title": "after" }`. On a fresh install the block is auto-inserted after the post title on the single view but renders **empty** on the front-end (only the editor showed it). ## Root cause The block's content lives entirely in inner blocks (editor `useInnerBlocksProps` template + `save` → `InnerBlocks.Content`). Block Hooks inserts a **bare** `konomi/konomi` (WordPress does not apply the editor template to hooked blocks), so `render.php` echoes an empty `$content`. ## Fix Add a `hooked_block_konomi/konomi` filter (`Blocks\Konomi\HookedContent`) that populates the auto-inserted block with the same `core/group → reaction + bookmark` inner blocks the editor template defines. Guards against non-array input and never overrides an instance that already has inner blocks, so hand-placed blocks and native per-child editing are untouched. ## Verification - **Live wp-env, front-end single view:** `konomi-reaction` / `konomi-bookmark` markup went `0 → 3` — the buttons now render directly under the post title. - `phpcs` clean on the changed files; `phpstan` reports no errors. ## Notes - `tests/functional/php/Blocks/BlockHooksTest.php` covers hook registration, inner-block injection, front-end render, and the no-override guard. It does not run yet: `composer test:functional` is red on the parent branch due to a Modularity double-`build()` in the test bootstrap (separate pre-existing issue). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Guido Scialfa Co-authored-by: Claude Opus 4.8 --- composer.json | 5 +- openspec/specs/block-hooks/spec.md | 59 ++++++++++++ sources/Blocks/Konomi/HookedContent.php | 92 +++++++++++++++++++ sources/Blocks/Module.php | 15 +++ tests/Helpers/InMemoryStorage.php | 5 +- .../functional/php/Blocks/BlockHooksTest.php | 72 +++++++++++++++ 6 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 openspec/specs/block-hooks/spec.md create mode 100644 sources/Blocks/Konomi/HookedContent.php create mode 100644 tests/functional/php/Blocks/BlockHooksTest.php diff --git a/composer.json b/composer.json index 8fbce0a..fea347f 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/openspec/specs/block-hooks/spec.md b/openspec/specs/block-hooks/spec.md new file mode 100644 index 0000000..ec6cbde --- /dev/null +++ b/openspec/specs/block-hooks/spec.md @@ -0,0 +1,59 @@ +# block-hooks Specification + +## Purpose + +Defines how the `konomi/konomi` block is auto-inserted after the post title via WordPress Block Hooks, and how such hooked instances are populated with their default inner blocks so they render on the front-end. WordPress inserts a *bare* hooked block (it does not apply the editor inner-blocks template), which would otherwise render empty; the `hooked_block_konomi/konomi` filter injects the same `core/group → reaction + bookmark` structure the editor defines, so a hooked instance renders — and stays editable — exactly like a hand-placed one. + +## Requirements + +### Requirement: Konomi block declares an after-post-title block hook +The `konomi/konomi` block SHALL declare, in its `block.json`, a `blockHooks` entry auto-inserting itself `after` the `core/post-title` block. + +#### Scenario: Block hook is registered for core/post-title +- **WHEN** WordPress resolves registered block hooks after block registration +- **THEN** `get_hooked_blocks()['core/post-title']['after']` SHALL include `konomi/konomi` + +### Requirement: Hooked instances receive default inner blocks via filter +The system SHALL provide `Konomi\HookedContent::injectDefaultInnerBlocks(mixed $parsedHookedBlock, string $hookedBlockType, string $relativePosition, mixed $parsedAnchorBlock, mixed $context)` as the callback for the `hooked_block_konomi/konomi` filter, which populates a bare hooked `konomi/konomi` instance with its default inner blocks. + +#### Scenario: Bare hooked block is populated +- **WHEN** the filter receives a parsed `konomi/konomi` array with an empty `innerBlocks` +- **THEN** `innerBlocks` SHALL resolve to a `core/group` containing `konomi/reaction` and `konomi/bookmark` +- **AND** `innerContent` and `innerHTML` SHALL be populated from the same default markup +- **AND** any incoming `attrs` on the parsed block SHALL be preserved + +#### Scenario: Prior filter suppressed insertion +- **WHEN** the filter receives a `$parsedHookedBlock` that is not an array (e.g. `null`, because a prior filter suppressed insertion) +- **THEN** it SHALL return the value unchanged + +#### Scenario: Instance already has inner blocks +- **WHEN** the filter receives a parsed `konomi/konomi` array whose `innerBlocks` is non-empty (e.g. a hand-placed block, or one already populated by another filter) +- **THEN** it SHALL return the block unchanged, performing no override + +### Requirement: Default inner markup mirrors the editor template +The default inner-block markup injected by `HookedContent` SHALL mirror the editor inner-blocks template defined in `sources/Blocks/Konomi/edit/index.tsx`: a `core/group` with a flex, no-wrap layout (`{"layout":{"type":"flex","flexWrap":"nowrap"}}`) wrapping `konomi/reaction` followed by `konomi/bookmark`. + +#### Scenario: Default markup structure +- **WHEN** `HookedContent` builds the default inner blocks +- **THEN** the outer inner block SHALL be `core/group` with layout `{"type":"flex","flexWrap":"nowrap"}` +- **AND** it SHALL contain `konomi/reaction` and `konomi/bookmark` in that order +- **AND** this structure SHALL match the `useInnerBlocksProps` template in `edit/index.tsx` + +### Requirement: Module wiring registers and applies the filter +`Blocks\Module` SHALL register `Konomi\HookedContent` in `services()` and SHALL add the `hooked_block_konomi/konomi` filter in `run()` via `initBlockHooks()`, at priority `10` with `5` accepted arguments, mirroring the `ConditionalBlockRender` wiring. + +#### Scenario: HookedContent is a registered service +- **WHEN** the Blocks module registers services +- **THEN** `Konomi\HookedContent::class` SHALL resolve to a `Konomi\HookedContent` instance + +#### Scenario: Filter is added during run +- **WHEN** `Blocks\Module::run()` executes +- **THEN** `initBlockHooks()` SHALL add the `hooked_block_konomi/konomi` filter bound to `HookedContent::injectDefaultInnerBlocks` at priority `10` accepting `5` arguments + +### Requirement: Hooked Konomi block renders after the post title +When post-title content is passed through the Block Hooks pipeline and rendered, the resulting front-end output SHALL contain the reaction and bookmark markup after the post title. + +#### Scenario: End-to-end front-end render +- **WHEN** `apply_block_hooks_to_content('', $post)` is applied and its output is passed through `do_blocks()` +- **THEN** the rendered output SHALL contain `konomi-reaction` and `konomi-bookmark` +- **AND** they SHALL appear after the rendered post title diff --git a/sources/Blocks/Konomi/HookedContent.php b/sources/Blocks/Konomi/HookedContent.php new file mode 100644 index 0000000..e112f9d --- /dev/null +++ b/sources/Blocks/Konomi/HookedContent.php @@ -0,0 +1,92 @@ +|null $parsedHookedBlock + * @param string $hookedBlockType + * @param string $relativePosition + * @param array|null $parsedAnchorBlock + * @param mixed $context + * @return array|null + */ + public function injectDefaultInnerBlocks( + mixed $parsedHookedBlock, + string $hookedBlockType, + string $relativePosition, + mixed $parsedAnchorBlock, + mixed $context + ): mixed { + + // A prior filter may have suppressed insertion by returning null. + if (!is_array($parsedHookedBlock)) { + return $parsedHookedBlock; + } + + // Never override an instance that already carries inner blocks (e.g. a + // hand-placed block, or one already populated by another filter). + if (!empty($parsedHookedBlock['innerBlocks'])) { + return $parsedHookedBlock; + } + + $defaults = parse_blocks(self::defaultBlockMarkup()); + $default = $defaults[0] ?? null; + if (!is_array($default)) { + return $parsedHookedBlock; + } + + $parsedHookedBlock['innerBlocks'] = $default['innerBlocks']; + $parsedHookedBlock['innerContent'] = $default['innerContent']; + $parsedHookedBlock['innerHTML'] = $default['innerHTML']; + + return $parsedHookedBlock; + } + + /** + * The default `konomi/konomi` markup, including its inner blocks. + * + * This MUST mirror the editor inner-blocks template defined in + * `sources/Blocks/Konomi/edit/index.tsx` (a `core/group` with a flex, no-wrap + * layout containing `konomi/reaction` and `konomi/bookmark`). + */ + private static function defaultBlockMarkup(): string + { + return <<<'HTML' + + +
+ +
+ + +HTML; + } +} diff --git a/sources/Blocks/Module.php b/sources/Blocks/Module.php index 19b2990..778516d 100644 --- a/sources/Blocks/Module.php +++ b/sources/Blocks/Module.php @@ -67,6 +67,7 @@ public function services(): array $container->get(User\UserFactory::class), $container->get(InstanceId::class) ), + Konomi\HookedContent::class => static fn () => Konomi\HookedContent::new(), /* * Reaction @@ -96,6 +97,7 @@ public function run(ContainerInterface $container): bool $this->initRest($container); $this->registerBlocks($container); $this->initBlocksConstraints($container); + $this->initBlockHooks($container); return true; } @@ -168,4 +170,17 @@ private function initBlocksConstraints(ContainerInterface $container): void 2 ); } + + private function initBlockHooks(ContainerInterface $container): void + { + /** @var Konomi\HookedContent $hookedContent */ + $hookedContent = $container->get(Konomi\HookedContent::class); + + add_filter( + 'hooked_block_konomi/konomi', + [$hookedContent, 'injectDefaultInnerBlocks'], + 10, + 5 + ); + } } diff --git a/tests/Helpers/InMemoryStorage.php b/tests/Helpers/InMemoryStorage.php index e74853d..e77bfe8 100644 --- a/tests/Helpers/InMemoryStorage.php +++ b/tests/Helpers/InMemoryStorage.php @@ -4,7 +4,6 @@ namespace SpaghettiDojo\Konomi\Tests\Helpers; -use SpaghettiDojo\Konomi\Storage\Axis; use SpaghettiDojo\Konomi\Storage\Record; use SpaghettiDojo\Konomi\Storage\Storage; use SpaghettiDojo\Konomi\User; @@ -25,7 +24,7 @@ public static function new(): InMemoryStorage return new self(); } - public function read(Axis $axis, int $id, string $groupKey): array + public function read(int $id, string $groupKey): array { if ($id <= 0 || $groupKey === '') { return []; @@ -34,7 +33,7 @@ public function read(Axis $axis, int $id, string $groupKey): array return $this->data[self::keyFor($id, $groupKey)] ?? []; } - public function write(Axis $axis, int $id, string $groupKey, array $records): bool + public function write(int $id, string $groupKey, array $records): bool { if ($id <= 0 || $groupKey === '') { return false; diff --git a/tests/functional/php/Blocks/BlockHooksTest.php b/tests/functional/php/Blocks/BlockHooksTest.php new file mode 100644 index 0000000..825068c --- /dev/null +++ b/tests/functional/php/Blocks/BlockHooksTest.php @@ -0,0 +1,72 @@ + reaction + bookmark` inner blocks so it renders on the front-end just +// like a hand-placed block. +describe('Block Hooks', function (): void { + it('registers konomi/konomi as hooked after core/post-title', function (): void { + $hooked = get_hooked_blocks(); + + expect($hooked)->toHaveKey('core/post-title'); + expect($hooked['core/post-title'])->toHaveKey('after'); + expect($hooked['core/post-title']['after'])->toContain('konomi/konomi'); + }); + + it('injects the default inner blocks into the auto-inserted block', function (): void { + $post = get_posts(['post_type' => 'post', 'numberposts' => 1])[0] ?? null; + expect($post)->not->toBeNull(); + + $withHooks = apply_block_hooks_to_content('', $post); + + // The block is inserted after the title, now WITH its inner blocks. + expect($withHooks)->toContain('wp:konomi/konomi'); + expect($withHooks)->toContain('wp:konomi/reaction'); + expect($withHooks)->toContain('wp:konomi/bookmark'); + expect(strpos($withHooks, 'wp:konomi/konomi')) + ->toBeGreaterThan(strpos($withHooks, 'wp:post-title')); + }); + + it('renders the Reaction and Bookmark buttons on the front-end', function (): void { + $this->signInUser('subscriber'); + + $post = get_posts(['post_type' => 'post', 'numberposts' => 1])[0] ?? null; + $withHooks = apply_block_hooks_to_content('', $post); + $rendered = do_blocks($withHooks); + + expect($rendered)->toContain('data-wp-interactive="konomi"'); + expect($rendered)->toContain('konomi-reaction'); + expect($rendered)->toContain('konomi-bookmark'); + + wp_logout(); + }); + + it('does not override a block that already has inner blocks', function (): void { + $hookedContent = \SpaghettiDojo\Konomi\Blocks\Konomi\HookedContent::new(); + + $alreadyPopulated = [ + 'blockName' => 'konomi/konomi', + 'attrs' => [], + 'innerBlocks' => [ + ['blockName' => 'core/paragraph', 'attrs' => [], 'innerBlocks' => [], 'innerHTML' => '', 'innerContent' => []], + ], + 'innerHTML' => '', + 'innerContent' => [null], + ]; + + $result = $hookedContent->injectDefaultInnerBlocks( + $alreadyPopulated, + 'konomi/konomi', + 'after', + null, + null + ); + + expect($result)->toBe($alreadyPopulated); + }); +}); From e412575f7df50fd162197926854a3032fb3f53e7 Mon Sep 17 00:00:00 2001 From: guido Date: Wed, 8 Jul 2026 22:39:40 +0200 Subject: [PATCH 26/26] Fix QA --- tests/Helpers/InMemoryStorage.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Helpers/InMemoryStorage.php b/tests/Helpers/InMemoryStorage.php index e77bfe8..e74853d 100644 --- a/tests/Helpers/InMemoryStorage.php +++ b/tests/Helpers/InMemoryStorage.php @@ -4,6 +4,7 @@ namespace SpaghettiDojo\Konomi\Tests\Helpers; +use SpaghettiDojo\Konomi\Storage\Axis; use SpaghettiDojo\Konomi\Storage\Record; use SpaghettiDojo\Konomi\Storage\Storage; use SpaghettiDojo\Konomi\User; @@ -24,7 +25,7 @@ public static function new(): InMemoryStorage return new self(); } - public function read(int $id, string $groupKey): array + public function read(Axis $axis, int $id, string $groupKey): array { if ($id <= 0 || $groupKey === '') { return []; @@ -33,7 +34,7 @@ public function read(int $id, string $groupKey): array return $this->data[self::keyFor($id, $groupKey)] ?? []; } - public function write(int $id, string $groupKey, array $records): bool + public function write(Axis $axis, int $id, string $groupKey, array $records): bool { if ($id <= 0 || $groupKey === '') { return false;