Adds the framework's event bus, the second cross-cutting service (after
auth) that PBCs and plug-ins both consume. Implements the transactional
outbox pattern from the architecture spec section 9 — events are
written to the database in the same transaction as the publisher's
domain change, so a publish followed by a rollback never escapes.
This is the seam where a future Kafka/NATS bridge plugs in WITHOUT
touching any PBC code.
What landed:
* New `platform/platform-events/` module:
- `EventOutboxEntry` JPA entity backed by `platform__event_outbox`
(id, event_id, topic, aggregate_type, aggregate_id, payload jsonb,
status, attempts, last_error, occurred_at, dispatched_at, version).
Status enum: PENDING / DISPATCHED / FAILED.
- `EventOutboxRepository` Spring Data JPA repo with a pessimistic
SELECT FOR UPDATE query for poller dispatch.
- `ListenerRegistry` — in-memory subscription holder, indexed both
by event class (Class.isInstance) and by topic string. Supports
a `**` wildcard for the platform's audit subscriber. Backed by
CopyOnWriteArrayList so dispatch is lock-free.
- `EventBusImpl` — implements the api.v1 EventBus. publish() writes
the outbox row AND synchronously delivers to in-process listeners
in the SAME transaction. Marked Propagation.MANDATORY so the bus
refuses to publish outside an existing transaction (preventing
publish-and-rollback leaks). Listener exceptions are caught and
logged; the outbox row still commits.
- `OutboxPoller` — Spring @Scheduled component that runs every 5s,
drains PENDING / FAILED rows under a pessimistic lock, marks them
DISPATCHED. v0.5 has no real external dispatcher — the poller is
the seam where Kafka/NATS plugs in later.
- `EventBusConfiguration` — @EnableScheduling so the poller actually
runs. Lives in this module so the seam activates automatically
when platform-events is on the classpath.
- `EventAuditLogSubscriber` — wildcard subscriber that logs every
event at INFO. Demo proof that the bus works end-to-end. Future
versions replace it with a real audit log writer.
* `platform__event_outbox` Liquibase changeset (platform-events-001):
table + unique index on event_id + index on (status, created_at) +
index on topic.
* DefaultPluginContext.eventBus is no longer a stub that throws —
it's now the real EventBus injected by VibeErpPluginManager.
Plug-ins can publish and subscribe via the api.v1 surface. Note:
subscriptions are NOT auto-scoped to the plug-in lifecycle in v0.5;
a plug-in that wants its subscriptions removed on stop() must call
subscription.close() explicitly. Auto-scoping lands when per-plug-in
Spring child contexts ship.
* pbc-identity now publishes `UserCreatedEvent` after a successful
UserService.create(). The event class is internal to pbc-identity
(not in api.v1) — other PBCs subscribe by topic string
(`identity.user.created`), not by class. This is the right tradeoff:
string topics are stable across plug-in classloaders, class equality
is not, and adding every event class to api.v1 would be perpetual
surface-area bloat.
Tests: 13 new unit tests (9 EventBusImplTest + 4 OutboxPollerTest)
plus 2 new UserServiceTest cases that verify the publish happens on
the happy path and does NOT happen when create() rejects a duplicate.
Total now 76 unit tests across the framework, all green.
End-to-end smoke test against fresh Postgres with the plug-in loaded
(everything green):
EventAuditLogSubscriber subscribed to ** at boot
Outbox empty before any user create ✓
POST /api/v1/auth/login → 200
POST /api/v1/identity/users (create alice) → 201
Outbox row appears with topic=identity.user.created,
status=PENDING immediately after create ✓
EventAuditLogSubscriber log line fires synchronously
inside the create transaction ✓
POST /api/v1/identity/users (create bob) → 201
Wait 8s (one OutboxPoller cycle)
Both outbox rows now DISPATCHED, dispatched_at set ✓
Existing PBCs still work:
GET /api/v1/identity/users → 3 users ✓
GET /api/v1/catalog/uoms → 15 UoMs ✓
Plug-in still works:
GET /api/v1/plugins/printing-shop/ping → 200 ✓
The most important assertion is the synchronous audit log line
appearing on the same thread as the user creation request. That
proves the entire chain — UserService.create() → eventBus.publish()
→ EventBusImpl writes outbox row → ListenerRegistry.deliver()
finds wildcard subscriber → EventAuditLogSubscriber.handle()
logs — runs end-to-end inside the publisher's transaction.
The poller flipping PENDING → DISPATCHED 5s later proves the
outbox + poller seam works without any external dispatcher.
Bug encountered and fixed during the smoke test:
• EventBusImplTest used `ObjectMapper().registerKotlinModule()`
which doesn't pick up jackson-datatype-jsr310. Production code
uses Spring Boot's auto-configured ObjectMapper which already
has jsr310 because spring-boot-starter-web is on the classpath
of distribution. The test setup was the only place using a bare
mapper. Fixed by switching to `findAndRegisterModules()` AND
by adding jackson-datatype-jsr310 as an explicit implementation
dependency of platform-events (so future modules that depend on
the bus without bringing web in still get Instant serialization).
What is explicitly NOT in this chunk:
• External dispatcher (Kafka/NATS bridge) — the poller is a no-op
that just marks rows DISPATCHED. The seam exists; the dispatcher
is a future P1.7.b unit.
• Exponential backoff on FAILED rows — every cycle re-attempts.
Real backoff lands when there's a real dispatcher to fail.
• Dead-letter queue — same.
• Per-plug-in subscription auto-scoping — plug-ins must close()
explicitly today.
• Async / fire-and-forget publish — synchronous in-process only.