diff --git a/.oxlintrc.json b/.oxlintrc.json
index 634c4b7c..0caeb87a 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -1,6 +1,6 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
- "plugins": ["unicorn", "typescript", "oxc"],
+ "plugins": ["eslint", "unicorn", "typescript", "oxc", "import", "react", "react-perf", "node", "jsx-a11y"],
"categories": {},
"rules": {
"constructor-super": "warn",
@@ -112,7 +112,8 @@
"unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-spread": "warn",
"unicorn/prefer-set-size": "warn",
- "unicorn/prefer-string-starts-ends-with": "warn"
+ "unicorn/prefer-string-starts-ends-with": "warn",
+ "import/no-cycle": "error"
},
"settings": {
"jsx-a11y": {
diff --git a/Dockerfile b/Dockerfile
index c9816cc3..b100b1f6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,8 +10,9 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
VITE_SHOUTRRR_VERSION=${SHOUTRRR_VERSION}
-RUN apk upgrade --no-cache && \
- apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini nfs-utils cifs-utils
+RUN apk update --no-cache && \
+ apk upgrade --no-cache && \
+ apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini nfs-utils cifs-utils util-linux
ENTRYPOINT ["/sbin/tini", "-s", "--"]
diff --git a/app/client/components/file-tree.tsx b/app/client/components/file-tree.tsx
index fa9559c2..22d0a5c4 100644
--- a/app/client/components/file-tree.tsx
+++ b/app/client/components/file-tree.tsx
@@ -489,18 +489,27 @@ interface ButtonProps {
const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onClick?.();
+ }
+ },
+ [onClick],
+ );
+
return (
- // biome-ignore lint/a11y/noStaticElementInteractions: we handle click and hover manually
- // biome-ignore lint/a11y/useKeyWithClickEvents: we handle click and hover manually
-
+
);
});
diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx
index 2e3b7f79..9ff31b8f 100644
--- a/app/client/components/snapshots-table.tsx
+++ b/app/client/components/snapshots-table.tsx
@@ -158,7 +158,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
}
setSelectedIds(newSelected);
}}
- aria-label={`Select snapshot ${snapshot.short_id}`}
+ aria-label={`Select snapshot ${snapshot.short_id}` as string}
/>
diff --git a/app/client/components/ui/alert.tsx b/app/client/components/ui/alert.tsx
index 1014ae72..229491a7 100644
--- a/app/client/components/ui/alert.tsx
+++ b/app/client/components/ui/alert.tsx
@@ -27,9 +27,9 @@ const Alert = React.forwardRef<
));
Alert.displayName = "Alert";
-const AlertTitle = React.forwardRef>(
+const AlertTitle = React.forwardRef>(
({ className, ...props }, ref) => (
-
+ {props.children}
),
);
AlertTitle.displayName = "AlertTitle";
diff --git a/app/client/components/ui/breadcrumb.tsx b/app/client/components/ui/breadcrumb.tsx
index e1fb0843..f838da10 100644
--- a/app/client/components/ui/breadcrumb.tsx
+++ b/app/client/components/ui/breadcrumb.tsx
@@ -43,16 +43,17 @@ function BreadcrumbLink({
);
}
-function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+function BreadcrumbPage({ className, children, ...props }: React.ComponentProps<"a">) {
return (
-
+ >
+ {children}
+
);
}
diff --git a/app/client/modules/auth/routes/download-recovery-key.tsx b/app/client/modules/auth/routes/download-recovery-key.tsx
index af29dd05..da20ce32 100644
--- a/app/client/modules/auth/routes/download-recovery-key.tsx
+++ b/app/client/modules/auth/routes/download-recovery-key.tsx
@@ -88,7 +88,6 @@ export default function DownloadRecoveryKeyPage() {
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
- autoFocus
disabled={downloadResticPassword.isPending}
/>
Enter your account password to download the recovery key
diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx
index f342ccd5..fb089eb6 100644
--- a/app/client/modules/auth/routes/login.tsx
+++ b/app/client/modules/auth/routes/login.tsx
@@ -84,7 +84,7 @@ export default function LoginPage() {
Username
-
+
diff --git a/app/client/modules/auth/routes/onboarding.tsx b/app/client/modules/auth/routes/onboarding.tsx
index f9e3f490..6290b598 100644
--- a/app/client/modules/auth/routes/onboarding.tsx
+++ b/app/client/modules/auth/routes/onboarding.tsx
@@ -115,7 +115,7 @@ export default function OnboardingPage() {
Username
-
+
Choose a username for the admin account
diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx
index 8e2a088b..577fb064 100644
--- a/app/client/modules/backups/components/create-schedule-form.tsx
+++ b/app/client/modules/backups/components/create-schedule-form.tsx
@@ -393,7 +393,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
type="button"
onClick={() => handleRemovePath(path)}
className="ml-1 hover:bg-destructive/20 rounded p-0.5 transition-colors"
- aria-label={`Remove ${path}`}
+ aria-label={`Remove ${path}` as string}
>
diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx
index 2036ccfa..16f1df03 100644
--- a/app/client/modules/backups/components/schedule-summary.tsx
+++ b/app/client/modules/backups/components/schedule-summary.tsx
@@ -65,7 +65,7 @@ export const ScheduleSummary = (props: Props) => {
repositoryLabel: schedule.repositoryId || "No repository selected",
retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy",
};
- }, [schedule, schedule.volume.name]);
+ }, [schedule]);
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx
index a1a75225..70a2fe8a 100644
--- a/app/client/modules/settings/routes/settings.tsx
+++ b/app/client/modules/settings/routes/settings.tsx
@@ -244,7 +244,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
onChange={(e) => setDownloadPassword(e.target.value)}
placeholder="Enter your password"
required
- autoFocus
/>
diff --git a/app/lib/auth.ts b/app/lib/auth.ts
index 049295a2..d2f60308 100644
--- a/app/lib/auth.ts
+++ b/app/lib/auth.ts
@@ -14,36 +14,66 @@ import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
export type AuthMiddlewareContext = MiddlewareContext>;
-export const auth = betterAuth({
- secret: await cryptoUtils.deriveSecret("better-auth"),
- hooks: {
- before: createAuthMiddleware(async (ctx) => {
- await ensureOnlyOneUser(ctx);
- await convertLegacyUserOnFirstLogin(ctx);
+const createBetterAuth = (secret: string) =>
+ betterAuth({
+ secret,
+ hooks: {
+ before: createAuthMiddleware(async (ctx) => {
+ await ensureOnlyOneUser(ctx);
+ await convertLegacyUserOnFirstLogin(ctx);
+ }),
+ },
+ database: drizzleAdapter(db, {
+ provider: "sqlite",
}),
- },
- database: drizzleAdapter(db, {
- provider: "sqlite",
- }),
- emailAndPassword: {
- enabled: true,
- },
- user: {
- modelName: "usersTable",
- additionalFields: {
- username: {
- type: "string",
- returned: true,
- required: true,
- },
- hasDownloadedResticPassword: {
- type: "boolean",
- returned: true,
+ emailAndPassword: {
+ enabled: true,
+ },
+ user: {
+ modelName: "usersTable",
+ additionalFields: {
+ username: {
+ type: "string",
+ returned: true,
+ required: true,
+ },
+ hasDownloadedResticPassword: {
+ type: "boolean",
+ returned: true,
+ },
},
},
+ session: {
+ modelName: "sessionsTable",
+ },
+ plugins: [username({})],
+ advanced: {
+ disableOriginCheck: true,
+ },
+ });
+
+type Auth = ReturnType;
+
+let _auth: Auth | null = null;
+
+const createAuth = async (): Promise => {
+ if (_auth) return _auth;
+
+ _auth = createBetterAuth(await cryptoUtils.deriveSecret("better-auth"));
+
+ return _auth;
+};
+
+export const auth = new Proxy(
+ {},
+ {
+ get(_, prop, receiver) {
+ if (!_auth) {
+ throw new Error("Auth not initialized. Call initAuth() first.");
+ }
+ return Reflect.get(_auth, prop, receiver);
+ },
},
- session: {
- modelName: "sessionsTable",
- },
- plugins: [username({})],
-});
+) as Auth;
+
+export const initAuth = createAuth;
diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts
index 003b8a39..972407a9 100644
--- a/app/server/core/constants.ts
+++ b/app/server/core/constants.ts
@@ -5,5 +5,3 @@ export const DATABASE_URL = process.env.DATABASE_URL || "/var/lib/zerobyte/data/
export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE];
-
-export const REQUIRED_MIGRATIONS = []; // ["v0.21.1"] add this once re-tagging migration is removed
diff --git a/app/server/index.ts b/app/server/index.ts
index f2c51b6c..8faf6a90 100644
--- a/app/server/index.ts
+++ b/app/server/index.ts
@@ -2,14 +2,12 @@ import { createHonoServer } from "react-router-hono-server/bun";
import * as schema from "./db/schema";
import { setSchema, runDbMigrations } from "./db/db";
import { startup } from "./modules/lifecycle/startup";
-import { retagSnapshots } from "./modules/lifecycle/migration";
import { logger } from "./utils/logger";
import { shutdown } from "./modules/lifecycle/shutdown";
-import { REQUIRED_MIGRATIONS } from "./core/constants";
-import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
import { createApp } from "./app";
import { config } from "./core/config";
import { runCLI } from "./cli";
+import { runMigrations } from "./modules/lifecycle/migrations";
setSchema(schema);
@@ -18,13 +16,11 @@ if (cliRun) {
process.exit(0);
}
-const app = createApp();
-
runDbMigrations();
-await retagSnapshots();
-await validateRequiredMigrations(REQUIRED_MIGRATIONS);
+const app = createApp();
+await runMigrations();
await startup();
export type AppType = typeof app;
diff --git a/app/server/modules/backends/nfs/nfs-backend.ts b/app/server/modules/backends/nfs/nfs-backend.ts
index 314b2554..876c18b0 100644
--- a/app/server/modules/backends/nfs/nfs-backend.ts
+++ b/app/server/modules/backends/nfs/nfs-backend.ts
@@ -1,5 +1,6 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
+import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
@@ -7,19 +8,24 @@ import { getMountForPath } from "../../../utils/mountinfo";
import { withTimeout } from "../../../utils/timeout";
import type { VolumeBackend } from "../backend";
import { executeMount, executeUnmount } from "../utils/backend-utils";
-import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting volume ${path}...`);
if (config.backend !== "nfs") {
logger.error("Provided config is not for NFS backend");
- return { status: BACKEND_STATUS.error, error: "Provided config is not for NFS backend" };
+ return {
+ status: BACKEND_STATUS.error,
+ error: "Provided config is not for NFS backend",
+ };
}
if (os.platform() !== "linux") {
logger.error("NFS mounting is only supported on Linux hosts.");
- return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." };
+ return {
+ status: BACKEND_STATUS.error,
+ error: "NFS mounting is only supported on Linux hosts.",
+ };
}
const { status } = await checkHealth(path);
@@ -37,6 +43,9 @@ const mount = async (config: BackendConfig, path: string) => {
const source = `${config.server}:${config.exportPath}`;
const options = [`vers=${config.version}`, `port=${config.port}`];
+ if (config.version === "3") {
+ options.push("nolock");
+ }
if (config.readOnly) {
options.push("ro");
}
@@ -47,6 +56,9 @@ const mount = async (config: BackendConfig, path: string) => {
await executeMount(args);
+ // Fallback with -i flag if the first mount fails using the mount helper
+ await executeMount(["-i", ...args]);
+
logger.info(`NFS volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted };
};
@@ -62,7 +74,10 @@ const mount = async (config: BackendConfig, path: string) => {
const unmount = async (path: string) => {
if (os.platform() !== "linux") {
logger.error("NFS unmounting is only supported on Linux hosts.");
- return { status: BACKEND_STATUS.error, error: "NFS unmounting is only supported on Linux hosts." };
+ return {
+ status: BACKEND_STATUS.error,
+ error: "NFS unmounting is only supported on Linux hosts.",
+ };
}
const run = async () => {
@@ -83,7 +98,10 @@ const unmount = async (path: string) => {
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "NFS unmount");
} catch (err) {
- logger.error("Error unmounting NFS volume", { path, error: toMessage(err) });
+ logger.error("Error unmounting NFS volume", {
+ path,
+ error: toMessage(err),
+ });
return { status: BACKEND_STATUS.error, error: toMessage(err) };
}
};
diff --git a/app/server/modules/backends/smb/smb-backend.ts b/app/server/modules/backends/smb/smb-backend.ts
index fd75c8cf..94e2f9e8 100644
--- a/app/server/modules/backends/smb/smb-backend.ts
+++ b/app/server/modules/backends/smb/smb-backend.ts
@@ -61,6 +61,9 @@ const mount = async (config: BackendConfig, path: string) => {
await executeMount(args);
+ // Fallback with -i flag if the first mount fails using the mount helper
+ await executeMount(["-i", ...args]);
+
logger.info(`SMB volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted };
};
diff --git a/app/server/modules/backends/utils/backend-utils.ts b/app/server/modules/backends/utils/backend-utils.ts
index e610217d..20153e59 100644
--- a/app/server/modules/backends/utils/backend-utils.ts
+++ b/app/server/modules/backends/utils/backend-utils.ts
@@ -1,23 +1,30 @@
import * as fs from "node:fs/promises";
import * as npath from "node:path";
+import { $ } from "bun";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
-import { $ } from "bun";
export const executeMount = async (args: string[]): Promise => {
- let stderr: string | undefined;
+ const shouldBeVerbose = process.env.LOG_LEVEL === "debug" || process.env.NODE_ENV !== "production";
+ const hasVerboseFlag = args.some((arg) => arg === "-v" || arg.startsWith("-vv"));
+ const effectiveArgs = shouldBeVerbose && !hasVerboseFlag ? ["-vvv", ...args] : args;
- logger.debug(`Executing mount ${args.join(" ")}`);
- const result = await $`mount ${args}`.nothrow();
- stderr = result.stderr.toString();
+ logger.debug(`Executing mount ${effectiveArgs.join(" ")}`);
+ const result = await $`mount ${effectiveArgs}`.nothrow();
- if (stderr?.trim()) {
- logger.warn(stderr.trim());
+ const stdout = result.stdout.toString().trim();
+ const stderr = result.stderr.toString().trim();
+
+ if (result.exitCode === 0) {
+ if (stdout) logger.debug(stdout);
+ if (stderr) logger.debug(stderr);
+ return;
}
- if (result.exitCode !== 0) {
- throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
- }
+ if (stdout) logger.warn(stdout);
+ if (stderr) logger.warn(stderr);
+
+ throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr || stdout || "unknown error"}`);
};
export const executeUnmount = async (path: string): Promise => {
diff --git a/app/server/modules/backends/webdav/webdav-backend.ts b/app/server/modules/backends/webdav/webdav-backend.ts
index 76eee4cb..775a758d 100644
--- a/app/server/modules/backends/webdav/webdav-backend.ts
+++ b/app/server/modules/backends/webdav/webdav-backend.ts
@@ -58,6 +58,9 @@ const mount = async (config: BackendConfig, path: string) => {
const args = ["-t", "davfs", "-o", options.join(","), source, path];
await executeMount(args);
+ // Fallback with -i flag if the first mount fails using the mount helper
+ await executeMount(["-i", ...args]);
+
logger.info(`WebDAV volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted };
};
diff --git a/app/server/modules/lifecycle/checkpoint.ts b/app/server/modules/lifecycle/checkpoint.ts
deleted file mode 100644
index d9f80a23..00000000
--- a/app/server/modules/lifecycle/checkpoint.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { eq, sql } from "drizzle-orm";
-import { db } from "../../db/db";
-import { appMetadataTable, usersTable } from "../../db/schema";
-import { logger } from "../../utils/logger";
-
-const MIGRATION_KEY_PREFIX = "migration:";
-
-export const recordMigrationCheckpoint = async (version: string): Promise => {
- const key = `${MIGRATION_KEY_PREFIX}${version}`;
- const now = Date.now();
-
- await db
- .insert(appMetadataTable)
- .values({
- key,
- value: JSON.stringify({ completedAt: new Date().toISOString() }),
- createdAt: now,
- updatedAt: now,
- })
- .onConflictDoUpdate({
- target: appMetadataTable.key,
- set: {
- value: JSON.stringify({ completedAt: new Date().toISOString() }),
- updatedAt: now,
- },
- });
-
- logger.info(`Recorded migration checkpoint for ${version}`);
-};
-
-export const hasMigrationCheckpoint = async (version: string): Promise => {
- const key = `${MIGRATION_KEY_PREFIX}${version}`;
- const result = await db.query.appMetadataTable.findFirst({
- where: eq(appMetadataTable.key, key),
- });
- return result !== undefined;
-};
-
-export const validateRequiredMigrations = async (requiredVersions: string[]): Promise => {
- const userCount = await db.select({ count: sql`count(*)` }).from(usersTable);
- const isFreshInstall = userCount[0]?.count === 0;
-
- if (isFreshInstall) {
- logger.info("Fresh installation detected, skipping migration checkpoint validation.");
-
- for (const version of requiredVersions) {
- const hasCheckpoint = await hasMigrationCheckpoint(version);
- if (!hasCheckpoint) {
- await recordMigrationCheckpoint(version);
- }
- }
-
- return;
- }
-
- for (const version of requiredVersions) {
- const hasCheckpoint = await hasMigrationCheckpoint(version);
- if (!hasCheckpoint) {
- logger.error(`
-================================================================================
-MIGRATION ERROR: Required migration ${version} has not been run.
-
-You are attempting to start a version of Zerobyte that requires migration
-checkpoints from previous versions. This typically happens when you skip
-versions during an upgrade.
-
-To fix this:
-1. First upgrade to version ${version} and run the application once
-2. Validate that everything is still working correctly
-3. Then upgrade to the current version
-
-================================================================================
-`);
- process.exit(1);
- }
- }
-};
-
-export const getMigrationCheckpoints = async (): Promise<{ version: string; completedAt: string }[]> => {
- const results = await db.query.appMetadataTable.findMany({
- where: (table, { like }) => like(table.key, `${MIGRATION_KEY_PREFIX}%`),
- });
-
- return results.map((r) => ({
- version: r.key.replace(MIGRATION_KEY_PREFIX, ""),
- completedAt: JSON.parse(r.value).completedAt,
- }));
-};
diff --git a/app/server/modules/lifecycle/migrations.ts b/app/server/modules/lifecycle/migrations.ts
new file mode 100644
index 00000000..83c0edb8
--- /dev/null
+++ b/app/server/modules/lifecycle/migrations.ts
@@ -0,0 +1,114 @@
+import { db } from "~/server/db/db";
+import { logger } from "../../utils/logger";
+import { v00001 } from "./migrations/00001-retag-snapshots";
+import { usersTable } from "~/server/db/schema";
+import { sql } from "drizzle-orm";
+import { eq } from "drizzle-orm";
+import { appMetadataTable } from "../../db/schema";
+
+const MIGRATION_KEY_PREFIX = "migration:";
+
+const recordMigrationCheckpoint = async (version: string): Promise => {
+ const key = `${MIGRATION_KEY_PREFIX}${version}`;
+ const now = Date.now();
+
+ await db
+ .insert(appMetadataTable)
+ .values({ key, value: JSON.stringify({ completedAt: new Date().toISOString() }), createdAt: now, updatedAt: now })
+ .onConflictDoUpdate({
+ target: appMetadataTable.key,
+ set: { value: JSON.stringify({ completedAt: new Date().toISOString() }), updatedAt: now },
+ });
+
+ logger.info(`Recorded migration checkpoint for ${version}`);
+};
+
+const hasMigrationCheckpoint = async (id: string): Promise => {
+ const key = `${MIGRATION_KEY_PREFIX}${id}`;
+ const result = await db.query.appMetadataTable.findFirst({
+ where: eq(appMetadataTable.key, key),
+ });
+ return result !== undefined;
+};
+
+type MigrationEntity = {
+ execute: () => Promise<{ success: boolean; errors: Array<{ name: string; error: string }> }>;
+ id: string;
+ type: "maintenance" | "critical";
+ dependsOn?: string[];
+};
+
+const registry: MigrationEntity[] = [v00001];
+
+export const runMigrations = async () => {
+ const userCount = await db.select({ count: sql`count(*)` }).from(usersTable);
+ const isFreshInstall = userCount[0]?.count === 0;
+
+ if (isFreshInstall) {
+ logger.debug("Fresh installation detected, skipping migration checkpoint validation.");
+
+ for (const migration of registry) {
+ const hasCheckpoint = await hasMigrationCheckpoint(migration.id);
+ if (!hasCheckpoint) {
+ await recordMigrationCheckpoint(migration.id);
+ }
+ }
+
+ return;
+ }
+
+ for (const migration of registry) {
+ const alreadyMigrated = await hasMigrationCheckpoint(migration.id);
+
+ if (alreadyMigrated) {
+ logger.debug(`Migration ${migration.id} already completed, skipping.`);
+ continue;
+ }
+
+ if (migration.dependsOn) {
+ for (const dep of migration.dependsOn) {
+ const depCompleted = await hasMigrationCheckpoint(dep);
+ if (!depCompleted) {
+ const err = [
+ "================================================================================",
+ `🚨 MIGRATION ERROR: Migration ${migration.id} depends on migration ${dep}.`,
+ "The application cannot start until the required migration has successfully completed.",
+ "Please fix the issues and restart the application.",
+ "",
+ "Seek support by opening an issue on the Zerobyte GitHub repository if you need assistance.",
+ "================================================================================",
+ ];
+ err.forEach((line) => logger.error(line));
+ process.exit(1);
+ }
+ }
+ }
+
+ logger.info(`Running migration: ${migration.id} (${migration.type})`);
+ const result = await migration.execute();
+ if (result.success) {
+ logger.info(`Migration ${migration.id} completed successfully.`);
+ await recordMigrationCheckpoint(migration.id);
+ } else {
+ logger.error(`Migration ${migration.id} completed with errors: ${result.errors.length} items failed.`);
+ for (const err of result.errors) {
+ logger.error(`Migration failure - ${err.name}: ${err.error}`);
+ }
+
+ if (migration.type === "critical") {
+ const err = [
+ "================================================================================",
+ `🚨 MIGRATION ERROR: Critical migration ${migration.id} failed.`,
+ "",
+ "The application cannot start until this migration has successfully completed.",
+ "",
+ "Please fix the issues and restart the application. Seek support by opening an issue",
+ "on the Zerobyte GitHub repository if you need assistance.",
+ "================================================================================",
+ ];
+ err.forEach((line) => logger.error(line));
+ process.exit(1);
+ }
+ }
+ }
+};
diff --git a/app/server/modules/lifecycle/migration.ts b/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts
similarity index 60%
rename from app/server/modules/lifecycle/migration.ts
rename to app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts
index 50fa9e5a..234922f8 100644
--- a/app/server/modules/lifecycle/migration.ts
+++ b/app/server/modules/lifecycle/migrations/00001-retag-snapshots.ts
@@ -1,60 +1,11 @@
import { eq } from "drizzle-orm";
-import { db } from "../../db/db";
-import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../db/schema";
-import { logger } from "../../utils/logger";
-import { hasMigrationCheckpoint, recordMigrationCheckpoint } from "./checkpoint";
+import { db } from "../../../db/db";
+import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../../db/schema";
+import { logger } from "../../../utils/logger";
import { toMessage } from "~/server/utils/errors";
import { safeSpawn } from "~/server/utils/spawn";
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "~/server/utils/restic";
-const MIGRATION_VERSION = "v0.21.1";
-
-interface MigrationResult {
- success: boolean;
- errors: Array<{ name: string; error: string }>;
-}
-
-export class MigrationError extends Error {
- version: string;
- failedItems: Array<{ name: string; error: string }>;
-
- constructor(version: string, failedItems: Array<{ name: string; error: string }>) {
- const itemNames = failedItems.map((e) => e.name).join(", ");
- super(`Migration ${version} failed for: ${itemNames}`);
- this.version = version;
- this.failedItems = failedItems;
- this.name = "MigrationError";
- }
-}
-
-export const retagSnapshots = async () => {
- const alreadyMigrated = await hasMigrationCheckpoint(MIGRATION_VERSION);
- if (alreadyMigrated) {
- logger.debug(`Migration ${MIGRATION_VERSION} already completed, skipping.`);
- return;
- }
-
- logger.info(`Starting snapshots retagging migration (${MIGRATION_VERSION})...`);
-
- const result = await migrateSnapshotsToShortIdTag();
- const allErrors = [...result.errors];
-
- if (allErrors.length > 0) {
- logger.error(`Migration ${MIGRATION_VERSION} completed with errors: ${allErrors.length} items failed.`);
- logger.error(
- `Some snapshots could not be retagged. Please check the logs for details. Fix any repository in error state and re-start zerobyte to retry the migration for failed items.`,
- );
- for (const err of allErrors) {
- logger.error(`Migration failure - ${err.name}: ${err.error}`);
- }
-
- return;
- }
-
- await recordMigrationCheckpoint(MIGRATION_VERSION);
- logger.info(`Snapshots retagging migration (${MIGRATION_VERSION}) complete.`);
-};
-
const migrateTag = async (
oldTag: string,
newTag: string,
@@ -81,7 +32,7 @@ const migrateTag = async (
return null;
};
-const migrateSnapshotsToShortIdTag = async (): Promise => {
+const execute = async () => {
const errors: Array<{ name: string; error: string }> = [];
const backupSchedules = await db.query.backupSchedulesTable.findMany({});
@@ -134,3 +85,9 @@ const migrateSnapshotsToShortIdTag = async (): Promise => {
return { success: errors.length === 0, errors };
};
+
+export const v00001 = {
+ execute,
+ id: "00001-retag-snapshots",
+ type: "maintenance" as const,
+};
diff --git a/app/server/modules/lifecycle/startup.ts b/app/server/modules/lifecycle/startup.ts
index 9d18aaa4..f7e5fadb 100644
--- a/app/server/modules/lifecycle/startup.ts
+++ b/app/server/modules/lifecycle/startup.ts
@@ -13,6 +13,8 @@ import { repositoriesService } from "../repositories/repositories.service";
import { notificationsService } from "../notifications/notifications.service";
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
import { cache } from "~/server/utils/cache";
+import { initAuth } from "~/lib/auth";
+import { toMessage } from "~/server/utils/errors";
const ensureLatestConfigurationSchema = async () => {
const volumes = await db.query.volumesTable.findMany({});
@@ -50,6 +52,11 @@ export const startup = async () => {
logger.error(`Error ensuring restic passfile exists: ${err.message}`);
});
+ await initAuth().catch((err) => {
+ logger.error(`Error initializing auth: ${toMessage(err)}`);
+ throw err;
+ });
+
await ensureLatestConfigurationSchema();
const volumes = await db.query.volumesTable.findMany({
diff --git a/app/server/utils/logger.ts b/app/server/utils/logger.ts
index 6d106f60..2bc39507 100644
--- a/app/server/utils/logger.ts
+++ b/app/server/utils/logger.ts
@@ -27,7 +27,7 @@ const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) =>
return sanitizeSensitiveData(JSON.stringify(m, null, 2));
}
- return sanitizeSensitiveData(String(JSON.stringify(m)));
+ return sanitizeSensitiveData(String(m as string));
});
winstonLogger.log(level, stringMessages.join(" "));
diff --git a/app/test/setup-client.ts b/app/test/setup-client.ts
index f737ee3e..a0faa585 100644
--- a/app/test/setup-client.ts
+++ b/app/test/setup-client.ts
@@ -1,4 +1,4 @@
import "./setup.ts";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
-GlobalRegistrator.register();
+GlobalRegistrator.register({ url: "http://localhost:3000" });
diff --git a/app/test/setup.ts b/app/test/setup.ts
index 81927747..ea73e5b0 100644
--- a/app/test/setup.ts
+++ b/app/test/setup.ts
@@ -4,6 +4,7 @@ import path from "node:path";
import { cwd } from "node:process";
import * as schema from "~/server/db/schema";
import { db, setSchema } from "~/server/db/db";
+import { initAuth } from "~/lib/auth";
setSchema(schema);
@@ -27,4 +28,5 @@ void mock.module("~/server/utils/crypto", () => ({
beforeAll(async () => {
const migrationsFolder = path.join(cwd(), "app", "drizzle");
migrate(db, { migrationsFolder });
+ await initAuth();
});