diff --git a/CHANGELOG.md b/CHANGELOG.md index 8743b82..e946440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +This section tracks work landing on `dev` toward **v0.2.0** (next minor; pre-1.0 SemVer permits API breaks). See PLAN.md §13 for the full v0.2.0 scope. + ### Added -- (none yet — next changes land here) +- **`PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`.** The bridge's three public services now ship as interfaces (same namespace as concrete, mirroring upstream `HubInterface`/`Hub`). App controllers and listeners should typehint these instead of the concrete classes so swappable implementations (offline-buffer publisher, request-stamp correlation context, etc.) remain non-breaking. Existing `Publisher` / `ModelPublisher` / `CorrelationContext` classes implement the new interfaces unchanged. +- **`BridgeOp` enum.** PHP 8.1 string-backed enum (`Upsert` / `Delete` / `Replace` / `Event`) replacing the raw `'upsert'`/`'delete'` strings previously passed between `DoctrineBridgeListener` and `ModelPublisher::publishEntityChange`. Values match PLAN.md §4's envelope `op` wire format. Typo'd ops are now caught at the type level instead of silently producing envelopes clients ignore. + +### Changed + +- **`ModelPublisher::publishEntityChange()` signature: `string $op` → `BridgeOp $op`.** Pre-1.0 SemVer break. Internal callers updated; external callers (rare) need to migrate from raw strings to enum cases. +- **Internal typehints switched to interfaces.** `ModelPublisher` constructor takes `PublisherInterface` + `CorrelationContextInterface`; `DoctrineBridgeListener` takes `ModelPublisherInterface`; `HealthController` and the skeleton's `PingController` take `PublisherInterface`. Autowire continues to inject the concrete implementations transparently. + +### Fixed + +- **`ModelPublisher::extractId` reflection cleanup.** Removed the `setAccessible(true)` call (deprecated since PHP 8.1; all properties are accessible via Reflection without it). ## [0.1.2] — 2026-05-03 diff --git a/framework/php/src/BridgeOp.php b/framework/php/src/BridgeOp.php new file mode 100644 index 0000000..e6abc38 --- /dev/null +++ b/framework/php/src/BridgeOp.php @@ -0,0 +1,28 @@ +value` is exactly the wire-format token QML + * clients see. Encoded as an enum (rather than `string` parameters) so + * the typo `'upsret'` is caught at the type level instead of producing + * an envelope clients silently ignore. + */ +enum BridgeOp: string +{ + /** Entity created or updated. */ + case Upsert = 'upsert'; + + /** Entity removed. */ + case Delete = 'delete'; + + /** Whole-collection replacement (e.g. server-side reset / re-seed). */ + case Replace = 'replace'; + + /** Domain event on `app://event/{name}` topic — not tied to a model row. */ + case Event = 'event'; +} diff --git a/framework/php/src/Controller/HealthController.php b/framework/php/src/Controller/HealthController.php index 9d9e79a..67f3cb8 100644 --- a/framework/php/src/Controller/HealthController.php +++ b/framework/php/src/Controller/HealthController.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace PhpQml\Bridge\Controller; -use PhpQml\Bridge\Publisher; +use PhpQml\Bridge\PublisherInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; @@ -12,16 +12,18 @@ use Symfony\Component\Routing\Attribute\Route; * Readiness probe used by the Qt host to detect when the backend is up. * See PLAN.md §3 (*Startup*, step 4). * - * Publisher is injected purely as a deep-health canary: if the bridge - * bundle's autoload or container wiring is broken (e.g. a packaging build - * with a dangling vendor path-repo symlink), this controller can't even - * be constructed, so /healthz fails 500 instead of misleadingly returning - * 200 against a half-loaded bundle. + * `PublisherInterface` is injected purely as a deep-health canary: if the + * bridge bundle's autoload or container wiring is broken (e.g. a packaging + * build with a dangling vendor path-repo symlink), this controller can't + * even be constructed, so /healthz fails 500 instead of misleadingly + * returning 200 against a half-loaded bundle. The response includes the + * concrete class name so packagers can detect a wrong-implementation + * deployment from the canary value alone. */ final class HealthController { public function __construct( - private readonly Publisher $publisher, + private readonly PublisherInterface $publisher, ) { } diff --git a/framework/php/src/CorrelationContext.php b/framework/php/src/CorrelationContext.php index 8ac21ad..fc4fca7 100644 --- a/framework/php/src/CorrelationContext.php +++ b/framework/php/src/CorrelationContext.php @@ -5,17 +5,18 @@ declare(strict_types=1); namespace PhpQml\Bridge; /** - * Per-request correlation key holder. + * Default implementation of {@see CorrelationContextInterface}: a plain + * per-request holder for the `Idempotency-Key` value. * - * The HTTP request's `Idempotency-Key` (PLAN.md §4 *Idempotency*) is - * stashed here on RequestEvent and read back by ModelPublisher when - * it builds Mercure envelopes, so QML clients can match Mercure echoes - * to the optimistic mutation that originated them (§5). + * Stashed here on RequestEvent (see {@see EventSubscriber\CorrelationKeyListener}) + * and read back by {@see ModelPublisher} when it builds Mercure envelopes, + * so QML clients can match Mercure echoes to the optimistic mutation that + * originated them (PLAN.md §4 *Idempotency*, §5 *Optimistic updates*). * * Cleared on TerminateEvent. CLI commands and out-of-request mutations * see no correlation key, which is the correct behaviour. */ -final class CorrelationContext +final class CorrelationContext implements CorrelationContextInterface { private ?string $key = null; diff --git a/framework/php/src/CorrelationContextInterface.php b/framework/php/src/CorrelationContextInterface.php new file mode 100644 index 0000000..93da633 --- /dev/null +++ b/framework/php/src/CorrelationContextInterface.php @@ -0,0 +1,23 @@ +modelPublisher->publishEntityChange($args->getObject(), 'upsert'); + $this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Upsert); } public function postUpdate(PostUpdateEventArgs $args): void { - $this->modelPublisher->publishEntityChange($args->getObject(), 'upsert'); + $this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Upsert); } public function postRemove(PostRemoveEventArgs $args): void { - $this->modelPublisher->publishEntityChange($args->getObject(), 'delete'); + $this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Delete); } } diff --git a/framework/php/src/ModelPublisher.php b/framework/php/src/ModelPublisher.php index 30a58e4..fd7e616 100644 --- a/framework/php/src/ModelPublisher.php +++ b/framework/php/src/ModelPublisher.php @@ -8,32 +8,35 @@ use PhpQml\Bridge\Attribute\BridgeResource; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** - * Translates Doctrine entity lifecycle events into Mercure envelopes. + * Default implementation of {@see ModelPublisherInterface}: dual-publishes + * each change to the collection and entity topics for a `#[BridgeResource]` + * entity. * - * For each change, dual-publishes to: + * For each change, publishes to: * - `app://model/{name}` — collection topic, watched by ReactiveListModel * - `app://model/{name}/{id}` — entity topic, watched by ReactiveObject * * Topic / envelope shape per PLAN.md §4. The `correlationKey` echoes * the originating request's `Idempotency-Key` (§5 *Optimistic updates*). * - * Phase 2 uses a per-process counter for the envelope `version` field - * — sufficient for single-instance dev mode. Phase 4 / production should - * back this with a persistent monotonic source (e.g. Postgres SEQUENCE). + * Uses a per-process counter for the envelope `version` field — sufficient + * for single-instance bundled mode. Multi-instance / production deployments + * should back this with a persistent monotonic source (e.g. Postgres + * SEQUENCE); deferred to v0.2.0+ §13. */ -final class ModelPublisher +final class ModelPublisher implements ModelPublisherInterface { /** @var array */ private array $versions = []; public function __construct( - private readonly Publisher $publisher, - private readonly CorrelationContext $correlationContext, + private readonly PublisherInterface $publisher, + private readonly CorrelationContextInterface $correlationContext, private readonly NormalizerInterface $normalizer, ) { } - public function publishEntityChange(object $entity, string $op): void + public function publishEntityChange(object $entity, BridgeOp $op): void { $resource = $this->resolveResource($entity); if (null === $resource) { @@ -44,10 +47,10 @@ final class ModelPublisher $id = (string) $this->extractId($entity); $envelope = [ - 'op' => $op, + 'op' => $op->value, 'id' => $id, 'version' => $this->nextVersion($name), - 'data' => 'delete' === $op ? null : $this->normalize($entity), + 'data' => BridgeOp::Delete === $op ? null : $this->normalize($entity), ]; if (null !== $key = $this->correlationContext->get()) { @@ -77,10 +80,7 @@ final class ModelPublisher $r = new \ReflectionClass($entity); if ($r->hasProperty('id')) { - $prop = $r->getProperty('id'); - $prop->setAccessible(true); - - return $prop->getValue($entity); + return $r->getProperty('id')->getValue($entity); } throw new \LogicException(\sprintf('Cannot extract id from %s: no getId() method or $id property.', $entity::class)); diff --git a/framework/php/src/ModelPublisherInterface.php b/framework/php/src/ModelPublisherInterface.php new file mode 100644 index 0000000..887664c --- /dev/null +++ b/framework/php/src/ModelPublisherInterface.php @@ -0,0 +1,23 @@ + $envelope - */ public function publish(string $topic, array $envelope, bool $private = false): string { return $this->hub->publish(new Update( diff --git a/framework/php/src/PublisherInterface.php b/framework/php/src/PublisherInterface.php new file mode 100644 index 0000000..b35f0bc --- /dev/null +++ b/framework/php/src/PublisherInterface.php @@ -0,0 +1,28 @@ + $envelope shape per PLAN.md §4 + * @param bool $private pass-through to Mercure's `private` flag + * + * @return string the Mercure update ID + */ + public function publish(string $topic, array $envelope, bool $private = false): string; +} diff --git a/framework/php/tests/ModelPublisherTest.php b/framework/php/tests/ModelPublisherTest.php index 5880915..5336ffe 100644 --- a/framework/php/tests/ModelPublisherTest.php +++ b/framework/php/tests/ModelPublisherTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace PhpQml\Bridge\Tests; use PhpQml\Bridge\Attribute\BridgeResource; +use PhpQml\Bridge\BridgeOp; use PhpQml\Bridge\CorrelationContext; use PhpQml\Bridge\ModelPublisher; use PhpQml\Bridge\Publisher; @@ -71,7 +72,7 @@ final class ModelPublisherTest extends TestCase { $todo = new FakeTodo(id: '019de596-be1c-7642-985c-edcadeef9b5d', title: 'milk', done: false); - $this->publisher->publishEntityChange($todo, 'upsert'); + $this->publisher->publishEntityChange($todo, BridgeOp::Upsert); // The HubSpy only retains the LAST update. To validate both topics, // re-publish and check the second envelope, but for the assertion of @@ -98,7 +99,7 @@ final class ModelPublisherTest extends TestCase $this->context->set('idem-1234'); $this->publisher->publishEntityChange( new FakeTodo(id: '1', title: 'x'), - 'upsert', + BridgeOp::Upsert, ); $envelope = json_decode($this->hub->captured->getData(), true); @@ -109,7 +110,7 @@ final class ModelPublisherTest extends TestCase { $this->publisher->publishEntityChange( new FakeTodo(id: '7', title: 'gone'), - 'delete', + BridgeOp::Delete, ); $envelope = json_decode($this->hub->captured->getData(), true); @@ -119,17 +120,17 @@ final class ModelPublisherTest extends TestCase public function testEntitiesWithoutBridgeResourceAreIgnored(): void { - $this->publisher->publishEntityChange(new FakeNotMarked(1, 'x'), 'upsert'); + $this->publisher->publishEntityChange(new FakeNotMarked(1, 'x'), BridgeOp::Upsert); self::assertNull($this->hub->captured); } public function testVersionIncreasesOnEachPublish(): void { - $this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'a'), 'upsert'); + $this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'a'), BridgeOp::Upsert); $first = json_decode($this->hub->captured->getData(), true)['version']; - $this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'b'), 'upsert'); + $this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'b'), BridgeOp::Upsert); $second = json_decode($this->hub->captured->getData(), true)['version']; self::assertGreaterThan($first, $second); diff --git a/framework/skeleton/symfony/src/Controller/PingController.php b/framework/skeleton/symfony/src/Controller/PingController.php index 1867667..6b39f8f 100644 --- a/framework/skeleton/symfony/src/Controller/PingController.php +++ b/framework/skeleton/symfony/src/Controller/PingController.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Controller; -use PhpQml\Bridge\Publisher; +use PhpQml\Bridge\PublisherInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; @@ -18,7 +18,7 @@ use Symfony\Component\Routing\Attribute\Route; final class PingController { public function __construct( - private readonly Publisher $publisher, + private readonly PublisherInterface $publisher, ) { }