Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -42,7 +43,17 @@ export class App {
private services: Map<AppServiceName, AppService> = 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);
}
Expand Down Expand Up @@ -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());
}

Expand Down
2 changes: 1 addition & 1 deletion src/services/google/manualSync.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
122 changes: 122 additions & 0 deletions src/services/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { AppServiceName } from '../app';

export type ServiceDependencySpec = {
required: readonly AppServiceName[];
optional: readonly AppServiceName[];
};

export const serviceDependencies: Record<AppServiceName, ServiceDependencySpec> = {
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<AppServiceName>(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<AppServiceName>(enabled);
const indegree = new Map<AppServiceName, number>();
const edges = new Map<AppServiceName, Set<AppServiceName>>();

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<AppServiceName, number>();
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;
}
119 changes: 119 additions & 0 deletions tests/services/registry.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading