Files
sm-bm-plugin/classes/material_revision.go
Jakub Zych 2a47186a7a 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
2026-11-09 01:12:49 +01:00

93 lines
3.1 KiB
Go

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()
}