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>
118 lines
3.7 KiB
PHP
118 lines
3.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace PhpQml\Bridge;
|
|
|
|
use PhpQml\Bridge\Attribute\BridgeResource;
|
|
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
|
|
|
/**
|
|
* Default implementation of {@see ModelPublisherInterface}: dual-publishes
|
|
* each change to the collection and entity topics for a `#[BridgeResource]`
|
|
* entity.
|
|
*
|
|
* 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*).
|
|
*
|
|
* 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 implements ModelPublisherInterface
|
|
{
|
|
/** @var array<string, int> */
|
|
private array $versions = [];
|
|
|
|
public function __construct(
|
|
private readonly PublisherInterface $publisher,
|
|
private readonly CorrelationContextInterface $correlationContext,
|
|
private readonly NormalizerInterface $normalizer,
|
|
) {
|
|
}
|
|
|
|
public function publishEntityChange(object $entity, BridgeOp $op): void
|
|
{
|
|
$resource = $this->resolveResource($entity);
|
|
if (null === $resource) {
|
|
return; // not a #[BridgeResource]
|
|
}
|
|
|
|
$name = $resource->name ?? self::deriveName($entity::class);
|
|
$id = (string) $this->extractId($entity);
|
|
|
|
$envelope = [
|
|
'op' => $op->value,
|
|
'id' => $id,
|
|
'version' => $this->nextVersion($name),
|
|
'data' => BridgeOp::Delete === $op ? null : $this->normalize($entity),
|
|
];
|
|
|
|
if (null !== $key = $this->correlationContext->get()) {
|
|
$envelope['correlationKey'] = $key;
|
|
}
|
|
|
|
$this->publisher->publish("app://model/{$name}", $envelope);
|
|
$this->publisher->publish("app://model/{$name}/{$id}", $envelope);
|
|
}
|
|
|
|
private function resolveResource(object $entity): ?BridgeResource
|
|
{
|
|
$reflection = new \ReflectionClass($entity);
|
|
$attrs = $reflection->getAttributes(BridgeResource::class);
|
|
if ([] === $attrs) {
|
|
return null;
|
|
}
|
|
|
|
return $attrs[0]->newInstance();
|
|
}
|
|
|
|
private function extractId(object $entity): mixed
|
|
{
|
|
if (method_exists($entity, 'getId')) {
|
|
return $entity->getId();
|
|
}
|
|
|
|
$r = new \ReflectionClass($entity);
|
|
if ($r->hasProperty('id')) {
|
|
return $r->getProperty('id')->getValue($entity);
|
|
}
|
|
|
|
throw new \LogicException(\sprintf('Cannot extract id from %s: no getId() method or $id property.', $entity::class));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalize(object $entity): array
|
|
{
|
|
/** @var array<string, mixed> $data */
|
|
$data = $this->normalizer->normalize($entity, 'json', [
|
|
'circular_reference_handler' => static fn (object $o): string => (string) ($o->getId() ?? ''),
|
|
]);
|
|
|
|
return $data;
|
|
}
|
|
|
|
private function nextVersion(string $name): int
|
|
{
|
|
if (!isset($this->versions[$name])) {
|
|
$this->versions[$name] = (int) (microtime(true) * 1_000_000);
|
|
}
|
|
|
|
return ++$this->versions[$name];
|
|
}
|
|
|
|
private static function deriveName(string $fqcn): string
|
|
{
|
|
$basename = substr($fqcn, (int) strrpos($fqcn, '\\') + 1);
|
|
|
|
return strtolower($basename);
|
|
}
|
|
}
|