feat: add SSE endpoint and server mode system

Add server-sent events at GET /api/events for live mutation
notifications. Add server mode (normal/read-only/degraded) exposed
via /health, /ready, and SSE server.state events.

New files:
- broker.go: SSE event hub with client management, non-blocking
  fan-out, ring buffer history for Last-Event-ID replay, heartbeat
- servermode.go: mode state machine with periodic MinIO health
  check and SIGUSR1 read-only toggle
- sse_handler.go: HTTP handler using http.Flusher and
  ResponseController to disable WriteTimeout for long-lived SSE
- broker_test.go, servermode_test.go: 13 unit tests

Modified:
- handlers.go: Server struct gains broker/serverState fields,
  Health/Ready include mode and sse_clients, write handlers
  emit item.created/updated/deleted and revision.created events
- routes.go: register GET /api/events, add RequireWritable
  middleware to all 8 editor-gated route groups
- middleware.go: RequireWritable returns 503 in read-only mode
- csv.go, ods.go: emit bulk item.created events after import
- storage.go: add Ping() method for health checks
- config.go: add ReadOnly field to ServerConfig
- main.go: create broker/state, start background goroutines,
  SIGUSR1 handler, graceful shutdown sequence

Closes #38, closes #39
This commit is contained in:
Forbes
2026-02-08 15:59:23 -06:00
parent 21227b7586
commit 3d7302f383
15 changed files with 747 additions and 14 deletions

147
internal/api/broker_test.go Normal file
View File

@@ -0,0 +1,147 @@
package api
import (
"testing"
"time"
"github.com/rs/zerolog"
)
func TestBrokerSubscribeUnsubscribe(t *testing.T) {
b := NewBroker(zerolog.Nop())
c := b.Subscribe()
if b.ClientCount() != 1 {
t.Fatalf("expected 1 client, got %d", b.ClientCount())
}
b.Unsubscribe(c)
if b.ClientCount() != 0 {
t.Fatalf("expected 0 clients, got %d", b.ClientCount())
}
}
func TestBrokerPublish(t *testing.T) {
b := NewBroker(zerolog.Nop())
c := b.Subscribe()
defer b.Unsubscribe(c)
b.Publish("item.created", `{"part_number":"F01-0001"}`)
select {
case ev := <-c.ch:
if ev.Type != "item.created" {
t.Fatalf("expected type item.created, got %s", ev.Type)
}
if ev.ID != 1 {
t.Fatalf("expected ID 1, got %d", ev.ID)
}
if ev.Data != `{"part_number":"F01-0001"}` {
t.Fatalf("unexpected data: %s", ev.Data)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for event")
}
}
func TestBrokerPublishDropsSlow(t *testing.T) {
b := NewBroker(zerolog.Nop())
c := b.Subscribe()
defer b.Unsubscribe(c)
// Fill the client's channel
for i := 0; i < clientChanSize+10; i++ {
b.Publish("heartbeat", "{}")
}
// Should have clientChanSize events buffered, rest dropped
count := len(c.ch)
if count != clientChanSize {
t.Fatalf("expected %d buffered events, got %d", clientChanSize, count)
}
}
func TestBrokerEventsSince(t *testing.T) {
b := NewBroker(zerolog.Nop())
b.Publish("item.created", `{"pn":"A"}`)
b.Publish("item.updated", `{"pn":"B"}`)
b.Publish("item.deleted", `{"pn":"C"}`)
events := b.EventsSince(1) // after ID 1
if len(events) != 2 {
t.Fatalf("expected 2 events, got %d", len(events))
}
if events[0].Type != "item.updated" {
t.Fatalf("expected item.updated, got %s", events[0].Type)
}
if events[1].Type != "item.deleted" {
t.Fatalf("expected item.deleted, got %s", events[1].Type)
}
// No events after the latest
events = b.EventsSince(3)
if len(events) != 0 {
t.Fatalf("expected 0 events, got %d", len(events))
}
}
func TestBrokerClientCount(t *testing.T) {
b := NewBroker(zerolog.Nop())
c1 := b.Subscribe()
c2 := b.Subscribe()
c3 := b.Subscribe()
if b.ClientCount() != 3 {
t.Fatalf("expected 3 clients, got %d", b.ClientCount())
}
b.Unsubscribe(c2)
if b.ClientCount() != 2 {
t.Fatalf("expected 2 clients, got %d", b.ClientCount())
}
b.Unsubscribe(c1)
b.Unsubscribe(c3)
if b.ClientCount() != 0 {
t.Fatalf("expected 0 clients, got %d", b.ClientCount())
}
}
func TestBrokerShutdown(t *testing.T) {
b := NewBroker(zerolog.Nop())
c := b.Subscribe()
b.Shutdown()
// Client's closed channel should be closed
select {
case <-c.closed:
// expected
case <-time.After(time.Second):
t.Fatal("client closed channel not closed after shutdown")
}
if b.ClientCount() != 0 {
t.Fatalf("expected 0 clients after shutdown, got %d", b.ClientCount())
}
}
func TestBrokerMonotonicIDs(t *testing.T) {
b := NewBroker(zerolog.Nop())
b.Publish("a", "{}")
b.Publish("b", "{}")
b.Publish("c", "{}")
events := b.EventsSince(0)
if len(events) != 3 {
t.Fatalf("expected 3 events, got %d", len(events))
}
for i := 1; i < len(events); i++ {
if events[i].ID <= events[i-1].ID {
t.Fatalf("event IDs not monotonic: %d <= %d", events[i].ID, events[i-1].ID)
}
}
}