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:
28
framework/php/src/BridgeOp.php
Normal file
28
framework/php/src/BridgeOp.php
Normal 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';
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
23
framework/php/src/CorrelationContextInterface.php
Normal file
23
framework/php/src/CorrelationContextInterface.php
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
23
framework/php/src/ModelPublisherInterface.php
Normal file
23
framework/php/src/ModelPublisherInterface.php
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
28
framework/php/src/PublisherInterface.php
Normal file
28
framework/php/src/PublisherInterface.php
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user