feat: schema-check (#25904)

This commit is contained in:
Jason Rasmussen
2026-02-12 17:59:00 -05:00
committed by GitHub
parent 7413356a2f
commit 8ef4e4d452
37 changed files with 449 additions and 213 deletions

View File

@@ -1,15 +1,59 @@
import { Injectable } from '@nestjs/common';
import { isAbsolute } from 'node:path';
import { isAbsolute, join } from 'node:path';
import { SALT_ROUNDS } from 'src/constants';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { schemaDiff } from 'src/sql-tools';
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
export type SchemaReport = {
migrations: MigrationStatus[];
drift: ReturnType<typeof schemaDiff>;
};
type MigrationStatus = {
name: string;
status: 'applied' | 'missing' | 'deleted';
};
@Injectable()
export class CliService extends BaseService {
async schemaReport(): Promise<SchemaReport> {
// eslint-disable-next-line unicorn/prefer-module
const allFiles = await this.storageRepository.readdir(join(__dirname, '../schema/migrations'));
const files = allFiles.filter((file) => file.endsWith('.js')).map((file) => file.slice(0, -3));
const rows = await this.databaseRepository.getMigrations();
const filesSet = new Set(files);
const rowsSet = new Set(rows.map((item) => item.name));
const combined = [...filesSet, ...rowsSet].toSorted();
const migrations: MigrationStatus[] = [];
for (const name of combined) {
if (filesSet.has(name) && rowsSet.has(name)) {
migrations.push({ name, status: 'applied' });
continue;
}
if (filesSet.has(name) && !rowsSet.has(name)) {
migrations.push({ name, status: 'missing' });
continue;
}
if (!filesSet.has(name) && rowsSet.has(name)) {
migrations.push({ name, status: 'deleted' });
continue;
}
}
const drift = await this.databaseRepository.getSchemaDrift();
return { migrations, drift };
}
async listUsers(): Promise<UserAdminResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: true });
return users.map((user) => mapUserAdmin(user));

View File

@@ -21,6 +21,11 @@ describe(DatabaseService.name, () => {
extensionRange = '0.2.x';
mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.VectorChord);
mocks.database.getExtensionVersionRange.mockReturnValue(extensionRange);
mocks.database.getSchemaDrift.mockResolvedValue({
items: [],
asSql: () => [],
asHuman: () => [],
});
versionBelowRange = '0.1.0';
minVersionInRange = '0.2.0';

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import semver from 'semver';
import { EXTENSION_NAMES, VECTOR_EXTENSIONS } from 'src/constants';
import { ErrorMessages, EXTENSION_NAMES, VECTOR_EXTENSIONS } from 'src/constants';
import { OnEvent } from 'src/decorators';
import { BootstrapEventPriority, DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum';
import { BaseService } from 'src/services/base.service';
@@ -124,6 +124,17 @@ export class DatabaseService extends BaseService {
const { database } = this.configRepository.getEnv();
if (!database.skipMigrations) {
await this.databaseRepository.runMigrations();
this.logger.log('Checking for schema drift');
const drift = await this.databaseRepository.getSchemaDrift();
if (drift.items.length === 0) {
this.logger.log('No schema drift detected');
} else {
this.logger.warn(`${ErrorMessages.SchemaDrift} or run \`immich-admin schema-check\``);
for (const warning of drift.asHuman()) {
this.logger.warn(` - ${warning}`);
}
}
}
await Promise.all([
this.databaseRepository.prewarm(VectorIndex.Clip),

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { ErrorMessages } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import {
@@ -114,9 +115,7 @@ export class StorageService extends BaseService {
this.logger.log(`Media location changed (from=${previous}, to=${current})`);
if (!path.startsWith(previous)) {
throw new Error(
'Detected an inconsistent media location. For more information, see https://docs.immich.app/errors#inconsistent-media-location',
);
throw new Error(ErrorMessages.InconsistentMediaLocation);
}
this.logger.warn(