feat: announce material revisions on materials:{id}

- every revision bump (API writes and /bmadmin saves) enqueues
  updated.jz.bm.material with {material_id, revision} in a savepoint
- the materials namespace authorizer allows a subscribe only to a
  visible material the user may manage
- api.ManagesMaterials and api.CanWatchMaterial
This commit is contained in:
Jakub Zych
2026-11-09 01:12:49 +01:00
parent 40e4a2438b
commit 2a47186a7a
13 changed files with 571 additions and 10 deletions

View File

@@ -0,0 +1,92 @@
package classes
import (
"context"
"log/slog"
"regexp"
"strconv"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"gorm.io/gorm"
)
// The live heads-up of a material's revision (D-20): an open editor learns
// that a newer revision was saved before its own save, which still meets
// the 409. Only ids and the revision number travel on the channel, never
// content.
const (
// MaterialsNamespace is the realtime namespace jz.bm owns.
MaterialsNamespace = "materials"
// MaterialRevisedEvent is the event published after a content write.
MaterialRevisedEvent = "updated.jz.bm.material"
)
// materialChannelRE is the only accepted form of a material channel:
// lighthouse.ChannelID reads segments with a PHP-style cast, which would
// take "materials:5abc" for material 5.
var materialChannelRE = regexp.MustCompile(`^materials:[1-9][0-9]{0,18}$`)
// MaterialChannel is the channel of material id.
func MaterialChannel(id uint) string {
return MaterialsNamespace + ":" + strconv.FormatUint(uint64(id), 10)
}
// MaterialChannelID returns the material id of a channel of the form
// materials:{id}, and false for any other channel (a presence: prefix, a
// leading zero, trailing characters, a third segment).
func MaterialChannelID(channel string) (uint, bool) {
if !materialChannelRE.MatchString(channel) {
return 0, false
}
n, err := strconv.ParseUint(channel[len(MaterialsNamespace)+1:], 10, strconv.IntSize)
if err != nil || n == 0 {
return 0, false
}
return uint(n), true
}
// MaterialRevised is the payload of MaterialRevisedEvent. The field order
// is fixed so the payload's key order is stable.
type MaterialRevised struct {
MaterialID uint `json:"material_id"`
Revision uint `json:"revision"`
}
// MaterialRevisionNotifier publishes MaterialRevisedEvent after a write
// that bumped a material's revision. A nil notifier, or one without a
// realtime service, publishes nothing.
type MaterialRevisionNotifier struct {
// Realtime is the app's lighthouse service; nil turns the event off.
Realtime *lighthouse.Service
// Log is where enqueue failures are logged; nil means slog.Default.
Log *slog.Logger
}
// Revised enqueues the event of material id at revision on the write's
// transaction tx, so it is published only when that write commits. The
// enqueue runs in a savepoint: a failure rolls back the savepoint only, is
// logged and never fails or loses the write. Without a transaction it does
// nothing.
func (n *MaterialRevisionNotifier) Revised(ctx context.Context, tx *gorm.DB, id, revision uint) {
if n == nil || n.Realtime == nil || tx == nil {
return
}
b := lighthouse.Broadcast{
Channels: []string{MaterialChannel(id)},
Event: MaterialRevisedEvent,
Payload: MaterialRevised{MaterialID: id, Revision: revision},
}
err := tx.Transaction(func(sp *gorm.DB) error {
return n.Realtime.Emit(ctx, sp, b)
})
if err != nil {
n.log().Warn("jz.bm: material revision event not queued", "material_id", id, "revision", revision, "error", err)
}
}
func (n *MaterialRevisionNotifier) log() *slog.Logger {
if n.Log != nil {
return n.Log
}
return slog.Default()
}

View File

@@ -0,0 +1,48 @@
package classes
import (
"context"
"testing"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"gorm.io/gorm"
)
func TestMaterialChannel(t *testing.T) {
for _, id := range []uint{1, 5, 42, 1234567890} {
ch := MaterialChannel(id)
got, ok := MaterialChannelID(ch)
if !ok || got != id {
t.Errorf("MaterialChannelID(%q) = %d, %v, want %d, true", ch, got, ok, id)
}
}
if got := MaterialChannel(5); got != "materials:5" {
t.Errorf("MaterialChannel(5) = %q", got)
}
for _, ch := range []string{
"materials:5abc",
"materials:05",
"materials:0",
"materials:5:x",
"presence:materials:5",
"materials:",
"materials",
"materials:-5",
"materials: 5",
"materials:99999999999999999999",
"user:5",
"",
} {
if id, ok := MaterialChannelID(ch); ok {
t.Errorf("MaterialChannelID(%q) = %d, true, want a refusal", ch, id)
}
}
}
func TestMaterialRevisionNotifierNil(t *testing.T) {
ctx := context.Background()
var nilNotifier *MaterialRevisionNotifier
nilNotifier.Revised(ctx, &gorm.DB{}, 5, 2)
(&MaterialRevisionNotifier{}).Revised(ctx, &gorm.DB{}, 5, 2)
(&MaterialRevisionNotifier{Realtime: &lighthouse.Service{}}).Revised(ctx, nil, 5, 2)
}