RetentionDays cannot be set to 0 (retain forever) via the normal create path #79
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Surfaced during #63 / PR #78.
Webhook.RetentionDayscarriesgorm:"default:30", so GORM substitutes the column default (30) for a zero value onCreate. The retention reaper correctly treats aRetentionDaysof 0 or less as "retain forever", but that value is unreachable through the normal create path: a webhook created without an explicit positive value gets 30, and there is no way to express "keep forever" from the create/edit flow.Net effect today: every webhook has an effective minimum retention (default 30 days) and cannot be set to keep events indefinitely.
Definition of done:
Note: this ties into the web-UI cleanup (#57) — whether and how retention is surfaced in the UI.
let's use 365*1000 days as the retain-forever value.
attempting to set retentiondays to 0 should rewrite this on insert/update to be the 365*1000 value in the db
Implementation requirements
Baseline:
main@4f5ecb1. @sneak's decision in the comments above is the design and is not up for reinterpretation:365 * 1000days is the retain-forever value, and an attempt to setretention_daysto 0 is rewritten to that value on insert and update.Current state, confirmed by reading
internal/database/model_webhook.go:12—RetentionDays int \gorm:"default:30" json:"retentionDays"`. The GORM column default is why a zero value becomes 30 onCreate`.internal/handlers/handlers.go:30—defaultRetentionDays = 30, a second source of truth for the same 30.internal/handlers/source_management.go:145-185— the create path;internal/handlers/source_management.go:445-461—parseRetention, the edit path.internal/database/retention.go:143—if wh.RetentionDays <= 0 { continue }, the current retain-forever branch.templates/sources_new.html:31andtemplates/source_edit.html:31both usemin="1" max="365";templates/sources_list.html:28renders{{.RetentionDays}}d retention;templates/source_detail.html:184rendersRetention: {{.Webhook.RetentionDays}} days.1. The sentinel
Add one exported constant in
internal/database— e.g.RetentionForeverDays = 365 * 1000— written as that expression, not as a bare365000, so its meaning is self-evident. It is the single source of truth; nothing else may hardcode the number.2. Rewrite on insert and update
Implement the rewrite as a GORM
BeforeSavehook onWebhookso it fires on create and update and cannot be bypassed by a future call site (a REST API is on the roadmap). AnyRetentionDays <= 0becomesRetentionForeverDays.Note the interaction that makes the hook necessary rather than optional: the
gorm:"default:30"tag substitutes 30 for a zero value at insert time, so a rewrite that happens anywhere later than the hook loses the race with GORM's own defaulting. The hook sets a non-zero value first, so the column default never applies.Verify the hook actually fires on the edit path as written — if the edit path uses
Updateswith a map or a selective column set rather than a structSave, confirm the hook still runs and the rewritten value is what gets persisted. Say in the PR body which save call you verified and how.3. The reaper
retention.gocurrently treats<= 0as retain-forever. With the sentinel stored, that branch is no longer reachable from normal data: 365000 days is a positive number, so the reaper would compute a cutoff a thousand years in the past and issue a pointlessDELETEthat matches nothing on every sweep for every retain-forever webhook.Make the reaper recognise
RetentionDays >= RetentionForeverDaysand skip the webhook entirely, before building any query. Keep the existing<= 0guard as defense-in-depth for any row written before this change.4. Form handling
parseRetentioncurrently doesif err == nil && v > 0, which silently drops both unparseable input and zero. Both behaviours are now wrong:0must be honoured and become retain-forever.Apply the same rules to the create path so create and edit cannot drift.
5. UI — keep it minimal, #57 owns the real cleanup
Three concrete things, no redesign:
min="1"must becomemin="0"intemplates/sources_new.htmlandtemplates/source_edit.html, with a hint that 0 means retain forever. Otherwise the value is unreachable from the UI, which is the entire bug.max="365"must go (or rise to the sentinel). This is a trap: the edit form pre-fillsvalue="{{.Webhook.RetentionDays}}", so a retain-forever webhook renders365000into an input capped at 365, and browser validation blocks the user from saving any edit to that webhook until they change retention. Verify a retain-forever webhook can round-trip through the edit form untouched.RetainsForever() bool, or a label helper) and use it intemplates/sources_list.htmlandtemplates/source_detail.html. Do not put the magic number in a template.6. Consolidate the duplicated default
defaultRetentionDays = 30ininternal/handlers/handlers.goandgorm:"default:30"on the model are two sources of truth for one policy. Fold them into one exported constant ininternal/databasealongside the sentinel, and reference it from both. If the GORM struct tag cannot take a constant, leave the tag but add a comment tying it to the constant and a test asserting the two agree.7. Tests
retention_days=0through the handler persistsRetentionForeverDays, not 30. This is the core regression test — it must fail without the hook.0persists the sentinel.8. Docs
0meaning retain forever, and that it is stored as365 * 1000days.TODO.mdupdated in the same commit as the code.Definition of done
Everything in the issue's Definition of done, plus items 1-8 above,
make checkgreen via the repo's own entrypoints only,.golangci.ymluntouched, single commit whose title ends with(closes #79), no attribution trailers.Implementation plan
Branch
issue-79-retention-foreveroffmain@4f5ecb1, single commit ending(closes #79).1.
internal/database/model_webhook.go— sentinel, default, hook, display helpersconst RetentionForeverDays = 365 * 1000(written as the expression), the single source of truth for the retain-forever value.const DefaultRetentionDays = 30, the single source of truth for the 30-day default. Thegorm:"default:30"struct tag cannot take a constant, so it stays, with a comment tying it toDefaultRetentionDaysand a reflect-based test asserting the tag literally readsdefault:30.func (w *Webhook) BeforeSave(*gorm.DB) errorrewriting anyRetentionDays <= 0toRetentionForeverDays. It is a hook rather than call-site logic precisely because thedefault:30tag substitutes 30 for a zero value during GORM's own create callback, which runs afterBeforeSave— the hook writes a non-zero value first, so the column default never fires.func (w Webhook) RetainsForever() bool— true for<= 0(legacy rows) or>= RetentionForeverDays. Value receiver so templates can call it on the non-pointerdatabase.Webhookembedded inWebhookListItem.func (w Webhook) RetentionLabel() string—"forever"or"30 days", so no template holds the magic number.2.
internal/database/retention.go— reapersweepskips onwh.RetainsForever()before touching the DB manager or building any query. That single call keeps the pre-existing<= 0guard as defense-in-depth for rows written before this change, and adds sentinel recognition, so a retain-forever webhook issues noDELETEat all rather than a no-op delete against a cutoff a thousand years in the past.3.
internal/handlers/— form handlingdefaultRetentionDaysfromhandlers.go; referencedatabase.DefaultRetentionDays.parseRetentionDays(raw string, fallback int) (int, error), used by both create and edit so they cannot drift:fallback(create passesdatabase.DefaultRetentionDays, edit passes the webhook's current value, preserving "leave unchanged")0-> returned as0and rewritten byBeforeSave; the handler does not know the sentinelparseRetention(the current silent-drop helper) is replaced.4. Templates
templates/sources_new.htmlandtemplates/source_edit.html:min="1"->min="0", andmax="365"removed. Themaxremoval is not cosmetic: the edit form pre-fillsvalue="{{.Webhook.RetentionDays}}", so a retain-forever webhook renders365000into an input capped at 365 and browser validation would block saving any edit to that webhook. A hint line states that 0 means retain forever.templates/sources_list.htmlandtemplates/source_detail.htmlrenderRetentionLabelinstead of the raw number.input,label,badge-info,text-xs text-gray-500 mt-1), so themake cssTailwind build is not required.5. Tests
In
internal/handlers(handler level, so they fail without the hook) andinternal/database:retention_days=0persistsRetentionForeverDays, not 30 — the core regression test0persists the sentinel365000back) with its retention unchangedgormtag default agrees withDefaultRetentionDays6. Docs
README
retention_daysrow and prose document0meaning retain forever and that it is stored as365 * 1000days.TODO.mdupdated in the same commit.Verification with
make fmt,make check, andscript/cibuildonly.clawbot referenced this issue2026-08-11 14:48:08 +02:00