package cabana import ( "errors" "log/slog" "net/http" "git.golem15.com/golem15/summercms/modules/pact" "github.com/jackc/pgx/v5/pgconn" "gorm.io/gorm" ) // AdminSingletonResult is the data of the singleton route: the id of the one // record a pact.AdminSingleton controller edits. type AdminSingletonResult struct { ID uint `json:"id"` } // singletonRefuses answers 404 for a list, create, bulk or delete request on // a singleton controller (D-11) and reports whether it did. func singletonRefuses(w http.ResponseWriter, r *http.Request, cc *CompiledController) bool { if cc == nil || !cc.singleton { return false } writeNotFound(w, r) return true } // singletonRace is a SingletonRecord that lost a first-or-create race: a // concurrent first request created the record between its read and its // insert, so the insert hit a unique index. type singletonRace struct{} func (singletonRace) Error() string { return "cabana: singleton record created concurrently" } // uniqueViolation reports a Postgres unique violation (SQLSTATE 23505). func uniqueViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" } // singletonRoute serves GET {vendor}/{plugin}/{controller}/singleton: in one // transaction it asks the controller's SingletonRecord for the record // (created when absent) and answers its id, after checking that the form // scope (FormExtendQuery) can load it. When two first requests race and the // loser's insert hits the record's unique index, the transaction runs once // more and finds the record the winner created. It is guarded like the form // schema; a controller that is not a singleton answers 404. func (s *service) singletonRoute(w http.ResponseWriter, r *http.Request) { s.protect(w, r, func(cc *CompiledController) { provider, ok := cc.Controller.(pact.AdminSingleton) if !cc.singleton || !ok || provider == nil { writeNotFound(w, r) return } db, err := s.db() if err != nil { WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } ctx := r.Context() var result AdminSingletonResult attempt := func(tx *gorm.DB) error { txCtx := withTx(ctx, tx) record, err := provider.SingletonRecord(txCtx, tx) if err != nil { if uniqueViolation(err) { return singletonRace{} } return lifecycleFailure(cc, err) } var id uint if record != nil { id = pkUint(record) } if id == 0 { return lifecycleFailure(cc, errors.New("cabana: SingletonRecord returned no record with an id")) } target, err := newWritableModel(cc) if err != nil { return err } pk, err := coercePK(target, id) if err != nil { return err } if err := loadRecord(txCtx, tx, cc, target, pk); err != nil { return err } result.ID = id return nil } err = db.WithContext(ctx).Transaction(attempt) var race singletonRace if errors.As(err, &race) { err = db.WithContext(ctx).Transaction(attempt) if errors.As(err, &race) { err = lifecycleFailure(cc, err) } } if err != nil { var missing recordNotFound if !errors.As(err, &missing) { slog.Error("cabana: singleton record failed", "controller", controllerID(cc), "error", err) } writeCRUDError(w, err) return } WriteData(w, http.StatusOK, result, nil) }) }