| name | vss-add-nest-module |
| description | Scaffolds and wires a new NestJS service/module for the Video Search & Summarization sample app's pipeline-manager using the repo's real conventions. Use when asked to add a new service/module to pipeline-manager, extend the VSS orchestrator, add a new endpoint/queue/event to pipeline-manager, or scaffold a NestJS module the repo way for the video-search-and-summarization sample app. |
Add a VSS pipeline-manager NestJS module
Use this when adding a new pipeline-manager/src/<feature>/ capability. First read nearby modules again (summary, search, video-upload, state-manager) because this repo has concrete patterns that matter more than generic NestJS advice.
Environment setup (run first)
This skill drives the Video Search & Summarization app through its real source
files, so the VSS application must be present and you must run commands from its
app root. Do this before anything else, and it works whether or not the VSS
source is already in your workspace.
Run the bundled bootstrap. It first tries to find an existing VSS checkout -
walking up from the current directory and inspecting the enclosing git repo - and
reuses it without ever re-cloning. Only when no checkout is found does it do a
shallow, single-branch, sparse checkout of just
sample-applications/video-search-and-summarization from main. It prints the
resolved app root on stdout:
SKILL_DIR=".github/skills/vss-add-nest-module"
APP_ROOT="$(bash "$SKILL_DIR/scripts/vss-bootstrap.sh")"
cd "$APP_ROOT"
Every command below assumes the working directory is this APP_ROOT. To pull
from a fork/branch or reuse a specific checkout dir, override VSS_REPO_URL,
VSS_REPO_BRANCH, or VSS_CLONE_DIR before running it.
1. Match the repo layout
Most feature modules use this shape:
src/<feature>/
<feature>.module.ts
controllers/<feature>.controller.ts
services/<feature>.service.ts
services/<feature>-db.service.ts # only when TypeORM-backed
queues/<feature>-queue.service.ts # only when tick/event queued
models/<feature>.model.ts
models/<feature>.entity.ts # only when persisted
Why: controllers stay thin, services hold orchestration, *-db.service.ts isolates TypeORM repository access, queues are event-driven processors, and models/ keeps DTO/interfaces/entities close to the feature.
Real examples:
@Module({
imports: [VideoUploadModule, StateManagerModule],
controllers: [SummaryController],
providers: [SummaryService],
})
export class SummaryModule {}
@Module({
providers: [SearchStateService, SearchDbService, SearchShimService],
controllers: [SearchController],
imports: [HttpModule, TypeOrmModule.forFeature([SearchEntity]), VideoUploadModule],
exports: [],
})
export class SearchModule {}
2. Create model/DTO files first
Use interfaces for internal shapes and Swagger classes for request/response documentation, as in summary/models/summary-pipeline.model.ts and search/model/search.model.ts.
export class SearchQueryDTO {
@ApiProperty({ description: 'Search query string', example: 'person walking' })
query: string;
@ApiPropertyOptional({ description: 'Comma-separated tags to filter by' })
tags?: string;
}
Why: controllers can type request bodies while Swagger gets concrete classes with decorators.
3. Add TypeORM only if the feature persists data
Entity pattern:
@Entity('search')
export class SearchEntity {
@PrimaryGeneratedColumn()
dbId?: number;
@Column({ unique: true })
queryId: string;
@Column('jsonb', { nullable: true })
results: SearchResult[];
}
Repository pattern:
@Injectable()
export class SearchDbService {
constructor(
@InjectRepository(SearchEntity)
private searchRepo: Repository<SearchEntity>,
) {}
async read(queryId: string): Promise<SearchEntity | null> {
const search = await this.searchRepo.findOne({ where: { queryId } });
return search ?? null;
}
}
Wire the entity in two places:
- Feature module:
TypeOrmModule.forFeature([NewEntity]).
- Root
app.module.ts: add NewEntity to TypeOrmModule.forRoot({ entities: [...] }).
Why: forFeature enables repository injection in the feature; the root entity list lets TypeORM create/sync the table.
4. Keep controllers thin and Swagger-tagged
Controllers inject services using the repo's $name property style and expose REST routes with @ApiTags, @ApiOperation, @ApiBody, @ApiParam, and response decorators.
@ApiTags('Search')
@Controller('search')
export class SearchController {
constructor(private $search: SearchStateService) {}
@Post('')
@ApiOperation({ summary: 'Add a new search query' })
@ApiBody({ type: SearchQueryDTO })
async addQuery(@Body() reqBody: SearchQueryDTO) {
return await this.$search.newQuery(reqBody.query);
}
}
Why: validation/errors and orchestration live in services; controllers translate HTTP to typed service calls.
5. Use DI through modules, not ad hoc construction
Import modules that export the services you need. Examples:
SummaryModule imports VideoUploadModule and StateManagerModule to inject VideoService, AppConfigService, StateService, UiService.
VideoUploadModule exports AppConfigService, FeaturesService, VideoService, VideoDbService.
StateManagerModule exports StateService, UiService, AudioQueueService.
If another module must inject your service, add it to exports.
Why: Nest resolves providers only across module boundaries through imports/exports.
6. Add events using the central enums
Define new event names in src/events/Pipeline.events.ts, src/events/app.events.ts, or src/events/socket.events.ts. Use dot-delimited names because root wiring uses:
EventEmitterModule.forRoot({ delimiter: '.', maxListeners: 5 })
Emit and listen like existing services:
this.$emitter.emit(SearchEvents.RUN_QUERY, res.queryId);
@OnEvent(SearchEvents.RUN_QUERY)
async reRunQuery(queryId: string) { ... }
Pipeline examples include pipeline.summary.start, pipeline.chunking.complete, and pipeline.summary.stream. App tick events are emitted by AppService and consumed by queues.
Why: event enums prevent string drift and decouple controllers/services/queues/gateways.
7. Use the repo queue pattern for background work
Queue processors are injectable services under state-manager/queues or a feature queues/ folder. They keep in-memory waiting/processing state, enqueue on a domain event, process on AppEvents.FAST_TICK, emit progress/completion events, and remove stale work on AppEvents.SUMMARY_REMOVED.
@Injectable()
export class SummaryQueueService {
waiting: SummaryQueueItem[] = [];
processing: SummaryQueueItem[] = [];
@OnEvent(PipelineEvents.SUMMARY_TRIGGER)
streamTrigger({ stateId }: PipelineDTOBase) {
this.waiting.push({ stateId, taskType: 'videoSummary' });
}
@OnEvent(AppEvents.FAST_TICK)
processQueue() {
if (this.waiting.length > 0 && this.$inferenceCount.hasLlmSlots()) {
const queueItem = this.waiting.shift()!;
this.processing.push(queueItem);
this.startVideoSummary(queueItem);
}
}
}
Why: long-running work is throttled centrally and does not block HTTP requests.
8. Send UI updates through SocketEvent + EventsGateway
Add socket event enum values to src/events/socket.events.ts, emit them from state/services, and add @OnEvent(...) handlers in sockets/events.gateway.ts.
@OnEvent(SocketEvent.SEARCH_UPDATE)
searchUpdate(payload: SearchQuery) {
this.server.emit('search:update', payload);
}
For state-specific updates, join clients to a state room and emit names like summary:sync/${stateId}/status.
Why: services remain transport-agnostic; only the gateway knows Socket.IO event names and rooms.
9. Read configuration through ConfigService
Add config keys in src/config/configuration.ts, then inject ConfigService:
constructor(private $config: ConfigService) {}
const endPoint: string = this.$config.get('search.endpoint')!;
const maxConcurrent = this.$config.get<number>('openai.vlmCaptioning.concurrent')!;
Why: this app centralizes environment parsing and nested config names (search.endpoint, datastore.bucketName, tick.fastInterval).
10. Register the module in root wiring
In src/app.module.ts:
import { NewFeatureModule } from './new-feature/new-feature.module';
import { NewFeatureEntity } from './new-feature/models/new-feature.entity';
@Module({
imports: [
TypeOrmModule.forRoot({
entities: [StateEntity, VideoEntity, SearchEntity, TagEntity, NewFeatureEntity],
}),
NewFeatureModule,
],
})
export class AppModule {}
Place EventEmitterModule.forRoot(...) once only; do not add another root event emitter.
11. Validate
From pipeline-manager/, run existing checks only:
npm run build
npm test -- --runInBand
npm run lint
If changing only docs or templates, no app build is required. If adding entities, verify migrations/sync behavior is acceptable for this sample (synchronize: true, migrationsRun: true are already set).
See references/nest-conventions.md for a catalog of actual repo conventions and file references. Use assets/module-template/ as a starting scaffold, then rename example to the real feature.