feat(11-03): broadcast model writes through River jobs enqueued in the write transaction

- Broadcastable contract and Bind[T] bindings; event {action}.{alias},
  default {model, actor, timestamp, ttl} payload, delete snapshot taken
  before the row goes
- GORM callbacks installed via lagoon.OnDatabase enqueue a summer.broadcast
  job on the write's *sql.Tx inside a savepoint; failures are logged and
  never abort the write; zero-key batch writes are skipped
- WithoutBroadcasting[T] (ctx-scoped, per type) and Service.Emit for one
  summary event; the one-attempt job namespaces channels and publishes or
  broadcasts; the payload travels as a JSON string so JSONB keeps its order
- no jobs for the null driver or Centrifugo without an API key
This commit is contained in:
Jakub Zych
2026-09-30 12:36:07 +02:00
parent 79fd705680
commit 211c413273
6 changed files with 736 additions and 6 deletions

View File

@@ -8,14 +8,18 @@
package lighthouse
import (
"database/sql"
"fmt"
"log/slog"
"reflect"
"strings"
"sync"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
const (
@@ -39,8 +43,9 @@ type Service struct {
queue string
timeout time.Duration
mu sync.RWMutex
lookup UserLookup
mu sync.RWMutex
lookup UserLookup
bindings map[reflect.Type]*handler
}
// From returns the app's Service, building and publishing it on first use.
@@ -48,7 +53,10 @@ type Service struct {
// realtime.broadcast_namespace (default ""), realtime.broadcast_queue
// (default "broadcasts") and realtime.broadcast_timeout (default 5s; an
// integer number of seconds or a duration string) and builds the driver
// through its registered factory. An unknown driver name is an error.
// through its registered factory. An unknown driver name is an error. It
// also registers the broadcast job with the app's conga manager (so call it
// from Boot, before a worker starts) and installs the broadcast GORM
// callbacks through lagoon.OnDatabase.
func From(app *backpack.App) (*Service, error) {
if app == nil {
return nil, fmt.Errorf("lighthouse: app is nil")
@@ -75,6 +83,16 @@ func From(app *backpack.App) (*Service, error) {
return nil, fmt.Errorf("lighthouse: driver %s returned nil", name)
}
svc.driver = d
if err := svc.registerJob(); err != nil {
return nil, fmt.Errorf("lighthouse: register broadcast job: %w", err)
}
// The broadcast callbacks need the GORM handle, which serve publishes
// after plugins boot.
if err := lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error {
return svc.installCallbacks(gdb)
}); err != nil {
return nil, fmt.Errorf("lighthouse: install broadcast callbacks: %w", err)
}
if err := app.Publish(svc); err != nil {
if existing, ok := app.Lookup[*Service](); ok && existing != nil {
return existing, nil