feat(migration): add Supabase import and recovery tooling
This commit is contained in:
+77
-2
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { applySqlitePragmas, ensureDataLayout } from "./lib/data-dir.mjs";
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const config = ensureDataLayout();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupDir = path.join(config.backupsDir, `neta-${timestamp}`);
|
||||
@@ -24,6 +25,8 @@ try {
|
||||
copyDirectoryIfExists(config.uploadsDir, uploadsBackupDir);
|
||||
|
||||
const manifest = {
|
||||
format: "neta-backup",
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
source: {
|
||||
dataDir: config.dataDir,
|
||||
@@ -39,7 +42,45 @@ const manifest = {
|
||||
|
||||
fs.writeFileSync(path.join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
|
||||
const removedBackups = applyRetention(config.backupsDir, backupDir, args.retentionCount);
|
||||
console.log(`Backup created at ${backupDir}`);
|
||||
if (args.retentionCount !== null) {
|
||||
console.log(
|
||||
`Retention kept the newest ${args.retentionCount} backup(s); removed ${removedBackups.length}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(values) {
|
||||
let retentionCount = parseOptionalPositiveInteger(
|
||||
process.env.BACKUP_RETENTION_COUNT,
|
||||
"BACKUP_RETENTION_COUNT",
|
||||
);
|
||||
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index];
|
||||
if (value === "--retention-count") {
|
||||
retentionCount = parseOptionalPositiveInteger(values[index + 1], "--retention-count");
|
||||
if (retentionCount === null) {
|
||||
throw new Error("--retention-count requires a positive integer.");
|
||||
}
|
||||
index += 1;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Usage: node scripts/backup.mjs [--retention-count <positive-integer>]",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { retentionCount };
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInteger(value, name) {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
if (!/^[1-9]\d*$/.test(value)) {
|
||||
throw new Error(`${name} must be a positive integer.`);
|
||||
}
|
||||
return Number.parseInt(value, 10);
|
||||
}
|
||||
|
||||
function copyDirectoryIfExists(sourceDir, targetDir) {
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
@@ -52,10 +93,14 @@ function copyDirectoryIfExists(sourceDir, targetDir) {
|
||||
const sourcePath = path.join(sourceDir, entry.name);
|
||||
const targetPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`Upload tree contains a symbolic link: ${sourcePath}`);
|
||||
} else if (entry.isDirectory()) {
|
||||
copyDirectoryIfExists(sourcePath, targetPath);
|
||||
} else if (entry.isFile()) {
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
} else {
|
||||
throw new Error(`Upload tree contains an unsupported filesystem entry: ${sourcePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +111,9 @@ function collectFiles(rootDir) {
|
||||
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(rootDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`Backup contains a symbolic link: ${entryPath}`);
|
||||
} else if (entry.isDirectory()) {
|
||||
files.push(...collectFiles(entryPath));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(entryPath);
|
||||
@@ -81,3 +128,31 @@ function hashFile(filePath) {
|
||||
hash.update(fs.readFileSync(filePath));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function applyRetention(backupsDir, currentBackupDir, retentionCount) {
|
||||
if (retentionCount === null) return [];
|
||||
|
||||
const candidates = fs
|
||||
.readdirSync(backupsDir, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
!entry.isSymbolicLink() &&
|
||||
/^neta-\d{4}-\d{2}-\d{2}T/.test(entry.name) &&
|
||||
fs.existsSync(path.join(backupsDir, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => path.join(backupsDir, entry.name))
|
||||
.sort((left, right) => path.basename(right).localeCompare(path.basename(left)));
|
||||
|
||||
const currentIndex = candidates.indexOf(currentBackupDir);
|
||||
if (currentIndex > 0) {
|
||||
candidates.splice(currentIndex, 1);
|
||||
candidates.unshift(currentBackupDir);
|
||||
}
|
||||
|
||||
const removed = candidates.slice(retentionCount);
|
||||
for (const candidate of removed) {
|
||||
fs.rmSync(candidate, { recursive: true, force: false });
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user