retention: write the audit row before the actions_log cap

RetainWithAudit appends the 'retain' audit row inside the same transaction and
before the cap, so the cap counts it. After a run, actions_log holds at most
EMAIL_AGENT_ACTIONS_LOG_MAX_ROWS rows, including the audit row. Adds a test.
This commit is contained in:
Builder
2026-10-13 10:42:44 +11:00
parent e5cce1c93a
commit d8788ef8e7
3 changed files with 50 additions and 7 deletions

View File

@@ -49,6 +49,14 @@ type RetentionResult struct {
// pass never leaves the database in a mixed state. It is safe to run on a
// schedule: every statement is idempotent.
func (s *Store) Retain(ctx context.Context, p RetentionPolicy) (RetentionResult, error) {
return s.RetainWithAudit(ctx, p, "", "")
}
// RetainWithAudit is Retain plus one actions_log row (FR-13), appended inside
// the same transaction before the cap is applied so the audit row is itself
// counted by the cap. An empty detail writes no audit row. This keeps the
// action log bounded even though every run records that it ran.
func (s *Store) RetainWithAudit(ctx context.Context, p RetentionPolicy, actor, detail string) (RetentionResult, error) {
var res RetentionResult
tx, err := s.db.BeginTx(ctx, nil)
@@ -74,6 +82,14 @@ WHERE body_text IS NOT NULL AND body_text <> '' AND received_at < ?`,
}
}
if detail != "" {
if _, err := tx.ExecContext(ctx, `
INSERT INTO actions_log (message_id, action, actor, detail)
VALUES (NULL, 'retain', ?, ?)`, nullString(actor), detail); err != nil {
return res, fmt.Errorf("log retention action: %w", err)
}
}
if p.ActionsLogMaxRows > 0 {
// Keep the newest N rows and delete the rest. id is monotonic, so it
// is the stable ordering for an append-only table.