feat: Function triggers — schedule, inbound hook, domain events

Migration 00016: function_id on scheduled_jobs/inbound_hooks,
event_pattern on functions, 'event' in the run-trigger enum.

- Jobs deliver to a function instead of HTTP when function_id is set;
  the run's status_code mirrors the process exit code. Clearing the
  field returns the job to URL delivery.
- Inbound hooks gain mode "function" — received body becomes stdin
  payload, acked immediately, run recorded async.
- event_pattern (document.*, *, exact type) fires the function detached
  on matching publishEvent — never blocks realtime/webhook fan-out.
- One executeFunction path serves manual/schedule/hook/event; run rows
  record the trigger source.
- Backend image now ships bun 1.3.14 so Functions work under compose.

Dashboard: job target picker, hook function mode, event-pattern field.

Verified live: job run → success (status_code 0), hook POST → run,
document.created → event run with correct payload.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-24 20:28:04 +02:00
1 parent c094afad65
commit a84049b8fc
40 files changed
+716 -157

No files matched your search

+75 -16
View File
@@ -35,6 +35,7 @@ type ScheduledJobSummary struct {
Name string `json:"name"`
Schedule string `json:"schedule"`
URL string `json:"url"`
FunctionID *string `json:"function_id,omitempty"`
Payload json.RawMessage `json:"payload"`
Enabled bool `json:"enabled"`
HasSecret bool `json:"has_secret"`
@@ -59,21 +60,23 @@ type ScheduledJobRunSummary struct {
}
type CreateScheduledJobInput struct {
Name string `json:"name" validate:"required,min=2"`
Schedule string `json:"schedule" validate:"required"`
URL string `json:"url" validate:"required,url"`
Secret string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
Name string `json:"name" validate:"required,min=2"`
Schedule string `json:"schedule" validate:"required"`
URL string `json:"url" validate:"required_without=FunctionID,omitempty,url"`
FunctionID string `json:"function_id" validate:"omitempty,uuid"`
Secret string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
}
type UpdateScheduledJobInput struct {
Name *string `json:"name" validate:"omitempty,min=2"`
Schedule *string `json:"schedule"`
URL *string `json:"url" validate:"omitempty,url"`
Secret *string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
Name *string `json:"name" validate:"omitempty,min=2"`
Schedule *string `json:"schedule"`
URL *string `json:"url" validate:"omitempty,url"`
FunctionID *string `json:"function_id" validate:"omitempty,uuid"`
Secret *string `json:"secret"`
Payload map[string]any `json:"payload"`
Enabled *bool `json:"enabled"`
}
func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
@@ -88,6 +91,10 @@ func toJobSummary(row db.CoreScheduledJob) ScheduledJobSummary {
HasSecret: len(row.Secret) > 0,
CreatedAt: row.CreatedAt.Time,
}
if row.FunctionID.Valid {
id := uuid.UUID(row.FunctionID.Bytes).String()
out.FunctionID = &id
}
if row.LastRunAt.Valid {
t := row.LastRunAt.Time
out.LastRunAt = &t
@@ -176,9 +183,25 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return ScheduledJobSummary{}, err
}
jobURL, err := normalizeWebhookURL(input.URL)
if err != nil {
return ScheduledJobSummary{}, err
jobURL := ""
var functionID pgtype.UUID
if input.FunctionID != "" {
fid, err := uuid.Parse(input.FunctionID)
if err != nil {
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
}
fn, err := s.repo.Queries().GetFunction(ctx, fid)
if err != nil || fn.ProjectID != projectID {
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
}
functionID = pgtype.UUID{Bytes: fid, Valid: true}
jobURL = "function://" + fid.String()
} else {
var err error
jobURL, err = normalizeWebhookURL(input.URL)
if err != nil {
return ScheduledJobSummary{}, err
}
}
sched, err := parseSchedule(input.Schedule)
if err != nil {
@@ -222,6 +245,7 @@ func (s *PlatformService) CreateScheduledJob(ctx context.Context, actor *models.
Enabled: enabled,
NextRunAt: next,
CreatedByUserID: createdBy,
FunctionID: functionID,
})
if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
@@ -260,6 +284,28 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
return ScheduledJobSummary{}, err
}
}
rotateFunction := input.FunctionID != nil
functionID := row.FunctionID
if rotateFunction {
raw := strings.TrimSpace(*input.FunctionID)
if raw == "" {
functionID = pgtype.UUID{} // explicit clear → back to URL delivery
if input.URL == nil && strings.HasPrefix(jobURL, "function://") {
return ScheduledJobSummary{}, fmt.Errorf("clearing function_id requires a url")
}
} else {
fid, ferr := uuid.Parse(raw)
if ferr != nil {
return ScheduledJobSummary{}, fmt.Errorf("invalid function_id")
}
fn, ferr := s.repo.Queries().GetFunction(ctx, fid)
if ferr != nil || fn.ProjectID != projectID {
return ScheduledJobSummary{}, fmt.Errorf("function not found in this project")
}
functionID = pgtype.UUID{Bytes: fid, Valid: true}
jobURL = "function://" + fid.String()
}
}
enabled := row.Enabled
if input.Enabled != nil {
enabled = *input.Enabled
@@ -302,6 +348,8 @@ func (s *PlatformService) UpdateScheduledJob(ctx context.Context, actor *models.
Secret: secretBytes,
Column10: rotatePayload,
Payload: payload,
Column12: rotateFunction,
FunctionID: functionID,
})
if err != nil {
if strings.Contains(err.Error(), "scheduled_jobs_project_id_name_key") {
@@ -410,6 +458,10 @@ type JobScheduler struct {
leader *leaderState
// onEvent fans a finished run out to webhooks and the realtime stream.
onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)
// runFn delivers to a function instead of HTTP when job.FunctionID is set.
// The function receives the resolved job payload, not the run envelope.
// Owned by PlatformService — the scheduler stays transport-agnostic.
runFn func(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun, payload json.RawMessage) (*int32, error)
}
type jobExec struct {
@@ -559,7 +611,8 @@ func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, sta
}
}
// deliver POSTs the job payload to its URL, signed like a webhook delivery.
// deliver POSTs the job payload to its URL — or invokes the target function
// when the job carries a function_id.
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload)
if err != nil {
@@ -575,6 +628,12 @@ func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": data,
})
if job.FunctionID.Valid {
if j.runFn == nil {
return nil, fmt.Errorf("functions runtime not configured")
}
return j.runFn(ctx, job, run, data)
}
secret := ""
if len(job.Secret) > 0 && j.enc != nil {
if plain, err := j.enc.Decrypt(job.Secret); err == nil {