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

@@ -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;
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,
) {
}

View File

@@ -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;

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\PostUpdateEventArgs;
use Doctrine\ORM\Events;
use PhpQml\Bridge\ModelPublisher;
use PhpQml\Bridge\BridgeOp;
use PhpQml\Bridge\ModelPublisherInterface;
/**
* Bridges Doctrine entity lifecycle events to Mercure publishes.
@@ -23,22 +24,22 @@ use PhpQml\Bridge\ModelPublisher;
final readonly class DoctrineBridgeListener
{
public function __construct(
private ModelPublisher $modelPublisher,
private ModelPublisherInterface $modelPublisher,
) {
}
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
{
$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);
}
}

View File

@@ -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<string, int> */
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));

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;
/**
* 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.
* Reactive-model-aware helpers (publishModelUpdate, etc.) arrive with
* the model layer in Phase 2.
* Application code should typehint `PublisherInterface` instead of this
* 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(
private HubInterface $hub,
) {
}
/**
* @param array<string, mixed> $envelope
*/
public function publish(string $topic, array $envelope, bool $private = false): string
{
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;
}