check / check (push) Waiting to run
Every reference from a model to the record it belongs to is now json:"-", so the webhook and target models (and the other parent and child pairs in internal/database) can no longer marshal each other in a loop. Preloading maps associations without reading json tags, so it still fills these references. One test preloads a webhook with its targets and entrypoints and a target with its webhook, checks the references are filled, and checks the JSON leaves them out; another builds each other model with its parent set and checks the parent's id is absent from its JSON. Model: opus-5-5
127 lines
4.8 KiB
Go
127 lines
4.8 KiB
Go
package database
|
|
|
|
import (
|
|
"math"
|
|
"strconv"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
// DefaultRetentionDays is the event retention period applied to a
|
|
// webhook created without an explicit retention value. It is the
|
|
// single source of truth for that policy and must stay in sync
|
|
// with the `gorm:"default:30"` column default on
|
|
// Webhook.RetentionDays below; a struct tag cannot reference a
|
|
// constant, so a test asserts the two agree.
|
|
DefaultRetentionDays = 30
|
|
|
|
// RetentionForeverDays is the sentinel RetentionDays value meaning
|
|
// "retain events forever". Users express that intent as 0, which
|
|
// Webhook.BeforeSave rewrites to this value: the column default
|
|
// substitutes DefaultRetentionDays for a zero value at insert
|
|
// time, so a zero can never survive a round trip to the database.
|
|
// Nothing outside this file may hardcode the number.
|
|
RetentionForeverDays = 365 * 1000
|
|
|
|
// MaxFiniteRetentionDays is the largest finite retention period the
|
|
// reaper's cutoff arithmetic can represent, and therefore the
|
|
// largest one a caller may request. It is derived from that
|
|
// arithmetic rather than picked: retentionCutoff computes
|
|
// retentionDays * hoursPerDay * time.Hour, and a time.Duration is
|
|
// an int64 nanosecond count, so math.MaxInt64 nanoseconds divided
|
|
// by an hour and then by a day is the exact ceiling — 106751 days,
|
|
// a little over 292 years.
|
|
//
|
|
// One day more overflows int64, wraps the product negative, and
|
|
// turns the cutoff into a timestamp in the far future that matches
|
|
// every row in the webhook's database. That is why this bound is
|
|
// enforced on input and why retentionCutoff saturates underneath
|
|
// it. Note that RetentionForeverDays deliberately sits above this
|
|
// ceiling: such webhooks are skipped before any cutoff is
|
|
// computed, and never reach the arithmetic at all.
|
|
MaxFiniteRetentionDays = int(
|
|
math.MaxInt64 / int64(time.Hour) / hoursPerDay,
|
|
)
|
|
)
|
|
|
|
// Webhook represents a webhook processing unit that groups entrypoints and targets
|
|
//
|
|
// Every method below takes a pointer receiver. BeforeSave has to,
|
|
// because it mutates the record and GORM only invokes hooks declared
|
|
// that way; the display helpers follow suit so the receiver kinds do
|
|
// not mix. Handlers therefore put a *Webhook into template data:
|
|
// html/template cannot call a pointer method on a value held in a map,
|
|
// because a map element is not addressable.
|
|
type Webhook struct {
|
|
BaseModel
|
|
|
|
UserID string `gorm:"type:uuid;not null" json:"userId"`
|
|
Name string `gorm:"not null" json:"name"`
|
|
Description string `json:"description"`
|
|
|
|
// RetentionDays is the number of days to retain events. A value of
|
|
// RetentionForeverDays means retain forever. The column default
|
|
// must equal DefaultRetentionDays.
|
|
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
|
|
|
// Relations. No model marshals the record it belongs to:
|
|
// User.Webhooks leads back here, and the JSON could loop.
|
|
User User `json:"-"`
|
|
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
|
|
Targets []Target `json:"targets,omitempty"`
|
|
}
|
|
|
|
// BeforeSave normalises RetentionDays on every insert and update. A
|
|
// non-positive value is the user's way of asking for "retain forever",
|
|
// which is stored as the RetentionForeverDays sentinel.
|
|
//
|
|
// This has to happen in a hook rather than at the call sites. GORM
|
|
// substitutes the column default (DefaultRetentionDays) for a zero
|
|
// value while building the insert statement, which runs after
|
|
// BeforeSave; rewriting any later than this loses that race and the
|
|
// row lands at 30 days. Living on the model also means a future call
|
|
// site — a REST API, a fixture, a migration — cannot bypass it.
|
|
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
|
if w.RetentionDays <= 0 {
|
|
w.RetentionDays = RetentionForeverDays
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// retainsForever reports whether a stored RetentionDays value means
|
|
// "keep events indefinitely". It is the single definition of that
|
|
// question, shared by Webhook.RetainsForever and by the reaper's
|
|
// cutoff computation so the two cannot disagree about which webhooks
|
|
// are exempt from reaping.
|
|
//
|
|
// It accepts the RetentionForeverDays sentinel written by BeforeSave
|
|
// and, defensively, the non-positive values that rows written before
|
|
// the sentinel existed may still carry.
|
|
func retainsForever(retentionDays int) bool {
|
|
return retentionDays <= 0 ||
|
|
retentionDays >= RetentionForeverDays
|
|
}
|
|
|
|
// RetainsForever reports whether this webhook's events are kept
|
|
// indefinitely.
|
|
func (w *Webhook) RetainsForever() bool {
|
|
return retainsForever(w.RetentionDays)
|
|
}
|
|
|
|
// RetentionLabel returns the webhook's retention policy as display
|
|
// text, so that no template has to know about the sentinel value.
|
|
func (w *Webhook) RetentionLabel() string {
|
|
if w.RetainsForever() {
|
|
return "forever"
|
|
}
|
|
|
|
if w.RetentionDays == 1 {
|
|
return "1 day"
|
|
}
|
|
|
|
return strconv.Itoa(w.RetentionDays) + " days"
|
|
}
|