package archive import ( "errors" "fmt" "os" "path/filepath" "slices" "strings" "time" "sneak.berlin/go/webhooker/internal/database" ) // The archive rotations: how often a database target starts a new // archive file. Every rotation but none puts the period of an event's // receive time, in UTC, in the name of the file the event goes to, // written in the layout beside it. const ( archiveRotationNone = "none" archiveRotationMonthly = "monthly" archiveRotationDaily = "daily" archiveRotationHourly = "hourly" archiveMonthLayout = "2006-01" archiveDayLayout = "2006-01-02" archiveHourLayout = "2006-01-02-15" ) // errArchiveRotationUnknown is returned for a rotation that is not one // of the four. var errArchiveRotationUnknown = errors.New( "rotation must be none, monthly, daily or hourly", ) // archiveFile is one of a database target's archive files, and the // period in its name: "" for the file named without one. type archiveFile struct { path string period string } // ValidateRotation checks a user-supplied archive rotation for // a database target: empty or none (both meaning one file), monthly, // daily or hourly. func ValidateRotation(rotation string) error { switch rotation { case "", archiveRotationNone, archiveRotationMonthly, archiveRotationDaily, archiveRotationHourly: return nil default: return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation) } } // archivePeriod returns the period, in UTC, that a rotation puts an // event received at receivedAt in: "2026-10" for monthly, // "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none // or an empty rotation. func archivePeriod(rotation string, receivedAt time.Time) string { switch rotation { case archiveRotationMonthly: return receivedAt.UTC().Format(archiveMonthLayout) case archiveRotationDaily: return receivedAt.UTC().Format(archiveDayLayout) case archiveRotationHourly: return receivedAt.UTC().Format(archiveHourLayout) default: return "" } } // archivePeriodPath returns the path of a database target's archive // file for a period: path, as Path gives it, with "-" and the // period put before its ".db". The period "" gives path itself. func archivePeriodPath(path, period string) string { if period == "" { return path } return strings.TrimSuffix(path, ".db") + "-" + period + ".db" } // PathAt returns the archive file a database target writes an // event received at receivedAt to: Path's file, with the // period in its name when the target rotates. func PathAt( dbMgr *database.WebhookDBManager, webhook *database.Webhook, target *database.Target, receivedAt time.Time, ) string { return archivePeriodPath( Path(dbMgr, webhook, target), archivePeriod(target.ArchiveRotation, receivedAt), ) } // archiveFiles lists the database target's archive files that exist, // given the path Path gives it: the file at path, then each // file archivePeriodPath names for path and a period, oldest period // first. Which rotation wrote a file does not matter, so the files of // an earlier rotation setting are listed too. func archiveFiles(path string) ([]archiveFile, error) { dir := filepath.Dir(path) entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("listing archive files: %w", err) } stem := strings.TrimSuffix(filepath.Base(path), ".db") var files []archiveFile for _, entry := range entries { period, ok := archiveFilePeriod(stem, entry.Name()) if ok { files = append(files, archiveFile{ path: filepath.Join(dir, entry.Name()), period: period, }) } } // A month sorts before the days and hours in it. slices.SortFunc(files, func(a, b archiveFile) int { return strings.Compare(a.period, b.period) }) return files, nil } // archiveFilePeriod reports whether name is the name of an archive // file of the target whose file name without a period is stem+".db", // and the period in it. func archiveFilePeriod(stem, name string) (string, bool) { rest, ok := strings.CutPrefix(name, stem) if !ok { return "", false } rest, ok = strings.CutSuffix(rest, ".db") if !ok { return "", false } if rest == "" { return "", true } period, ok := strings.CutPrefix(rest, "-") if !ok { return "", false } for _, layout := range []string{ archiveMonthLayout, archiveDayLayout, archiveHourLayout, } { _, err := time.Parse(layout, period) if err == nil { return period, true } } return "", false }