1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 22:51:59 +00:00

refactor(server): events (#13003)

* refactor(server): events

* chore: better type

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Jason Rasmussen 2024-09-30 10:35:11 -04:00 committed by GitHub
parent 95c67949f7
commit a2d457b01d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 260 additions and 259 deletions

View file

@ -76,7 +76,6 @@ describe('/asset', () => {
let user2Assets: AssetMediaResponseDto[];
let locationAsset: AssetMediaResponseDto;
let ratingAsset: AssetMediaResponseDto;
let facesAsset: AssetMediaResponseDto;
const setupTests = async () => {
await utils.resetDatabase();
@ -236,7 +235,7 @@ describe('/asset', () => {
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
// asset faces
facesAsset = await utils.createAsset(admin.accessToken, {
const facesAsset = await utils.createAsset(admin.accessToken, {
assetData: {
filename: 'portrait.jpg',
bytes: await readFile(facesAssetFilepath),

View file

@ -2,7 +2,6 @@ import { BullModule } from '@nestjs/bullmq';
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import _ from 'lodash';
@ -42,7 +41,6 @@ const imports = [
BullModule.registerQueue(...bullQueues),
ClsModule.forRoot(clsConfig),
ConfigModule.forRoot(immichAppConfig),
EventEmitterModule.forRoot(),
OpenTelemetryModule.forRoot(otelConfig),
TypeOrmModule.forRootAsync({
inject: [ModuleRef],
@ -114,16 +112,3 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy {
providers: [...common, ...commands, SchedulerRegistry],
})
export class ImmichAdminModule {}
@Module({
imports: [
ConfigModule.forRoot(immichAppConfig),
EventEmitterModule.forRoot(),
TypeOrmModule.forRoot(databaseConfig),
TypeOrmModule.forFeature(entities),
OpenTelemetryModule.forRoot(otelConfig),
],
controllers: [...controllers],
providers: [...common, ...middleware, SchedulerRegistry],
})
export class AppTestModule {}

View file

@ -1,7 +1,6 @@
#!/usr/bin/env node
import { INestApplication } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { SchedulerRegistry } from '@nestjs/schedule';
import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
@ -85,7 +84,6 @@ class SqlGenerator {
logger: this.sqlLogger,
}),
TypeOrmModule.forFeature(entities),
EventEmitterModule.forRoot(),
OpenTelemetryModule.forRoot(otelConfig),
],
providers: [...repositories, AuthService, SchedulerRegistry],

View file

@ -4,7 +4,6 @@ import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { load as loadYaml } from 'js-yaml';
import * as _ from 'lodash';
import { Subject } from 'rxjs';
import { SystemConfig, defaults } from 'src/config';
import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { SystemMetadataKey } from 'src/enum';
@ -24,8 +23,6 @@ export class SystemConfigCore {
private config: SystemConfig | null = null;
private lastUpdated: number | null = null;
config$ = new Subject<SystemConfig>();
private constructor(
private repository: ISystemMetadataRepository,
private logger: ILoggerRepository,
@ -42,6 +39,11 @@ export class SystemConfigCore {
instance = null;
}
invalidateCache() {
this.config = null;
this.lastUpdated = null;
}
async getConfig({ withCache }: { withCache: boolean }): Promise<SystemConfig> {
if (!withCache || !this.config) {
const lastUpdated = this.lastUpdated;
@ -74,14 +76,7 @@ export class SystemConfigCore {
await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig);
const config = await this.getConfig({ withCache: false });
this.config$.next(config);
return config;
}
async refreshConfig() {
const newConfig = await this.getConfig({ withCache: false });
this.config$.next(newConfig);
return this.getConfig({ withCache: false });
}
isUsingConfigFile() {

View file

@ -1,11 +1,9 @@
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
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 { MetadataKey } from 'src/enum';
import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface';
import { EmitEvent } from 'src/interfaces/event.interface';
import { setUnion } from 'src/utils/set';
// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
@ -133,15 +131,14 @@ export interface GenerateSqlQueries {
/** Decorator to enable versioning/tracking of generated Sql */
export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options);
export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) =>
OnEvent(event, { suppressErrors: false, ...options });
export type EmitConfig = {
event: EmitEvent;
export type EventConfig = {
name: EmitEvent;
/** handle socket.io server events as well */
server?: boolean;
/** lower value has higher priority, defaults to 0 */
priority?: number;
};
export const OnEmit = (config: EmitConfig) => SetMetadata(MetadataKey.ON_EMIT_CONFIG, config);
export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EVENT_CONFIG, config);
type LifecycleRelease = 'NEXT_RELEASE' | string;
type LifecycleMetadata = {

View file

@ -310,7 +310,7 @@ export enum MetadataKey {
ADMIN_ROUTE = 'admin_route',
SHARED_ROUTE = 'shared_route',
API_KEY_SECURITY = 'api_key',
ON_EMIT_CONFIG = 'on_emit_config',
EVENT_CONFIG = 'event_config',
}
export enum RouteKey {

View file

@ -4,13 +4,19 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d
export const IEventRepository = 'IEventRepository';
type EmitEventMap = {
type EventMap = {
// app events
'app.bootstrap': ['api' | 'microservices'];
'app.shutdown': [];
// config events
'config.update': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
'config.update': [
{
newConfig: SystemConfig;
/** When the server starts, `oldConfig` is `undefined` */
oldConfig?: SystemConfig;
},
];
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
// album events
@ -43,12 +49,18 @@ type EmitEventMap = {
// user events
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
// websocket events
'websocket.connect': [{ userId: string }];
};
export type EmitEvent = keyof EmitEventMap;
export const serverEvents = ['config.update'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export type EmitEvent = keyof EventMap;
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 type ArgOf<T extends EmitEvent> = EventMap[T][0];
export type ArgsOf<T extends EmitEvent> = EventMap[T];
export enum ClientEvent {
UPLOAD_SUCCESS = 'on_upload_success',
@ -82,19 +94,15 @@ export interface ClientEventMap {
[ClientEvent.SESSION_DELETE]: string;
}
export enum ServerEvent {
CONFIG_UPDATE = 'config.update',
WEBSOCKET_CONNECT = 'websocket.connect',
}
export interface ServerEventMap {
[ServerEvent.CONFIG_UPDATE]: null;
[ServerEvent.WEBSOCKET_CONNECT]: { userId: string };
}
export type EventItem<T extends EmitEvent> = {
event: T;
handler: EmitHandler<T>;
server: boolean;
};
export interface IEventRepository {
on<T extends keyof EmitEventMap>(event: T, handler: EmitHandler<T>): void;
emit<T extends keyof EmitEventMap>(event: T, ...args: ArgsOf<T>): Promise<void>;
on<T extends keyof EventMap>(item: EventItem<T>): void;
emit<T extends keyof EventMap>(event: T, ...args: ArgsOf<T>): Promise<void>;
/**
* Send to connected clients for a specific user
@ -105,7 +113,7 @@ export interface IEventRepository {
*/
clientBroadcast<E extends keyof ClientEventMap>(event: E, data: ClientEventMap[E]): void;
/**
* Notify listeners in this and connected processes. Subscribe to an event with `@OnServerEvent`
* Send to all connected servers
*/
serverSend<E extends keyof ServerEventMap>(event: E, data: ServerEventMap[E]): boolean;
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void;
}

View file

@ -1,6 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
OnGatewayConnection,
OnGatewayDisconnect,
@ -13,16 +12,17 @@ import {
ArgsOf,
ClientEventMap,
EmitEvent,
EmitHandler,
EventItem,
IEventRepository,
ServerEvent,
ServerEventMap,
serverEvents,
ServerEvents,
} from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthService } from 'src/services/auth.service';
import { Instrumentation } from 'src/utils/instrumentation';
import { handlePromiseError } from 'src/utils/misc';
type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler<T>[] }>;
type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>;
@Instrumentation()
@WebSocketGateway({
@ -39,7 +39,6 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
constructor(
private moduleRef: ModuleRef,
private eventEmitter: EventEmitter2,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(EventRepository.name);
@ -48,14 +47,10 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
afterInit(server: Server) {
this.logger.log('Initialized websocket server');
for (const event of Object.values(ServerEvent)) {
if (event === ServerEvent.WEBSOCKET_CONNECT) {
continue;
}
server.on(event, (data: unknown) => {
for (const event of serverEvents) {
server.on(event, (...args: ArgsOf<any>) => {
this.logger.debug(`Server event: ${event} (receive)`);
this.eventEmitter.emit(event, data);
handlePromiseError(this.onEvent({ name: event, args, server: true }), this.logger);
});
}
}
@ -72,7 +67,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
if (auth.session) {
await client.join(auth.session.id);
}
this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id });
await this.onEvent({ name: 'websocket.connect', args: [{ userId: auth.user.id }], server: false });
} catch (error: Error | any) {
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
client.emit('error', 'unauthorized');
@ -85,18 +80,29 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
await client.leave(client.nsp.name);
}
on<T extends EmitEvent>(event: T, handler: EmitHandler<T>): void {
on<T extends EmitEvent>(item: EventItem<T>): void {
const event = item.event;
if (!this.emitHandlers[event]) {
this.emitHandlers[event] = [];
}
this.emitHandlers[event].push(handler);
this.emitHandlers[event].push(item);
}
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);
return this.onEvent({ name: event, args, server: false });
}
private async onEvent<T extends EmitEvent>(event: { name: T; args: ArgsOf<T>; server: boolean }): Promise<void> {
const handlers = this.emitHandlers[event.name] || [];
for (const { handler, server } of handlers) {
// exclude handlers that ignore server events
if (!server && event.server) {
continue;
}
await handler(...event.args);
}
}
@ -108,9 +114,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
this.server?.emit(event, data);
}
serverSend<E extends keyof ServerEventMap>(event: E, data: ServerEventMap[E]) {
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
this.logger.debug(`Server event: ${event} (send)`);
this.server?.serverSideEmit(event, data);
return this.eventEmitter.emit(event, data);
this.server?.serverSideEmit(event, ...args);
}
}

View file

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Duration } from 'luxon';
import semver from 'semver';
import { OnEmit } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { IConfigRepository } from 'src/interfaces/config.interface';
import {
DatabaseExtension,
@ -74,7 +74,7 @@ export class DatabaseService {
this.logger.setContext(DatabaseService.name);
}
@OnEmit({ event: 'app.bootstrap', priority: -200 })
@OnEvent({ name: 'app.bootstrap', priority: -200 })
async onBootstrap() {
const version = await this.databaseRepository.getPostgresVersion();
const current = semver.coerce(version);

View file

@ -1,6 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { defaults } from 'src/config';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import {
@ -60,6 +59,19 @@ describe(JobService.name, () => {
expect(sut).toBeDefined();
});
describe('onConfigUpdate', () => {
it('should update concurrency', () => {
sut.onBootstrap('microservices');
sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults });
expect(jobMock.setConcurrency).toHaveBeenCalledTimes(14);
expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1);
expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1);
expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5);
expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1);
});
});
describe('handleNightlyJobs', () => {
it('should run the scheduled jobs', async () => {
await sut.handleNightlyJobs();
@ -239,36 +251,6 @@ describe(JobService.name, () => {
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
});
it('should subscribe to config changes', async () => {
await sut.init(makeMockHandlers(JobStatus.FAILED));
SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({
job: {
[QueueName.BACKGROUND_TASK]: { concurrency: 10 },
[QueueName.SMART_SEARCH]: { concurrency: 10 },
[QueueName.METADATA_EXTRACTION]: { concurrency: 10 },
[QueueName.FACE_DETECTION]: { concurrency: 10 },
[QueueName.SEARCH]: { concurrency: 10 },
[QueueName.SIDECAR]: { concurrency: 10 },
[QueueName.LIBRARY]: { concurrency: 10 },
[QueueName.MIGRATION]: { concurrency: 10 },
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 10 },
[QueueName.NOTIFICATION]: { concurrency: 5 },
},
} as SystemConfig);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.BACKGROUND_TASK, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SMART_SEARCH, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.FACE_DETECTION, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.MIGRATION, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10);
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10);
});
const tests: Array<{ item: JobItem; jobs: JobName[] }> = [
{
item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } },

View file

@ -1,11 +1,12 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { snakeCase } from 'lodash';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEvent } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
import { AssetType, ManualJobName } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
import {
ConcurrentQueueName,
IJobRepository,
@ -45,6 +46,7 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
@Injectable()
export class JobService {
private configCore: SystemConfigCore;
private isMicroservices = false;
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@ -59,6 +61,28 @@ export class JobService {
this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
}
@OnEvent({ name: 'app.bootstrap' })
onBootstrap(app: ArgOf<'app.bootstrap'>) {
this.isMicroservices = app === 'microservices';
}
@OnEvent({ name: 'config.update', server: true })
onConfigUpdate({ newConfig: config, oldConfig }: ArgOf<'config.update'>) {
if (!oldConfig || !this.isMicroservices) {
return;
}
this.logger.debug(`Updating queue concurrency settings`);
for (const queueName of Object.values(QueueName)) {
let concurrency = 1;
if (this.isConcurrentQueue(queueName)) {
concurrency = config.job[queueName].concurrency;
}
this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`);
this.jobRepository.setConcurrency(queueName, concurrency);
}
}
async create(dto: JobCreateDto): Promise<void> {
await this.jobRepository.queue(asJobItem(dto));
}
@ -209,18 +233,6 @@ export class JobService {
}
});
}
this.configCore.config$.subscribe((config) => {
this.logger.debug(`Updating queue concurrency settings`);
for (const queueName of Object.values(QueueName)) {
let concurrency = 1;
if (this.isConcurrentQueue(queueName)) {
concurrency = config.job[queueName].concurrency;
}
this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`);
this.jobRepository.setConcurrency(queueName, concurrency);
}
});
}
private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName {

View file

@ -1,7 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { Stats } from 'node:fs';
import { SystemConfig } from 'src/config';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { defaults, SystemConfig } from 'src/config';
import { mapLibrary } from 'src/dtos/library.dto';
import { UserEntity } from 'src/entities/user.entity';
import { AssetType } from 'src/enum';
@ -81,22 +80,26 @@ describe(LibraryService.name, () => {
});
describe('onBootstrapEvent', () => {
it('should init cron job and subscribe to config changes', async () => {
it('should init cron job and handle config changes', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryScan);
await sut.onBootstrap();
expect(systemMock.get).toHaveBeenCalled();
expect(jobMock.addCronJob).toHaveBeenCalled();
SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({
library: {
scan: {
enabled: true,
cronExpression: '0 1 * * *',
expect(jobMock.addCronJob).toHaveBeenCalled();
expect(systemMock.get).toHaveBeenCalled();
await sut.onConfigUpdate({
oldConfig: defaults,
newConfig: {
library: {
scan: {
enabled: true,
cronExpression: '0 1 * * *',
},
watch: { enabled: false },
},
watch: { enabled: true },
},
} as SystemConfig);
} as SystemConfig,
});
expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true);
});

View file

@ -4,7 +4,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 { OnEvent } from 'src/decorators';
import {
CreateLibraryDto,
LibraryResponseDto,
@ -61,7 +61,7 @@ export class LibraryService {
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
}
@OnEmit({ event: 'app.bootstrap' })
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap() {
const config = await this.configCore.getConfig({ withCache: false });
@ -83,19 +83,24 @@ export class LibraryService {
if (this.watchLibraries) {
await this.watchAll();
}
this.configCore.config$.subscribe(({ library }) => {
this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled);
if (library.watch.enabled !== this.watchLibraries) {
// Watch configuration changed, update accordingly
this.watchLibraries = library.watch.enabled;
handlePromiseError(this.watchLibraries ? this.watchAll() : this.unwatchAll(), this.logger);
}
});
}
@OnEmit({ event: 'config.validate' })
@OnEvent({ name: 'config.update', server: true })
async onConfigUpdate({ newConfig: { library }, oldConfig }: ArgOf<'config.update'>) {
if (!oldConfig || !this.watchLock) {
return;
}
this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled);
if (library.watch.enabled !== this.watchLibraries) {
// Watch configuration changed, update accordingly
this.watchLibraries = library.watch.enabled;
await (this.watchLibraries ? this.watchAll() : this.unwatchAll());
}
}
@OnEvent({ name: 'config.validate' })
onConfigValidate({ newConfig }: ArgOf<'config.validate'>) {
const { scan } = newConfig.library;
if (!validateCronExpression(scan.cronExpression)) {
@ -185,7 +190,7 @@ export class LibraryService {
}
}
@OnEmit({ event: 'app.shutdown' })
@OnEvent({ name: 'app.shutdown' })
async onShutdown() {
await this.unwatchAll();
}

View file

@ -8,7 +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 { OnEvent } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
@ -132,7 +132,7 @@ export class MetadataService {
);
}
@OnEmit({ event: 'app.bootstrap' })
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
if (app !== 'microservices') {
return;
@ -141,7 +141,12 @@ export class MetadataService {
await this.init(config);
}
@OnEmit({ event: 'config.update' })
@OnEvent({ name: 'app.shutdown' })
async onShutdown() {
await this.repository.teardown();
}
@OnEvent({ name: 'config.update' })
async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) {
await this.init(newConfig);
}
@ -164,11 +169,6 @@ export class MetadataService {
}
}
@OnEmit({ event: 'app.shutdown' })
async onShutdown() {
await this.repository.teardown();
}
async handleLivePhotoLinking(job: IEntityJob): Promise<JobStatus> {
const { id } = job;
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
@ -333,12 +333,12 @@ export class MetadataService {
return this.processSidecar(id, false);
}
@OnEmit({ event: 'asset.tag' })
@OnEvent({ name: 'asset.tag' })
async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) {
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
}
@OnEmit({ event: 'asset.untag' })
@OnEvent({ name: 'asset.untag' })
async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) {
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
}

View file

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { OnEmit } from 'src/decorators';
import { OnEvent } 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';
@ -43,7 +43,7 @@ export class MicroservicesService {
private versionService: VersionService,
) {}
@OnEmit({ event: 'app.bootstrap' })
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
if (app !== 'microservices') {
return;

View file

@ -6,7 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
@ -100,6 +100,15 @@ describe(NotificationService.name, () => {
expect(sut).toBeDefined();
});
describe('onConfigUpdate', () => {
it('should emit client and server events', () => {
const update = { newConfig: defaults };
expect(sut.onConfigUpdate(update)).toBeUndefined();
expect(eventMock.clientBroadcast).toHaveBeenCalledWith(ClientEvent.CONFIG_UPDATE, {});
expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update);
});
});
describe('onConfigValidateEvent', () => {
it('validates smtp config when enabling smtp', async () => {
const oldConfig = configs.smtpDisabled;

View file

@ -1,7 +1,7 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { OnEvent } 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';
@ -43,7 +43,13 @@ export class NotificationService {
this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
}
@OnEmit({ event: 'config.validate', priority: -100 })
@OnEvent({ name: 'config.update' })
onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) {
this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {});
this.eventRepository.serverSend('config.update', { oldConfig, newConfig });
}
@OnEvent({ name: 'config.validate', priority: -100 })
async onConfigValidate({ oldConfig, newConfig }: ArgOf<'config.validate'>) {
try {
if (
@ -58,74 +64,74 @@ export class NotificationService {
}
}
@OnEmit({ event: 'asset.hide' })
@OnEvent({ name: 'asset.hide' })
onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId);
}
@OnEmit({ event: 'asset.show' })
@OnEvent({ name: 'asset.show' })
async onAssetShow({ assetId }: ArgOf<'asset.show'>) {
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } });
}
@OnEmit({ event: 'asset.trash' })
@OnEvent({ name: 'asset.trash' })
onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) {
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]);
}
@OnEmit({ event: 'asset.delete' })
@OnEvent({ name: 'asset.delete' })
onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) {
this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId);
}
@OnEmit({ event: 'assets.trash' })
@OnEvent({ name: 'assets.trash' })
onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) {
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds);
}
@OnEmit({ event: 'assets.restore' })
@OnEvent({ name: 'assets.restore' })
onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) {
this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds);
}
@OnEmit({ event: 'stack.create' })
@OnEvent({ name: 'stack.create' })
onStackCreate({ userId }: ArgOf<'stack.create'>) {
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
}
@OnEmit({ event: 'stack.update' })
@OnEvent({ name: 'stack.update' })
onStackUpdate({ userId }: ArgOf<'stack.update'>) {
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
}
@OnEmit({ event: 'stack.delete' })
@OnEvent({ name: 'stack.delete' })
onStackDelete({ userId }: ArgOf<'stack.delete'>) {
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
}
@OnEmit({ event: 'stacks.delete' })
@OnEvent({ name: 'stacks.delete' })
onStacksDelete({ userId }: ArgOf<'stacks.delete'>) {
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
}
@OnEmit({ event: 'user.signup' })
@OnEvent({ name: 'user.signup' })
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
if (notify) {
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } });
}
}
@OnEmit({ event: 'album.update' })
@OnEvent({ name: 'album.update' })
async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) {
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } });
}
@OnEmit({ event: 'album.invite' })
@OnEvent({ name: 'album.invite' })
async onAlbumInvite({ id, userId }: ArgOf<'album.invite'>) {
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } });
}
@OnEmit({ event: 'session.delete' })
@OnEvent({ name: 'session.delete' })
onSessionDelete({ sessionId }: ArgOf<'session.delete'>) {
// after the response is sent
setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500);

View file

@ -3,7 +3,7 @@ import { getBuildMetadata, getServerLicensePublicKey } from 'src/config';
import { serverVersion } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import {
ServerAboutResponseDto,
@ -42,7 +42,7 @@ export class ServerService {
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
}
@OnEmit({ event: 'app.bootstrap' })
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(): Promise<void> {
const featureFlags = await this.getFeatures();
if (featureFlags.configFile) {

View file

@ -1,7 +1,7 @@
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 { OnEvent } from 'src/decorators';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
@ -39,7 +39,7 @@ export class SmartInfoService {
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
}
@OnEmit({ event: 'app.bootstrap' })
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
if (app !== 'microservices') {
return;
@ -49,7 +49,12 @@ export class SmartInfoService {
await this.init(config);
}
@OnEmit({ event: 'config.validate' })
@OnEvent({ name: 'config.update' })
async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) {
await this.init(newConfig, oldConfig);
}
@OnEvent({ name: 'config.validate' })
onConfigValidate({ newConfig }: ArgOf<'config.validate'>) {
try {
getCLIPModelInfo(newConfig.machineLearning.clip.modelName);
@ -60,11 +65,6 @@ export class SmartInfoService {
}
}
@OnEmit({ event: 'config.update' })
async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) {
await this.init(newConfig, oldConfig);
}
private async init(newConfig: SystemConfig, oldConfig?: SystemConfig) {
if (!isSmartSearchEnabled(newConfig.machineLearning)) {
return;

View file

@ -1,6 +1,5 @@
import { Stats } from 'node:fs';
import { SystemConfig, defaults } from 'src/config';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
@ -74,7 +73,7 @@ describe(StorageTemplateService.name, () => {
loggerMock,
);
SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults);
sut.onConfigUpdate({ newConfig: defaults });
});
describe('onConfigValidate', () => {
@ -164,13 +163,15 @@ describe(StorageTemplateService.name, () => {
originalPath: newMotionPicturePath,
});
});
it('Should use handlebar if condition for album', async () => {
it('should use handlebar if condition for album', async () => {
const asset = assetStub.image;
const user = userStub.user1;
const album = albumStub.oneAsset;
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
SystemConfigCore.create(systemMock, loggerMock).config$.next(config);
sut.onConfigUpdate({ oldConfig: defaults, newConfig: config });
userMock.get.mockResolvedValue(user);
assetMock.getByIds.mockResolvedValueOnce([asset]);
@ -185,12 +186,13 @@ describe(StorageTemplateService.name, () => {
pathType: AssetPathType.ORIGINAL,
});
});
it('Should use handlebar else condition for album', async () => {
it('should use handlebar else condition for album', async () => {
const asset = assetStub.image;
const user = userStub.user1;
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}';
SystemConfigCore.create(systemMock, loggerMock).config$.next(config);
sut.onConfigUpdate({ oldConfig: defaults, newConfig: config });
userMock.get.mockResolvedValue(user);
assetMock.getByIds.mockResolvedValueOnce([asset]);
@ -205,6 +207,7 @@ describe(StorageTemplateService.name, () => {
pathType: AssetPathType.ORIGINAL,
});
});
it('should migrate previously failed move from original path when it still exists', async () => {
userMock.get.mockResolvedValue(userStub.user1);
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`;
@ -242,6 +245,7 @@ describe(StorageTemplateService.name, () => {
originalPath: newPath,
});
});
it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => {
userMock.get.mockResolvedValue(userStub.user1);
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`;

View file

@ -3,7 +3,6 @@ import handlebar from 'handlebars';
import { DateTime } from 'luxon';
import path from 'node:path';
import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config';
import {
supportedDayTokens,
supportedHourTokens,
@ -15,7 +14,7 @@ import {
} from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
@ -76,7 +75,6 @@ export class StorageTemplateService {
) {
this.logger.setContext(StorageTemplateService.name);
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
this.configCore.config$.subscribe((config) => this.onConfig(config));
this.storageCore = StorageCore.create(
assetRepository,
cryptoRepository,
@ -88,7 +86,16 @@ export class StorageTemplateService {
);
}
@OnEmit({ event: 'config.validate' })
@OnEvent({ name: 'config.update', server: true })
onConfigUpdate({ newConfig }: ArgOf<'config.update'>) {
const template = newConfig.storageTemplate.template;
if (!this._template || template !== this.template.raw) {
this.logger.debug(`Compiling new storage template: ${template}`);
this._template = this.compile(template);
}
}
@OnEvent({ name: 'config.validate' })
onConfigValidate({ newConfig }: ArgOf<'config.validate'>) {
try {
const { compiled } = this.compile(newConfig.storageTemplate.template);
@ -282,14 +289,6 @@ export class StorageTemplateService {
}
}
private onConfig(config: SystemConfig) {
const template = config.storageTemplate.template;
if (!this._template || template !== this.template.raw) {
this.logger.debug(`Compiling new storage template: ${template}`);
this._template = this.compile(template);
}
}
private compile(template: string) {
return {
raw: template,

View file

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { OnEmit } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
@ -21,7 +21,7 @@ export class StorageService {
this.logger.setContext(StorageService.name);
}
@OnEmit({ event: 'app.bootstrap' })
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap() {
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };

View file

@ -6,14 +6,13 @@ import {
CQMode,
ImageFormat,
LogLevel,
SystemMetadataKey,
ToneMapping,
TranscodeHWAccel,
TranscodePolicy,
VideoCodec,
VideoContainer,
} from 'src/enum';
import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { QueueName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@ -381,14 +380,13 @@ describe(SystemConfigService.name, () => {
});
describe('updateConfig', () => {
it('should update the config and emit client and server events', async () => {
it('should update the config and emit an event', async () => {
systemMock.get.mockResolvedValue(partialConfig);
await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig);
expect(eventMock.clientBroadcast).toHaveBeenCalled();
expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null);
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_CONFIG, partialConfig);
expect(eventMock.emit).toHaveBeenCalledWith(
'config.update',
expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }),
);
});
it('should throw an error if a config file is in use', async () => {

View file

@ -1,7 +1,7 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { instanceToPlain } from 'class-transformer';
import _ from 'lodash';
import { SystemConfig, defaults } from 'src/config';
import { defaults } from 'src/config';
import {
supportedDayTokens,
supportedHourTokens,
@ -13,10 +13,10 @@ import {
supportedYearTokens,
} from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit, OnServerEvent } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto';
import { LogLevel } from 'src/enum';
import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { toPlainObject } from 'src/utils/object';
@ -32,13 +32,12 @@ export class SystemConfigService {
) {
this.logger.setContext(SystemConfigService.name);
this.core = SystemConfigCore.create(repository, this.logger);
this.core.config$.subscribe((config) => this.setLogLevel(config));
}
@OnEmit({ event: 'app.bootstrap', priority: -100 })
@OnEvent({ name: 'app.bootstrap', priority: -100 })
async onBootstrap() {
const config = await this.core.getConfig({ withCache: false });
this.core.config$.next(config);
await this.eventRepository.emit('config.update', { newConfig: config });
}
async getConfig(): Promise<SystemConfigDto> {
@ -50,7 +49,18 @@ export class SystemConfigService {
return mapConfig(defaults);
}
@OnEmit({ event: 'config.validate' })
@OnEvent({ name: 'config.update', server: true })
onConfigUpdate({ newConfig: { logging } }: ArgOf<'config.update'>) {
const envLevel = this.getEnvLogLevel();
const configLevel = logging.enabled ? logging.level : false;
const level = envLevel ?? configLevel;
this.logger.setLogLevel(level);
this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`);
// TODO only do this if the event is a socket.io event
this.core.invalidateCache();
}
@OnEvent({ name: 'config.validate' })
onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) {
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.');
@ -73,9 +83,6 @@ export class SystemConfigService {
const newConfig = await this.core.updateConfig(dto);
// 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('config.update', { newConfig, oldConfig });
return mapConfig(newConfig);
@ -101,19 +108,6 @@ export class SystemConfigService {
return theme.customCss;
}
@OnServerEvent(ServerEvent.CONFIG_UPDATE)
async onConfigUpdateEvent() {
await this.core.refreshConfig();
}
private setLogLevel({ logging }: SystemConfig) {
const envLevel = this.getEnvLogLevel();
const configLevel = logging.enabled ? logging.level : false;
const level = envLevel ?? configLevel;
this.logger.setLogLevel(level);
this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`);
}
private getEnvLogLevel() {
return process.env.IMMICH_LOG_LEVEL as LogLevel;
}

View file

@ -1,5 +1,5 @@
import { Inject } from '@nestjs/common';
import { OnEmit } from 'src/decorators';
import { OnEvent } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TrashResponseDto } from 'src/dtos/trash.dto';
@ -54,7 +54,7 @@ export class TrashService {
return { count };
}
@OnEmit({ event: 'assets.delete' })
@OnEvent({ name: 'assets.delete' })
async onAssetsDelete() {
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
}

View file

@ -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 { OnEmit, OnServerEvent } from 'src/decorators';
import { OnEvent } 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, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface';
import { ArgOf, ClientEvent, IEventRepository } 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';
@ -37,7 +37,7 @@ export class VersionService {
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
}
@OnEmit({ event: 'app.bootstrap' })
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(): Promise<void> {
await this.handleVersionCheck();
}
@ -90,8 +90,8 @@ export class VersionService {
return JobStatus.SUCCESS;
}
@OnServerEvent(ServerEvent.WEBSOCKET_CONNECT)
async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) {
@OnEvent({ name: 'websocket.connect' })
async onWebsocketConnection({ userId }: ArgOf<'websocket.connect'>) {
this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion);
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE);
if (metadata) {

View file

@ -1,6 +1,6 @@
import { ModuleRef, Reflector } from '@nestjs/core';
import _ from 'lodash';
import { EmitConfig } from 'src/decorators';
import { EventConfig } from 'src/decorators';
import { MetadataKey } from 'src/enum';
import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface';
import { services } from 'src/services';
@ -9,6 +9,7 @@ type Item<T extends EmitEvent> = {
event: T;
handler: EmitHandler<T>;
priority: number;
server: boolean;
label: string;
};
@ -35,14 +36,15 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => {
continue;
}
const options = reflector.get<EmitConfig>(MetadataKey.ON_EMIT_CONFIG, handler);
if (!options) {
const event = reflector.get<EventConfig>(MetadataKey.EVENT_CONFIG, handler);
if (!event) {
continue;
}
items.push({
event: options.event,
priority: options.priority || 0,
event: event.name,
priority: event.priority || 0,
server: event.server ?? false,
handler: handler.bind(instance),
label: `${Service.name}.${handler.name}`,
});
@ -52,8 +54,8 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => {
const handlers = _.orderBy(items, ['priority'], ['asc']);
// register by priority
for (const { event, handler } of handlers) {
repository.on(event as EmitEvent, handler);
for (const handler of handlers) {
repository.on(handler);
}
return handlers;

View file

@ -3,7 +3,7 @@ import { Mocked, vitest } from 'vitest';
export const newEventRepositoryMock = (): Mocked<IEventRepository> => {
return {
on: vitest.fn(),
on: vitest.fn() as any,
emit: vitest.fn() as any,
clientSend: vitest.fn(),
clientBroadcast: vitest.fn(),