diff --git a/src/app.ts b/src/app.ts index 28edcba..2d71e53 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,7 @@ import { VkBot } from './services/bots/vk'; import { GoogleService } from './services/google'; import { ImageService } from './services/image'; import { ParserService } from './services/parser'; +import { topologicalSort, validateServiceDependencies } from './services/registry'; import { Timetable } from './services/timetable'; import { VKApp } from './services/vk_app'; @@ -42,7 +43,17 @@ export class App { private services: Map = new Map(); private init: boolean = false; - constructor(initialServices: AppServiceName[] = []) { + constructor(initialServices: AppServiceName[] = [], options: { validate?: boolean } = {}) { + if (initialServices.length === 0) return; + const shouldValidate = options.validate ?? true; + if (shouldValidate) { + validateServiceDependencies(initialServices); + const ordered = topologicalSort(initialServices); + for (const service of ordered) { + this.registerService(service); + } + return; + } for (const service of initialServices) { this.registerService(service); } @@ -90,7 +101,7 @@ export class App { } this.logger.log('Подключение к БД: Успешно!'); - for (const [serviceId, service] of this.services) { + for (const [, service] of this.services) { promises.push(service.run()); } diff --git a/src/services/google/manualSync.ts b/src/services/google/manualSync.ts index 7f345db..9afe0a7 100644 --- a/src/services/google/manualSync.ts +++ b/src/services/google/manualSync.ts @@ -1,7 +1,7 @@ import { App } from '../../app'; import { CalendarItem } from './models/calendar'; -const app = new App(['timetable', 'google_calendar', 'parser']); +const app = new App(['timetable', 'google_calendar', 'parser'], { validate: false }); const api = app.getService('google_calendar').api.calendar.api; const { calendarController: controller } = app.getService('google_calendar'); diff --git a/src/services/registry.ts b/src/services/registry.ts new file mode 100644 index 0000000..9f23f91 --- /dev/null +++ b/src/services/registry.ts @@ -0,0 +1,122 @@ +import type { AppServiceName } from '../app'; + +export type ServiceDependencySpec = { + required: readonly AppServiceName[]; + optional: readonly AppServiceName[]; +}; + +export const serviceDependencies: Record = { + http: { required: [], optional: [] }, + parser: { required: [], optional: [] }, + timetable: { required: [], optional: ['parser'] }, + image: { required: [], optional: ['http'] }, + bot: { required: ['parser'], optional: [] }, + tg: { required: ['bot'], optional: ['image', 'timetable'] }, + vk: { required: ['bot'], optional: ['image', 'timetable'] }, + viber: { required: ['bot', 'http'], optional: ['image', 'timetable'] }, + api: { required: ['http'], optional: ['timetable'] }, + alice: { required: ['http'], optional: ['timetable'] }, + vkApp: { required: ['http'], optional: [] }, + google_calendar: { required: ['http', 'bot', 'parser', 'timetable'], optional: [] } +}; + +export class ServiceDependencyError extends Error { + constructor( + message: string, + public readonly missing: Array<{ service: AppServiceName; requires: AppServiceName }> + ) { + super(message); + this.name = 'ServiceDependencyError'; + } +} + +export class ServiceCycleError extends Error { + constructor( + message: string, + public readonly cycle: AppServiceName[] + ) { + super(message); + this.name = 'ServiceCycleError'; + } +} + +export function validateServiceDependencies(enabled: readonly AppServiceName[]): void { + const enabledSet = new Set(enabled); + const missing: Array<{ service: AppServiceName; requires: AppServiceName }> = []; + + for (const service of enabledSet) { + const spec = serviceDependencies[service]; + for (const dep of spec.required) { + if (!enabledSet.has(dep)) { + missing.push({ service, requires: dep }); + } + } + } + + if (missing.length > 0) { + const lines = missing.map(({ service, requires }) => ` - '${service}' requires '${requires}' to be enabled`); + throw new ServiceDependencyError( + `Service dependency check failed:\n${lines.join('\n')}\n` + + `Add the missing services to config.services or remove the dependents.`, + missing + ); + } +} + +export function topologicalSort(enabled: readonly AppServiceName[]): AppServiceName[] { + const enabledSet = new Set(enabled); + const indegree = new Map(); + const edges = new Map>(); + + for (const service of enabledSet) { + indegree.set(service, 0); + edges.set(service, new Set()); + } + + for (const service of enabledSet) { + const spec = serviceDependencies[service]; + const deps = [...spec.required, ...spec.optional]; + for (const dep of deps) { + if (!enabledSet.has(dep)) continue; + const successors = edges.get(dep)!; + if (successors.has(service)) continue; + successors.add(service); + indegree.set(service, (indegree.get(service) ?? 0) + 1); + } + } + + const order: AppServiceName[] = []; + const queue: AppServiceName[] = []; + const inputIndex = new Map(); + enabled.forEach((service, index) => inputIndex.set(service, index)); + + const byInputOrder = (a: AppServiceName, b: AppServiceName) => (inputIndex.get(a) ?? 0) - (inputIndex.get(b) ?? 0); + + for (const service of enabledSet) { + if (indegree.get(service) === 0) queue.push(service); + } + queue.sort(byInputOrder); + + while (queue.length > 0) { + const service = queue.shift()!; + order.push(service); + for (const next of edges.get(service) ?? []) { + const deg = (indegree.get(next) ?? 0) - 1; + indegree.set(next, deg); + if (deg === 0) { + queue.push(next); + queue.sort(byInputOrder); + } + } + } + + if (order.length !== enabledSet.size) { + const remaining = [...enabledSet].filter((s) => !order.includes(s)); + throw new ServiceCycleError( + `Service dependency graph contains a cycle involving: ${remaining.join(', ')}`, + remaining + ); + } + + return order; +} diff --git a/tests/services/registry.test.ts b/tests/services/registry.test.ts new file mode 100644 index 0000000..12ffd04 --- /dev/null +++ b/tests/services/registry.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { + ServiceCycleError, + ServiceDependencyError, + serviceDependencies, + topologicalSort, + validateServiceDependencies +} from '../../src/services/registry'; + +describe('validateServiceDependencies', () => { + it('passes for empty service list', () => { + expect(() => validateServiceDependencies([])).not.toThrow(); + }); + + it('passes when all required deps are present', () => { + expect(() => validateServiceDependencies(['http', 'api', 'timetable', 'parser'])).not.toThrow(); + }); + + it('fails when required dep is missing', () => { + expect(() => validateServiceDependencies(['api'])).toThrow(ServiceDependencyError); + }); + + it('reports every missing dep for clear diagnostics', () => { + try { + validateServiceDependencies(['api', 'alice']); + throw new Error('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ServiceDependencyError); + const e = err as ServiceDependencyError; + expect(e.missing).toEqual([ + { service: 'api', requires: 'http' }, + { service: 'alice', requires: 'http' } + ]); + expect(e.message).toMatch(/requires 'http'/); + } + }); + + it('requires parser for bot and google_calendar transitive deps', () => { + expect(() => validateServiceDependencies(['bot', 'tg'])).toThrow(/requires 'parser'/); + expect(() => validateServiceDependencies(['http', 'bot', 'parser', 'google_calendar'])).toThrow( + /requires 'timetable'/ + ); + }); + + it('ignores optional deps that are missing', () => { + expect(() => validateServiceDependencies(['timetable'])).not.toThrow(); + expect(() => validateServiceDependencies(['api', 'http'])).not.toThrow(); + }); + + it('passes for a full production set with every transitive dep present', () => { + expect(() => + validateServiceDependencies(['http', 'parser', 'timetable', 'image', 'bot', 'tg', 'api', 'google_calendar']) + ).not.toThrow(); + }); +}); + +describe('topologicalSort', () => { + it('returns an empty order for empty input', () => { + expect(topologicalSort([])).toEqual([]); + }); + + it('places dependencies before dependents', () => { + const order = topologicalSort(['api', 'http']); + expect(order.indexOf('http')).toBeLessThan(order.indexOf('api')); + }); + + it('respects optional dependencies in startup order', () => { + const order = topologicalSort(['timetable', 'parser']); + expect(order).toEqual(['parser', 'timetable']); + }); + + it('orders a realistic production set correctly', () => { + const order = topologicalSort(['http', 'parser', 'timetable', 'bot', 'tg', 'image', 'api', 'google_calendar']); + expect(order.indexOf('http')).toBeLessThan(order.indexOf('api')); + expect(order.indexOf('bot')).toBeLessThan(order.indexOf('tg')); + expect(order.indexOf('bot')).toBeLessThan(order.indexOf('google_calendar')); + expect(order.indexOf('http')).toBeLessThan(order.indexOf('google_calendar')); + expect(order.indexOf('parser')).toBeLessThan(order.indexOf('timetable')); + expect(order.indexOf('image')).toBeLessThan(order.indexOf('tg')); + }); + + it('always places required deps before dependents regardless of input order', () => { + const a = topologicalSort(['http', 'api', 'timetable']); + const b = topologicalSort(['api', 'timetable', 'http']); + expect(a.indexOf('http')).toBeLessThan(a.indexOf('api')); + expect(b.indexOf('http')).toBeLessThan(b.indexOf('api')); + }); +}); + +describe('service registry declarations', () => { + it('declares every service exactly once', () => { + const keys = Object.keys(serviceDependencies); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('never declares a service as its own dependency', () => { + for (const [name, spec] of Object.entries(serviceDependencies)) { + expect(spec.required).not.toContain(name); + expect(spec.optional).not.toContain(name); + } + }); + + it('does not declare the same dep as both required and optional', () => { + for (const [, spec] of Object.entries(serviceDependencies)) { + const required = new Set(spec.required); + for (const dep of spec.optional) { + expect(required.has(dep)).toBe(false); + } + } + }); +}); + +describe('ServiceCycleError', () => { + it('is a thrown type', () => { + const err = new ServiceCycleError('cycle', ['api', 'http']); + expect(err).toBeInstanceOf(Error); + expect(err.cycle).toEqual(['api', 'http']); + }); +});