mirror of
https://github.com/immich-app/immich.git
synced 2024-11-24 08:52:28 +02:00
refactor: server emit events (#11780)
This commit is contained in:
parent
32c05ea950
commit
433c7ab01d
@ -5,6 +5,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@ne
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import _ from 'lodash';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { OpenTelemetryModule } from 'nestjs-otel';
|
||||
import { commands } from 'src/commands';
|
||||
@ -13,6 +14,7 @@ import { controllers } from 'src/controllers';
|
||||
import { databaseConfig } from 'src/database.config';
|
||||
import { entities } from 'src/entities';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { AuthGuard } from 'src/middleware/auth.guard';
|
||||
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
@ -54,15 +56,25 @@ export class ApiModule implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
setupEventHandlers(this.moduleRef);
|
||||
await this.eventRepository.emit('onBootstrapEvent', 'api');
|
||||
const items = setupEventHandlers(this.moduleRef);
|
||||
|
||||
await this.eventRepository.emit('onBootstrap', 'api');
|
||||
|
||||
this.logger.setContext('EventLoader');
|
||||
const eventMap = _.groupBy(items, 'event');
|
||||
for (const [event, handlers] of Object.entries(eventMap)) {
|
||||
for (const { priority, label } of handlers) {
|
||||
this.logger.verbose(`Added ${event} {${label}${priority ? '' : ', ' + priority}} event`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.eventRepository.emit('onShutdownEvent');
|
||||
await this.eventRepository.emit('onShutdown');
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,11 +90,11 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async onModuleInit() {
|
||||
setupEventHandlers(this.moduleRef);
|
||||
await this.eventRepository.emit('onBootstrapEvent', 'microservices');
|
||||
await this.eventRepository.emit('onBootstrap', 'microservices');
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.eventRepository.emit('onShutdownEvent');
|
||||
await this.eventRepository.emit('onShutdown');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,7 @@ import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces';
|
||||
import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
|
||||
import _ from 'lodash';
|
||||
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
|
||||
import { ServerEvent } from 'src/interfaces/event.interface';
|
||||
import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface';
|
||||
import { Metadata } from 'src/middleware/auth.guard';
|
||||
import { setUnion } from 'src/utils/set';
|
||||
|
||||
@ -136,11 +136,12 @@ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GEN
|
||||
export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) =>
|
||||
OnEvent(event, { suppressErrors: false, ...options });
|
||||
|
||||
export type HandlerOptions = {
|
||||
export type EmitConfig = {
|
||||
event: EmitEvent;
|
||||
/** lower value has higher priority, defaults to 0 */
|
||||
priority: number;
|
||||
priority?: number;
|
||||
};
|
||||
export const EventHandlerOptions = (options: HandlerOptions) => SetMetadata(Metadata.EVENT_HANDLER_OPTIONS, options);
|
||||
export const OnEmit = (config: EmitConfig) => SetMetadata(Metadata.ON_EMIT_CONFIG, config);
|
||||
|
||||
type LifecycleRelease = 'NEXT_RELEASE' | string;
|
||||
type LifecycleMetadata = {
|
||||
|
@ -4,41 +4,27 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d
|
||||
|
||||
export const IEventRepository = 'IEventRepository';
|
||||
|
||||
export type SystemConfigUpdateEvent = { newConfig: SystemConfig; oldConfig: SystemConfig };
|
||||
export type AlbumUpdateEvent = {
|
||||
id: string;
|
||||
/** user id */
|
||||
updatedBy: string;
|
||||
};
|
||||
export type AlbumInviteEvent = { id: string; userId: string };
|
||||
export type UserSignupEvent = { notify: boolean; id: string; tempPassword?: string };
|
||||
|
||||
type MaybePromise<T> = Promise<T> | T;
|
||||
type Handler<T = undefined> = (data: T) => MaybePromise<void>;
|
||||
|
||||
const noop = () => {};
|
||||
const dummyHandlers = {
|
||||
type EmitEventMap = {
|
||||
// app events
|
||||
onBootstrapEvent: noop as Handler<'api' | 'microservices'>,
|
||||
onShutdownEvent: noop as () => MaybePromise<void>,
|
||||
onBootstrap: ['api' | 'microservices'];
|
||||
onShutdown: [];
|
||||
|
||||
// config events
|
||||
onConfigUpdateEvent: noop as Handler<SystemConfigUpdateEvent>,
|
||||
onConfigValidateEvent: noop as Handler<SystemConfigUpdateEvent>,
|
||||
onConfigUpdate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
|
||||
onConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
|
||||
|
||||
// album events
|
||||
onAlbumUpdateEvent: noop as Handler<AlbumUpdateEvent>,
|
||||
onAlbumInviteEvent: noop as Handler<AlbumInviteEvent>,
|
||||
onAlbumUpdate: [{ id: string; updatedBy: string }];
|
||||
onAlbumInvite: [{ id: string; userId: string }];
|
||||
|
||||
// user events
|
||||
onUserSignupEvent: noop as Handler<UserSignupEvent>,
|
||||
onUserSignup: [{ notify: boolean; id: string; tempPassword?: string }];
|
||||
};
|
||||
|
||||
export type EventHandlers = typeof dummyHandlers;
|
||||
export type EmitEvent = keyof EventHandlers;
|
||||
export type EmitEventHandler<T extends EmitEvent> = (...args: Parameters<EventHandlers[T]>) => MaybePromise<void>;
|
||||
export const events = Object.keys(dummyHandlers) as EmitEvent[];
|
||||
export type OnEvents = Partial<EventHandlers>;
|
||||
export type EmitEvent = keyof EmitEventMap;
|
||||
export type EmitHandler<T extends EmitEvent> = (...args: ArgsOf<T>) => Promise<void> | void;
|
||||
export type ArgOf<T extends EmitEvent> = EmitEventMap[T][0];
|
||||
export type ArgsOf<T extends EmitEvent> = EmitEventMap[T];
|
||||
|
||||
export enum ClientEvent {
|
||||
UPLOAD_SUCCESS = 'on_upload_success',
|
||||
@ -81,8 +67,8 @@ export interface ServerEventMap {
|
||||
}
|
||||
|
||||
export interface IEventRepository {
|
||||
on<T extends EmitEvent>(event: T, handler: EmitEventHandler<T>): void;
|
||||
emit<T extends EmitEvent>(event: T, ...args: Parameters<EmitEventHandler<T>>): Promise<void>;
|
||||
on<T extends keyof EmitEventMap>(event: T, handler: EmitHandler<T>): void;
|
||||
emit<T extends keyof EmitEventMap>(event: T, ...args: ArgsOf<T>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send to connected clients for a specific user
|
||||
|
@ -20,7 +20,7 @@ export enum Metadata {
|
||||
ADMIN_ROUTE = 'admin_route',
|
||||
SHARED_ROUTE = 'shared_route',
|
||||
API_KEY_SECURITY = 'api_key',
|
||||
EVENT_HANDLER_OPTIONS = 'event_handler_options',
|
||||
ON_EMIT_CONFIG = 'on_emit_config',
|
||||
}
|
||||
|
||||
type AdminRoute = { admin?: true };
|
||||
|
@ -9,9 +9,10 @@ import {
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import {
|
||||
ArgsOf,
|
||||
ClientEventMap,
|
||||
EmitEvent,
|
||||
EmitEventHandler,
|
||||
EmitHandler,
|
||||
IEventRepository,
|
||||
ServerEvent,
|
||||
ServerEventMap,
|
||||
@ -20,6 +21,8 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
|
||||
type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler<T>[] }>;
|
||||
|
||||
@Instrumentation()
|
||||
@WebSocketGateway({
|
||||
cors: true,
|
||||
@ -28,7 +31,7 @@ import { Instrumentation } from 'src/utils/instrumentation';
|
||||
})
|
||||
@Injectable()
|
||||
export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, IEventRepository {
|
||||
private emitHandlers: Partial<Record<EmitEvent, EmitEventHandler<EmitEvent>[]>> = {};
|
||||
private emitHandlers: EmitHandlers = {};
|
||||
|
||||
@WebSocketServer()
|
||||
private server?: Server;
|
||||
@ -78,12 +81,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
||||
await client.leave(client.nsp.name);
|
||||
}
|
||||
|
||||
on<T extends EmitEvent>(event: T, handler: EmitEventHandler<T>): void {
|
||||
const handlers: EmitEventHandler<EmitEvent>[] = this.emitHandlers[event] || [];
|
||||
this.emitHandlers[event] = [...handlers, handler];
|
||||
on<T extends EmitEvent>(event: T, handler: EmitHandler<T>): void {
|
||||
if (!this.emitHandlers[event]) {
|
||||
this.emitHandlers[event] = [];
|
||||
}
|
||||
|
||||
this.emitHandlers[event].push(handler);
|
||||
}
|
||||
|
||||
async emit<T extends EmitEvent>(event: T, ...args: Parameters<EmitEventHandler<T>>): Promise<void> {
|
||||
async emit<T extends EmitEvent>(event: T, ...args: ArgsOf<T>): Promise<void> {
|
||||
const handlers = this.emitHandlers[event] || [];
|
||||
for (const handler of handlers) {
|
||||
await handler(...args);
|
||||
|
@ -380,7 +380,7 @@ describe(AlbumService.name, () => {
|
||||
userId: authStub.user2.user.id,
|
||||
albumId: albumStub.sharedWithAdmin.id,
|
||||
});
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInviteEvent', {
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('onAlbumInvite', {
|
||||
id: albumStub.sharedWithAdmin.id,
|
||||
userId: userStub.user2.id,
|
||||
});
|
||||
@ -568,7 +568,7 @@ describe(AlbumService.name, () => {
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdateEvent', {
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', {
|
||||
id: 'album-123',
|
||||
updatedBy: authStub.admin.user.id,
|
||||
});
|
||||
@ -612,7 +612,7 @@ describe(AlbumService.name, () => {
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdateEvent', {
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('onAlbumUpdate', {
|
||||
id: 'album-123',
|
||||
updatedBy: authStub.user1.user.id,
|
||||
});
|
||||
|
@ -187,7 +187,7 @@ export class AlbumService {
|
||||
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
|
||||
});
|
||||
|
||||
await this.eventRepository.emit('onAlbumUpdateEvent', { id, updatedBy: auth.user.id });
|
||||
await this.eventRepository.emit('onAlbumUpdate', { id, updatedBy: auth.user.id });
|
||||
}
|
||||
|
||||
return results;
|
||||
@ -235,7 +235,7 @@ export class AlbumService {
|
||||
}
|
||||
|
||||
await this.albumUserRepository.create({ userId: userId, albumId: id, role });
|
||||
await this.eventRepository.emit('onAlbumInviteEvent', { id, userId });
|
||||
await this.eventRepository.emit('onAlbumInvite', { id, userId });
|
||||
}
|
||||
|
||||
return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets);
|
||||
|
@ -45,7 +45,7 @@ describe(DatabaseService.name, () => {
|
||||
it('should throw an error if PostgreSQL version is below minimum supported version', async () => {
|
||||
databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0');
|
||||
|
||||
await expect(sut.onBootstrapEvent()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0');
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0');
|
||||
|
||||
expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@ -65,7 +65,7 @@ describe(DatabaseService.name, () => {
|
||||
availableVersion: minVersionInRange,
|
||||
});
|
||||
|
||||
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined();
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(databaseMock.getPostgresVersion).toHaveBeenCalled();
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledWith(extension);
|
||||
@ -79,7 +79,7 @@ describe(DatabaseService.name, () => {
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null });
|
||||
const message = `The ${extensionName} extension is not available in this Postgres instance.
|
||||
If using a container image, ensure the image has the extension installed.`;
|
||||
await expect(sut.onBootstrapEvent()).rejects.toThrow(message);
|
||||
await expect(sut.onBootstrap()).rejects.toThrow(message);
|
||||
|
||||
expect(databaseMock.createExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
@ -91,7 +91,7 @@ describe(DatabaseService.name, () => {
|
||||
availableVersion: versionBelowRange,
|
||||
});
|
||||
|
||||
await expect(sut.onBootstrapEvent()).rejects.toThrow(
|
||||
await expect(sut.onBootstrap()).rejects.toThrow(
|
||||
`The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`,
|
||||
);
|
||||
|
||||
@ -101,7 +101,7 @@ describe(DatabaseService.name, () => {
|
||||
it(`should throw an error if ${extension} extension version is a nightly`, async () => {
|
||||
databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' });
|
||||
|
||||
await expect(sut.onBootstrapEvent()).rejects.toThrow(
|
||||
await expect(sut.onBootstrap()).rejects.toThrow(
|
||||
`The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`,
|
||||
);
|
||||
|
||||
@ -117,7 +117,7 @@ describe(DatabaseService.name, () => {
|
||||
});
|
||||
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
||||
|
||||
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined();
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
|
||||
expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1);
|
||||
@ -132,7 +132,7 @@ describe(DatabaseService.name, () => {
|
||||
installedVersion: minVersionInRange,
|
||||
});
|
||||
|
||||
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined();
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
|
||||
@ -145,7 +145,7 @@ describe(DatabaseService.name, () => {
|
||||
installedVersion: null,
|
||||
});
|
||||
|
||||
await expect(sut.onBootstrapEvent()).rejects.toThrow();
|
||||
await expect(sut.onBootstrap()).rejects.toThrow();
|
||||
|
||||
expect(databaseMock.createExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
|
||||
@ -159,7 +159,7 @@ describe(DatabaseService.name, () => {
|
||||
installedVersion: minVersionInRange,
|
||||
});
|
||||
|
||||
await expect(sut.onBootstrapEvent()).rejects.toThrow();
|
||||
await expect(sut.onBootstrap()).rejects.toThrow();
|
||||
|
||||
expect(databaseMock.createExtension).not.toHaveBeenCalled();
|
||||
expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled();
|
||||
@ -173,7 +173,7 @@ describe(DatabaseService.name, () => {
|
||||
installedVersion: updateInRange,
|
||||
});
|
||||
|
||||
await expect(sut.onBootstrapEvent()).rejects.toThrow(
|
||||
await expect(sut.onBootstrap()).rejects.toThrow(
|
||||
`The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`,
|
||||
);
|
||||
|
||||
@ -189,7 +189,7 @@ describe(DatabaseService.name, () => {
|
||||
});
|
||||
databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension'));
|
||||
|
||||
await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to update extension');
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension');
|
||||
|
||||
expect(loggerMock.warn.mock.calls[0][0]).toContain(
|
||||
`The ${extensionName} extension can be updated to ${updateInRange}.`,
|
||||
@ -206,7 +206,7 @@ describe(DatabaseService.name, () => {
|
||||
});
|
||||
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true });
|
||||
|
||||
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined();
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName);
|
||||
@ -218,7 +218,7 @@ describe(DatabaseService.name, () => {
|
||||
it(`should reindex ${extension} indices if needed`, async () => {
|
||||
databaseMock.shouldReindex.mockResolvedValue(true);
|
||||
|
||||
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined();
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
|
||||
expect(databaseMock.reindex).toHaveBeenCalledTimes(2);
|
||||
@ -229,7 +229,7 @@ describe(DatabaseService.name, () => {
|
||||
it(`should not reindex ${extension} indices if not needed`, async () => {
|
||||
databaseMock.shouldReindex.mockResolvedValue(false);
|
||||
|
||||
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined();
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2);
|
||||
expect(databaseMock.reindex).toHaveBeenCalledTimes(0);
|
||||
@ -240,7 +240,7 @@ describe(DatabaseService.name, () => {
|
||||
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
|
||||
process.env.DB_SKIP_MIGRATIONS = 'true';
|
||||
|
||||
await expect(sut.onBootstrapEvent()).resolves.toBeUndefined();
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -255,7 +255,7 @@ describe(DatabaseService.name, () => {
|
||||
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
||||
databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension'));
|
||||
|
||||
await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension');
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
|
||||
|
||||
expect(loggerMock.fatal).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.fatal.mock.calls[0][0]).toContain(
|
||||
@ -274,7 +274,7 @@ describe(DatabaseService.name, () => {
|
||||
databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
||||
databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension'));
|
||||
|
||||
await expect(sut.onBootstrapEvent()).rejects.toThrow('Failed to create extension');
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
|
||||
|
||||
expect(loggerMock.fatal).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.fatal.mock.calls[0][0]).toContain(
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import semver from 'semver';
|
||||
import { getVectorExtension } from 'src/database.config';
|
||||
import { EventHandlerOptions } from 'src/decorators';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import {
|
||||
DatabaseExtension,
|
||||
DatabaseLock,
|
||||
@ -10,7 +10,6 @@ import {
|
||||
VectorExtension,
|
||||
VectorIndex,
|
||||
} from 'src/interfaces/database.interface';
|
||||
import { OnEvents } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
|
||||
type CreateFailedArgs = { name: string; extension: string; otherName: string };
|
||||
@ -61,7 +60,7 @@ const messages = {
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnEvents {
|
||||
export class DatabaseService {
|
||||
constructor(
|
||||
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
@ -69,8 +68,8 @@ export class DatabaseService implements OnEvents {
|
||||
this.logger.setContext(DatabaseService.name);
|
||||
}
|
||||
|
||||
@EventHandlerOptions({ priority: -200 })
|
||||
async onBootstrapEvent() {
|
||||
@OnEmit({ event: 'onBootstrap', priority: -200 })
|
||||
async onBootstrap() {
|
||||
const version = await this.databaseRepository.getPostgresVersion();
|
||||
const current = semver.coerce(version);
|
||||
const postgresRange = this.databaseRepository.getPostgresVersionRange();
|
||||
|
@ -73,7 +73,7 @@ describe(LibraryService.name, () => {
|
||||
it('should init cron job and subscribe to config changes', async () => {
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.libraryScan);
|
||||
|
||||
await sut.onBootstrapEvent();
|
||||
await sut.onBootstrap();
|
||||
expect(systemMock.get).toHaveBeenCalled();
|
||||
expect(jobMock.addCronJob).toHaveBeenCalled();
|
||||
|
||||
@ -105,7 +105,7 @@ describe(LibraryService.name, () => {
|
||||
),
|
||||
);
|
||||
|
||||
await sut.onBootstrapEvent();
|
||||
await sut.onBootstrap();
|
||||
|
||||
expect(storageMock.watch.mock.calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
@ -118,7 +118,7 @@ describe(LibraryService.name, () => {
|
||||
it('should not initialize watcher when watching is disabled', async () => {
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
|
||||
|
||||
await sut.onBootstrapEvent();
|
||||
await sut.onBootstrap();
|
||||
|
||||
expect(storageMock.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -127,7 +127,7 @@ describe(LibraryService.name, () => {
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
|
||||
databaseMock.tryLock.mockResolvedValue(false);
|
||||
|
||||
await sut.onBootstrapEvent();
|
||||
await sut.onBootstrap();
|
||||
|
||||
expect(storageMock.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -136,7 +136,7 @@ describe(LibraryService.name, () => {
|
||||
describe('onConfigValidateEvent', () => {
|
||||
it('should allow a valid cron expression', () => {
|
||||
expect(() =>
|
||||
sut.onConfigValidateEvent({
|
||||
sut.onConfigValidate({
|
||||
newConfig: { library: { scan: { cronExpression: '0 0 * * *' } } } as SystemConfig,
|
||||
oldConfig: {} as SystemConfig,
|
||||
}),
|
||||
@ -145,7 +145,7 @@ describe(LibraryService.name, () => {
|
||||
|
||||
it('should fail for an invalid cron expression', () => {
|
||||
expect(() =>
|
||||
sut.onConfigValidateEvent({
|
||||
sut.onConfigValidate({
|
||||
newConfig: { library: { scan: { cronExpression: 'foo' } } } as SystemConfig,
|
||||
oldConfig: {} as SystemConfig,
|
||||
}),
|
||||
@ -730,7 +730,7 @@ describe(LibraryService.name, () => {
|
||||
const mockClose = vitest.fn();
|
||||
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
|
||||
|
||||
await sut.onBootstrapEvent();
|
||||
await sut.onBootstrap();
|
||||
await sut.delete(libraryStub.externalLibraryWithImportPaths1.id);
|
||||
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
@ -861,7 +861,7 @@ describe(LibraryService.name, () => {
|
||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
||||
libraryMock.getAll.mockResolvedValue([]);
|
||||
|
||||
await sut.onBootstrapEvent();
|
||||
await sut.onBootstrap();
|
||||
await sut.create({
|
||||
ownerId: authStub.admin.user.id,
|
||||
importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths,
|
||||
@ -917,7 +917,7 @@ describe(LibraryService.name, () => {
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
|
||||
libraryMock.getAll.mockResolvedValue([]);
|
||||
|
||||
await sut.onBootstrapEvent();
|
||||
await sut.onBootstrap();
|
||||
});
|
||||
|
||||
it('should update library', async () => {
|
||||
@ -933,7 +933,7 @@ describe(LibraryService.name, () => {
|
||||
beforeEach(async () => {
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
|
||||
|
||||
await sut.onBootstrapEvent();
|
||||
await sut.onBootstrap();
|
||||
});
|
||||
|
||||
it('should not watch library', async () => {
|
||||
@ -949,7 +949,7 @@ describe(LibraryService.name, () => {
|
||||
beforeEach(async () => {
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
|
||||
libraryMock.getAll.mockResolvedValue([]);
|
||||
await sut.onBootstrapEvent();
|
||||
await sut.onBootstrap();
|
||||
});
|
||||
|
||||
it('should watch library', async () => {
|
||||
@ -1107,8 +1107,8 @@ describe(LibraryService.name, () => {
|
||||
const mockClose = vitest.fn();
|
||||
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
|
||||
|
||||
await sut.onBootstrapEvent();
|
||||
await sut.onShutdownEvent();
|
||||
await sut.onBootstrap();
|
||||
await sut.onShutdown();
|
||||
|
||||
expect(mockClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
@ -6,6 +6,7 @@ import path, { basename, parse } from 'node:path';
|
||||
import picomatch from 'picomatch';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import {
|
||||
CreateLibraryDto,
|
||||
LibraryResponseDto,
|
||||
@ -22,7 +23,7 @@ import { AssetType } from 'src/enum';
|
||||
import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IBaseJob,
|
||||
IEntityJob,
|
||||
@ -45,7 +46,7 @@ import { validateCronExpression } from 'src/validation';
|
||||
const LIBRARY_SCAN_BATCH_SIZE = 5000;
|
||||
|
||||
@Injectable()
|
||||
export class LibraryService implements OnEvents {
|
||||
export class LibraryService {
|
||||
private configCore: SystemConfigCore;
|
||||
private watchLibraries = false;
|
||||
private watchLock = false;
|
||||
@ -65,7 +66,8 @@ export class LibraryService implements OnEvents {
|
||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||
}
|
||||
|
||||
async onBootstrapEvent() {
|
||||
@OnEmit({ event: 'onBootstrap' })
|
||||
async onBootstrap() {
|
||||
const config = await this.configCore.getConfig({ withCache: false });
|
||||
|
||||
const { watch, scan } = config.library;
|
||||
@ -102,7 +104,7 @@ export class LibraryService implements OnEvents {
|
||||
});
|
||||
}
|
||||
|
||||
onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) {
|
||||
onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) {
|
||||
const { scan } = newConfig.library;
|
||||
if (!validateCronExpression(scan.cronExpression)) {
|
||||
throw new Error(`Invalid cron expression ${scan.cronExpression}`);
|
||||
@ -187,7 +189,8 @@ export class LibraryService implements OnEvents {
|
||||
}
|
||||
}
|
||||
|
||||
async onShutdownEvent() {
|
||||
@OnEmit({ event: 'onShutdown' })
|
||||
async onShutdown() {
|
||||
await this.unwatchAll();
|
||||
}
|
||||
|
||||
|
@ -95,7 +95,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await sut.onShutdownEvent();
|
||||
await sut.onShutdown();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@ -104,7 +104,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
describe('onBootstrapEvent', () => {
|
||||
it('should pause and resume queue during init', async () => {
|
||||
await sut.onBootstrapEvent('microservices');
|
||||
await sut.onBootstrap('microservices');
|
||||
|
||||
expect(jobMock.pause).toHaveBeenCalledTimes(1);
|
||||
expect(mapMock.init).toHaveBeenCalledTimes(1);
|
||||
@ -114,7 +114,7 @@ describe(MetadataService.name, () => {
|
||||
it('should return if reverse geocoding is disabled', async () => {
|
||||
systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: false } });
|
||||
|
||||
await sut.onBootstrapEvent('microservices');
|
||||
await sut.onBootstrap('microservices');
|
||||
|
||||
expect(jobMock.pause).not.toHaveBeenCalled();
|
||||
expect(mapMock.init).not.toHaveBeenCalled();
|
||||
|
@ -8,6 +8,7 @@ import path from 'node:path';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { AssetType } from 'src/enum';
|
||||
@ -15,7 +16,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { ClientEvent, IEventRepository, OnEvents } from 'src/interfaces/event.interface';
|
||||
import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IBaseJob,
|
||||
IEntityJob,
|
||||
@ -86,7 +87,7 @@ const validate = <T>(value: T): NonNullable<T> | null => {
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MetadataService implements OnEvents {
|
||||
export class MetadataService {
|
||||
private storageCore: StorageCore;
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
@ -120,7 +121,8 @@ export class MetadataService implements OnEvents {
|
||||
);
|
||||
}
|
||||
|
||||
async onBootstrapEvent(app: 'api' | 'microservices') {
|
||||
@OnEmit({ event: 'onBootstrap' })
|
||||
async onBootstrap(app: ArgOf<'onBootstrap'>) {
|
||||
if (app !== 'microservices') {
|
||||
return;
|
||||
}
|
||||
@ -128,7 +130,8 @@ export class MetadataService implements OnEvents {
|
||||
await this.init(config);
|
||||
}
|
||||
|
||||
async onConfigUpdateEvent({ newConfig }: { newConfig: SystemConfig }) {
|
||||
@OnEmit({ event: 'onConfigUpdate' })
|
||||
async onConfigUpdate({ newConfig }: ArgOf<'onConfigUpdate'>) {
|
||||
await this.init(newConfig);
|
||||
}
|
||||
|
||||
@ -150,7 +153,8 @@ export class MetadataService implements OnEvents {
|
||||
}
|
||||
}
|
||||
|
||||
async onShutdownEvent() {
|
||||
@OnEmit({ event: 'onShutdown' })
|
||||
async onShutdown() {
|
||||
await this.repository.teardown();
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvents } from 'src/interfaces/event.interface';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AuditService } from 'src/services/audit.service';
|
||||
@ -19,7 +20,7 @@ import { VersionService } from 'src/services/version.service';
|
||||
import { otelShutdown } from 'src/utils/instrumentation';
|
||||
|
||||
@Injectable()
|
||||
export class MicroservicesService implements OnEvents {
|
||||
export class MicroservicesService {
|
||||
constructor(
|
||||
private auditService: AuditService,
|
||||
private assetService: AssetService,
|
||||
@ -38,7 +39,8 @@ export class MicroservicesService implements OnEvents {
|
||||
private versionService: VersionService,
|
||||
) {}
|
||||
|
||||
async onBootstrapEvent(app: 'api' | 'microservices') {
|
||||
@OnEmit({ event: 'onBootstrap' })
|
||||
async onBootstrap(app: ArgOf<'onBootstrap'>) {
|
||||
if (app !== 'microservices') {
|
||||
return;
|
||||
}
|
||||
|
@ -90,7 +90,7 @@ describe(NotificationService.name, () => {
|
||||
const newConfig = configs.smtpEnabled;
|
||||
|
||||
notificationMock.verifySmtp.mockResolvedValue(true);
|
||||
await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow();
|
||||
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
|
||||
expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport);
|
||||
});
|
||||
|
||||
@ -99,7 +99,7 @@ describe(NotificationService.name, () => {
|
||||
const newConfig = configs.smtpTransport;
|
||||
|
||||
notificationMock.verifySmtp.mockResolvedValue(true);
|
||||
await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow();
|
||||
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
|
||||
expect(notificationMock.verifySmtp).toHaveBeenCalledWith(newConfig.notifications.smtp.transport);
|
||||
});
|
||||
|
||||
@ -107,7 +107,7 @@ describe(NotificationService.name, () => {
|
||||
const oldConfig = { ...configs.smtpEnabled };
|
||||
const newConfig = { ...configs.smtpEnabled };
|
||||
|
||||
await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow();
|
||||
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
|
||||
expect(notificationMock.verifySmtp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -115,19 +115,19 @@ describe(NotificationService.name, () => {
|
||||
const oldConfig = { ...configs.smtpEnabled };
|
||||
const newConfig = { ...configs.smtpDisabled };
|
||||
|
||||
await expect(sut.onConfigValidateEvent({ oldConfig, newConfig })).resolves.not.toThrow();
|
||||
await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow();
|
||||
expect(notificationMock.verifySmtp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onUserSignupEvent', () => {
|
||||
it('skips when notify is false', async () => {
|
||||
await sut.onUserSignupEvent({ id: '', notify: false });
|
||||
await sut.onUserSignup({ id: '', notify: false });
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should queue notify signup event if notify is true', async () => {
|
||||
await sut.onUserSignupEvent({ id: '', notify: true });
|
||||
await sut.onUserSignup({ id: '', notify: true });
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.NOTIFY_SIGNUP,
|
||||
data: { id: '', tempPassword: undefined },
|
||||
@ -137,7 +137,7 @@ describe(NotificationService.name, () => {
|
||||
|
||||
describe('onAlbumUpdateEvent', () => {
|
||||
it('should queue notify album update event', async () => {
|
||||
await sut.onAlbumUpdateEvent({ id: '', updatedBy: '42' });
|
||||
await sut.onAlbumUpdate({ id: '', updatedBy: '42' });
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||
data: { id: '', senderId: '42' },
|
||||
@ -147,7 +147,7 @@ describe(NotificationService.name, () => {
|
||||
|
||||
describe('onAlbumInviteEvent', () => {
|
||||
it('should queue notify album invite event', async () => {
|
||||
await sut.onAlbumInviteEvent({ id: '', userId: '42' });
|
||||
await sut.onAlbumInvite({ id: '', userId: '42' });
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.NOTIFY_ALBUM_INVITE,
|
||||
data: { id: '', recipientId: '42' },
|
||||
|
@ -2,17 +2,12 @@ import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { isEqual } from 'lodash';
|
||||
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
||||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import {
|
||||
AlbumInviteEvent,
|
||||
AlbumUpdateEvent,
|
||||
OnEvents,
|
||||
SystemConfigUpdateEvent,
|
||||
UserSignupEvent,
|
||||
} from 'src/interfaces/event.interface';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IEmailJob,
|
||||
IJobRepository,
|
||||
@ -30,7 +25,7 @@ import { getFilenameExtension } from 'src/utils/file';
|
||||
import { getPreferences } from 'src/utils/preferences';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService implements OnEvents {
|
||||
export class NotificationService {
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@ -46,7 +41,8 @@ export class NotificationService implements OnEvents {
|
||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
|
||||
}
|
||||
|
||||
async onConfigValidateEvent({ oldConfig, newConfig }: SystemConfigUpdateEvent) {
|
||||
@OnEmit({ event: 'onConfigValidate', priority: -100 })
|
||||
async onConfigValidate({ oldConfig, newConfig }: ArgOf<'onConfigValidate'>) {
|
||||
try {
|
||||
if (
|
||||
newConfig.notifications.smtp.enabled &&
|
||||
@ -60,17 +56,20 @@ export class NotificationService implements OnEvents {
|
||||
}
|
||||
}
|
||||
|
||||
async onUserSignupEvent({ notify, id, tempPassword }: UserSignupEvent) {
|
||||
@OnEmit({ event: 'onUserSignup' })
|
||||
async onUserSignup({ notify, id, tempPassword }: ArgOf<'onUserSignup'>) {
|
||||
if (notify) {
|
||||
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } });
|
||||
}
|
||||
}
|
||||
|
||||
async onAlbumUpdateEvent({ id, updatedBy }: AlbumUpdateEvent) {
|
||||
@OnEmit({ event: 'onAlbumUpdate' })
|
||||
async onAlbumUpdate({ id, updatedBy }: ArgOf<'onAlbumUpdate'>) {
|
||||
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } });
|
||||
}
|
||||
|
||||
async onAlbumInviteEvent({ id, userId }: AlbumInviteEvent) {
|
||||
@OnEmit({ event: 'onAlbumInvite' })
|
||||
async onAlbumInvite({ id, userId }: ArgOf<'onAlbumInvite'>) {
|
||||
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } });
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,7 @@ import { getBuildMetadata, getServerLicensePublicKey } from 'src/config';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import {
|
||||
ServerAboutResponseDto,
|
||||
@ -16,7 +17,6 @@ import {
|
||||
} from 'src/dtos/server.dto';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { OnEvents } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
@ -27,7 +27,7 @@ import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
export class ServerService implements OnEvents {
|
||||
export class ServerService {
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@ -42,7 +42,8 @@ export class ServerService implements OnEvents {
|
||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||
}
|
||||
|
||||
async onBootstrapEvent(): Promise<void> {
|
||||
@OnEmit({ event: 'onBootstrap' })
|
||||
async onBootstrap(): Promise<void> {
|
||||
const featureFlags = await this.getFeatures();
|
||||
if (featureFlags.configFile) {
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, {
|
||||
|
@ -49,7 +49,7 @@ describe(SmartInfoService.name, () => {
|
||||
describe('onConfigValidateEvent', () => {
|
||||
it('should allow a valid model', () => {
|
||||
expect(() =>
|
||||
sut.onConfigValidateEvent({
|
||||
sut.onConfigValidate({
|
||||
newConfig: { machineLearning: { clip: { modelName: 'ViT-B-16__openai' } } } as SystemConfig,
|
||||
oldConfig: {} as SystemConfig,
|
||||
}),
|
||||
@ -58,7 +58,7 @@ describe(SmartInfoService.name, () => {
|
||||
|
||||
it('should allow including organization', () => {
|
||||
expect(() =>
|
||||
sut.onConfigValidateEvent({
|
||||
sut.onConfigValidate({
|
||||
newConfig: { machineLearning: { clip: { modelName: 'immich-app/ViT-B-16__openai' } } } as SystemConfig,
|
||||
oldConfig: {} as SystemConfig,
|
||||
}),
|
||||
@ -67,7 +67,7 @@ describe(SmartInfoService.name, () => {
|
||||
|
||||
it('should fail for an unsupported model', () => {
|
||||
expect(() =>
|
||||
sut.onConfigValidateEvent({
|
||||
sut.onConfigValidate({
|
||||
newConfig: { machineLearning: { clip: { modelName: 'test-model' } } } as SystemConfig,
|
||||
oldConfig: {} as SystemConfig,
|
||||
}),
|
||||
@ -77,7 +77,7 @@ describe(SmartInfoService.name, () => {
|
||||
|
||||
describe('onBootstrapEvent', () => {
|
||||
it('should return if not microservices', async () => {
|
||||
await sut.onBootstrapEvent('api');
|
||||
await sut.onBootstrap('api');
|
||||
|
||||
expect(systemMock.get).not.toHaveBeenCalled();
|
||||
expect(searchMock.getDimensionSize).not.toHaveBeenCalled();
|
||||
@ -92,7 +92,7 @@ describe(SmartInfoService.name, () => {
|
||||
it('should return if machine learning is disabled', async () => {
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
|
||||
|
||||
await sut.onBootstrapEvent('microservices');
|
||||
await sut.onBootstrap('microservices');
|
||||
|
||||
expect(systemMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(searchMock.getDimensionSize).not.toHaveBeenCalled();
|
||||
@ -107,7 +107,7 @@ describe(SmartInfoService.name, () => {
|
||||
it('should return if model and DB dimension size are equal', async () => {
|
||||
searchMock.getDimensionSize.mockResolvedValue(512);
|
||||
|
||||
await sut.onBootstrapEvent('microservices');
|
||||
await sut.onBootstrap('microservices');
|
||||
|
||||
expect(systemMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
@ -123,7 +123,7 @@ describe(SmartInfoService.name, () => {
|
||||
searchMock.getDimensionSize.mockResolvedValue(768);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.onBootstrapEvent('microservices');
|
||||
await sut.onBootstrap('microservices');
|
||||
|
||||
expect(systemMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
@ -138,7 +138,7 @@ describe(SmartInfoService.name, () => {
|
||||
searchMock.getDimensionSize.mockResolvedValue(768);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true });
|
||||
|
||||
await sut.onBootstrapEvent('microservices');
|
||||
await sut.onBootstrap('microservices');
|
||||
|
||||
expect(systemMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
@ -154,7 +154,7 @@ describe(SmartInfoService.name, () => {
|
||||
it('should return if machine learning is disabled', async () => {
|
||||
systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
|
||||
|
||||
await sut.onConfigUpdateEvent({
|
||||
await sut.onConfigUpdate({
|
||||
newConfig: systemConfigStub.machineLearningDisabled as SystemConfig,
|
||||
oldConfig: systemConfigStub.machineLearningDisabled as SystemConfig,
|
||||
});
|
||||
@ -172,7 +172,7 @@ describe(SmartInfoService.name, () => {
|
||||
it('should return if model and DB dimension size are equal', async () => {
|
||||
searchMock.getDimensionSize.mockResolvedValue(512);
|
||||
|
||||
await sut.onConfigUpdateEvent({
|
||||
await sut.onConfigUpdate({
|
||||
newConfig: {
|
||||
machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true },
|
||||
} as SystemConfig,
|
||||
@ -194,7 +194,7 @@ describe(SmartInfoService.name, () => {
|
||||
searchMock.getDimensionSize.mockResolvedValue(512);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.onConfigUpdateEvent({
|
||||
await sut.onConfigUpdate({
|
||||
newConfig: {
|
||||
machineLearning: { clip: { modelName: 'ViT-L-14-quickgelu__dfn2b', enabled: true }, enabled: true },
|
||||
} as SystemConfig,
|
||||
@ -215,7 +215,7 @@ describe(SmartInfoService.name, () => {
|
||||
searchMock.getDimensionSize.mockResolvedValue(512);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
await sut.onConfigUpdateEvent({
|
||||
await sut.onConfigUpdate({
|
||||
newConfig: {
|
||||
machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true },
|
||||
} as SystemConfig,
|
||||
@ -237,7 +237,7 @@ describe(SmartInfoService.name, () => {
|
||||
searchMock.getDimensionSize.mockResolvedValue(512);
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true });
|
||||
|
||||
await sut.onConfigUpdateEvent({
|
||||
await sut.onConfigUpdate({
|
||||
newConfig: {
|
||||
machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true },
|
||||
} as SystemConfig,
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IBaseJob,
|
||||
IEntityJob,
|
||||
@ -21,7 +22,7 @@ import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
@Injectable()
|
||||
export class SmartInfoService implements OnEvents {
|
||||
export class SmartInfoService {
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@ -37,7 +38,8 @@ export class SmartInfoService implements OnEvents {
|
||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||
}
|
||||
|
||||
async onBootstrapEvent(app: 'api' | 'microservices') {
|
||||
@OnEmit({ event: 'onBootstrap' })
|
||||
async onBootstrap(app: ArgOf<'onBootstrap'>) {
|
||||
if (app !== 'microservices') {
|
||||
return;
|
||||
}
|
||||
@ -46,7 +48,8 @@ export class SmartInfoService implements OnEvents {
|
||||
await this.init(config);
|
||||
}
|
||||
|
||||
onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) {
|
||||
@OnEmit({ event: 'onConfigValidate' })
|
||||
onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) {
|
||||
try {
|
||||
getCLIPModelInfo(newConfig.machineLearning.clip.modelName);
|
||||
} catch {
|
||||
@ -56,7 +59,8 @@ export class SmartInfoService implements OnEvents {
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigUpdateEvent({ oldConfig, newConfig }: SystemConfigUpdateEvent) {
|
||||
@OnEmit({ event: 'onConfigUpdate' })
|
||||
async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'onConfigUpdate'>) {
|
||||
await this.init(newConfig, oldConfig);
|
||||
}
|
||||
|
||||
|
@ -76,10 +76,10 @@ describe(StorageTemplateService.name, () => {
|
||||
SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults);
|
||||
});
|
||||
|
||||
describe('onConfigValidateEvent', () => {
|
||||
describe('onConfigValidate', () => {
|
||||
it('should allow valid templates', () => {
|
||||
expect(() =>
|
||||
sut.onConfigValidateEvent({
|
||||
sut.onConfigValidate({
|
||||
newConfig: {
|
||||
storageTemplate: {
|
||||
template:
|
||||
@ -93,7 +93,7 @@ describe(StorageTemplateService.name, () => {
|
||||
|
||||
it('should fail for an invalid template', () => {
|
||||
expect(() =>
|
||||
sut.onConfigValidateEvent({
|
||||
sut.onConfigValidate({
|
||||
newConfig: {
|
||||
storageTemplate: {
|
||||
template: '{{foo}}',
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
} from 'src/constants';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetPathType } from 'src/entities/move.entity';
|
||||
import { AssetType } from 'src/enum';
|
||||
@ -22,7 +23,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { OnEvents, SystemConfigUpdateEvent } from 'src/interfaces/event.interface';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||
@ -46,7 +47,7 @@ interface RenderMetadata {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageTemplateService implements OnEvents {
|
||||
export class StorageTemplateService {
|
||||
private configCore: SystemConfigCore;
|
||||
private storageCore: StorageCore;
|
||||
private _template: {
|
||||
@ -88,7 +89,8 @@ export class StorageTemplateService implements OnEvents {
|
||||
);
|
||||
}
|
||||
|
||||
onConfigValidateEvent({ newConfig }: SystemConfigUpdateEvent) {
|
||||
@OnEmit({ event: 'onConfigValidate' })
|
||||
onConfigValidate({ newConfig }: ArgOf<'onConfigValidate'>) {
|
||||
try {
|
||||
const { compiled } = this.compile(newConfig.storageTemplate.template);
|
||||
this.render(compiled, {
|
||||
|
@ -20,9 +20,9 @@ describe(StorageService.name, () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('onBootstrapEvent', () => {
|
||||
describe('onBootstrap', () => {
|
||||
it('should create the library folder on initialization', () => {
|
||||
sut.onBootstrapEvent();
|
||||
sut.onBootstrap();
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library');
|
||||
});
|
||||
});
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { OnEvents } from 'src/interfaces/event.interface';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
|
||||
@Injectable()
|
||||
export class StorageService implements OnEvents {
|
||||
export class StorageService {
|
||||
constructor(
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
@ -14,7 +14,8 @@ export class StorageService implements OnEvents {
|
||||
this.logger.setContext(StorageService.name);
|
||||
}
|
||||
|
||||
onBootstrapEvent() {
|
||||
@OnEmit({ event: 'onBootstrap' })
|
||||
onBootstrap() {
|
||||
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
|
||||
this.storageRepository.mkdirSync(libraryBase);
|
||||
}
|
||||
|
@ -13,20 +13,14 @@ import {
|
||||
supportedYearTokens,
|
||||
} from 'src/constants';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { EventHandlerOptions, OnServerEvent } from 'src/decorators';
|
||||
import { OnEmit, OnServerEvent } from 'src/decorators';
|
||||
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto';
|
||||
import {
|
||||
ClientEvent,
|
||||
IEventRepository,
|
||||
OnEvents,
|
||||
ServerEvent,
|
||||
SystemConfigUpdateEvent,
|
||||
} from 'src/interfaces/event.interface';
|
||||
import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
|
||||
@Injectable()
|
||||
export class SystemConfigService implements OnEvents {
|
||||
export class SystemConfigService {
|
||||
private core: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@ -39,8 +33,8 @@ export class SystemConfigService implements OnEvents {
|
||||
this.core.config$.subscribe((config) => this.setLogLevel(config));
|
||||
}
|
||||
|
||||
@EventHandlerOptions({ priority: -100 })
|
||||
async onBootstrapEvent() {
|
||||
@OnEmit({ event: 'onBootstrap', priority: -100 })
|
||||
async onBootstrap() {
|
||||
const config = await this.core.getConfig({ withCache: false });
|
||||
this.core.config$.next(config);
|
||||
}
|
||||
@ -54,7 +48,8 @@ export class SystemConfigService implements OnEvents {
|
||||
return mapConfig(defaults);
|
||||
}
|
||||
|
||||
onConfigValidateEvent({ newConfig, oldConfig }: SystemConfigUpdateEvent) {
|
||||
@OnEmit({ event: 'onConfigValidate' })
|
||||
onConfigValidate({ newConfig, oldConfig }: ArgOf<'onConfigValidate'>) {
|
||||
if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) {
|
||||
throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.');
|
||||
}
|
||||
@ -68,7 +63,7 @@ export class SystemConfigService implements OnEvents {
|
||||
const oldConfig = await this.core.getConfig({ withCache: false });
|
||||
|
||||
try {
|
||||
await this.eventRepository.emit('onConfigValidateEvent', { newConfig: dto, oldConfig });
|
||||
await this.eventRepository.emit('onConfigValidate', { newConfig: dto, oldConfig });
|
||||
} catch (error) {
|
||||
this.logger.warn(`Unable to save system config due to a validation error: ${error}`);
|
||||
throw new BadRequestException(error instanceof Error ? error.message : error);
|
||||
@ -79,7 +74,7 @@ export class SystemConfigService implements OnEvents {
|
||||
// TODO probably move web socket emits to a separate service
|
||||
this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {});
|
||||
this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null);
|
||||
await this.eventRepository.emit('onConfigUpdateEvent', { newConfig, oldConfig });
|
||||
await this.eventRepository.emit('onConfigUpdate', { newConfig, oldConfig });
|
||||
|
||||
return mapConfig(newConfig);
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ export class UserAdminService {
|
||||
const { notify, ...rest } = dto;
|
||||
const user = await this.userCore.createUser(rest);
|
||||
|
||||
await this.eventRepository.emit('onUserSignupEvent', {
|
||||
await this.eventRepository.emit('onUserSignup', {
|
||||
notify: !!notify,
|
||||
id: user.id,
|
||||
tempPassword: user.shouldChangePassword ? rest.password : undefined,
|
||||
|
@ -3,11 +3,11 @@ import { DateTime } from 'luxon';
|
||||
import semver, { SemVer } from 'semver';
|
||||
import { isDev, serverVersion } from 'src/constants';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnServerEvent } from 'src/decorators';
|
||||
import { OnEmit, OnServerEvent } from 'src/decorators';
|
||||
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { VersionCheckMetadata } from 'src/entities/system-metadata.entity';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { ClientEvent, IEventRepository, OnEvents, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface';
|
||||
import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||
@ -23,7 +23,7 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class VersionService implements OnEvents {
|
||||
export class VersionService {
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@ -37,7 +37,8 @@ export class VersionService implements OnEvents {
|
||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||
}
|
||||
|
||||
async onBootstrapEvent(): Promise<void> {
|
||||
@OnEmit({ event: 'onBootstrap' })
|
||||
async onBootstrap(): Promise<void> {
|
||||
await this.handleVersionCheck();
|
||||
}
|
||||
|
||||
|
@ -1,33 +1,57 @@
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
import _ from 'lodash';
|
||||
import { HandlerOptions } from 'src/decorators';
|
||||
import { EmitEvent, EmitEventHandler, IEventRepository, OnEvents, events } from 'src/interfaces/event.interface';
|
||||
import { EmitConfig } from 'src/decorators';
|
||||
import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { Metadata } from 'src/middleware/auth.guard';
|
||||
import { services } from 'src/services';
|
||||
|
||||
type Item<T extends EmitEvent> = {
|
||||
event: T;
|
||||
handler: EmitHandler<T>;
|
||||
priority: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const setupEventHandlers = (moduleRef: ModuleRef) => {
|
||||
const reflector = moduleRef.get(Reflector, { strict: false });
|
||||
const repository = moduleRef.get<IEventRepository>(IEventRepository);
|
||||
const handlers: Array<{ event: EmitEvent; handler: EmitEventHandler<EmitEvent>; priority: number }> = [];
|
||||
const items: Item<EmitEvent>[] = [];
|
||||
|
||||
// discovery
|
||||
for (const Service of services) {
|
||||
const instance = moduleRef.get<OnEvents>(Service);
|
||||
for (const event of events) {
|
||||
const handler = instance[event] as EmitEventHandler<typeof event>;
|
||||
const instance = moduleRef.get<any>(Service);
|
||||
const ctx = Object.getPrototypeOf(instance);
|
||||
for (const property of Object.getOwnPropertyNames(ctx)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(ctx, property);
|
||||
if (!descriptor || descriptor.get || descriptor.set) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const handler = instance[property];
|
||||
if (typeof handler !== 'function') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = reflector.get<HandlerOptions>(Metadata.EVENT_HANDLER_OPTIONS, handler);
|
||||
const priority = options?.priority || 0;
|
||||
const options = reflector.get<EmitConfig>(Metadata.ON_EMIT_CONFIG, handler);
|
||||
if (!options) {
|
||||
continue;
|
||||
}
|
||||
|
||||
handlers.push({ event, handler: handler.bind(instance), priority });
|
||||
items.push({
|
||||
event: options.event,
|
||||
priority: options.priority || 0,
|
||||
handler: handler.bind(instance),
|
||||
label: `${Service.name}.${handler.name}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handlers = _.orderBy(items, ['priority'], ['asc']);
|
||||
|
||||
// register by priority
|
||||
for (const { event, handler } of _.orderBy(handlers, ['priority'], ['asc'])) {
|
||||
repository.on(event, handler);
|
||||
for (const { event, handler } of handlers) {
|
||||
repository.on(event as EmitEvent, handler);
|
||||
}
|
||||
|
||||
return handlers;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user