mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 03:32:21 +03:00
Compare commits
9 Commits
release/v3
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81dd15ff6 | ||
|
|
db6cd9d838 | ||
|
|
b48122b564 | ||
|
|
1c115ca53f | ||
|
|
0be9545312 | ||
|
|
c7b521dfb3 | ||
|
|
a13869364e | ||
|
|
956fe72c08 | ||
|
|
d37a389203 |
@@ -1195,8 +1195,8 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
|
||||
| `TAILSCALED_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscaled` daemon binary. |
|
||||
| `TAILSCALE_AUTHKEY` | _(unset)_ | `src/lib/tailscaleTunnel.ts` | Pre-shared Tailscale auth key for non-interactive / headless `tailscale up` (passed via `--auth-key=`). When unset, login falls back to the interactive browser auth URL. |
|
||||
| `NGROK_AUTHTOKEN` | _(unset)_ | `src/lib/ngrokTunnel.ts` | Authenticates outbound ngrok tunnels. |
|
||||
| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts`, `src/lib/db/migrationRunner.ts` | Maximum SQLite backup files retained on disk. Applies to manual/scheduled backups and to pre-migration snapshots. Overrides the value saved from Settings → Database backup retention. |
|
||||
| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts`, `src/lib/db/migrationRunner.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. Applies to manual/scheduled backups and to pre-migration snapshots. Overrides the value saved from Settings → Database backup retention. |
|
||||
| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. Overrides the value saved from Settings → Database backup retention. |
|
||||
| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. Overrides the value saved from Settings → Database backup retention. |
|
||||
| `OMNIROUTE_BACKUP_SCHEDULE_JOB_INTERVAL_MS` | `30000` | `src/lib/jobs/backupScheduleJob.ts` | Tick interval (ms) of the server-side job that executes `backup-schedule.json`. Must stay well under the 1-minute cron granularity; values below `5000` or unparseable fall back to `30000`. |
|
||||
| `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. |
|
||||
| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` for any Podman topology. Because the container cannot determine whether the engine is local or reached through Podman Machine, the warning stays topology-neutral and points to `contrib/podman/README.md`. |
|
||||
|
||||
@@ -117,21 +117,6 @@ export const OUTPUT_STYLE_CATALOG: Record<string, OutputStyle> = {
|
||||
full: `# Eu tenho TDAH — saída action-first\n\nO leitor tem TDAH. Molde a saída para que um cérebro TDAH consiga agir sobre ela:\n1. Comece pela próxima ação — comando, path ou snippet primeiro; contexto depois, se necessário.\n2. Numere trabalho multi-passo; cada passo é uma ação delimitada; use o menor número de passos que funcione.\n3. Termine com UMA próxima ação concreta executável em menos de dois minutos.\n4. Suprima tangentes: termine a primeira questão, ofereça a segunda como pergunta separada.\n5. Em trabalho multi-turno, reafirme onde as coisas estão ("passo 3 de 5 feito") — o leitor não guarda estado entre mensagens.\n6. Quando houver esforço humano, estime em unidades concretas (minutos, uma tarde), nunca "um pouco de trabalho".\n7. Torne vitórias visíveis: diga o que funciona agora e como testar.\n8. Erros de forma direta: causa e fix; nunca "Opa!".\n9. Listas com no máximo 5 itens; acima disso, divida em "agora" vs "depois".\n10. Sem preâmbulo, sem recap, sem despedidas ("Espero ter ajudado").\nExceções: pedido explícito de "explique" recebe corpo completo (ainda sem preâmbulo/despedida); ações destrutivas recebem confirmação antes; ambiguidade real recebe uma pergunta curta de esclarecimento. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Eu tenho TDAH (ultra)\nAção primeiro: comando/path/snippet, prosa depois se precisar. Passos numerados e delimitados, o mínimo que funcione. UMA próxima ação <2 min no fim. Sem tangentes — pergunta separada. Multi-turno: reafirme o estado. Esforço humano: unidades concretas de tempo. Vitórias visíveis. Erros: causa + fix. Listas ≤5. Zero preâmbulo/recap/despedidas. "Explique" recebe corpo completo; ação destrutiva recebe confirmação; ambiguidade real recebe uma pergunta. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
vi: {
|
||||
lite: `# Tôi bị ADHD (rút gọn)\nBắt đầu bằng hành động: lệnh, đường dẫn hoặc đoạn mã trước, văn xuôi sau. Đánh số công việc nhiều bước; mỗi bước là một hành động giới hạn. Kết thúc bằng MỘT hành động cụ thể tiếp theo. Không mở đầu, không tóm tắt lại, không lời chào cuối. ${SHARED_BOUNDARIES}`,
|
||||
full: `# Tôi bị ADHD — đầu ra ưu tiên hành động\n\nNgười đọc bị ADHD. Hãy định hình đầu ra để một bộ não ADHD có thể hành động ngay:\n1. Mở đầu bằng hành động kế tiếp — lệnh, đường dẫn hoặc đoạn mã trước; ngữ cảnh sau, nếu cần.\n2. Đánh số công việc nhiều bước; mỗi bước là một hành động giới hạn; dùng ít bước nhất mà vẫn chạy được.\n3. Kết thúc bằng MỘT hành động cụ thể làm được dưới hai phút.\n4. Chặn lạc đề: xong việc thứ nhất, việc thứ hai đưa ra thành câu hỏi riêng.\n5. Trong công việc nhiều lượt, nhắc lại đang ở đâu ("xong bước 3 trên 5") — người đọc không giữ trạng thái giữa các tin nhắn.\n6. Khi có công sức của con người, ước lượng bằng đơn vị cụ thể (phút, một buổi chiều), không bao giờ nói "hơi tốn công".\n7. Cho thấy kết quả: nói rõ cái gì đã chạy được và thử thế nào.\n8. Báo lỗi thẳng thắn: nguyên nhân và cách sửa; không "Ôi không".\n9. Danh sách tối đa 5 mục; nhiều hơn thì tách "làm ngay" và "để sau".\n10. Không mở đầu, không tóm tắt lại, không lời chào cuối ("Hy vọng giúp ích").\nNgoại lệ: yêu cầu "giải thích" thì viết đầy đủ (vẫn không mở đầu/chào cuối); hành động phá huỷ phải xác nhận trước; mơ hồ thật sự thì hỏi một câu ngắn. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Tôi bị ADHD (siêu gọn)\nHành động trước: lệnh/đường dẫn/đoạn mã, văn xuôi sau nếu cần. Bước đánh số, giới hạn, ít nhất có thể. MỘT hành động <2 phút ở cuối. Không lạc đề — hỏi riêng. Nhiều lượt: nhắc lại trạng thái. Công sức người: đơn vị thời gian cụ thể. Kết quả rõ ràng. Lỗi: nguyên nhân + cách sửa. Danh sách ≤5. Không mở đầu/tóm tắt/chào cuối. "Giải thích" thì viết đầy đủ; hành động phá huỷ phải xác nhận; mơ hồ thật thì hỏi một câu. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
ja: {
|
||||
lite: `# ADHDです(軽量)\n行動から始める:コマンド、パス、スニペットを先に、散文は後。複数手順は番号付き;各手順は一つの区切られた行動。最後は具体的な次の行動を一つ。前置きなし、要約の繰り返しなし、締めの挨拶なし。${SHARED_BOUNDARIES}`,
|
||||
full: `# ADHDです — 行動優先の出力\n\n読み手はADHDです。ADHDの脳が動けるように出力を整えること:\n1. 次の行動から始める — コマンド、パス、スニペットを先に;文脈は必要なら後。\n2. 複数手順は番号付き;各手順は一つの区切られた行動;動く最小の手順数で。\n3. 最後は2分以内でできる具体的な次の行動を一つ。\n4. 脱線を抑える:最初の件を終えてから、二件目は別の質問として出す。\n5. 複数ターンの作業では現在地を言い直す(「5つ中3つ完了」)— 読み手はメッセージ間で状態を保持できない。\n6. 人手がかかる場合は具体的な単位で見積もる(分、半日)。「少し手間」は禁止。\n7. 成果を見せる:今何が動くか、どう試すかを述べる。\n8. エラーは淡々と:原因と対処;「おっと」は禁止。\n9. リストは5項目まで;超えるなら「今やる」と「後で」に分ける。\n10. 前置きなし、要約の繰り返しなし、締めの挨拶なし(「お役に立てば幸いです」)。\n例外:明示的な「説明して」には本文を十分に書く(前置き・締めはなし);破壊的操作は先に確認;本当に曖昧なら短い確認質問を一つ。${SHARED_BOUNDARIES}`,
|
||||
ultra: `# ADHDです(超軽量)\n行動優先:コマンド/パス/スニペット、必要なら散文。番号付きの区切られた手順、動く最小限。最後に2分未満の次の行動を一つ。脱線なし — 別の質問へ。複数ターン:状態を言い直す。人手:具体的な時間単位。成果を明示。エラー:原因+対処。リストは5まで。前置き/要約/締めの挨拶はゼロ。「説明して」には本文を十分に;破壊的操作は確認;本当の曖昧さには質問を一つ。${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
id: {
|
||||
lite: `# Saya punya ADHD (ringkas)\nMulai dari aksi: perintah, path, atau cuplikan kode dulu, prosa belakangan. Beri nomor untuk pekerjaan banyak langkah; tiap langkah satu aksi yang terbatas. Akhiri dengan SATU langkah berikutnya yang konkret. Tanpa pembuka, tanpa rekap, tanpa basa-basi penutup. ${SHARED_BOUNDARIES}`,
|
||||
full: `# Saya punya ADHD — keluaran yang mengutamakan aksi\n\nPembaca punya ADHD. Bentuk keluaran supaya otak ADHD bisa langsung bertindak:\n1. Mulai dari aksi berikutnya — perintah, path, atau cuplikan kode dulu; konteks belakangan, kalau perlu.\n2. Beri nomor untuk pekerjaan banyak langkah; tiap langkah satu aksi terbatas; pakai langkah sesedikit mungkin yang tetap jalan.\n3. Akhiri dengan SATU langkah konkret yang bisa dikerjakan di bawah dua menit.\n4. Tahan bahasan sampingan: selesaikan yang pertama, tawarkan yang kedua sebagai pertanyaan terpisah.\n5. Pada pekerjaan banyak giliran, ulangi posisi saat ini ("langkah 3 dari 5 selesai") — pembaca tidak menyimpan status antar pesan.\n6. Kalau ada usaha manusia, perkirakan dalam satuan konkret (menit, satu sore), jangan "agak butuh kerja".\n7. Tunjukkan hasil: sebutkan apa yang sekarang jalan dan cara mencobanya.\n8. Error apa adanya: sebab dan perbaikannya; jangan "Waduh".\n9. Daftar maksimal 5 butir; lebih dari itu pisahkan "sekarang" dan "nanti".\n10. Tanpa pembuka, tanpa rekap, tanpa basa-basi penutup ("Semoga membantu").\nPengecualian: permintaan eksplisit "jelaskan" dapat isi penuh (tetap tanpa pembuka/penutup); aksi merusak dikonfirmasi dulu; ambiguitas nyata dapat satu pertanyaan singkat. ${SHARED_BOUNDARIES}`,
|
||||
ultra: `# Saya punya ADHD (ultra)\nAksi dulu: perintah/path/cuplikan, prosa kalau perlu. Langkah bernomor dan terbatas, sesedikit mungkin. SATU langkah <2 menit di akhir. Tanpa bahasan sampingan — jadikan pertanyaan terpisah. Banyak giliran: ulangi status. Usaha manusia: satuan waktu konkret. Hasil terlihat. Error: sebab + perbaikan. Daftar ≤5. Nol pembuka/rekap/penutup. "Jelaskan" dapat isi penuh; aksi merusak dikonfirmasi; ambiguitas nyata dapat satu pertanyaan. ${SHARED_BOUNDARIES}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
"terse-cjk": {
|
||||
|
||||
@@ -42,7 +42,6 @@ export const INTENTIONALLY_INTERNAL = new Set([
|
||||
"accessTokens", // intentionally-internal: 4 rotas /api/cli/* (connect, whoami, tokens, tokens/[id]) + server/authz/accessTokenAuth.ts via import direto "@/lib/db/accessTokens" (Rule #2)
|
||||
"apiKeyColumnFallbacks", // db-internal: importado só por db/apiKeys.ts (API_KEY_COLUMN_FALLBACKS — fallbacks de coluna split do apiKeys.ts)
|
||||
"apiKeyUsageLimitFields", // db-internal: importado só por db/apiKeys.ts (helpers de campo de limite de uso split do apiKeys.ts; mig 101)
|
||||
"backupRetention", // db-internal: importado só por db/backup.ts e db/migrationRunner.ts (política de retenção compartilhada; mora fora de backup.ts porque core.ts importa migrationRunner.ts — importar backup.ts de lá fecharia um ciclo, #10421)
|
||||
"caseMapping", // db-internal: importado só por db/core.ts (toSnakeCase/toCamelCase/objToSnake — column-mapping snake↔camel split do core.ts, #4947)
|
||||
"cleanup", // intentionally-internal: 3 API routes (purge-quota-snapshots, purge-call-logs, purge-detailed-logs)
|
||||
"cliToolState", // intentionally-internal: 14+ API routes em /api/cli-tools/*-settings
|
||||
|
||||
@@ -14,13 +14,6 @@ import {
|
||||
DATA_DIR,
|
||||
} from "./core";
|
||||
import { resetAllDbModuleState } from "./stateReset";
|
||||
import {
|
||||
MAX_DB_BACKUPS,
|
||||
DEFAULT_DB_BACKUP_RETENTION_DAYS,
|
||||
parsePositiveInt,
|
||||
parseNonNegativeInt,
|
||||
pruneBackupDirectory,
|
||||
} from "./backupRetention";
|
||||
import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
|
||||
|
||||
type CountRow = { cnt?: number };
|
||||
@@ -29,8 +22,22 @@ type CountRow = { cnt?: number };
|
||||
|
||||
let _lastBackupAt = 0;
|
||||
const BACKUP_THROTTLE_MS = 60 * 60 * 1000; // 60 minutes
|
||||
const MAX_DB_BACKUPS = 20;
|
||||
const DEFAULT_DB_BACKUP_RETENTION_DAYS = 0;
|
||||
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number) {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function parseNonNegativeInt(value: string | undefined, fallback: number) {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
// #3834: the "Keep latest backups" UI value is persisted here so it survives a page
|
||||
// refresh / the loadStorageHealth() refetch. A dedicated namespace avoids any
|
||||
// cross-talk with the databaseSettings key_value store (which rewrites all of its own
|
||||
@@ -101,16 +108,108 @@ function getBackupDir() {
|
||||
return DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
|
||||
}
|
||||
|
||||
export function cleanupDbBackups(options?: {
|
||||
maxFiles?: number;
|
||||
retentionDays?: number;
|
||||
backupDir?: string;
|
||||
}) {
|
||||
const backupDir = options?.backupDir ?? getBackupDir();
|
||||
const maxFiles = options?.maxFiles ?? getDbBackupMaxFiles();
|
||||
const retentionDays = options?.retentionDays ?? getDbBackupRetentionDays();
|
||||
function getBackupFamilyBase(filename: string) {
|
||||
if (filename.endsWith("-wal") || filename.endsWith("-shm")) return filename.slice(0, -4);
|
||||
if (filename.endsWith("-journal")) return filename.slice(0, -8);
|
||||
return filename;
|
||||
}
|
||||
|
||||
return pruneBackupDirectory({ backupDir, maxFiles, retentionDays });
|
||||
function collectBackupFamilies(backupDir: string) {
|
||||
if (!fs.existsSync(backupDir)) return [];
|
||||
|
||||
const families = new Map<
|
||||
string,
|
||||
{
|
||||
base: string;
|
||||
hasPrimary: boolean;
|
||||
primaryMtimeMs: number;
|
||||
latestMtimeMs: number;
|
||||
files: string[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const name of fs.readdirSync(backupDir)) {
|
||||
if (!name.startsWith("db_")) continue;
|
||||
const base = getBackupFamilyBase(name);
|
||||
const filePath = path.join(backupDir, name);
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const family = families.get(base) || {
|
||||
base,
|
||||
hasPrimary: false,
|
||||
primaryMtimeMs: 0,
|
||||
latestMtimeMs: 0,
|
||||
files: [],
|
||||
};
|
||||
|
||||
family.files.push(name);
|
||||
family.latestMtimeMs = Math.max(family.latestMtimeMs, stat.mtimeMs);
|
||||
if (name === base && name.endsWith(".sqlite")) {
|
||||
family.hasPrimary = true;
|
||||
family.primaryMtimeMs = stat.mtimeMs;
|
||||
}
|
||||
|
||||
families.set(base, family);
|
||||
}
|
||||
|
||||
return [...families.values()];
|
||||
}
|
||||
|
||||
export function cleanupDbBackups(options?: { maxFiles?: number; retentionDays?: number }) {
|
||||
const backupDir = getBackupDir();
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
return {
|
||||
deletedBackupFamilies: 0,
|
||||
deletedFiles: 0,
|
||||
keptBackupFamilies: 0,
|
||||
maxFiles: options?.maxFiles ?? getDbBackupMaxFiles(),
|
||||
retentionDays: options?.retentionDays ?? getDbBackupRetentionDays(),
|
||||
};
|
||||
}
|
||||
|
||||
const maxFiles = Math.max(1, options?.maxFiles ?? getDbBackupMaxFiles());
|
||||
const retentionDays = Math.max(0, options?.retentionDays ?? getDbBackupRetentionDays());
|
||||
const cutoffMs = retentionDays > 0 ? Date.now() - retentionDays * 24 * 60 * 60 * 1000 : 0;
|
||||
const families = collectBackupFamilies(backupDir);
|
||||
const primaryFamilies = families
|
||||
.filter((family) => family.hasPrimary)
|
||||
.sort((a, b) => b.primaryMtimeMs - a.primaryMtimeMs);
|
||||
const keepPrimaryBases = new Set(primaryFamilies.slice(0, maxFiles).map((family) => family.base));
|
||||
|
||||
let deletedBackupFamilies = 0;
|
||||
let deletedFiles = 0;
|
||||
|
||||
for (const family of families) {
|
||||
const isOverflowPrimary = family.hasPrimary && !keepPrimaryBases.has(family.base);
|
||||
const isExpired = retentionDays > 0 && family.latestMtimeMs < cutoffMs;
|
||||
const isOrphan = !family.hasPrimary;
|
||||
if (!isOverflowPrimary && !isExpired && !isOrphan) continue;
|
||||
|
||||
deletedBackupFamilies += 1;
|
||||
for (const name of family.files) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(backupDir, name));
|
||||
deletedFiles += 1;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deletedBackupFamilies,
|
||||
deletedFiles,
|
||||
keptBackupFamilies: collectBackupFamilies(backupDir).filter((family) => family.hasPrimary)
|
||||
.length,
|
||||
maxFiles,
|
||||
retentionDays,
|
||||
};
|
||||
}
|
||||
|
||||
function coerceBoolean(value: unknown): boolean | null {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* Backup retention primitives — pure filesystem work, no `core.ts` dependency.
|
||||
*
|
||||
* This module exists so BOTH backup call sites can share one retention policy:
|
||||
*
|
||||
* - `backup.ts` (manual/API/auto backups) — resolves the operator's settings from the
|
||||
* database and delegates here.
|
||||
* - `migrationRunner.ts` (pre-migration snapshots) — cannot import `backup.ts`, because
|
||||
* `core.ts` already imports `migrationRunner.ts` and `backup.ts` imports `core.ts`;
|
||||
* that edge would close a cycle. Keeping the policy here, free of `core`, lets the
|
||||
* migration path prune without one.
|
||||
*
|
||||
* Before #10421 the migration path had no retention at all and `db_backups/` grew
|
||||
* without bound (observed: 48.999 files / 204 GB against a 5,3 MB live database).
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export const MAX_DB_BACKUPS = 20;
|
||||
export const DEFAULT_DB_BACKUP_RETENTION_DAYS = 0;
|
||||
|
||||
export function parsePositiveInt(value: string | undefined, fallback: number) {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function parseNonNegativeInt(value: string | undefined, fallback: number) {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* A backup "family" is the primary `.sqlite` file plus its SQLite sidecars
|
||||
* (`-wal` / `-shm` / `-journal`). Retention operates on families so a sidecar is never
|
||||
* orphaned from — or outlives — the snapshot it belongs to.
|
||||
*/
|
||||
export function getBackupFamilyBase(filename: string) {
|
||||
if (filename.endsWith("-wal") || filename.endsWith("-shm")) return filename.slice(0, -4);
|
||||
if (filename.endsWith("-journal")) return filename.slice(0, -8);
|
||||
return filename;
|
||||
}
|
||||
|
||||
export type BackupFamily = {
|
||||
base: string;
|
||||
hasPrimary: boolean;
|
||||
primaryMtimeMs: number;
|
||||
latestMtimeMs: number;
|
||||
files: string[];
|
||||
};
|
||||
|
||||
export function collectBackupFamilies(backupDir: string): BackupFamily[] {
|
||||
if (!fs.existsSync(backupDir)) return [];
|
||||
|
||||
const families = new Map<string, BackupFamily>();
|
||||
|
||||
for (const name of fs.readdirSync(backupDir)) {
|
||||
if (!name.startsWith("db_")) continue;
|
||||
const base = getBackupFamilyBase(name);
|
||||
const filePath = path.join(backupDir, name);
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const family = families.get(base) || {
|
||||
base,
|
||||
hasPrimary: false,
|
||||
primaryMtimeMs: 0,
|
||||
latestMtimeMs: 0,
|
||||
files: [],
|
||||
};
|
||||
|
||||
family.files.push(name);
|
||||
family.latestMtimeMs = Math.max(family.latestMtimeMs, stat.mtimeMs);
|
||||
if (name === base && name.endsWith(".sqlite")) {
|
||||
family.hasPrimary = true;
|
||||
family.primaryMtimeMs = stat.mtimeMs;
|
||||
}
|
||||
|
||||
families.set(base, family);
|
||||
}
|
||||
|
||||
return [...families.values()];
|
||||
}
|
||||
|
||||
export type PruneResult = {
|
||||
deletedBackupFamilies: number;
|
||||
deletedFiles: number;
|
||||
keptBackupFamilies: number;
|
||||
maxFiles: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete backup families beyond `maxFiles` (newest kept), older than `retentionDays`
|
||||
* (0 disables the age rule), or orphaned (sidecars whose primary is already gone).
|
||||
*/
|
||||
export function pruneBackupDirectory(options: {
|
||||
backupDir: string;
|
||||
maxFiles: number;
|
||||
retentionDays: number;
|
||||
}): PruneResult {
|
||||
const { backupDir } = options;
|
||||
const maxFiles = Math.max(1, options.maxFiles);
|
||||
const retentionDays = Math.max(0, options.retentionDays);
|
||||
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
return {
|
||||
deletedBackupFamilies: 0,
|
||||
deletedFiles: 0,
|
||||
keptBackupFamilies: 0,
|
||||
maxFiles,
|
||||
retentionDays,
|
||||
};
|
||||
}
|
||||
|
||||
const cutoffMs = retentionDays > 0 ? Date.now() - retentionDays * 24 * 60 * 60 * 1000 : 0;
|
||||
const families = collectBackupFamilies(backupDir);
|
||||
const primaryFamilies = families
|
||||
.filter((family) => family.hasPrimary)
|
||||
.sort((a, b) => b.primaryMtimeMs - a.primaryMtimeMs);
|
||||
const keepPrimaryBases = new Set(primaryFamilies.slice(0, maxFiles).map((family) => family.base));
|
||||
|
||||
let deletedBackupFamilies = 0;
|
||||
let deletedFiles = 0;
|
||||
|
||||
for (const family of families) {
|
||||
const isOverflowPrimary = family.hasPrimary && !keepPrimaryBases.has(family.base);
|
||||
const isExpired = retentionDays > 0 && family.latestMtimeMs < cutoffMs;
|
||||
const isOrphan = !family.hasPrimary;
|
||||
if (!isOverflowPrimary && !isExpired && !isOrphan) continue;
|
||||
|
||||
deletedBackupFamilies += 1;
|
||||
for (const name of family.files) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(backupDir, name));
|
||||
deletedFiles += 1;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deletedBackupFamilies,
|
||||
deletedFiles,
|
||||
keptBackupFamilies: collectBackupFamilies(backupDir).filter((family) => family.hasPrimary)
|
||||
.length,
|
||||
maxFiles,
|
||||
retentionDays,
|
||||
};
|
||||
}
|
||||
@@ -29,15 +29,6 @@ import {
|
||||
OPTIONAL_FTS5_MIGRATION_VERSIONS,
|
||||
} from "./migrationRunner/constants";
|
||||
import { getExtraMigrationFiles } from "./migrationRunner/extraDirs";
|
||||
// Retention primitives live in their own `core`-free module: `core.ts` imports this file,
|
||||
// so importing `backup.ts` (which imports `core.ts`) here would close a dependency cycle.
|
||||
import {
|
||||
MAX_DB_BACKUPS,
|
||||
DEFAULT_DB_BACKUP_RETENTION_DAYS,
|
||||
parsePositiveInt,
|
||||
parseNonNegativeInt,
|
||||
pruneBackupDirectory,
|
||||
} from "./backupRetention";
|
||||
|
||||
const isNodeTestRunnerChild = typeof process.env.NODE_TEST_CONTEXT === "string";
|
||||
|
||||
@@ -814,56 +805,6 @@ function rehomeLegacyVersionSlotMigrations(
|
||||
return repaired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a persisted `dbBackup` retention setting through the adapter that is ALREADY open
|
||||
* for this migration run.
|
||||
*
|
||||
* `backup.ts`'s equivalent goes through `getDbInstance()`, which is unsafe here: this
|
||||
* code runs from inside database initialization, so asking for the singleton would
|
||||
* re-enter it. Reading off `db` keeps the same stored values without that risk. A DB too
|
||||
* old to have `key_value` yet simply falls back to the default.
|
||||
*/
|
||||
function readStoredBackupSetting(db: SqliteAdapter, key: string, min: number): number | undefined {
|
||||
try {
|
||||
const row = db
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
|
||||
.get("dbBackup", key) as { value?: string } | undefined;
|
||||
if (!row?.value) return undefined;
|
||||
const parsed = JSON.parse(row.value);
|
||||
return Number.isInteger(parsed) && parsed >= min ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the backup retention budget after a pre-migration snapshot (#10421).
|
||||
*
|
||||
* Precedence matches `backup.ts`: env override → persisted operator setting → default.
|
||||
* Never throws: a migration must not fail because housekeeping did.
|
||||
*/
|
||||
function pruneMigrationBackups(db: SqliteAdapter, backupDir: string): void {
|
||||
try {
|
||||
const maxFiles = process.env.DB_BACKUP_MAX_FILES
|
||||
? parsePositiveInt(process.env.DB_BACKUP_MAX_FILES, MAX_DB_BACKUPS)
|
||||
: (readStoredBackupSetting(db, "maxFiles", 1) ?? MAX_DB_BACKUPS);
|
||||
const retentionDays = process.env.DB_BACKUP_RETENTION_DAYS
|
||||
? parseNonNegativeInt(process.env.DB_BACKUP_RETENTION_DAYS, DEFAULT_DB_BACKUP_RETENTION_DAYS)
|
||||
: (readStoredBackupSetting(db, "retentionDays", 0) ?? DEFAULT_DB_BACKUP_RETENTION_DAYS);
|
||||
|
||||
const result = pruneBackupDirectory({ backupDir, maxFiles, retentionDays });
|
||||
if (result.deletedFiles > 0) {
|
||||
console.log(
|
||||
`[Migration] Pruned ${result.deletedFiles} old backup file(s) ` +
|
||||
`(${result.keptBackupFamilies} kept, maxFiles=${maxFiles}, retentionDays=${retentionDays}).`
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[Migration] Failed to prune old backups: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pre-migration backup of the SQLite database using VACUUM INTO.
|
||||
* Returns the backup path on success, null on failure.
|
||||
@@ -884,12 +825,6 @@ function createPreMigrationBackup(db: SqliteAdapter): string | null {
|
||||
|
||||
db.exec(`VACUUM INTO '${escapedBackupPath}'`);
|
||||
console.log(`[Migration] Pre-migration backup created: ${backupPath}`);
|
||||
|
||||
// #10421: apply the operator's retention budget right here. Without this the
|
||||
// migration path was the one backup producer that never pruned, so every process
|
||||
// start with a pending migration added ~5 MB forever (observed: 49k files / 204 GB).
|
||||
pruneMigrationBackups(db, backupDir);
|
||||
|
||||
return backupPath;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -121,13 +121,12 @@ test("INTENTIONALLY_INTERNAL is exported from check-db-rules.mjs", () => {
|
||||
assert.ok(INTENTIONALLY_INTERNAL.size > 0, "INTENTIONALLY_INTERNAL must not be empty");
|
||||
});
|
||||
|
||||
test("INTENTIONALLY_INTERNAL contains the expected 40 audited modules", () => {
|
||||
test("INTENTIONALLY_INTERNAL contains the expected 39 audited modules", () => {
|
||||
const expected = [
|
||||
"_rowTypes",
|
||||
"accessTokens",
|
||||
"apiKeyColumnFallbacks",
|
||||
"apiKeyUsageLimitFields",
|
||||
"backupRetention",
|
||||
"caseMapping",
|
||||
"cleanup",
|
||||
"cliToolState",
|
||||
|
||||
@@ -70,37 +70,6 @@ describe("i-have-adhd output style", () => {
|
||||
assert.ok(/preâmbulo/.test(pt.full), "pt-BR.full mentions preâmbulo");
|
||||
});
|
||||
|
||||
it("has i18n maps for pt-BR, vi, ja and id (parity with ponytail)", () => {
|
||||
assert.ok(ADHD.i18n, "i18n must be defined");
|
||||
for (const lang of ["pt-BR", "vi", "ja", "id"]) {
|
||||
const levels = ADHD.i18n[lang];
|
||||
assert.ok(levels, `${lang} must exist`);
|
||||
assertString(levels.lite, `${lang}.lite`);
|
||||
assertString(levels.full, `${lang}.full`);
|
||||
assertString(levels.ultra, `${lang}.ultra`);
|
||||
assert.ok(
|
||||
levels.full.includes("Code blocks"),
|
||||
`${lang}.full must keep the shared boundaries clause`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("each translation is written in its own language, not copied English", () => {
|
||||
// Native-script / native-word anchors: a level that merely duplicated the
|
||||
// English text would pass the structural checks above but fail here.
|
||||
const anchors: Record<string, RegExp> = {
|
||||
vi: /hành động/, // "action"
|
||||
ja: /[-ヿ]/, // kana — Japanese-exclusive
|
||||
id: /aksi|langkah/, // "action" / "step"
|
||||
};
|
||||
for (const [lang, pattern] of Object.entries(anchors)) {
|
||||
const levels = ADHD.i18n?.[lang];
|
||||
assert.ok(levels, `${lang} i18n must exist`);
|
||||
assert.ok(pattern.test(levels.full), `${lang}.full must use ${lang} vocabulary`);
|
||||
assert.ok(pattern.test(levels.ultra), `${lang}.ultra must use ${lang} vocabulary`);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries no locale gate", () => {
|
||||
assert.equal(outputStyleMeta("i-have-adhd").locale, undefined);
|
||||
});
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Guard for the output-style × language matrix.
|
||||
*
|
||||
* Why this exists: the compression INPUT engines understand 10 languages (rule
|
||||
* packs under open-sse/services/compression/rules/ + the detector), but the
|
||||
* OUTPUT styles only instruct in a subset. `less-code` shipped English-only and
|
||||
* nobody noticed for months, because every other test is per-style. This test
|
||||
* is per-MATRIX: it pins the expected coverage so a new style cannot silently
|
||||
* be born English-only, and so an existing style cannot silently lose a locale.
|
||||
*
|
||||
* Adding a language to a style: extend BASELINE_LANGUAGES below (the assertion
|
||||
* is "at least these", so growth never fails the gate).
|
||||
* Adding a NEW style: it must cover REQUIRED_LANGUAGES, or be listed in
|
||||
* KNOWN_ENGLISH_ONLY with a tracking issue.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
OUTPUT_STYLE_CATALOG,
|
||||
OUTPUT_STYLE_IDS,
|
||||
outputStyleMeta,
|
||||
} from "../../../open-sse/services/compression/outputStyles/catalog.ts";
|
||||
|
||||
/** Minimum i18n coverage every new non-locale-gated style must ship with. */
|
||||
const REQUIRED_LANGUAGES = ["pt-BR"];
|
||||
|
||||
/**
|
||||
* Styles that predate this guard and are still English-only.
|
||||
* Do NOT add entries here without an issue — fix the coverage instead.
|
||||
*/
|
||||
const KNOWN_ENGLISH_ONLY: Record<string, string> = {
|
||||
// 9router port that never got translated. Tracked in the compression i18n
|
||||
// backlog; the fix is mechanical (same shape as ponytail/i-have-adhd).
|
||||
"less-code": "pre-existing gap — English-only since the 9router port",
|
||||
};
|
||||
|
||||
/**
|
||||
* Frozen per-style coverage. The assertion is a SUPERSET check, so adding a
|
||||
* language is always allowed; removing one fails the gate.
|
||||
*/
|
||||
const BASELINE_LANGUAGES: Record<string, string[]> = {
|
||||
// terse-prose reuses CAVEMAN_INSTRUCTION_BY_LANGUAGE (outputMode.ts), which
|
||||
// localizes to pt-BR/ja/id — keep the two in sync when adding a language.
|
||||
"terse-prose": ["pt-BR", "ja", "id"],
|
||||
"less-code": [],
|
||||
ponytail: ["pt-BR", "vi", "ja", "id"],
|
||||
"i-have-adhd": ["pt-BR", "vi", "ja", "id"],
|
||||
// locale-gated to zh: the single-language instruction IS the feature.
|
||||
"terse-cjk": [],
|
||||
};
|
||||
|
||||
function languagesOf(id: string): string[] {
|
||||
return Object.keys(outputStyleMeta(id).i18n ?? {});
|
||||
}
|
||||
|
||||
test("every catalog style is covered by the matrix baseline", () => {
|
||||
for (const id of OUTPUT_STYLE_IDS) {
|
||||
assert.ok(
|
||||
id in BASELINE_LANGUAGES,
|
||||
`style "${id}" is missing from BASELINE_LANGUAGES — add its expected languages ` +
|
||||
`(and translate it: a new style must cover ${REQUIRED_LANGUAGES.join(", ")})`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("no style loses a language it already had", () => {
|
||||
for (const [id, expected] of Object.entries(BASELINE_LANGUAGES)) {
|
||||
if (!OUTPUT_STYLE_CATALOG[id]) continue; // style removed — covered by the catalog tests
|
||||
const actual = languagesOf(id);
|
||||
for (const lang of expected) {
|
||||
assert.ok(
|
||||
actual.includes(lang),
|
||||
`style "${id}" lost its "${lang}" translation (has: ${actual.join(", ") || "none"})`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a non-locale-gated style ships the required languages, or is a known gap", () => {
|
||||
for (const id of OUTPUT_STYLE_IDS) {
|
||||
const meta = outputStyleMeta(id);
|
||||
// A locale-gated style is only ever offered under its own locale, so a
|
||||
// single-language instruction is correct by design (e.g. terse-cjk → zh).
|
||||
if (meta.locale) continue;
|
||||
if (id in KNOWN_ENGLISH_ONLY) continue;
|
||||
const actual = languagesOf(id);
|
||||
for (const lang of REQUIRED_LANGUAGES) {
|
||||
assert.ok(
|
||||
actual.includes(lang),
|
||||
`style "${id}" must ship a "${lang}" translation (has: ${actual.join(", ") || "none"}). ` +
|
||||
`English-only styles need an entry in KNOWN_ENGLISH_ONLY with a tracking issue.`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("every declared translation has all three intensity levels", () => {
|
||||
for (const id of OUTPUT_STYLE_IDS) {
|
||||
const i18n = outputStyleMeta(id).i18n ?? {};
|
||||
for (const [lang, levels] of Object.entries(i18n)) {
|
||||
for (const level of ["lite", "full", "ultra"] as const) {
|
||||
assert.equal(
|
||||
typeof levels[level],
|
||||
"string",
|
||||
`${id}.i18n["${lang}"].${level} must be a string`
|
||||
);
|
||||
assert.ok(levels[level].length > 0, `${id}.i18n["${lang}"].${level} must be non-empty`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("every translated level carries the shared boundaries clause", () => {
|
||||
// The boundary clause is what keeps code, paths, commands, errors and URLs
|
||||
// verbatim. A translation that drops it would let the model rewrite them.
|
||||
const anchor = "Code blocks";
|
||||
for (const id of OUTPUT_STYLE_IDS) {
|
||||
const i18n = outputStyleMeta(id).i18n ?? {};
|
||||
for (const [lang, levels] of Object.entries(i18n)) {
|
||||
for (const level of ["lite", "full", "ultra"] as const) {
|
||||
assert.ok(
|
||||
levels[level].includes(anchor),
|
||||
`${id}.i18n["${lang}"].${level} is missing the shared boundaries clause`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("KNOWN_ENGLISH_ONLY does not hide a style that is actually translated", () => {
|
||||
// Stale-allowlist guard: once a gap is fixed, its entry must be removed.
|
||||
for (const id of Object.keys(KNOWN_ENGLISH_ONLY)) {
|
||||
if (!OUTPUT_STYLE_CATALOG[id]) continue;
|
||||
assert.equal(
|
||||
languagesOf(id).length,
|
||||
0,
|
||||
`style "${id}" now has translations — remove it from KNOWN_ENGLISH_ONLY`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
// #10421 — pre-migration backups were created on every migration run and never pruned,
|
||||
// so `db_backups/` grew without bound (observed: 48.999 files / 204 GB against a 5,3 MB
|
||||
// live database). The pruning logic already existed in `cleanupDbBackups()` but nothing
|
||||
// on the migration path ever reached it. These tests pin the retention step to the
|
||||
// backup call site so the operator's maxFiles/retentionDays budget is honored there too.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const serial = { concurrency: false };
|
||||
|
||||
async function importFresh(modulePath: string) {
|
||||
const url = pathToFileURL(path.resolve(modulePath)).href;
|
||||
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
}
|
||||
|
||||
function withMockedMigrationFs(files: Record<string, string>, fn: () => void) {
|
||||
const originalExistsSync = fs.existsSync;
|
||||
const originalReaddirSync = fs.readdirSync;
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
|
||||
const isMigrationDir = (target: unknown) =>
|
||||
String(target).replaceAll("\\", "/").endsWith("/src/lib/db/migrations") ||
|
||||
String(target).replaceAll("\\", "/").endsWith("/migrations");
|
||||
|
||||
fs.existsSync = ((target: unknown) => {
|
||||
if (isMigrationDir(target)) return true;
|
||||
const fileName = path.basename(String(target));
|
||||
if (Object.hasOwn(files, fileName)) return true;
|
||||
return originalExistsSync(target as string);
|
||||
}) as typeof fs.existsSync;
|
||||
|
||||
fs.readdirSync = ((target: string, options?: unknown) => {
|
||||
if (isMigrationDir(target)) return Object.keys(files);
|
||||
return originalReaddirSync(target, options as never);
|
||||
}) as typeof fs.readdirSync;
|
||||
|
||||
fs.readFileSync = ((target: unknown, options?: unknown) => {
|
||||
const fileName = path.basename(String(target));
|
||||
if (Object.hasOwn(files, fileName)) return files[fileName];
|
||||
return originalReadFileSync(target as string, options as never);
|
||||
}) as typeof fs.readFileSync;
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs.existsSync = originalExistsSync;
|
||||
fs.readdirSync = originalReaddirSync;
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal SqliteAdapter over a real on-disk file (VACUUM INTO needs a file, not :memory:). */
|
||||
function createFileDb(sqlitePath: string) {
|
||||
const db = new Database(sqlitePath);
|
||||
|
||||
return {
|
||||
driver: "better-sqlite3",
|
||||
get open() {
|
||||
return db.open;
|
||||
},
|
||||
get name() {
|
||||
return db.name;
|
||||
},
|
||||
prepare: (sql: string) => db.prepare(sql),
|
||||
exec: (sql: string) => db.exec(sql),
|
||||
pragma: (str: string, options?: unknown) => db.pragma(str, options as never),
|
||||
transaction: (fn: (...args: unknown[]) => unknown) => {
|
||||
const tx = db.transaction((...args: unknown[]) => fn(...args));
|
||||
return (...args: unknown[]) => tx(...args);
|
||||
},
|
||||
immediate: (fn: () => void) => fn(),
|
||||
async backup() {},
|
||||
checkpoint() {},
|
||||
close: () => db.close(),
|
||||
get raw() {
|
||||
return db;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DB that already has migrations applied (so the pre-migration backup path is
|
||||
* reached: it requires `applied.size > 0`) plus one pending migration to trigger a run.
|
||||
*/
|
||||
function seedAppliedDb(db: ReturnType<typeof createFileDb>) {
|
||||
db.exec(`
|
||||
CREATE TABLE provider_connections (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE combos (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE call_logs (id TEXT PRIMARY KEY);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record 001 as applied in the runner's own ledger table. `runMigrations` only takes a
|
||||
* pre-migration backup when `applied.size > 0`, so this is what puts the test on the
|
||||
* code path under exercise.
|
||||
*/
|
||||
function seedAppliedMigration(db: ReturnType<typeof createFileDb>) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _omniroute_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO _omniroute_migrations (version, name, applied_at) VALUES (?, ?, ?)"
|
||||
).run("001", "initial_schema", new Date().toISOString());
|
||||
}
|
||||
|
||||
function makeTempDataDir() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-backup-retention-"));
|
||||
fs.mkdirSync(path.join(dir, "db_backups"), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Pre-existing backups, oldest first, with distinct mtimes so retention ordering is stable. */
|
||||
function seedBackups(backupDir: string, count: number) {
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = `db_2026-08-${String(i + 1).padStart(2, "0")}T00-00-00-000Z_pre-migration.sqlite`;
|
||||
const filePath = path.join(backupDir, name);
|
||||
fs.writeFileSync(filePath, "x");
|
||||
const t = new Date(2026, 7, i + 1).getTime() / 1000;
|
||||
fs.utimesSync(filePath, t, t);
|
||||
names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function countBackups(backupDir: string) {
|
||||
return fs.readdirSync(backupDir).filter((n) => n.startsWith("db_")).length;
|
||||
}
|
||||
|
||||
function withEnv(vars: Record<string, string | undefined>, fn: () => void) {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
saved[k] = process.env[k];
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
"#10421 runMigrations prunes pre-migration backups to the configured maxFiles",
|
||||
serial,
|
||||
async () => {
|
||||
const dataDir = makeTempDataDir();
|
||||
const backupDir = path.join(dataDir, "db_backups");
|
||||
const sqlitePath = path.join(dataDir, "storage.sqlite");
|
||||
const db = createFileDb(sqlitePath);
|
||||
|
||||
try {
|
||||
seedAppliedDb(db);
|
||||
seedBackups(backupDir, 30);
|
||||
assert.equal(countBackups(backupDir), 30, "precondition: 30 stale backups on disk");
|
||||
|
||||
const { runMigrations } = await importFresh("src/lib/db/migrationRunner.ts");
|
||||
|
||||
withEnv(
|
||||
{
|
||||
DB_BACKUP_MAX_FILES: "5",
|
||||
DB_BACKUP_RETENTION_DAYS: "0",
|
||||
DISABLE_SQLITE_AUTO_BACKUP: undefined,
|
||||
},
|
||||
() => {
|
||||
withMockedMigrationFs(
|
||||
{
|
||||
"001_initial_schema.sql": "SELECT 1;",
|
||||
"002_retention_probe.sql": "CREATE TABLE retention_probe_10421 (id INTEGER);",
|
||||
},
|
||||
() => {
|
||||
// Mark 001 as applied so `applied.size > 0` and the backup path is reached.
|
||||
seedAppliedMigration(db);
|
||||
|
||||
runMigrations(db);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const remaining = countBackups(backupDir);
|
||||
assert.ok(
|
||||
remaining <= 5,
|
||||
`expected retention to cap db_backups at 5 files, found ${remaining} — ` +
|
||||
`pre-migration backups are accumulating unbounded (#10421)`
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("#10421 the newest pre-migration backup survives pruning", serial, async () => {
|
||||
const dataDir = makeTempDataDir();
|
||||
const backupDir = path.join(dataDir, "db_backups");
|
||||
const sqlitePath = path.join(dataDir, "storage.sqlite");
|
||||
const db = createFileDb(sqlitePath);
|
||||
|
||||
try {
|
||||
seedAppliedDb(db);
|
||||
seedBackups(backupDir, 10);
|
||||
|
||||
const { runMigrations } = await importFresh("src/lib/db/migrationRunner.ts");
|
||||
|
||||
withEnv(
|
||||
{
|
||||
DB_BACKUP_MAX_FILES: "3",
|
||||
DB_BACKUP_RETENTION_DAYS: "0",
|
||||
DISABLE_SQLITE_AUTO_BACKUP: undefined,
|
||||
},
|
||||
() => {
|
||||
withMockedMigrationFs(
|
||||
{
|
||||
"001_initial_schema.sql": "SELECT 1;",
|
||||
"002_retention_probe.sql": "CREATE TABLE retention_probe_10421b (id INTEGER);",
|
||||
},
|
||||
() => {
|
||||
seedAppliedMigration(db);
|
||||
|
||||
runMigrations(db);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const remaining = fs.readdirSync(backupDir).filter((n) => n.startsWith("db_"));
|
||||
assert.ok(remaining.length <= 3, `expected <=3 backups, found ${remaining.length}`);
|
||||
|
||||
// The backup written by THIS run must be among the survivors — pruning must never
|
||||
// discard the snapshot that protects the migration it was taken for.
|
||||
const seededNames = new Set(
|
||||
Array.from({ length: 10 }, (_, i) => {
|
||||
return `db_2026-08-${String(i + 1).padStart(2, "0")}T00-00-00-000Z_pre-migration.sqlite`;
|
||||
})
|
||||
);
|
||||
const fresh = remaining.filter((n) => !seededNames.has(n));
|
||||
assert.equal(fresh.length, 1, `expected the run's own backup to survive, got ${fresh.length}`);
|
||||
} finally {
|
||||
db.close();
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user