diff --git a/Taskfile.yml b/Taskfile.yml
index b7d8fc0..dc3a66b 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -178,7 +178,7 @@ tasks:
mutate:
desc: Gremlins mutation testing of the use cases → tmp/gremlins.json
cmds:
- - bash scripts/mutate.sh
+ - bash scripts/mutate.sh {{.CLI_ARGS}}
test:web:
desc: Run the SPA test suite (Vitest) in run mode
diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go
index d89012c..b9b6d6c 100644
--- a/api/dependecyInjection.go
+++ b/api/dependecyInjection.go
@@ -320,8 +320,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
- GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, time.Now),
- UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, recorder, time.Now),
+ GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, time.Now),
+ UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, repos.AuditRepo, recorder, time.Now),
}
}
diff --git a/api/handlers/settings/settingsHandler.go b/api/handlers/settings/settingsHandler.go
index 19e4a45..dd1038f 100644
--- a/api/handlers/settings/settingsHandler.go
+++ b/api/handlers/settings/settingsHandler.go
@@ -7,6 +7,7 @@ import (
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
+ "io"
"net/http"
"time"
)
@@ -18,12 +19,15 @@ type Handler struct {
}
type UpdateSettingsRequest struct {
- ActivityRetentionDays *int `json:"activity_retention_days" validate:"required,oneof=0 30 90 365"`
+ ActivityRetentionDays *int `json:"activity_retention_days" validate:"omitempty,oneof=0 30 90 365"`
+ ActivityRetentionMaxGB *int `json:"activity_retention_max_gb" validate:"omitempty,oneof=0 1 5 10 25 50"`
}
type settingsResponse struct {
Version string `json:"version"`
ActivityRetentionDays int `json:"activity_retention_days"`
+ ActivityRetentionMaxGB int `json:"activity_retention_max_gb"`
+ ActivityLogSizeBytes int64 `json:"activity_log_size_bytes"`
ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"`
ActivityNextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
@@ -43,8 +47,13 @@ func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) {
}
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil || hasNullValue(body) {
+ http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
+ return
+ }
var request UpdateSettingsRequest
- if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ if err := json.Unmarshal(body, &request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
@@ -53,7 +62,10 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
return
}
- retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), *request.ActivityRetentionDays)
+ retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), useCases.UpdateActivityRetentionCommand{
+ Days: request.ActivityRetentionDays,
+ MaxGB: request.ActivityRetentionMaxGB,
+ })
if err != nil {
if errors.Is(err, audit.ErrInvalidRetention) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
@@ -65,10 +77,25 @@ func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
h.write(w, retention)
}
+func hasNullValue(body []byte) bool {
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(body, &fields); err != nil {
+ return false
+ }
+ for _, value := range fields {
+ if string(value) == "null" {
+ return true
+ }
+ }
+ return false
+}
+
func (h *Handler) write(w http.ResponseWriter, retention audit.Settings) {
body, err := json.Marshal(settingsResponse{
Version: h.GetSettingsUseCase.GetSettings().Version,
ActivityRetentionDays: retention.RetentionDays,
+ ActivityRetentionMaxGB: retention.RetentionMaxGB,
+ ActivityLogSizeBytes: retention.LogSizeBytes,
ActivityLastCleanupAt: retention.LastCleanupAt,
ActivityLastCleanupRemoved: retention.LastCleanupRemoved,
ActivityNextCleanupAt: retention.NextCleanupAt,
diff --git a/docs/api.md b/docs/api.md
index 669f906..609cdd0 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -45,9 +45,12 @@ Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `me
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
`GET /v1/settings` also returns `activity_retention_days` (30, 90, 365 or 0 for forever, default
-90), `activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
-`{"activity_retention_days": 30}` is session only and owner only (403 "Only the owner can do this.");
-any other value answers 400.
+90), `activity_retention_max_gb` (0 for no size limit), `activity_log_size_bytes`,
+`activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
+`{"activity_retention_days": 30}`, `{"activity_retention_max_gb": 5}` or both is session only and
+owner only (403 "Only the owner can do this."); any other value answers 400. The daily cleanup
+removes events past the age limit, then the oldest events until the log fits the size limit.
+Whichever limit is reached first wins.
### What is recorded
Every real action is recorded by its use case, once, after the change was made (outcome `success`)
diff --git a/docs/configuration.md b/docs/configuration.md
index 8c878d6..40ca577 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -105,7 +105,7 @@ For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.
## Activity log retention
-The activity log needs no environment variable. Its retention period (30, 90, 365 days or forever, 90 by default) is a setting the owner changes with `PATCH /v1/settings`, and the daily cleanup runs at 03:00 in the server's time zone (`TZ`, or the host's zone).
+The activity log needs no environment variable. Its retention (an age of 30, 90, 365 days or forever, 90 by default, and an optional maximum size in GB, none by default; whichever limit is reached first removes the oldest events) is a setting the owner changes with `PATCH /v1/settings`, and the daily cleanup runs at 03:00 in the server's time zone (`TZ`, or the host's zone).
## Adding a variable
diff --git a/docs/design/screens/settings.md b/docs/design/screens/settings.md
index 169ec1d..504fb42 100644
--- a/docs/design/screens/settings.md
+++ b/docs/design/screens/settings.md
@@ -184,11 +184,12 @@ Duplicates tab: secondary "Purge duplicates" button (merge) next to the sort men
The last card on the page, below Media cleanup, owner only (hidden for admins and for the read-only Media cleanup view), same card styling and 720 px column as the others.
-- Header: "Retention" / "How long activity is kept." "Saved" (check, success) at the right after a save, fading after 2 s.
-- "Keep activity for" `USelectMenu`: 30 days, 90 days, 365 days, Forever. Default 90 days. Helper: "Older events are removed automatically." For Forever: "Nothing is removed automatically. The log grows over time."
-- Info alert (`lock`): "Activity logs are append-only", "Events can't be edited or deleted from the app. Only the retention period removes them."
-- Divider, then read-only lines: "Last cleanup: 6 Oct 2026, 03:00 · 142 events removed" and "Next cleanup: 7 Oct 2026, 03:00". Forever: "Never" and "Not scheduled".
-- Saves on change. Choosing a shorter period than the current one first shows a warning alert, "Events older than 30 days will be removed", "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.", buttons "Apply 30 days" and "Cancel"; nothing is sent until Apply. Lengthening or choosing Forever saves at once.
+- Header: "Retention" / "How long activity is kept, or how much of it. Whichever limit is reached first removes the oldest events." "Saved" (check, success) at the right after a save, fading after 2 s.
+- "Keep activity for" `USelectMenu`: 30 days, 90 days, 365 days, Forever. Default 90 days. Helper: "Events older than this are removed." when a size limit is set, otherwise "Older events are removed automatically." For Forever: "Nothing is removed automatically. The log grows over time." without a size limit, "No age limit. Only the size limit applies." with one.
+- An "or" divider, then "Keep at most" `USelectMenu` (160 px wide, full width on mobile): "No size limit" (default) and sizes in GB. Helper without a limit: "Optional. Logs can grow without a size cap." With one: "When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB." The figure after "Currently" is the real size of the activity log. The size options are listed in the API note below.
+- Info alert (`lock`): "Activity logs are append-only", "Events can't be edited or deleted from the app. Only the retention limits remove them."
+- Divider, then read-only lines: "Last cleanup: 6 Oct 2026, 03:00 · 142 events removed" and "Next cleanup: 7 Oct 2026, 03:00". Forever with no size limit: "Never" and "Not scheduled". Forever with a size limit still shows a schedule, because the size check runs at every cleanup.
+- Both selects save on change. Choosing a shorter age, or a smaller size limit than the current one, first shows a warning alert, "Events older than 30 days will be removed" (or "Events over 5 GB will be removed"), "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.", buttons "Apply 30 days" (or "Apply 5 GB") and "Cancel"; nothing is sent until Apply. Lengthening, raising, choosing Forever or choosing "No size limit" saves at once.
- Save error alert: "Couldn't save the retention period", "Your previous setting is still in place.", "Try again". The control reverts.
-- States: Default (90 days, saved), Shortening the period, Forever, Save error. Loading and Offline follow the Media cleanup card.
-- `GET /v1/settings` adds `activity_retention_days` (30, 90, 365 or 0 for forever), `activity_last_cleanup_at` (null before the first cleanup) and `activity_last_cleanup_removed`. `PATCH /v1/settings` takes `{ "activity_retention_days": 30 }`; any other value answers 400 "The request cannot be processed due to a mismatch in the format of the body."; a non-owner gets 403 "Only the owner can do this.". The cleanup runs once a day at 03:00 server time and never touches the last 24 hours.
+- States: "Default, 90 days or 5 GB", "Age only (no size limit)", "Size only (forever)", "Shortening the period" (also with a 5 GB limit), "Forever" and "Save error". Loading and Offline follow the Media cleanup card.
+- `GET /v1/settings` adds `activity_retention_days` (30, 90, 365 or 0 for forever), `activity_retention_max_gb` (0 for no size limit, otherwise one of the size options), `activity_log_size_bytes`, `activity_last_cleanup_at` (null before the first cleanup) and `activity_last_cleanup_removed`. `PATCH /v1/settings` takes `{ "activity_retention_days": 30 }`, `{ "activity_retention_max_gb": 5 }` or both; any other value answers 400 "The request cannot be processed due to a mismatch in the format of the body."; a non-owner gets 403 "Only the owner can do this.". The cleanup runs once a day at 03:00 server time, removes events older than the age limit, then removes the oldest events until the log is under the size limit, and never touches the last 24 hours.
diff --git a/docs/design/source/AppFrame.dc.html b/docs/design/source/AppFrame.dc.html
index ab234e1..7d4ed0c 100644
--- a/docs/design/source/AppFrame.dc.html
+++ b/docs/design/source/AppFrame.dc.html
@@ -232,7 +232,7 @@
{{ s.upd.link }}
Media cleanupMerge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.
Saving…Saved
Only owners and admins can change this.
Automatic purgeRuns in the background. Off by default.
EveryBetween 1 and 365 days.
{{ cl.daysVal }}days
{{ cl.errTxt }}{{ cl.lastLine }}{{ cl.nextLine }}
Purge nowMerge every group of duplicates that has an image in use, right away.
Purge duplicates
-RetentionHow long activity is kept. Only owners can change it.
Saving…Saved
Couldn't save the retention periodYour previous setting is still in place.
Try againKeep activity for{{ rt.help }}
{{ rt.val }}
Events older than 30 days will be removedThis happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.
Apply 30 daysCancel
Activity logs are append-onlyEvents can't be edited or deleted from the app. Only the retention period removes them.
{{ rt.last }}{{ rt.next }}
+RetentionHow long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.
Saving…Saved
Couldn't save the retention periodYour previous setting is still in place.
Try againKeep activity for{{ rt.help }}
{{ rt.val }}
or
Keep at most{{ rt.sizeHelp }}
{{ rt.sizeVal }}
Events older than 30 days will be removedThis happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.
Apply 30 daysCancel
Activity logs are append-onlyEvents can't be edited or deleted from the app. Only the retention limits remove them.
{{ rt.last }}{{ rt.next }}
@@ -424,8 +424,8 @@ class Component extends DCLogic {
const act = { show: !!ho.act && k === 'home' && !s.boot, title: own ? 'Your recent activity' : 'Recent activity', rows: actRows, error: ho.act === 'error', empty: ho.act === 'empty', emptyText: own ? 'Your actions will appear here.' : 'Actions on your site will appear here.' }
const C = s.cleanup || {}, cOn = !!C.on, cDis = !!(C.ro || C.offline || C.loading), cErr = !!(C.invalid || C.saveErr)
const cl = { loading: !!C.loading, ready: !C.loading, ro: !!C.ro, notRo: !C.ro, on: cOn, saving: !!C.saving, saved: !!C.saved && !C.saving, swW: m ? '44px' : '36px', swH: m ? '44px' : '24px', swOp: cDis ? '0.4' : '1', swRing: C.focusSw ? '2px solid var(--ui-primary)' : '0', swBd: cOn ? 'var(--ui-primary)' : 'var(--ui-border-accented)', swBg: cOn ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', knobL: cOn ? '17px' : '1px', knobBd: cOn ? 'transparent' : 'var(--ui-border-accented)', daysW: m ? '100%' : '140px', daysAlign: m ? 'stretch' : 'flex-end', daysBd: cErr ? 'var(--ui-error)' : C.focusDays ? 'var(--ui-primary)' : 'var(--ui-border-accented)', daysSh: C.focusDays && !cErr ? '0 0 0 1px var(--ui-primary)' : 'none', daysOp: cDis ? '0.4' : '1', daysVal: C.days ?? '30', hasErr: cErr, errTxt: C.invalid ? 'Enter a whole number of days between 1 and 365.' : 'Couldn\'t save. Try again in a moment.', errMax: m ? 'none' : '300px', errTA: m ? 'left' : 'right', lastLine: C.last ? 'Last purge: ' + C.last.d + ' · ' + C.last.c + ' copies merged · ' + C.last.a + ' articles updated' : 'Never run', nextLine: 'Next purge: ' + (C.next || '14 Jul 2026'), purgeOp: C.offline || C.loading ? '0.4' : '1', btnShrink: m ? '1' : '0' }
- const R = s.ret || {}, rd = R.days || '90', fv = rd === '0'
- const rt = { show: !s.noRet, saving: !!R.saving, saved: !!R.saved, err: !!R.err, shorten: !!R.shorten, val: { '30': '30 days', '90': '90 days', '365': '365 days', '0': 'Forever' }[R.shorten ? '30' : rd], help: fv ? 'Nothing is removed automatically. The log grows over time.' : 'Older events are removed automatically.', last: fv ? 'Last cleanup: Never' : 'Last cleanup: 6 Oct 2026, 03:00 · 142 events removed', next: fv ? 'Next cleanup: Not scheduled' : 'Next cleanup: 7 Oct 2026, 03:00', selW: m ? '100%' : '160px' }
+ const R = s.ret || {}, rd = R.days || '90', fv = rd === '0', sz = R.size || '0', hs = sz !== '0'
+ const rt = { show: !s.noRet, saving: !!R.saving, saved: !!R.saved, err: !!R.err, shorten: !!R.shorten, val: { '30': '30 days', '90': '90 days', '365': '365 days', '0': 'Forever' }[R.shorten ? '30' : rd], help: fv ? (hs ? 'No age limit. Only the size limit applies.' : 'Nothing is removed automatically. The log grows over time.') : (hs ? 'Events older than this are removed.' : 'Older events are removed automatically.'), last: fv && !hs ? 'Last cleanup: Never' : 'Last cleanup: 6 Oct 2026, 03:00 · 142 events removed', next: fv && !hs ? 'Next cleanup: Not scheduled' : 'Next cleanup: 7 Oct 2026, 03:00', sizeVal: hs ? sz + ' GB' : 'No size limit', sizeHelp: hs ? 'When logs pass ' + sz + ' GB, the oldest events are removed. Currently 3.2 GB.' : 'Optional. Logs can grow without a size cap.', selW: m ? '100%' : '160px' }
const purgeDlg = (() => {
if (!s.purge) return null
const h = React.createElement, bh = m ? 44 : 32
diff --git a/docs/design/source/RenewCMS Screens Back-office.dc.html b/docs/design/source/RenewCMS Screens Back-office.dc.html
index 11eb2bc..41c6d01 100644
--- a/docs/design/source/RenewCMS Screens Back-office.dc.html
+++ b/docs/design/source/RenewCMS Screens Back-office.dc.html
@@ -427,8 +427,10 @@ class Component extends DCLogic {
Mp({ tab: 'dups', offline: true, name: 'Duplicates tab: offline', trigger: 'The button is disabled with the existing banner.' })
] },
{ label: 'Settings: Retention', title: 'Settings: Retention (owner only)', route: '/settings', api: 'GET /v1/settings · PATCH /v1/settings', note: 'The last card on the page, same card styling as Version, Updates and Media cleanup. Saves on change.', states: [
- RT('Default, 90 days', 'Saves on change.', { days: '90', saved: true }),
- RT('Shortening the period', 'Confirms before older events are removed.', { days: '90', shorten: true }),
+ RT('Default, 90 days or 5 GB', 'Saves on change. Whichever limit is reached first removes the oldest events.', { days: '90', size: '5', saved: true }),
+ RT('Age only (no size limit)', 'The size select reads "No size limit". Same as before this option.', { days: '90' }),
+ RT('Size only (forever)', 'Age is set to Forever, so only the size limit removes events.', { days: '0', size: '5' }),
+ RT('Shortening the period', 'Confirms before older events are removed.', { days: '90', size: '5', shorten: true }),
RT('Forever', 'No cleanup scheduled.', { days: '0' }),
RT('Save error', 'The previous setting is still in place.', { days: '90', err: true })
] },
diff --git a/docs/design/states.md b/docs/design/states.md
index 8526f09..9fbc315 100644
--- a/docs/design/states.md
+++ b/docs/design/states.md
@@ -322,6 +322,6 @@ Added by the Media screens, none of which exist yet:
| My activity | `/activity` | No strip, Person filter or Export; own events only |
| Export | Header button, toasts | "Preparing file…", then "Export started. Your file will download shortly." or "Couldn't export activity. Try again." |
| Home card | `/home` | Owner or admin, regular user, loading, error, empty |
-| Retention | `/settings` | Default, shortening (confirm), Forever, save error |
+| Retention | `/settings` | Default (90 days or 5 GB), age only, size only (Forever), shortening (confirm), save error |
401 is global. The 403 on `/activity` has local copy because the page itself is refused; the 403 on the summary or export of a non-admin is never shown, since the controls are hidden.
diff --git a/features/activity-log-backend.feature b/features/activity-log-backend.feature
index defada0..2d59087 100644
--- a/features/activity-log-backend.feature
+++ b/features/activity-log-backend.feature
@@ -30,7 +30,7 @@ Feature: The back-office keeps an append-only activity log that owners can read
| change.kind | text, body |
Scenario: The repository exposes no way to edit or delete one event
- Then the audit Repository interface has exactly the methods Append, List, Summary and DeleteOlderThan
+ Then the audit Repository interface has exactly the methods Append, List, Summary, DeleteOlderThan, DeleteOldest and SizeBytes
And the persistence implementation exposes no other exported method than those and its constructor
And the HTTP surface has no POST, PUT, PATCH or DELETE under /v1/activity
@@ -464,7 +464,7 @@ Feature: The back-office keeps an append-only activity log that owners can read
| 2026-10-06T03:00:00 | 2026-10-06T04:00:00 | false |
| 2026-10-06T03:00:00 | 2026-10-07T03:00:00 | true |
- Scenario: Forever never runs
+ Scenario: Forever with no size limit never runs
Given the retention is 0
When Tick runs at 2026-10-06T03:00:00
Then ran is false and DeleteOlderThan is not called
@@ -559,3 +559,10 @@ Feature: The back-office keeps an append-only activity log that owners can read
When the owner sends GET /v1/activity
Then the three events are listed newest first with their documented items
And GET /v1/activity?security=true lists only the failed login
+
+ Scenario: The size limit extension amends the retention rules
+ Given features/activity_home_card_and_retention.feature, rule "Retention size limit"
+ Then the settings default also has activity_retention_max_gb 0 and activity_log_size_bytes 0
+ And PATCH /v1/settings also accepts activity_retention_max_gb validated against audit.SizeOptions()
+ And the last 24 hours are never removed by DeleteOldest either
+ And a retention change is recorded with the max_gb_from and max_gb_to params when the size changes
diff --git a/features/activity_home_card_and_retention.feature b/features/activity_home_card_and_retention.feature
index c484c52..54f3624 100644
--- a/features/activity_home_card_and_retention.feature
+++ b/features/activity_home_card_and_retention.feature
@@ -347,3 +347,272 @@ Feature: Recent activity card on Home and Retention card on Settings
Scenario: Existing behaviour is unchanged
Then every scenario of home.feature, settings_page.feature (except its exact-body statement, superseded above) and the Media cleanup scenarios still holds with GET /v1/activity?size=5 mocked and the settings mock widened with the four activity_* fields
+
+ Rule: Retention size limit, an optional maximum size as an alternative to the age limit
+
+ Background:
+ Given this rule supersedes the statements above where they conflict: the settings body, the next-cleanup rule, the PATCH validation, the card copy and the shortening rule
+ And this extension changes Go code in the audit settings, the audit repositories, the settings and cleanup use cases and the settings handler
+ And GET /v1/settings and the PATCH response carry exactly {"version", "activity_retention_days", "activity_retention_max_gb", "activity_log_size_bytes", "activity_last_cleanup_at", "activity_last_cleanup_removed", "activity_next_cleanup_at"}, snake_case
+ And activity_next_cleanup_at is null only when activity_retention_days is 0 and activity_retention_max_gb is 0, otherwise it is the next 03:00 (audit.CleanupHour) in the server zone strictly after now, so Forever with a size limit has a schedule
+ And the PATCH body is a partial object with activity_retention_days in {0, 30, 90, 365} and/or activity_retention_max_gb in {0, 1, 5, 10, 25, 50}, absent keys are left unchanged, and an empty object, a null, a wrong type, any other value or malformed JSON answers 400 with apperr.BodyErrorMessage and saves nothing
+ And a non-owner PATCH answers 403 "Only the owner can do this."
+ And validation goes through validate tags and validator.Validate.Struct
+ And the size options are one list in the audit domain, audit.SizeOptions(), mirroring RetentionOptions()
+ And activity_log_size_bytes is the payload size of the activity events table, measured by audit.Repository.SizeBytes: the sum of the OCTET_LENGTH of the text columns action, actor_name, target_type, target_id, target_name, failure_reason, ip, user_agent, params and changes plus a fixed per-row overhead constant defined in the domain
+ And it is not the database file size and excludes indexes, free pages and other tables, and repositories never touch files
+ And the audit Repository gains SizeBytes() (int64, error) and DeleteOldest(limit int, before time.Time) (int, error); DeleteOldest removes the oldest events by created_at then id and only those older than before
+ And the cleanup runs the age step when days is greater than 0, then removes the oldest events in batches through DeleteOldest, calling SizeBytes between batches, until the log is at or under the limit or a batch removes 0
+ And the cutoff of every removal is the 24 hour guard (audit.KeepRecentHours); when the limit cannot be met because of the guard, the cleanup stops without error, records the run and tries again at the next 03:00
+ And Tick returns early only when days is 0 and max_gb is 0
+ And the removed count is the total of the age and size removals, in the last-cleanup fields and in the system.activity_cleanup params {"removed": n}
+ And system.activity_retention_changed is recorded once per PATCH that changes something, with params {"from","to"} when the age changes and "max_gb_from" and "max_gb_to" when the size changes, and no event when nothing changes
+ And lib/activitySentence.ts has an English and a French sentence for a size change
+ And GET /v1/settings computes the size on every call and a SizeBytes failure answers 500, like any use case error; every verified user receives the number, only the owner sees the card
+ And the SPA shows the size in GB (1 GB is 1024^3 bytes) with one decimal, or "Less than 0.1 GB" below 0.1 GB
+ And the header description is "How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it."
+ And the append-only alert reads "Events can't be edited or deleted from the app. Only the retention limits remove them."
+ And below the age select an "or" divider is followed by the label "Keep at most" over a USelectMenu 160 px wide (full width on mobile) with the items "No size limit", "1 GB", "5 GB", "10 GB", "25 GB", "50 GB" (values 0, 1, 5, 10, 25, 50)
+ And the age helper is "Older events are removed automatically." without a size limit, "Events older than this are removed." with one, "Nothing is removed automatically. The log grows over time." for Forever without a size limit and "No age limit. Only the size limit applies." for Forever with one
+ And the size helper is "Optional. Logs can grow without a size cap." without a limit and "When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB." with one, the figure being activity_log_size_bytes and never a hard-coded value
+ And the size rank is 1 < 5 < 10 < 25 < 50 < No size limit and a lower rank than the saved one is "smaller"
+ And choosing a smaller size, including a size when there was none, sends nothing and shows the warning "Events over {n} GB will be removed" with "This happens at the next cleanup on {date}. It can't be undone.", "Apply {n} GB" and "Cancel"; choosing a larger size or "No size limit" saves at once
+ And each select sends only its own key, both share one status area, one error alert and one pending warning, and a warning concerns one control at a time: a longer or larger choice on either control drops it and another shortening replaces it
+ And the visual states are "Default, 90 days or 5 GB", "Age only (no size limit)", "Size only (forever)", the shortening warning with a 5 GB limit, Forever and Save error
+
+ Scenario Outline: Settings API, GET reports the size limit and the measured size
+ Given the saved retention is days with a size limit of GB and the activity table holds bytes of payload
+ When a verified user sends GET /v1/settings at the server time 2026-10-06T10:00:00+02:00
+ Then the status is 200 and the body has exactly the seven keys
+ And "activity_retention_max_gb" is and "activity_log_size_bytes" is
+ And "activity_next_cleanup_at" is
+
+ Examples:
+ | days | gb | bytes | next |
+ | 90 | 0 | 0 | "2026-10-07T03:00:00+02:00" |
+ | 90 | 5 | 3435973837 | "2026-10-07T03:00:00+02:00" |
+ | 0 | 5 | 3435973837 | "2026-10-07T03:00:00+02:00" |
+ | 0 | 0 | 1024 | null |
+
+ Scenario: Settings API, a fresh install defaults to no size limit
+ Given no settings row exists
+ When a verified user sends GET /v1/settings
+ Then activity_retention_days is 90, activity_retention_max_gb is 0 and activity_log_size_bytes is 0
+
+ Scenario Outline: Settings API, PATCH saves a size limit, an age, or both
+ Given the saved retention is 90 days and no size limit and the user is the owner
+ When the owner sends PATCH /v1/settings with
+ Then the status is 200 and the body has activity_retention_days , activity_retention_max_gb and activity_next_cleanup_at
+ And the repository holds and
+
+ Examples:
+ | body | days | gb | next |
+ | {"activity_retention_max_gb": 5} | 90 | 5 | the next 03:00 server time |
+ | {"activity_retention_days": 0, "activity_retention_max_gb": 5} | 0 | 5 | the next 03:00 server time |
+ | {"activity_retention_days": 30} | 30 | 0 | the next 03:00 server time |
+ | {"activity_retention_days": 0} | 0 | 0 | null |
+
+ Scenario: Settings API, leaving the size limit while Forever clears the schedule
+ Given the saved retention is 0 days with a size limit of 5 GB
+ When the owner sends PATCH /v1/settings with {"activity_retention_max_gb": 0}
+ Then activity_next_cleanup_at is null
+
+ Scenario Outline: Settings API, PATCH rejects invalid size input
+ Given the saved retention is 90 days with a size limit of 5 GB
+ When the owner sends PATCH /v1/settings with
+ Then the status is 400 with the text of apperr.BodyErrorMessage and nothing is saved
+
+ Examples:
+ | body |
+ | {"activity_retention_max_gb": 2} |
+ | {"activity_retention_max_gb": -1} |
+ | {"activity_retention_max_gb": 100} |
+ | {"activity_retention_max_gb": 5.5} |
+ | {"activity_retention_max_gb": "5"} |
+ | {"activity_retention_max_gb": null} |
+ | {"activity_retention_days": 30, "activity_retention_max_gb": 2} |
+ | {} |
+
+ Scenario: Settings API, a PATCH changing nothing is a no-op
+ Given the saved retention is 90 days and 5 GB
+ When the owner sends PATCH /v1/settings with {"activity_retention_days": 90, "activity_retention_max_gb": 5}
+ Then the status is 200, nothing is saved and no audit event is recorded
+
+ Scenario: Settings API, a non-owner cannot set the size limit
+ When an admin sends PATCH /v1/settings with {"activity_retention_max_gb": 5}
+ Then the status is 403 with "Only the owner can do this." and nothing is saved
+
+ Scenario: Settings API, a failing size measurement answers 500
+ Given SizeBytes fails
+ When a verified user sends GET /v1/settings
+ Then the status is 500 with the generic internal error text
+
+ Scenario Outline: Settings API, the change is audited
+ Given the saved retention is 90 days and no size limit
+ When the owner sends PATCH /v1/settings with
+ Then one event "system.activity_retention_changed" exists with params
+
+ Examples:
+ | body | params |
+ | {"activity_retention_days": 30} | {"from": 90, "to": 30} |
+ | {"activity_retention_max_gb": 5} | {"max_gb_from": 0, "max_gb_to": 5} |
+ | {"activity_retention_days": 30, "activity_retention_max_gb": 5} | {"from": 90, "to": 30, "max_gb_from": 0, "max_gb_to": 5} |
+
+ Scenario Outline: Cleanup, the size limit removes the oldest events
+ Given the retention is days, the size limit is 5 GB and the log is 7 GB
+ And the oldest events are older than the guard
+ When the cleanup runs at 2026-10-06T03:00:00
+ Then events are removed oldest first, by created_at then id, until SizeBytes is at or under 5 GB
+ And the removed count is the total of the age removals and the size removals
+ And the last cleanup time is the clock's time
+
+ Examples:
+ | days |
+ | 90 |
+ | 0 |
+
+ Scenario: Cleanup, age first then size
+ Given the retention is 30 days, the size limit is 5 GB, 100 events are older than 30 days and the log still exceeds 5 GB after they go
+ When the cleanup runs
+ Then the 100 events go first, then the oldest remaining events until the log is at or under 5 GB
+
+ Scenario: Cleanup, the age limit alone is enough
+ Given the log is under the size limit after the age removal
+ When the cleanup runs
+ Then no size removal happens
+
+ Scenario: Cleanup, Forever with no size limit never runs
+ Given the retention is 0 days and no size limit
+ When Tick runs at 2026-10-06T03:00:00
+ Then ran is false and neither DeleteOlderThan nor DeleteOldest is called
+
+ Scenario: Cleanup, the last 24 hours are protected even when the size cannot be met
+ Given the size limit is 1 GB and the log is 4 GB, all of it from the last 24 hours
+ When the cleanup runs
+ Then nothing is removed, no error is returned, the last cleanup records 0 removed and the next tick tries again
+
+ Scenario: Cleanup, a failing size removal is retried
+ Given DeleteOldest fails
+ When Tick runs
+ Then the error is returned and logged, the last cleanup fields are unchanged and the next tick runs again
+
+ Scenario: Cleanup, a size removal records one system event
+ Given the age step removed 10 events and the size step removed 40
+ When the cleanup runs
+ Then one "system.activity_cleanup" event exists with params {"removed": 50}
+
+ Scenario: Persistence measures the table payload portably
+ Given a real in-memory SQLite database and events with known text lengths, one with a multi-byte name
+ Then SizeBytes equals the sum of the byte lengths of the text columns plus the fixed per-row overhead
+ And it is 0 for an empty table
+ And deleting events lowers it
+
+ Scenario: Persistence deletes the oldest events up to a limit and a guard
+ Given 10 events, 3 of them newer than the guard
+ When DeleteOldest runs with limit 5
+ Then the 5 oldest events are removed
+ When DeleteOldest runs with limit 20
+ Then only the 2 remaining events older than the guard are removed and the 3 newest stay
+
+ Scenario: Settings retention, Default with 90 days or 5 GB
+ Given the role is "owner" and GET /v1/settings resolves 200 with activity_retention_days 90, activity_retention_max_gb 5 and activity_log_size_bytes 3435973837
+ When the owner navigates to /settings
+ Then the age select shows "90 days" with the helper "Events older than this are removed."
+ And an "or" divider and "Keep at most" show "5 GB" with the helper "When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB."
+ And the lock alert reads "Events can't be edited or deleted from the app. Only the retention limits remove them."
+ And "Next cleanup" shows the date from activity_next_cleanup_at
+
+ Scenario: Settings retention, Age only (no size limit)
+ Given activity_retention_max_gb is 0
+ When the owner navigates to /settings
+ Then the size select reads "No size limit" with the helper "Optional. Logs can grow without a size cap." and the age helper is "Older events are removed automatically."
+
+ Scenario: Settings retention, Size only (forever)
+ Given activity_retention_days is 0, activity_retention_max_gb is 5 and activity_next_cleanup_at is set
+ When the owner navigates to /settings
+ Then the age helper reads "No age limit. Only the size limit applies." and "Next cleanup" shows the date, not "Not scheduled"
+
+ Scenario: Settings retention, Forever with no size limit
+ Given activity_retention_days is 0, activity_retention_max_gb is 0 and activity_next_cleanup_at is null
+ When the owner navigates to /settings
+ Then the age helper reads "Nothing is removed automatically. The log grows over time." and "Next cleanup" reads "Not scheduled"
+
+ Scenario Outline: Settings retention, the measured size is formatted
+ Given the size limit is 5 GB and activity_log_size_bytes is
+ When the owner navigates to /settings
+ Then the size helper ends with ""
+
+ Examples:
+ | bytes | shown |
+ | 3435973837 | Currently 3.2 GB. |
+ | 0 | Currently Less than 0.1 GB. |
+ | 53687091 | Currently Less than 0.1 GB. |
+ | 5368709120 | Currently 5.0 GB. |
+
+ Scenario Outline: Settings retention, a larger size or no limit saves at once
+ Given the saved size is
+ When the owner chooses ""
+ Then PATCH is sent once with {"activity_retention_max_gb": }, never the age key, and no warning shows
+ And the shared status reads "Saving…" then "Saved"
+
+ Examples:
+ | from | to | value |
+ | 5 GB | 10 GB | 10 |
+ | 5 GB | No size limit | 0 |
+ | 1 GB | 50 GB | 50 |
+
+ Scenario Outline: Settings retention, a smaller size asks first
+ Given the saved size is
+ When the owner chooses ""
+ Then no PATCH is sent and the size select shows ""
+ And a warning alert shows "Events over GB will be removed" and "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone." with "Apply GB" and "Cancel"
+
+ Examples:
+ | from | to | n |
+ | 10 GB | 5 GB | 5 |
+ | No size limit | 5 GB | 5 |
+ | 50 GB | 1 GB | 1 |
+
+ Scenario: Settings retention, Apply and Cancel for a smaller size
+ Given the warning for 5 GB is shown on a saved 10 GB
+ When the owner activates "Apply 5 GB" and PATCH resolves 200 with activity_retention_max_gb 5
+ Then PATCH was sent once with {"activity_retention_max_gb": 5} and "Saved" shows
+ Given the warning is shown again
+ When the owner activates "Cancel"
+ Then the size select reads the saved value and no PATCH was sent
+
+ Scenario: Settings retention, only one warning at a time
+ Given the age warning for 30 days is open
+ When the owner chooses "5 GB" as a smaller size
+ Then the age warning is replaced by the size warning and the age select reverts to the saved value
+ When the owner chooses a longer age or a larger size
+ Then the warning is dropped and PATCH is sent once for that control
+
+ Scenario: Settings retention, switching age to Forever with a size limit
+ Given the saved period is 90 days and 5 GB
+ When the owner chooses "Forever"
+ Then PATCH is sent with {"activity_retention_days": 0} and the age helper becomes "No age limit. Only the size limit applies."
+ And "Next cleanup" still shows the returned date
+
+ Scenario Outline: Settings retention, a size save error reverts only the size select
+ Given the saved size is 5 GB and the owner chooses "10 GB"
+ When PATCH /v1/settings
+ Then the error alert "Couldn't save the retention period" with "Your previous setting is still in place." and "Try again" shows
+ And the size select reads "5 GB" again and the age select is unchanged
+ When the owner activates "Try again" and PATCH resolves 200
+ Then PATCH was sent a second time with {"activity_retention_max_gb": 10} and "Saved" replaces the alert
+
+ Examples:
+ | failure |
+ | resolves with a 500 response |
+ | resolves with a 400 response |
+ | resolves with a 403 response |
+ | never reaches the server |
+
+ Scenario: Settings retention, offline disables the size select too
+ Given the owner is on /settings and the browser goes offline
+ Then both selects, "Apply" and "Cancel" are disabled
+
+ Scenario: The French interface covers the size strings
+ Given the language is French
+ Then every new string has a French entry in messages.ts, and the Activity sentence for a size change is French
diff --git a/features/activity_home_card_and_retention.qa.md b/features/activity_home_card_and_retention.qa.md
index f693d6b..099e0ca 100644
--- a/features/activity_home_card_and_retention.qa.md
+++ b/features/activity_home_card_and_retention.qa.md
@@ -72,3 +72,51 @@ Settings (mount SettingsView, GET /v1/settings mocked with the five-key body):
- [ ] Home at 1440 and 390 px, light and dark: 420 px column in the far-right of the full content width, 16 px gap, row clamping, "View all" 44 px touch target; the article column fills the remaining width and still reads well.
- [ ] Keyboard and focus: Home tab order "New article", article rows, "View all"; Settings select, Apply, Cancel in order and focus back on the select after Cancel.
- [ ] Hover, focus rings, the 2 s "Saved" fade and reduced motion.
+
+## Retention size limit extension
+
+Spec: rule "Retention size limit" in activity_home_card_and_retention.feature. Items 14 to 25 above are updated for the widened mock body (activity_retention_max_gb, activity_log_size_bytes) and the new helper and alert copy.
+
+Implementation note for coder (informational, not a QA assertion):
+- audit.Settings gains RetentionMaxGB; the settings use case result carries LogSizeBytes from audit.Repository.SizeBytes.
+- audit.SizeOptions() is [0, 1, 5, 10, 25, 50]; invalid values return ErrInvalidRetention.
+- audit.Repository gains SizeBytes() (int64, error) and DeleteOldest(limit int, before time.Time) (int, error).
+- audit.SettingsRepository.SaveRetention takes days and max_gb; the model and mapper gain retention_max_gb.
+- The cleanup use case runs the age step when days > 0, then loops DeleteOldest batches with SizeBytes until the log fits or a batch removes 0; the cutoff is the 24 h guard.
+- The handler request struct is a partial with pointer fields and validate tags; an empty object is rejected.
+- settingsResponse gains activity_retention_max_gb and activity_log_size_bytes. Update the settings_page tests that count keys.
+
+### Go (httptest, fakes)
+- G11. GET key set is exactly the seven keys; max_gb and size_bytes carry the fake's values; a fresh fake gives 90, 0, 0.
+- G12. GET next-cleanup table: (90, 0), (90, 5), (0, 5) are non-null; (0, 0) is JSON null with the key present.
+- G13. PATCH table: size alone, age alone, both, Forever plus size, size 0 with Forever (next becomes null); the fake repository holds the pair.
+- G14. PATCH invalid: sizes 2, -1, 100, 5.5, "5", null, mixed with a valid age, {}, not JSON; each 400 with the BodyErrorMessage text and nothing saved. Non-owner 403, no cookie 401.
+- G15. No-op PATCH of both saved values: 200, no save, no event.
+- G16. Audit params table for the three bodies (age, size, both).
+- G17. Cleanup use case with a fake events repository that has a size model: age then size, Forever plus size, Forever with no size (ran=false, no calls), size already met (no DeleteOldest), the guard stops at 24 h with 0 removed and no error, DeleteOldest error propagates and leaves the last cleanup unchanged, total removed in the event params and in SaveLastCleanup, the batch loop terminates when a batch removes 0.
+- G18. CleanupDue table unchanged; Tick runs for Forever when a size limit is set.
+- G19. Domain: SizeOptions validation; next-cleanup is nil only when both are 0.
+- G20. Persistence on in-memory SQLite (extend test/activity_persistence_test.go): SizeBytes with multi-byte text, the empty table, after delete; DeleteOldest with the limit and the guard; the migrated model has retention_max_gb defaulting to 0.
+- G21. The interface-shape test is updated to the six methods.
+- G22. A SizeBytes error propagates and the handler answers 500 "Internal server error".
+
+### SPA (Vitest), extending web/src/views/__tests__/SettingsRetention.spec.ts
+- 26. Default 90 days or 5 GB; Age only; Size only (forever); Forever without a size: selects, helpers, "Currently 3.2 GB.", the "or" divider, the lines.
+- 27. Size formatting table (3.2, "Less than 0.1 GB", 5.0); 1024^3 base.
+- 28. Larger size or No size limit: one PATCH with only the size key, "Saving…" then "Saved" (fake timers).
+- 29. Smaller size, and a size when none was set: no PATCH, warning copy, Apply sends one PATCH, Cancel reverts.
+- 30. One warning at a time; a longer or larger choice drops it.
+- 31. Save errors (500, 400, 403, network): only the size select reverts; Try again re-sends the same body without re-confirming; 403 refreshes the session and the card disappears.
+- 32. Offline disables both selects and Apply and Cancel; 401 is global.
+- 33. French parity for the new strings; lib/activitySentence.spec.ts covers the size-change sentence in English and French.
+
+### Visual (task visual:check)
+The board draws the card in group "Settings: Retention" with the states 'Age only (no size limit)' and 'Size only (forever)'. In settings.md the states are bullets, not headings, so confirm on the first run whether the tool iterates them. Setups use the board's own sample data (5 GB, 3.2 GB) and the owner /v1/settings mock gets the widened body. Clock-sensitive states stay skip: 'clock-sensitive'. No exception beforehand.
+
+### Manual verification
+- [ ] Real SQLite file: activity_log_size_bytes grows as events are added and falls after a forced cleanup.
+- [ ] Forced cleanup with a small limit removes the oldest events and leaves the last 24 h.
+- [ ] Optional: SizeBytes on real MySQL and PostgreSQL.
+- [ ] Real PATCH as owner for each option, as admin 403, as owner with 2 GB 400.
+- [ ] Keyboard order: age select, size select, Apply, Cancel; focus returns to the triggering select after Cancel.
+- [ ] 1440 and 390 px, light and dark: the "or" divider and the 160 px or full-width size select as drawn in the board.
diff --git a/internal/domain/audit/auditDomain.go b/internal/domain/audit/auditDomain.go
index 03d3918..12b5c88 100644
--- a/internal/domain/audit/auditDomain.go
+++ b/internal/domain/audit/auditDomain.go
@@ -105,6 +105,8 @@ const (
ParamRemoved = "removed"
ParamFrom = "from"
ParamTo = "to"
+ ParamMaxGBFrom = "max_gb_from"
+ ParamMaxGBTo = "max_gb_to"
ParamMerged = "merged"
ParamArticlesUpdated = "articles_updated"
ParamFailedGroups = "failed_groups"
diff --git a/internal/domain/audit/auditRepositoryInterface.go b/internal/domain/audit/auditRepositoryInterface.go
index 8ae23c5..78caa20 100644
--- a/internal/domain/audit/auditRepositoryInterface.go
+++ b/internal/domain/audit/auditRepositoryInterface.go
@@ -7,10 +7,12 @@ type Repository interface {
List(filter Filter) (Page, error)
Summary(query SummaryQuery) (Summary, error)
DeleteOlderThan(cutoff time.Time) (int, error)
+ DeleteOldest(limit int, before time.Time) (int, error)
+ SizeBytes() (int64, error)
}
type SettingsRepository interface {
Get() (Settings, error)
- SaveRetention(days int) error
+ SaveRetention(days int, maxGB int) error
SaveLastCleanup(at time.Time, removed int) error
}
diff --git a/internal/domain/audit/auditSettings.go b/internal/domain/audit/auditSettings.go
index ed2c14b..73ee023 100644
--- a/internal/domain/audit/auditSettings.go
+++ b/internal/domain/audit/auditSettings.go
@@ -7,6 +7,9 @@ const (
ForeverRetentionDays = 0
CleanupHour = 3
KeepRecentHours = 24
+ NoSizeLimitGB = 0
+ BytesPerGB = int64(1) << 30
+ RowOverheadBytes = int64(64)
)
func RetentionOptions() []int {
@@ -22,8 +25,23 @@ func ValidateRetentionDays(days int) error {
return ErrInvalidRetention
}
+func SizeOptions() []int {
+ return []int{NoSizeLimitGB, 1, 5, 10, 25, 50}
+}
+
+func ValidateRetentionMaxGB(maxGB int) error {
+ for _, option := range SizeOptions() {
+ if option == maxGB {
+ return nil
+ }
+ }
+ return ErrInvalidRetention
+}
+
type Settings struct {
RetentionDays int `json:"activity_retention_days"`
+ RetentionMaxGB int `json:"activity_retention_max_gb"`
+ LogSizeBytes int64 `json:"activity_log_size_bytes"`
LastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
LastCleanupRemoved int `json:"activity_last_cleanup_removed"`
NextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
@@ -33,6 +51,14 @@ func DefaultSettings() Settings {
return Settings{RetentionDays: DefaultRetentionDays}
}
+func (s Settings) HasRetentionLimit() bool {
+ return s.RetentionDays != ForeverRetentionDays || s.RetentionMaxGB != NoSizeLimitGB
+}
+
+func (s Settings) MaxLogSizeBytes() int64 {
+ return int64(s.RetentionMaxGB) * BytesPerGB
+}
+
func (s Settings) CleanupDue(now time.Time) bool {
todayRun := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location())
if now.Before(todayRun) {
@@ -43,7 +69,7 @@ func (s Settings) CleanupDue(now time.Time) bool {
func (s Settings) WithNextCleanup(now time.Time) Settings {
s.NextCleanupAt = nil
- if s.RetentionDays == ForeverRetentionDays {
+ if !s.HasRetentionLimit() {
return s
}
next := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location())
@@ -62,6 +88,10 @@ func ClampCutoff(now time.Time, cutoff time.Time) time.Time {
return cutoff
}
+func GuardCutoff(now time.Time) time.Time {
+ return ClampCutoff(now, now)
+}
+
func RetentionCutoff(now time.Time, retentionDays int) time.Time {
return ClampCutoff(now, now.Add(-time.Duration(retentionDays)*24*time.Hour))
}
diff --git a/internal/infrastructure/persistence/mappers/auditMapper.go b/internal/infrastructure/persistence/mappers/auditMapper.go
index 4a6a2e1..ad9c9a8 100644
--- a/internal/infrastructure/persistence/mappers/auditMapper.go
+++ b/internal/infrastructure/persistence/mappers/auditMapper.go
@@ -83,6 +83,7 @@ func encodeJSON(value any, empty string) (string, error) {
func AuditSettingsToDomain(m models.AuditSettings) audit.Settings {
return audit.Settings{
RetentionDays: m.RetentionDays,
+ RetentionMaxGB: m.RetentionMaxGB,
LastCleanupAt: m.LastCleanupAt,
LastCleanupRemoved: m.LastCleanupRemoved,
}
@@ -91,6 +92,7 @@ func AuditSettingsToDomain(m models.AuditSettings) audit.Settings {
func AuditSettingsToModel(s audit.Settings) models.AuditSettings {
return models.AuditSettings{
RetentionDays: s.RetentionDays,
+ RetentionMaxGB: s.RetentionMaxGB,
LastCleanupAt: s.LastCleanupAt,
LastCleanupRemoved: s.LastCleanupRemoved,
}
diff --git a/internal/infrastructure/persistence/models/auditSettingsModel.go b/internal/infrastructure/persistence/models/auditSettingsModel.go
index bda6733..b801e73 100644
--- a/internal/infrastructure/persistence/models/auditSettingsModel.go
+++ b/internal/infrastructure/persistence/models/auditSettingsModel.go
@@ -5,6 +5,7 @@ import "time"
type AuditSettings struct {
ID uint32 `gorm:"primaryKey"`
RetentionDays int `gorm:"not null"`
+ RetentionMaxGB int `gorm:"not null;default:0"`
LastCleanupAt *time.Time
LastCleanupRemoved int `gorm:"not null"`
}
diff --git a/internal/infrastructure/persistence/repositories/auditEventRepository.go b/internal/infrastructure/persistence/repositories/auditEventRepository.go
index b81d1b8..31c5714 100644
--- a/internal/infrastructure/persistence/repositories/auditEventRepository.go
+++ b/internal/infrastructure/persistence/repositories/auditEventRepository.go
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
+ "slices"
"strings"
"time"
@@ -186,3 +187,49 @@ func (a *AuditEventRepository) DeleteOlderThan(cutoff time.Time) (int, error) {
result := a.db.Where("created_at < ?", cutoff.UTC()).Delete(&entity.AuditEvent{})
return int(result.RowsAffected), result.Error
}
+
+const (
+ deleteChunkSize = 500
+ payloadSizeSQL = `COALESCE(SUM(
+ OCTET_LENGTH(action) + OCTET_LENGTH(actor_name) + OCTET_LENGTH(target_type) +
+ OCTET_LENGTH(target_id) + OCTET_LENGTH(target_name) + COALESCE(OCTET_LENGTH(failure_reason), 0) +
+ COALESCE(OCTET_LENGTH(ip), 0) + COALESCE(OCTET_LENGTH(user_agent), 0) +
+ OCTET_LENGTH(params) + OCTET_LENGTH(changes)), 0) AS payload, COUNT(*) AS total`
+)
+
+type payloadRow struct {
+ Payload int64
+ Total int64
+}
+
+func (a *AuditEventRepository) SizeBytes() (int64, error) {
+ var row payloadRow
+ if err := a.db.Model(&entity.AuditEvent{}).Select(payloadSizeSQL).Scan(&row).Error; err != nil {
+ return 0, err
+ }
+ return row.Payload + row.Total*audit.RowOverheadBytes, nil
+}
+
+func (a *AuditEventRepository) DeleteOldest(limit int, before time.Time) (int, error) {
+ if limit < 1 {
+ return 0, nil
+ }
+ var ids []uint64
+ err := a.db.Model(&entity.AuditEvent{}).
+ Where("created_at < ?", before.UTC()).
+ Order("created_at ASC, id ASC").
+ Limit(limit).
+ Pluck("id", &ids).Error
+ if err != nil {
+ return 0, err
+ }
+ removed := 0
+ for chunk := range slices.Chunk(ids, deleteChunkSize) {
+ result := a.db.Where("id IN ?", chunk).Delete(&entity.AuditEvent{})
+ if result.Error != nil {
+ return removed, result.Error
+ }
+ removed += int(result.RowsAffected)
+ }
+ return removed, nil
+}
diff --git a/internal/infrastructure/persistence/repositories/auditSettingsRepository.go b/internal/infrastructure/persistence/repositories/auditSettingsRepository.go
index b3bedab..d843ab2 100644
--- a/internal/infrastructure/persistence/repositories/auditSettingsRepository.go
+++ b/internal/infrastructure/persistence/repositories/auditSettingsRepository.go
@@ -34,9 +34,10 @@ func (a *AuditSettingsRepository) Get() (audit.Settings, error) {
return mappers.AuditSettingsToDomain(model), nil
}
-func (a *AuditSettingsRepository) SaveRetention(days int) error {
- return a.update(map[string]any{"retention_days": days}, func(settings *audit.Settings) {
+func (a *AuditSettingsRepository) SaveRetention(days int, maxGB int) error {
+ return a.update(map[string]any{"retention_days": days, "retention_max_gb": maxGB}, func(settings *audit.Settings) {
settings.RetentionDays = days
+ settings.RetentionMaxGB = maxGB
})
}
diff --git a/internal/infrastructure/useCases/ActivityRetention.go b/internal/infrastructure/useCases/ActivityRetention.go
index 5ce6816..fa1c480 100644
--- a/internal/infrastructure/useCases/ActivityRetention.go
+++ b/internal/infrastructure/useCases/ActivityRetention.go
@@ -8,11 +8,12 @@ import (
type GetActivityRetentionUseCase struct {
settings audit.SettingsRepository
+ events audit.Repository
now func() time.Time
}
-func NewGetActivityRetentionUseCase(settings audit.SettingsRepository, now func() time.Time) *GetActivityRetentionUseCase {
- return &GetActivityRetentionUseCase{settings, now}
+func NewGetActivityRetentionUseCase(settings audit.SettingsRepository, events audit.Repository, now func() time.Time) *GetActivityRetentionUseCase {
+ return &GetActivityRetentionUseCase{settings, events, now}
}
func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, error) {
@@ -20,38 +21,80 @@ func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, er
if err != nil {
return audit.Settings{}, err
}
+ current.LogSizeBytes, err = g.events.SizeBytes()
+ if err != nil {
+ return audit.Settings{}, err
+ }
return current.WithNextCleanup(g.now()), nil
}
+type UpdateActivityRetentionCommand struct {
+ Days *int
+ MaxGB *int
+}
+
type UpdateActivityRetentionUseCase struct {
settings audit.SettingsRepository
+ events audit.Repository
recorder audit.Recorder
now func() time.Time
}
-func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, recorder audit.Recorder, now func() time.Time) *UpdateActivityRetentionUseCase {
- return &UpdateActivityRetentionUseCase{settings, recorder, now}
+func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, events audit.Repository, recorder audit.Recorder, now func() time.Time) *UpdateActivityRetentionUseCase {
+ return &UpdateActivityRetentionUseCase{settings, events, recorder, now}
}
-func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Context, days int) (audit.Settings, error) {
- if err := audit.ValidateRetentionDays(days); err != nil {
- return audit.Settings{}, err
+func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Context, command UpdateActivityRetentionCommand) (audit.Settings, error) {
+ if command.Days == nil && command.MaxGB == nil {
+ return audit.Settings{}, audit.ErrInvalidRetention
+ }
+ if command.Days != nil {
+ if err := audit.ValidateRetentionDays(*command.Days); err != nil {
+ return audit.Settings{}, err
+ }
+ }
+ if command.MaxGB != nil {
+ if err := audit.ValidateRetentionMaxGB(*command.MaxGB); err != nil {
+ return audit.Settings{}, err
+ }
}
current, err := u.settings.Get()
if err != nil {
return audit.Settings{}, err
}
- if current.RetentionDays == days {
+ size, err := u.events.SizeBytes()
+ if err != nil {
+ return audit.Settings{}, err
+ }
+ current.LogSizeBytes = size
+
+ updated := current
+ if command.Days != nil {
+ updated.RetentionDays = *command.Days
+ }
+ if command.MaxGB != nil {
+ updated.RetentionMaxGB = *command.MaxGB
+ }
+ daysChanged := updated.RetentionDays != current.RetentionDays
+ sizeChanged := updated.RetentionMaxGB != current.RetentionMaxGB
+ if !daysChanged && !sizeChanged {
return current.WithNextCleanup(u.now()), nil
}
- if err := u.settings.SaveRetention(days); err != nil {
+ if err := u.settings.SaveRetention(updated.RetentionDays, updated.RetentionMaxGB); err != nil {
return audit.Settings{}, err
}
event := audit.NewEvent(audit.ActionRetentionChanged, audit.CategorySystem)
- event.Params = map[string]any{audit.ParamFrom: current.RetentionDays, audit.ParamTo: days}
+ event.Params = map[string]any{}
+ if daysChanged {
+ event.Params[audit.ParamFrom] = current.RetentionDays
+ event.Params[audit.ParamTo] = updated.RetentionDays
+ }
+ if sizeChanged {
+ event.Params[audit.ParamMaxGBFrom] = current.RetentionMaxGB
+ event.Params[audit.ParamMaxGBTo] = updated.RetentionMaxGB
+ }
u.recorder.Record(ctx, event)
- current.RetentionDays = days
- return current.WithNextCleanup(u.now()), nil
+ return updated.WithNextCleanup(u.now()), nil
}
diff --git a/internal/infrastructure/useCases/CleanupActivity.go b/internal/infrastructure/useCases/CleanupActivity.go
index 443f215..f672b21 100644
--- a/internal/infrastructure/useCases/CleanupActivity.go
+++ b/internal/infrastructure/useCases/CleanupActivity.go
@@ -6,14 +6,22 @@ import (
"time"
)
+const defaultCleanupBatchSize = 1000
+
type CleanupActivityUseCase struct {
- events audit.Repository
- settings audit.SettingsRepository
- recorder audit.Recorder
+ events audit.Repository
+ settings audit.SettingsRepository
+ recorder audit.Recorder
+ batchSize int
}
func NewCleanupActivityUseCase(events audit.Repository, settings audit.SettingsRepository, recorder audit.Recorder) *CleanupActivityUseCase {
- return &CleanupActivityUseCase{events, settings, recorder}
+ return &CleanupActivityUseCase{events, settings, recorder, defaultCleanupBatchSize}
+}
+
+func (c *CleanupActivityUseCase) WithBatchSize(batchSize int) *CleanupActivityUseCase {
+ c.batchSize = batchSize
+ return c
}
func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool, error) {
@@ -21,13 +29,23 @@ func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool,
if err != nil {
return audit.CleanupResult{}, false, err
}
- if settings.RetentionDays == audit.ForeverRetentionDays || !settings.CleanupDue(now) {
+ if !settings.HasRetentionLimit() || !settings.CleanupDue(now) {
return audit.CleanupResult{}, false, nil
}
- removed, err := c.events.DeleteOlderThan(audit.RetentionCutoff(now, settings.RetentionDays))
- if err != nil {
- return audit.CleanupResult{}, false, err
+ removed := 0
+ if settings.RetentionDays != audit.ForeverRetentionDays {
+ removed, err = c.events.DeleteOlderThan(audit.RetentionCutoff(now, settings.RetentionDays))
+ if err != nil {
+ return audit.CleanupResult{}, false, err
+ }
+ }
+ if settings.RetentionMaxGB != audit.NoSizeLimitGB {
+ trimmed, err := c.trimToSize(now, settings.MaxLogSizeBytes())
+ if err != nil {
+ return audit.CleanupResult{}, false, err
+ }
+ removed += trimmed
}
if err := c.settings.SaveLastCleanup(now, removed); err != nil {
return audit.CleanupResult{}, false, err
@@ -38,6 +56,28 @@ func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool,
return audit.CleanupResult{Removed: removed}, true, nil
}
+func (c *CleanupActivityUseCase) trimToSize(now time.Time, limitBytes int64) (int, error) {
+ guard := audit.GuardCutoff(now)
+ removed := 0
+ for {
+ size, err := c.events.SizeBytes()
+ if err != nil {
+ return removed, err
+ }
+ if size <= limitBytes {
+ return removed, nil
+ }
+ batch, err := c.events.DeleteOldest(c.batchSize, guard)
+ if err != nil {
+ return removed, err
+ }
+ if batch == 0 {
+ return removed, nil
+ }
+ removed += batch
+ }
+}
+
func (c *CleanupActivityUseCase) recordCleanup(removed int) {
event := audit.NewEvent(audit.ActionCleanup, audit.CategorySystem)
event.Actor = audit.SystemActor()
diff --git a/scripts/mutate.sh b/scripts/mutate.sh
index 26788af..954613e 100644
--- a/scripts/mutate.sh
+++ b/scripts/mutate.sh
@@ -4,6 +4,7 @@ set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
+trap 'echo "mutate: interrupted" >&2; exit 130' INT TERM
native() {
if command -v cygpath >/dev/null 2>&1; then
@@ -13,8 +14,14 @@ native() {
fi
}
+exclude_pattern() {
+ local sep='[/\\]'
+ local name="${1//./[.]}"
+ printf '%s' "(^|${sep})${name}\$"
+}
+
repo="$work/repo"
-mkdir -p "$repo" "$work/tmp" "$root/tmp"
+mkdir -p "$repo" "$repo/tmp" "$repo/.git" "$work/tmp" "$root/tmp"
tar -C "$root" -c \
--exclude=./bin --exclude=./web --exclude=./node_modules --exclude=./.git \
@@ -26,12 +33,62 @@ export GOCACHE="$(native "$work/gocache")"
export TEMP="$(native "$work/tmp")"
export TMP="$TEMP"
export TMPDIR="$TEMP"
+export GOFLAGS="-buildvcs=false${MUTATE_RUN:+ -run=$MUTATE_RUN}"
cd "$repo"
code=0
-gremlins unleash --integration --tags nofrontend --workers 4 \
- --coverpkg=./internal/infrastructure/useCases/...,./api/middleware/...,./api/pkg/claims/... \
- --timeout-coefficient 10 --output "$(native "$root/tmp/gremlins.json")" \
- ./internal/infrastructure/useCases ./api/middleware ./api/pkg/claims || code=$?
+scopes=(
+ ./internal/infrastructure/useCases
+ ./api/middleware
+ ./api/pkg/claims
+ ./internal/domain/audit
+ ./internal/infrastructure/persistence/repositories
+ ./api/handlers/settings
+)
+if [ "$#" -gt 0 ]; then
+ scopes=("$@")
+fi
+coverpkg=""
+for scope in "${scopes[@]}"; do
+ coverpkg="${coverpkg:+$coverpkg,}$scope/..."
+done
+parts=()
+i=0
+for scope in "${scopes[@]}"; do
+ i=$((i + 1))
+ part="$work/part$i.json"
+ rc=0
+ excludes=()
+ if [ -n "${MUTATE_FILES:-}" ]; then
+ keep=0
+ for f in "$scope"/*.go; do
+ base="$(basename "$f")"
+ case "$base" in *_test.go) continue ;; esac
+ case ",${MUTATE_FILES}," in
+ *",$base,"*) keep=$((keep + 1)) ;;
+ *) excludes+=(-E "$(exclude_pattern "$base")") ;;
+ esac
+ done
+ if [ "$keep" -eq 0 ]; then continue; fi
+ fi
+ gremlins unleash --integration --tags nofrontend --workers "${MUTATE_WORKERS:-4}" --coverpkg="$coverpkg" --timeout-coefficient 10 ${excludes[@]+"${excludes[@]}"} --output "$(native "$part")" "$scope" || rc=$?
+ if [ "$rc" -ge 128 ]; then exit "$rc"; fi
+ if [ "$rc" -ne 0 ]; then code=$rc; fi
+ if [ -f "$part" ]; then parts+=("$(native "$part")"); fi
+done
+python - "$(native "$root/tmp/gremlins.json")" "${parts[@]}" <<'PY'
+import json, sys
+out, parts = sys.argv[1], sys.argv[2:]
+merged = None
+for p in parts:
+ d = json.load(open(p))
+ if merged is None:
+ merged = d
+ else:
+ merged["files"] = merged.get("files", []) + d.get("files", [])
+if merged is None:
+ merged = {"files": []}
+json.dump(merged, open(out, "w"), indent=2)
+PY
go clean -cache >/dev/null 2>&1 || true
exit "$code"
diff --git a/test/activity_domain_test.go b/test/activity_domain_test.go
index 70b1758..3045cc6 100644
--- a/test/activity_domain_test.go
+++ b/test/activity_domain_test.go
@@ -76,7 +76,7 @@ func TestActivityDomain(t *testing.T) {
names = append(names, repoType.Method(i).Name)
}
sort.Strings(names)
- wanted := []string{"Append", "DeleteOlderThan", "List", "Summary"}
+ wanted := []string{"Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
if !reflect.DeepEqual(names, wanted) {
t.Errorf("expected methods %v, got %v", wanted, names)
}
diff --git a/test/activity_log_backend_http_test.go b/test/activity_log_backend_http_test.go
index 6b884a3..f6259f9 100644
--- a/test/activity_log_backend_http_test.go
+++ b/test/activity_log_backend_http_test.go
@@ -81,8 +81,8 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, e.users, h.clock, h.recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock),
GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"),
- GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.clock),
- UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock),
+ GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock),
+ UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock),
ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(h.probe, h.clock),
ExportActivityUseCase: useCases.NewExportActivityUseCase(h.probe, h.clock),
@@ -670,7 +670,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) {
rec := e.get("/v1/settings", asUser(e, activityEditorID))
- assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String())
+ assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_retention_max_gb":0,"activity_log_size_bytes":0,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String())
})
t.Run("The last cleanup is reported", func(t *testing.T) {
diff --git a/test/activity_next_cleanup_test.go b/test/activity_next_cleanup_test.go
index 1fa61d6..4400fd5 100644
--- a/test/activity_next_cleanup_test.go
+++ b/test/activity_next_cleanup_test.go
@@ -50,7 +50,7 @@ func TestSettingsNextCleanupDomain(t *testing.T) {
func TestSettingsNextCleanupUseCases(t *testing.T) {
t.Run("Get returns the next cleanup from the clock or nil for Forever", func(t *testing.T) {
fake := newActivitySettingsFake()
- get := useCases.NewGetActivityRetentionUseCase(fake, settingsTestClock)
+ get := useCases.NewGetActivityRetentionUseCase(fake, newSizeEvents(t), settingsTestClock)
current, err := get.GetActivityRetention()
if err != nil || current.NextCleanupAt == nil || current.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" {
@@ -66,17 +66,17 @@ func TestSettingsNextCleanupUseCases(t *testing.T) {
t.Run("Update returns the next cleanup of the new period", func(t *testing.T) {
fake := newActivitySettingsFake()
- update := useCases.NewUpdateActivityRetentionUseCase(fake, discardRecorder, settingsTestClock)
+ update := useCases.NewUpdateActivityRetentionUseCase(fake, newSizeEvents(t), discardRecorder, settingsTestClock)
- forever, err := update.UpdateActivityRetention(testCtx, 0)
+ forever, err := update.UpdateActivityRetention(testCtx, retentionDays(0))
if err != nil || forever.RetentionDays != 0 || forever.NextCleanupAt != nil {
t.Fatalf("unexpected %+v, %v", forever, err)
}
- back, err := update.UpdateActivityRetention(testCtx, 365)
+ back, err := update.UpdateActivityRetention(testCtx, retentionDays(365))
if err != nil || back.NextCleanupAt == nil || back.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" {
t.Fatalf("unexpected %+v, %v", back, err)
}
- same, err := update.UpdateActivityRetention(testCtx, 365)
+ same, err := update.UpdateActivityRetention(testCtx, retentionDays(365))
if err != nil || same.NextCleanupAt == nil {
t.Fatalf("unexpected %+v, %v", same, err)
}
@@ -84,7 +84,7 @@ func TestSettingsNextCleanupUseCases(t *testing.T) {
t.Run("Update refuses an invalid period and saves nothing", func(t *testing.T) {
fake := newActivitySettingsFake()
- _, err := useCases.NewUpdateActivityRetentionUseCase(fake, discardRecorder, settingsTestClock).UpdateActivityRetention(testCtx, 7)
+ _, err := useCases.NewUpdateActivityRetentionUseCase(fake, newSizeEvents(t), discardRecorder, settingsTestClock).UpdateActivityRetention(testCtx, retentionDays(7))
if !errors.Is(err, audit.ErrInvalidRetention) || fake.retentionOK != 0 {
t.Fatalf("expected a refusal, got %v", err)
}
@@ -101,7 +101,7 @@ func TestSettingsNextCleanupHttp(t *testing.T) {
return decoded
}
- t.Run("GET keeps the existing fields and adds exactly one", func(t *testing.T) {
+ t.Run("GET keeps the existing fields and adds exactly one next cleanup and the size fields", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC))
e.settings.settings.LastCleanupRemoved = 142
@@ -109,7 +109,7 @@ func TestSettingsNextCleanupHttp(t *testing.T) {
rec := e.get("/v1/settings", asUser(e, activityEditorID))
body := keys(t, rec.Body.String())
- if rec.Code != http.StatusOK || len(body) != 5 || body["activity_next_cleanup_at"] != "2026-10-07T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
+ if rec.Code != http.StatusOK || len(body) != 7 || body["activity_next_cleanup_at"] != "2026-10-07T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
t.Errorf("unexpected %d %v", rec.Code, body)
}
})
diff --git a/test/activity_persistence_test.go b/test/activity_persistence_test.go
index d08bf00..d6f185a 100644
--- a/test/activity_persistence_test.go
+++ b/test/activity_persistence_test.go
@@ -108,7 +108,7 @@ func TestActivityPersistence(t *testing.T) {
names = append(names, repoType.Method(i).Name)
}
sort.Strings(names)
- wanted := []string{"Append", "DeleteOlderThan", "List", "Summary"}
+ wanted := []string{"Append", "DeleteOlderThan", "DeleteOldest", "List", "SizeBytes", "Summary"}
if !reflect.DeepEqual(names, wanted) {
t.Errorf("expected %v, got %v", wanted, names)
}
@@ -375,7 +375,7 @@ func TestActivitySettingsPersistence(t *testing.T) {
if settings.RetentionDays != 90 || settings.LastCleanupAt == nil || !settings.LastCleanupAt.Equal(at) || settings.LastCleanupRemoved != 142 {
t.Fatalf("unexpected settings %+v", settings)
}
- if err := repo.SaveRetention(0); err != nil {
+ if err := repo.SaveRetention(0, 0); err != nil {
t.Fatal(err)
}
settings, _ = repo.Get()
diff --git a/test/activity_retention_size_http_test.go b/test/activity_retention_size_http_test.go
new file mode 100644
index 0000000..6a3b166
--- /dev/null
+++ b/test/activity_retention_size_http_test.go
@@ -0,0 +1,190 @@
+package test
+
+import (
+ "RenewCMS/api/pkg/apperr"
+ "encoding/json"
+ "net/http"
+ "reflect"
+ "sort"
+ "testing"
+)
+
+func sizeSettingsBody(t *testing.T, body []byte) map[string]any {
+ t.Helper()
+ var decoded map[string]any
+ if err := json.Unmarshal(body, &decoded); err != nil {
+ t.Fatalf("invalid json %q: %v", body, err)
+ }
+ return decoded
+}
+
+func TestSettingsRetentionSizeHttp(t *testing.T) {
+ owner := func(e *activityHTTPEnv) activityRequest { return asUser(e, activityOwnerID) }
+
+ t.Run("Settings API, GET reports the size limit and the measured size", func(t *testing.T) {
+ cases := []struct {
+ days, gb int
+ bytes int64
+ next any
+ }{
+ {90, 0, 0, "2026-10-07T03:00:00+02:00"},
+ {90, 5, 3435973837, "2026-10-07T03:00:00+02:00"},
+ {0, 5, 3435973837, "2026-10-07T03:00:00+02:00"},
+ {0, 0, 1024, nil},
+ }
+ keys := []string{"version", "activity_retention_days", "activity_retention_max_gb", "activity_log_size_bytes", "activity_last_cleanup_at", "activity_last_cleanup_removed", "activity_next_cleanup_at"}
+ sort.Strings(keys)
+ for _, c := range cases {
+ e := newActivityHTTPEnv(t, 1000)
+ e.now = settingsTestClock()
+ e.settings.settings.RetentionDays = c.days
+ e.settings.settings.RetentionMaxGB = c.gb
+ e.probe.sizeBytes = &c.bytes
+
+ rec := e.get("/v1/settings", asUser(e, activityEditorID))
+
+ body := sizeSettingsBody(t, rec.Body.Bytes())
+ var got []string
+ for key := range body {
+ got = append(got, key)
+ }
+ sort.Strings(got)
+ if rec.Code != http.StatusOK || !reflect.DeepEqual(got, keys) {
+ t.Fatalf("unexpected %d %v", rec.Code, got)
+ }
+ if body["activity_retention_max_gb"] != float64(c.gb) || body["activity_log_size_bytes"] != float64(c.bytes) || body["activity_next_cleanup_at"] != c.next {
+ t.Errorf("%+v: unexpected body %v", c, body)
+ }
+ }
+ })
+
+ t.Run("Settings API, a fresh install defaults to no size limit", func(t *testing.T) {
+ e := newActivityHTTPEnv(t, 1000)
+
+ body := sizeSettingsBody(t, e.get("/v1/settings", asUser(e, activityEditorID)).Body.Bytes())
+
+ if body["activity_retention_days"] != float64(90) || body["activity_retention_max_gb"] != float64(0) || body["activity_log_size_bytes"] != float64(0) {
+ t.Errorf("unexpected body %v", body)
+ }
+ })
+
+ t.Run("Settings API, PATCH saves a size limit, an age, or both", func(t *testing.T) {
+ cases := []struct {
+ body string
+ days, gb int
+ null bool
+ }{
+ {`{"activity_retention_max_gb": 5}`, 90, 5, false},
+ {`{"activity_retention_days": 0, "activity_retention_max_gb": 5}`, 0, 5, false},
+ {`{"activity_retention_days": 30}`, 30, 0, false},
+ {`{"activity_retention_days": 0}`, 0, 0, true},
+ }
+ for _, c := range cases {
+ e := newActivityHTTPEnv(t, 1000)
+
+ rec := e.send(http.MethodPatch, "/v1/settings", c.body, owner(e))
+
+ body := sizeSettingsBody(t, rec.Body.Bytes())
+ next, present := body["activity_next_cleanup_at"]
+ if rec.Code != http.StatusOK || body["activity_retention_days"] != float64(c.days) || body["activity_retention_max_gb"] != float64(c.gb) || !present || (next == nil) != c.null {
+ t.Errorf("%s: unexpected %d %v", c.body, rec.Code, body)
+ }
+ if e.settings.settings.RetentionDays != c.days || e.settings.settings.RetentionMaxGB != c.gb {
+ t.Errorf("%s: the repository holds %+v", c.body, e.settings.settings)
+ }
+ }
+ })
+
+ t.Run("Settings API, leaving the size limit while Forever clears the schedule", func(t *testing.T) {
+ e := newActivityHTTPEnv(t, 1000)
+ e.settings.settings.RetentionDays = 0
+ e.settings.settings.RetentionMaxGB = 5
+
+ rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 0}`, owner(e))
+
+ body := sizeSettingsBody(t, rec.Body.Bytes())
+ next, present := body["activity_next_cleanup_at"]
+ if rec.Code != http.StatusOK || !present || next != nil {
+ t.Errorf("unexpected %d %v", rec.Code, body)
+ }
+ })
+
+ t.Run("Settings API, PATCH rejects invalid size input", func(t *testing.T) {
+ bodies := []string{
+ `{"activity_retention_max_gb": 2}`,
+ `{"activity_retention_max_gb": -1}`,
+ `{"activity_retention_max_gb": 100}`,
+ `{"activity_retention_max_gb": 5.5}`,
+ `{"activity_retention_max_gb": "5"}`,
+ `{"activity_retention_max_gb": null}`,
+ `{"activity_retention_days": 30, "activity_retention_max_gb": 2}`,
+ `{"activity_retention_days": null}`,
+ `{"activity_retention_days": 30, "activity_retention_max_gb": null}`,
+ `{"activity_retention_days": null, "activity_retention_max_gb": 5}`,
+ `[1]`,
+ `{}`,
+ `not json`,
+ }
+ for _, body := range bodies {
+ e := newActivityHTTPEnv(t, 1000)
+ e.settings.settings.RetentionMaxGB = 5
+
+ assertPlain(t, e.send(http.MethodPatch, "/v1/settings", body, owner(e)), http.StatusBadRequest, activityMismatch)
+
+ if e.settings.retentionOK != 0 || e.settings.settings.RetentionDays != 90 || e.settings.settings.RetentionMaxGB != 5 || len(e.all(t)) != 0 {
+ t.Errorf("%s: something was saved", body)
+ }
+ }
+ })
+
+ t.Run("Settings API, a PATCH changing nothing is a no-op", func(t *testing.T) {
+ e := newActivityHTTPEnv(t, 1000)
+ e.settings.settings.RetentionMaxGB = 5
+
+ rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 90, "activity_retention_max_gb": 5}`, owner(e))
+
+ if rec.Code != http.StatusOK || e.settings.retentionOK != 0 || len(e.all(t)) != 0 {
+ t.Errorf("unexpected %d, saves %d, events %d", rec.Code, e.settings.retentionOK, len(e.all(t)))
+ }
+ })
+
+ t.Run("Settings API, a non-owner cannot set the size limit", func(t *testing.T) {
+ e := newActivityHTTPEnv(t, 1000)
+
+ rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 5}`, asUser(e, activityAdminID))
+
+ assertPlain(t, rec, http.StatusForbidden, "Only the owner can do this.")
+ if e.settings.retentionOK != 0 {
+ t.Error("the admin changed something")
+ }
+ assertPlain(t, e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_max_gb": 5}`), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
+ })
+
+ t.Run("Settings API, a failing size measurement answers 500", func(t *testing.T) {
+ e := newActivityHTTPEnv(t, 1000)
+ e.probe.sizeErr = errRepositoryFailure
+
+ assertPlain(t, e.get("/v1/settings", asUser(e, activityEditorID)), http.StatusInternalServerError, "Internal server error")
+ })
+
+ t.Run("Settings API, the change is audited", func(t *testing.T) {
+ cases := []struct {
+ body string
+ params map[string]any
+ }{
+ {`{"activity_retention_days": 30}`, map[string]any{"from": float64(90), "to": float64(30)}},
+ {`{"activity_retention_max_gb": 5}`, map[string]any{"max_gb_from": float64(0), "max_gb_to": float64(5)}},
+ {`{"activity_retention_days": 30, "activity_retention_max_gb": 5}`, map[string]any{"from": float64(90), "to": float64(30), "max_gb_from": float64(0), "max_gb_to": float64(5)}},
+ }
+ for _, c := range cases {
+ e := newActivityHTTPEnv(t, 1000)
+
+ e.send(http.MethodPatch, "/v1/settings", c.body, owner(e))
+
+ events := e.all(t)
+ if len(events) != 1 || events[0].Action != "system.activity_retention_changed" || !reflect.DeepEqual(events[0].Params, c.params) {
+ t.Errorf("%s: unexpected events %+v", c.body, events)
+ }
+ }
+ })
+}
diff --git a/test/activity_retention_size_test.go b/test/activity_retention_size_test.go
new file mode 100644
index 0000000..92589ae
--- /dev/null
+++ b/test/activity_retention_size_test.go
@@ -0,0 +1,623 @@
+package test
+
+import (
+ "RenewCMS/internal/domain/audit"
+ persistence "RenewCMS/internal/infrastructure/persistence/repositories"
+ "RenewCMS/internal/infrastructure/useCases"
+ "context"
+ "encoding/json"
+ "errors"
+ "reflect"
+ "sort"
+ "testing"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+const sizeTestGB = audit.BytesPerGB
+
+type sizedEvent struct {
+ id uint64
+ at time.Time
+ bytes int64
+}
+
+type sizedEventsFake struct {
+ events []sizedEvent
+ ageCalls int
+ oldestCalls int
+ sizeCalls int
+ sizeErr error
+ oldestErr error
+}
+
+var _ audit.Repository = &sizedEventsFake{}
+
+func (f *sizedEventsFake) add(at time.Time, bytes int64) {
+ f.events = append(f.events, sizedEvent{id: uint64(len(f.events) + 1), at: at, bytes: bytes})
+}
+
+func (f *sizedEventsFake) Append(event audit.Event) (audit.Event, error) { return event, nil }
+
+func (f *sizedEventsFake) List(audit.Filter) (audit.Page, error) { return audit.Page{}, nil }
+
+func (f *sizedEventsFake) Summary(audit.SummaryQuery) (audit.Summary, error) {
+ return audit.Summary{}, nil
+}
+
+func (f *sizedEventsFake) DeleteOlderThan(cutoff time.Time) (int, error) {
+ f.ageCalls++
+ kept := f.events[:0:0]
+ removed := 0
+ for _, event := range f.events {
+ if event.at.Before(cutoff) {
+ removed++
+ continue
+ }
+ kept = append(kept, event)
+ }
+ f.events = kept
+ return removed, nil
+}
+
+func (f *sizedEventsFake) DeleteOldest(limit int, before time.Time) (int, error) {
+ f.oldestCalls++
+ if f.oldestErr != nil {
+ return 0, f.oldestErr
+ }
+ sort.SliceStable(f.events, func(i, j int) bool {
+ if f.events[i].at.Equal(f.events[j].at) {
+ return f.events[i].id < f.events[j].id
+ }
+ return f.events[i].at.Before(f.events[j].at)
+ })
+ removed := 0
+ for removed < limit && removed < len(f.events) && f.events[removed].at.Before(before) {
+ removed++
+ }
+ f.events = f.events[removed:]
+ return removed, nil
+}
+
+func (f *sizedEventsFake) SizeBytes() (int64, error) {
+ f.sizeCalls++
+ if f.sizeErr != nil {
+ return 0, f.sizeErr
+ }
+ var total int64
+ for _, event := range f.events {
+ total += event.bytes
+ }
+ return total, nil
+}
+
+type recordingRecorder struct {
+ events []audit.Event
+}
+
+func (r *recordingRecorder) Record(_ context.Context, event audit.Event) {
+ r.events = append(r.events, event)
+}
+
+type sizeCleanupEnv struct {
+ events *sizedEventsFake
+ settings *activitySettingsFake
+ recorder *recordingRecorder
+ cleanup *useCases.CleanupActivityUseCase
+ now time.Time
+}
+
+func newSizeCleanupEnv(days int, maxGB int) *sizeCleanupEnv {
+ e := &sizeCleanupEnv{
+ events: &sizedEventsFake{},
+ settings: newActivitySettingsFake(),
+ recorder: &recordingRecorder{},
+ now: cleanupAt(6, 3, 0),
+ }
+ e.settings.settings.RetentionDays = days
+ e.settings.settings.RetentionMaxGB = maxGB
+ e.cleanup = useCases.NewCleanupActivityUseCase(e.events, e.settings, e.recorder).WithBatchSize(1)
+ return e
+}
+
+func (e *sizeCleanupEnv) daysAgo(days int) time.Time {
+ return e.now.Add(-time.Duration(days) * 24 * time.Hour)
+}
+
+func TestActivityRetentionSizeDomain(t *testing.T) {
+ t.Run("The size options are 0, 1, 5, 10, 25 and 50", func(t *testing.T) {
+ if !reflect.DeepEqual(audit.SizeOptions(), []int{0, 1, 5, 10, 25, 50}) {
+ t.Errorf("unexpected options %v", audit.SizeOptions())
+ }
+ for _, gb := range []int{0, 1, 5, 10, 25, 50} {
+ if err := audit.ValidateRetentionMaxGB(gb); err != nil {
+ t.Errorf("%d should be valid: %v", gb, err)
+ }
+ }
+ for _, gb := range []int{-1, 2, 3, 100, 51} {
+ if err := audit.ValidateRetentionMaxGB(gb); !errors.Is(err, audit.ErrInvalidRetention) {
+ t.Errorf("%d should be invalid, got %v", gb, err)
+ }
+ }
+ })
+
+ t.Run("A fresh install has no size limit", func(t *testing.T) {
+ if audit.DefaultSettings().RetentionMaxGB != 0 || audit.BytesPerGB != 1024*1024*1024 {
+ t.Errorf("unexpected defaults %+v", audit.DefaultSettings())
+ }
+ })
+
+ t.Run("The next cleanup is empty only when both limits are 0", func(t *testing.T) {
+ now := time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone)
+ cases := []struct {
+ days, gb int
+ scheduled bool
+ }{{90, 0, true}, {90, 5, true}, {0, 5, true}, {0, 0, false}}
+ for _, c := range cases {
+ next := audit.Settings{RetentionDays: c.days, RetentionMaxGB: c.gb}.WithNextCleanup(now).NextCleanupAt
+ if (next != nil) != c.scheduled {
+ t.Errorf("days %d gb %d: unexpected %v", c.days, c.gb, next)
+ }
+ }
+ })
+
+ t.Run("The limit in bytes is gigabytes times 1024 cubed", func(t *testing.T) {
+ if got := (audit.Settings{RetentionMaxGB: 5}).MaxLogSizeBytes(); got != 5368709120 {
+ t.Errorf("unexpected %d", got)
+ }
+ })
+}
+
+func TestActivityRetentionSizeCleanup(t *testing.T) {
+ t.Run("The size limit removes the oldest events", func(t *testing.T) {
+ for _, days := range []int{90, 0} {
+ e := newSizeCleanupEnv(days, 5)
+ for age := 20; age > 13; age-- {
+ e.events.add(e.daysAgo(age), sizeTestGB)
+ }
+
+ result, ran, err := e.cleanup.Tick(e.now)
+
+ size, _ := e.events.SizeBytes()
+ if err != nil || !ran || result.Removed != 2 || size != 5*sizeTestGB {
+ t.Fatalf("days %d: unexpected %+v ran=%v size=%d err=%v", days, result, ran, size, err)
+ }
+ if e.events.events[0].id != 3 {
+ t.Errorf("days %d: the oldest events must go first, first left is %d", days, e.events.events[0].id)
+ }
+ last := e.settings.settings
+ if last.LastCleanupAt == nil || !last.LastCleanupAt.Equal(e.now) || last.LastCleanupRemoved != 2 {
+ t.Errorf("days %d: unexpected last cleanup %+v", days, last)
+ }
+ }
+ })
+
+ t.Run("Age first then size, and one event with the total", func(t *testing.T) {
+ e := newSizeCleanupEnv(30, 5)
+ for i := 0; i < 10; i++ {
+ e.events.add(e.daysAgo(40), 10)
+ }
+ for age := 20; age > 13; age-- {
+ e.events.add(e.daysAgo(age), sizeTestGB)
+ }
+
+ result, _, err := e.cleanup.Tick(e.now)
+
+ if err != nil || result.Removed != 12 || e.settings.settings.LastCleanupRemoved != 12 {
+ t.Fatalf("expected 12 removed, got %+v, %v", result, err)
+ }
+ if len(e.recorder.events) != 1 || e.recorder.events[0].Action != "system.activity_cleanup" || e.recorder.events[0].Params[audit.ParamRemoved] != 12 {
+ t.Errorf("unexpected events %+v", e.recorder.events)
+ }
+ })
+
+ t.Run("The age limit alone is enough", func(t *testing.T) {
+ e := newSizeCleanupEnv(30, 5)
+ e.events.add(e.daysAgo(40), 10*sizeTestGB)
+ e.events.add(e.daysAgo(2), sizeTestGB)
+
+ result, _, err := e.cleanup.Tick(e.now)
+
+ if err != nil || result.Removed != 1 || e.events.oldestCalls != 0 {
+ t.Errorf("unexpected %+v, %d size removals, %v", result, e.events.oldestCalls, err)
+ }
+ })
+
+ t.Run("The size is already met", func(t *testing.T) {
+ e := newSizeCleanupEnv(0, 5)
+ e.events.add(e.daysAgo(10), sizeTestGB)
+
+ result, ran, err := e.cleanup.Tick(e.now)
+
+ if err != nil || !ran || result.Removed != 0 || e.events.oldestCalls != 0 || len(e.recorder.events) != 0 {
+ t.Errorf("unexpected %+v ran=%v %v", result, ran, err)
+ }
+ })
+
+ t.Run("Forever with no size limit never runs", func(t *testing.T) {
+ e := newSizeCleanupEnv(0, 0)
+ e.events.add(e.daysAgo(10), 100*sizeTestGB)
+
+ _, ran, err := e.cleanup.Tick(e.now)
+
+ if err != nil || ran || e.events.ageCalls != 0 || e.events.oldestCalls != 0 || e.events.sizeCalls != 0 {
+ t.Errorf("expected nothing, got ran=%v %v", ran, err)
+ }
+ })
+
+ t.Run("Forever with a size limit runs and does no age removal", func(t *testing.T) {
+ e := newSizeCleanupEnv(0, 1)
+ e.events.add(e.daysAgo(400), 2*sizeTestGB)
+
+ _, ran, err := e.cleanup.Tick(e.now)
+
+ if err != nil || !ran || e.events.ageCalls != 0 || len(e.events.events) != 0 {
+ t.Errorf("unexpected ran=%v %v", ran, err)
+ }
+ })
+
+ t.Run("The last 24 hours are protected even when the size cannot be met", func(t *testing.T) {
+ e := newSizeCleanupEnv(0, 1)
+ for i := 0; i < 4; i++ {
+ e.events.add(e.now.Add(-time.Duration(i+1)*time.Hour), sizeTestGB)
+ }
+
+ result, ran, err := e.cleanup.Tick(e.now)
+
+ if err != nil || !ran || result.Removed != 0 || len(e.events.events) != 4 {
+ t.Fatalf("unexpected %+v ran=%v %v", result, ran, err)
+ }
+ if e.settings.settings.LastCleanupAt == nil || e.settings.settings.LastCleanupRemoved != 0 || len(e.recorder.events) != 0 {
+ t.Errorf("the run must be recorded with 0 removed, got %+v", e.settings.settings)
+ }
+ })
+
+ t.Run("A failing size removal is retried", func(t *testing.T) {
+ e := newSizeCleanupEnv(0, 1)
+ e.events.add(e.daysAgo(10), 4*sizeTestGB)
+ e.events.oldestErr = errRepositoryFailure
+
+ _, ran, err := e.cleanup.Tick(e.now)
+
+ if !errors.Is(err, errRepositoryFailure) || ran || e.settings.settings.LastCleanupAt != nil {
+ t.Fatalf("unexpected ran=%v %v", ran, err)
+ }
+ e.events.oldestErr = nil
+ if _, ran, err = e.cleanup.Tick(e.now.Add(time.Hour)); err != nil || !ran {
+ t.Errorf("expected the next tick to run, got ran=%v %v", ran, err)
+ }
+ })
+
+ t.Run("A failing size measurement is returned", func(t *testing.T) {
+ e := newSizeCleanupEnv(0, 1)
+ e.events.sizeErr = errRepositoryFailure
+
+ _, ran, err := e.cleanup.Tick(e.now)
+
+ if !errors.Is(err, errRepositoryFailure) || ran {
+ t.Errorf("unexpected ran=%v %v", ran, err)
+ }
+ })
+
+ t.Run("The batch loop ends when a batch removes nothing", func(t *testing.T) {
+ e := newSizeCleanupEnv(0, 1)
+ e.events.add(e.daysAgo(10), 3*sizeTestGB)
+ e.events.add(e.now.Add(-time.Hour), 3*sizeTestGB)
+
+ _, _, err := e.cleanup.Tick(e.now)
+
+ if err != nil || e.events.oldestCalls != 2 || len(e.events.events) != 1 {
+ t.Errorf("unexpected %d calls, %d left, %v", e.events.oldestCalls, len(e.events.events), err)
+ }
+ })
+}
+
+func TestActivityRetentionSizeUseCases(t *testing.T) {
+ newEnv := func() (*activitySettingsFake, *sizedEventsFake, *recordingRecorder, *useCases.UpdateActivityRetentionUseCase) {
+ settings := newActivitySettingsFake()
+ events := &sizedEventsFake{}
+ recorder := &recordingRecorder{}
+ return settings, events, recorder, useCases.NewUpdateActivityRetentionUseCase(settings, events, recorder, settingsTestClock)
+ }
+
+ t.Run("Get carries the measured size", func(t *testing.T) {
+ settings := newActivitySettingsFake()
+ settings.settings.RetentionMaxGB = 5
+ events := &sizedEventsFake{}
+ events.add(settingsTestClock(), 3435973837)
+
+ current, err := useCases.NewGetActivityRetentionUseCase(settings, events, settingsTestClock).GetActivityRetention()
+
+ if err != nil || current.LogSizeBytes != 3435973837 || current.RetentionMaxGB != 5 {
+ t.Errorf("unexpected %+v, %v", current, err)
+ }
+ })
+
+ t.Run("A size failure propagates from Get and Update", func(t *testing.T) {
+ settings, events, _, update := newEnv()
+ events.sizeErr = errRepositoryFailure
+
+ _, getErr := useCases.NewGetActivityRetentionUseCase(settings, events, settingsTestClock).GetActivityRetention()
+ _, updateErr := update.UpdateActivityRetention(testCtx, retentionMaxGB(5))
+
+ if !errors.Is(getErr, errRepositoryFailure) || !errors.Is(updateErr, errRepositoryFailure) || settings.retentionOK != 0 {
+ t.Errorf("unexpected %v, %v", getErr, updateErr)
+ }
+ })
+
+ t.Run("An empty command or an invalid size is refused and saves nothing", func(t *testing.T) {
+ settings, _, recorder, update := newEnv()
+ two := 2
+ for _, command := range []useCases.UpdateActivityRetentionCommand{{}, {MaxGB: &two}, {Days: activityPtr(30), MaxGB: &two}} {
+ if _, err := update.UpdateActivityRetention(testCtx, command); !errors.Is(err, audit.ErrInvalidRetention) {
+ t.Errorf("expected a refusal, got %v", err)
+ }
+ }
+ if settings.retentionOK != 0 || len(recorder.events) != 0 {
+ t.Error("something was saved")
+ }
+ })
+
+ t.Run("Each key leaves the other unchanged", func(t *testing.T) {
+ settings, _, _, update := newEnv()
+
+ sized, err := update.UpdateActivityRetention(testCtx, retentionMaxGB(5))
+ if err != nil || sized.RetentionDays != 90 || sized.RetentionMaxGB != 5 {
+ t.Fatalf("unexpected %+v, %v", sized, err)
+ }
+ aged, err := update.UpdateActivityRetention(testCtx, retentionDays(30))
+ if err != nil || aged.RetentionDays != 30 || aged.RetentionMaxGB != 5 || settings.settings.RetentionMaxGB != 5 {
+ t.Fatalf("unexpected %+v, %v", aged, err)
+ }
+ })
+
+ t.Run("The audit params follow what changed", func(t *testing.T) {
+ cases := []struct {
+ name string
+ command useCases.UpdateActivityRetentionCommand
+ params map[string]any
+ }{
+ {"age", retentionDays(30), map[string]any{"from": 90, "to": 30}},
+ {"size", retentionMaxGB(5), map[string]any{"max_gb_from": 0, "max_gb_to": 5}},
+ {"both", useCases.UpdateActivityRetentionCommand{Days: activityPtr(30), MaxGB: activityPtr(5)}, map[string]any{"from": 90, "to": 30, "max_gb_from": 0, "max_gb_to": 5}},
+ }
+ for _, c := range cases {
+ _, _, recorder, update := newEnv()
+ if _, err := update.UpdateActivityRetention(testCtx, c.command); err != nil {
+ t.Fatal(err)
+ }
+ if len(recorder.events) != 1 || recorder.events[0].Action != "system.activity_retention_changed" || !reflect.DeepEqual(recorder.events[0].Params, c.params) {
+ t.Errorf("%s: unexpected events %+v", c.name, recorder.events)
+ }
+ }
+ })
+
+ t.Run("Saving the same pair is a no-op", func(t *testing.T) {
+ settings, _, recorder, update := newEnv()
+ settings.settings.RetentionMaxGB = 5
+
+ current, err := update.UpdateActivityRetention(testCtx, useCases.UpdateActivityRetentionCommand{Days: activityPtr(90), MaxGB: activityPtr(5)})
+
+ if err != nil || current.RetentionMaxGB != 5 || settings.retentionOK != 0 || len(recorder.events) != 0 {
+ t.Errorf("unexpected %+v, %v", current, err)
+ }
+ })
+}
+
+func TestActivityRetentionSizePersistence(t *testing.T) {
+ text := func(event audit.Event) int64 {
+ var total int64
+ for _, field := range []string{event.Action, event.Actor.Name, event.Target.Type, event.Target.ID, event.Target.Name} {
+ total += int64(len([]byte(field)))
+ }
+ if event.FailureReason != nil {
+ total += int64(len(*event.FailureReason))
+ }
+ if event.IP != nil {
+ total += int64(len(*event.IP))
+ }
+ if event.UserAgent != nil {
+ total += int64(len(*event.UserAgent))
+ }
+ return total
+ }
+
+ t.Run("SizeBytes is 0 for an empty table", func(t *testing.T) {
+ repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
+
+ size, err := repo.SizeBytes()
+
+ if err != nil || size != 0 {
+ t.Errorf("unexpected %d, %v", size, err)
+ }
+ })
+
+ t.Run("SizeBytes sums the byte lengths of the text columns plus the row overhead", func(t *testing.T) {
+ repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
+ plain := activityUserEvent(1, "marta", activityDay(1, 10))
+ plain.Params = map[string]any{"a": 1}
+ accented := activityUserEvent(2, "Zoë Çelik 日本", activityDay(2, 10))
+ accented.IP = activityPtr("203.0.113.12")
+ accented.UserAgent = activityPtr("Mozilla/5.0 é")
+ accented.FailureReason = activityPtr("Wrong username or password.")
+ accented.Changes = []audit.Change{{Field: "title", Kind: audit.ChangeText, Before: activityPtr("é"), After: activityPtr("e")}}
+ var wanted int64
+ for _, event := range []audit.Event{plain, accented} {
+ stored := activityMustAppend(t, repo, event)
+ wanted += text(stored) + int64(len(mustMarshalParams(t, stored))) + int64(len(mustMarshalChanges(t, stored))) + audit.RowOverheadBytes
+ }
+
+ size, err := repo.SizeBytes()
+
+ if err != nil || size != wanted {
+ t.Errorf("expected %d, got %d, %v", wanted, size, err)
+ }
+ })
+
+ t.Run("Deleting events lowers the size", func(t *testing.T) {
+ repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
+ activityMustAppend(t, repo, activityUserEvent(1, "a", activityDay(1, 10)))
+ activityMustAppend(t, repo, activityUserEvent(1, "a", activityDay(20, 10)))
+ before, _ := repo.SizeBytes()
+
+ if _, err := repo.DeleteOlderThan(activityDay(10, 10)); err != nil {
+ t.Fatal(err)
+ }
+
+ after, err := repo.SizeBytes()
+ if err != nil || after >= before || after <= 0 {
+ t.Errorf("expected a lower size than %d, got %d, %v", before, after, err)
+ }
+ })
+
+ t.Run("DeleteOldest removes the oldest events up to a limit and a guard", func(t *testing.T) {
+ repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
+ now := activityDay(20, 12)
+ var ids []uint64
+ for i := 0; i < 7; i++ {
+ ids = append(ids, activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(10-i)*24*time.Hour))).ID)
+ }
+ for i := 0; i < 3; i++ {
+ ids = append(ids, activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(i+1)*time.Hour))).ID)
+ }
+ guard := audit.GuardCutoff(now)
+
+ removed, err := repo.DeleteOldest(5, guard)
+ if err != nil || removed != 5 {
+ t.Fatalf("expected 5 removed, got %d, %v", removed, err)
+ }
+ page, _ := repo.List(audit.Filter{Page: 1, Size: 100})
+ left := activityIDs(page.Items)
+ sort.Slice(left, func(i, j int) bool { return left[i] < left[j] })
+ if !reflect.DeepEqual(left, ids[5:]) {
+ t.Errorf("expected %v left, got %v", ids[5:], left)
+ }
+
+ removed, err = repo.DeleteOldest(20, guard)
+ page, _ = repo.List(audit.Filter{Page: 1, Size: 100})
+ if err != nil || removed != 2 || page.Total != 3 {
+ t.Errorf("expected only the 2 older events removed, got %d with %d left, %v", removed, page.Total, err)
+ }
+ if removed, err = repo.DeleteOldest(0, guard); err != nil || removed != 0 {
+ t.Errorf("a zero limit removes nothing, got %d, %v", removed, err)
+ }
+ if removed, err = repo.DeleteOldest(-1, guard); err != nil || removed != 0 {
+ t.Errorf("a negative limit removes nothing, got %d, %v", removed, err)
+ }
+ })
+
+ t.Run("DeleteOldest honours a limit of 1", func(t *testing.T) {
+ repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
+ now := activityDay(20, 12)
+ for i := 0; i < 3; i++ {
+ activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(10-i)*24*time.Hour)))
+ }
+
+ removed, err := repo.DeleteOldest(1, audit.GuardCutoff(now))
+
+ page, _ := repo.List(audit.Filter{Page: 1, Size: 100})
+ if err != nil || removed != 1 || page.Total != 2 {
+ t.Errorf("expected 1 removed and 2 left, got %d and %d, %v", removed, page.Total, err)
+ }
+ })
+
+ t.Run("DeleteOldest removes more events than one delete chunk", func(t *testing.T) {
+ repo := persistence.NewAuditEventRepository(openActivityDatabase(t))
+ now := activityDay(20, 12)
+ for i := 0; i < 1001; i++ {
+ activityMustAppend(t, repo, activityUserEvent(1, "a", now.Add(-time.Duration(2000-i)*time.Hour)))
+ }
+
+ removed, err := repo.DeleteOldest(1001, audit.GuardCutoff(now))
+
+ page, _ := repo.List(audit.Filter{Page: 1, Size: 10})
+ if err != nil || removed != 1001 || page.Total != 0 {
+ t.Errorf("expected 1001 removed, got %d with %d left, %v", removed, page.Total, err)
+ }
+ })
+
+ t.Run("The size limit column defaults to 0 and is stored", func(t *testing.T) {
+ repo := persistence.NewAuditSettingsRepository(openActivityDatabase(t))
+ settings, err := repo.Get()
+ if err != nil || settings.RetentionMaxGB != 0 {
+ t.Fatalf("unexpected %+v, %v", settings, err)
+ }
+
+ if err := repo.SaveRetention(0, 5); err != nil {
+ t.Fatal(err)
+ }
+ settings, _ = repo.Get()
+ if settings.RetentionDays != 0 || settings.RetentionMaxGB != 5 {
+ t.Errorf("unexpected %+v", settings)
+ }
+ if err := repo.SaveLastCleanup(activityDay(6, 3), 1); err != nil {
+ t.Fatal(err)
+ }
+ settings, _ = repo.Get()
+ if settings.RetentionMaxGB != 5 {
+ t.Errorf("the size limit must survive a cleanup record, got %+v", settings)
+ }
+ })
+}
+
+func TestActivityRetentionSizePersistenceErrors(t *testing.T) {
+ closedDatabase := func(t *testing.T) *gorm.DB {
+ t.Helper()
+ db := openActivityDatabase(t)
+ sqlDb, err := db.DB()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sqlDb.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return db
+ }
+
+ t.Run("SizeBytes returns the database error", func(t *testing.T) {
+ size, err := persistence.NewAuditEventRepository(closedDatabase(t)).SizeBytes()
+
+ if err == nil || size != 0 {
+ t.Errorf("expected an error and 0, got %d, %v", size, err)
+ }
+ })
+
+ t.Run("DeleteOldest returns the database error", func(t *testing.T) {
+ removed, err := persistence.NewAuditEventRepository(closedDatabase(t)).DeleteOldest(5, activityDay(20, 12))
+
+ if err == nil || removed != 0 {
+ t.Errorf("expected an error and 0, got %d, %v", removed, err)
+ }
+ })
+
+ t.Run("SaveRetention returns the database error", func(t *testing.T) {
+ if err := persistence.NewAuditSettingsRepository(closedDatabase(t)).SaveRetention(30, 5); err == nil {
+ t.Error("expected an error")
+ }
+ })
+
+ t.Run("Get returns the database error", func(t *testing.T) {
+ if _, err := persistence.NewAuditSettingsRepository(closedDatabase(t)).Get(); err == nil {
+ t.Error("expected an error")
+ }
+ })
+}
+
+func mustMarshalParams(t *testing.T, event audit.Event) []byte {
+ t.Helper()
+ encoded, err := json.Marshal(event.Params)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return encoded
+}
+
+func mustMarshalChanges(t *testing.T, event audit.Event) []byte {
+ t.Helper()
+ encoded, err := json.Marshal(event.Changes)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return encoded
+}
diff --git a/test/activity_retention_test.go b/test/activity_retention_test.go
index c622669..25304c1 100644
--- a/test/activity_retention_test.go
+++ b/test/activity_retention_test.go
@@ -18,7 +18,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
t.Run("The default is 90 days with no cleanup yet", func(t *testing.T) {
h := newActivityHarness(t)
- settings, err := useCases.NewGetActivityRetentionUseCase(h.settings, h.clock).GetActivityRetention()
+ settings, err := useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock).GetActivityRetention()
if err != nil || settings.RetentionDays != 90 || settings.LastCleanupAt != nil || settings.LastCleanupRemoved != 0 {
t.Errorf("unexpected default %+v, %v", settings, err)
@@ -29,7 +29,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
for _, days := range []int{30, 90, 365, 0} {
h := newActivityHarness(t)
- settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), days)
+ settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(days))
if err != nil || settings.RetentionDays != days || h.settings.settings.RetentionDays != days {
t.Errorf("%d: unexpected result %+v, %v", days, settings, err)
@@ -41,7 +41,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
for _, days := range []int{7, -1, 366} {
h := newActivityHarness(t)
- _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), days)
+ _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(days))
if !errors.Is(err, audit.ErrInvalidRetention) || h.settings.settings.RetentionDays != 90 || len(h.all(t)) != 0 {
t.Errorf("%d: expected a refusal, got %v", days, err)
@@ -52,7 +52,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
t.Run("A change is recorded as a system event by the owner", func(t *testing.T) {
h := newActivityHarness(t)
- _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 30)
+ _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(30))
events := h.all(t)
if err != nil || len(events) != 1 {
@@ -70,7 +70,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
t.Run("Setting the same value records nothing", func(t *testing.T) {
h := newActivityHarness(t)
- settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 90)
+ settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(90))
if err != nil || settings.RetentionDays != 90 || len(h.all(t)) != 0 || h.settings.retentionOK != 0 {
t.Errorf("expected no event and no write, got %v", err)
@@ -81,7 +81,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
h := newActivityHarness(t)
h.settings.saveErr = errRepositoryFailure
- _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 30)
+ _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(30))
if !errors.Is(err, errRepositoryFailure) || len(h.all(t)) != 0 {
t.Errorf("expected the failure and no event, got %v", err)
@@ -254,7 +254,7 @@ func TestActivityCleanupUseCase(t *testing.T) {
h.now = now
h.seed(t, activityUserEvent(1, "a", now.Add(-40*24*time.Hour)))
- _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(activityOwnerCtx(), 30)
+ _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(activityOwnerCtx(), retentionDays(30))
if err != nil || len(h.all(t)) != 2 {
t.Fatalf("the change itself must remove nothing, got %d events, %v", len(h.all(t)), err)
}
diff --git a/test/activity_support_test.go b/test/activity_support_test.go
index c500a5d..e08d18a 100644
--- a/test/activity_support_test.go
+++ b/test/activity_support_test.go
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/auditing"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
+ "RenewCMS/internal/infrastructure/useCases"
"bytes"
"context"
"errors"
@@ -25,6 +26,10 @@ type activityProbeRepo struct {
deleteErr error
deleteCalls int
readErr error
+ sizeErr error
+ sizeBytes *int64
+ oldestErr error
+ oldestCalls int
}
var _ audit.Repository = &activityProbeRepo{}
@@ -64,6 +69,24 @@ func (p *activityProbeRepo) DeleteOlderThan(cutoff time.Time) (int, error) {
return p.inner.DeleteOlderThan(cutoff)
}
+func (p *activityProbeRepo) DeleteOldest(limit int, before time.Time) (int, error) {
+ p.oldestCalls++
+ if p.oldestErr != nil {
+ return 0, p.oldestErr
+ }
+ return p.inner.DeleteOldest(limit, before)
+}
+
+func (p *activityProbeRepo) SizeBytes() (int64, error) {
+ if p.sizeErr != nil {
+ return 0, p.sizeErr
+ }
+ if p.sizeBytes != nil {
+ return *p.sizeBytes, nil
+ }
+ return p.inner.SizeBytes()
+}
+
type activitySettingsFake struct {
settings audit.Settings
saveErr error
@@ -85,12 +108,13 @@ func (f *activitySettingsFake) Get() (audit.Settings, error) {
return f.settings, nil
}
-func (f *activitySettingsFake) SaveRetention(days int) error {
+func (f *activitySettingsFake) SaveRetention(days int, maxGB int) error {
if f.saveErr != nil {
return f.saveErr
}
f.retentionOK++
f.settings.RetentionDays = days
+ f.settings.RetentionMaxGB = maxGB
return nil
}
@@ -144,3 +168,16 @@ func (h *activityHarness) seed(t *testing.T, event audit.Event) audit.Event {
}
var errDiskFull = errors.New("disk full")
+
+func newSizeEvents(t *testing.T) audit.Repository {
+ t.Helper()
+ return persistence.NewAuditEventRepository(openActivityDatabase(t))
+}
+
+func retentionDays(days int) useCases.UpdateActivityRetentionCommand {
+ return useCases.UpdateActivityRetentionCommand{Days: &days}
+}
+
+func retentionMaxGB(maxGB int) useCases.UpdateActivityRetentionCommand {
+ return useCases.UpdateActivityRetentionCommand{MaxGB: &maxGB}
+}
diff --git a/test/api_keys_http_test.go b/test/api_keys_http_test.go
index d116421..0430f13 100644
--- a/test/api_keys_http_test.go
+++ b/test/api_keys_http_test.go
@@ -133,7 +133,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
- GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), time.Now),
+ GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), time.Now),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
diff --git a/test/settings_http_test.go b/test/settings_http_test.go
index 4f5de0f..934ce36 100644
--- a/test/settings_http_test.go
+++ b/test/settings_http_test.go
@@ -15,12 +15,12 @@ import (
"github.com/go-chi/jwtauth/v5"
)
-func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux {
+func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
settingsHandler := settings.NewHandler(
useCases.NewGetSettingsUseCase(version),
- useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), settingsTestClock),
- useCases.NewUpdateActivityRetentionUseCase(newActivitySettingsFake(), discardRecorder, settingsTestClock),
+ useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), settingsTestClock),
+ useCases.NewUpdateActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), discardRecorder, settingsTestClock),
)
r := chi.NewRouter()
@@ -35,7 +35,7 @@ func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version s
}
func settingsBody(version string) string {
- return `{"version":"` + version + `","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00+02:00"}`
+ return `{"version":"` + version + `","activity_retention_days":90,"activity_retention_max_gb":0,"activity_log_size_bytes":0,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00+02:00"}`
}
func TestSettingsHttp(t *testing.T) {
@@ -46,7 +46,7 @@ func TestSettingsHttp(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
- router := newSettingsRouter(userRepo, token, version)
+ router := newSettingsRouter(t, userRepo, token, version)
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
@@ -64,7 +64,7 @@ func TestSettingsHttp(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
- router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
+ router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
t.Setenv("ENVIRONMENT", "development")
@@ -80,7 +80,7 @@ func TestSettingsHttp(t *testing.T) {
t.Run("A request without a jwt cookie is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
- router := newSettingsRouter(newMockUserRepo(), token, "v2.2.0-beta")
+ router := newSettingsRouter(t, newMockUserRepo(), token, "v2.2.0-beta")
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", nil)
@@ -96,7 +96,7 @@ func TestSettingsHttp(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
- router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
+ router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
@@ -113,7 +113,7 @@ func TestSettingsHttp(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo()
userRepo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, TokenVersion: 0}
- router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
+ router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
cookie := mintGetCurrentUserCookie(t, token, 8, 0)
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
diff --git a/web/src/components/RetentionCard.vue b/web/src/components/RetentionCard.vue
index 8c53ee9..5b2c432 100644
--- a/web/src/components/RetentionCard.vue
+++ b/web/src/components/RetentionCard.vue
@@ -4,15 +4,18 @@ import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useOnline } from '@vueuse/core'
import { ApiError } from '@/composables/useApi'
-import { useSettings, type WorkspaceSettings } from '@/composables/useSettings'
+import { useSettings, type RetentionPatch, type WorkspaceSettings } from '@/composables/useSettings'
import {
SAVED_VISIBLE_MS,
helperCopy,
isShorter,
+ isSmaller,
lastCleanupLine,
nextCleanupLine,
retentionOptions,
shorteningDescription,
+ sizeHelperCopy,
+ sizeOptions,
} from '@/lib/activityRetention'
import { useSessionStore } from '@/stores/session'
@@ -24,6 +27,12 @@ const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
type SaveStatus = 'idle' | 'saving' | 'saved'
+type Control = 'days' | 'gb'
+
+interface Pending {
+ control: Control
+ value: number
+}
const props = defineProps<{
settings: WorkspaceSettings | null
@@ -40,9 +49,10 @@ const session = useSessionStore()
const { updateRetention } = useSettings()
const online = useOnline()
-const selected = ref(props.settings?.activity_retention_days ?? 0)
-const shortening = ref(null)
-const failedValue = ref(null)
+const selectedDays = ref(props.settings?.activity_retention_days ?? 0)
+const selectedGb = ref(props.settings?.activity_retention_max_gb ?? 0)
+const shortening = ref(null)
+const failed = ref(null)
const saveStatus = ref('idle')
let savedTimer: ReturnType | null = null
let pendingSaves = 0
@@ -50,11 +60,68 @@ let pendingSaves = 0
const offline = computed(() => !online.value)
const skeletonVisible = computed(() => props.loadState === 'loading' && props.showSkeleton)
const savedDays = computed(() => props.settings?.activity_retention_days ?? 0)
-const options = computed(() => retentionOptions())
+const savedGb = computed(() => props.settings?.activity_retention_max_gb ?? 0)
+const logSize = computed(() => props.settings?.activity_log_size_bytes ?? 0)
-watch(savedDays, (days) => {
+const controls = computed(() => [
+ {
+ key: 'days' as const,
+ labelId: 'retention-label',
+ label: t('Keep activity for'),
+ helper: helperCopy(selectedDays.value, selectedGb.value),
+ items: retentionOptions(),
+ value: selectedDays.value,
+ },
+ {
+ key: 'gb' as const,
+ labelId: 'retention-size-label',
+ label: t('Keep at most'),
+ helper: sizeHelperCopy(selectedGb.value, logSize.value),
+ items: sizeOptions(),
+ value: selectedGb.value,
+ },
+])
+
+const warningTitle = computed(() => {
+ const pending = shortening.value
+ if (pending === null) {
+ return ''
+ }
+ return pending.control === 'days'
+ ? t('Events older than {0} days will be removed', [pending.value])
+ : t('Events over {0} GB will be removed', [pending.value])
+})
+
+const applyLabel = computed(() => {
+ const pending = shortening.value
+ if (pending === null) {
+ return ''
+ }
+ return pending.control === 'days'
+ ? t('Apply {0} days', [pending.value])
+ : t('Apply {0} GB', [pending.value])
+})
+
+function select(control: Control, value: number) {
+ if (control === 'days') {
+ selectedDays.value = value
+ } else {
+ selectedGb.value = value
+ }
+}
+
+function savedValue(control: Control) {
+ return control === 'days' ? savedDays.value : savedGb.value
+}
+
+function settle() {
+ selectedDays.value = savedDays.value
+ selectedGb.value = savedGb.value
+}
+
+watch([savedDays, savedGb], () => {
if (shortening.value === null && pendingSaves === 0) {
- selected.value = days
+ settle()
}
})
@@ -76,23 +143,34 @@ function finishSaving(succeeded: boolean) {
}, SAVED_VISIBLE_MS)
}
-async function save(days: number) {
+function patchFor({ control, value }: Pending): RetentionPatch {
+ return control === 'days'
+ ? { activity_retention_days: value }
+ : { activity_retention_max_gb: value }
+}
+
+async function save(pending: Pending) {
pendingSaves += 1
clearSavedTimer()
- failedValue.value = null
- selected.value = days
+ failed.value = null
+ select(pending.control, pending.value)
saveStatus.value = 'saving'
let succeeded = false
try {
- const updated = await updateRetention(days)
+ const updated = await updateRetention(patchFor(pending))
emit('saved', updated)
- selected.value = updated.activity_retention_days
+ select(
+ pending.control,
+ pending.control === 'days'
+ ? updated.activity_retention_days
+ : updated.activity_retention_max_gb,
+ )
succeeded = true
} catch (error) {
- selected.value = savedDays.value
+ select(pending.control, savedValue(pending.control))
const status = error instanceof ApiError ? error.status : 0
if (status !== HTTP_UNAUTHORIZED) {
- failedValue.value = days
+ failed.value = pending
}
if (status === HTTP_FORBIDDEN) {
session.refresh()
@@ -105,40 +183,49 @@ async function save(days: number) {
}
}
-function choose(value: number) {
+function isReduction({ control, value }: Pending) {
+ return control === 'days' ? isShorter(value, savedDays.value) : isSmaller(value, savedGb.value)
+}
+
+function choose(control: Control, value: number) {
if (offline.value) {
return
}
+ const previous = shortening.value
shortening.value = null
- failedValue.value = null
- if (value === savedDays.value) {
- selected.value = value
+ failed.value = null
+ if (previous !== null && previous.control !== control) {
+ select(previous.control, savedValue(previous.control))
+ }
+ const pending: Pending = { control, value }
+ if (value === savedValue(control)) {
+ select(control, value)
return
}
- if (isShorter(value, savedDays.value)) {
- selected.value = value
- shortening.value = value
+ if (isReduction(pending)) {
+ select(control, value)
+ shortening.value = pending
return
}
- save(value)
+ save(pending)
}
function apply() {
- const days = shortening.value
+ const pending = shortening.value
shortening.value = null
- if (days !== null) {
- save(days)
+ if (pending !== null) {
+ save(pending)
}
}
function cancel() {
shortening.value = null
- selected.value = savedDays.value
+ settle()
}
function retry() {
- if (failedValue.value !== null) {
- save(failedValue.value)
+ if (failed.value !== null) {
+ save(failed.value)
}
}
@@ -158,7 +245,11 @@ onBeforeUnmount(clearSavedTimer)
{{ t('Retention') }}
- {{ t('How long activity is kept. Only owners can change it.') }}
+ {{
+ t(
+ 'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.',
+ )
+ }}
{{ t('Try again') }}
@@ -241,31 +332,41 @@ onBeforeUnmount(clearSavedTimer)
-
-
-
- {{ t('Keep activity for') }}
-
- {{ helperCopy(selected) }}
-
-
+
-
+
+
+
+ {{ control.label }}
+
+ {{ control.helper }}
+
+
+
+
@@ -287,7 +388,7 @@ onBeforeUnmount(clearSavedTimer)
:class="OUTLINE_BUTTON"
@click="apply"
>
- {{ t('Apply {0} days', [shortening]) }}
+ {{ applyLabel }}
@@ -324,7 +425,7 @@ onBeforeUnmount(clearSavedTimer)
{{ t('Next cleanup') }}
-
+
{{ nextCleanupLine(settings) }}
diff --git a/web/src/composables/useSettings.ts b/web/src/composables/useSettings.ts
index f6906b1..2c4e549 100644
--- a/web/src/composables/useSettings.ts
+++ b/web/src/composables/useSettings.ts
@@ -3,11 +3,18 @@ import { useApi } from '@/composables/useApi'
export interface WorkspaceSettings {
version: string
activity_retention_days: number
+ activity_retention_max_gb: number
+ activity_log_size_bytes: number
activity_last_cleanup_at: string | null
activity_last_cleanup_removed: number
activity_next_cleanup_at: string | null
}
+export interface RetentionPatch {
+ activity_retention_days?: number
+ activity_retention_max_gb?: number
+}
+
const SETTINGS_PATH = '/v1/settings/'
const JSON_HEADERS = { 'Content-Type': 'application/json' }
@@ -18,11 +25,11 @@ export function useSettings() {
return request(SETTINGS_PATH)
}
- function updateRetention(days: number): Promise {
+ function updateRetention(patch: RetentionPatch): Promise {
return request(SETTINGS_PATH, {
method: 'PATCH',
headers: JSON_HEADERS,
- body: JSON.stringify({ activity_retention_days: days }),
+ body: JSON.stringify(patch),
})
}
diff --git a/web/src/data/__tests__/apiCatalogue.spec.ts b/web/src/data/__tests__/apiCatalogue.spec.ts
index e94b4e7..cc9333a 100644
--- a/web/src/data/__tests__/apiCatalogue.spec.ts
+++ b/web/src/data/__tests__/apiCatalogue.spec.ts
@@ -322,8 +322,10 @@ describe('Response samples use the real domain JSON keys', () => {
expect(keysOf(parsed(find('GET /v1/settings')))).toEqual([
'activity_last_cleanup_at',
'activity_last_cleanup_removed',
+ 'activity_log_size_bytes',
'activity_next_cleanup_at',
'activity_retention_days',
+ 'activity_retention_max_gb',
'version',
])
})
diff --git a/web/src/data/apiCatalogue.ts b/web/src/data/apiCatalogue.ts
index 68afc8d..4b57417 100644
--- a/web/src/data/apiCatalogue.ts
+++ b/web/src/data/apiCatalogue.ts
@@ -722,6 +722,8 @@ export const API_GROUPS: ApiGroup[] = [
json({
version: 'v3.0.0-beta',
activity_retention_days: 90,
+ activity_retention_max_gb: 5,
+ activity_log_size_bytes: 3435973837,
activity_last_cleanup_at: '2026-09-10T03:00:00Z',
activity_last_cleanup_removed: 12,
activity_next_cleanup_at: '2026-09-11T03:00:00Z',
diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts
index 8f706f2..d7a2e95 100644
--- a/web/src/i18n/messages.ts
+++ b/web/src/i18n/messages.ts
@@ -1155,6 +1155,10 @@ export const messages = {
'Restarted to apply the update {0}': 'Redémarrage pour appliquer la mise à jour {0}',
'Changed the activity retention from {0} to {1}':
"Durée de conservation de l'activité modifiée : {0} → {1}",
+ 'Changed the activity size limit from {0} to {1}':
+ "Limite de taille de l'activité modifiée : {0} → {1}",
+ 'Changed the activity retention from {0} to {1} and the size limit from {2} to {3}':
+ "Durée de conservation de l'activité modifiée : {0} → {1}, et limite de taille : {2} → {3}",
'Removed {0} old events from the activity log':
"{0} anciens événements supprimés du journal d'activité",
'Removed 1 old event from the activity log':
@@ -1193,8 +1197,8 @@ export const messages = {
'Actions on your site will appear here.': 'Les actions sur votre site apparaîtront ici.',
'Your actions will appear here.': 'Vos actions apparaîtront ici.',
Retention: 'Conservation',
- 'How long activity is kept. Only owners can change it.':
- 'Durée de conservation de l’activité. Seul le propriétaire peut la modifier.',
+ 'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.':
+ 'Durée de conservation de l’activité et volume de journal à conserver. La première limite atteinte supprime les événements les plus anciens. Seul le propriétaire peut la modifier.',
'Keep activity for': 'Conserver l’activité pendant',
'90 days': '90 jours',
'365 days': '365 jours',
@@ -1203,8 +1207,8 @@ export const messages = {
'Nothing is removed automatically. The log grows over time.':
'Rien n’est supprimé automatiquement. Le journal grossit avec le temps.',
'Activity logs are append-only': 'Le journal d’activité est en ajout seul',
- "Events can't be edited or deleted from the app. Only the retention period removes them.":
- 'Les événements ne peuvent pas être modifiés ni supprimés depuis l’application. Seule la durée de conservation les supprime.',
+ "Events can't be edited or deleted from the app. Only the retention limits remove them.":
+ 'Les événements ne peuvent pas être modifiés ni supprimés depuis l’application. Seules les limites de conservation les suppriment.',
'Last cleanup': 'Dernier nettoyage',
'Next cleanup': 'Prochain nettoyage',
'Not scheduled': 'Non planifié',
@@ -1217,6 +1221,20 @@ export const messages = {
"Older events will be removed at the next cleanup. It can't be undone.":
'Les anciens événements seront supprimés au prochain nettoyage. Cette action est irréversible.',
'Apply {0} days': 'Appliquer {0} jours',
+ 'Keep at most': 'Conserver au maximum',
+ or: 'ou',
+ 'No size limit': 'Aucune limite de taille',
+ '{0} GB': '{0} Go',
+ 'Less than 0.1 GB': 'Moins de 0,1 Go',
+ 'Events older than this are removed.': 'Les événements plus anciens sont supprimés.',
+ 'No age limit. Only the size limit applies.':
+ 'Aucune limite d’âge. Seule la limite de taille s’applique.',
+ 'Optional. Logs can grow without a size cap.':
+ 'Facultatif. Le journal peut grossir sans limite de taille.',
+ 'When logs pass {0} GB, the oldest events are removed. Currently {1}.':
+ 'Au-delà de {0} Go, les événements les plus anciens sont supprimés. Actuellement {1}.',
+ 'Events over {0} GB will be removed': 'Les événements au-delà de {0} Go seront supprimés',
+ 'Apply {0} GB': 'Appliquer {0} Go',
"Couldn't save the retention period": 'Impossible d’enregistrer la durée de conservation',
'Your previous setting is still in place.': 'Votre réglage précédent est conservé.',
} as const
diff --git a/web/src/lib/__tests__/activityRetention.spec.ts b/web/src/lib/__tests__/activityRetention.spec.ts
index b9f0edb..40a30b8 100644
--- a/web/src/lib/__tests__/activityRetention.spec.ts
+++ b/web/src/lib/__tests__/activityRetention.spec.ts
@@ -3,8 +3,11 @@ import { initializeLocale, setLocale } from '@/i18n'
import type { WorkspaceSettings } from '@/composables/useSettings'
import { recentWhenLabel } from '../activityTime'
import {
+ formatLogSize,
helperCopy,
isShorter,
+ isSmaller,
+ sizeHelperCopy,
lastCleanupLine,
nextCleanupLine,
shorteningDescription,
@@ -20,6 +23,8 @@ function settings(overrides: Partial = {}): WorkspaceSettings
return {
version: 'v2.2.0-beta',
activity_retention_days: 90,
+ activity_retention_max_gb: 0,
+ activity_log_size_bytes: 0,
activity_last_cleanup_at: at(6, 3, 0),
activity_last_cleanup_removed: 142,
activity_next_cleanup_at: at(7, 3, 0),
@@ -86,3 +91,43 @@ describe('retention lines', () => {
)
})
})
+
+describe('retention size limit', () => {
+ it('ranks 1 < 5 < 10 < 25 < 50 < No size limit', () => {
+ expect(isSmaller(1, 5)).toBe(true)
+ expect(isSmaller(5, 0)).toBe(true)
+ expect(isSmaller(0, 50)).toBe(false)
+ expect(isSmaller(5, 5)).toBe(false)
+ expect(isSmaller(10, 50)).toBe(true)
+ })
+
+ it.each([
+ [0, 'Less than 0.1 GB'],
+ [53687091, 'Less than 0.1 GB'],
+ [107374183, '0.1 GB'],
+ [1073741824, '1.0 GB'],
+ [3435973837, '3.2 GB'],
+ [5368709120, '5.0 GB'],
+ ])('formats %s bytes as %s on a 1024^3 base', (bytes, shown) => {
+ expect(formatLogSize(bytes)).toBe(shown)
+ })
+
+ it('words the helpers for each combination', () => {
+ expect(helperCopy(90, 5)).toBe('Events older than this are removed.')
+ expect(helperCopy(0, 5)).toBe('No age limit. Only the size limit applies.')
+ expect(helperCopy(0, 0)).toBe('Nothing is removed automatically. The log grows over time.')
+ expect(sizeHelperCopy(0, 99)).toBe('Optional. Logs can grow without a size cap.')
+ expect(sizeHelperCopy(5, 3435973837)).toBe(
+ 'When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB.',
+ )
+ })
+
+ it('words the helpers in French with Go and a decimal comma', () => {
+ setLocale('fr')
+
+ expect(sizeHelperCopy(5, 3435973837)).toBe(
+ 'Au-delà de 5 Go, les événements les plus anciens sont supprimés. Actuellement 3,2 Go.',
+ )
+ expect(formatLogSize(0)).toBe('Moins de 0,1 Go')
+ })
+})
diff --git a/web/src/lib/__tests__/activitySentence.spec.ts b/web/src/lib/__tests__/activitySentence.spec.ts
index ac785c6..418e657 100644
--- a/web/src/lib/__tests__/activitySentence.spec.ts
+++ b/web/src/lib/__tests__/activitySentence.spec.ts
@@ -101,6 +101,26 @@ describe('Sentences: English and French come from the same code and params', ()
'Changed the activity retention from 90 days to Forever',
"Durée de conservation de l'activité modifiée : 90 jours → Toujours",
],
+ [
+ 'system.activity_retention_changed size only',
+ {
+ action: 'system.activity_retention_changed',
+ target: null,
+ params: { max_gb_from: 0, max_gb_to: 5 },
+ },
+ 'Changed the activity size limit from No size limit to 5 GB',
+ "Limite de taille de l'activité modifiée : Aucune limite de taille → 5 Go",
+ ],
+ [
+ 'system.activity_retention_changed age and size',
+ {
+ action: 'system.activity_retention_changed',
+ target: null,
+ params: { from: 90, to: 30, max_gb_from: 10, max_gb_to: 0 },
+ },
+ 'Changed the activity retention from 90 days to 30 days and the size limit from 10 GB to No size limit',
+ "Durée de conservation de l'activité modifiée : 90 jours → 30 jours, et limite de taille : 10 Go → Aucune limite de taille",
+ ],
[
'system.restart_requested update',
{
diff --git a/web/src/lib/activityRetention.ts b/web/src/lib/activityRetention.ts
index ced2f47..f7dfb7f 100644
--- a/web/src/lib/activityRetention.ts
+++ b/web/src/lib/activityRetention.ts
@@ -1,11 +1,16 @@
-import { t } from '@/i18n'
+import { formatNumber, t } from '@/i18n'
import type { WorkspaceSettings } from '@/composables/useSettings'
import { localDayTime } from '@/lib/activityTime'
export const FOREVER_DAYS = 0
+export const NO_SIZE_LIMIT = 0
export const SAVED_VISIBLE_MS = 2000
+const BYTES_PER_GB = 1024 ** 3
+const MIN_SHOWN_GB = 0.1
+
const RANKED_PERIODS = [30, 90, 365, FOREVER_DAYS]
+const RANKED_SIZES = [1, 5, 10, 25, 50, NO_SIZE_LIMIT]
export function retentionOptions() {
return RANKED_PERIODS.map((value) => ({ value, label: periodLabel(value) }))
@@ -32,12 +37,52 @@ export function isShorter(candidate: number, saved: number) {
return periodRank(candidate) < periodRank(saved)
}
-export function helperCopy(days: number) {
- return days === FOREVER_DAYS
- ? t('Nothing is removed automatically. The log grows over time.')
+export function sizeOptions() {
+ return RANKED_SIZES.map((value) => ({ value, label: sizeLabel(value) }))
+}
+
+export function sizeLabel(gb: number) {
+ return gb === NO_SIZE_LIMIT ? t('No size limit') : t('{0} GB', [gb])
+}
+
+export function sizeRank(gb: number) {
+ return RANKED_SIZES.indexOf(gb)
+}
+
+export function isSmaller(candidate: number, saved: number) {
+ return sizeRank(candidate) < sizeRank(saved)
+}
+
+export function formatLogSize(bytes: number) {
+ const gb = bytes / BYTES_PER_GB
+ if (gb < MIN_SHOWN_GB) {
+ return t('Less than 0.1 GB')
+ }
+ const figure = formatNumber(gb, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
+ return t('{0} GB', [figure])
+}
+
+export function helperCopy(days: number, gb = NO_SIZE_LIMIT) {
+ const sized = gb !== NO_SIZE_LIMIT
+ if (days === FOREVER_DAYS) {
+ return sized
+ ? t('No age limit. Only the size limit applies.')
+ : t('Nothing is removed automatically. The log grows over time.')
+ }
+ return sized
+ ? t('Events older than this are removed.')
: t('Older events are removed automatically.')
}
+export function sizeHelperCopy(gb: number, bytes: number) {
+ return gb === NO_SIZE_LIMIT
+ ? t('Optional. Logs can grow without a size cap.')
+ : t('When logs pass {0} GB, the oldest events are removed. Currently {1}.', [
+ gb,
+ formatLogSize(bytes),
+ ])
+}
+
export function lastCleanupLine(settings: WorkspaceSettings) {
if (!settings.activity_last_cleanup_at) {
return t('Never')
diff --git a/web/src/lib/activitySentence.ts b/web/src/lib/activitySentence.ts
index a1f4f05..b8d2fdf 100644
--- a/web/src/lib/activitySentence.ts
+++ b/web/src/lib/activitySentence.ts
@@ -1,6 +1,7 @@
import { formatNumber, t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
+import { sizeLabel } from '@/lib/activityRetention'
export interface SentenceSegment {
text: string
@@ -139,9 +140,36 @@ function retentionDays(value: unknown) {
return days === 0 ? t('Forever') : t('{0} days', [formatNumber(days)])
}
+function changedAge(params: Record) {
+ return 'from' in params || 'to' in params
+}
+
+function changedSize(params: Record) {
+ return 'max_gb_from' in params || 'max_gb_to' in params
+}
+
+function retentionTemplate(params: Record): MessageKey {
+ if (changedSize(params) && changedAge(params)) {
+ return 'Changed the activity retention from {0} to {1} and the size limit from {2} to {3}'
+ }
+ return changedSize(params)
+ ? 'Changed the activity size limit from {0} to {1}'
+ : 'Changed the activity retention from {0} to {1}'
+}
+
+function retentionValues(params: Record) {
+ const sizes = [sizeLabel(count(params.max_gb_from)), sizeLabel(count(params.max_gb_to))]
+ if (!changedAge(params)) return sizes
+ const ages = [retentionDays(params.from), retentionDays(params.to)]
+ return changedSize(params) ? [...ages, ...sizes] : ages
+}
+
function resolveTemplate(item: ActivityItem): MessageKey | null {
const template = CATALOGUE[item.action]
if (!template) return null
+ if (item.action === 'system.activity_retention_changed') {
+ return retentionTemplate(item.params)
+ }
if (item.action === 'system.restart_requested' && item.outcome === 'success') {
return item.params.kind === 'update'
? 'Restarted to apply the update {0}'
@@ -156,7 +184,7 @@ function slotValues(item: ActivityItem, template: MessageKey) {
return [purgeSummary(item.params)]
}
if (item.action === 'system.activity_retention_changed') {
- return [retentionDays(item.params.from), retentionDays(item.params.to)]
+ return retentionValues(item.params)
}
if (item.action === 'system.activity_cleanup') {
return [formatNumber(count(item.params.removed))]
diff --git a/web/src/views/__tests__/SettingsRetention.spec.ts b/web/src/views/__tests__/SettingsRetention.spec.ts
index 3ce9339..1225f6c 100644
--- a/web/src/views/__tests__/SettingsRetention.spec.ts
+++ b/web/src/views/__tests__/SettingsRetention.spec.ts
@@ -30,6 +30,8 @@ function settings(overrides: Partial = {}): WorkspaceSettings
return {
version: 'v2.2.0-beta',
activity_retention_days: 90,
+ activity_retention_max_gb: 0,
+ activity_log_size_bytes: 0,
activity_last_cleanup_at: at(6, 3),
activity_last_cleanup_removed: 142,
activity_next_cleanup_at: at(7, 3),
@@ -116,14 +118,26 @@ async function choose(days: number) {
await flushPromises()
}
-function selectedLabel() {
- const select = wrapper!.findComponent(USelectMenu)
+function labelOf(select: ReturnType) {
const value = select.props('modelValue')
return (select.props('items') as { value: number; label: string }[]).find(
(item) => item.value === value,
)?.label
}
+function selectedLabel() {
+ return labelOf(wrapper!.findAllComponents(USelectMenu)[0]!)
+}
+
+function sizeSelectLabel() {
+ return labelOf(wrapper!.findAllComponents(USelectMenu)[1]!)
+}
+
+async function chooseSize(gb: number) {
+ wrapper!.findAllComponents(USelectMenu)[1]!.vm.$emit('update:modelValue', gb)
+ await flushPromises()
+}
+
afterEach(async () => {
wrapper?.unmount()
wrapper = null
@@ -153,7 +167,7 @@ describe('Settings retention, Default with 90 days', () => {
await mountPage('owner', () => Promise.resolve(settings()))
expect(text()).toContain('Retention')
- expect(text()).toContain('How long activity is kept.')
+ expect(text()).toContain('How long activity is kept, and how much log data to keep.')
expect(selectedLabel()).toBe('90 days')
expect(text()).toContain('Older events are removed automatically.')
expect(text()).toContain('Activity logs are append-only')
@@ -476,3 +490,342 @@ describe('Settings retention, the French interface', () => {
expect(selectedLabel()).toBe('90 jours')
})
})
+
+const FIVE_GB_SETTINGS = {
+ activity_retention_max_gb: 5,
+ activity_log_size_bytes: 3435973837,
+}
+
+describe('Settings retention size limit, states', () => {
+ it('Default with 90 days or 5 GB', async () => {
+ await mountPage('owner', () => Promise.resolve(settings(FIVE_GB_SETTINGS)))
+
+ expect(selectedLabel()).toBe('90 days')
+ expect(sizeSelectLabel()).toBe('5 GB')
+ expect(text()).toContain('Events older than this are removed.')
+ expect(text()).toContain('Keep at most')
+ expect(text()).toContain('or')
+ expect(text()).toContain(
+ 'When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB.',
+ )
+ expect(text()).toContain(
+ "Events can't be edited or deleted from the app. Only the retention limits remove them.",
+ )
+ expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
+ })
+
+ it('Age only (no size limit)', async () => {
+ await mountPage('owner', () => Promise.resolve(settings()))
+
+ expect(sizeSelectLabel()).toBe('No size limit')
+ expect(text()).toContain('Optional. Logs can grow without a size cap.')
+ expect(text()).toContain('Older events are removed automatically.')
+ })
+
+ it('Size only (forever)', async () => {
+ await mountPage('owner', () =>
+ Promise.resolve(settings({ ...FIVE_GB_SETTINGS, activity_retention_days: 0 })),
+ )
+
+ expect(selectedLabel()).toBe('Forever')
+ expect(text()).toContain('No age limit. Only the size limit applies.')
+ expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
+ })
+
+ it('Forever with no size limit', async () => {
+ await mountPage('owner', () =>
+ Promise.resolve(
+ settings({ activity_retention_days: 0, activity_next_cleanup_at: null }),
+ ),
+ )
+
+ expect(text()).toContain('Nothing is removed automatically. The log grows over time.')
+ expect(line('retention-next')).toBe('Not scheduled')
+ })
+
+ it.each([
+ [3435973837, 'Currently 3.2 GB.'],
+ [0, 'Currently Less than 0.1 GB.'],
+ [53687091, 'Currently Less than 0.1 GB.'],
+ [5368709120, 'Currently 5.0 GB.'],
+ ])('formats %s bytes as "%s"', async (bytes, shown) => {
+ await mountPage('owner', () =>
+ Promise.resolve(
+ settings({ activity_retention_max_gb: 5, activity_log_size_bytes: bytes }),
+ ),
+ )
+
+ expect(text()).toContain(shown)
+ })
+})
+
+describe('Settings retention size limit, a larger size or no limit saves at once', () => {
+ it.each([
+ [5, 10],
+ [5, 0],
+ [1, 50],
+ ])('saves %s GB to %s with one PATCH of the size key only', async (from, to) => {
+ vi.useFakeTimers()
+ const pending = deferred()
+ await mountPage(
+ 'owner',
+ () => Promise.resolve(settings({ activity_retention_max_gb: from })),
+ () => pending.promise,
+ )
+
+ await chooseSize(to)
+
+ expect(patches()).toHaveLength(1)
+ expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: to }))
+ expect(text()).toContain('Saving…')
+ expect(text()).not.toContain('Events over')
+
+ pending.resolve(settings({ activity_retention_max_gb: to }))
+ await flushPromises()
+
+ expect(text()).toContain('Saved')
+ expect(sizeSelectLabel()).toBe(to === 0 ? 'No size limit' : `${to} GB`)
+
+ await vi.advanceTimersByTimeAsync(SAVED_VISIBLE_MS)
+
+ expect(text()).not.toContain('Saved')
+ })
+})
+
+describe('Settings retention size limit, a smaller size asks first', () => {
+ it.each([
+ [10, 5],
+ [0, 5],
+ [50, 1],
+ ])('warns for %s GB then %s GB without sending', async (from, to) => {
+ await mountPage('owner', () =>
+ Promise.resolve(settings({ activity_retention_max_gb: from })),
+ )
+
+ await chooseSize(to)
+
+ expect(patches()).toHaveLength(0)
+ expect(sizeSelectLabel()).toBe(`${to} GB`)
+ expect(text()).toContain(`Events over ${to} GB will be removed`)
+ expect(text()).toContain(
+ "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.",
+ )
+ expect(button(`Apply ${to} GB`)).toBeDefined()
+ expect(button('Cancel')).toBeDefined()
+ })
+
+ it('applies with one PATCH and cancels without any request', async () => {
+ await mountPage(
+ 'owner',
+ () => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
+ () => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
+ )
+ await chooseSize(5)
+
+ button('Apply 5 GB')!.click()
+ await flushPromises()
+
+ expect(patches()).toHaveLength(1)
+ expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: 5 }))
+ expect(text()).toContain('Saved')
+ expect(text()).not.toContain('Events over')
+
+ await chooseSize(1)
+ button('Cancel')!.click()
+ await flushPromises()
+
+ expect(sizeSelectLabel()).toBe('5 GB')
+ expect(patches()).toHaveLength(1)
+ })
+})
+
+describe('Settings retention size limit, one warning at a time', () => {
+ it('replaces the age warning with the size warning and reverts the age select', async () => {
+ await mountPage('owner', () => Promise.resolve(settings({ activity_retention_max_gb: 10 })))
+ await choose(30)
+
+ await chooseSize(5)
+
+ expect(text()).toContain('Events over 5 GB will be removed')
+ expect(text()).not.toContain('Events older than 30 days')
+ expect(selectedLabel()).toBe('90 days')
+ })
+
+ it('drops the warning when a larger size is chosen', async () => {
+ await mountPage(
+ 'owner',
+ () => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
+ () => Promise.resolve(settings({ activity_retention_max_gb: 25 })),
+ )
+ await chooseSize(5)
+
+ await chooseSize(25)
+
+ expect(text()).not.toContain('Events over')
+ expect(patches()).toHaveLength(1)
+ expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: 25 }))
+ expect(sizeSelectLabel()).toBe('25 GB')
+ })
+
+ it('drops a size warning for a longer age and sends only the age', async () => {
+ await mountPage(
+ 'owner',
+ () => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
+ () =>
+ Promise.resolve(
+ settings({ activity_retention_days: 365, activity_retention_max_gb: 10 }),
+ ),
+ )
+ await chooseSize(5)
+
+ await choose(365)
+
+ expect(text()).not.toContain('Events over')
+ expect(sizeSelectLabel()).toBe('10 GB')
+ expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 365 }))
+ })
+})
+
+describe('Settings retention size limit, switching age to Forever', () => {
+ it('sends the age only and keeps the schedule', async () => {
+ await mountPage(
+ 'owner',
+ () => Promise.resolve(settings(FIVE_GB_SETTINGS)),
+ () =>
+ Promise.resolve(
+ settings({
+ ...FIVE_GB_SETTINGS,
+ activity_retention_days: 0,
+ activity_next_cleanup_at: at(8, 3),
+ }),
+ ),
+ )
+
+ await choose(0)
+
+ expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 0 }))
+ expect(text()).toContain('No age limit. Only the size limit applies.')
+ expect(line('retention-next')).toBe('8 Oct 2026, 03:00')
+ })
+})
+
+describe('Settings retention size limit, save errors revert only the size select', () => {
+ it.each([
+ ['500', () => Promise.reject(new ApiError(500))],
+ ['400', () => Promise.reject(new ApiError(400))],
+ ['403', () => Promise.reject(new ApiError(403))],
+ ['network', () => Promise.reject(new NetworkError())],
+ ])('on %s', async (_name, failure) => {
+ let attempt = 0
+ await mountPage(
+ 'owner',
+ () => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
+ () => {
+ attempt += 1
+ return attempt === 1
+ ? failure()
+ : Promise.resolve(settings({ activity_retention_max_gb: 10 }))
+ },
+ )
+
+ await chooseSize(10)
+
+ expect(text()).toContain("Couldn't save the retention period")
+ expect(sizeSelectLabel()).toBe('5 GB')
+ expect(selectedLabel()).toBe('90 days')
+ expect(text()).not.toContain('Saved')
+
+ Array.from(retention()!.querySelectorAll('button'))
+ .find((candidate) => candidate.textContent?.trim() === 'Try again')!
+ .click()
+ await flushPromises()
+
+ expect(patches()).toHaveLength(2)
+ expect(patches()[1]![1].body).toBe(JSON.stringify({ activity_retention_max_gb: 10 }))
+ expect(text()).toContain('Saved')
+ })
+
+ it('retries an applied smaller size without asking again', async () => {
+ let attempt = 0
+ await mountPage(
+ 'owner',
+ () => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
+ () => {
+ attempt += 1
+ return attempt === 1
+ ? Promise.reject(new ApiError(500))
+ : Promise.resolve(settings({ activity_retention_max_gb: 5 }))
+ },
+ )
+ await chooseSize(5)
+ button('Apply 5 GB')!.click()
+ await flushPromises()
+
+ Array.from(retention()!.querySelectorAll('button'))
+ .find((candidate) => candidate.textContent?.trim() === 'Try again')!
+ .click()
+ await flushPromises()
+
+ expect(patches()).toHaveLength(2)
+ expect(text()).not.toContain('Events over')
+ })
+
+ it('refreshes the session on a 403 and removes the card for a demoted owner', async () => {
+ await mountPage(
+ 'owner',
+ () => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
+ () => Promise.reject(new ApiError(403)),
+ )
+ getMeMock.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: 'admin' })
+
+ await chooseSize(10)
+
+ expect(retention()).toBeNull()
+ })
+
+ it('shows no card alert on a 401', async () => {
+ await mountPage(
+ 'owner',
+ () => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
+ () => Promise.reject(new ApiError(401)),
+ )
+
+ await chooseSize(10)
+
+ expect(text()).not.toContain("Couldn't save")
+ expect(sizeSelectLabel()).toBe('5 GB')
+ })
+})
+
+describe('Settings retention size limit, offline and French', () => {
+ it('disables both selects, Apply and Cancel offline', async () => {
+ await mountPage('owner', () => Promise.resolve(settings({ activity_retention_max_gb: 10 })))
+ await chooseSize(5)
+
+ const online = vi.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false)
+ window.dispatchEvent(new Event('offline'))
+ await flushPromises()
+
+ const selects = wrapper!.findAllComponents(USelectMenu)
+ expect(selects[0]!.props('disabled')).toBe(true)
+ expect(selects[1]!.props('disabled')).toBe(true)
+ expect(button('Apply 5 GB')!.disabled).toBe(true)
+ expect(button('Cancel')!.disabled).toBe(true)
+ online.mockRestore()
+ })
+
+ it('words the size strings in French', async () => {
+ setLocale('fr')
+ await mountPage('owner', () => Promise.resolve(settings(FIVE_GB_SETTINGS)))
+
+ expect(text()).toContain('Conserver au maximum')
+ expect(text()).toContain('ou')
+ expect(sizeSelectLabel()).toBe('5 Go')
+ expect(text()).toContain(
+ 'Au-delà de 5 Go, les événements les plus anciens sont supprimés. Actuellement 3,2 Go.',
+ )
+ await chooseSize(1)
+ expect(text()).toContain('Les événements au-delà de 1 Go seront supprimés')
+ expect(button('Appliquer 1 Go')).toBeDefined()
+ })
+})
diff --git a/web/visual/check.ts b/web/visual/check.ts
index 8a266bc..552b747 100644
--- a/web/visual/check.ts
+++ b/web/visual/check.ts
@@ -67,6 +67,19 @@ const extraSources: Record = {
],
alias: {},
},
+ {
+ board: officeBoard,
+ group: 'Settings: Retention',
+ states: [
+ 'Default, 90 days or 5 GB',
+ 'Age only (no size limit)',
+ 'Size only (forever)',
+ 'Shortening the period',
+ 'Forever',
+ 'Save error',
+ ],
+ alias: {},
+ },
{
board: officeBoard,
group: 'Purge dialog',
diff --git a/web/visual/states.ts b/web/visual/states.ts
index db5c69d..862deb9 100644
--- a/web/visual/states.ts
+++ b/web/visual/states.ts
@@ -168,6 +168,8 @@ const updateRunningVersion = 'v2.2.0-beta'
const workspaceSettingsBody = {
version: updateRunningVersion,
activity_retention_days: 90,
+ activity_retention_max_gb: 0,
+ activity_log_size_bytes: 0,
activity_last_cleanup_at: '2026-10-06T03:00:00Z',
activity_last_cleanup_removed: 142,
activity_next_cleanup_at: '2026-10-07T03:00:00Z',
@@ -2401,8 +2403,8 @@ function settingsBody(body: unknown): ApiMock {
return { status: 200, body }
}
-async function scrollSettingsToCleanup(page: Page) {
- const top = (page.viewportSize()?.width ?? 1440) < 768 ? 480 : 300
+async function scrollSettingsToCleanup(page: Page, desktopTop = 300, mobileTop = 480) {
+ const top = (page.viewportSize()?.width ?? 1440) < 768 ? mobileTop : desktopTop
await page.evaluate((top) => {
const card = document.querySelector('[data-testid="settings-card"]')
if (card?.parentElement && !card.parentElement.querySelector('[data-scroll-room]')) {
@@ -2499,6 +2501,92 @@ function successState(settings: ApiMock) {
)
}
+const retentionPatchPath = 'PATCH /v1/settings/'
+const retentionGetPath = 'GET /v1/settings/'
+const retentionFiveGb = { activity_retention_max_gb: 5, activity_log_size_bytes: 3435973837 }
+
+function retentionApi(
+ settings: Record,
+ overrides: Record = {},
+): Record {
+ return cleanupApi(settingsBody(purgeSettingsBody()), {
+ [retentionGetPath]: settingsBody({ ...workspaceSettingsBody, ...settings }),
+ ...overrides,
+ })
+}
+
+function retentionState(
+ api: Record,
+ steps?: (page: Page) => Promise,
+ exception?: DiffException,
+): VisualState {
+ const scroll = (page: Page) => scrollSettingsToCleanup(page, 560, 936)
+ return {
+ path: '/settings',
+ now: boardNow,
+ api,
+ exception,
+ steps: async (page) => {
+ await scroll(page)
+ await steps?.(page)
+ await page.mouse.move(0, 0)
+ await scroll(page)
+ },
+ }
+}
+
+function chooseRetention(control: string, option: string) {
+ return async (page: Page) => {
+ await page.locator(`[aria-labelledby="${control}"]`).click()
+ await page.getByRole('option', { name: option, exact: true }).click()
+ }
+}
+
+const retentionStates: Record = {
+ 'Default, 90 days or 5 GB': retentionState(
+ retentionApi(
+ { activity_retention_max_gb: 1, activity_log_size_bytes: 3435973837 },
+ {
+ [retentionPatchPath]: settingsBody({
+ ...workspaceSettingsBody,
+ ...retentionFiveGb,
+ }),
+ },
+ ),
+ async (page) => {
+ await chooseRetention('retention-size-label', '5 GB')(page)
+ await page.getByText('Saved', { exact: true }).first().waitFor()
+ },
+ ),
+ 'Age only (no size limit)': retentionState(retentionApi({})),
+ 'Size only (forever)': retentionState(
+ retentionApi({ ...retentionFiveGb, activity_retention_days: 0 }),
+ ),
+ 'Shortening the period': retentionState(
+ retentionApi(retentionFiveGb),
+ chooseRetention('retention-label', '30 days'),
+ ),
+ Forever: retentionState(
+ retentionApi({
+ activity_retention_days: 0,
+ activity_last_cleanup_at: null,
+ activity_next_cleanup_at: null,
+ }),
+ ),
+ 'Save error': retentionState(
+ retentionApi({}, { [retentionPatchPath]: { status: 500 } }),
+ async (page) => {
+ await chooseRetention('retention-label', '365 days')(page)
+ await page.getByText("Couldn't save the retention period").waitFor()
+ },
+ {
+ maxPercent: 13,
+ reason: 'Board draws 28 px buttons on mobile; foundations.md requires 44 px touch targets',
+ approvedBy: 'user',
+ },
+ ),
+}
+
const cleanupStates: Record = {
'Default: off, never run': cleanupState(cleanupApi(settingsBody(purgeSettingsBody()))),
'On: 30 days, last run': cleanupState(cleanupApi(settingsBody(purgeOnBody))),
@@ -3155,6 +3243,7 @@ export const visualStates: Record> = {
},
settings: {
...cleanupStates,
+ ...retentionStates,
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
Checking: {
path: '/settings',