feat(12.2-01): add deferred bindings, guarded upload store and purge
- deferred_bindings migration set under summercms.deferred with backend_user_id - lagoon.DeferredBind/Unbind/Bindings/Forget/Slaves scoped by DeferredKey - lagoon.PurgeDeferred with SKIP LOCKED batches and after-commit blob deletes - attach.Store with the ported image guard, extension and MIME limits - attach.Relation, attach.HasRelations, attach.BlobKeys, File.ThumbKey - lagoon README and attachments docs
This commit is contained in:
294
modules/lagoon/deferred.go
Normal file
294
modules/lagoon/deferred.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// DeferredFileType is the slave_type of a deferred binding that points at a
|
||||
// system_files row: the table name, never a PHP class string.
|
||||
const DeferredFileType = "system_files"
|
||||
|
||||
// DeferredBinding is one row of WinterCMS's deferred_bindings table: a
|
||||
// pending bind (IsBind true) or unbind of the slave SlaveType/SlaveID to the
|
||||
// MasterField relation of a MasterType record that one admin's form session
|
||||
// (SessionKey, BackendUserID) has not saved yet. PivotData holds the
|
||||
// DeferredEnvelope JSON or is nil.
|
||||
type DeferredBinding struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
MasterType string `gorm:"column:master_type"`
|
||||
MasterField string `gorm:"column:master_field"`
|
||||
SlaveType string `gorm:"column:slave_type"`
|
||||
SlaveID string `gorm:"column:slave_id"`
|
||||
PivotData *string `gorm:"column:pivot_data"`
|
||||
SessionKey string `gorm:"column:session_key"`
|
||||
IsBind bool `gorm:"column:is_bind"`
|
||||
BackendUserID uint `gorm:"column:backend_user_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
// TableName is deferred_bindings.
|
||||
func (DeferredBinding) TableName() string { return "deferred_bindings" }
|
||||
|
||||
// DeferredKey identifies one admin's pending work on one master type: the
|
||||
// form's session key, the backend admin who owns it and the master record's
|
||||
// morph type (MorphType). Every deferred-binding operation is scoped by all
|
||||
// three, so a session key used by another admin, or against another model,
|
||||
// matches nothing.
|
||||
type DeferredKey struct {
|
||||
SessionKey string
|
||||
AdminID uint
|
||||
MasterType string
|
||||
}
|
||||
|
||||
func (k DeferredKey) validate() error {
|
||||
if strings.TrimSpace(k.SessionKey) == "" {
|
||||
return fmt.Errorf("lagoon: deferred binding session key is empty")
|
||||
}
|
||||
if k.AdminID == 0 {
|
||||
return fmt.Errorf("lagoon: deferred binding admin id is zero")
|
||||
}
|
||||
if strings.TrimSpace(k.MasterType) == "" {
|
||||
return fmt.Errorf("lagoon: deferred binding master type is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeferredEnvelope is the framework's pivot_data shape,
|
||||
// {"created":true,"pivot":{...}}. Created marks a slave row that was created
|
||||
// under deferral, which PurgeDeferred deletes with an expired binding; Pivot
|
||||
// holds the pivot values of a deferred belongsToMany link. A binding without
|
||||
// the envelope (nil pivot_data, or JSON without these keys) is a plain link.
|
||||
type DeferredEnvelope struct {
|
||||
Created bool `json:"created,omitempty"`
|
||||
Pivot map[string]any `json:"pivot,omitempty"`
|
||||
}
|
||||
|
||||
// Envelope decodes the binding's pivot_data. Nil or empty pivot_data is the
|
||||
// zero envelope (a plain link); invalid JSON is an error.
|
||||
func (b DeferredBinding) Envelope() (DeferredEnvelope, error) {
|
||||
var env DeferredEnvelope
|
||||
if b.PivotData == nil || strings.TrimSpace(*b.PivotData) == "" {
|
||||
return env, nil
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*b.PivotData), &env); err != nil {
|
||||
return DeferredEnvelope{}, fmt.Errorf("lagoon: deferred binding %d pivot_data: %w", b.ID, err)
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// MorphType is the master_type or slave_type string of model: its
|
||||
// attach.Owner MorphName when it implements attach.Owner, else its GORM table
|
||||
// name under db's naming strategy. An empty result is an error.
|
||||
func MorphType(db *gorm.DB, model any) (string, error) {
|
||||
if model == nil {
|
||||
return "", fmt.Errorf("lagoon: morph type of a nil model")
|
||||
}
|
||||
if owner, ok := model.(attach.Owner); ok {
|
||||
if name := strings.TrimSpace(owner.MorphName()); name != "" {
|
||||
return name, nil
|
||||
}
|
||||
return "", fmt.Errorf("lagoon: morph type of %T is empty", model)
|
||||
}
|
||||
if db == nil {
|
||||
return "", fmt.Errorf("lagoon: gorm db is nil")
|
||||
}
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(model); err != nil {
|
||||
return "", fmt.Errorf("lagoon: morph type of %T: %w", model, err)
|
||||
}
|
||||
if stmt.Schema == nil || strings.TrimSpace(stmt.Schema.Table) == "" {
|
||||
return "", fmt.Errorf("lagoon: morph type of %T is empty", model)
|
||||
}
|
||||
return stmt.Schema.Table, nil
|
||||
}
|
||||
|
||||
func deferredSession(ctx context.Context, tx *gorm.DB) *gorm.DB {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return tx.Session(&gorm.Session{NewDB: true, Context: ctx})
|
||||
}
|
||||
|
||||
func checkDeferredArgs(tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string) error {
|
||||
if tx == nil {
|
||||
return fmt.Errorf("lagoon: gorm db is nil")
|
||||
}
|
||||
if err := key.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(field) == "" {
|
||||
return fmt.Errorf("lagoon: deferred binding field is empty")
|
||||
}
|
||||
if strings.TrimSpace(slaveType) == "" {
|
||||
return fmt.Errorf("lagoon: deferred binding slave type is empty")
|
||||
}
|
||||
if strings.TrimSpace(slaveID) == "" {
|
||||
return fmt.Errorf("lagoon: deferred binding slave id is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findBinding is WinterCMS's DeferredBinding::findBindingRecord, scoped to
|
||||
// the owning admin and locked for the rest of the transaction.
|
||||
func findBinding(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string) (*DeferredBinding, error) {
|
||||
var row DeferredBinding
|
||||
err := deferredSession(ctx, tx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("master_type = ? AND master_field = ? AND slave_type = ? AND slave_id = ? AND session_key = ? AND backend_user_id = ?",
|
||||
key.MasterType, field, slaveType, slaveID, key.SessionKey, key.AdminID).
|
||||
Order("id").
|
||||
Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lagoon: find deferred binding: %w", err)
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func insertBinding(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string, bind bool, env *DeferredEnvelope) error {
|
||||
row := DeferredBinding{
|
||||
MasterType: key.MasterType,
|
||||
MasterField: field,
|
||||
SlaveType: slaveType,
|
||||
SlaveID: slaveID,
|
||||
SessionKey: key.SessionKey,
|
||||
IsBind: bind,
|
||||
BackendUserID: key.AdminID,
|
||||
}
|
||||
if env != nil && (env.Created || len(env.Pivot) > 0) {
|
||||
raw, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lagoon: deferred binding envelope: %w", err)
|
||||
}
|
||||
s := string(raw)
|
||||
row.PivotData = &s
|
||||
}
|
||||
if err := deferredSession(ctx, tx).Create(&row).Error; err != nil {
|
||||
return fmt.Errorf("lagoon: insert deferred binding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteBinding(ctx context.Context, tx *gorm.DB, id uint) error {
|
||||
if err := deferredSession(ctx, tx).Where("id = ?", id).Delete(&DeferredBinding{}).Error; err != nil {
|
||||
return fmt.Errorf("lagoon: delete deferred binding: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeferredBind records that slaveType/slaveID is to be bound to the field
|
||||
// relation of key's unsaved master, with env (nil for a plain link) as its
|
||||
// pivot_data. It ports WinterCMS's DeferredBinding::beforeCreate: a second
|
||||
// bind of the same slave in the same session writes nothing, and a bind
|
||||
// that meets a pending unbind of the same slave cancels it (the unbind row
|
||||
// is deleted and no bind row is written). An empty session key, a zero
|
||||
// AdminID or an empty MasterType is an error.
|
||||
func DeferredBind(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string, env *DeferredEnvelope) error {
|
||||
if err := checkDeferredArgs(tx, key, field, slaveType, slaveID); err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := findBinding(ctx, tx, key, field, slaveType, slaveID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.IsBind {
|
||||
return nil
|
||||
}
|
||||
return deleteBinding(ctx, tx, existing.ID)
|
||||
}
|
||||
return insertBinding(ctx, tx, key, field, slaveType, slaveID, true, env)
|
||||
}
|
||||
|
||||
// DeferredUnbind records that slaveType/slaveID is to be unbound from the
|
||||
// field relation of key's master. A second unbind writes nothing. An unbind
|
||||
// that meets a pending bind of the same slave cancels the pair: the bind
|
||||
// row is deleted, no unbind row is written, and the cancelled bind is
|
||||
// returned so the caller can remove the slave it created (a pending upload
|
||||
// or a child created under deferral). Otherwise it returns nil.
|
||||
func DeferredUnbind(ctx context.Context, tx *gorm.DB, key DeferredKey, field, slaveType, slaveID string) (*DeferredBinding, error) {
|
||||
if err := checkDeferredArgs(tx, key, field, slaveType, slaveID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := findBinding(ctx, tx, key, field, slaveType, slaveID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
if !existing.IsBind {
|
||||
return nil, nil
|
||||
}
|
||||
if err := deleteBinding(ctx, tx, existing.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
return nil, insertBinding(ctx, tx, key, field, slaveType, slaveID, false, nil)
|
||||
}
|
||||
|
||||
// DeferredBindings returns key's bindings whose master_field is one of
|
||||
// fields, in id order, locked FOR UPDATE so two saves with the same session
|
||||
// key are serialized. No fields means no bindings.
|
||||
func DeferredBindings(ctx context.Context, tx *gorm.DB, key DeferredKey, fields []string) ([]DeferredBinding, error) {
|
||||
if tx == nil {
|
||||
return nil, fmt.Errorf("lagoon: gorm db is nil")
|
||||
}
|
||||
if err := key.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []DeferredBinding
|
||||
err := deferredSession(ctx, tx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("session_key = ? AND backend_user_id = ? AND master_type = ? AND master_field IN ?",
|
||||
key.SessionKey, key.AdminID, key.MasterType, fields).
|
||||
Order("id").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lagoon: deferred bindings: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DeferredForget deletes exactly the bindings with the given ids, after
|
||||
// their work has been applied. The caller passes only ids it read for its
|
||||
// own key through DeferredBindings.
|
||||
func DeferredForget(ctx context.Context, tx *gorm.DB, ids []uint) error {
|
||||
if tx == nil {
|
||||
return fmt.Errorf("lagoon: gorm db is nil")
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := deferredSession(ctx, tx).Where("id IN ?", ids).Delete(&DeferredBinding{}).Error; err != nil {
|
||||
return fmt.Errorf("lagoon: forget deferred bindings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeferredSlaves returns a subquery selecting the slave_id values of key's
|
||||
// bindings for field and slaveType in one direction (bind true for pending
|
||||
// binds, false for pending unbinds), for use as CAST(pk AS TEXT) IN (?) in
|
||||
// a list query (WinterCMS's withDeferred). slave_id is a text column, so the
|
||||
// primary key must be cast to text for the comparison.
|
||||
func DeferredSlaves(tx *gorm.DB, key DeferredKey, field, slaveType string, bind bool) *gorm.DB {
|
||||
return tx.Session(&gorm.Session{NewDB: true}).
|
||||
Model(&DeferredBinding{}).
|
||||
Select("slave_id").
|
||||
Where("session_key = ? AND backend_user_id = ? AND master_type = ? AND master_field = ? AND slave_type = ? AND is_bind = ?",
|
||||
key.SessionKey, key.AdminID, key.MasterType, field, slaveType, bind)
|
||||
}
|
||||
Reference in New Issue
Block a user