feat(migration): add Supabase import and recovery tooling

This commit is contained in:
poyrazavsever
2026-07-17 09:13:06 +03:00
parent 64a5f96872
commit 5948907e5d
10 changed files with 5525 additions and 9 deletions
+77 -2
View File
@@ -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;
}
+86
View File
@@ -0,0 +1,86 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { applySqlitePragmas, ensureDataLayout, getDataConfig } from "./lib/data-dir.mjs";
import {
applySupabaseImport,
assertTargetImportSafety,
loadImportBundle,
prepareSupabaseImport,
rollbackStagedFiles,
stageImportFiles,
validateTargetOwner,
} from "./lib/supabase-import.mjs";
const args = parseArgs(process.argv.slice(2));
if (!args.from || !args.ownerUserId) {
throw new Error(
"Usage: node scripts/import-supabase.mjs --from <export-dir> --owner-user-id <better-auth-user-id> [--dry-run] [--allow-existing] [--report <path>]",
);
}
const config = ensureDataLayout(getDataConfig(process.env));
const { bundle, bundleDir, bundlePath } = loadImportBundle(args.from);
const plan = prepareSupabaseImport({
bundle,
bundleDir,
targetOwnerUserId: args.ownerUserId,
});
const db = new Database(config.databasePath);
let stagedFiles = [];
try {
applySqlitePragmas(db);
const owner = validateTargetOwner(db, args.ownerUserId);
const existingCounts = assertTargetImportSafety(db, plan, args.allowExisting || args.dryRun);
const report = {
format: "neta-supabase-import-report",
version: 1,
mode: args.dryRun ? "dry-run" : "apply",
exportPath: bundlePath,
sourceOwnerUserId: plan.sourceOwnerUserId,
targetOwnerUserId: plan.targetOwnerUserId,
targetOwnerEmail: owner.email,
sourceCounts: plan.sourceCounts,
targetCounts: plan.targetCounts,
existingCounts,
warnings: plan.warnings,
verification: null,
completedAt: null,
};
if (!args.dryRun) {
stagedFiles = stageImportFiles(plan, config.uploadsDir);
try {
report.verification = applySupabaseImport(db, plan);
report.completedAt = new Date().toISOString();
} catch (error) {
rollbackStagedFiles(stagedFiles);
throw error;
}
}
const reportPath = path.resolve(
args.report ?? path.join(config.dataDir, args.dryRun ? "import-dry-run-report.json" : "import-report.json"),
);
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
console.log(`${args.dryRun ? "Import dry-run" : "Import"} completed. Report: ${reportPath}`);
for (const warning of report.warnings) console.warn(`Warning: ${warning}`);
} finally {
db.close();
}
function parseArgs(values) {
const parsed = { dryRun: false, allowExisting: false };
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
if (value === "--dry-run") parsed.dryRun = true;
else if (value === "--allow-existing") parsed.allowExisting = true;
else if (value === "--from") parsed.from = values[++index];
else if (value === "--owner-user-id") parsed.ownerUserId = values[++index];
else if (value === "--report") parsed.report = values[++index];
else throw new Error(`Unknown argument: ${value}`);
}
return parsed;
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -58,6 +58,20 @@ execFileSync(process.execPath, ["scripts/backup.mjs"], {
env,
stdio: "inherit",
});
for (let index = 0; index < 2; index += 1) {
execFileSync(
process.execPath,
["scripts/backup.mjs", "--retention-count", "2"],
{ cwd: process.cwd(), env, stdio: "inherit" },
);
}
const retainedBackups = fs
.readdirSync(path.join(smokeRoot, "backups"))
.filter((name) => name.startsWith("neta-"));
if (retainedBackups.length !== 2) {
throw new Error(`Backup retention smoke check failed: expected 2, received ${retainedBackups.length}.`);
}
const backupDir = fs
.readdirSync(path.join(smokeRoot, "backups"))
@@ -69,6 +83,14 @@ if (!backupDir) {
throw new Error("Backup smoke check failed: no backup directory produced.");
}
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
cwd: process.cwd(),
env: { ...process.env, DATA_DIR: restoreRoot, DATABASE_PATH: "" },
stdio: "inherit",
});
const staleUploadPath = path.join(restoreRoot, "uploads", "stale.txt");
fs.writeFileSync(staleUploadPath, "must-be-replaced");
execFileSync(process.execPath, ["scripts/restore.mjs", "--from", backupDir, "--target", restoreRoot, "--force"], {
cwd: process.cwd(),
env: process.env,
@@ -96,6 +118,9 @@ const restoredUploadFixture = path.join(
if (fs.readFileSync(restoredUploadFixture, "utf8") !== "neta-upload-backup-fixture") {
throw new Error("Restore smoke check failed: upload fixture missing or corrupted.");
}
if (fs.existsSync(staleUploadPath)) {
throw new Error("Atomic restore smoke check failed: stale upload tree was not replaced.");
}
fs.appendFileSync(path.join(backupDir, "uploads", "project-assets", "backup-fixture.txt"), "-tampered");
let corruptedBackupRejected = false;
+368
View File
@@ -0,0 +1,368 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
const root = path.join(process.cwd(), ".data", `phase8-import-smoke-${Date.now()}`);
const dataDir = path.join(root, "data");
const exportDir = path.join(root, "export");
const databasePath = path.join(dataDir, "neta.db");
const ownerUserId = "phase8-target-owner";
const sourceOwnerUserId = "00000000-0000-4000-8000-000000000008";
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
const rollbackDir = path.join(root, "rollback");
fs.mkdirSync(path.join(exportDir, "storage", "avatars"), { recursive: true });
fs.mkdirSync(path.join(exportDir, "storage", "project-assets"), { recursive: true });
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
cwd: process.cwd(),
env,
stdio: "inherit",
});
const db = new Database(databasePath);
db.pragma("foreign_keys = ON");
db.prepare(`
insert into user (id, name, email, email_verified, created_at, updated_at)
values (?, ?, ?, 1, ?, ?)
`).run(ownerUserId, "Target Owner", "phase8-owner@example.com", Date.now(), Date.now());
db.prepare(`
insert into app_profiles (auth_user_id, email, display_name, role, disabled, created_at, updated_at)
values (?, ?, ?, 'freelancer', 0, ?, ?)
`).run(ownerUserId, "phase8-owner@example.com", "Target Owner", Date.now(), Date.now());
db.close();
execFileSync(process.execPath, ["scripts/backup.mjs"], {
cwd: process.cwd(),
env,
stdio: "inherit",
});
const preCutoverBackupDir = fs
.readdirSync(path.join(dataDir, "backups"))
.map((name) => path.join(dataDir, "backups", name))
.sort()
.at(-1);
assert.ok(preCutoverBackupDir, "Pre-cutover backup must exist");
const avatarBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
const coverBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 5, 6, 7, 8]);
const avatarLocalPath = "storage/avatars/avatar.png";
const coverLocalPath = "storage/project-assets/cover.png";
fs.writeFileSync(path.join(exportDir, avatarLocalPath), avatarBytes);
fs.writeFileSync(path.join(exportDir, coverLocalPath), coverBytes);
const baseBundle = createBundle();
writeBundle(exportDir, baseBundle);
runImport(["--dry-run"]);
let check = new Database(databasePath);
assert.equal(check.prepare("select count(*) as value from clients").get().value, 0, "Dry-run must not mutate");
check.close();
runImport([]);
runImport(["--allow-existing"]);
check = new Database(databasePath);
check.pragma("foreign_keys = ON");
try {
assert.equal(check.prepare("select count(*) as value from clients").get().value, 1);
assert.equal(check.prepare("select count(*) as value from projects").get().value, 1);
assert.equal(check.prepare("select count(*) as value from files").get().value, 2);
assert.equal(check.prepare("select count(*) as value from journal_entries").get().value, 1);
assert.equal(
check.prepare("select status from tasks where id = 'source-task'").get().status,
"done",
"Legacy completed task status must normalize to done",
);
assert.equal(
check.prepare("select source_journal_entry_id as value from tasks where id = 'source-task'").get().value,
"source-daily",
"Merged journal references must target the canonical daily log",
);
assert.equal(
check.prepare("select amount_minor as value from finance_transactions where id = 'source-finance'").get().value,
1234,
"Money must convert to integer minor units exactly",
);
assert.equal(
check.prepare("select tax_basis_points as value from invoices where id = 'source-invoice'").get().value,
1850,
"Tax percentage must convert to basis points exactly",
);
const journal = check.prepare(`
select note, legacy_ai_metadata as legacyAiMetadata
from journal_entries where id = 'source-daily'
`).get();
assert.match(journal.note, /Daily note/);
assert.match(journal.note, /Legacy journal content/);
assert.match(journal.legacyAiMetadata, /source-journal/);
const settings = check.prepare(`
select p.timezone, p.default_currency as defaultCurrency,
a.provider, a.model, a.encrypted_api_key as encryptedApiKey
from user_preferences p
inner join user_ai_settings a on a.owner_user_id = p.owner_user_id
where p.owner_user_id = ?
`).get(ownerUserId);
assert.deepEqual(settings, {
timezone: "Europe/Istanbul",
defaultCurrency: "TRY",
provider: "gemini",
model: "gemini-test-model",
encryptedApiKey: null,
});
assert.equal(
check.prepare("select auth_user_id as value from clients where id = 'source-client'").get().value,
null,
"Legacy client auth links must not be imported",
);
assert.equal(
check.prepare("select requested_by_user_id as value from project_revisions where id = 'source-revision'").get().value,
ownerUserId,
"Historical revision requester must use a valid target principal",
);
const project = check.prepare(`
select legacy_cover_image_path as cover from projects where id = 'source-project'
`).get();
assert.match(project.cover, /^\/api\/files\/import-/);
const section = check.prepare(`
select metadata from project_planning_sections where id = 'source-section'
`).get();
assert.match(section.metadata, /\/api\/files\/import-/);
assert.equal(check.prepare("select name from user where id = ?").get(ownerUserId).name, "Source Owner");
assert.deepEqual(check.pragma("foreign_key_check"), [], "Imported database must satisfy all foreign keys");
} finally {
check.close();
}
const reportText = fs.readFileSync(path.join(dataDir, "import-report.json"), "utf8");
assert.doesNotMatch(reportText, /legacy-plain-text-secret|source-password-secret/);
assert.match(reportText, /intentionally not imported/);
const invalidEnumDir = cloneExport("invalid-enum");
const invalidEnum = structuredClone(baseBundle);
invalidEnum.tables.tasks[0].status = "mystery";
writeBundle(invalidEnumDir, invalidEnum);
assertImportFails(invalidEnumDir, /unknown value/);
const invalidForeignKeyDir = cloneExport("invalid-foreign-key");
const invalidForeignKey = structuredClone(baseBundle);
invalidForeignKey.tables.projects[0].client_id = "missing-client";
writeBundle(invalidForeignKeyDir, invalidForeignKey);
assertImportFails(invalidForeignKeyDir, /references missing id/);
const unsafeStorageDir = cloneExport("unsafe-storage");
const unsafeStorage = structuredClone(baseBundle);
unsafeStorage.storage.objects[0].local_path = "../escape.png";
writeBundle(unsafeStorageDir, unsafeStorage);
assertImportFails(unsafeStorageDir, /unsafe|escapes root/);
execFileSync(
process.execPath,
[
"scripts/restore.mjs",
"--from",
preCutoverBackupDir,
"--target",
rollbackDir,
"--force",
],
{ cwd: process.cwd(), env: process.env, stdio: "inherit" },
);
const rollbackDb = new Database(path.join(rollbackDir, "neta.db"), { readonly: true });
try {
assert.equal(
rollbackDb.prepare("select count(*) as value from clients").get().value,
0,
"Rollback backup must restore the pre-import client count",
);
assert.equal(
rollbackDb.prepare("select count(*) as value from user where id = ?").get(ownerUserId).value,
1,
"Rollback backup must preserve the Better Auth owner",
);
} finally {
rollbackDb.close();
}
console.log("Phase 8 import smoke passed: dry-run, normalization, files, idempotency, negative validation and rollback rehearsal verified.");
function runImport(extraArgs, from = exportDir) {
execFileSync(
process.execPath,
[
"scripts/import-supabase.mjs",
"--from",
from,
"--owner-user-id",
ownerUserId,
...extraArgs,
],
{ cwd: process.cwd(), env, stdio: "inherit" },
);
}
function assertImportFails(from, pattern) {
assert.throws(
() => execFileSync(
process.execPath,
[
"scripts/import-supabase.mjs",
"--from",
from,
"--owner-user-id",
ownerUserId,
"--dry-run",
],
{ cwd: process.cwd(), env, stdio: "pipe" },
),
(error) => pattern.test(`${error.stdout ?? ""}\n${error.stderr ?? ""}`),
);
}
function cloneExport(name) {
const destination = path.join(root, name);
fs.cpSync(exportDir, destination, { recursive: true });
return destination;
}
function writeBundle(directory, bundle) {
fs.writeFileSync(path.join(directory, "export.json"), `${JSON.stringify(bundle, null, 2)}\n`);
}
function hash(bytes) {
return crypto.createHash("sha256").update(bytes).digest("hex");
}
function createBundle() {
const createdAt = "2026-07-17T10:00:00.000Z";
const tableNames = [
"profiles", "clients", "client_activities", "projects", "project_planning_sections",
"project_revisions", "tasks", "calendar_events", "finance_transactions", "daily_logs",
"journals", "chat_sessions", "chat_messages", "proposals", "contracts", "invoices",
"subscriptions", "app_settings", "document_embeddings",
];
const tables = Object.fromEntries(tableNames.map((name) => [name, []]));
tables.profiles.push({
id: sourceOwnerUserId,
first_name: "Source",
last_name: "Owner",
avatar_url: "https://legacy.example/storage/v1/object/public/avatars/source/avatar.png",
});
tables.clients.push({
id: "source-client", user_id: sourceOwnerUserId, client_auth_id: "legacy-client-auth-user",
name: "Imported Client", email: "client@example.com", status: "active",
pipeline_stage: "won", created_at: createdAt,
});
tables.client_activities.push({
id: "source-activity", user_id: sourceOwnerUserId, client_id: "source-client",
type: "meeting", title: "Imported Meeting", activity_date: createdAt, created_at: createdAt,
});
tables.projects.push({
id: "source-project", user_id: sourceOwnerUserId, client_id: "source-client",
name: "Imported Project", type: "client_project", status: "active",
budget_amount: "1000.05", currency: "try", progress: 25, progress_type: "manual",
revision_quota: 2, cover_image_path: "source/project-cover.png", created_at: createdAt,
});
tables.daily_logs.push({
id: "source-daily", user_id: sourceOwnerUserId, log_date: "2026-07-17",
mood_score: 4, energy_score: 3, work_satisfaction_score: 5,
note: "Daily note", created_at: createdAt,
});
tables.journals.push({
id: "source-journal", user_id: sourceOwnerUserId, date: "2026-07-17T08:00:00.000Z",
mood: "focused", energy: 4, content: "Legacy journal content", ai_tags: ["focus"],
ai_summary: "Legacy summary", created_at: createdAt,
});
tables.tasks.push({
id: "source-task", user_id: sourceOwnerUserId, client_id: "source-client",
project_id: "source-project", source_journal_id: "source-journal", title: "Imported Task",
status: "completed", priority: "high", date: "2026-07-17",
is_public_to_client: true, created_at: createdAt,
});
tables.calendar_events.push({
id: "source-event", user_id: sourceOwnerUserId, client_id: "source-client",
project_id: "source-project", task_id: "source-task", title: "Imported Event",
type: "meeting", starts_at: createdAt, ends_at: "2026-07-17T11:00:00.000Z",
created_at: createdAt,
});
tables.finance_transactions.push({
id: "source-finance", user_id: sourceOwnerUserId, client_id: "source-client",
project_id: "source-project", type: "income", amount: "12.34", currency: "try",
transaction_date: "2026-07-17", payment_status: "paid", created_at: createdAt,
});
tables.project_planning_sections.push({
id: "source-section", user_id: sourceOwnerUserId, project_id: "source-project",
category: "assets", title: "Imported Assets", metadata: { cover: "source/project-cover.png" },
sort_order: 0, created_at: createdAt,
});
tables.project_revisions.push({
id: "source-revision", project_id: "source-project", client_id: "source-client",
requested_by: "legacy-client-auth-user", description: "Imported revision",
status: "completed", created_at: createdAt,
});
tables.chat_sessions.push({
id: "source-chat", user_id: sourceOwnerUserId, title: "Imported Chat", created_at: createdAt,
});
tables.chat_messages.push({
id: "source-message", session_id: "source-chat", role: "user",
content: "Imported message", context_journal_ids: ["source-journal"], created_at: createdAt,
});
tables.proposals.push({
id: "source-proposal", user_id: sourceOwnerUserId, client_id: "source-client",
project_id: "source-project", title: "Imported Proposal", amount: "100.00",
currency: "TRY", status: "accepted", created_at: createdAt,
});
tables.contracts.push({
id: "source-contract", user_id: sourceOwnerUserId, proposal_id: "source-proposal",
client_id: "source-client", title: "Imported Contract", status: "active", created_at: createdAt,
});
tables.invoices.push({
id: "source-invoice", user_id: sourceOwnerUserId, client_id: "source-client",
project_id: "source-project", invoice_number: "IMPORT-001", amount: "100.00",
tax_rate: "18.50", currency: "TRY", status: "paid", issue_date: "2026-07-17",
created_at: createdAt,
});
tables.subscriptions.push({
id: "source-subscription", user_id: sourceOwnerUserId, name: "Imported Hosting",
amount: "50.00", currency: "TRY", billing_cycle: "monthly", status: "active",
created_at: createdAt,
});
tables.app_settings.push({
id: "source-settings", user_id: sourceOwnerUserId, timezone: "Europe/Istanbul",
currency: "TRY", ai_provider: "google", ai_model: "gemini-test-model",
api_key: "legacy-plain-text-secret", created_at: createdAt,
});
tables.document_embeddings.push({
id: "source-embedding", user_id: sourceOwnerUserId, content: "Archived only",
});
return {
format: "neta-supabase-export",
version: 1,
exported_at: createdAt,
source: { owner_user_id: sourceOwnerUserId },
auth: { users: [{ password: "source-password-secret" }] },
tables,
storage: {
objects: [
{
bucket: "avatars", object_path: "source/avatar.png",
source_url: "https://legacy.example/storage/v1/object/public/avatars/source/avatar.png",
local_path: avatarLocalPath, original_name: "avatar.png", mime_type: "image/png",
bytes: avatarBytes.length, sha256: hash(avatarBytes), created_at: createdAt,
},
{
bucket: "project-assets", object_path: "source/project-cover.png",
local_path: coverLocalPath, original_name: "cover.png", mime_type: "image/png",
bytes: coverBytes.length, sha256: hash(coverBytes), project_id: "source-project",
portal_visible: true,
references: [{ type: "project_cover", project_id: "source-project" }],
created_at: createdAt,
},
],
},
};
}
+69 -6
View File
@@ -12,7 +12,7 @@ if (!args.from) {
const targetEnv = {
...process.env,
DATA_DIR: args.target || process.env.DATA_DIR,
DATABASE_PATH: undefined,
DATABASE_PATH: args.target ? undefined : process.env.DATABASE_PATH,
};
const config = ensureDataLayout(getDataConfig(targetEnv));
@@ -31,13 +31,62 @@ if (fs.existsSync(config.databasePath) && !args.force) {
throw new Error(`Target database exists: ${config.databasePath}. Pass --force to overwrite.`);
}
fs.copyFileSync(backupDbPath, config.databasePath);
const restoreId = `${process.pid}-${Date.now()}`;
const stagedDatabasePath = `${config.databasePath}.restore-stage-${restoreId}`;
const rollbackDatabasePath = `${config.databasePath}.restore-rollback-${restoreId}`;
const stagedUploadsDir = `${config.uploadsDir}.restore-stage-${restoreId}`;
const rollbackUploadsDir = `${config.uploadsDir}.restore-rollback-${restoreId}`;
if (fs.existsSync(backupUploadsDir)) {
fs.rmSync(config.uploadsDir, { recursive: true, force: true });
copyDirectory(backupUploadsDir, config.uploadsDir);
let databaseMovedToRollback = false;
let uploadsMovedToRollback = false;
let stagedDatabaseInstalled = false;
let stagedUploadsInstalled = false;
try {
fs.copyFileSync(backupDbPath, stagedDatabasePath, fs.constants.COPYFILE_EXCL);
if (hashFile(stagedDatabasePath) !== hashFile(backupDbPath)) {
throw new Error("Staged database checksum does not match the verified backup.");
}
fs.mkdirSync(stagedUploadsDir);
if (fs.existsSync(backupUploadsDir)) {
copyDirectory(backupUploadsDir, stagedUploadsDir);
}
if (fs.existsSync(config.databasePath)) {
fs.renameSync(config.databasePath, rollbackDatabasePath);
databaseMovedToRollback = true;
}
if (fs.existsSync(config.uploadsDir)) {
fs.renameSync(config.uploadsDir, rollbackUploadsDir);
uploadsMovedToRollback = true;
}
fs.renameSync(stagedDatabasePath, config.databasePath);
stagedDatabaseInstalled = true;
fs.renameSync(stagedUploadsDir, config.uploadsDir);
stagedUploadsInstalled = true;
} catch (error) {
if (stagedDatabaseInstalled && fs.existsSync(config.databasePath)) {
fs.rmSync(config.databasePath, { force: true });
}
if (stagedUploadsInstalled && fs.existsSync(config.uploadsDir)) {
fs.rmSync(config.uploadsDir, { recursive: true, force: true });
}
if (databaseMovedToRollback && fs.existsSync(rollbackDatabasePath)) {
fs.renameSync(rollbackDatabasePath, config.databasePath);
}
if (uploadsMovedToRollback && fs.existsSync(rollbackUploadsDir)) {
fs.renameSync(rollbackUploadsDir, config.uploadsDir);
}
throw error;
} finally {
fs.rmSync(stagedDatabasePath, { force: true });
fs.rmSync(stagedUploadsDir, { recursive: true, force: true });
}
fs.rmSync(rollbackDatabasePath, { force: true });
fs.rmSync(rollbackUploadsDir, { recursive: true, force: true });
console.log(`Backup restored from ${backupDir} to ${config.dataDir}`);
function parseArgs(values) {
@@ -67,10 +116,14 @@ function copyDirectory(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(`Backup contains a symbolic link: ${sourcePath}`);
} else if (entry.isDirectory()) {
copyDirectory(sourcePath, targetPath);
} else if (entry.isFile()) {
fs.copyFileSync(sourcePath, targetPath);
} else {
throw new Error(`Backup contains an unsupported filesystem entry: ${sourcePath}`);
}
}
}
@@ -81,6 +134,12 @@ function verifyManifest(rootDir, manifestFile) {
}
const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
if (
(manifest.format !== undefined && manifest.format !== "neta-backup") ||
(manifest.version !== undefined && manifest.version !== 1)
) {
throw new Error("Backup manifest format or version is not supported.");
}
if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
throw new Error("Backup manifest has no file entries.");
}
@@ -135,3 +194,7 @@ function collectBackupFiles(rootDir, currentDir) {
}
return files;
}
function hashFile(filePath) {
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
}
@@ -0,0 +1,15 @@
CREATE TABLE `user_preferences` (
`owner_user_id` text PRIMARY KEY NOT NULL,
`timezone` text DEFAULT 'Europe/Istanbul' NOT NULL,
`default_currency` text DEFAULT 'TRY' NOT NULL,
`language` text DEFAULT 'tr' NOT NULL,
`date_format` text DEFAULT 'dd.MM.yyyy' NOT NULL,
`color_mode` text DEFAULT 'system' NOT NULL,
`sidebar_collapsed` integer DEFAULT false NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "user_preferences_currency_check" CHECK(length("user_preferences"."default_currency") = 3),
CONSTRAINT "user_preferences_language_check" CHECK("user_preferences"."language" in ('tr', 'en')),
CONSTRAINT "user_preferences_color_mode_check" CHECK("user_preferences"."color_mode" in ('light', 'dark', 'system'))
);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -43,6 +43,13 @@
"when": 1784234752708,
"tag": "0005_brief_black_bolt",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1784266217938,
"tag": "0006_moaning_kitty_pryde",
"breakpoints": true
}
]
}
+23 -1
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { check, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { check, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { user } from "./auth";
export type AiProvider = "gemini" | "openai" | "groq" | "ollama";
@@ -23,3 +23,25 @@ export const userAiSettings = sqliteTable(
),
],
);
export const userPreferences = sqliteTable(
"user_preferences",
{
ownerUserId: text("owner_user_id")
.primaryKey()
.references(() => user.id, { onDelete: "cascade" }),
timezone: text("timezone").default("Europe/Istanbul").notNull(),
defaultCurrency: text("default_currency").default("TRY").notNull(),
language: text("language").default("tr").notNull(),
dateFormat: text("date_format").default("dd.MM.yyyy").notNull(),
colorMode: text("color_mode").default("system").notNull(),
sidebarCollapsed: integer("sidebar_collapsed", { mode: "boolean" }).default(false).notNull(),
createdAt: text("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
updatedAt: text("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
},
(table) => [
check("user_preferences_currency_check", sql`length(${table.defaultCurrency}) = 3`),
check("user_preferences_language_check", sql`${table.language} in ('tr', 'en')`),
check("user_preferences_color_mode_check", sql`${table.colorMode} in ('light', 'dark', 'system')`),
],
);