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:
92
classes/material_revision.go
Normal file
92
classes/material_revision.go
Normal 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()
|
||||
}
|
||||
48
classes/material_revision_test.go
Normal file
48
classes/material_revision_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user