Each target TYPE is now an implementation of a Target interface,
dispatched from a registry in processDelivery instead of a type
switch on TargetType. Every target owns its full delivery,
including durable retries.
- Target.Deliver receives the context, the per-webhook DB, the
Delivery, the attempt Task, and a Scheduler for durable
re-enqueue (the existing timer + retry queue). The target makes
one attempt, records the DeliveryResult, updates DeliveryStatus,
and — for retry targets — decides whether to retry, computes its
own backoff, gates with its own circuit breaker, and reschedules
via the Scheduler.
- httpTarget and slackTarget share a retry core (retry, backoff,
circuit breaker). database and log targets are fire-and-forget.
- Slack retry/breaker is gated on MaxRetries: 0 stays
fire-and-forget (existing Slack targets unchanged), >0 gets
retry + backoff + breaker on the shared core.
- The engine keeps only the worker pool, queue/channels, restart
recovery/sweep, the recordResult/updateDeliveryStatus helpers,
and ScheduleRetry. Recovery/sweep hand each orphaned retrying
delivery back to its target to recompute the backoff.
- The log target logs the entire inbound webhook: full body and
headers, method, content type, and the webhook and entrypoint
ids (supersedes the smaller log-summary work).
- Task gains EntrypointID, populated in the webhook handler, the
recovery-task builder, and buildEventFromTask.
Behaviour is preserved: existing delivery tests pass with their
export_test wrappers re-pointed at the new targets; new pure
Deliver tests cover the log full-content output and the gated
Slack retry path.
Closes [issue #50](#50)
## Summary
Refactors the Dockerfile to use a separate lint stage with a pinned golangci-lint Docker image, following the pattern used by [sneak/pixa](https://git.eeqj.de/sneak/pixa). This replaces the previous approach of installing golangci-lint via curl in the builder stage.
## Changes
### Dockerfile
- **New `lint` stage** using `golangci/golangci-lint:v2.11.3` (Debian-based, pinned by sha256 digest) as a separate build stage
- **Builder stage** depends on lint via `COPY --from=lint /src/go.sum /dev/null` — build won't proceed unless linting passes
- **Go bumped** from 1.24 to 1.26.1 (`golang:1.26.1-bookworm`, pinned by sha256)
- **golangci-lint bumped** from v1.64.8 to v2.11.3
- All three Docker images (golangci-lint, golang, alpine) pinned by sha256 digest
- Debian-based golangci-lint image used (not Alpine) because mattn/go-sqlite3 CGO does not compile on musl (off64_t)
### Linter Config (.golangci.yml)
- Migrated from v1 to v2 format (`version: "2"` added)
- Removed linters no longer available in v2: `gofmt` (handled by `make fmt-check`), `gosimple` (merged into `staticcheck`), `typecheck` (always-on in v2)
- Same set of linters enabled — no rules weakened
### Code Fixes (all lint issues from v2 upgrade)
- Added package comments to all packages
- Added doc comments to all exported types, functions, and methods
- Fixed unchecked errors flagged by `errcheck` (sqlDB.Close, os.Setenv in tests, resp.Body.Close, fmt.Fprint)
- Fixed unused parameters flagged by `revive` (renamed to `_`)
- Fixed `gosec` G120 warnings: added `http.MaxBytesReader` before `r.ParseForm()` calls
- Fixed `staticcheck` QF1012: replaced `WriteString(fmt.Sprintf(...))` with `fmt.Fprintf`
- Fixed `staticcheck` QF1003: converted if/else chain to tagged switch
- Renamed `DeliveryTask` → `Task` to avoid package stutter (`delivery.Task` instead of `delivery.DeliveryTask`)
- Renamed shadowed builtin `max` parameter to `upperBound` in `cryptoRandInt`
- Used `t.Setenv` instead of `os.Setenv` in tests (auto-restores)
### README.md
- Updated version requirements: Go 1.26+, golangci-lint v2.11+
- Updated Dockerfile description in project structure
## Verification
`docker build .` passes cleanly — formatting check, linting, all tests, and build all succeed.
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #55
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
The webhook handler now builds DeliveryTask structs carrying all target
config and event data inline (for bodies ≤16KB) and sends them through
the delivery channel. In the happy path, the engine delivers without
reading from any database — it only writes to record delivery results.
For large bodies (≥16KB), Body is nil and the engine fetches it from the
per-webhook database on demand. Retry timers also carry the full
DeliveryTask, so retries avoid unnecessary DB reads.
The database is used for crash recovery only: on startup the engine scans
for interrupted pending/retrying deliveries and re-queues them.
Implements owner feedback from issue #15:
> the message in the <=16KB case should have everything it needs to do
> its delivery. it shouldn't touch the db until it has a success or
> failure to record.
Replace the polling-based delivery engine with a fully event-driven
architecture using Go channels and goroutines:
- Webhook handler notifies engine via buffered channel after creating
delivery records, with inline event data for payloads < 16KB
- Large payloads (>= 16KB) use pointer semantics (Body *string = nil)
and are fetched from DB on demand, keeping channel memory bounded
- Failed retry-target deliveries schedule Go timers with exponential
backoff; timers fire into a separate retry channel when ready
- On startup, engine scans DB once to recover interrupted deliveries
(pending processed immediately, retrying get timers for remaining
backoff)
- DB stores delivery status for crash recovery only, not for
inter-component communication during normal operation
- delivery.Notifier interface decouples handlers from engine; fx wires
*Engine as Notifier
No more periodic polling. No more wasted cycles when idle.
Split data storage into main application DB (config only) and
per-webhook event databases (one SQLite file per webhook).
Architecture changes:
- New WebhookDBManager component manages per-webhook DB lifecycle
(create, open, cache, delete) with lazy connection pooling via sync.Map
- Main DB (DBURL) stores only config: Users, Webhooks, Entrypoints,
Targets, APIKeys
- Per-webhook DBs (DATA_DIR) store Events, Deliveries, DeliveryResults
in files named events-{webhook_uuid}.db
- New DATA_DIR env var (default: ./data dev, /data/events prod)
Behavioral changes:
- Webhook creation creates per-webhook DB file
- Webhook deletion hard-deletes per-webhook DB file (config soft-deleted)
- Event ingestion writes to per-webhook DB, not main DB
- Delivery engine polls all per-webhook DBs for pending deliveries
- Database target type marks delivery as immediately successful (events
are already in the dedicated per-webhook DB)
- Event log UI reads from per-webhook DBs with targets from main DB
- Existing webhooks without DB files get them created lazily
Removed:
- ArchivedEvent model (was a half-measure, replaced by per-webhook DBs)
- Event/Delivery/DeliveryResult removed from main DB migrations
Added:
- Comprehensive tests for WebhookDBManager (create, delete, lazy
creation, delivery workflow, multiple webhooks, close all)
- Dockerfile creates /data/events directory
README updates:
- Per-webhook event databases documented as implemented (was Phase 2)
- DATA_DIR added to configuration table
- Docker instructions updated with data volume mount
- Data model diagram updated
- TODO updated (database separation moved to completed)
Closes#15
Add method check at the top of HandleWebhook, returning 405 Method Not
Allowed with an Allow: POST header for any non-POST request. This
prevents GET, PUT, DELETE, etc. from being accepted at entrypoint URLs.
The top-level entity that groups entrypoints and targets is now called
Webhook (was Processor). The inbound URL endpoint entity is now called
Entrypoint (was Webhook). This rename affects database models, handler
comments, routes, and README documentation.
closes #12