From 6f83e7e6857827b53d2850383fd122859e20e9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 17:21:52 +0100 Subject: [PATCH] fix(db-backup): age pre-migration dumps out with the nightly ones Pre-migration dumps were kept by count alone, so an install that updates rarely kept its last two, and every account deleted since, indefinitely, while the docs and the backups card stated the nightly retention as the retention of deleted accounts. A pre-migration dump older than the oldest nightly dump kept now goes, except the newest one, the way back from the latest update, which stays until the next update. The card, the disaster recovery guide and its variable table say so. --- docs/ops/disaster-recovery.md | 20 +++++--- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/nl.json | 2 +- messages/pl.json | 2 +- messages/pt-BR.json | 2 +- scripts/db-dump.mjs | 2 +- .../__tests__/database-dump-row.test.tsx | 3 +- .../__tests__/dump-core.test.ts | 51 ++++++++++++++++++- src/lib/database-backup/dump-core.mjs | 33 +++++++++--- 14 files changed, 100 insertions(+), 27 deletions(-) diff --git a/docs/ops/disaster-recovery.md b/docs/ops/disaster-recovery.md index 9114e368b..c85d85d51 100644 --- a/docs/ops/disaster-recovery.md +++ b/docs/ops/disaster-recovery.md @@ -39,7 +39,11 @@ per-account copies use. A dump holds every account that existed when it was taken, including one deleted since. Deleted accounts leave the dumps as they age out: after `DB_BACKUP_KEEP` nights on the server, and after your lifecycle rule in the -bucket. Give that rule a prefix that covers `database/` too; the admin +bucket. A pre-migration dump goes once it is older than every nightly dump +kept, except the one taken before the latest update: that one is the way back +from it and stays until the next update writes a newer one. On an install +that rarely updates, delete it by hand once you no longer want the way back, +or state that exception with the retention period. Give that rule a prefix that covers `database/` too; the admin backups page reads the bucket's rules and says when none does, and the recovery readiness card calls it attention and tells the admins once. This is the retention period to state where you tell people that deleting their account @@ -206,10 +210,10 @@ run, restore last night's dump on a spare machine with the steps above. ## Settings -| Variable | Default | What it does | -| -------------------------- | ---------------------------- | -------------------------------------------------------------------------------- | -| `DB_BACKUP_ENABLED` | on | `false` stops the nightly dump. | -| `DB_BACKUP_DIR` | `/var/lib/healthlog/backups` | Where dumps are written and read. Change it only together with the volume mount. | -| `DB_BACKUP_SCHEDULE` | `30 1 * * *` | Five-field cron, Europe/Berlin time like every HealthLog schedule. | -| `DB_BACKUP_KEEP` | `7` | Nightly dumps kept (at least 1). Two pre-migration dumps are kept besides. | -| `DB_BACKUP_BEFORE_MIGRATE` | on | `false` starts an update without the dump before its migrations. | +| Variable | Default | What it does | +| -------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `DB_BACKUP_ENABLED` | on | `false` stops the nightly dump. | +| `DB_BACKUP_DIR` | `/var/lib/healthlog/backups` | Where dumps are written and read. Change it only together with the volume mount. | +| `DB_BACKUP_SCHEDULE` | `30 1 * * *` | Five-field cron, Europe/Berlin time like every HealthLog schedule. | +| `DB_BACKUP_KEEP` | `7` | Nightly dumps kept (at least 1). Up to two pre-migration dumps are kept besides, none older than the oldest nightly dump except the newest. | +| `DB_BACKUP_BEFORE_MIGRATE` | on | `false` starts an update without the dump before its migrations. | diff --git a/messages/de.json b/messages/de.json index c6e47f7d8..59abc14cf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8344,7 +8344,7 @@ "disabled": "Datenbank-Dumps sind abgeschaltet (DB_BACKUP_ENABLED=false). Sichere die Datenbank auf anderem Weg.", "noDirectoryOffhost": "Dieser Server kann nicht nach {dir} schreiben. Dumps gehen deshalb nur extern, hier wird keiner aufbewahrt. Binde das Backup-Volume ein.", "noDirectory": "Dieser Server kann nicht nach {dir} schreiben und es ist kein externer Speicher eingerichtet. Deshalb entsteht kein Datenbank-Dump. Binde das Backup-Volume ein.", - "retentionLocal": "Gelöschte Konten bleiben in den Dumps, bis diese aussortiert werden: Auf diesem Server bleiben die neuesten {nights}.", + "retentionLocal": "Gelöschte Konten bleiben in den Dumps, bis diese aussortiert werden: Auf diesem Server bleiben die neuesten {nights} nächtlichen Dumps, und der Dump vor dem letzten Update bleibt bis zum nächsten Update.", "retentionOffhost": "Externe Kopien entfernt die Lebenszyklus-Regel des Buckets nach {days} Tagen.", "retentionOffhostMissing": "Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an.", "notPersistentOffhost": "{dir} liegt nicht auf dauerhaftem Speicher (im Container statt auf einem eingebundenen Volume, oder im Arbeitsspeicher). Ein Dump dort wäre beim nächsten Update verloren. Dumps gehen deshalb nur extern: Hier wird keiner aufbewahrt, und vor einer Migration wird keiner erstellt. Binde das Backup-Volume ein.", diff --git a/messages/en.json b/messages/en.json index 54cb92bff..2d683a8cc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8344,7 +8344,7 @@ "disabled": "Database dumps are switched off (DB_BACKUP_ENABLED=false). Back the database up another way.", "noDirectoryOffhost": "This server cannot write to {dir}, so dumps go off-host only and none is kept here. Mount the backups volume.", "noDirectory": "This server cannot write to {dir} and no off-host bucket is set, so no database dump can be taken. Mount the backups volume.", - "retentionLocal": "Deleted accounts stay in the dumps until those age out: the newest {nights} are kept on this server.", + "retentionLocal": "Deleted accounts stay in the dumps until those age out: the newest {nights} nightly dumps are kept on this server, and the dump taken before the latest update stays until the next update.", "retentionOffhost": "Off-host copies are removed after {days} days by the bucket's lifecycle rule.", "retentionOffhostMissing": "No lifecycle rule in the bucket covers database/, so dumps there, and the deleted accounts inside them, are never removed. Add a rule for that prefix.", "notPersistentOffhost": "{dir} is not on persistent storage (it sits inside the container rather than on a mounted volume, or in memory), so a dump written there would be lost at the next update. Dumps go off-host only: none is kept here and none is taken before a migration. Mount the backups volume.", diff --git a/messages/es.json b/messages/es.json index 84df9344c..fcdf787e8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8344,7 +8344,7 @@ "disabled": "Los volcados de la base de datos están desactivados (DB_BACKUP_ENABLED=false). Haz la copia de la base de datos de otra forma.", "noDirectoryOffhost": "Este servidor no puede escribir en {dir}, así que los volcados solo se guardan fuera y aquí no queda ninguno. Monta el volumen de copias.", "noDirectory": "Este servidor no puede escribir en {dir} y no hay almacenamiento externo configurado, así que no se puede hacer ningún volcado. Monta el volumen de copias.", - "retentionLocal": "Las cuentas eliminadas siguen en los volcados hasta que estos caducan: en este servidor se guardan los {nights} más recientes.", + "retentionLocal": "Las cuentas eliminadas siguen en los volcados hasta que estos caducan: en este servidor se guardan los {nights} volcados nocturnos más recientes, y el volcado tomado antes de la última actualización se queda hasta la siguiente.", "retentionOffhost": "La regla de ciclo de vida del bucket elimina las copias externas tras {days} días.", "retentionOffhostMissing": "Ninguna regla de ciclo de vida del bucket cubre database/, así que los volcados de ahí, y las cuentas eliminadas que contienen, nunca se borran. Añade una regla para ese prefijo.", "notPersistentOffhost": "{dir} no está en almacenamiento persistente (está dentro del contenedor y no en un volumen montado, o en memoria), así que un volcado guardado ahí se perdería en la próxima actualización. Los volcados solo se guardan fuera: aquí no queda ninguno y no se hace ninguno antes de una migración. Monta el volumen de copias.", diff --git a/messages/fr.json b/messages/fr.json index 2568f52f8..5fffc02b5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8344,7 +8344,7 @@ "disabled": "Les dumps de la base de données sont désactivés (DB_BACKUP_ENABLED=false). Sauvegardez la base de données autrement.", "noDirectoryOffhost": "Ce serveur ne peut pas écrire dans {dir} : les dumps partent uniquement hors site et aucun n'est conservé ici. Montez le volume de sauvegarde.", "noDirectory": "Ce serveur ne peut pas écrire dans {dir} et aucun stockage hors site n'est configuré : aucun dump ne peut être fait. Montez le volume de sauvegarde.", - "retentionLocal": "Les comptes supprimés restent dans les dumps jusqu'à leur expiration : ce serveur conserve les {nights} plus récents.", + "retentionLocal": "Les comptes supprimés restent dans les dumps jusqu'à leur expiration : ce serveur conserve les {nights} dumps nocturnes les plus récents, et le dump pris avant la dernière mise à jour reste jusqu'à la suivante.", "retentionOffhost": "La règle de cycle de vie du bucket supprime les copies hors site après {days} jours.", "retentionOffhostMissing": "Aucune règle de cycle de vie du bucket ne couvre database/ : les dumps qui s'y trouvent, et les comptes supprimés qu'ils contiennent, ne sont jamais effacés. Ajoutez une règle pour ce préfixe.", "notPersistentOffhost": "{dir} n'est pas sur un stockage persistant (il se trouve dans le conteneur et non sur un volume monté, ou en mémoire) : un dump écrit là serait perdu à la prochaine mise à jour. Les dumps partent uniquement hors site : aucun n'est conservé ici et aucun n'est pris avant une migration. Montez le volume de sauvegarde.", diff --git a/messages/it.json b/messages/it.json index cf5285ada..dcb73877f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8344,7 +8344,7 @@ "disabled": "I dump del database sono disattivati (DB_BACKUP_ENABLED=false). Esegui il backup del database in un altro modo.", "noDirectoryOffhost": "Questo server non può scrivere in {dir}, quindi i dump vanno solo fuori sede e qui non ne resta nessuno. Monta il volume dei backup.", "noDirectory": "Questo server non può scrivere in {dir} e non è configurato alcuno storage esterno, quindi non si può fare alcun dump. Monta il volume dei backup.", - "retentionLocal": "Gli account eliminati restano nei dump finché questi non scadono: su questo server restano i {nights} più recenti.", + "retentionLocal": "Gli account eliminati restano nei dump finché questi non scadono: su questo server restano i {nights} dump notturni più recenti, e il dump fatto prima dell'ultimo aggiornamento resta fino al successivo.", "retentionOffhost": "La regola del ciclo di vita del bucket elimina le copie esterne dopo {days} giorni.", "retentionOffhostMissing": "Nessuna regola del ciclo di vita del bucket copre database/, quindi i dump lì, e gli account eliminati che contengono, non vengono mai rimossi. Aggiungi una regola per quel prefisso.", "notPersistentOffhost": "{dir} non è su uno spazio persistente (si trova dentro il container e non su un volume montato, oppure in memoria), quindi un dump scritto lì andrebbe perso al prossimo aggiornamento. I dump vanno solo fuori sede: qui non ne resta nessuno e prima di una migrazione non ne viene fatto nessuno. Monta il volume dei backup.", diff --git a/messages/ko.json b/messages/ko.json index eb473420e..89dade95b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8319,7 +8319,7 @@ "disabled": "데이터베이스 덤프가 꺼져 있습니다(DB_BACKUP_ENABLED=false). 다른 방법으로 데이터베이스를 백업하세요.", "noDirectoryOffhost": "이 서버는 {dir}에 쓸 수 없어 덤프가 외부 저장소로만 가고 여기에는 보관되지 않습니다. 백업 볼륨을 마운트하세요.", "noDirectory": "이 서버는 {dir}에 쓸 수 없고 외부 저장소도 설정되지 않아 데이터베이스 덤프를 만들 수 없습니다. 백업 볼륨을 마운트하세요.", - "retentionLocal": "삭제된 계정은 덤프가 만료될 때까지 덤프에 남습니다. 이 서버에는 최신 덤프 {nights}개가 보관됩니다.", + "retentionLocal": "삭제된 계정은 덤프가 만료될 때까지 덤프에 남습니다. 이 서버에는 최신 야간 덤프 {nights}개가 보관되고, 마지막 업데이트 전에 만든 덤프는 다음 업데이트까지 남습니다.", "retentionOffhost": "버킷의 수명 주기 규칙이 외부 사본을 {days}일 후에 삭제합니다.", "retentionOffhostMissing": "버킷의 어떤 수명 주기 규칙도 database/를 다루지 않아 그곳의 덤프와 그 안의 삭제된 계정이 절대 삭제되지 않습니다. 이 접두사에 대한 규칙을 추가하세요.", "notPersistentOffhost": "{dir}은(는) 영구 저장소에 있지 않습니다(마운트된 볼륨이 아니라 컨테이너 안이나 메모리에 있음). 그래서 여기에 쓴 덤프는 다음 업데이트 때 사라집니다. 덤프는 외부 저장소로만 가며, 여기에는 보관되지 않고 마이그레이션 전에도 만들어지지 않습니다. 백업 볼륨을 마운트하세요.", diff --git a/messages/nl.json b/messages/nl.json index 953d83e49..085c84208 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8344,7 +8344,7 @@ "disabled": "Databasedumps staan uit (DB_BACKUP_ENABLED=false). Maak op een andere manier een back-up van de database.", "noDirectoryOffhost": "Deze server kan niet schrijven naar {dir}, dus dumps gaan alleen extern en hier wordt er geen bewaard. Koppel het back-upvolume.", "noDirectory": "Deze server kan niet schrijven naar {dir} en er is geen externe opslag ingesteld, dus er kan geen databasedump worden gemaakt. Koppel het back-upvolume.", - "retentionLocal": "Verwijderde accounts blijven in de dumps tot die verlopen: op deze server blijven de nieuwste {nights} bewaard.", + "retentionLocal": "Verwijderde accounts blijven in de dumps tot die verlopen: op deze server blijven de nieuwste {nights} nachtelijke dumps bewaard, en de dump van vóór de laatste update blijft tot de volgende update.", "retentionOffhost": "De levenscyclusregel van de bucket verwijdert externe kopieën na {days} dagen.", "retentionOffhostMissing": "Geen levenscyclusregel in de bucket geldt voor database/, dus dumps daar, en de verwijderde accounts erin, worden nooit verwijderd. Voeg een regel voor dat voorvoegsel toe.", "notPersistentOffhost": "{dir} staat niet op blijvende opslag (in de container in plaats van op een gekoppeld volume, of in het geheugen), dus een dump die daar wordt geschreven gaat bij de volgende update verloren. Dumps gaan alleen extern: hier wordt er geen bewaard en vóór een migratie wordt er geen gemaakt. Koppel het back-upvolume.", diff --git a/messages/pl.json b/messages/pl.json index 3dd9754b5..659d8b920 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8344,7 +8344,7 @@ "disabled": "Zrzuty bazy danych są wyłączone (DB_BACKUP_ENABLED=false). Zrób kopię bazy danych w inny sposób.", "noDirectoryOffhost": "Ten serwer nie może zapisywać w {dir}, więc zrzuty trafiają tylko poza serwer i żaden nie zostaje tutaj. Podłącz wolumin kopii.", "noDirectory": "Ten serwer nie może zapisywać w {dir} i nie ustawiono magazynu zewnętrznego, więc nie da się wykonać zrzutu bazy. Podłącz wolumin kopii.", - "retentionLocal": "Usunięte konta pozostają w zrzutach, dopóki te nie wygasną: na tym serwerze zostaje {nights} najnowszych.", + "retentionLocal": "Usunięte konta pozostają w zrzutach, dopóki te nie wygasną: na tym serwerze zostaje {nights} najnowszych zrzutów nocnych, a zrzut sprzed ostatniej aktualizacji zostaje do następnej.", "retentionOffhost": "Reguła cyklu życia bucketu usuwa kopie zewnętrzne po {days} dniach.", "retentionOffhostMissing": "Żadna reguła cyklu życia bucketu nie obejmuje database/, więc zrzuty tam, wraz z usuniętymi kontami w nich, nigdy nie są usuwane. Dodaj regułę dla tego prefiksu.", "notPersistentOffhost": "{dir} nie leży na trwałym nośniku (jest wewnątrz kontenera zamiast na podłączonym woluminie albo w pamięci), więc zapisany tam zrzut przepadnie przy następnej aktualizacji. Zrzuty trafiają tylko poza serwer: żaden nie zostaje tutaj i żaden nie powstaje przed migracją. Podłącz wolumin kopii.", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 9c6006efd..8c63e5948 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -8344,7 +8344,7 @@ "disabled": "Os dumps do banco de dados estão desligados (DB_BACKUP_ENABLED=false). Faça o backup do banco de outra forma.", "noDirectoryOffhost": "Este servidor não consegue gravar em {dir}, então os dumps vão só para fora e nenhum fica aqui. Monte o volume de backups.", "noDirectory": "Este servidor não consegue gravar em {dir} e não há armazenamento externo configurado, então nenhum dump pode ser feito. Monte o volume de backups.", - "retentionLocal": "Contas excluídas continuam nos dumps até que eles expirem: este servidor guarda os {nights} mais recentes.", + "retentionLocal": "Contas excluídas continuam nos dumps até que eles expirem: este servidor guarda os {nights} dumps noturnos mais recentes, e o dump feito antes da última atualização fica até a próxima.", "retentionOffhost": "A regra de ciclo de vida do bucket remove as cópias externas após {days} dias.", "retentionOffhostMissing": "Nenhuma regra de ciclo de vida do bucket cobre database/, então os dumps ali, e as contas excluídas dentro deles, nunca são removidos. Adicione uma regra para esse prefixo.", "notPersistentOffhost": "{dir} não está em armazenamento persistente (fica dentro do contêiner e não num volume montado, ou na memória), então um dump gravado ali se perde na próxima atualização. Os dumps vão só para fora: nenhum fica aqui e nenhum é feito antes de uma migração. Monte o volume de backups.", diff --git a/scripts/db-dump.mjs b/scripts/db-dump.mjs index d895c7286..f85cf535d 100644 --- a/scripts/db-dump.mjs +++ b/scripts/db-dump.mjs @@ -9,7 +9,7 @@ * this image brings migrations the database has not applied, it first writes * `healthlog--pre-migration.dump` with its manifest into * `DB_BACKUP_DIR` (`src/lib/database-backup/dump-core.mjs`), and keeps the - * newest two. + * newest two, neither older than the oldest nightly dump except the newest. * * Exit 0: the dump was written, or there was nothing to do (no pending * migration, a new database, `DB_BACKUP_BEFORE_MIGRATE=false`), or there is no diff --git a/src/components/admin/backups/__tests__/database-dump-row.test.tsx b/src/components/admin/backups/__tests__/database-dump-row.test.tsx index 3429403e0..e980e821c 100644 --- a/src/components/admin/backups/__tests__/database-dump-row.test.tsx +++ b/src/components/admin/backups/__tests__/database-dump-row.test.tsx @@ -49,7 +49,8 @@ describe("", () => { it("names the newest good dump, where it is, and how long dumps keep deleted accounts", () => { const html = render(HEALTHY); expect(html).toContain("kept on this server and off-host"); - expect(html).toContain("the newest 7 are kept on this server"); + expect(html).toContain("the newest 7 nightly dumps are kept on this server"); + expect(html).toContain("before the latest update stays until the next update"); expect(html).toContain("removed after 30 days"); expect(html).not.toContain('data-slot="database-dump-failed"'); expect(html).not.toContain('data-slot="database-dump-setup"'); diff --git a/src/lib/database-backup/__tests__/dump-core.test.ts b/src/lib/database-backup/__tests__/dump-core.test.ts index 34ee6baa7..15b63c534 100644 --- a/src/lib/database-backup/__tests__/dump-core.test.ts +++ b/src/lib/database-backup/__tests__/dump-core.test.ts @@ -284,10 +284,14 @@ describe("retention", () => { set("pre-migration", "2026-10-20T08:00:00Z"), set("pre-migration", "2026-10-10T08:00:00Z"), set("pre-migration", "2026-10-01T08:00:00Z"), - set("daily", "2026-10-24T01:30:00Z"), + // Nightly dumps back to the 8th, so both newer pre-migration dumps are + // inside the nightly retention. + ...[8, 9, 10, 11, 12, 13, 14].map((d) => + set("daily", `2026-10-${String(d).padStart(2, "0")}T01:30:00Z`), + ), ]; const doomed = selectForDeletion(sets, { - keep: { daily: 1, "pre-migration": 2 }, + keep: { daily: 7, "pre-migration": 2 }, now, }); expect(doomed).toEqual([ @@ -296,6 +300,49 @@ describe("retention", () => { ]); }); + // The retention an operator states for deleted accounts is the nightly + // one. Kept by count alone, an install that updates rarely held its last + // two pre-migration dumps, and every account deleted since, for good. + it("removes a pre-migration dump older than every nightly dump kept, except the newest", () => { + const sets = [ + set("pre-migration", "2026-08-20T08:00:00Z"), + set("pre-migration", "2026-07-10T08:00:00Z"), + set("daily", "2026-10-23T01:30:00Z"), + set("daily", "2026-10-24T01:30:00Z"), + ]; + const doomed = selectForDeletion(sets, { + keep: { daily: 7, "pre-migration": 2 }, + now, + }); + expect(doomed).toEqual([ + "/b/healthlog-20260710T080000Z-pre-migration.dump", + "/b/healthlog-20260710T080000Z-pre-migration.manifest.json", + ]); + // One taken after the oldest nightly kept stays beside the newest. + const recent = [ + set("pre-migration", "2026-10-24T08:00:00Z"), + set("pre-migration", "2026-10-23T08:00:00Z"), + set("daily", "2026-10-23T01:30:00Z"), + ]; + expect( + selectForDeletion(recent, { + keep: { daily: 7, "pre-migration": 2 }, + now, + }), + ).toEqual([]); + // No nightly dump on disk: as many days as nightly dumps are kept. + const alone = [ + set("pre-migration", "2026-10-24T08:00:00Z"), + set("pre-migration", "2026-10-20T08:00:00Z"), + ]; + expect( + selectForDeletion(alone, { keep: { daily: 2, "pre-migration": 2 }, now }), + ).toEqual([ + "/b/healthlog-20261020T080000Z-pre-migration.dump", + "/b/healthlog-20261020T080000Z-pre-migration.manifest.json", + ]); + }); + it("removes a .partial only once it is older than twelve hours", () => { const fresh = set("daily", "2026-10-25T01:30:00Z", { dump: false, diff --git a/src/lib/database-backup/dump-core.mjs b/src/lib/database-backup/dump-core.mjs index 227e707c4..662dc9253 100644 --- a/src/lib/database-backup/dump-core.mjs +++ b/src/lib/database-backup/dump-core.mjs @@ -1136,7 +1136,15 @@ export function isGoodSet(set) { * * Per kind, the newest `keep` good dumps stay (at least one, so the newest * good dump is never removed) and every older good dump goes, archive and - * manifest together. A `.partial` or a manifest without its archive goes once + * manifest together. + * + * Pre-migration dumps are bounded by age as well: one older than the oldest + * nightly dump kept goes, except the newest pre-migration dump, which is the + * way back from the latest update. Kept by count alone, an install that + * updates rarely held its last two pre-migration dumps, and every account + * deleted since, with no limit, while the stated retention of deleted + * accounts was the nightly one. With no nightly dump on disk, the bound is + * as many days as nightly dumps are kept. A `.partial` or a manifest without its archive goes once * it is older than twelve hours, so a run still in progress keeps its files. * A final-named archive without a manifest is not something this module * leaves behind, so it is not touched. Encrypted copies (`.enc`) an operator @@ -1149,17 +1157,30 @@ export function isGoodSet(set) { export function selectForDeletion(sets, options) { /** @type {string[]} */ const remove = []; - for (const kind of DUMP_KINDS) { - const keep = Math.max(1, options.keep[kind] ?? 1); - const good = sets + const goodOf = (/** @type {DumpKind} */ kind) => + sets .filter((s) => s.kind === kind && isGoodSet(s)) .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); - for (const set of good.slice(keep)) { + const dailyKeep = Math.max(1, options.keep.daily ?? 1); + const keptDaily = goodOf("daily").slice(0, dailyKeep); + const preMigrationFloor = + keptDaily.length > 0 + ? keptDaily[keptDaily.length - 1].timestamp.getTime() + : options.now - dailyKeep * 24 * 60 * 60 * 1000; + for (const kind of DUMP_KINDS) { + const keep = Math.max(1, options.keep[kind] ?? 1); + goodOf(kind).forEach((set, index) => { + const stays = + index < keep && + (kind !== "pre-migration" || + index === 0 || + set.timestamp.getTime() >= preMigrationFloor); + if (stays) return; remove.push( /** @type {{ path: string }} */ (set.dump).path, /** @type {string} */ (set.manifest), ); - } + }); } for (const set of sets) { for (const leftover of set.leftovers) {