Files
sm-bm-plugin/controllers/materials_hooks.go
Jakub Zych 263c9c3926 feat: revision-check the upsert, MCP and admin writes
- POST /materials locks the matched row and answers 409 to a stale
  revision when it updates; a create ignores revision
- bm_manage_save_material and bm_manage_update_material take revision
- /bmadmin saves bump the revision on content changes (not checked)
- document revisions and the 409 in the README
2026-11-08 23:36:17 +01:00

139 lines
4.8 KiB
Go

package controllers
import (
"context"
"errors"
"sync"
"sync/atomic"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
var (
_ pact.FormBeforeUpdate = materialsAdmin{}
_ pact.FormAfterCreate = materialsAdmin{}
_ pact.FormAfterUpdate = materialsAdmin{}
_ pact.FormAfterDelete = materialsAdmin{}
)
// pendingRevisions holds, per material id, the content fingerprint
// (models.MaterialContent) FormBeforeUpdate took before an admin save, for
// FormAfterUpdate to compare. The row lock FormBeforeUpdate takes keeps one
// pending save per id at a time.
var pendingRevisions sync.Map
// MaterialIndex keeps the materials search index current once a write
// commits (classes.MaterialIndexer). QueueExtract enqueues the file text job
// inside tx. Its calls never fail the write.
type MaterialIndex interface {
SyncAfterCommit(ctx context.Context, tx *gorm.DB, id uint)
RemoveAfterCommit(ctx context.Context, tx *gorm.DB, id uint)
QueueExtract(ctx context.Context, tx *gorm.DB, id uint)
}
type materialIndexHolder struct{ ix MaterialIndex }
var materialIndex atomic.Value
// UseMaterialIndex installs the index the materials admin syncs after a
// save or delete; nil turns the syncs off. Boot installs it, because the
// generated controller value has no field to carry it.
func UseMaterialIndex(ix MaterialIndex) {
materialIndex.Store(materialIndexHolder{ix: ix})
}
func installedMaterialIndex() MaterialIndex {
h, _ := materialIndex.Load().(materialIndexHolder)
return h.ix
}
// FormAfterCreate queues the file text job in the save transaction (the
// form's deferred files commit in the same one) and syncs the new material
// once it commits.
func (materialsAdmin) FormAfterCreate(ctx context.Context, model any) error {
return materialAfterCommit(ctx, model, false)
}
// FormBeforeUpdate runs the embedded date check, then, inside the save
// transaction, locks the material's row and snapshots its content for
// FormAfterUpdate. An admin save is not revision-checked (the form sends no
// revision); it only bumps the revision when the content changed, so a later
// push holding an older revision is refused instead of overwriting the
// admin edit. Outside a write (no transaction) it does nothing more.
func (c materialsAdmin) FormBeforeUpdate(ctx context.Context, model any) error {
if err := c.temporalHooks.FormBeforeUpdate(ctx, model); err != nil {
return err
}
m, ok := model.(*models.Material)
if !ok || m == nil {
return errors.New("materials admin: form model is not a material")
}
tx, ok := cabana.TxFromContext(ctx)
if !ok || m.ID == 0 {
return nil
}
var locked int
if err := tx.Session(&gorm.Session{NewDB: true}).Raw("SELECT 1 FROM jz_bm_materials WHERE id = ? FOR UPDATE", m.ID).Scan(&locked).Error; err != nil {
return err
}
before, err := models.MaterialContent(tx, m.ID)
if err != nil {
return err
}
pendingRevisions.Store(m.ID, before)
return nil
}
// FormAfterUpdate bumps the revision when the save changed the material's
// content, then queues the file text job in the save transaction and syncs
// the material once it commits. The compare runs here, not in
// FormBeforeUpdate, because the course, the zjazd and the tags are final
// only after the row write and the pivot sync. A revise error fails the save
// like any database error. A save that added no file queues a job that finds
// no new PDF and only resyncs; a file removed in the form leaves the
// document through the cascade and this sync.
func (materialsAdmin) FormAfterUpdate(ctx context.Context, model any) error {
m, ok := model.(*models.Material)
if !ok || m == nil {
return errors.New("materials admin: form model is not a material")
}
if pending, found := pendingRevisions.LoadAndDelete(m.ID); found {
if tx, ok := cabana.TxFromContext(ctx); ok {
if _, _, err := models.ReviseMaterial(tx, m.ID, pending.(string)); err != nil {
return err
}
}
}
return materialAfterCommit(ctx, model, false)
}
// FormAfterDelete removes the material's document once the delete commits.
func (materialsAdmin) FormAfterDelete(ctx context.Context, model any) error {
return materialAfterCommit(ctx, model, true)
}
// materialAfterCommit schedules the index work of an admin save on the save
// transaction. Only a model that is not a material is an error: the index
// never fails a save.
func materialAfterCommit(ctx context.Context, model any, remove bool) error {
m, ok := model.(*models.Material)
if !ok || m == nil {
return errors.New("materials admin: form model is not a material")
}
ix := installedMaterialIndex()
if ix == nil || m.ID == 0 {
return nil
}
tx, _ := cabana.TxFromContext(ctx)
if remove {
ix.RemoveAfterCommit(ctx, tx, m.ID)
return nil
}
ix.QueueExtract(ctx, tx, m.ID)
ix.SyncAfterCommit(ctx, tx, m.ID)
return nil
}