From 6c393ccb78432d68137764a495d73221959fe37a Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 1 Mar 2026 16:40:05 -0800 Subject: [PATCH] fix: database target writes to dedicated archive table The "database" target type now writes events to a separate archived_events table instead of just marking the delivery as done. This table persists independently of internal event retention/pruning, allowing the data to be consumed by external systems or preserved indefinitely. New ArchivedEvent model copies the full event payload (method, headers, body, content_type) along with webhook/entrypoint/event/target IDs. --- internal/database/model_archived_event.go | 19 ++++++++++++++++ internal/database/models.go | 1 + internal/delivery/engine.go | 27 ++++++++++++++++++++++- internal/server/routes.go | 22 +++++++++--------- 4 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 internal/database/model_archived_event.go diff --git a/internal/database/model_archived_event.go b/internal/database/model_archived_event.go new file mode 100644 index 0000000..9f75d23 --- /dev/null +++ b/internal/database/model_archived_event.go @@ -0,0 +1,19 @@ +package database + +// ArchivedEvent stores webhook events delivered via the "database" target type. +// These records persist independently of internal event retention and pruning, +// providing a durable archive for downstream consumption. +type ArchivedEvent struct { + BaseModel + + WebhookID string `gorm:"type:uuid;not null;index" json:"webhook_id"` + EntrypointID string `gorm:"type:uuid;not null" json:"entrypoint_id"` + EventID string `gorm:"type:uuid;not null" json:"event_id"` + TargetID string `gorm:"type:uuid;not null" json:"target_id"` + + // Original request data (copied from Event at archive time) + Method string `gorm:"not null" json:"method"` + Headers string `gorm:"type:text" json:"headers"` // JSON + Body string `gorm:"type:text" json:"body"` + ContentType string `json:"content_type"` +} diff --git a/internal/database/models.go b/internal/database/models.go index ce19b36..23dea14 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -11,5 +11,6 @@ func (d *Database) Migrate() error { &Event{}, &Delivery{}, &DeliveryResult{}, + &ArchivedEvent{}, ) } diff --git a/internal/delivery/engine.go b/internal/delivery/engine.go index 41599d7..a2c2e0d 100644 --- a/internal/delivery/engine.go +++ b/internal/delivery/engine.go @@ -244,7 +244,32 @@ func (e *Engine) deliverRetry(_ context.Context, d *database.Delivery) { } func (e *Engine) deliverDatabase(d *database.Delivery) { - // The event is already stored in the database; mark as delivered. + // Write the event to the dedicated archived_events table. This table + // persists independently of internal event retention/pruning, so the + // data remains available for external consumption even after the + // original event is cleaned up. + archived := &database.ArchivedEvent{ + WebhookID: d.Event.WebhookID, + EntrypointID: d.Event.EntrypointID, + EventID: d.EventID, + TargetID: d.TargetID, + Method: d.Event.Method, + Headers: d.Event.Headers, + Body: d.Event.Body, + ContentType: d.Event.ContentType, + } + + if err := e.database.DB().Create(archived).Error; err != nil { + e.log.Error("failed to archive event", + "delivery_id", d.ID, + "event_id", d.EventID, + "error", err, + ) + e.recordResult(d, 1, false, 0, "", err.Error(), 0) + e.updateDeliveryStatus(d, database.DeliveryStatusFailed) + return + } + e.recordResult(d, 1, true, 0, "", "", 0) e.updateDeliveryStatus(d, database.DeliveryStatusDelivered) } diff --git a/internal/server/routes.go b/internal/server/routes.go index 34ec050..9e80ecd 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -100,17 +100,17 @@ func (s *Server) SetupRoutes() { s.router.Route("/source/{sourceID}", func(r chi.Router) { r.Use(s.mw.RequireAuth()) - r.Get("/", s.h.HandleSourceDetail()) // View webhook details - r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form - r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission - r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook - r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs - r.Post("/entrypoints", s.h.HandleEntrypointCreate()) // Add entrypoint - r.Post("/entrypoints/{entrypointID}/delete", s.h.HandleEntrypointDelete()) // Delete entrypoint - r.Post("/entrypoints/{entrypointID}/toggle", s.h.HandleEntrypointToggle()) // Toggle entrypoint active - r.Post("/targets", s.h.HandleTargetCreate()) // Add target - r.Post("/targets/{targetID}/delete", s.h.HandleTargetDelete()) // Delete target - r.Post("/targets/{targetID}/toggle", s.h.HandleTargetToggle()) // Toggle target active + r.Get("/", s.h.HandleSourceDetail()) // View webhook details + r.Get("/edit", s.h.HandleSourceEdit()) // Show edit form + r.Post("/edit", s.h.HandleSourceEditSubmit()) // Handle edit submission + r.Post("/delete", s.h.HandleSourceDelete()) // Delete webhook + r.Get("/logs", s.h.HandleSourceLogs()) // View webhook logs + r.Post("/entrypoints", s.h.HandleEntrypointCreate()) // Add entrypoint + r.Post("/entrypoints/{entrypointID}/delete", s.h.HandleEntrypointDelete()) // Delete entrypoint + r.Post("/entrypoints/{entrypointID}/toggle", s.h.HandleEntrypointToggle()) // Toggle entrypoint active + r.Post("/targets", s.h.HandleTargetCreate()) // Add target + r.Post("/targets/{targetID}/delete", s.h.HandleTargetDelete()) // Delete target + r.Post("/targets/{targetID}/toggle", s.h.HandleTargetToggle()) // Toggle target active }) // Entrypoint endpoint — accepts incoming webhook POST requests only.