@codervisor/devlog-core
Version:
Core devlog management functionality
323 lines (322 loc) • 14 kB
JavaScript
/**
* DevlogService - Simplified business logic for devlog operations
*
* Replaces ProjectDevlogManager with a cleaner service-based approach
* that uses direct TypeORM repositories instead of complex storage abstractions.
*/
import { DevlogEntryEntity } from '../entities/index.js';
import { getDataSource } from '../utils/typeorm-config.js';
import { DevlogValidator } from '../validation/devlog-schemas.js';
import { generateDevlogKey } from '../utils/key-generator.js';
export class DevlogService {
projectId;
static instances = new Map();
static TTL_MS = 5 * 60 * 1000; // 5 minutes TTL
database;
devlogRepository;
constructor(projectId) {
this.projectId = projectId;
// Database initialization will happen in ensureInitialized()
this.database = null; // Temporary placeholder
this.devlogRepository = null; // Temporary placeholder
}
/**
* Initialize the database connection if not already initialized
*/
async ensureInitialized() {
try {
if (!this.database || !this.database.isInitialized) {
console.log('[DevlogService] Getting initialized DataSource...');
this.database = await getDataSource();
this.devlogRepository = this.database.getRepository(DevlogEntryEntity);
console.log('[DevlogService] DataSource ready with entities:', this.database.entityMetadatas.length);
console.log('[DevlogService] Repository initialized:', !!this.devlogRepository);
}
}
catch (error) {
console.error('[DevlogService] Failed to initialize:', error);
throw error;
}
}
/**
* Get singleton instance for specific projectId with TTL. If TTL expired, create new instance.
*/
static getInstance(projectId) {
const instanceKey = projectId || 0; // Use 0 for undefined projectId
const now = Date.now();
const existingInstance = DevlogService.instances.get(instanceKey);
if (!existingInstance || now - existingInstance.createdAt > DevlogService.TTL_MS) {
const newService = new DevlogService(projectId);
DevlogService.instances.set(instanceKey, {
service: newService,
createdAt: now,
});
return newService;
}
return existingInstance.service;
}
async get(id) {
await this.ensureInitialized();
// Validate devlog ID
const idValidation = DevlogValidator.validateDevlogId(id);
if (!idValidation.success) {
throw new Error(`Invalid devlog ID: ${idValidation.errors.join(', ')}`);
}
const entity = await this.devlogRepository.findOne({ where: { id: idValidation.data } });
if (!entity) {
return null;
}
return entity.toDevlogEntry();
}
async save(entry) {
await this.ensureInitialized();
// Validate devlog entry data
const validation = DevlogValidator.validateDevlogEntry(entry);
if (!validation.success) {
throw new Error(`Invalid devlog entry: ${validation.errors.join(', ')}`);
}
const validatedEntry = validation.data;
// Generate a semantic key if not provided
if (!validatedEntry.key) {
validatedEntry.key = generateDevlogKey(validatedEntry.title, validatedEntry.type, validatedEntry.description);
}
// If this is an update (entry has ID), validate status transition
if (validatedEntry.id) {
const existingEntity = await this.devlogRepository.findOne({
where: { id: validatedEntry.id },
});
if (existingEntity && existingEntity.status !== validatedEntry.status) {
const statusTransition = DevlogValidator.validateStatusTransition(existingEntity.status, validatedEntry.status);
if (!statusTransition.success) {
throw new Error(statusTransition.error);
}
}
}
// Validate unique key within project if key is provided
if (validatedEntry.key && validatedEntry.projectId) {
const keyValidation = await DevlogValidator.validateUniqueKey(validatedEntry.key, validatedEntry.projectId, validatedEntry.id, async (key, projectId, excludeId) => {
const existing = await this.devlogRepository.findOne({
where: { key, projectId },
});
return !!existing && existing.id !== excludeId;
});
if (!keyValidation.success) {
throw new Error(keyValidation.error);
}
}
// Convert to entity and save
const entity = DevlogEntryEntity.fromDevlogEntry(validatedEntry);
await this.devlogRepository.save(entity);
}
async delete(id) {
await this.ensureInitialized();
// Validate devlog ID
const idValidation = DevlogValidator.validateDevlogId(id);
if (!idValidation.success) {
throw new Error(`Invalid devlog ID: ${idValidation.errors.join(', ')}`);
}
const result = await this.devlogRepository.delete({ id: idValidation.data });
if (result.affected === 0) {
throw new Error(`Devlog with ID '${id}' not found`);
}
}
async list(filter) {
await this.ensureInitialized();
// Validate filter if provided
if (filter) {
const filterValidation = DevlogValidator.validateFilter(filter);
if (!filterValidation.success) {
throw new Error(`Invalid filter: ${filterValidation.errors.join(', ')}`);
}
// Use validated filter for consistent behavior
filter = filterValidation.data;
}
const projectFilter = this.addProjectFilter(filter);
// Build TypeORM query based on filter
const queryBuilder = this.devlogRepository.createQueryBuilder('devlog');
return await this.handleList(projectFilter, queryBuilder);
}
async search(query, filter) {
await this.ensureInitialized();
// Validate search query
if (!query || typeof query !== 'string' || query.trim().length === 0) {
throw new Error('Search query is required and must be a non-empty string');
}
// Validate filter if provided
if (filter) {
const filterValidation = DevlogValidator.validateFilter(filter);
if (!filterValidation.success) {
throw new Error(`Invalid filter: ${filterValidation.errors.join(', ')}`);
}
// Use validated filter for consistent behavior
filter = filterValidation.data;
}
const projectFilter = this.addProjectFilter(filter);
const queryBuilder = this.devlogRepository.createQueryBuilder('devlog');
// Apply search query
queryBuilder
.where('devlog.title LIKE :query', { query: `%${query}%` })
.orWhere('devlog.description LIKE :query', { query: `%${query}%` })
.orWhere('devlog.businessContext LIKE :query', { query: `%${query}%` })
.orWhere('devlog.technicalContext LIKE :query', { query: `%${query}%` });
return await this.handleList(projectFilter, queryBuilder);
}
async getStats(filter) {
await this.ensureInitialized();
// Validate filter if provided
if (filter) {
const filterValidation = DevlogValidator.validateFilter(filter);
if (!filterValidation.success) {
throw new Error(`Invalid filter: ${filterValidation.errors.join(', ')}`);
}
// Use validated filter for consistent behavior
filter = filterValidation.data;
}
const projectFilter = this.addProjectFilter(filter);
const queryBuilder = this.devlogRepository.createQueryBuilder('devlog');
// Apply project filter
if (projectFilter.projectId !== undefined) {
queryBuilder.where('devlog.projectId = :projectId', { projectId: projectFilter.projectId });
}
const totalEntries = await queryBuilder.getCount();
// Get counts by status
const statusCounts = await queryBuilder
.select('devlog.status', 'status')
.addSelect('COUNT(*)', 'count')
.groupBy('devlog.status')
.getRawMany();
// Get counts by type
const typeCounts = await queryBuilder
.select('devlog.type', 'type')
.addSelect('COUNT(*)', 'count')
.groupBy('devlog.type')
.getRawMany();
// Get counts by priority
const priorityCounts = await queryBuilder
.select('devlog.priority', 'priority')
.addSelect('COUNT(*)', 'count')
.groupBy('devlog.priority')
.getRawMany();
const byStatus = statusCounts.reduce((acc, { status, count }) => {
acc[status] = parseInt(count);
return acc;
}, {});
const byType = typeCounts.reduce((acc, { type, count }) => {
acc[type] = parseInt(count);
return acc;
}, {});
const byPriority = priorityCounts.reduce((acc, { priority, count }) => {
acc[priority] = parseInt(count);
return acc;
}, {});
// Calculate open vs closed entries
const openStatuses = ['new', 'in-progress', 'blocked', 'in-review', 'testing'];
const closedStatuses = ['done', 'cancelled'];
const openEntries = openStatuses.reduce((sum, status) => sum + (byStatus[status] || 0), 0);
const closedEntries = closedStatuses.reduce((sum, status) => sum + (byStatus[status] || 0), 0);
return {
totalEntries,
openEntries,
closedEntries,
byStatus: byStatus,
byType: byType,
byPriority: byPriority,
};
}
async getTimeSeriesStats(projectId, request) {
await this.ensureInitialized();
// Calculate date range
const days = request?.days || 30;
const to = request?.to ? new Date(request.to) : new Date();
const from = request?.from
? new Date(request.from)
: new Date(Date.now() - days * 24 * 60 * 60 * 1000);
// For now, return a basic implementation
// This would need to be expanded based on specific time series requirements
const queryBuilder = this.devlogRepository.createQueryBuilder('devlog');
queryBuilder.where('devlog.projectId = :projectId', {
projectId: projectId,
});
const entries = await queryBuilder
.select('DATE(devlog.createdAt)', 'date')
.addSelect('COUNT(*)', 'count')
.andWhere('devlog.createdAt >= :from', { from: from.toISOString() })
.andWhere('devlog.createdAt <= :to', { to: to.toISOString() })
.groupBy('DATE(devlog.createdAt)')
.orderBy('DATE(devlog.createdAt)', 'ASC')
.getRawMany();
// Convert to TimeSeriesDataPoint format
const dataPoints = entries.map((entry) => ({
date: entry.date,
totalCreated: parseInt(entry.count), // Simplified - this should be cumulative
totalClosed: 0, // Would need to query closed entries
open: parseInt(entry.count), // Simplified
dailyCreated: parseInt(entry.count),
dailyClosed: 0,
}));
return {
dataPoints,
dateRange: {
from: from.toISOString().split('T')[0], // YYYY-MM-DD format
to: to.toISOString().split('T')[0],
},
};
}
async getNextId() {
await this.ensureInitialized();
const result = await this.devlogRepository
.createQueryBuilder('devlog')
.select('MAX(devlog.id)', 'maxId')
.getRawOne();
return (result?.maxId || 0) + 1;
}
async handleList(projectFilter, queryBuilder) {
// Apply project filter
if (projectFilter.projectId !== undefined) {
queryBuilder.andWhere('devlog.projectId = :projectId', {
projectId: projectFilter.projectId,
});
}
// Apply status filter
if (projectFilter.status) {
queryBuilder.andWhere('devlog.status IN (:...statuses)', { statuses: projectFilter.status });
}
// Apply priority filter
if (projectFilter.priority) {
queryBuilder.andWhere('devlog.priority IN (:...priorities)', {
priorities: projectFilter.priority,
});
}
// Apply pagination
const pagination = projectFilter.pagination || { page: 1, limit: 20 };
const page = pagination.page || 1;
const limit = pagination.limit || 20;
const offset = (page - 1) * limit;
queryBuilder.skip(offset).take(limit);
queryBuilder.orderBy('devlog.updatedAt', 'DESC');
const [entities, total] = await queryBuilder.getManyAndCount();
const entries = entities.map((entity) => entity.toDevlogEntry());
return {
items: entries,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasPreviousPage: page > 1,
hasNextPage: offset + entries.length < total,
},
};
}
/**
* Add project filter to devlog filter if project context is available
*/
addProjectFilter(filter) {
const projectFilter = { ...filter };
// Add project-specific filtering using projectId
if (this.projectId) {
projectFilter.projectId = this.projectId;
}
return projectFilter;
}
}