v0.2.0 (1/N): public API surface — interfaces + BridgeOp enum

Establishes the contract layer the rest of v0.2.0 builds on. Pre-1.0
SemVer break: ModelPublisher::publishEntityChange() now takes BridgeOp
instead of a raw string.

Interfaces (Symfony idiom: same namespace as concrete, like HubInterface
next to Hub):
  - PublisherInterface — publish(string, array, bool)
  - ModelPublisherInterface — publishEntityChange(object, BridgeOp)
  - CorrelationContextInterface — set/get/clear

App code should typehint these instead of the concretes so swappable
implementations (offline-buffer publisher, multi-hub fan-out, request-
stamp correlation) remain non-breaking. Concrete classes implement them
unchanged; autowire continues to inject the implementations transparently.

BridgeOp: PHP 8.1 string-backed enum with cases Upsert / Delete /
Replace / Event matching PLAN.md §4's envelope `op` wire format.
Internal call sites updated; tests use the cases directly.

Switched typehints:
  - ModelPublisher ctor: PublisherInterface + CorrelationContextInterface
  - DoctrineBridgeListener ctor: ModelPublisherInterface
  - HealthController ctor: PublisherInterface (still emits `Publisher`
    as bundle canary value — `::class` resolves to the concrete class
    name regardless of typehint, so bundled-supervisor.sh's grep stays
    green)
  - skeleton PingController ctor: PublisherInterface (canonical app
    pattern — example/todo has no Publisher consumer to update)

Drive-by: removed deprecated setAccessible(true) call in
ModelPublisher::extractId — PHP 8.1+ allows reflection without it.

PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot all pass; dev
container compiles in the example app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:50:01 +02:00
parent 4d6b9fde2c
commit 56e3d671d9
12 changed files with 168 additions and 49 deletions

View File

@@ -6,9 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased] ## [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 ### 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 ## [0.1.2] — 2026-05-03

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge;
/**
* The four `op` values used in the bridge's Mercure envelopes (PLAN.md §4).
*
* String-backed so `$op->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';
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Controller; namespace PhpQml\Bridge\Controller;
use PhpQml\Bridge\Publisher; use PhpQml\Bridge\PublisherInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route; 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. * Readiness probe used by the Qt host to detect when the backend is up.
* See PLAN.md §3 (*Startup*, step 4). * See PLAN.md §3 (*Startup*, step 4).
* *
* Publisher is injected purely as a deep-health canary: if the bridge * `PublisherInterface` is injected purely as a deep-health canary: if the
* bundle's autoload or container wiring is broken (e.g. a packaging build * bridge bundle's autoload or container wiring is broken (e.g. a packaging
* with a dangling vendor path-repo symlink), this controller can't even * build with a dangling vendor path-repo symlink), this controller can't
* be constructed, so /healthz fails 500 instead of misleadingly returning * even be constructed, so /healthz fails 500 instead of misleadingly
* 200 against a half-loaded bundle. * 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 final class HealthController
{ {
public function __construct( public function __construct(
private readonly Publisher $publisher, private readonly PublisherInterface $publisher,
) { ) {
} }

View File

@@ -5,17 +5,18 @@ declare(strict_types=1);
namespace PhpQml\Bridge; 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 (see {@see EventSubscriber\CorrelationKeyListener})
* stashed here on RequestEvent and read back by ModelPublisher when * and read back by {@see ModelPublisher} when it builds Mercure envelopes,
* it builds Mercure envelopes, so QML clients can match Mercure echoes * so QML clients can match Mercure echoes to the optimistic mutation that
* to the optimistic mutation that originated them (§5). * originated them (PLAN.md §4 *Idempotency*, §5 *Optimistic updates*).
* *
* Cleared on TerminateEvent. CLI commands and out-of-request mutations * Cleared on TerminateEvent. CLI commands and out-of-request mutations
* see no correlation key, which is the correct behaviour. * see no correlation key, which is the correct behaviour.
*/ */
final class CorrelationContext final class CorrelationContext implements CorrelationContextInterface
{ {
private ?string $key = null; private ?string $key = null;

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge;
/**
* Per-request holder for the `Idempotency-Key` echoed back as the
* `correlationKey` field in Mercure envelopes (PLAN.md §4 *Idempotency*,
* §5 *Optimistic updates*).
*
* The public API surface of `CorrelationContext`. Apps can swap the
* implementation if they need request-scoped storage with different
* semantics (e.g. message-bus stamps for async dispatch).
*/
interface CorrelationContextInterface
{
public function set(?string $key): void;
public function get(): ?string;
public function clear(): void;
}

View File

@@ -9,7 +9,8 @@ use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostRemoveEventArgs; use Doctrine\ORM\Event\PostRemoveEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs; use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use PhpQml\Bridge\ModelPublisher; use PhpQml\Bridge\BridgeOp;
use PhpQml\Bridge\ModelPublisherInterface;
/** /**
* Bridges Doctrine entity lifecycle events to Mercure publishes. * Bridges Doctrine entity lifecycle events to Mercure publishes.
@@ -23,22 +24,22 @@ use PhpQml\Bridge\ModelPublisher;
final readonly class DoctrineBridgeListener final readonly class DoctrineBridgeListener
{ {
public function __construct( public function __construct(
private ModelPublisher $modelPublisher, private ModelPublisherInterface $modelPublisher,
) { ) {
} }
public function postPersist(PostPersistEventArgs $args): void public function postPersist(PostPersistEventArgs $args): void
{ {
$this->modelPublisher->publishEntityChange($args->getObject(), 'upsert'); $this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Upsert);
} }
public function postUpdate(PostUpdateEventArgs $args): void 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 public function postRemove(PostRemoveEventArgs $args): void
{ {
$this->modelPublisher->publishEntityChange($args->getObject(), 'delete'); $this->modelPublisher->publishEntityChange($args->getObject(), BridgeOp::Delete);
} }
} }

View File

@@ -8,32 +8,35 @@ use PhpQml\Bridge\Attribute\BridgeResource;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; 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}` — collection topic, watched by ReactiveListModel
* - `app://model/{name}/{id}` — entity topic, watched by ReactiveObject * - `app://model/{name}/{id}` — entity topic, watched by ReactiveObject
* *
* Topic / envelope shape per PLAN.md §4. The `correlationKey` echoes * Topic / envelope shape per PLAN.md §4. The `correlationKey` echoes
* the originating request's `Idempotency-Key` (§5 *Optimistic updates*). * the originating request's `Idempotency-Key` (§5 *Optimistic updates*).
* *
* Phase 2 uses a per-process counter for the envelope `version` field * Uses a per-process counter for the envelope `version` field — sufficient
* — sufficient for single-instance dev mode. Phase 4 / production should * for single-instance bundled mode. Multi-instance / production deployments
* back this with a persistent monotonic source (e.g. Postgres SEQUENCE). * 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<string, int> */ /** @var array<string, int> */
private array $versions = []; private array $versions = [];
public function __construct( public function __construct(
private readonly Publisher $publisher, private readonly PublisherInterface $publisher,
private readonly CorrelationContext $correlationContext, private readonly CorrelationContextInterface $correlationContext,
private readonly NormalizerInterface $normalizer, private readonly NormalizerInterface $normalizer,
) { ) {
} }
public function publishEntityChange(object $entity, string $op): void public function publishEntityChange(object $entity, BridgeOp $op): void
{ {
$resource = $this->resolveResource($entity); $resource = $this->resolveResource($entity);
if (null === $resource) { if (null === $resource) {
@@ -44,10 +47,10 @@ final class ModelPublisher
$id = (string) $this->extractId($entity); $id = (string) $this->extractId($entity);
$envelope = [ $envelope = [
'op' => $op, 'op' => $op->value,
'id' => $id, 'id' => $id,
'version' => $this->nextVersion($name), '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()) { if (null !== $key = $this->correlationContext->get()) {
@@ -77,10 +80,7 @@ final class ModelPublisher
$r = new \ReflectionClass($entity); $r = new \ReflectionClass($entity);
if ($r->hasProperty('id')) { if ($r->hasProperty('id')) {
$prop = $r->getProperty('id'); return $r->getProperty('id')->getValue($entity);
$prop->setAccessible(true);
return $prop->getValue($entity);
} }
throw new \LogicException(\sprintf('Cannot extract id from %s: no getId() method or $id property.', $entity::class)); throw new \LogicException(\sprintf('Cannot extract id from %s: no getId() method or $id property.', $entity::class));

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge;
/**
* Translates a Doctrine entity lifecycle event into the bridge's Mercure
* envelopes (collection topic + entity topic) per PLAN.md §6.
*
* The public API surface of `ModelPublisher`. App code that wants to
* republish a model change manually (rare — the Doctrine listener covers
* the common case) should typehint this interface.
*/
interface ModelPublisherInterface
{
/**
* Publish an `upsert` / `delete` / `replace` envelope for the given
* `#[BridgeResource]` entity. Entities not tagged with the attribute
* are silently ignored.
*/
public function publishEntityChange(object $entity, BridgeOp $op): void;
}

View File

@@ -8,22 +8,22 @@ use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update; use Symfony\Component\Mercure\Update;
/** /**
* Publishes envelopes onto the bridge's Mercure hub. * Default implementation of {@see PublisherInterface}: a thin wrapper over
* `symfony/mercure`'s `HubInterface` that JSON-encodes the envelope and
* forwards the publish.
* *
* Topic conventions and envelope shape are defined in PLAN.md §4. * Topic conventions and envelope shape are defined in PLAN.md §4.
* Reactive-model-aware helpers (publishModelUpdate, etc.) arrive with * Application code should typehint `PublisherInterface` instead of this
* the model layer in Phase 2. * concrete class so swappable implementations (offline buffer, multi-hub
* fan-out) remain a non-breaking change.
*/ */
final readonly class Publisher final readonly class Publisher implements PublisherInterface
{ {
public function __construct( public function __construct(
private HubInterface $hub, private HubInterface $hub,
) { ) {
} }
/**
* @param array<string, mixed> $envelope
*/
public function publish(string $topic, array $envelope, bool $private = false): string public function publish(string $topic, array $envelope, bool $private = false): string
{ {
return $this->hub->publish(new Update( return $this->hub->publish(new Update(

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge;
/**
* Publishes envelopes onto the bridge's Mercure hub.
*
* The public API surface of `Publisher`. App code should typehint this
* interface — same idiom as upstream `HubInterface` / `EventDispatcherInterface`
* — so the implementation can evolve (e.g. an offline-buffering decorator,
* a multi-hub fan-out) without breaking consumers.
*
* Topic conventions and envelope shape are defined in PLAN.md §4.
*/
interface PublisherInterface
{
/**
* Publish an envelope onto a Mercure topic.
*
* @param array<string, mixed> $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;
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace PhpQml\Bridge\Tests; namespace PhpQml\Bridge\Tests;
use PhpQml\Bridge\Attribute\BridgeResource; use PhpQml\Bridge\Attribute\BridgeResource;
use PhpQml\Bridge\BridgeOp;
use PhpQml\Bridge\CorrelationContext; use PhpQml\Bridge\CorrelationContext;
use PhpQml\Bridge\ModelPublisher; use PhpQml\Bridge\ModelPublisher;
use PhpQml\Bridge\Publisher; 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); $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, // The HubSpy only retains the LAST update. To validate both topics,
// re-publish and check the second envelope, but for the assertion of // 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->context->set('idem-1234');
$this->publisher->publishEntityChange( $this->publisher->publishEntityChange(
new FakeTodo(id: '1', title: 'x'), new FakeTodo(id: '1', title: 'x'),
'upsert', BridgeOp::Upsert,
); );
$envelope = json_decode($this->hub->captured->getData(), true); $envelope = json_decode($this->hub->captured->getData(), true);
@@ -109,7 +110,7 @@ final class ModelPublisherTest extends TestCase
{ {
$this->publisher->publishEntityChange( $this->publisher->publishEntityChange(
new FakeTodo(id: '7', title: 'gone'), new FakeTodo(id: '7', title: 'gone'),
'delete', BridgeOp::Delete,
); );
$envelope = json_decode($this->hub->captured->getData(), true); $envelope = json_decode($this->hub->captured->getData(), true);
@@ -119,17 +120,17 @@ final class ModelPublisherTest extends TestCase
public function testEntitiesWithoutBridgeResourceAreIgnored(): void 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); self::assertNull($this->hub->captured);
} }
public function testVersionIncreasesOnEachPublish(): void 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']; $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']; $second = json_decode($this->hub->captured->getData(), true)['version'];
self::assertGreaterThan($first, $second); self::assertGreaterThan($first, $second);

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use PhpQml\Bridge\Publisher; use PhpQml\Bridge\PublisherInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -18,7 +18,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class PingController final class PingController
{ {
public function __construct( public function __construct(
private readonly Publisher $publisher, private readonly PublisherInterface $publisher,
) { ) {
} }